diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 951a208..08f6a81 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 f111341..48ab857 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 08ef6ef..985fb78 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 0f58d31..3a19735 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 d804598..e807295 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 41ff907..61505f9 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 26429be..2fe84af 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 6bd9fbf..c2af353 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 0e42d08..9d7e105 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 5efca8d..11c04a7 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 f56db05..d96125b 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 2ebb0da..5e6b9a7 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 977ce8c..623520d 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 e835e9d..90c9403 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 565f392..e7bd7f7 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 87c2c43..a3cdfef 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 4df361e..5f269d7 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 932f06e..4ed600a 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 eb8a47d..1cdd432 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 6b74c07..27cc57b 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 1e76464..2d7bed5 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 8fb3d62..47a5f23 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 ef5ed4c..e93d16f 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 bf5c94a..412bd3f 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 b839622..f5ba0c7 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 1406023..5b43551 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 fe902ba..df147e7 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 c9aecb2..6e9f9aa 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 6eced5c..43f951a 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 92185b8..0c1b449 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 5734536..d7f8036 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 4487397..d92573c 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 66bc30c..1ed7977 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 30da886..ea6f559 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 23dd9d6..688eac4 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 49e1f0a..6c0f41a 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 6dfee5b..84842ee 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 e9cec94..0b6af86 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 3dee89f..1e2de5e 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 0451fff..c78829a 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 ad7b529..265c6e4 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 575536e..b4090e2 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 1c95dd2..2f5e71d 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 d37cd8d..761a315 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 1fd5d8a..c6ca111 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 50bc46e..0482f81 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 2b4d399..f5d5cd5 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 d26799d..21fa307 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 4dc5a98..c702fc7 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 ec8f0d9..001da56 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 9c34d2b..ecf1c02 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 490d5e8..47c2c9d 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 76d2eda..ab35995 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 568807c..77510bc 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 b54aec5..af3ed03 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 c05b96f..b20aee8 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 10a909e..b149cde 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 43a4ca4..2144f04 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 f78950b..4bc45fa 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 403b6d9..9b139b7 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 4ba980e..99660b1 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, ),