From 07196974664ff3d9af3c3ebe5a5ba17b2f9b1dfb Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 11 Apr 2026 14:48:26 -0400 Subject: [PATCH 01/10] inital working LibreMap --- DEVELOPMENT.md | 2 +- android/app/build.gradle.kts | 2 +- .../plugins/GeneratedPluginRegistrant.java | 5 + ios/Podfile.lock | 111 - ios/Runner.xcodeproj/project.pbxproj | 22 + .../xcshareddata/swiftpm/Package.resolved | 68 + .../xcshareddata/xcschemes/Runner.xcscheme | 18 + .../xcshareddata/swiftpm/Package.resolved | 68 + lib/models/user_preferences.dart | 16 +- lib/providers/app_state_provider.dart | 30 +- lib/screens/settings_screen.dart | 14 + lib/widgets/map_widget.dart | 3152 ++++++++++++----- pubspec.lock | 96 +- pubspec.yaml | 4 +- web/index.html | 4 + 15 files changed, 2574 insertions(+), 1038 deletions(-) create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b7b1ad2..50aab68 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -337,7 +337,7 @@ Key packages used in this project: - `flutter_blue_plus`: Mobile Bluetooth (Android/iOS) - `flutter_web_bluetooth`: Web Bluetooth (Chrome/Edge) - `geolocator`: GPS/Location -- `flutter_map`: Map rendering +- `maplibre_gl`: Map rendering (MapLibre GL vector tiles via OpenFreeMap) - `hive`: Local storage - `provider`: State management - `http`: API requests diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3468904..f0529ba 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -42,7 +42,7 @@ android { applicationId = "net.meshmapper.app" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = 23 // MapLibre GL requires 23+ targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index a11d769..aa77ae3 100644 --- a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -55,6 +55,11 @@ public static void registerWith(@NonNull FlutterEngine flutterEngine) { } catch (Exception e) { Log.e(TAG, "Error registering plugin just_audio, com.ryanheise.just_audio.JustAudioPlugin", e); } + try { + flutterEngine.getPlugins().add(new org.maplibre.maplibregl.MapLibreMapsPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin maplibre_gl, org.maplibre.maplibregl.MapLibreMapsPlugin", e); + } try { flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin()); } catch (Exception e) { diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5a4c713..ebb426d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,144 +1,33 @@ PODS: - - audio_session (0.0.1): - - Flutter - - DKImagePickerController/Core (4.3.9): - - DKImagePickerController/ImageDataManager - - DKImagePickerController/Resource - - DKImagePickerController/ImageDataManager (4.3.9) - - DKImagePickerController/PhotoGallery (4.3.9): - - DKImagePickerController/Core - - DKPhotoGallery - - DKImagePickerController/Resource (4.3.9) - - DKPhotoGallery (0.0.19): - - DKPhotoGallery/Core (= 0.0.19) - - DKPhotoGallery/Model (= 0.0.19) - - DKPhotoGallery/Preview (= 0.0.19) - - DKPhotoGallery/Resource (= 0.0.19) - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Core (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Preview - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Model (0.0.19): - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Preview (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Resource - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Resource (0.0.19): - - SDWebImage - - SwiftyGif - - file_picker (0.0.1): - - DKImagePickerController/PhotoGallery - - Flutter - Flutter (1.0.0) - flutter_background_service_ios (0.0.3): - Flutter - - flutter_blue_plus_darwin (0.0.2): - - Flutter - - FlutterMacOS - flutter_local_notifications (0.0.1): - Flutter - - geolocator_apple (1.2.0): - - Flutter - - FlutterMacOS - - just_audio (0.0.1): - - Flutter - - FlutterMacOS - - package_info_plus (0.4.5): - - Flutter - permission_handler_apple (9.3.0): - Flutter - - SDWebImage (5.21.5): - - SDWebImage/Core (= 5.21.5) - - SDWebImage/Core (5.21.5) - - share_plus (0.0.1): - - Flutter - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - SwiftyGif (5.4.5) - - url_launcher_ios (0.0.1): - - Flutter - - wakelock_plus (0.0.1): - - Flutter DEPENDENCIES: - - audio_session (from `.symlinks/plugins/audio_session/ios`) - - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`) - - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - - just_audio (from `.symlinks/plugins/just_audio/darwin`) - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - - share_plus (from `.symlinks/plugins/share_plus/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - -SPEC REPOS: - trunk: - - DKImagePickerController - - DKPhotoGallery - - SDWebImage - - SwiftyGif EXTERNAL SOURCES: - audio_session: - :path: ".symlinks/plugins/audio_session/ios" - file_picker: - :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter flutter_background_service_ios: :path: ".symlinks/plugins/flutter_background_service_ios/ios" - flutter_blue_plus_darwin: - :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin" flutter_local_notifications: :path: ".symlinks/plugins/flutter_local_notifications/ios" - geolocator_apple: - :path: ".symlinks/plugins/geolocator_apple/darwin" - just_audio: - :path: ".symlinks/plugins/just_audio/darwin" - package_info_plus: - :path: ".symlinks/plugins/package_info_plus/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" - share_plus: - :path: ".symlinks/plugins/share_plus/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" - wakelock_plus: - :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: - audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 - DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c - DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e - flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 - geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e - just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 - share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 60334cf..d045a55 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -65,6 +66,7 @@ C5DCC2A7546C7F71461B567A /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; D478469A7D705340684EFF2B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; F799CC3DB45F3F5C30B5907D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -80,6 +82,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 75D889865C3C829654189002 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -120,6 +123,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -187,6 +191,9 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -213,6 +220,9 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -752,6 +762,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..a28342f --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,68 @@ +{ + "pins" : [ + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "maplibre-gl-native-distribution", + "kind" : "remoteSourceControl", + "location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git", + "state" : { + "revision" : "1051e9dfa3546bece1a6eaf33a5ac85ac35d6bda", + "version" : "6.19.1" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d4..c3fedb2 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + { appState.updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); }, ), + if (prefs.mapTilesEnabled) + ListTile( + leading: const Icon(Icons.opacity), + title: const Text('Coverage Overlay Opacity'), + subtitle: Slider( + value: prefs.coverageOverlayOpacity.clamp(0.3, 1.0), + min: 0.3, + max: 1.0, + divisions: 7, + label: '${(prefs.coverageOverlayOpacity * 100).round()}%', + onChanged: (value) => appState.setCoverageOverlayOpacity(value), + ), + trailing: Text('${(prefs.coverageOverlayOpacity * 100).round()}%'), + ), ListTile( leading: const Icon(Icons.visibility), title: const Text('Color Vision'), diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index e8c21f9..4c0aa41 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1,11 +1,11 @@ +import 'dart:async'; import 'dart:math' as math; +import 'dart:typed_data'; import 'dart:ui' as ui; -import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_cancellable_tile_provider/flutter_map_cancellable_tile_provider.dart'; -import 'package:latlong2/latlong.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:provider/provider.dart'; import '../models/log_entry.dart'; @@ -18,24 +18,196 @@ import '../utils/distance_formatter.dart'; import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; -/// Map style options +/// Satellite style as inline MapLibre style JSON (ArcGIS raster source). +/// The `glyphs` URL is required because our native symbol layers +/// (repeater cluster count, individual repeater hex IDs, distance labels) +/// use `textField`, and MapLibre iOS wedges its resource loader with +/// NSURLError -1002 if it tries to resolve glyphs against a style that +/// doesn't declare a glyphs URL. +const _satelliteStyleJson = '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{"satellite":{"type":"raster","tiles":["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"],"tileSize":256,"maxzoom":17}},"layers":[{"id":"satellite-layer","type":"raster","source":"satellite"}]}'; + +/// Blank style with dark background — used when mapTilesEnabled is false +/// (saves mobile data while still showing markers and overlays). +/// Includes a `glyphs` URL so native annotations using textField (repeater +/// hex IDs, distance labels) can render their text even when tiles are off. +const _blankStyleJson = '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{},"layers":[{"id":"background","type":"background","paint":{"background-color":"#0F172A"}}]}'; + +/// Default font stack used for all native text labels (textField property). +/// Available in OpenFreeMap glyph sets (Liberty, Bright, Dark, Positron). +const _defaultFontStack = ['Noto Sans Regular']; + +/// Image-name constants for the marker bitmaps registered via +/// `controller.addImage()` and referenced by `SymbolOptions.iconImage`. +/// +/// Repeater shapes have one bitmap per (status color × hop_byte shape) — 12 +/// total. Coverage markers have one bitmap per (ping type × success state) for +/// the user's currently-selected style — 8 total per style preference. GPS +/// marker has one bitmap per style — 6 total. +class _MapImages { + _MapImages._(); + + // Repeater shape bitmaps: status × hop_bytes + // Names: rep_active_1, rep_dead_2, rep_dup_3, etc. + static String repeater(String status, int hopBytes) => + 'rep_${status}_$hopBytes'; + + static const repeaterStatuses = ['active', 'dead', 'new', 'dup']; + static const repeaterHopBytes = [1, 2, 3]; + + // Coverage marker bitmaps: type × success state + // Names: cov_tx_ok, cov_disc_fail, etc. + static String coverage(String type, bool success) => + 'cov_${type}_${success ? "ok" : "fail"}'; + + static const coverageTypes = ['tx', 'rx', 'disc', 'trace']; + + // GPS marker bitmaps: one per style + // Names: gps_arrow, gps_car, etc. The list of styles lives in + // _registerMapImages where we map each style key to its CustomPainter. + static String gps(String style) => 'gps_$style'; +} + +/// Renders a [CustomPainter] into a PNG byte buffer using `dart:ui`. +/// +/// This is the bridge between our existing Flutter `CustomPainter` marker +/// rendering code and MapLibre's native annotation system. The bytes returned +/// here can be passed to `controller.addImage(name, bytes)` and then referenced +/// by `SymbolOptions.iconImage: name`. The native engine renders the symbol +/// in the same pass as the map tiles, eliminating the Flutter platform-view +/// sync lag that affects widget overlays. +/// +/// [size] is the logical size in pixels — the output bitmap is upscaled by +/// [devicePixelRatio] for crispness on high-DPI screens. Default 3.0 covers +/// Renders a distance-label pill: white text on a semi-transparent rounded +/// rectangle background. Returns the PNG bytes and the logical size (width/ +/// height in logical pixels, NOT device pixels) so the caller can use it for +/// screen-space collision tests. +/// +/// Sized dynamically to the text — the pill grows with longer labels. Uses +/// devicePixelRatio=3.0 to match the other bitmap markers on this map. +Future<({Uint8List bytes, Size size})> _renderDistanceLabelPng( + String text, { + double devicePixelRatio = 3.0, +}) async { + const fontSize = 11.0; + const horizontalPad = 6.0; + const verticalPad = 3.0; + const cornerRadius = 6.0; + + // Measure the text first so we can size the pill to fit. + final textPainter = TextPainter( + text: TextSpan( + text: text, + style: const TextStyle( + fontSize: fontSize, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + + final logicalWidth = textPainter.width + horizontalPad * 2; + final logicalHeight = textPainter.height + verticalPad * 2; + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.scale(devicePixelRatio); + + // Background pill. + final bgPaint = Paint()..color = Colors.black.withValues(alpha: 0.72); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, logicalWidth, logicalHeight), + const Radius.circular(cornerRadius), + ), + bgPaint, + ); + + // Subtle light border for separation from dark map backgrounds. + final borderPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.25) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(0.5, 0.5, logicalWidth - 1, logicalHeight - 1), + const Radius.circular(cornerRadius), + ), + borderPaint, + ); + + textPainter.paint(canvas, const Offset(horizontalPad, verticalPad)); + + final picture = recorder.endRecording(); + final image = await picture.toImage( + (logicalWidth * devicePixelRatio).round(), + (logicalHeight * devicePixelRatio).round(), + ); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + picture.dispose(); + image.dispose(); + if (byteData == null) { + throw StateError('Failed to encode distance label to PNG bytes'); + } + return ( + bytes: byteData.buffer.asUint8List(), + size: Size(logicalWidth, logicalHeight), + ); +} + +/// most modern phones (typical DPR is 2.0–3.5). +Future _renderPainterToPng( + CustomPainter painter, + Size size, { + double devicePixelRatio = 3.0, +}) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + // Scale the canvas so the painter still draws at logical size, but the + // resulting bitmap has more actual pixels. + canvas.scale(devicePixelRatio); + painter.paint(canvas, size); + final picture = recorder.endRecording(); + final image = await picture.toImage( + (size.width * devicePixelRatio).round(), + (size.height * devicePixelRatio).round(), + ); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + picture.dispose(); + image.dispose(); + if (byteData == null) { + throw StateError('Failed to encode CustomPainter to PNG bytes'); + } + return byteData.buffer.asUint8List(); +} + +/// Map style options. +/// +/// Declaration order matters: it determines the cycle order when the user +/// taps the "switch style" button (see `_cycleMapStyle`). Liberty is first +/// because it's the default for new users. enum MapStyle { + liberty, dark, light, satellite, } extension MapStyleExtension on MapStyle { - /// Convert from stored string preference to MapStyle enum + /// Convert from stored string preference to MapStyle enum. + /// Defaults to Liberty for unknown / unset preferences. static MapStyle fromString(String value) { switch (value) { + case 'dark': + return MapStyle.dark; case 'light': return MapStyle.light; case 'satellite': return MapStyle.satellite; - case 'dark': + case 'liberty': default: - return MapStyle.dark; + return MapStyle.liberty; } } @@ -45,6 +217,8 @@ extension MapStyleExtension on MapStyle { return 'Dark'; case MapStyle.light: return 'Light'; + case MapStyle.liberty: + return 'Liberty'; case MapStyle.satellite: return 'Satellite'; } @@ -56,57 +230,28 @@ extension MapStyleExtension on MapStyle { return Icons.dark_mode; case MapStyle.light: return Icons.light_mode; + case MapStyle.liberty: + return Icons.map; case MapStyle.satellite: return Icons.satellite_alt; } } - String get urlTemplate { + /// MapLibre style URL (or inline JSON for satellite) + String get styleUrl { switch (this) { case MapStyle.dark: - return 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; + return 'https://tiles.openfreemap.org/styles/dark'; case MapStyle.light: - return 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + return 'https://tiles.openfreemap.org/styles/bright'; + case MapStyle.liberty: + return 'https://tiles.openfreemap.org/styles/liberty'; case MapStyle.satellite: - return 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; - } - } - - List? get subdomains { - switch (this) { - case MapStyle.dark: - return ['a', 'b', 'c', 'd']; - case MapStyle.light: - return null; // OSM doesn't use subdomains anymore - case MapStyle.satellite: - return null; // ArcGIS doesn't use subdomains - } - } - - /// Whether this style supports retina tiles via {r} placeholder - bool get supportsRetina { - switch (this) { - case MapStyle.dark: - return true; // Carto supports @2x via {r} - case MapStyle.light: - return false; // OSM has no retina support - case MapStyle.satellite: - return false; // ArcGIS has no retina support + return _satelliteStyleJson; } } } -/// Custom tile provider that silently handles HTTP errors (404, 503, etc.) -/// instead of flooding the console with exceptions -final class SilentCancellableNetworkTileProvider extends CancellableNetworkTileProvider { - SilentCancellableNetworkTileProvider() : super( - dioClient: Dio( - BaseOptions( - validateStatus: (status) => true, // Accept all status codes - ), - ), - ); -} /// Resolved repeater with SNR and ambiguity info for ping focus mode. /// Line color is based on [snr] (green/yellow/red). When a short hex ID @@ -120,7 +265,7 @@ class _ResolvedRepeater { } /// Map widget with TX/RX markers -/// Uses flutter_map with OpenStreetMap tiles +/// Uses MapLibre GL with OpenFreeMap vector tiles class MapWidget extends StatefulWidget { /// Bottom padding in pixels to account for overlays (e.g., control panel in portrait) /// The map will offset its center point upward by half this value @@ -149,8 +294,8 @@ class MapWidget extends StatefulWidget { State createState() => _MapWidgetState(); } -class _MapWidgetState extends State with TickerProviderStateMixin { - final MapController _mapController = MapController(); +class _MapWidgetState extends State { + MapLibreMapController? _mapController; // Auto-follow GPS like a navigation app bool _autoFollow = false; // Disabled by default - users often zoom out first @@ -164,6 +309,23 @@ class _MapWidgetState extends State with TickerProviderStateMixin { bool _alwaysNorth = true; // true = north always up, false = rotate with heading double? _lastHeading; // Track last heading for smooth rotation + // Desired camera zoom while auto-follow is active. Set when the user taps + // "center on position" and updated when the user pinch-zooms. Each auto- + // follow GPS tick uses this as the animation target zoom — otherwise a tick + // that arrives during the initial zoom animation cancels it (animateCamera + // replaces in-flight animations), leaving the camera stuck at an + // intermediate zoom and the marker off-center. + double? _autoFollowDesiredZoom; + + // Bearing derivation state. geolocator's Position.heading is only reliable + // at speed — on both Android (Location.getBearing() requires hasBearing() + // and speed > 0) and iOS (CLLocation.course == -1 when invalid) it's + // effectively 0 or -1 when stationary or walking slowly. We keep our own + // anchor-to-current bearing as a fallback so the arrow/walk marker and + // heading-mode map rotation behave correctly at low speeds. + LatLng? _bearingAnchor; // last fix used as the bearing origin + double? _computedHeading; // last known-good bearing in degrees 0..360 + // MeshMapper overlay toggle (on by default) bool _showMeshMapperOverlay = true; @@ -185,17 +347,79 @@ class _MapWidgetState extends State with TickerProviderStateMixin { bool _wasAutoFollowBeforeFocus = false; bool _wasRotatingBeforeFocus = false; // true if heading mode was active - // Smooth animation for map movement - AnimationController? _animationController; - Animation? _animation; - LatLng? _animationStartPosition; - LatLng? _animationEndPosition; - - // Smooth animation for map rotation - AnimationController? _rotationAnimationController; - Animation? _rotationAnimation; - double? _rotationStartAngle; - double? _rotationEndAngle; + // MapLibre style and overlay tracking + int _lastCacheBust = 0; + // Tracks the zone code we last rendered the coverage overlay for. When the + // zone check succeeds after the style has already loaded (e.g. first check + // failed with gps_inaccurate and a later retry succeeded), _addCoverageOverlay + // would otherwise never re-run and the raster layer would stay missing. + String? _lastOverlayZoneCode; + // Last coverage overlay opacity we pushed into MapLibre. Compared against + // the current preference in _buildMap to detect slider changes and apply + // them live via _applyCoverageOverlayOpacity (no layer rebuild needed). + double? _lastAppliedCoverageOpacity; + bool _styleLoaded = false; + bool _hasStyleLoadedOnce = false; // True after first onStyleLoadedCallback (prevents re-centering on style switch) + + // Tracks the last marker data version we synced to native annotations. + // The build() method computes a version hash from app state and only triggers + // _syncAllAnnotations when the hash changes (avoiding unnecessary diff work). + int _lastMarkerDataVersion = -1; + + // Tile load failure detection — shows a banner if map tiles haven't loaded + // within a timeout after style load. Cleared when onMapIdle fires. + bool _tileLoadFailed = false; + Timer? _tileLoadTimeoutTimer; + static const _tileLoadTimeoutSeconds = 8; + + // Re-entrance guard for _onStyleLoaded. The iOS plugin can fire + // onStyleLoadedCallback multiple times during a single style switch, + // which causes the sync logic to race against itself. This flag bails + // any nested call. + bool _styleLoadInProgress = false; + + // True only after _setupRepeaterClusterLayers has finished creating the + // cluster GeoJSON source AND all 3 layers. Set to false at the start of + // each style load. Used as an additional guard for build()-triggered post- + // frame syncs so they don't race ahead of source creation and try to call + // setGeoJsonSource on a source that doesn't exist yet (which produces the + // "Failed to update repeater source: sourceNotFound" error at startup). + bool _clusterLayersReady = false; + + // Native annotation tracking — populated by sync methods. + // Maps from app-state IDs to MapLibre Symbol/Line objects so we can diff + // (add new, update existing, remove deleted) on each data version change. + // NOTE: repeaters do NOT use the annotation manager — they live in a custom + // cluster-enabled GeoJSON source so MapLibre can group nearby markers into + // count bubbles at low zoom. See _setupRepeaterClusterLayers(). + final Map _coverageSymbols = {}; // key: "{type}_{ts.ms}" + final Map _distanceLabelSymbols = {}; // key: focused repeater id + // Per focused-repeater metadata used by the collision-avoidance reflow: + // the image size (for hit-box overlap tests) and the repeater lat/lon (so + // we can slide the label along the ping→repeater line at a new parameter t). + final Map _distanceLabelImageSize = {}; + final Map _distanceLabelRepeaterPos = {}; + // Tracks distance-label image names we've registered via addImage, so the + // style-reload path can drop stale names from the map's image cache if ever + // needed. Right now we just re-addImage on each sync (idempotent). + final Set _registeredDistanceLabelImages = {}; + Symbol? _gpsSymbol; // single GPS marker + + // Repeater cluster source/layer IDs (custom GeoJSON layer with cluster: true) + static const _repeaterSourceId = 'repeaters-source'; + static const _repeaterIndividualLayerId = 'repeaters-individual'; + static const _repeaterClusterBubbleLayerId = 'repeaters-cluster-bubble'; + static const _repeaterClusterCountLayerId = 'repeaters-cluster-count'; + + // Tracks which marker style preference the coverage images are currently + // registered for. When the user changes their preference, we re-register. + String? _registeredCoverageStyle; + + // True after _registerMapImages() finishes — gates symbol creation. + bool _imagesRegistered = false; + + // Last bearing seen by camera listener (for non-rotating GPS counter-rotation) + double _lastBearing = 0; // Default center (Ottawa) static const LatLng _defaultCenter = LatLng(45.4215, -75.6972); @@ -203,11 +427,29 @@ class _MapWidgetState extends State with TickerProviderStateMixin { @override void dispose() { - _animationController?.dispose(); - _rotationAnimationController?.dispose(); + _tileLoadTimeoutTimer?.cancel(); + _mapController?.removeListener(_onCameraChanged); super.dispose(); } + /// Camera change listener — fires every frame during pan/zoom (because + /// trackCameraPosition: true is set on MapLibreMap). With native annotations, + /// the markers themselves don't need a per-frame rebuild — they're rendered + /// by the native map engine and stay in sync automatically. The only thing + /// we still need to do here is update the GPS marker's iconRotate when the + /// camera bearing changes, because for rotating styles (arrow/walk/pacman) + /// iconRotate = heading - bearing and the bearing animates continuously in + /// heading mode. Throttled by a small bearing delta to avoid spamming + /// updateSymbol. + void _onCameraChanged() { + if (!mounted || _mapController == null) return; + final pos = _mapController!.cameraPosition; + if (pos == null) return; + if ((pos.bearing - _lastBearing).abs() < 0.5) return; + _lastBearing = pos.bearing; + _updateGpsSymbolRotation(); + } + @override void didUpdateWidget(MapWidget oldWidget) { super.didUpdateWidget(oldWidget); @@ -219,231 +461,211 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _lastGpsPosition != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _autoFollow && _lastGpsPosition != null) { + final double targetBearing = (!_alwaysNorth && _computedHeading != null) + ? _computedHeading! + : 0.0; + final double targetZoom = _autoFollowDesiredZoom ?? + _mapController?.cameraPosition?.zoom ?? + _defaultZoom; final adjustedPosition = _offsetPositionForPadding( _lastGpsPosition!, widget.bottomPaddingPixels, widget.rightPaddingPixels, + targetZoom, + targetBearing, + ); + _animateAutoFollowCamera( + target: adjustedPosition, + zoom: targetZoom, + bearing: targetBearing, ); - _animateToPosition(adjustedPosition); } }); } } - /// Smoothly animate the map to a new position - void _animateToPosition(LatLng target) { - if (!_isMapReady || !mounted) return; - - // Get current position - final currentCenter = _mapController.camera.center; - - // Skip if already at target (within small threshold) - final distance = const Distance().as(LengthUnit.Meter, currentCenter, target); - if (distance < 1) return; // Less than 1 meter, don't animate - - // Cancel any running animation - _animationController?.stop(); - _animationController?.dispose(); - - // Create new animation controller - // Duration based on distance - shorter for small movements, longer for big jumps - final duration = Duration(milliseconds: distance < 100 ? 200 : 300); - - _animationController = AnimationController( - duration: duration, - vsync: this, - ); - - _animation = CurvedAnimation( - parent: _animationController!, - curve: Curves.easeOutCubic, // Smooth deceleration - ); - - _animationStartPosition = currentCenter; - _animationEndPosition = target; - - _animation!.addListener(() { - if (!mounted || _animationStartPosition == null || _animationEndPosition == null) return; - - // Interpolate between start and end positions - final t = _animation!.value; - final lat = _animationStartPosition!.latitude + - ((_animationEndPosition!.latitude - _animationStartPosition!.latitude) * t); - final lng = _animationStartPosition!.longitude + - ((_animationEndPosition!.longitude - _animationStartPosition!.longitude) * t); - - _mapController.move(LatLng(lat, lng), _mapController.camera.zoom); - }); - - _animationController!.forward(); - } - /// Smoothly animate the map to a new position with zoom void _animateToPositionWithZoom(LatLng target, double targetZoom) { - if (!_isMapReady || !mounted) return; - - // Get current position and zoom - final currentCenter = _mapController.camera.center; - final currentZoom = _mapController.camera.zoom; - - // Cancel any running animation - _animationController?.stop(); - _animationController?.dispose(); - - // Create new animation controller - const duration = Duration(milliseconds: 500); // Smooth zoom + pan - - _animationController = AnimationController( - duration: duration, - vsync: this, + if (_mapController == null || !_isMapReady || !mounted) return; + _mapController!.animateCamera( + CameraUpdate.newLatLngZoom(target, targetZoom), + duration: const Duration(milliseconds: 500), ); + } - _animation = CurvedAnimation( - parent: _animationController!, - curve: Curves.easeInOutCubic, // Smooth acceleration and deceleration + /// Atomic auto-follow camera update: animates target, zoom, and bearing + /// together in a single animateCamera call. + /// + /// Using separate animateCamera calls for position and rotation races — + /// the second call cancels the first, so each GPS tick in heading mode + /// lost either the pan or the rotation. Bundling everything into one + /// newCameraPosition update avoids the race entirely and also keeps the + /// initial zoom animation from being cancelled by the first auto-follow + /// tick. + void _animateAutoFollowCamera({ + required LatLng target, + required double zoom, + required double bearing, + int durationMs = 300, + }) { + if (_mapController == null || !_isMapReady || !mounted) return; + _mapController!.animateCamera( + CameraUpdate.newCameraPosition(CameraPosition( + target: target, + zoom: zoom, + bearing: bearing, + )), + duration: Duration(milliseconds: durationMs), ); - - _animationStartPosition = currentCenter; - _animationEndPosition = target; - - _animation!.addListener(() { - if (!mounted || _animationStartPosition == null || _animationEndPosition == null) return; - - // Interpolate between start and end positions - final t = _animation!.value; - final lat = _animationStartPosition!.latitude + - ((_animationEndPosition!.latitude - _animationStartPosition!.latitude) * t); - final lng = _animationStartPosition!.longitude + - ((_animationEndPosition!.longitude - _animationStartPosition!.longitude) * t); - - // Interpolate zoom - final zoom = currentZoom + ((targetZoom - currentZoom) * t); - - _mapController.move(LatLng(lat, lng), zoom); - }); - - _animationController!.forward(); } /// Zoom to fit a focused ping and its connected repeaters on screen void _zoomToFocusBounds(LatLng pingLocation, List<_ResolvedRepeater> repeaters) { - if (!_isMapReady || !mounted) return; + if (_mapController == null || !_isMapReady || !mounted) return; final points = [pingLocation, ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon))]; if (points.length < 2) return; - final fitted = CameraFit.coordinates( - coordinates: points, - padding: EdgeInsets.fromLTRB(60, 60, 60, MediaQuery.of(context).size.height * 0.4), - maxZoom: 15, - ).fit(_mapController.camera); + // Build bounding box from all points + double minLat = points[0].latitude, maxLat = points[0].latitude; + double minLon = points[0].longitude, maxLon = points[0].longitude; + for (final p in points) { + if (p.latitude < minLat) minLat = p.latitude; + if (p.latitude > maxLat) maxLat = p.latitude; + if (p.longitude < minLon) minLon = p.longitude; + if (p.longitude > maxLon) maxLon = p.longitude; + } + final bounds = LatLngBounds( + southwest: LatLng(minLat, minLon), + northeast: LatLng(maxLat, maxLon), + ); - _animateToPositionWithZoom(fitted.center, fitted.zoom); + final bottomPad = MediaQuery.of(context).size.height * 0.4; + _mapController!.animateCamera( + CameraUpdate.newLatLngBounds(bounds, left: 60, top: 60, right: 60, bottom: bottomPad), + duration: const Duration(milliseconds: 500), + ); } /// Smoothly animate the map rotation to match heading + /// MapLibre bearing is clockwise from north (same as GPS heading) void _animateToRotation(double targetHeading) { - if (!_isMapReady || !mounted || _alwaysNorth) return; + if (_mapController == null || !_isMapReady || !mounted || _alwaysNorth) return; - // Get current rotation (in degrees) - final currentRotation = _mapController.camera.rotation; - - // Normalize target heading to -180 to 180 range for smooth rotation - // Map heading is counter-clockwise from north, GPS heading is clockwise - // So we need to negate it: -targetHeading - double targetRotation = -targetHeading; - - // Normalize angles to -180 to 180 range - while (targetRotation > 180) { - targetRotation -= 360; - } - while (targetRotation < -180) { - targetRotation += 360; - } + final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; // Calculate shortest rotation path - double delta = targetRotation - currentRotation; - while (delta > 180) { - delta -= 360; - } - while (delta < -180) { - delta += 360; - } + double delta = targetHeading - currentBearing; + while (delta > 180) { delta -= 360; } + while (delta < -180) { delta += 360; } // Skip if rotation change is very small (less than 2 degrees) if (delta.abs() < 2) return; - // Cancel any running rotation animation - _rotationAnimationController?.stop(); - _rotationAnimationController?.dispose(); - - // Create new rotation animation controller - // Faster rotation for small changes, slower for large changes - final duration = Duration(milliseconds: delta.abs() < 45 ? 300 : 500); - - _rotationAnimationController = AnimationController( - duration: duration, - vsync: this, + _mapController!.animateCamera( + CameraUpdate.bearingTo(targetHeading), + duration: Duration(milliseconds: delta.abs() < 45 ? 300 : 500), ); + } - _rotationAnimation = CurvedAnimation( - parent: _rotationAnimationController!, - curve: Curves.easeInOutCubic, // Smooth acceleration and deceleration - ); - - _rotationStartAngle = currentRotation; - _rotationEndAngle = currentRotation + delta; - - _rotationAnimation!.addListener(() { - if (!mounted || _rotationStartAngle == null || _rotationEndAngle == null) return; - - // Interpolate between start and end angles - final t = _rotationAnimation!.value; - final rotation = _rotationStartAngle! + - ((_rotationEndAngle! - _rotationStartAngle!) * t); + /// Produce a reliable heading in degrees (0..360) from successive GPS fixes. + /// + /// Prefers `Position.heading` when the device is moving fast enough for the + /// hardware bearing to be trustworthy; otherwise derives the bearing from + /// the delta between the last anchor fix and the current one. Returns the + /// last known-good value (possibly null) when we don't have enough motion + /// yet. This exists because geolocator reports heading=0 (Android) or + /// -1 (iOS) at rest and during slow/stop-and-go movement, which would + /// otherwise leave the arrow/walk marker stuck pointing north. + double? _computeHeading(Position p) { + final here = LatLng(p.latitude, p.longitude); + + // Fast path: trust the GPS chip when it's actually moving. + // geolocator reports speed in m/s. 1 m/s ≈ 3.6 km/h — slower than that, + // the hardware bearing is either stale or not computed. + final gpsHeading = p.heading; + if (p.speed >= 1.0 && gpsHeading >= 0 && gpsHeading <= 360) { + _bearingAnchor = here; + _computedHeading = gpsHeading; + return _computedHeading; + } - _mapController.rotate(rotation); - }); + // Slow/stationary path: compute our own bearing once we have enough travel. + if (_bearingAnchor == null) { + _bearingAnchor = here; + } else { + final moved = Geolocator.distanceBetween( + _bearingAnchor!.latitude, _bearingAnchor!.longitude, + here.latitude, here.longitude, + ); + if (moved >= 5.0) { + final bearing = Geolocator.bearingBetween( + _bearingAnchor!.latitude, _bearingAnchor!.longitude, + here.latitude, here.longitude, + ); + // bearingBetween returns -180..180; normalize to 0..360. + _computedHeading = (bearing + 360) % 360; + _bearingAnchor = here; + } + } - _rotationAnimationController!.forward(); + return _computedHeading; // may be null until first meaningful motion } - /// Offset a lat/lon position by screen pixels (to account for UI overlays) - /// Shifts the map center to keep the GPS marker centered in the visible map area - /// - bottomPadding: shifts center down (portrait mode with bottom panel) - /// - rightPadding: shifts center left (landscape mode with side panel) - LatLng _offsetPositionForPadding(LatLng position, double bottomPadding, [double rightPadding = 0, double? atZoom]) { - if (!_isMapReady) return position; + /// Offset a lat/lon position by screen pixels (to account for UI overlays). + /// Shifts the camera target so the GPS marker sits in the visible (unpadded) + /// part of the map: + /// - bottomPadding > 0: camera shifts "screen-down" so marker appears toward + /// the top half (portrait with bottom panel open). + /// - rightPadding > 0: camera shifts "screen-right" so marker appears toward + /// the left half (landscape with side panel open on the right). + /// + /// [atZoom] and [atBearing] override the current camera values. Callers that + /// are *about* to animate the camera to a new zoom/bearing must pass the + /// target values — otherwise the offset gets computed at an interpolated + /// mid-animation value and the marker settles off-center. + LatLng _offsetPositionForPadding( + LatLng position, + double bottomPadding, [ + double rightPadding = 0, + double? atZoom, + double? atBearing, + ]) { + if (_mapController == null || !_isMapReady) return position; if (bottomPadding <= 0 && rightPadding <= 0) return position; - // Get meters per pixel at current zoom (or at a specific zoom if provided) + // Get meters per pixel at the target zoom (or current camera zoom). // Approx: 40075km / (256 * 2^zoom) at equator, adjusted by cos(lat) - final zoom = atZoom ?? _mapController.camera.zoom; + final zoom = atZoom ?? _mapController!.cameraPosition?.zoom ?? _defaultZoom; final metersPerPixel = 40075000 / (256 * math.pow(2, zoom)) * math.cos(position.latitude * math.pi / 180); + // Start with the offset expressed as if the map were north-up + // (bearing = 0): bottom padding shifts the target geographic-south, + // right padding shifts the target geographic-west. double latOffset = 0; double lonOffset = 0; - - // Bottom padding: shift center south (map moves up, marker appears centered) if (bottomPadding > 0) { final meterOffset = (bottomPadding / 2) * metersPerPixel; latOffset = -(meterOffset / 111000); // ~111km per degree latitude } - - // Right padding: shift center west (map moves right, marker appears centered) if (rightPadding > 0) { final meterOffset = (rightPadding / 2) * metersPerPixel; - // Longitude degrees per meter varies with latitude lonOffset = -(meterOffset / (111000 * math.cos(position.latitude * math.pi / 180))); } - // When the map is rotated (heading mode), geographic "south" no longer maps - // to "screen down". Rotate the offset vector by the camera rotation so the - // shift always points in the correct screen direction. - final rotationDeg = _mapController.camera.rotation; - if (rotationDeg.abs() > 0.1) { - final rotationRad = -rotationDeg * math.pi / 180; + // When the map is rotated, "screen-down" no longer points geographic + // south — it points wherever bearing + 180° aims. Rotate the offset + // vector so the shift still lands in the correct screen direction. + // + // MapLibre bearing is clockwise from north (heading east => bearing 90, + // screen-down => world-west). To send a south-pointing input vector to + // the world direction that corresponds to screen-down at the given + // bearing, we rotate it clockwise by `bearing` — i.e. by +bearing, not + // -bearing as the previous implementation did. + final bearingDeg = atBearing ?? _mapController!.cameraPosition?.bearing ?? 0; + if (bearingDeg.abs() > 0.1) { + final rotationRad = bearingDeg * math.pi / 180; final cosR = math.cos(rotationRad); final sinR = math.sin(rotationRad); final rotatedLat = latOffset * cosR - lonOffset * sinR; @@ -501,6 +723,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } if (appState.currentPosition != null) { + // Recompute our derived heading for this frame. _computedHeading is + // updated as a side effect; use it below instead of reading + // currentPosition.heading directly (which is unreliable at low speeds). + _computeHeading(appState.currentPosition!); + // One-time initial zoom to GPS when we first get a position // This happens even with auto-follow disabled so user sees their location // Don't apply panel offset - center directly on GPS so pin is in middle of screen @@ -524,28 +751,54 @@ class _MapWidgetState extends State with TickerProviderStateMixin { }); } - // Auto-follow GPS position when enabled - use smooth animation + // Auto-follow GPS position when enabled. When auto-follow is on we + // bundle pan, zoom, and bearing into a single animateCamera call so + // the three don't race each other. _autoFollowDesiredZoom is the + // zoom the camera is animating toward — using it instead of the + // (potentially interpolated) current zoom prevents drift during the + // initial zoom animation after tapping center-on-position. if (_autoFollow && _isMapReady) { final newPosition = center; - // Only animate if position has actually changed if (_lastGpsPosition == null || _lastGpsPosition!.latitude != newPosition.latitude || _lastGpsPosition!.longitude != newPosition.longitude) { _lastGpsPosition = newPosition; - // Use post frame callback to avoid build-during-build issues + final double targetBearing = (!_alwaysNorth && _computedHeading != null) + ? _computedHeading! + : 0.0; + final double targetZoom = _autoFollowDesiredZoom ?? + _mapController?.cameraPosition?.zoom ?? + _defaultZoom; + // Track _lastHeading here too so the separate rotation block + // below (which runs when auto-follow is off) doesn't fire a + // redundant rotation animation on the next frame. + if (!_alwaysNorth && _computedHeading != null) { + _lastHeading = _computedHeading; + } WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _autoFollow) { - // Apply offset for bottom padding when control panel is open - final adjustedPosition = _offsetPositionForPadding(newPosition, widget.bottomPaddingPixels, widget.rightPaddingPixels); - _animateToPosition(adjustedPosition); // Smooth animation instead of jump + final adjustedPosition = _offsetPositionForPadding( + newPosition, + widget.bottomPaddingPixels, + widget.rightPaddingPixels, + targetZoom, + targetBearing, + ); + _animateAutoFollowCamera( + target: adjustedPosition, + zoom: targetZoom, + bearing: targetBearing, + ); } }); } } - // Handle map rotation based on heading (when not in Always North mode) - if (!_alwaysNorth && _isMapReady) { - final heading = appState.currentPosition!.heading; + // Handle map rotation based on heading when NOT auto-following. + // When auto-follow is on, rotation is bundled into the combined + // camera update above so we don't race two animateCamera calls. + if (!_autoFollow && !_alwaysNorth && _isMapReady && _computedHeading != null) { + final heading = _computedHeading!; if (_lastHeading == null) { // First heading after startup — store without rotating so the // initial zoom animation can settle at rotation 0 (where the @@ -555,14 +808,18 @@ class _MapWidgetState extends State with TickerProviderStateMixin { debugLog('[MAP] First heading after startup (${heading.toStringAsFixed(1)}°) — stored without rotating'); } else if ((heading - _lastHeading!).abs() > 2) { _lastHeading = heading; - // Use post frame callback to avoid build-during-build issues WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && !_alwaysNorth) { + if (mounted && !_alwaysNorth && !_autoFollow) { _animateToRotation(heading); } }); } } + } else { + // GPS lock lost — clear bearing state so reacquisition starts fresh + // instead of snapping the marker/map to a stale direction. + _bearingAnchor = null; + _computedHeading = null; } // Handle navigation trigger from log screen or graph @@ -573,20 +830,23 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (target != null) { // Reset map controls to default state _autoFollow = false; // Disable center on GPS + _autoFollowDesiredZoom = null; _alwaysNorth = true; // Set to north-up mode _rotationLocked = false; // Unlock rotation _lastHeading = null; // Reset heading tracking + _bearingAnchor = null; // Reset derived-heading anchor + _computedHeading = null; // Navigate to the coordinates with close zoom (18 = street level view) // Center directly on target without offset - we want the pin in the middle WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { + if (mounted && _mapController != null) { final targetPosition = LatLng(target.lat, target.lon); // Rotate map back to north (0 degrees) first - final currentRotation = _mapController.camera.rotation; - if (currentRotation.abs() > 2) { - _mapController.rotate(0); + final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; + if (currentBearing.abs() > 2) { + _mapController!.animateCamera(CameraUpdate.bearingTo(0)); } // Animate to the exact target position (no offset) @@ -596,6 +856,27 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } } + // Sync native annotations whenever marker data changes (provider triggers + // a rebuild). The version hash detects changes to ping/repeater counts, + // GPS position, focus state, prefs, etc. Native annotations stay in sync + // with the camera automatically — we only need to push data updates. + // + // _clusterLayersReady is the critical guard here: it ensures the cluster + // GeoJSON source actually exists before any sync attempts to push data + // into it. Without this, a Provider data update arriving in the brief + // window between _registerMapImages and _setupRepeaterClusterLayers + // (inside _onStyleLoaded) would race ahead and call setGeoJsonSource on + // a not-yet-created source, throwing "sourceNotFound". + if (_isMapReady && _styleLoaded && _imagesRegistered && _clusterLayersReady) { + final dataVersion = _computeMarkerDataVersion(appState); + if (dataVersion != _lastMarkerDataVersion) { + _lastMarkerDataVersion = dataVersion; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _syncAllAnnotations(appState); + }); + } + } + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; // Get safe area padding for dynamic island/notch in landscape final safePadding = MediaQuery.of(context).padding; @@ -604,8 +885,16 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return Stack( children: [ - // Map - _buildMap(appState, center), + // Map — wait for Hive-loaded preferences before constructing + // MapLibreMap, otherwise the default mapStyle ('liberty') would + // render first and then swap to the user's saved style. + if (appState.preferencesLoaded) + _buildMap(appState, center) + else + const ColoredBox( + color: Color(0xFF1A1A1A), + child: SizedBox.expand(), + ), // GPS Info + Top Repeaters overlay (top-left, respects dynamic island in landscape) Positioned( @@ -623,206 +912,1586 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), ), - // Map controls - top-right in both orientations, collapsible - Positioned( - top: topPadding, - right: 8, - child: _buildCollapsibleMapControls(appState), - ), - ], - ); + // Map controls - top-right in both orientations, collapsible + Positioned( + top: topPadding, + right: 8, + child: _buildCollapsibleMapControls(appState), + ), + + // Tile load failure banner — appears if base tiles haven't finished + // loading within ${_tileLoadTimeoutSeconds}s after style load. + if (_tileLoadFailed) + Positioned( + top: topPadding, + left: 0, + right: 0, + child: Center( + child: _buildTileLoadFailedBanner(), + ), + ), + ], + ); + } + + /// Banner shown when map tiles fail to load within the timeout window. + Widget _buildTileLoadFailedBanner() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 80), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.orange.shade900.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.shade700, width: 1), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_off, color: Colors.white, size: 16), + SizedBox(width: 8), + Flexible( + child: Text( + 'Map tiles unavailable — check connection', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + + /// Collapsible map controls (toggle at top, expands downward) + Widget _buildCollapsibleMapControls(AppStateProvider appState) { + // Use external state if provided, otherwise use internal state + final isExpanded = widget.mapControlsExpanded ?? _mapControlsExpanded; + final onToggle = widget.onMapControlsToggle ?? () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Toggle button (always visible) - at top + GestureDetector( + onTap: onToggle, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: isExpanded + ? const BorderRadius.vertical(top: Radius.circular(8)) + : BorderRadius.circular(8), + ), + child: Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + color: Colors.white, + size: 22, + ), + ), + ), + // Map controls (only when expanded) - below the toggle button + if (isExpanded) + _buildMapControls(appState), + ], + ); + } + + Widget _buildMap(AppStateProvider appState, LatLng center) { + final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + // When mapTilesEnabled is false, use a blank style (just background) to save mobile data + final newStyleUrl = appState.preferences.mapTilesEnabled + ? mapStyle.styleUrl + : _blankStyleJson; + + // Style changes flow through MapLibreMap.styleString — the plugin's + // didUpdateWidget detects the new value and fires a native setStyle. + // onStyleLoadedCallback → _onStyleLoaded re-registers images, rebuilds + // cluster layers, re-adds the coverage overlay, and re-syncs annotations. + + // Detect cache bust change and refresh overlay + if (appState.overlayCacheBust != _lastCacheBust && _isMapReady && _styleLoaded) { + _lastCacheBust = appState.overlayCacheBust; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _refreshCoverageOverlay(appState); + }); + } + + // Detect zoneCode transition (null → value, or zone change) and add/refresh + // the overlay. Needed because _addCoverageOverlay only runs during + // _onStyleLoaded — if the first zone check failed with gps_inaccurate, the + // style loads with zoneCode=null and the overlay is skipped. When a later + // retry sets the zone, nothing would otherwise trigger the raster layer. + if (appState.zoneCode != _lastOverlayZoneCode && _isMapReady && _styleLoaded) { + _lastOverlayZoneCode = appState.zoneCode; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _refreshCoverageOverlay(appState); + }); + } + + // Detect coverage overlay opacity change (user dragged the slider in + // Settings → General) and push it to the live raster layer without + // rebuilding the whole overlay. Skipped while ping focus mode is active — + // focus forces opacity to 0 and _dismissPingFocus restores the preference + // value directly. + final wantedOpacity = appState.preferences.coverageOverlayOpacity; + if (_isMapReady && + _styleLoaded && + _focusedPingLocation == null && + _lastAppliedCoverageOpacity != null && + _lastAppliedCoverageOpacity != wantedOpacity) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _applyCoverageOverlayOpacity(wantedOpacity); + }); + } + + return Stack( + children: [ + // MapLibre GL map (base tiles via style; coverage overlay added programmatically) + MapLibreMap( + styleString: newStyleUrl, + initialCameraPosition: CameraPosition( + target: center, + zoom: _defaultZoom, + ), + minMaxZoomPreference: const MinMaxZoomPreference(3, 17), + rotateGesturesEnabled: !_rotationLocked, + scrollGesturesEnabled: true, + zoomGesturesEnabled: true, + tiltGesturesEnabled: false, // 2D wardriving map + compassEnabled: false, // We have our own controls + // CRITICAL: must be true so the controller's `cameraPosition` getter + // stays synced with the platform side. Without this, the Dart-side + // _cameraPosition is set once at construction and never updated, which + // breaks our sync projection (markers project to stale positions and + // get filtered out by viewport bounds). Also enables camera-move events + // during gestures so _onCameraChanged fires every frame for live + // marker overlay updates. + trackCameraPosition: true, + onMapCreated: _onMapCreated, + onStyleLoadedCallback: () => _onStyleLoaded(appState), + onMapIdle: _onMapIdle, + onCameraIdle: _onCameraIdle, + // NOTE: we do NOT pass onMapClick here. The iOS plugin's + // handleMapTap fires `feature#onTap` when a tap hits any + // interactive layer (including our cluster source layers) and + // does NOT fire `map#onMapClick` in that case. We register a + // listener on `controller.onFeatureTapped` in _onMapCreated + // instead — that fires for taps on custom layer features. + ), + // No widget marker overlay — markers are now native MapLibre + // annotations rendered by the platform view itself. + ], + ); + } + + void _onMapCreated(MapLibreMapController controller) { + _mapController = controller; + // Wire up native annotation tap callbacks. These streams fire when the + // user taps on a symbol/line that the platform-side hit-test matches. + // Since the controller is created exactly once, this listener registration + // happens exactly once too — no need to remove and re-add on style switch. + controller.onSymbolTapped.add(_handleSymbolTap); + // Generic feature tap handler — fires for ANY interactive style layer, + // including our custom repeater cluster + individual layers (which are + // NOT managed by the annotation manager). We dispatch in _handleFeatureTap + // based on the layerId. + controller.onFeatureTapped.add(_handleFeatureTap); + } + + /// Routes a native symbol tap to the appropriate detail sheet. + /// The tap event carries the [Symbol] object, which has the metadata Map we + /// attached when calling addSymbol() in the various sync methods. We use the + /// `kind` and `id` keys to look up the original ping/repeater object from + /// app state and call the existing `_show*Details()` method (which expects + /// the full object, not just an ID). + void _handleSymbolTap(Symbol symbol) { + final data = symbol.data; + if (data == null) return; + final kind = data['kind'] as String?; + final id = data['id']; + final appState = context.read(); + + switch (kind) { + // 'repeater' is no longer handled here — repeaters are in a custom + // cluster GeoJSON layer (not the annotation manager) and dispatch + // through _handleMapClick + queryRenderedFeatures instead. + case 'tx': + final ping = appState.txPings + .where((p) => p.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (ping != null) _showTxPingDetails(ping); + break; + case 'rx': + final ping = appState.rxPings + .where((p) => p.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (ping != null) _showRxPingDetails(ping); + break; + case 'disc': + final entry = appState.discLogEntries + .where((e) => e.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (entry != null) _showDiscPingDetails(entry); + break; + case 'trace': + final entry = appState.traceLogEntries + .where((e) => e.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (entry != null) _showTraceDetails(entry); + break; + // gps, distance-label: not tappable in original — no action + } + } + + /// Handles taps on custom layer features (repeater cluster bubbles and + /// individual repeaters). Wired in [_onMapCreated] via + /// `controller.onFeatureTapped.add(_handleFeatureTap)`. + /// + /// The iOS/Android tap dispatcher calls this for ANY tap that hits an + /// interactive style layer, BEFORE falling back to `onMapClick`. Since our + /// cluster source layers are interactive, taps on repeaters/clusters always + /// route here (not through onMapClick). + /// + /// We dispatch by [layerId]: + /// - cluster bubble layer → zoom in 2 levels around the tap point + /// - individual repeater layer → look up the Repeater by id and open the + /// existing detail sheet + /// + /// [id] is the GeoJSON Feature `id` (which we set to `repeater.id` for + /// individual repeaters; MapLibre auto-generates one for cluster features). + /// [annotation] is always null here since these layers aren't managed by + /// the annotation manager. + void _handleFeatureTap( + math.Point point, + LatLng coordinates, + String id, + String layerId, + Annotation? annotation, + ) { + if (!mounted) return; + + // Cluster tap: just zoom in. We accept hits on EITHER the bubble circle + // layer OR the count-text symbol layer that sits on top of it. The + // platform-side hit-test iterates layers top-down and returns the first + // feature it finds; for cluster taps, the centered count text usually + // gets hit before the underlying bubble, so we have to recognise both + // layer IDs as "user tapped a cluster". Either way the action is the + // same: animate-zoom in 2 levels around the tap point. + // + // The explicit 200ms duration is important for perceived responsiveness. + // Without it, iOS uses setCamera(animated: true) which has a slow ease-in + // start (~150ms before any noticeable motion). Passing a duration switches + // the native code path to fly(to:withDuration:) which ramps in faster and + // finishes in 200ms, making the tap feel "instant" rather than delayed. + if (layerId == _repeaterClusterBubbleLayerId || + layerId == _repeaterClusterCountLayerId) { + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, 17.0); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + return; + } + + // Individual repeater: look up by id (which is repeater.id) and open the + // existing detail sheet. We recompute isDuplicate and hopOverride from + // app state rather than carrying them in feature properties — the values + // are cheap to derive and always reflect the latest data. + if (layerId == _repeaterIndividualLayerId) { + _showRepeaterDetailsById(id); + return; + } + + // GPS marker tap: the GPS marker is a non-interactive symbol on the + // annotation manager layer (which sits ON TOP of all custom layers in + // paint order). Without intervention, taps on the GPS marker hit the + // annotation layer first and stop the iOS dispatcher from checking the + // cluster layers underneath. Detect that case here and re-query the + // cluster layers at the same screen point so the user can still tap + // a cluster/repeater that the GPS marker happens to be sitting on top of. + if (annotation is Symbol) { + final kind = annotation.data?['kind'] as String?; + if (kind == 'gps') { + _fallThroughToRepeaterAt(point, coordinates); + return; + } + } + } + + /// When a tap hits the GPS marker (which has no detail sheet), try to find + /// any repeater cluster or individual repeater under the same point and + /// dispatch THAT instead. We use [queryRenderedFeatures] explicitly scoped + /// to the cluster source's layers, since the iOS native tap dispatcher + /// already short-circuited at the GPS marker layer above. + Future _fallThroughToRepeaterAt( + math.Point point, + LatLng coordinates, + ) async { + if (_mapController == null) return; + try { + final features = await _mapController!.queryRenderedFeatures( + point, + const [ + _repeaterClusterCountLayerId, + _repeaterClusterBubbleLayerId, + _repeaterIndividualLayerId, + ], + null, + ); + if (features.isEmpty || !mounted) return; + + // The Dart-side wrapper jsonDecodes each feature into a Map for us + // (see method_channel_maplibre_gl.dart::queryRenderedFeatures). So we + // can read properties directly without parsing JSON. + final feature = features.first as Map; + final properties = (feature['properties'] as Map?) ?? {}; + + // Cluster (auto-tagged by MapLibre when cluster: true is set on source). + // Same explicit 200ms duration as the direct cluster path in + // _handleFeatureTap so both tap routes feel identical. + if (properties['cluster'] == true) { + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, 17.0); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + return; + } + + // Individual repeater. The feature `id` field is the repeater.id we set + // in _buildRepeaterFeatureCollection (or fall back to the property). + final repeaterId = + (feature['id'] ?? properties['repeaterId'])?.toString(); + if (repeaterId != null) { + _showRepeaterDetailsById(repeaterId); + } + } catch (e) { + debugError('[MAP] queryRenderedFeatures fall-through failed: $e'); + } + } + + /// Open the repeater detail sheet for a given [repeaterId]. Looks up the + /// Repeater object from app state and recomputes the duplicate/hopOverride + /// flags. Used by both direct tap dispatch and the GPS fall-through path. + void _showRepeaterDetailsById(String repeaterId) { + if (!mounted) return; + final appState = context.read(); + final repeater = + appState.repeaters.where((r) => r.id == repeaterId).firstOrNull; + if (repeater == null) return; + + final duplicates = _getDuplicateRepeaterIds(appState.repeaters); + final isDuplicate = duplicates.contains(repeater.id); + final hopOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; + + _showRepeaterDetails( + repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: hopOverride, + ); + } + + Future _onStyleLoaded(AppStateProvider appState) async { + // Re-entrance guard. iOS plugin sometimes fires onStyleLoadedCallback + // multiple times during a single setStyle. The race causes "Layer not + // found" errors during the symbol manager's _rebuildLayers and + // double-registers images. Bail any nested call so the first invocation + // runs to completion uninterrupted. + if (_styleLoadInProgress) { + debugLog('[MAP] _onStyleLoaded re-entered while already running, skipping'); + return; + } + _styleLoadInProgress = true; + try { + _styleLoaded = true; + _isMapReady = true; + + // CRITICAL: clear stale Symbol references from any previous style load. + // Style reloads cause maplibre_gl to construct a brand-new SymbolManager + // with empty internal _idToAnnotation maps. Our _gpsSymbol / + // _coverageSymbols / _distanceLabelSymbols still reference the OLD + // Symbol objects whose IDs are not in the new manager — calling + // updateSymbol on them throws "you can only set existing annotations". + // Clearing them now means the next sync will call addSymbol (which + // creates fresh symbols in the new manager) instead of updateSymbol. + _gpsSymbol = null; + _coverageSymbols.clear(); + _distanceLabelSymbols.clear(); + // Mark cluster layers as not-ready until _setupRepeaterClusterLayers + // creates them on the new style. This gates build()-driven post-frame + // syncs from racing ahead of source creation. + _clusterLayersReady = false; + + // Disable symbol decluttering on the annotation manager. By default, + // MapLibre symbol layers hide overlapping icons/labels at lower zoom to + // reduce visual clutter — but for wardriving we want every coverage + // marker visible regardless of density. (Repeaters are now in their own + // cluster-enabled GeoJSON layer with its own per-layer overlap settings.) + await _configureSymbolDecluttering(); + + // Pre-render and register all marker bitmaps for native annotations. + // Style reloads (e.g., user switches dark→liberty) wipe registered images, + // so we always re-register on every style load. Awaited so the cluster + // layer (which references icon image names) sees them when it's created. + _imagesRegistered = false; + await _registerMapImages(appState); + + // Set up the repeater cluster source + 3 layers. Must run AFTER images + // are registered, since the individual symbol layer's iconImage expression + // looks up names registered by _registerMapImages. + await _setupRepeaterClusterLayers(); + + // Re-add coverage overlay AFTER cluster layers exist so _addCoverageOverlay + // can target the bottom repeater layer as its belowLayerId reference. This + // keeps the insertion point consistent with the zoneCode watcher path — + // both end up with raster at the bottom of the repeater stack, not above it. + await _refreshCoverageOverlay(appState); + _lastOverlayZoneCode = appState.zoneCode; + + // Start tile-load timeout. If onMapIdle doesn't fire within N seconds, + // we assume tiles are failing to load (network down, server error, etc.) + // and surface a banner. Cleared as soon as onMapIdle fires. + _tileLoadTimeoutTimer?.cancel(); + if (appState.preferences.mapTilesEnabled) { + _tileLoadFailed = false; + _tileLoadTimeoutTimer = Timer(const Duration(seconds: _tileLoadTimeoutSeconds), () { + if (mounted && !_tileLoadFailed) { + debugWarn('[MAP] Tile load timeout — tiles did not finish loading within ${_tileLoadTimeoutSeconds}s'); + setState(() => _tileLoadFailed = true); + } + }); + } else { + // Blank style — never show the warning + _tileLoadFailed = false; + } + + // First-load-only setup: center on GPS and register camera listener. + // On subsequent style switches, preserve the user's pan position. + if (!_hasStyleLoadedOnce) { + _hasStyleLoadedOnce = true; + + // Center on GPS if available (initial centering) + if (appState.currentPosition != null) { + final center = LatLng( + appState.currentPosition!.latitude, + appState.currentPosition!.longitude, + ); + _mapController!.animateCamera( + CameraUpdate.newLatLngZoom(center, _defaultZoom), + ); + } + + // Register camera listener ONCE so marker overlay positions update on pan/zoom + _mapController!.addListener(_onCameraChanged); + } + + // Force an initial annotation sync now that images are registered AND the + // cluster source/layers exist. This pushes the current app state into the + // newly-created native annotations on first style load (and again whenever + // the style is reloaded, since style reloads wipe everything). + if (mounted) { + await _syncAllAnnotations(appState); + // Update the data version to match what we just synced. Without this, + // the build()-driven post-frame sync would fire AGAIN with the same + // data because _lastMarkerDataVersion still holds the previous value + // — that double-sync was racing the first sync's symbol refs and + // throwing "you can only set existing annotations" errors twice. + _lastMarkerDataVersion = _computeMarkerDataVersion(appState); + if (mounted) setState(() {}); + } + } finally { + _styleLoadInProgress = false; + } + } + + /// Fires when the map finishes loading visible tiles and the camera is idle. + /// We use this as the "tiles loaded successfully" signal — clears the failure + /// timer and hides any tile-load warning banner. + void _onMapIdle() { + _tileLoadTimeoutTimer?.cancel(); + if (_tileLoadFailed && mounted) { + debugLog('[MAP] Tiles recovered after earlier load failure'); + setState(() => _tileLoadFailed = false); + } + } + + /// Fires when the camera stops moving — after both gestures and + /// programmatic animations. While auto-follow is on, we use this as the + /// point to sync our tracked target zoom with whatever zoom the camera + /// actually settled at (e.g. after the user pinch-zoomed). That keeps the + /// next auto-follow GPS tick from snapping the camera back to a stale + /// target zoom. + void _onCameraIdle() { + if (!_autoFollow || _mapController == null) return; + final currentZoom = _mapController!.cameraPosition?.zoom; + if (currentZoom != null) { + _autoFollowDesiredZoom = currentZoom; + } + } + + /// Add MeshMapper coverage raster overlay as a MapLibre source+layer + Future _addCoverageOverlay(AppStateProvider appState) async { + if (_mapController == null || !_showMeshMapperOverlay) return; + if (!appState.preferences.mapTilesEnabled) return; + if (appState.zoneCode == null || appState.zoneCode!.isEmpty) return; + + final cvdParam = appState.preferences.colorVisionType != 'none' + ? '&cvd=${appState.preferences.colorVisionType}' + : ''; + final url = 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}$cvdParam'; + + try { + await _mapController!.addSource( + 'meshmapper-overlay', + RasterSourceProperties(tiles: [url], tileSize: 256, maxzoom: 17), + ); + // Target the bottom of the repeater cluster stack when it exists, so the + // raster lands beneath ALL marker layers (repeater clusters + symbol + // annotations). During the initial style load, _setupRepeaterClusterLayers + // runs before this — so _clusterLayersReady is true and we use the + // individual repeater layer as the reference. The zoneCode watcher also + // fires after cluster setup, so both paths converge to the same stack. + // Fallback to the symbol annotation layer only if cluster layers haven't + // been created yet (shouldn't happen in practice, but keeps the raster + // underneath markers either way). + final belowLayer = _clusterLayersReady + ? _repeaterIndividualLayerId + : _symbolAnnotationLayerId(); + // While ping focus mode is active, force the newly added raster layer + // to opacity 0 so a cache-bust tile refresh (fires 5s after every API + // upload success — see AppStateProvider._tileRefreshTimer) doesn't + // make the overlay pop back into view in the middle of focus mode. + // Dismissing focus restores the preference value via + // _applyCoverageOverlayOpacity in _dismissPingFocus. + final opacity = _focusedPingLocation != null + ? 0.0 + : appState.preferences.coverageOverlayOpacity; + await _mapController!.addRasterLayer( + 'meshmapper-overlay', + 'meshmapper-overlay-layer', + RasterLayerProperties(rasterOpacity: opacity), + belowLayerId: belowLayer, + ); + _lastAppliedCoverageOpacity = opacity; + debugLog('[MAP] Coverage overlay added (below ${belowLayer ?? "top"}, opacity ${opacity.toStringAsFixed(2)})'); + } catch (e) { + debugLog('[MAP] Failed to add coverage overlay: $e'); + } + } + + /// Apply a new coverage overlay opacity to the live raster layer without + /// removing/re-adding it. No-op if the layer doesn't exist yet. + Future _applyCoverageOverlayOpacity(double opacity) async { + if (_mapController == null) return; + try { + await _mapController!.setLayerProperties( + 'meshmapper-overlay-layer', + RasterLayerProperties(rasterOpacity: opacity), + ); + _lastAppliedCoverageOpacity = opacity; + debugLog('[MAP] Coverage overlay opacity updated to ${opacity.toStringAsFixed(2)}'); + } catch (e) { + // Layer may not exist yet (e.g. before first style load or when the + // overlay is hidden). Safe to ignore — next _addCoverageOverlay call + // will pick up the current preference value. + debugLog('[MAP] Coverage overlay opacity update skipped: $e'); + } + } + + /// Returns the layer ID of the symbol annotation manager's first (and only) + /// layer, or `null` if the manager isn't initialized yet. Used as a + /// `belowLayerId` reference to insert other layers (coverage overlay, focus + /// lines) BENEATH the marker symbols so markers always render on top. + String? _symbolAnnotationLayerId() { + final manager = _mapController?.symbolManager; + if (manager == null) return null; + return '${manager.id}_0'; + } + + /// Disables MapLibre's default symbol-collision behavior for our marker + /// annotations. Without this, repeater markers fade out as you zoom out + /// because the symbol layer hides overlapping icons + labels to reduce + /// visual clutter — undesirable for a wardriving app where every marker + /// matters. Called once per style load, before any symbols are added. + Future _configureSymbolDecluttering() async { + if (_mapController == null) return; + try { + await _mapController!.setSymbolIconAllowOverlap(true); + await _mapController!.setSymbolIconIgnorePlacement(true); + await _mapController!.setSymbolTextAllowOverlap(true); + await _mapController!.setSymbolTextIgnorePlacement(true); + } catch (e) { + debugError('[MAP] Failed to configure symbol decluttering: $e'); + } + } + + /// Remove coverage overlay source and layer + Future _removeCoverageOverlay() async { + if (_mapController == null) return; + try { + await _mapController!.removeLayer('meshmapper-overlay-layer'); + await _mapController!.removeSource('meshmapper-overlay'); + } catch (_) {} + } + + /// Refresh coverage overlay (remove and re-add with new URL) + Future _refreshCoverageOverlay(AppStateProvider appState) async { + await _removeCoverageOverlay(); + await _addCoverageOverlay(appState); + } + + /// Returns the fill color for a repeater status keyword. + /// Mirrors the priority logic in [_getRepeaterMarkerColor]. + Color _repeaterStatusColor(String status) { + switch (status) { + case 'dup': + return PingColors.repeaterDuplicate; + case 'dead': + return PingColors.repeaterDead; + case 'new': + return PingColors.repeaterNew; + case 'active': + default: + return PingColors.repeaterActive; + } + } + + /// Returns the color for a coverage marker (TX/RX/DISC/Trace × success/fail). + Color _coverageStatusColor(String type, bool success) { + switch (type) { + case 'tx': + return success ? PingColors.txSuccess : PingColors.txFail; + case 'rx': + return PingColors.rx; + case 'disc': + return success ? PingColors.discSuccess : PingColors.discFail; + case 'trace': + return success ? Colors.cyan : Colors.grey; + default: + return Colors.grey; + } + } + + /// Returns the borderRadius value for a repeater shape based on hop_bytes. + /// Mirrors the values in the original `_buildRepeaterMarkers` (lines ~2390). + double _repeaterBorderRadius(int hopBytes) { + if (hopBytes >= 3) return 8; + if (hopBytes == 2) return 6; + return 4; + } + + /// Pre-renders and registers all marker bitmaps that the native MapLibre + /// symbols reference via `iconImage`. Called from [_onStyleLoaded] after the + /// style is ready (so addImage can succeed). Idempotent — safe to call again + /// if a style reload happens; addImage replaces existing entries by name. + /// + /// Generates: + /// - 12 repeater shape bitmaps (4 status colors × 3 hop_byte radii) — fixed + /// width 48px, the widest case (6-char hex IDs); shorter text is centered + /// by MapLibre's textField rendering. + /// - 8 coverage marker bitmaps for the user's currently-selected style. + /// - 6 GPS marker bitmaps (one per style). + /// + /// Marker style preference changes are handled separately by + /// [_reregisterCoverageImages] which only re-runs the coverage section. + Future _registerMapImages(AppStateProvider appState) async { + if (_mapController == null) return; + + try { + // 1. Repeater shapes — 12 variants + const repeaterSize = Size(48, 28); + for (final status in _MapImages.repeaterStatuses) { + final color = _repeaterStatusColor(status); + for (final hopBytes in _MapImages.repeaterHopBytes) { + final painter = _RepeaterShapePainter( + fillColor: color, + borderRadius: _repeaterBorderRadius(hopBytes), + ); + final bytes = await _renderPainterToPng(painter, repeaterSize); + await _mapController!.addImage( + _MapImages.repeater(status, hopBytes), + bytes, + ); + } + } + + // 2. Coverage markers — 8 variants for current style + await _registerCoverageImages(appState.preferences.markerStyle); + + // 3. GPS marker variants — 6 styles + const gpsSize = Size(48, 48); + final gpsPainters = { + 'arrow': const _ArrowPainter(), + 'car': const _CarMarkerPainter(), + 'bike': const _BikeMarkerPainter(), + 'boat': const _BoatMarkerPainter(), + 'walk': const _WalkMarkerPainter(), + 'pacman': const _PacmanMarkerPainter(), + }; + for (final entry in gpsPainters.entries) { + final bytes = await _renderPainterToPng(entry.value, gpsSize); + await _mapController!.addImage(_MapImages.gps(entry.key), bytes); + } + + _imagesRegistered = true; + debugLog('[MAP] Registered ${_MapImages.repeaterStatuses.length * _MapImages.repeaterHopBytes.length} repeater + 8 coverage + ${gpsPainters.length} GPS marker images'); + // NOTE: do NOT trigger _syncAllAnnotations here. The repeater cluster + // source/layers haven't been created yet — _onStyleLoaded calls + // _setupRepeaterClusterLayers AFTER us, then triggers the initial sync + // once everything is in place. + } catch (e) { + debugError('[MAP] Failed to register marker images: $e'); + } + } + + /// Generates and registers the 8 coverage marker bitmaps for [styleName]. + /// Called from [_registerMapImages] on initial setup, and from the + /// preference-change handler when the user picks a different marker shape. + Future _registerCoverageImages(String styleName) async { + if (_mapController == null) return; + // 40×40 canvas with the 24×24 glyph centered inside it — the transparent + // padding enlarges the native symbol hit target (~40×40 px) without + // changing the visual marker size. Fixes finicky taps on small markers. + const coverageSize = Size(40, 40); + for (final type in _MapImages.coverageTypes) { + for (final success in [true, false]) { + final painter = _CoverageMarkerPainter( + style: styleName, + color: _coverageStatusColor(type, success), + ); + final bytes = await _renderPainterToPng(painter, coverageSize); + await _mapController!.addImage( + _MapImages.coverage(type, success), + bytes, + ); + } + } + _registeredCoverageStyle = styleName; + } + + /// Returns the status keyword used as the iconImage suffix for a repeater. + /// Mirrors the priority logic in [_getRepeaterMarkerColor]: duplicate > dead + /// > new > active. + String _repeaterStatusKey(Repeater repeater, bool isDuplicate) { + if (isDuplicate) return 'dup'; + if (repeater.isDead) return 'dead'; + if (repeater.isNew) return 'new'; + return 'active'; + } + + /// Converts a Flutter [Color] to a `#RRGGBB` (or `#RRGGBBAA`) hex string + /// for MapLibre symbol/line properties (which take CSS-style color strings). + String _colorToHex(Color color, {bool includeAlpha = false}) { + final argb = color.toARGB32() & 0xFFFFFFFF; + final rr = ((argb >> 16) & 0xFF).toRadixString(16).padLeft(2, '0'); + final gg = ((argb >> 8) & 0xFF).toRadixString(16).padLeft(2, '0'); + final bb = (argb & 0xFF).toRadixString(16).padLeft(2, '0'); + if (includeAlpha) { + final aa = ((argb >> 24) & 0xFF).toRadixString(16).padLeft(2, '0'); + return '#$rr$gg$bb$aa'; + } + return '#$rr$gg$bb'; + } + + /// Builds a GeoJSON FeatureCollection of all repeaters in app state, with + /// per-feature properties used by the data-driven symbol layer expressions + /// (iconImage, color, opacity, hex). Re-pushed to the cluster source whenever + /// the marker data version changes — MapLibre handles re-clustering natively. + Map _buildRepeaterFeatureCollection(AppStateProvider appState) { + final duplicates = _getDuplicateRepeaterIds(appState.repeaters); + final hopOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; + final focusActive = _focusedPingLocation != null; + + final features = >[]; + for (final repeater in appState.repeaters) { + final isDuplicate = duplicates.contains(repeater.id); + final statusKey = _repeaterStatusKey(repeater, isDuplicate); + final isConnected = focusActive && + _focusedRepeaters.any((r) => r.repeater.id == repeater.id); + // In focus mode, hide repeaters not involved in the focused ping entirely + // (skip the feature) rather than dimming — cleaner focus view and prevents + // them from contributing to clusters. + if (focusActive && !isConnected) continue; + final effectiveBytes = hopOverride ?? repeater.hopBytes; + // Clamp to the 1/2/3 hop_byte image variants we registered + final shapeBytes = effectiveBytes >= 3 + ? 3 + : effectiveBytes == 2 + ? 2 + : 1; + final iconImage = _MapImages.repeater(statusKey, shapeBytes); + final hex = repeater.displayHexId(overrideHopBytes: hopOverride); + final colorHex = _colorToHex(_repeaterStatusColor(statusKey)); + + features.add({ + 'type': 'Feature', + 'id': repeater.id, + 'properties': { + 'repeaterId': repeater.id, + 'iconImage': iconImage, + 'color': colorHex, + 'hex': hex, + 'isDuplicate': isDuplicate, + if (hopOverride != null) 'hopOverride': hopOverride, + }, + 'geometry': { + 'type': 'Point', + // GeoJSON convention: [longitude, latitude] + 'coordinates': [repeater.lon, repeater.lat], + }, + }); + } + + return {'type': 'FeatureCollection', 'features': features}; + } + + /// Creates the cluster-enabled GeoJSON source and three rendering layers + /// (individual symbols, cluster bubble circles, cluster count text). Called + /// once per style load AFTER images are registered (the individual symbol + /// layer references the registered icon names via a data-driven expression). + Future _setupRepeaterClusterLayers() async { + if (_mapController == null) return; + + // Idempotent: tear down any existing source/layers from a previous style load + for (final layerId in [ + _repeaterClusterCountLayerId, + _repeaterClusterBubbleLayerId, + _repeaterIndividualLayerId, + ]) { + try { + await _mapController!.removeLayer(layerId); + } catch (_) {} + } + try { + await _mapController!.removeSource(_repeaterSourceId); + } catch (_) {} + + // Empty source with cluster enabled. We'll push real data via setGeoJsonSource + // from _syncRepeaterSymbols whenever the marker data version changes. + // + // IMPORTANT: pass `data` as a Dart Map (NOT jsonEncode-d string). The iOS + // plugin's `buildShapeSource` assumes that if `data` is a String, it must be + // a URL — and crashes via JSONSerialization.data() if a non-URL string is + // passed and the URL parse fails. Maps are handled correctly. + try { + await _mapController!.addSource( + _repeaterSourceId, + const GeojsonSourceProperties( + data: {'type': 'FeatureCollection', 'features': []}, + cluster: true, + clusterRadius: 50, + clusterMaxZoom: 14, + ), + ); + + // Place all three layers BELOW the symbol annotation manager so coverage + // markers / GPS / distance labels still render on top of repeater clusters. + final belowLayer = _symbolAnnotationLayerId(); + + // Layer 1: individual repeater markers (when not part of a cluster). + // Data-driven properties read from each feature's `properties` map. + await _mapController!.addSymbolLayer( + _repeaterSourceId, + _repeaterIndividualLayerId, + const SymbolLayerProperties( + iconImage: ['get', 'iconImage'], + iconColor: ['get', 'color'], + iconSize: 1.4, + iconAllowOverlap: true, + iconIgnorePlacement: true, + textField: ['get', 'hex'], + textColor: '#FFFFFF', + textHaloColor: '#000000', + textHaloWidth: 1.5, + textSize: 13, + textAllowOverlap: true, + textIgnorePlacement: true, + textFont: _defaultFontStack, + ), + filter: ['!', ['has', 'point_count']], + belowLayerId: belowLayer, + ); + + // Layer 2: cluster bubble (circle, sized by point_count). + // The 'step' expression makes the bubble grow as more repeaters merge: + // - default radius 18px (clusters of 2-9) + // - 22px for clusters of 10+ + // - 26px for clusters of 50+ + await _mapController!.addCircleLayer( + _repeaterSourceId, + _repeaterClusterBubbleLayerId, + CircleLayerProperties( + circleColor: _colorToHex(PingColors.repeaterActive), + circleRadius: const [ + 'step', + ['get', 'point_count'], + 18, + 10, 22, + 50, 26, + ], + circleStrokeColor: '#FFFFFF', + circleStrokeWidth: 2, + circleOpacity: 0.9, + ), + filter: ['has', 'point_count'], + belowLayerId: belowLayer, + ); + + // Layer 3: cluster count text (uses MapLibre's built-in + // 'point_count_abbreviated' property — automatically formatted as + // "1.2k" for large counts). + await _mapController!.addSymbolLayer( + _repeaterSourceId, + _repeaterClusterCountLayerId, + const SymbolLayerProperties( + textField: ['get', 'point_count_abbreviated'], + textColor: '#FFFFFF', + textSize: 14, + textHaloColor: '#000000', + textHaloWidth: 1, + textAllowOverlap: true, + textIgnorePlacement: true, + textFont: _defaultFontStack, + ), + filter: ['has', 'point_count'], + belowLayerId: belowLayer, + ); + + // All 3 layers + source created successfully — mark ready so the + // build()-triggered post-frame sync can run, and so _syncRepeaterSymbols + // is allowed to push data via setGeoJsonSource. + _clusterLayersReady = true; + } catch (e) { + debugError('[MAP] Failed to set up repeater cluster layers: $e'); + } + } + + /// Pushes the current repeater state into the cluster source. MapLibre + /// re-clusters natively whenever the source data changes. Replaces the + /// previous per-symbol addSymbol/updateSymbol/removeSymbol diff loop. + Future _syncRepeaterSymbols(AppStateProvider appState) async { + if (_mapController == null || + !_styleLoaded || + !_imagesRegistered || + !_clusterLayersReady) { + return; + } + try { + final geojson = _buildRepeaterFeatureCollection(appState); + await _mapController!.setGeoJsonSource(_repeaterSourceId, geojson); + } catch (e) { + debugError('[MAP] Failed to update repeater source: $e'); + } + } + + /// Composite key for a coverage marker symbol — kind + timestamp ms. + /// Used as the map key in [_coverageSymbols] and to detect updates/removals. + String _coverageKey(String type, DateTime ts) => + '${type}_${ts.millisecondsSinceEpoch}'; + + /// Diff-syncs native coverage symbols (TX/RX/DISC/Trace) against app state. + /// One symbol per ping, image varies by type/success state, opacity reflects + /// focus mode (faded if focus active and this isn't the focused ping). + /// + /// Marker style preference changes are NOT handled here — when the user + /// switches between circle/pin/diamond/dot, the caller must first call + /// [_handleMarkerStyleChange] to re-register the bitmap variants. + Future _syncCoverageSymbols(AppStateProvider appState) async { + if (_mapController == null || !_styleLoaded || !_imagesRegistered) return; + + // Re-register coverage images if the user changed their style preference + final currentStyle = appState.preferences.markerStyle; + if (_registeredCoverageStyle != currentStyle) { + await _registerCoverageImages(currentStyle); + // After re-registering, all existing coverage symbols still reference + // the same image names — but the underlying bitmaps have changed shape. + // The native side picks up the new bitmaps automatically. No need to + // update each symbol. + } + + final wantedKeys = {}; + final focusActive = _focusedPingLocation != null; + + Future syncOne({ + required String type, + required double lat, + required double lon, + required DateTime ts, + required bool success, + required int idForMetadata, + }) async { + final key = _coverageKey(type, ts); + final isFocused = _isFocusedPing(lat, lon, ts); + // In focus mode, hide every coverage marker except the focused ping. + // Skipping wantedKeys lets the cleanup loop remove them entirely so the + // map is uncluttered. Dismissing focus re-syncs and restores them. + if (focusActive && !isFocused) return; + wantedKeys.add(key); + + final options = SymbolOptions( + geometry: LatLng(lat, lon), + iconImage: _MapImages.coverage(type, success), + iconSize: isFocused ? 1.2 : 1.0, + ); + + final existing = _coverageSymbols[key]; + if (existing == null) { + try { + final symbol = await _mapController!.addSymbol( + options, + {'kind': type, 'id': idForMetadata}, + ); + _coverageSymbols[key] = symbol; + } catch (e) { + debugError('[MAP] addSymbol($type) failed at $ts: $e'); + } + } else { + try { + await _mapController!.updateSymbol(existing, options); + } catch (e) { + debugError('[MAP] updateSymbol($type) failed at $ts: $e'); + } + } + } + + // TX pings + for (final ping in appState.txPings) { + await syncOne( + type: 'tx', + lat: ping.latitude, + lon: ping.longitude, + ts: ping.timestamp, + success: ping.heardRepeaters.isNotEmpty, + idForMetadata: ping.timestamp.millisecondsSinceEpoch, + ); + } + + // RX pings + for (final ping in appState.rxPings) { + await syncOne( + type: 'rx', + lat: ping.latitude, + lon: ping.longitude, + ts: ping.timestamp, + success: true, // RX has no fail state — always uses the rx color + idForMetadata: ping.timestamp.millisecondsSinceEpoch, + ); + } + + // DISC entries (success = received node responses; drop = treat as TX fail) + for (final entry in appState.discLogEntries) { + final received = entry.nodeCount > 0; + // When discDropEnabled, "no response" should look like a TX fail color. + // We model that by using the 'tx' image variant for failed DISCs: + final type = (!received && appState.discDropEnabled) ? 'tx' : 'disc'; + await syncOne( + type: type, + lat: entry.latitude, + lon: entry.longitude, + ts: entry.timestamp, + success: received, + idForMetadata: entry.timestamp.millisecondsSinceEpoch, + ); + } + + // Trace entries + for (final entry in appState.traceLogEntries) { + await syncOne( + type: 'trace', + lat: entry.latitude, + lon: entry.longitude, + ts: entry.timestamp, + success: entry.success, + idForMetadata: entry.timestamp.millisecondsSinceEpoch, + ); + } + + // Remove symbols for pings that no longer exist (e.g., user cleared markers) + final toRemove = _coverageSymbols.keys.where((k) => !wantedKeys.contains(k)).toList(); + for (final key in toRemove) { + final sym = _coverageSymbols.remove(key); + if (sym != null) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + } + } + + /// Returns true if the given GPS marker style should rotate to face the + /// user's heading (vs staying screen-aligned). Arrow/walk/pacman face the + /// heading; car/bike/boat icons stay upright on a rotated map. + bool _gpsStyleFacesHeading(String style) => + style == 'arrow' || style == 'walk' || style == 'pacman'; + + /// Computes the iconRotate value for the GPS marker. + /// + /// MapLibre annotation symbols use the default `icon-rotation-alignment: auto` + /// which resolves to `viewport` for point symbols — meaning iconRotate is + /// applied in screen space, not map space. That has two consequences: + /// + /// - Rotating styles (arrow/walk/pacman) must point in the direction of + /// travel both in always-north mode (where bearing = 0, so iconRotate + /// = heading) AND in heading mode (where the map is rotated so that + /// direction-of-travel is screen-up — so iconRotate should be 0). + /// The single formula that works for both is `heading - bearing`. + /// + /// - Non-rotating styles (car/bike/boat) should always be drawn upright + /// on screen. With viewport alignment that's iconRotate = 0 regardless + /// of bearing; the icon is already screen-aligned by default. + double _gpsIconRotate(String style, double heading) { + final bearing = _mapController?.cameraPosition?.bearing ?? 0; + if (_gpsStyleFacesHeading(style)) { + final rotated = heading - bearing; + // Normalize to 0..360 so MapLibre doesn't take the "long way around" + // when iconRotate crosses the ±180° seam during interpolation. + return (rotated % 360 + 360) % 360; + } + return 0; + } + + /// Adds, updates, or removes the single GPS position symbol to match + /// [appState.currentPosition]. Called from the post-frame sync trigger. + Future _syncGpsSymbol(AppStateProvider appState) async { + if (_mapController == null || !_styleLoaded || !_imagesRegistered) return; + + final pos = appState.currentPosition; + if (pos == null) { + // No GPS lock — remove existing GPS symbol if present + if (_gpsSymbol != null) { + try { + await _mapController!.removeSymbol(_gpsSymbol!); + } catch (_) {} + _gpsSymbol = null; + } + return; + } + + final style = appState.preferences.gpsMarkerStyle; + // Use the derived heading (updated by _computeHeading in build()) so the + // arrow/walk/pacman markers actually point in the direction of travel + // even when pos.heading is stale or unset. + final iconRotate = _gpsIconRotate(style, _computedHeading ?? 0); + + final options = SymbolOptions( + geometry: LatLng(pos.latitude, pos.longitude), + iconImage: _MapImages.gps(style), + iconRotate: iconRotate, + ); + + if (_gpsSymbol == null) { + try { + _gpsSymbol = await _mapController!.addSymbol(options, {'kind': 'gps'}); + } catch (e) { + debugError('[MAP] addSymbol(gps) failed: $e'); + } + } else { + try { + await _mapController!.updateSymbol(_gpsSymbol!, options); + } catch (e) { + debugError('[MAP] updateSymbol(gps) failed: $e'); + } + } + } + + /// Updates only the GPS symbol's iconRotate. Called from the camera-change + /// listener when the bearing changes — under viewport alignment, rotating + /// styles (arrow/walk/pacman) are the ones whose iconRotate depends on the + /// bearing (iconRotate = heading - bearing), so they need refreshing as the + /// bearing animates. Non-rotating styles use iconRotate = 0 and don't care. + /// Cheaper than calling [_syncGpsSymbol] which also updates position. + Future _updateGpsSymbolRotation() async { + if (_gpsSymbol == null || _mapController == null) return; + final appState = context.read(); + final pos = appState.currentPosition; + if (pos == null) return; + final style = appState.preferences.gpsMarkerStyle; + if (!_gpsStyleFacesHeading(style)) return; + try { + await _mapController!.updateSymbol( + _gpsSymbol!, + SymbolOptions(iconRotate: _gpsIconRotate(style, _computedHeading ?? 0)), + ); + } catch (_) {} + } + + // Source/layer ID constants for the focus-mode dotted lines + static const _focusLinesSourceId = 'focus-lines-source'; + static const _focusLinesLayerId = 'focus-lines-layer'; + static const _focusLinesAmbiguousLayerId = 'focus-lines-ambiguous-border'; + + /// Builds and applies the focus-mode dotted polylines that visually connect + /// a focused ping to each repeater that heard it. Color-coded by SNR; + /// ambiguous matches get a wider white outline drawn underneath. + /// + /// Implementation uses a GeoJSON source + line layer (rather than the + /// annotation-level addLine API) because LineOptions does not expose + /// `lineDasharray`, but LineLayerProperties does. + /// + /// Idempotent: removes any existing source/layers first, then re-adds with + /// the latest focus state. + Future _updateFocusLines() async { + if (_mapController == null || !_styleLoaded) return; + + // Always remove existing layers/source first (silently ignore if absent). + // Order matters: remove the layers BEFORE the source they reference. + try { + await _mapController!.removeLayer(_focusLinesLayerId); + } catch (_) {} + try { + await _mapController!.removeLayer(_focusLinesAmbiguousLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_focusLinesSourceId); + } catch (_) {} + + if (_focusedPingLocation == null || _focusedRepeaters.isEmpty) return; + + // Build a FeatureCollection with one LineString per connected repeater. + // Per-feature properties carry the line color (data-driven styling) and + // ambiguous flag (used as a layer filter for the border line). + final features = >[]; + for (final r in _focusedRepeaters) { + final color = r.snr != null ? PingColors.snrColor(r.snr!) : Colors.grey; + features.add({ + 'type': 'Feature', + 'properties': { + 'color': _colorToHex(color), + 'ambiguous': r.ambiguous, + }, + 'geometry': { + 'type': 'LineString', + 'coordinates': [ + [_focusedPingLocation!.longitude, _focusedPingLocation!.latitude], + [r.repeater.lon, r.repeater.lat], + ], + }, + }); + } + + // Pass the FeatureCollection as a Dart Map (NOT a jsonEncode-d string). + // The iOS plugin's buildShapeSource crashes if `data` is a string that's + // not a URL — see fix in _setupRepeaterClusterLayers for the same gotcha. + final geojson = { + 'type': 'FeatureCollection', + 'features': features, + }; + + try { + await _mapController!.addSource( + _focusLinesSourceId, + GeojsonSourceProperties(data: geojson), + ); + + // Insert focus line layers BELOW the individual repeater layer so + // repeater boxes (and the cluster bubbles/count text above them, plus + // the symbol annotation markers on top of those) all render on top of + // the connecting lines. This is especially important at the repeater + // end of each line, where the dotted stroke would otherwise draw over + // the repeater box. + const belowLayer = _repeaterIndividualLayerId; + + // Border line (white, wider, only for ambiguous matches) — added FIRST + // so it renders BENEATH the colored line on top. + await _mapController!.addLineLayer( + _focusLinesSourceId, + _focusLinesAmbiguousLayerId, + const LineLayerProperties( + lineColor: '#FFFFFF', + lineOpacity: 0.6, + lineWidth: 6.5, + lineDasharray: [2, 4], + lineCap: 'round', + ), + filter: ['==', ['get', 'ambiguous'], true], + belowLayerId: belowLayer, + ); + + // Main colored line (color from feature property via data-driven expression) + await _mapController!.addLineLayer( + _focusLinesSourceId, + _focusLinesLayerId, + const LineLayerProperties( + lineColor: ['get', 'color'], + lineOpacity: 0.9, + lineWidth: 3.5, + lineDasharray: [2, 4], + lineCap: 'round', + ), + belowLayerId: belowLayer, + ); + } catch (e) { + debugError('[MAP] Failed to add focus lines: $e'); + } + } + + /// Diff-syncs the distance label symbols shown in focus mode. Each label is + /// a bitmap pill (white text on a dark rounded rectangle background, baked + /// into an addImage icon) placed at the midpoint of the ping→repeater line. + /// + /// A later pass ([_reflowDistanceLabelsForCollisions]) may slide individual + /// labels along their lines after the zoom-to-fit animation settles, to + /// prevent them from overlapping on screen. + Future _syncDistanceLabels(AppStateProvider appState) async { + if (_mapController == null || !_styleLoaded) return; + + // No focus → remove all existing labels and wipe the tracking maps. + // + // Order matters here: snapshot the symbols to remove and clear the + // tracking maps SYNCHRONOUSLY before awaiting any removeSymbol call. + // + // Why: removeSymbol is async. If we cleared after the await loop, a + // concurrent _syncDistanceLabels call (triggered by e.g. the user + // tapping a new ping and its focus activating during the yield) would + // see the old tracking data — populate new symbols for the new focus + // into the still-populated map — and then our late `.clear()` would + // wipe the new-focus entries from tracking, leaving orphaned native + // symbols on the map and causing the NEXT sync to double-add them. + // By clearing first, any concurrent sync starts from a clean slate. + if (_focusedPingLocation == null || _focusedRepeaters.isEmpty) { + final toRemove = List.of(_distanceLabelSymbols.values); + _distanceLabelSymbols.clear(); + _distanceLabelImageSize.clear(); + _distanceLabelRepeaterPos.clear(); + for (final sym in toRemove) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + return; + } + + final isImperial = appState.preferences.isImperial; + final ping = _focusedPingLocation!; + final wantedKeys = {}; + + for (final r in _focusedRepeaters) { + final key = r.repeater.id; + wantedKeys.add(key); + final midLat = (ping.latitude + r.repeater.lat) / 2; + final midLon = (ping.longitude + r.repeater.lon) / 2; + final meters = GpsService.distanceBetween( + ping.latitude, + ping.longitude, + r.repeater.lat, + r.repeater.lon, + ); + final labelText = meters < 1000 + ? formatMeters(meters, isImperial: isImperial) + : formatKilometers(meters / 1000, isImperial: isImperial); + + // Dedup the bitmap image by label text — identical distances reuse one + // registered image. addImage is idempotent by name, so re-registering + // the same name is a no-op on subsequent calls. + final imageName = 'distance-label-${labelText.hashCode}'; + Size? imageSize; + if (!_registeredDistanceLabelImages.contains(imageName)) { + try { + final rendered = await _renderDistanceLabelPng(labelText); + await _mapController!.addImage(imageName, rendered.bytes); + _registeredDistanceLabelImages.add(imageName); + imageSize = rendered.size; + } catch (e) { + debugError('[MAP] render/addImage(distance label) failed: $e'); + } + } + // If we didn't just render (reuse case) we still need the size for + // collision tests. Re-render for measurement; this is cheap and rare. + if (imageSize == null) { + try { + final rendered = await _renderDistanceLabelPng(labelText); + imageSize = rendered.size; + } catch (_) { + imageSize = const Size(60, 18); + } + } + _distanceLabelImageSize[key] = imageSize; + _distanceLabelRepeaterPos[key] = + LatLng(r.repeater.lat, r.repeater.lon); + + final options = SymbolOptions( + geometry: LatLng(midLat, midLon), + iconImage: imageName, + iconSize: 1.0, + iconAnchor: 'center', + ); + + final existing = _distanceLabelSymbols[key]; + if (existing == null) { + try { + _distanceLabelSymbols[key] = await _mapController!.addSymbol( + options, + {'kind': 'distance'}, + ); + } catch (e) { + debugError('[MAP] addSymbol(distance) failed: $e'); + } + } else { + try { + await _mapController!.updateSymbol(existing, options); + } catch (e) { + debugError('[MAP] updateSymbol(distance) failed: $e'); + } + } + } + + // Remove labels for repeaters no longer in focus + final toRemove = + _distanceLabelSymbols.keys.where((k) => !wantedKeys.contains(k)).toList(); + for (final key in toRemove) { + final sym = _distanceLabelSymbols.remove(key); + _distanceLabelImageSize.remove(key); + _distanceLabelRepeaterPos.remove(key); + if (sym != null) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + } } - /// Collapsible map controls (toggle at top, expands downward) - Widget _buildCollapsibleMapControls(AppStateProvider appState) { - // Use external state if provided, otherwise use internal state - final isExpanded = widget.mapControlsExpanded ?? _mapControlsExpanded; - final onToggle = widget.onMapControlsToggle ?? () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); + /// After the focus zoom-to-fit animation settles, walks the placed distance + /// labels and slides any that overlap on screen to a different position + /// along their ping→repeater line. Uses toScreenLocationBatch to sample + /// candidate t values (0.5, 0.4, 0.6, 0.3, 0.7, 0.25, 0.75) for each label + /// and greedily picks the first non-colliding slot. + Future _reflowDistanceLabelsForCollisions() async { + if (_mapController == null || !mounted) return; + if (_focusedPingLocation == null) return; + if (_distanceLabelSymbols.isEmpty) return; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Toggle button (always visible) - at top - GestureDetector( - onTap: onToggle, - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: isExpanded - ? const BorderRadius.vertical(top: Radius.circular(8)) - : BorderRadius.circular(8), - ), - child: Icon( - isExpanded ? Icons.expand_less : Icons.expand_more, - color: Colors.white, - size: 22, - ), - ), - ), - // Map controls (only when expanded) - below the toggle button - if (isExpanded) - _buildMapControls(appState), - ], - ); - } + final ping = _focusedPingLocation!; + // Deterministic order: iterate focused repeaters in the list order we got + // them in (SNR-ranked upstream), so the "primary" label wins t=0.5. + final orderedIds = _focusedRepeaters + .map((r) => r.repeater.id) + .where(_distanceLabelSymbols.containsKey) + .toList(); + if (orderedIds.isEmpty) return; + + // Candidate t values to try, in preference order. + const candidateTs = [0.5, 0.4, 0.6, 0.3, 0.7, 0.25, 0.75]; + + // Step 1: compute all candidate LatLngs for every label so we can batch + // the toScreenLocation calls (one round-trip instead of N×T). + final candidateLatLngs = []; + for (final id in orderedIds) { + final repeaterPos = _distanceLabelRepeaterPos[id]; + if (repeaterPos == null) continue; + for (final t in candidateTs) { + candidateLatLngs.add(LatLng( + ping.latitude + (repeaterPos.latitude - ping.latitude) * t, + ping.longitude + (repeaterPos.longitude - ping.longitude) * t, + )); + } + } - Widget _buildMap(AppStateProvider appState, LatLng center) { - return Builder( - builder: (context) => FlutterMap( - mapController: _mapController, - options: MapOptions( - initialCenter: center, - initialZoom: _defaultZoom, - minZoom: 3, - maxZoom: 17, - interactionOptions: InteractionOptions( - flags: _rotationLocked - ? InteractiveFlag.all & ~InteractiveFlag.rotate - : InteractiveFlag.all, - ), - onMapReady: () { - _isMapReady = true; - // Initial center on GPS if available - if (appState.currentPosition != null) { - _mapController.move(center, _defaultZoom); - } - }, - ), - children: [ - // Tile layer (dynamic based on selected style from preferences) - // Skipped entirely when map tiles are disabled to save mobile data - if (appState.preferences.mapTilesEnabled) - Builder( - builder: (context) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); - return TileLayer( - urlTemplate: mapStyle.urlTemplate, - subdomains: mapStyle.subdomains ?? const [], - userAgentPackageName: 'com.meshmapper.app', - maxZoom: 17, - retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), - tileDisplay: const TileDisplay.fadeIn( - reloadStartOpacity: 1.0, - ), - tileProvider: SilentCancellableNetworkTileProvider(), - ); - }, - ), + List> screenPoints; + try { + screenPoints = + await _mapController!.toScreenLocationBatch(candidateLatLngs); + } catch (e) { + debugError('[MAP] toScreenLocationBatch(distance labels) failed: $e'); + return; + } + if (!mounted || _focusedPingLocation == null) return; + + // Step 2: greedily place each label at the first candidate t whose + // screen rect doesn't overlap any already-placed label rect. + const gap = 4.0; // extra spacing between pills in logical pixels + final placedRects = []; + var cursor = 0; + for (final id in orderedIds) { + final repeaterPos = _distanceLabelRepeaterPos[id]; + final labelSize = + _distanceLabelImageSize[id] ?? const Size(60, 18); + if (repeaterPos == null) { + cursor += candidateTs.length; + continue; + } - // MeshMapper coverage overlay (only when zone code available, overlay enabled, and tiles enabled) - if (appState.preferences.mapTilesEnabled && appState.zoneCode != null && _showMeshMapperOverlay) - TileLayer( - urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}${appState.preferences.colorVisionType != 'none' ? '&cvd=${appState.preferences.colorVisionType}' : ''}', - userAgentPackageName: 'com.meshmapper.app', - minZoom: 3, - maxZoom: 17, - tileDisplay: const TileDisplay.fadeIn( - reloadStartOpacity: 1.0, - ), - tileProvider: SilentCancellableNetworkTileProvider(), - ), + int bestIdx = 0; + Rect? bestRect; + for (var i = 0; i < candidateTs.length; i++) { + final sp = screenPoints[cursor + i]; + final rect = Rect.fromCenter( + center: Offset(sp.x.toDouble(), sp.y.toDouble()), + width: labelSize.width + gap, + height: labelSize.height + gap, + ); + final collides = placedRects.any((r) => r.overlaps(rect)); + if (!collides) { + bestIdx = i; + bestRect = rect; + break; + } + // Fallback: keep the first candidate rect so we still place somewhere + // if every slot collides. + bestRect ??= rect; + } - // Coverage markers (TX, RX, DISC, Trace) — sorted by timestamp, newest on top - // During focus mode, the focused marker is excluded and rendered in its own top layer - MarkerLayer( - markers: _buildCoverageMarkers( - txPings: appState.txPings, - rxPings: appState.rxPings, - discEntries: appState.discLogEntries, - discDropEnabled: appState.discDropEnabled, - traceEntries: appState.traceLogEntries, - excludeFocused: _focusedPingLocation != null, - ), - ), + final tChosen = candidateTs[bestIdx]; + final targetLatLng = LatLng( + ping.latitude + (repeaterPos.latitude - ping.latitude) * tChosen, + ping.longitude + (repeaterPos.longitude - ping.longitude) * tChosen, + ); + placedRects.add(bestRect!); + + final symbol = _distanceLabelSymbols[id]; + if (symbol != null) { + try { + await _mapController!.updateSymbol( + symbol, + SymbolOptions(geometry: targetLatLng), + ); + } catch (e) { + debugError('[MAP] updateSymbol(distance reflow) failed: $e'); + } + } - // Focus mode: polylines from focused ping to each connected repeater - // Line color = SNR (green/yellow/red). Ambiguous matches get a white border. - if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) - PolylineLayer( - polylines: _focusedRepeaters.map((r) { - final lineColor = r.snr != null - ? PingColors.snrColor(r.snr!) - : Colors.grey; - return Polyline( - points: [_focusedPingLocation!, LatLng(r.repeater.lat, r.repeater.lon)], - color: lineColor.withValues(alpha: 0.9), - strokeWidth: 3.5, - isDotted: true, - borderStrokeWidth: r.ambiguous ? 1.5 : 0, - borderColor: r.ambiguous ? Colors.white.withValues(alpha: 0.6) : null, - ); - }).toList(), - ), + cursor += candidateTs.length; + } + } - // Repeater markers (magenta with ID, rotate with map) - // During focus mode, split into two layers: faded repeaters below, connected on top - if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) ...[ - // Faded non-connected repeaters (below) - MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, - onlyFaded: true, - ), - ), - // Distance labels (middle) - MarkerLayer( - rotate: true, - markers: _buildFocusDistanceLabels(appState), - ), - // Connected repeaters (on top) - MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, - onlyConnected: true, - ), - ), - // Focused ping marker (above everything except GPS) - MarkerLayer( - markers: _buildFocusedPingMarker( - txPings: appState.txPings, - rxPings: appState.rxPings, - discEntries: appState.discLogEntries, - discDropEnabled: appState.discDropEnabled, - traceEntries: appState.traceLogEntries, - ), - ), - ] else - // Normal mode: single layer with all repeaters - MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, - ), - ), + /// Single entry point that syncs all native annotations against current + /// app state. Called from the post-frame callback in [build] when the + /// marker data version changes (so we don't sync on every camera tick). + Future _syncAllAnnotations(AppStateProvider appState) async { + await _syncRepeaterSymbols(appState); + await _syncCoverageSymbols(appState); + await _syncGpsSymbol(appState); + await _updateFocusLines(); + await _syncDistanceLabels(appState); + } - // Current position marker - if (appState.currentPosition != null) - MarkerLayer( - // Vehicle/boat icons stay upright by counter-rotating against map rotation; - // arrow, walk, and pacman rotate with heading (handled by Transform.rotate in the painter) - rotate: appState.preferences.gpsMarkerStyle != 'arrow' && - appState.preferences.gpsMarkerStyle != 'walk' && - appState.preferences.gpsMarkerStyle != 'pacman', - markers: [ - Marker( - point: LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, - ), - width: 48, - height: 48, - child: _buildCurrentPositionMarker(appState.currentPosition!.heading), - ), - ], - ), - ], - ), + /// Compute a version hash of all data that affects the marker list. + /// When this changes, the cached marker list is rebuilt; otherwise it's reused + /// across camera-change rebuilds (which happen at ~60Hz during pan/zoom). + int _computeMarkerDataVersion(AppStateProvider appState) { + return Object.hash( + appState.txPings.length, + appState.rxPings.length, + appState.discLogEntries.length, + appState.traceLogEntries.length, + appState.repeaters.length, + appState.discDropEnabled, + appState.enforceHopBytes, + appState.effectiveHopBytes, + _focusedPingLocation, + _focusedPingTimestamp, + _focusedRepeaters.length, + appState.preferences.gpsMarkerStyle, + appState.currentPosition?.latitude, + appState.currentPosition?.longitude, + _computedHeading, ); } @@ -1117,6 +2786,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (_autoFollow) { setState(() { _autoFollow = false; + _autoFollowDesiredZoom = null; }); appState.setMapAutoFollow(false); return; @@ -1128,14 +2798,31 @@ class _MapWidgetState extends State with TickerProviderStateMixin { appState.currentPosition!.latitude, appState.currentPosition!.longitude, ); + const targetZoom = 17.0; // Street level zoom when enabling follow setState(() { _autoFollow = true; _lastGpsPosition = targetPosition; + _autoFollowDesiredZoom = targetZoom; }); appState.setMapAutoFollow(true); - // Apply offset for bottom padding when control panel is open - final adjustedPosition = _offsetPositionForPadding(targetPosition, widget.bottomPaddingPixels, widget.rightPaddingPixels); - _animateToPositionWithZoom(adjustedPosition, 17.0); // Street level zoom when enabling follow + // Bundle target + zoom + bearing into one animation so the + // initial centering can't be half-cancelled by a racing GPS tick. + final double targetBearing = (!_alwaysNorth && _computedHeading != null) + ? _computedHeading! + : 0.0; + final adjustedPosition = _offsetPositionForPadding( + targetPosition, + widget.bottomPaddingPixels, + widget.rightPaddingPixels, + targetZoom, + targetBearing, + ); + _animateAutoFollowCamera( + target: adjustedPosition, + zoom: targetZoom, + bearing: targetBearing, + durationMs: 500, + ); } } @@ -1143,6 +2830,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { setState(() { _showMeshMapperOverlay = !_showMeshMapperOverlay; }); + if (_showMeshMapperOverlay) { + _addCoverageOverlay(context.read()); + } else { + _removeCoverageOverlay(); + } } void _toggleNorthMode() { @@ -1151,49 +2843,24 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _alwaysNorth = !_alwaysNorth; // If switching to Always North mode, smoothly rotate map back to north - if (_alwaysNorth && _isMapReady) { - // Reset heading tracking + if (_alwaysNorth && _isMapReady && _mapController != null) { _lastHeading = null; - // Smoothly rotate back to north (0 degrees) - final currentRotation = _mapController.camera.rotation; - if (currentRotation.abs() > 2) { - // Cancel any running rotation animation - _rotationAnimationController?.stop(); - _rotationAnimationController?.dispose(); - - // Create animation to rotate back to north - const duration = Duration(milliseconds: 500); - _rotationAnimationController = AnimationController( - duration: duration, - vsync: this, - ); - - _rotationAnimation = CurvedAnimation( - parent: _rotationAnimationController!, - curve: Curves.easeInOutCubic, + final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; + if (currentBearing.abs() > 2) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 500), ); - - _rotationStartAngle = currentRotation; - _rotationEndAngle = 0.0; // North - - _rotationAnimation!.addListener(() { - if (!mounted || _rotationStartAngle == null || _rotationEndAngle == null) return; - - final t = _rotationAnimation!.value; - final rotation = _rotationStartAngle! + - ((_rotationEndAngle! - _rotationStartAngle!) * t); - - _mapController.rotate(rotation); - }); - - _rotationAnimationController!.forward(); } } else if (!_alwaysNorth && appState.currentPosition != null) { // If switching to heading mode, immediately start rotating to current heading _lastHeading = null; // Force initial rotation + // Prefer our derived heading; fall back to whatever GPS reports (may + // be 0 if we haven't moved yet — better than no rotation at all). + final initialHeading = _computedHeading ?? appState.currentPosition!.heading; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && !_alwaysNorth && appState.currentPosition != null) { - _animateToRotation(appState.currentPosition!.heading); + _animateToRotation(initialHeading); } }); } @@ -1207,40 +2874,13 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _rotationLocked = !_rotationLocked; // When enabling lock in "Always North" mode, rotate back to north - // When in "Rotate with Heading" mode, keep current rotation - if (_rotationLocked && _isMapReady && _alwaysNorth) { - final currentRotation = _mapController.camera.rotation; - if (currentRotation.abs() > 2) { - // Cancel any running rotation animation - _rotationAnimationController?.stop(); - _rotationAnimationController?.dispose(); - - // Create animation to rotate back to north - const duration = Duration(milliseconds: 500); - _rotationAnimationController = AnimationController( - duration: duration, - vsync: this, - ); - - _rotationAnimation = CurvedAnimation( - parent: _rotationAnimationController!, - curve: Curves.easeInOutCubic, + if (_rotationLocked && _isMapReady && _alwaysNorth && _mapController != null) { + final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; + if (currentBearing.abs() > 2) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 500), ); - - _rotationStartAngle = currentRotation; - _rotationEndAngle = 0.0; // North - - _rotationAnimation!.addListener(() { - if (!mounted || _rotationStartAngle == null || _rotationEndAngle == null) return; - - final t = _rotationAnimation!.value; - final rotation = _rotationStartAngle! + - ((_rotationEndAngle! - _rotationStartAngle!) * t); - - _mapController.rotate(rotation); - }); - - _rotationAnimationController!.forward(); } } }); @@ -1754,104 +3394,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } /// Build a coverage marker child widget based on the user's marker style preference. - Widget _buildCoverageMarkerChild(Color color) { - final style = context.read().preferences.markerStyle; - switch (style) { - case 'circle': - return Container( - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2.0), - boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 2, offset: Offset(0, 1))], - ), - ); - case 'pin': - return CustomPaint( - size: const Size(20, 20), - painter: _PinMarkerPainter(color), - ); - case 'diamond': - return CustomPaint( - size: const Size(20, 20), - painter: _DiamondMarkerPainter(color), - ); - case 'dot': - default: - return Container( - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Colors.white.withValues(alpha: 0.6), width: 1.5), - boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 2, offset: Offset(0, 1))], - ), - ); - } - } - - /// Build all coverage dot markers sorted by timestamp (oldest first = drawn underneath). - /// Newer pings always render on top regardless of type. - List _buildCoverageMarkers({ - required List txPings, - required List rxPings, - required List discEntries, - required bool discDropEnabled, - required List traceEntries, - bool excludeFocused = false, - }) { - final timestamped = <(DateTime, Marker)>[ - for (final ping in txPings) - if (!excludeFocused || !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) - (ping.timestamp, _buildTxMarker(ping)), - for (final ping in rxPings) - if (!excludeFocused || !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) - (ping.timestamp, _buildRxMarker(ping)), - for (final entry in discEntries) - if (!excludeFocused || !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) - (entry.timestamp, _buildDiscMarker(entry, discDropEnabled)), - for (final entry in traceEntries) - if (!excludeFocused || !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) - (entry.timestamp, _buildTraceMarker(entry)), - ]; - - timestamped.sort((a, b) => a.$1.compareTo(b.$1)); - return timestamped.map((e) => e.$2).toList(); - } - - /// Build just the focused ping marker for rendering in its own top layer. - List _buildFocusedPingMarker({ - required List txPings, - required List rxPings, - required List discEntries, - required bool discDropEnabled, - required List traceEntries, - }) { - if (_focusedPingLocation == null) return []; - - for (final ping in txPings) { - if (_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) { - return [_buildTxMarker(ping)]; - } - } - for (final ping in rxPings) { - if (_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) { - return [_buildRxMarker(ping)]; - } - } - for (final entry in discEntries) { - if (_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) { - return [_buildDiscMarker(entry, discDropEnabled)]; - } - } - for (final entry in traceEntries) { - if (_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) { - return [_buildTraceMarker(entry)]; - } - } - return []; - } - /// Check if a ping at given lat/lon/timestamp is the currently focused ping. + /// Used by the native annotation sync to apply focus-mode styling (size, + /// opacity) to the focused ping vs other pings. bool _isFocusedPing(double lat, double lon, DateTime timestamp) { return _focusedPingLocation != null && _focusedPingTimestamp == timestamp && @@ -1859,74 +3404,6 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _focusedPingLocation!.longitude == lon; } - /// Apply focus fade to a marker color. Returns dimmed color if focus is active - /// and this marker is not the focused one. - Color _applyFocusFade(Color color, bool isFocused) { - if (_focusedPingLocation == null || isFocused) return color; - return color.withValues(alpha: 0.15); - } - - Marker _buildTxMarker(TxPing ping) { - final isFocused = _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); - final color = ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess; - final size = isFocused ? 24.0 : 20.0; - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: size, - height: size, - child: GestureDetector( - onTap: () => _showTxPingDetails(ping), - child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), - ), - ); - } - - Marker _buildRxMarker(RxPing ping) { - final isFocused = _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); - final size = isFocused ? 24.0 : 20.0; - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: size, - height: size, - child: GestureDetector( - onTap: () => _showRxPingDetails(ping), - child: _buildCoverageMarkerChild(_applyFocusFade(PingColors.rx, isFocused)), - ), - ); - } - - Marker _buildDiscMarker(DiscLogEntry entry, bool discDropEnabled) { - final isFocused = _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); - final color = entry.nodeCount == 0 - ? (discDropEnabled ? PingColors.txFail : PingColors.discFail) - : _discMarkerColor; - final size = isFocused ? 24.0 : 20.0; - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: size, - height: size, - child: GestureDetector( - onTap: () => _showDiscPingDetails(entry), - child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), - ), - ); - } - - Marker _buildTraceMarker(TraceLogEntry entry) { - final isFocused = _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); - final color = entry.success ? Colors.cyan : Colors.grey; - final size = isFocused ? 24.0 : 20.0; - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: size, - height: size, - child: GestureDetector( - onTap: () => _showTraceDetails(entry), - child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), - ), - ); - } - void _showTraceDetails(TraceLogEntry entry) { // Activate focus mode for successful traces with a known repeater if (entry.success) { @@ -1943,6 +3420,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { context: context, useSafeArea: true, isScrollControlled: true, + // Transparent barrier so the map stays fully bright during focus mode — + // the default Colors.black54 scrim would defeat the purpose of focus. + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), @@ -2166,50 +3646,6 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ).whenComplete(() => _dismissPingFocus()); } - /// Build distance label markers at the midpoint of each focus line. - List _buildFocusDistanceLabels(AppStateProvider appState) { - if (_focusedPingLocation == null) return []; - final isImperial = appState.preferences.isImperial; - final ping = _focusedPingLocation!; - - return _focusedRepeaters.map((r) { - final repeaterPos = LatLng(r.repeater.lat, r.repeater.lon); - // Midpoint of the line - final midLat = (ping.latitude + repeaterPos.latitude) / 2; - final midLon = (ping.longitude + repeaterPos.longitude) / 2; - // Distance in meters — use GpsService for consistency with repeater popup - final meters = GpsService.distanceBetween( - ping.latitude, ping.longitude, repeaterPos.latitude, repeaterPos.longitude, - ); - final label = meters < 1000 - ? formatMeters(meters, isImperial: isImperial) - : formatKilometers(meters / 1000, isImperial: isImperial); - - return Marker( - point: LatLng(midLat, midLon), - width: 70, - height: 22, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - label, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Colors.white, - ), - ), - ), - ); - }).toList(); - } - /// DISC marker color (delegates to active palette) static Color get _discMarkerColor => PingColors.discSuccess; @@ -2253,8 +3689,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Activate ping focus mode — draw lines, fade markers, zoom to fit. void _activatePingFocus(LatLng pingLocation, DateTime timestamp, List<_ResolvedRepeater> repeaters) { - _preFocusCenter = _mapController.camera.center; - _preFocusZoom = _mapController.camera.zoom; + final pos = _mapController?.cameraPosition; + _preFocusCenter = pos?.target; + _preFocusZoom = pos?.zoom; _wasAutoFollowBeforeFocus = _autoFollow; _wasRotatingBeforeFocus = !_alwaysNorth; @@ -2265,10 +3702,12 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Lock to north-up during focus so the zoom-to-fit view is stable if (!_alwaysNorth) { _alwaysNorth = true; - _animateToRotation(0); // Won't fire because _alwaysNorth is now true - // Snap rotation to 0 directly - if (_isMapReady) { - _mapController.rotate(0); + // Snap rotation to north (instant — avoids wobble before zoom-to-fit animation) + if (_isMapReady && _mapController != null) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 1), + ); } } @@ -2278,11 +3717,25 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _focusedRepeaters = repeaters; }); + // Hide the MeshMapper coverage raster overlay for a clean focus view. + // Uses opacity=0 rather than removing the layer to avoid a tile refetch + // on dismiss. No-ops gracefully if the layer isn't present. + _applyCoverageOverlayOpacity(0.0); + WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _focusedPingLocation != null) { _zoomToFocusBounds(pingLocation, repeaters); } }); + + // Once the 500ms zoom-to-fit animation settles, re-flow the distance + // labels so any that collide on screen slide along their lines to a + // non-overlapping slot. 600ms gives the camera a bit of buffer beyond + // the animation duration. + Future.delayed(const Duration(milliseconds: 600), () { + if (!mounted || _focusedPingLocation == null) return; + _reflowDistanceLabelsForCollisions(); + }); } /// Dismiss ping focus mode — restore map state. @@ -2303,6 +3756,12 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _focusedRepeaters = []; }); + // Restore the MeshMapper coverage raster overlay opacity. Safe if the + // layer was hidden via the toggle during focus — setLayerProperties is + // wrapped in try/catch inside the helper. + final appState = context.read(); + _applyCoverageOverlayOpacity(appState.preferences.coverageOverlayOpacity); + if (center != null && zoom != null) { _animateToPositionWithZoom(center, zoom); @@ -2349,118 +3808,6 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return _repeaterMarkerColor; // Active (default) } - List _buildRepeaterMarkers( - List repeaters, - int? regionHopBytesOverride, { - bool onlyFaded = false, - bool onlyConnected = false, - }) { - final duplicateIds = _getDuplicateRepeaterIds(repeaters); - final hasFocus = _focusedPingLocation != null; - - return repeaters.where((repeater) { - if (!hasFocus) return true; // No focus — include all - final isConnected = _focusedRepeaters.any((r) => r.repeater.id == repeater.id); - if (onlyConnected) return isConnected; - if (onlyFaded) return !isConnected; - return true; - }).map((repeater) { - final isDuplicate = duplicateIds.contains(repeater.id); - final markerColor = _getRepeaterMarkerColor(repeater, isDuplicate); - - // During focus mode, fade repeaters not connected to the focused ping - final isConnected = hasFocus && _focusedRepeaters.any((r) => r.repeater.id == repeater.id); - final effectiveColor = (hasFocus && !isConnected) - ? markerColor.withValues(alpha: 0.15) - : markerColor; - final effectiveBorderColor = (hasFocus && !isConnected) - ? Colors.white.withValues(alpha: 0.15) - : Colors.white; - final effectiveTextColor = (hasFocus && !isConnected) - ? Colors.white.withValues(alpha: 0.15) - : Colors.white; - - // Display hex ID based on per-repeater hop_bytes (or regional admin override) - final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); - final effectiveBytes = regionHopBytesOverride ?? repeater.hopBytes; - final isLongId = displayId.length > 2; - final markerWidth = displayId.length > 4 ? 48.0 : isLongId ? 40.0 : 28.0; - - // Shape varies by hop bytes: 1=square, 2=rounded rect, 3=more rounded - final borderRadius = effectiveBytes >= 3 - ? BorderRadius.circular(8) - : effectiveBytes == 2 - ? BorderRadius.circular(6) - : BorderRadius.circular(4); - - return Marker( - point: LatLng(repeater.lat, repeater.lon), - width: markerWidth, - height: 28, - child: GestureDetector( - onTap: () => _showRepeaterDetails(repeater, isDuplicate: isDuplicate, regionHopBytesOverride: regionHopBytesOverride), - child: Container( - padding: isLongId - ? const EdgeInsets.symmetric(horizontal: 4) - : EdgeInsets.zero, - decoration: BoxDecoration( - color: effectiveColor, - borderRadius: borderRadius, - border: Border.all(color: effectiveBorderColor, width: 2), - boxShadow: (hasFocus && !isConnected) ? null : const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - alignment: Alignment.center, - child: Text( - displayId, - style: TextStyle( - fontSize: displayId.length > 4 ? 8 : isLongId ? 9 : 10, - fontWeight: FontWeight.bold, - color: effectiveTextColor, - fontFamily: 'monospace', - ), - ), - ), - ), - ); - }).toList(); - } - - Widget _buildCurrentPositionMarker(double heading) { - // Convert heading from degrees to radians - // heading is 0-360 degrees, 0 = North, 90 = East - final headingRadians = heading * (math.pi / 180); - final style = context.read().preferences.gpsMarkerStyle; - - // Arrow, walk, and pacman rotate with heading; vehicle/boat icons don't (they face up) - final shouldRotate = style == 'arrow' || style == 'walk' || style == 'pacman'; - - final CustomPainter painter; - switch (style) { - case 'car': - painter = const _CarMarkerPainter(); - case 'bike': - painter = const _BikeMarkerPainter(); - case 'boat': - painter = const _BoatMarkerPainter(); - case 'walk': - painter = const _WalkMarkerPainter(); - case 'pacman': - painter = const _PacmanMarkerPainter(); - case 'arrow': - default: - painter = const _ArrowPainter(); - } - - final child = CustomPaint(size: const Size(24, 24), painter: painter); - return shouldRotate ? Transform.rotate(angle: headingRadians, child: child) : child; - } - /// Compute node column width based on hop byte count. /// [extraPadding] adds space for additional content (e.g. nodeTypeLabel in DISC popup). double _nodeColumnWidth({double extraPadding = 0}) { @@ -2496,6 +3843,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { context: context, useSafeArea: true, isScrollControlled: true, + // Transparent barrier so the map stays fully bright during focus mode — + // the default Colors.black54 scrim would defeat the purpose of focus. + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), @@ -2711,6 +4061,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { showModalBottomSheet( context: context, useSafeArea: true, + // Transparent barrier so the map stays fully bright during focus mode — + // the default Colors.black54 scrim would defeat the purpose of focus. + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), @@ -2913,6 +4266,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { context: context, useSafeArea: true, isScrollControlled: true, + // Transparent barrier so the map stays fully bright during focus mode — + // the default Colors.black54 scrim would defeat the purpose of focus. + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), @@ -3763,6 +5119,132 @@ class _DiamondMarkerPainter extends CustomPainter { bool shouldRepaint(covariant _DiamondMarkerPainter oldDelegate) => oldDelegate.color != color; } +/// Paints a repeater marker shape (filled colored rounded box with white border +/// and drop shadow). Used at startup to generate bitmap variants for native +/// MapLibre symbols. The text (hex ID) is rendered separately by the symbol's +/// `textField` property at runtime — this painter only draws the box itself. +class _RepeaterShapePainter extends CustomPainter { + final Color fillColor; + final double borderRadius; + + const _RepeaterShapePainter({ + required this.fillColor, + required this.borderRadius, + }); + + @override + void paint(Canvas canvas, Size size) { + // Inset the box by the shadow blur amount so the shadow has room to draw + const shadowBlur = 4.0; + final boxRect = Rect.fromLTWH( + shadowBlur, + shadowBlur, + size.width - 2 * shadowBlur, + size.height - 2 * shadowBlur, + ); + + // Drop shadow (positioned 2px below the box) + final shadowPaint = Paint() + ..color = Colors.black26 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, shadowBlur); + canvas.drawRRect( + RRect.fromRectAndRadius( + boxRect.shift(const Offset(0, 2)), + Radius.circular(borderRadius), + ), + shadowPaint, + ); + + // Filled colored box + final fillPaint = Paint()..color = fillColor; + canvas.drawRRect( + RRect.fromRectAndRadius(boxRect, Radius.circular(borderRadius)), + fillPaint, + ); + + // White border (2px wide, drawn inside the box edge) + final borderPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0; + final innerRect = boxRect.deflate(1); + canvas.drawRRect( + RRect.fromRectAndRadius(innerRect, Radius.circular(borderRadius - 1)), + borderPaint, + ); + } + + @override + bool shouldRepaint(covariant _RepeaterShapePainter old) => + old.fillColor != fillColor || old.borderRadius != borderRadius; +} + +/// Paints a coverage ping marker (TX/RX/DISC/Trace) in one of the four user +/// styles. Used at startup to generate bitmap variants for native MapLibre +/// symbols. Reuses _PinMarkerPainter and _DiamondMarkerPainter for those styles. +class _CoverageMarkerPainter extends CustomPainter { + final String style; // 'circle' / 'pin' / 'diamond' / 'dot' + final Color color; + + const _CoverageMarkerPainter({required this.style, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + // Visible glyph area — the canvas is typically larger (40×40) so the + // surrounding pixels stay transparent, giving MapLibre a bigger native + // tap hit target without enlarging the actual marker visual. + const innerSize = Size(24, 24); + final dx = (size.width - innerSize.width) / 2; + final dy = (size.height - innerSize.height) / 2; + canvas.save(); + canvas.translate(dx, dy); + switch (style) { + case 'pin': + _PinMarkerPainter(color).paint(canvas, innerSize); + break; + case 'diamond': + _DiamondMarkerPainter(color).paint(canvas, innerSize); + break; + case 'circle': + _paintCircle(canvas, innerSize, borderAlpha: 1.0, borderWidth: 2.0); + break; + case 'dot': + default: + _paintCircle(canvas, innerSize, borderAlpha: 0.6, borderWidth: 1.5); + break; + } + canvas.restore(); + } + + void _paintCircle(Canvas canvas, Size size, {required double borderAlpha, required double borderWidth}) { + final center = Offset(size.width / 2, size.height / 2); + final radius = math.min(size.width, size.height) / 2 - 2; + + // Drop shadow (slightly below) + final shadowPaint = Paint() + ..color = Colors.black12 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2); + canvas.drawCircle(center.translate(0, 1), radius, shadowPaint); + + // Filled circle + canvas.drawCircle(center, radius, Paint()..color = color); + + // White border + canvas.drawCircle( + center, + radius, + Paint() + ..color = Colors.white.withValues(alpha: borderAlpha) + ..style = PaintingStyle.stroke + ..strokeWidth = borderWidth, + ); + } + + @override + bool shouldRepaint(covariant _CoverageMarkerPainter old) => + old.style != style || old.color != color; +} + /// A stateful widget for sound item with play button visual feedback class _SoundItemWidget extends StatefulWidget { final IconData icon; diff --git a/pubspec.lock b/pubspec.lock index 5b88216..243980a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -430,22 +430,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.2.0" - flutter_map: - dependency: "direct main" - description: - name: flutter_map - sha256: "87cc8349b8fa5dccda5af50018c7374b6645334a0d680931c1fe11bce88fa5bb" - url: "https://pub.dev" - source: hosted - version: "6.2.1" - flutter_map_cancellable_tile_provider: - dependency: "direct main" - description: - name: flutter_map_cancellable_tile_provider - sha256: ae18dd59faf74f3eca1d28f83e59b47741bbff962e123bbebe9335c04d432f44 - url: "https://pub.dev" - source: hosted - version: "2.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -672,14 +656,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.16" - latlong2: - dependency: "direct main" - description: - name: latlong2 - sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" - url: "https://pub.dev" - source: hosted - version: "0.9.1" leak_tracker: dependency: transitive description: @@ -712,30 +688,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - lists: + logging: dependency: transitive description: - name: lists - sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.0.1" - logger: + version: "1.3.0" + maplibre_gl: + dependency: "direct main" + description: + name: maplibre_gl + sha256: d9773555ae4ebab94bbc3ae2176b077cfda486ec729eefe01e1613f164cb8410 + url: "https://pub.dev" + source: hosted + version: "0.25.0" + maplibre_gl_platform_interface: dependency: transitive description: - name: logger - sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + name: maplibre_gl_platform_interface + sha256: bd7de401dea24dd7e8a6f2fa736ddee7dbbee3e24a9027f0afdd619994702047 url: "https://pub.dev" source: hosted - version: "2.6.2" - logging: + version: "0.25.0" + maplibre_gl_web: dependency: transitive description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + name: maplibre_gl_web + sha256: af0e48bf96e8dd99f8b958a1953126971eb8a0527b9735441d4f24df3913f5a2 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "0.25.0" matcher: dependency: transitive description: @@ -760,14 +744,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" - mgrs_dart: - dependency: transitive - description: - name: mgrs_dart - sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 - url: "https://pub.dev" - source: hosted - version: "2.0.0" mime: dependency: transitive description: @@ -960,14 +936,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" - polylabel: - dependency: transitive - description: - name: polylabel - sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" - url: "https://pub.dev" - source: hosted - version: "1.0.1" pool: dependency: transitive description: @@ -984,14 +952,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" - proj4dart: - dependency: transitive - description: - name: proj4dart - sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e - url: "https://pub.dev" - source: hosted - version: "2.1.0" provider: dependency: "direct main" description: @@ -1221,14 +1181,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - unicode: - dependency: transitive - description: - name: unicode - sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" - url: "https://pub.dev" - source: hosted - version: "0.3.1" url_launcher: dependency: "direct main" description: @@ -1373,14 +1325,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" - wkt_parser: - dependency: transitive - description: - name: wkt_parser - sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" - url: "https://pub.dev" - source: hosted - version: "2.0.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 60b10a0..a0dcd0c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,9 +13,7 @@ dependencies: flutter_blue_plus: ^1.32.0 flutter_web_bluetooth: ^0.2.3 geolocator: ^11.0.0 - flutter_map: ^6.1.0 - flutter_map_cancellable_tile_provider: ^2.0.0 - latlong2: ^0.9.0 + maplibre_gl: ^0.25.0 http: ^1.2.0 shared_preferences: ^2.2.0 hive: ^2.2.3 diff --git a/web/index.html b/web/index.html index e0c14e7..4af36e5 100644 --- a/web/index.html +++ b/web/index.html @@ -27,6 +27,10 @@ // The value below is injected by flutter build, do not touch. const serviceWorkerVersion = null; + + + + From 9df1c639b85ccf02aaea5f6bef3192d8ad72d67c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 11 Apr 2026 20:18:21 -0400 Subject: [PATCH 02/10] Enhance map widget state management by adding flags to prevent concurrent overlay refreshes and syncs, improving performance and stability during rapid state changes. --- lib/widgets/map_widget.dart | 125 ++++++++++++++++++++++++++++-------- 1 file changed, 100 insertions(+), 25 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 4c0aa41..09b6c27 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -358,6 +358,12 @@ class _MapWidgetState extends State { // the current preference in _buildMap to detect slider changes and apply // them live via _applyCoverageOverlayOpacity (no layer rebuild needed). double? _lastAppliedCoverageOpacity; + // Guard flag that coalesces multiple overlay-refresh triggers (cache bust + // and zone change) in the same frame into a single post-frame callback. + // Without this, two watchers can schedule concurrent _refreshCoverageOverlay + // runs whose remove/add calls interleave and produce "Source already exists" + // errors in the native log. + bool _coverageRefreshScheduled = false; bool _styleLoaded = false; bool _hasStyleLoadedOnce = false; // True after first onStyleLoadedCallback (prevents re-centering on style switch) @@ -365,6 +371,14 @@ class _MapWidgetState extends State { // The build() method computes a version hash from app state and only triggers // _syncAllAnnotations when the hash changes (avoiding unnecessary diff work). int _lastMarkerDataVersion = -1; + // Serializes concurrent _syncAllAnnotations runs. Without this, a second + // build() can fire a sync while the previous one is still awaiting platform + // calls — both would mutate _coverageSymbols / _distanceLabelSymbols, and + // the older sync's cleanup loop would remove symbols the newer sync just + // added. The flag causes re-entrant post-frame callbacks to bail; after the + // in-flight sync finishes, the finally block checks if the data version + // advanced during the run and triggers a rebuild if so. + bool _syncInFlight = false; // Tile load failure detection — shows a banner if map tiles haven't loaded // within a timeout after style load. Cleared when onMapIdle fires. @@ -428,7 +442,17 @@ class _MapWidgetState extends State { @override void dispose() { _tileLoadTimeoutTimer?.cancel(); - _mapController?.removeListener(_onCameraChanged); + final controller = _mapController; + if (controller != null) { + controller.removeListener(_onCameraChanged); + // Symbol/feature tap listeners are registered in _onMapCreated onto + // separate callback collections that ChangeNotifier.dispose() does NOT + // clear. Remove them explicitly so an in-flight tap that gets queued + // before the platform channel is torn down can't reach into a disposed + // State. try/catch swallows the edge case where _onMapCreated never ran. + try { controller.onSymbolTapped.remove(_handleSymbolTap); } catch (_) {} + try { controller.onFeatureTapped.remove(_handleFeatureTap); } catch (_) {} + } super.dispose(); } @@ -871,8 +895,22 @@ class _MapWidgetState extends State { final dataVersion = _computeMarkerDataVersion(appState); if (dataVersion != _lastMarkerDataVersion) { _lastMarkerDataVersion = dataVersion; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _syncAllAnnotations(appState); + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + // Guard against concurrent build()-triggered syncs stepping on each + // other. _syncAllAnnotations awaits multiple native platform calls + // and can take ~100ms+; during auto-ping bursts multiple rebuilds + // would otherwise schedule overlapping runs whose cleanup loops + // would remove symbols the other sync just added. + if (_syncInFlight) return; + _syncInFlight = true; + try { + await _syncAllAnnotations(appState); + } catch (e) { + debugError('[MAP] _syncAllAnnotations failed: $e'); + } finally { + _syncInFlight = false; + } }); } } @@ -1010,24 +1048,32 @@ class _MapWidgetState extends State { // onStyleLoadedCallback → _onStyleLoaded re-registers images, rebuilds // cluster layers, re-adds the coverage overlay, and re-syncs annotations. - // Detect cache bust change and refresh overlay - if (appState.overlayCacheBust != _lastCacheBust && _isMapReady && _styleLoaded) { - _lastCacheBust = appState.overlayCacheBust; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _refreshCoverageOverlay(appState); - }); - } - - // Detect zoneCode transition (null → value, or zone change) and add/refresh - // the overlay. Needed because _addCoverageOverlay only runs during - // _onStyleLoaded — if the first zone check failed with gps_inaccurate, the - // style loads with zoneCode=null and the overlay is skipped. When a later - // retry sets the zone, nothing would otherwise trigger the raster layer. - if (appState.zoneCode != _lastOverlayZoneCode && _isMapReady && _styleLoaded) { - _lastOverlayZoneCode = appState.zoneCode; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _refreshCoverageOverlay(appState); - }); + // Detect cache bust or zoneCode change → schedule a SINGLE coalesced + // refresh. Previously each watcher scheduled its own post-frame callback, + // which could race when both changed in the same frame (e.g. a zone + // transition that also rotates cache bust). The _coverageRefreshScheduled + // flag ensures at most one refresh is queued per frame. + // + // The zoneCode watcher is needed because _addCoverageOverlay only runs + // during _onStyleLoaded — if the first zone check failed with + // gps_inaccurate, the style loads with zoneCode=null and the overlay is + // skipped. When a later retry sets the zone, nothing else would trigger + // the raster layer. + final cacheBustChanged = + appState.overlayCacheBust != _lastCacheBust && _isMapReady && _styleLoaded; + final zoneChanged = + appState.zoneCode != _lastOverlayZoneCode && _isMapReady && _styleLoaded; + if (cacheBustChanged || zoneChanged) { + if (cacheBustChanged) _lastCacheBust = appState.overlayCacheBust; + if (zoneChanged) _lastOverlayZoneCode = appState.zoneCode; + if (!_coverageRefreshScheduled) { + _coverageRefreshScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) async { + _coverageRefreshScheduled = false; + if (!mounted) return; + await _refreshCoverageOverlay(appState); + }); + } } // Detect coverage overlay opacity change (user dragged the slider in @@ -1107,6 +1153,7 @@ class _MapWidgetState extends State { /// app state and call the existing `_show*Details()` method (which expects /// the full object, not just an ID). void _handleSymbolTap(Symbol symbol) { + if (!mounted) return; final data = symbol.data; if (data == null) return; final kind = data['kind'] as String?; @@ -1324,6 +1371,13 @@ class _MapWidgetState extends State { _gpsSymbol = null; _coverageSymbols.clear(); _distanceLabelSymbols.clear(); + // Distance-label companions: the native side wipes registered images on + // style reload, so the "already registered" cache must be cleared too or + // the next focus mode will skip addImage() and reference a non-existent + // image. The size/repeater-position maps are cleared for consistency. + _distanceLabelImageSize.clear(); + _distanceLabelRepeaterPos.clear(); + _registeredDistanceLabelImages.clear(); // Mark cluster layers as not-ready until _setupRepeaterClusterLayers // creates them on the new style. This gates build()-driven post-frame // syncs from racing ahead of source creation. @@ -1892,10 +1946,14 @@ class _MapWidgetState extends State { } } - /// Composite key for a coverage marker symbol — kind + timestamp ms. + /// Composite key for a coverage marker symbol — kind + timestamp ms + lat/lon. /// Used as the map key in [_coverageSymbols] and to detect updates/removals. - String _coverageKey(String type, DateTime ts) => - '${type}_${ts.millisecondsSinceEpoch}'; + /// Lat/lon at 5-decimal precision (~1.1m) is included so two distinct pings + /// that happen to land in the same millisecond (possible under heavy RX + /// traffic) don't collide on a shared key. + String _coverageKey(String type, DateTime ts, double lat, double lon) => + '${type}_${ts.millisecondsSinceEpoch}_' + '${lat.toStringAsFixed(5)}_${lon.toStringAsFixed(5)}'; /// Diff-syncs native coverage symbols (TX/RX/DISC/Trace) against app state. /// One symbol per ping, image varies by type/success state, opacity reflects @@ -1928,7 +1986,7 @@ class _MapWidgetState extends State { required bool success, required int idForMetadata, }) async { - final key = _coverageKey(type, ts); + final key = _coverageKey(type, ts, lat, lon); final isFocused = _isFocusedPing(lat, lon, ts); // In focus mode, hide every coverage marker except the focused ping. // Skipping wantedKeys lets the cleanup loop remove them entirely so the @@ -2475,7 +2533,21 @@ class _MapWidgetState extends State { /// Compute a version hash of all data that affects the marker list. /// When this changes, the cached marker list is rebuilt; otherwise it's reused /// across camera-change rebuilds (which happen at ~60Hz during pan/zoom). + /// + /// Captures **in-place** mutations too: TX pings grow `heardRepeaters` during + /// the 7s echo window, and DISC entries grow `discoveredNodes` as late + /// responses land. Summing counts makes the hash sensitive to these additions + /// even though the parent list length doesn't change. int _computeMarkerDataVersion(AppStateProvider appState) { + int txEchoTotal = 0; + for (final p in appState.txPings) { + txEchoTotal += p.heardRepeaters.length; + } + int discNodeTotal = 0; + for (final e in appState.discLogEntries) { + discNodeTotal += e.discoveredNodes.length; + } + return Object.hash( appState.txPings.length, appState.rxPings.length, @@ -2489,9 +2561,12 @@ class _MapWidgetState extends State { _focusedPingTimestamp, _focusedRepeaters.length, appState.preferences.gpsMarkerStyle, + appState.preferences.markerStyle, appState.currentPosition?.latitude, appState.currentPosition?.longitude, _computedHeading, + txEchoTotal, + discNodeTotal, ); } From 2a1c63e8186c0f66cd96f63c95ef16173280b321 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Wed, 15 Apr 2026 12:09:38 -0700 Subject: [PATCH 03/10] add offline management --- android/app/build.gradle.kts | 2 +- lib/main.dart | 9 + lib/screens/offline_maps_screen.dart | 1116 +++++++++++++++++++++++++ lib/screens/settings_screen.dart | 18 +- lib/services/offline_map_service.dart | 534 ++++++++++++ lib/widgets/map_widget.dart | 35 +- 6 files changed, 1706 insertions(+), 8 deletions(-) create mode 100644 lib/screens/offline_maps_screen.dart create mode 100644 lib/services/offline_map_service.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f0529ba..d7c6a8d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -42,7 +42,7 @@ android { applicationId = "net.meshmapper.app" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = 23 // MapLibre GL requires 23+ + minSdk = flutter.minSdkVersion // MapLibre GL requires 23+ targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/lib/main.dart b/lib/main.dart index 08ef6ef..2745c9c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'services/bluetooth/mobile_bluetooth.dart'; import 'services/bluetooth/web_bluetooth.dart'; import 'services/background_service.dart'; import 'services/debug_file_logger.dart'; +import 'services/offline_map_service.dart'; import 'utils/debug_logger_io.dart'; void main() async { @@ -69,6 +70,11 @@ void main() async { await BackgroundServiceManager.cleanupOrphanedService(); } + // Clean up any stale offline map download notification + if (!kIsWeb) { + await OfflineMapService().cleanupOrphanedNotification(); + } + runApp(MeshMapperApp(initialThemeMode: initialThemeMode)); } @@ -215,6 +221,9 @@ class MeshMapperApp extends StatelessWidget { ChangeNotifierProvider( create: (_) => AppStateProvider(bluetoothService: bluetoothService), ), + ChangeNotifierProvider( + create: (_) => OfflineMapService()..initialize(), + ), ], child: _ThemedApp(initialThemeMode: initialThemeMode), ); diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart new file mode 100644 index 0000000..ca4ac1e --- /dev/null +++ b/lib/screens/offline_maps_screen.dart @@ -0,0 +1,1116 @@ +import 'dart:math' show Point; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:provider/provider.dart'; + +import '../providers/app_state_provider.dart'; +import '../services/offline_map_service.dart'; +import '../widgets/app_toast.dart'; + +/// Available map styles for offline download. +/// Satellite uses inline raster JSON which doesn't work well with the offline +/// region downloader, so we only offer the vector tile styles. +const _downloadStyles = { + 'Liberty': 'https://tiles.openfreemap.org/styles/liberty', + 'Dark': 'https://tiles.openfreemap.org/styles/dark', + 'Light': 'https://tiles.openfreemap.org/styles/bright', +}; + +/// Screen for managing offline map tile downloads. +/// +/// Accessible from the Settings screen. The underlying [OfflineMapService] +/// lives at the app level (via Provider), so downloads continue even after +/// navigating away from this screen. A system notification shows progress. +class OfflineMapsScreen extends StatefulWidget { + const OfflineMapsScreen({super.key}); + + @override + State createState() => _OfflineMapsScreenState(); +} + +class _OfflineMapsScreenState extends State { + @override + void initState() { + super.initState(); + // Listen for background download completions to show a toast. + final service = context.read(); + service.addListener(_onServiceUpdate); + } + + @override + void dispose() { + // Use try-catch in case the provider is already disposed during app teardown. + try { + context.read().removeListener(_onServiceUpdate); + } catch (_) {} + super.dispose(); + } + + void _onServiceUpdate() { + if (!mounted) return; + final service = context.read(); + final completed = service.consumeLastCompletedName(); + if (completed != null) { + AppToast.success(context, '"$completed" downloaded'); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final service = context.watch(); + + return Scaffold( + appBar: AppBar( + toolbarHeight: 40, + title: const Text('Offline Maps', style: TextStyle(fontSize: 18)), + ), + body: !service.initialized + ? const Center(child: CircularProgressIndicator()) + : kIsWeb + ? _buildWebUnsupported(theme) + : ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), + children: [ + _buildStorageCard(context, service, theme, isDark), + const SizedBox(height: 8), + _buildDownloadedRegionsCard( + context, service, theme, isDark), + const SizedBox(height: 8), + if (service.isDownloading) + _buildDownloadProgressCard( + context, service, theme, isDark), + ], + ), + floatingActionButton: + (service.initialized && !kIsWeb && !service.isDownloading) + ? FloatingActionButton.extended( + onPressed: () => _showDownloadDialog(context), + icon: const Icon(Icons.download), + label: const Text('Download Region'), + ) + : null, + ); + } + + Widget _buildWebUnsupported(ThemeData theme) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_off, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + 'Offline maps are not available on web', + style: theme.textTheme.titleMedium + ?.copyWith(color: Colors.grey.shade500), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Use the mobile app to download map regions for offline use', + style: theme.textTheme.bodySmall + ?.copyWith(color: Colors.grey.shade400), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + // ────────────────────────────────────────────── + // Storage usage card + // ────────────────────────────────────────────── + + Widget _buildStorageCard(BuildContext context, OfflineMapService service, + ThemeData theme, bool isDark) { + final usageRatio = service.usageRatio; + final barColor = usageRatio > 0.9 + ? Colors.red + : usageRatio > 0.7 + ? Colors.orange + : theme.colorScheme.primary; + + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Storage', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: () => _showStorageLimitDialog(context, service), + icon: const Icon(Icons.tune, size: 16), + label: const Text('Limit'), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Usage bar + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: usageRatio, + minHeight: 20, + backgroundColor: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.grey.shade200, + color: barColor, + ), + ), + const SizedBox(height: 8), + + // Usage text + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${service.totalUsedDisplay} used', + style: theme.textTheme.bodySmall?.copyWith( + color: barColor, + fontWeight: FontWeight.w600, + ), + ), + Text( + '${service.storageLimitDisplay} limit', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + '${service.regions.length} region${service.regions.length == 1 ? '' : 's'} downloaded', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey.shade500, + fontSize: 11, + ), + ), + ], + ), + ), + ); + } + + // ────────────────────────────────────────────── + // Downloaded regions list + // ────────────────────────────────────────────── + + Widget _buildDownloadedRegionsCard(BuildContext context, + OfflineMapService service, ThemeData theme, bool isDark) { + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 0), + child: Row( + children: [ + Text( + 'Downloaded Regions', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + if (service.regions.isNotEmpty) + IconButton( + icon: const Icon(Icons.refresh, size: 20), + onPressed: () => service.refreshRegions(), + tooltip: 'Refresh', + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + if (service.regions.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + children: [ + Icon(Icons.map_outlined, + size: 48, color: Colors.grey.shade400), + const SizedBox(height: 8), + Text( + 'No offline regions downloaded', + style: TextStyle(color: Colors.grey.shade500), + ), + const SizedBox(height: 4), + Text( + 'Tap "Download Region" to save map tiles for offline use', + style: TextStyle(fontSize: 12, color: Colors.grey.shade400), + textAlign: TextAlign.center, + ), + ], + ), + ) + else + ...service.regions.map( + (region) => _RegionTile( + region: region, + onDelete: () => _confirmDeleteRegion(context, service, region), + ), + ), + if (service.regions.length > 1) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: OutlinedButton.icon( + onPressed: () => _confirmDeleteAll(context, service), + icon: const Icon(Icons.delete_sweep, size: 18), + label: const Text('Delete All'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide(color: Colors.red, width: 0.5), + minimumSize: const Size.fromHeight(36), + visualDensity: VisualDensity.compact, + ), + ), + ), + const SizedBox(height: 4), + ], + ), + ); + } + + // ────────────────────────────────────────────── + // Download progress card + // ────────────────────────────────────────────── + + Widget _buildDownloadProgressCard(BuildContext context, + OfflineMapService service, ThemeData theme, bool isDark) { + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Downloading', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + service.downloadingRegionName ?? 'Region', + style: theme.textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: service.downloadProgress ?? 0, + minHeight: 8, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Text( + '${((service.downloadProgress ?? 0) * 100).round()}%', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Download continues in the background if you leave this screen', + style: TextStyle(fontSize: 11, color: Colors.grey.shade500), + ), + ], + ), + ), + ); + } + + // ────────────────────────────────────────────── + // Storage limit dialog + // ────────────────────────────────────────────── + + Future _showStorageLimitDialog( + BuildContext context, OfflineMapService service) async { + int currentLimit = service.storageLimitMb; + + final result = await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setDialogState) { + return AlertDialog( + title: const Text('Storage Limit'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$currentLimit MB', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 16), + Slider( + value: currentLimit.toDouble(), + min: OfflineMapService.minStorageLimitMb.toDouble(), + max: OfflineMapService.maxStorageLimitMb.toDouble(), + divisions: (OfflineMapService.maxStorageLimitMb - + OfflineMapService.minStorageLimitMb) ~/ + 50, + label: '$currentLimit MB', + onChanged: (value) { + setDialogState(() => currentLimit = value.round()); + }, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${OfflineMapService.minStorageLimitMb} MB', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + '${OfflineMapService.maxStorageLimitMb} MB', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Currently using ${service.totalUsedDisplay}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, currentLimit), + child: const Text('Save'), + ), + ], + ); + }, + ); + }, + ); + + if (result != null) { + await service.setStorageLimit(result); + if (mounted) { + AppToast.success(context, 'Storage limit set to $result MB'); + } + } + } + + // ────────────────────────────────────────────── + // Download new region dialog + // ────────────────────────────────────────────── + + Future _showDownloadDialog(BuildContext context) async { + final started = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const _DownloadRegionPage(), + ), + ); + + // Toast is handled by the _onServiceUpdate listener when the download + // completes (which may happen long after this page returns). + if (started == true && mounted) { + AppToast.simple( + context, 'Download started — check notifications for progress'); + } + } + + // ────────────────────────────────────────────── + // Delete confirmations + // ────────────────────────────────────────────── + + Future _confirmDeleteRegion(BuildContext context, + OfflineMapService service, OfflineMapRegion region) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete Region?'), + content: Text( + 'Delete "${region.name}"? This will free approximately ' + '${region.sizeDisplay} of storage.\n\n' + 'Note: shared tiles used by other regions may not be freed immediately.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true) { + final success = await service.deleteRegion(region.id); + if (mounted) { + if (success) { + AppToast.success(context, '"${region.name}" deleted'); + } else { + AppToast.error( + context, service.lastError ?? 'Failed to delete region'); + } + } + } + } + + Future _confirmDeleteAll( + BuildContext context, OfflineMapService service) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete All Regions?'), + content: Text( + 'Delete all ${service.regions.length} downloaded regions? ' + 'This will free approximately ${service.totalUsedDisplay}.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Delete All'), + ), + ], + ), + ); + + if (confirmed == true) { + await service.deleteAllRegions(); + if (mounted) { + AppToast.success(context, 'All regions deleted'); + } + } + } +} + +// ═══════════════════════════════════════════════ +// Region list tile +// ═══════════════════════════════════════════════ + +class _RegionTile extends StatelessWidget { + final OfflineMapRegion region; + final VoidCallback onDelete; + + const _RegionTile({required this.region, required this.onDelete}); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: _styleIcon(region.styleName), + title: Text( + region.name, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${region.styleName} · z${region.minZoom.round()}-${region.maxZoom.round()} · ${region.sizeDisplay}\n' + '${region.boundsDisplay}', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + isThreeLine: true, + trailing: IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20), + onPressed: onDelete, + tooltip: 'Delete', + ), + ); + } + + Widget _styleIcon(String styleName) { + switch (styleName.toLowerCase()) { + case 'dark': + return const Icon(Icons.dark_mode); + case 'light': + return const Icon(Icons.light_mode); + case 'satellite': + return const Icon(Icons.satellite_alt); + case 'liberty': + default: + return const Icon(Icons.map); + } + } +} + +// ═══════════════════════════════════════════════ +// Download region flow (full-page) +// ═══════════════════════════════════════════════ + +class _DownloadRegionPage extends StatefulWidget { + const _DownloadRegionPage(); + + @override + State<_DownloadRegionPage> createState() => _DownloadRegionPageState(); +} + +class _DownloadRegionPageState extends State<_DownloadRegionPage> { + final _nameController = TextEditingController(); + String _selectedStyle = 'Liberty'; + double _minZoom = 6; + double _maxZoom = 14; + bool _submitting = false; + String? _error; + + // Bounds selection via interactive map + MapLibreMapController? _mapController; + LatLng? _boundsNE; + LatLng? _boundsSW; + int _tapCount = 0; + Line? _boundsLine; + Fill? _boundsFill; + + // Existing region overlays + final List _existingFills = []; + final List _existingLines = []; + bool _showExisting = true; + + @override + void initState() { + super.initState(); + // grab user's current map style to start + final pref = context.read().preferences.mapStyle; + final mapped = pref.substring(0, 1).toUpperCase() + pref.substring(1); + if (_downloadStyles.containsKey(mapped)) { + _selectedStyle = mapped; + } + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + LatLngBounds? get _selectedBounds { + if (_boundsNE == null || _boundsSW == null) return null; + return LatLngBounds(southwest: _boundsSW!, northeast: _boundsNE!); + } + + bool get _canSubmit => + _nameController.text.trim().isNotEmpty && + _selectedBounds != null && + !_submitting; + + int get _estimatedTiles { + final bounds = _selectedBounds; + if (bounds == null) return 0; + return OfflineMapService.estimateTileCount(bounds, _minZoom, _maxZoom); + } + + String get _estimatedSize { + final bytes = OfflineMapService.estimateSizeBytes(_estimatedTiles); + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(0)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return Scaffold( + appBar: AppBar( + toolbarHeight: 40, + title: const Text('Download Region', style: TextStyle(fontSize: 18)), + ), + body: Column( + children: [ + // Map for bounds selection + Expanded( + flex: 3, + child: Stack( + children: [ + MapLibreMap( + styleString: _downloadStyles[_selectedStyle]!, + initialCameraPosition: const CameraPosition( + target: LatLng(49.28, -123.12), // Vancouver default + zoom: 10, + ), + onMapCreated: (controller) { + _mapController = controller; + }, + onStyleLoadedCallback: () { + if (_showExisting) _drawExistingRegions(); + }, + onMapClick: _onMapTap, + rotateGesturesEnabled: false, + tiltGesturesEnabled: false, + ), + // Bounds instruction overlay + Positioned( + top: 8, + left: 8, + right: 8, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: (isDark ? Colors.black : Colors.white) + .withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _selectedBounds != null + ? 'Region selected · ~$_estimatedTiles tiles · $_estimatedSize' + : _tapCount == 1 + ? 'Tap the opposite corner to complete the region' + : 'Tap two corners on the map to select a region', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ), + // Reset bounds button + if (_selectedBounds != null) + Positioned( + top: 8, + right: 8, + child: Material( + type: MaterialType.circle, + color: theme.colorScheme.primaryContainer, + elevation: 2, + child: InkWell( + customBorder: const CircleBorder(), + onTap: _resetBounds, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(Icons.restart_alt, + size: 20, + color: theme.colorScheme.onPrimaryContainer), + ), + ), + ), + ), + // Existing regions toggle + Positioned( + bottom: 8, + left: 8, + child: Material( + borderRadius: BorderRadius.circular(16), + color: (isDark ? Colors.black : Colors.white) + .withValues(alpha: 0.85), + elevation: 2, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: _toggleExistingRegions, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _showExisting + ? Icons.visibility + : Icons.visibility_off, + size: 14, + color: const Color(0xFFF59E0B), + ), + const SizedBox(width: 4), + Text( + '${context.read().regions.length} existing', + style: theme.textTheme.bodySmall?.copyWith( + fontSize: 11, + color: _showExisting + ? const Color(0xFFF59E0B) + : Colors.grey, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + + // Configuration panel + Expanded( + flex: 2, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Region name + TextField( + controller: _nameController, + decoration: InputDecoration( + labelText: 'Region Name', + hintText: 'e.g. Downtown Vancouver', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8)), + isDense: true, + prefixIcon: const Icon(Icons.label_outline, size: 20), + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + + // Style selector + Row( + children: [ + const Text('Style: ', + style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(width: 8), + Expanded( + child: SegmentedButton( + segments: _downloadStyles.keys + .map((s) => ButtonSegment( + value: s, + label: Text(s, + style: const TextStyle(fontSize: 12)), + )) + .toList(), + selected: {_selectedStyle}, + onSelectionChanged: (selected) { + setState(() => _selectedStyle = selected.first); + }, + style: SegmentedButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Zoom range + Row( + children: [ + Text( + 'Zoom: ${_minZoom.round()} – ${_maxZoom.round()}', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + const Spacer(), + Text( + '~$_estimatedTiles tiles', + style: TextStyle( + fontSize: 12, color: Colors.grey.shade500), + ), + ], + ), + RangeSlider( + values: RangeValues(_minZoom, _maxZoom), + min: 0, + max: 18, + divisions: 18, + labels: RangeLabels( + _minZoom.round().toString(), + _maxZoom.round().toString(), + ), + onChanged: (values) { + setState(() { + _minZoom = values.start; + _maxZoom = values.end; + }); + }, + ), + + if (_error != null) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.red.withValues(alpha: 0.3)), + ), + child: Text( + _error!, + style: const TextStyle(color: Colors.red, fontSize: 12), + ), + ), + ], + + const SizedBox(height: 12), + + // Download button + FilledButton.icon( + onPressed: _canSubmit ? _startDownload : null, + icon: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.download), + label: + Text(_submitting ? 'Starting...' : 'Download Region'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(44), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + void _onMapTap(Point point, LatLng coordinates) { + setState(() { + if (_tapCount == 0) { + _boundsSW = coordinates; + _boundsNE = null; + _tapCount = 1; + _clearBoundsOverlay(); + } else if (_tapCount == 1) { + // Ensure SW is actually southwest and NE is northeast + final lat1 = _boundsSW!.latitude; + final lng1 = _boundsSW!.longitude; + final lat2 = coordinates.latitude; + final lng2 = coordinates.longitude; + + _boundsSW = LatLng( + lat1 < lat2 ? lat1 : lat2, + lng1 < lng2 ? lng1 : lng2, + ); + _boundsNE = LatLng( + lat1 > lat2 ? lat1 : lat2, + lng1 > lng2 ? lng1 : lng2, + ); + _tapCount = 2; + _drawBoundsOverlay(); + } + }); + } + + void _resetBounds() { + _clearBoundsOverlay(); + setState(() { + _boundsSW = null; + _boundsNE = null; + _tapCount = 0; + }); + } + + /// Draw outlines for all previously downloaded regions so the user + /// can see existing coverage while selecting a new area. + Future _drawExistingRegions() async { + if (_mapController == null) return; + final service = context.read(); + for (final region in service.regions) { + try { + final sw = region.bounds.southwest; + final ne = region.bounds.northeast; + final nw = LatLng(ne.latitude, sw.longitude); + final se = LatLng(sw.latitude, ne.longitude); + final ring = [sw, se, ne, nw, sw]; + + final fill = await _mapController!.addFill(FillOptions( + geometry: [ring], + fillColor: '#F59E0B', // amber-500 + fillOpacity: 0.10, + )); + final line = await _mapController!.addLine(LineOptions( + geometry: ring, + lineColor: '#F59E0B', + lineWidth: 1.5, + lineOpacity: 0.6, + )); + _existingFills.add(fill); + _existingLines.add(line); + } catch (e) { + debugPrint( + '[OFFLINE_MAP] Failed to draw existing region ${region.name}: $e'); + } + } + } + + Future _clearExistingRegions() async { + if (_mapController == null) return; + for (final f in _existingFills) { + try { + await _mapController!.removeFill(f); + } catch (_) {} + } + for (final l in _existingLines) { + try { + await _mapController!.removeLine(l); + } catch (_) {} + } + _existingFills.clear(); + _existingLines.clear(); + } + + void _toggleExistingRegions() { + setState(() => _showExisting = !_showExisting); + if (_showExisting) { + _drawExistingRegions(); + } else { + _clearExistingRegions(); + } + } + + Future _drawBoundsOverlay() async { + if (_mapController == null || _boundsSW == null || _boundsNE == null) + return; + + final sw = _boundsSW!; + final ne = _boundsNE!; + final nw = LatLng(ne.latitude, sw.longitude); + final se = LatLng(sw.latitude, ne.longitude); + final ring = [sw, se, ne, nw, sw]; + + try { + _boundsFill = await _mapController!.addFill(FillOptions( + geometry: [ring], + fillColor: '#4A90D9', + fillOpacity: 0.15, + )); + _boundsLine = await _mapController!.addLine(LineOptions( + geometry: ring, + lineColor: '#4A90D9', + lineWidth: 2.0, + lineOpacity: 0.8, + )); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to draw bounds overlay: $e'); + } + } + + Future _clearBoundsOverlay() async { + if (_mapController == null) return; + try { + if (_boundsFill != null) { + await _mapController!.removeFill(_boundsFill!); + _boundsFill = null; + } + if (_boundsLine != null) { + await _mapController!.removeLine(_boundsLine!); + _boundsLine = null; + } + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to clear bounds overlay: $e'); + } + } + + Future _startDownload() async { + final bounds = _selectedBounds; + if (bounds == null) return; + + final name = _nameController.text.trim(); + if (name.isEmpty) return; + + final service = context.read(); + final styleUrl = _downloadStyles[_selectedStyle]!; + + // Check storage limit + final estBytes = OfflineMapService.estimateSizeBytes(_estimatedTiles); + if (service.wouldExceedLimit(estBytes)) { + setState(() { + _error = 'This download would exceed your storage limit. ' + 'Free up space or increase the limit in storage settings.'; + }); + return; + } + + setState(() { + _submitting = true; + _error = null; + }); + + // Fire-and-forget: the service runs the download in the background + // and shows a system notification for progress. We just kick it off + // and return to the management screen. + service.downloadRegion( + name: name, + bounds: bounds, + styleUrl: styleUrl, + styleName: _selectedStyle, + minZoom: _minZoom, + maxZoom: _maxZoom, + ); + + // Give the service a tick to validate and start + await Future.delayed(const Duration(milliseconds: 100)); + + if (!mounted) return; + + if (service.lastError != null && !service.isDownloading) { + setState(() { + _submitting = false; + _error = service.lastError; + }); + } else { + // Download is queued — return to the management screen + Navigator.pop(context, true); + } + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 4cfe78e..bfd2832 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -29,6 +29,7 @@ import '../widgets/bug_report_dialog.dart'; import '../widgets/upload_logs_dialog.dart'; import 'package:intl/intl.dart'; import '../widgets/app_toast.dart'; +import 'offline_maps_screen.dart'; /// Settings screen for user preferences and API configuration class SettingsScreen extends StatefulWidget { @@ -154,12 +155,27 @@ class _SettingsScreenState extends State { title: const Text('Disable Map Tiles'), subtitle: Text(prefs.mapTilesEnabled ? 'Map and coverage tiles load normally' - : 'Disabled to save mobile data'), + : 'Network tiles disabled · downloaded regions still visible'), value: !prefs.mapTilesEnabled, onChanged: (value) { appState.updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); }, ), + if (!kIsWeb) + ListTile( + leading: const Icon(Icons.download_for_offline), + title: const Text('Offline Maps'), + subtitle: const Text('Download map tiles for offline use'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const OfflineMapsScreen(), + ), + ); + }, + ), if (prefs.mapTilesEnabled) ListTile( leading: const Icon(Icons.opacity), diff --git a/lib/services/offline_map_service.dart b/lib/services/offline_map_service.dart new file mode 100644 index 0000000..56df1f9 --- /dev/null +++ b/lib/services/offline_map_service.dart @@ -0,0 +1,534 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Metadata keys stored with each offline region. +class _MetaKeys { + _MetaKeys._(); + static const name = 'name'; + static const styleName = 'styleName'; + static const createdAt = 'createdAt'; + + /// Estimated size in bytes (rough heuristic based on tile count). + static const estimatedBytes = 'estimatedBytes'; +} + +/// A user-friendly wrapper around a raw [OfflineRegion]. +class OfflineMapRegion { + final int id; + final String name; + final String styleName; + final LatLngBounds bounds; + final double minZoom; + final double maxZoom; + final DateTime createdAt; + final int estimatedBytes; + + const OfflineMapRegion({ + required this.id, + required this.name, + required this.styleName, + required this.bounds, + required this.minZoom, + required this.maxZoom, + required this.createdAt, + required this.estimatedBytes, + }); + + factory OfflineMapRegion.fromOfflineRegion(OfflineRegion region) { + final meta = region.metadata; + return OfflineMapRegion( + id: region.id, + name: (meta[_MetaKeys.name] as String?) ?? 'Region ${region.id}', + styleName: (meta[_MetaKeys.styleName] as String?) ?? 'Unknown', + bounds: region.definition.bounds, + minZoom: region.definition.minZoom, + maxZoom: region.definition.maxZoom, + createdAt: DateTime.tryParse( + (meta[_MetaKeys.createdAt] as String?) ?? '') ?? + DateTime.now(), + // Platform channel JSON round-trip can return int as num/double. + estimatedBytes: (meta[_MetaKeys.estimatedBytes] as num?)?.toInt() ?? 0, + ); + } + + /// Human-readable size string. + String get sizeDisplay { + if (estimatedBytes < 1024) return '$estimatedBytes B'; + if (estimatedBytes < 1024 * 1024) { + return '${(estimatedBytes / 1024).toStringAsFixed(1)} KB'; + } + return '${(estimatedBytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + /// Short bounds description (e.g. "49.2°N, 123.1°W"). + String get boundsDisplay { + final sw = bounds.southwest; + final ne = bounds.northeast; + final latCenter = (sw.latitude + ne.latitude) / 2; + final lngCenter = (sw.longitude + ne.longitude) / 2; + final latDir = latCenter >= 0 ? 'N' : 'S'; + final lngDir = lngCenter >= 0 ? 'E' : 'W'; + return '${latCenter.abs().toStringAsFixed(2)}°$latDir, ' + '${lngCenter.abs().toStringAsFixed(2)}°$lngDir'; + } +} + +/// Manages offline map tile downloads, listing, deletion, and storage limits. +/// +/// Lives at the app level (provided via [ChangeNotifierProvider] in main.dart) +/// so downloads continue when the user navigates away from the Offline Maps +/// screen. A system notification shows real-time progress. +/// +/// Not available on web (maplibre_gl offline APIs are mobile-only). +class OfflineMapService extends ChangeNotifier { + static const _storageLimitKey = 'offline_map_storage_limit_mb'; + static const int defaultStorageLimitMb = 500; + static const int minStorageLimitMb = 50; + static const int maxStorageLimitMb = 5000; + + // ── Notification constants ── + static const String _notifChannelId = 'meshmapper_offline_maps'; + static const String _notifChannelName = 'Offline Map Downloads'; + static const int _progressNotifId = 889; + static const int _completeNotifId = 890; + + final FlutterLocalNotificationsPlugin _notifPlugin = + FlutterLocalNotificationsPlugin(); + bool _notifInitialized = false; + + // ── Region state ── + List _regions = []; + List get regions => List.unmodifiable(_regions); + + int _storageLimitMb = defaultStorageLimitMb; + int get storageLimitMb => _storageLimitMb; + int get storageLimitBytes => _storageLimitMb * 1024 * 1024; + + /// Total estimated bytes across all downloaded regions. + int get totalUsedBytes => + _regions.fold(0, (sum, r) => sum + r.estimatedBytes); + + double get usageRatio { + if (storageLimitBytes == 0) return 0; + return (totalUsedBytes / storageLimitBytes).clamp(0.0, 1.0); + } + + String get totalUsedDisplay => _formatBytes(totalUsedBytes); + String get storageLimitDisplay => '$_storageLimitMb MB'; + + // ── Download state ── + + /// Currently active download progress (null if idle). + double? _downloadProgress; + double? get downloadProgress => _downloadProgress; + + String? _downloadingRegionName; + String? get downloadingRegionName => _downloadingRegionName; + + bool get isDownloading => _downloadProgress != null; + + String? _lastError; + String? get lastError => _lastError; + + /// Name of the most recently completed download (for one-shot UI toast). + /// Call [consumeLastCompletedName] to read and clear. + String? _lastCompletedName; + String? consumeLastCompletedName() { + final name = _lastCompletedName; + _lastCompletedName = null; + return name; + } + + bool _initialized = false; + bool get initialized => _initialized; + + // ── Initialization ── + + /// Initialize: create notification channel, load storage limit, refresh list. + /// Safe to call multiple times — subsequent calls are no-ops. + Future initialize() async { + if (kIsWeb) return; + if (_initialized) return; + try { + await _initNotifications(); + final prefs = await SharedPreferences.getInstance(); + _storageLimitMb = + prefs.getInt(_storageLimitKey) ?? defaultStorageLimitMb; + await refreshRegions(); + _initialized = true; + notifyListeners(); + } catch (e) { + debugPrint('[OFFLINE_MAP] Init error: $e'); + _initialized = true; + notifyListeners(); + } + } + + /// Set up the Android notification channel for download progress. + Future _initNotifications() async { + if (_notifInitialized) return; + try { + const AndroidNotificationChannel channel = AndroidNotificationChannel( + _notifChannelId, + _notifChannelName, + description: 'Shows progress when downloading offline map tiles', + importance: Importance.low, // No sound/vibration + showBadge: false, + ); + + await _notifPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.createNotificationChannel(channel); + + // Initialize the plugin (required before showing notifications). + const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher'); + const iosInit = DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ); + await _notifPlugin.initialize( + const InitializationSettings(android: androidInit, iOS: iosInit), + ); + _notifInitialized = true; + } catch (e) { + debugPrint('[OFFLINE_MAP] Notification init error: $e'); + } + } + + // ── Notifications ── + + Future _showProgressNotification(String regionName, int percent) async { + if (!_notifInitialized) return; + try { + final androidDetails = AndroidNotificationDetails( + _notifChannelId, + _notifChannelName, + channelDescription: 'Shows progress when downloading offline map tiles', + importance: Importance.low, + priority: Priority.low, + showProgress: true, + maxProgress: 100, + progress: percent, + ongoing: true, // Non-dismissable while downloading + autoCancel: false, + onlyAlertOnce: true, // Don't buzz on every update + icon: '@mipmap/ic_launcher', + ); + + await _notifPlugin.show( + _progressNotifId, + 'Downloading "$regionName"', + '$percent% complete', + NotificationDetails(android: androidDetails), + ); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to show progress notification: $e'); + } + } + + Future _showCompleteNotification(String regionName) async { + await _dismissProgressNotification(); + if (!_notifInitialized) return; + try { + const androidDetails = AndroidNotificationDetails( + _notifChannelId, + _notifChannelName, + channelDescription: 'Shows progress when downloading offline map tiles', + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + icon: '@mipmap/ic_launcher', + ); + + await _notifPlugin.show( + _completeNotifId, + 'Download Complete', + '"$regionName" is ready for offline use', + const NotificationDetails(android: androidDetails), + ); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to show complete notification: $e'); + } + } + + Future _showErrorNotification(String regionName) async { + await _dismissProgressNotification(); + if (!_notifInitialized) return; + try { + const androidDetails = AndroidNotificationDetails( + _notifChannelId, + _notifChannelName, + channelDescription: 'Shows progress when downloading offline map tiles', + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + icon: '@mipmap/ic_launcher', + ); + + await _notifPlugin.show( + _completeNotifId, + 'Download Failed', + 'Failed to download "$regionName"', + const NotificationDetails(android: androidDetails), + ); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to show error notification: $e'); + } + } + + Future _dismissProgressNotification() async { + try { + await _notifPlugin.cancel(_progressNotifId); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to dismiss notification: $e'); + } + } + + // ── Region queries ── + + /// Refresh the list of downloaded regions from MapLibre native storage. + Future refreshRegions() async { + if (kIsWeb) return; + try { + final rawRegions = await getListOfRegions(); + final parsed = []; + for (final r in rawRegions) { + try { + parsed.add(OfflineMapRegion.fromOfflineRegion(r)); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to parse region ${r.id}: $e'); + } + } + _regions = parsed; + _regions.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + notifyListeners(); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to list regions: $e'); + } + } + + // ── Storage limit ── + + /// Update the storage limit (in MB) and persist it. + Future setStorageLimit(int limitMb) async { + _storageLimitMb = limitMb.clamp(minStorageLimitMb, maxStorageLimitMb); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_storageLimitKey, _storageLimitMb); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to save storage limit: $e'); + } + notifyListeners(); + } + + // ── Tile estimation ── + + /// Estimate tile count for a region (rough heuristic). + /// Uses the standard 2^z tile count formula for each zoom level. + static int estimateTileCount( + LatLngBounds bounds, double minZoom, double maxZoom) { + int total = 0; + for (int z = minZoom.floor(); z <= maxZoom.ceil(); z++) { + final tilesPerSide = 1 << z; // 2^z + final lonFraction = (bounds.northeast.longitude - + bounds.southwest.longitude) + .abs() / + 360.0; + final latFraction = + (bounds.northeast.latitude - bounds.southwest.latitude).abs() / + 180.0; + final xTiles = (lonFraction * tilesPerSide).ceil().clamp(1, tilesPerSide); + final yTiles = (latFraction * tilesPerSide).ceil().clamp(1, tilesPerSide); + total += xTiles * yTiles; + } + return total; + } + + /// Rough estimate of download size in bytes from tile count. + /// Vector tiles average ~15-25 KB each; raster tiles ~20-40 KB. + /// We use 20 KB as a middle estimate. + static int estimateSizeBytes(int tileCount) => tileCount * 20 * 1024; + + /// Check if downloading a region of [estimatedBytes] would exceed the limit. + bool wouldExceedLimit(int estimatedBytes) => + (totalUsedBytes + estimatedBytes) > storageLimitBytes; + + // ── Download ── + + /// Download an offline region. + /// + /// The download runs in MapLibre's native layer, so it survives Flutter + /// screen navigation. This service (kept alive by the app-level Provider) + /// receives progress callbacks and forwards them to both [notifyListeners] + /// and a system notification. + /// + /// Returns the new [OfflineMapRegion] on success, null on failure. + Future downloadRegion({ + required String name, + required LatLngBounds bounds, + required String styleUrl, + required String styleName, + double minZoom = 0, + double maxZoom = 14, + }) async { + if (kIsWeb) return null; + if (isDownloading) { + _lastError = 'A download is already in progress'; + notifyListeners(); + return null; + } + + final tileCount = estimateTileCount(bounds, minZoom, maxZoom); + final estBytes = estimateSizeBytes(tileCount); + + if (wouldExceedLimit(estBytes)) { + _lastError = + 'Download would exceed storage limit (${_formatBytes(estBytes)} needed, ' + '${_formatBytes(storageLimitBytes - totalUsedBytes)} remaining)'; + notifyListeners(); + return null; + } + + _downloadProgress = 0; + _downloadingRegionName = name; + _lastError = null; + _lastCompletedName = null; + notifyListeners(); + _showProgressNotification(name, 0); + + try { + final definition = OfflineRegionDefinition( + bounds: bounds, + mapStyleUrl: styleUrl, + minZoom: minZoom, + maxZoom: maxZoom, + ); + + final metadata = { + _MetaKeys.name: name, + _MetaKeys.styleName: styleName, + _MetaKeys.createdAt: DateTime.now().toIso8601String(), + _MetaKeys.estimatedBytes: estBytes, + }; + + final region = await downloadOfflineRegion( + definition, + metadata: metadata, + onEvent: _onDownloadEvent, + ); + + // downloadOfflineRegion resolves once the native download is queued, + // not necessarily when it finishes. The _onDownloadEvent callback + // handles completion. But if progress is already null (Success fired + // synchronously), the download completed inline. + if (_downloadProgress != null) { + // Still in progress — the event callback will finalize. + return null; + } + + // Completed synchronously (small region / cached tiles) + _downloadProgress = null; + _downloadingRegionName = null; + _lastCompletedName = name; + await refreshRegions(); + _showCompleteNotification(name); + return _regions.firstWhere((r) => r.id == region.id, + orElse: () => OfflineMapRegion.fromOfflineRegion(region)); + } catch (e) { + _downloadProgress = null; + _downloadingRegionName = null; + _lastError = 'Download failed: $e'; + notifyListeners(); + _showErrorNotification(name); + return null; + } + } + + void _onDownloadEvent(DownloadRegionStatus status) { + if (status is Success) { + final name = _downloadingRegionName ?? 'Region'; + _downloadProgress = null; + _downloadingRegionName = null; + _lastCompletedName = name; + notifyListeners(); // Immediately clear progress state + _showCompleteNotification(name); + // Small delay lets the native DB commit before we query it. + Future.delayed(const Duration(milliseconds: 500), () { + refreshRegions(); + }); + } else if (status is InProgress) { + _downloadProgress = status.progress / 100.0; + notifyListeners(); + // Throttle notification updates to every 2% to avoid flooding + final percent = status.progress.round(); + if (percent % 2 == 0) { + _showProgressNotification( + _downloadingRegionName ?? 'Region', percent); + } + } else { + // Error status + final name = _downloadingRegionName ?? 'Region'; + _downloadProgress = null; + _downloadingRegionName = null; + _lastError = 'Download error occurred'; + _showErrorNotification(name); + notifyListeners(); + } + } + + // ── Deletion ── + + /// Delete a downloaded region by ID. + Future deleteRegion(int regionId) async { + if (kIsWeb) return false; + try { + await deleteOfflineRegion(regionId); + await refreshRegions(); + return true; + } catch (e) { + debugPrint('[OFFLINE_MAP] Delete failed: $e'); + _lastError = 'Failed to delete region: $e'; + notifyListeners(); + return false; + } + } + + /// Delete all downloaded regions. + Future deleteAllRegions() async { + if (kIsWeb) return; + final ids = _regions.map((r) => r.id).toList(); + for (final id in ids) { + try { + await deleteOfflineRegion(id); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to delete region $id: $e'); + } + } + await refreshRegions(); + } + + // ── Cleanup ── + + /// Cancel any stale progress notification from a previous session. + /// Called at app startup (mirrors BackgroundServiceManager.cleanupOrphanedService). + Future cleanupOrphanedNotification() async { + if (kIsWeb) return; + try { + await _initNotifications(); + await _notifPlugin.cancel(_progressNotifId); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to cleanup orphaned notification: $e'); + } + } + + static String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 848f432..8496e19 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -383,6 +383,10 @@ class _MapWidgetState extends State { // Tile load failure detection — shows a banner if map tiles haven't loaded // within a timeout after style load. Cleared when onMapIdle fires. bool _tileLoadFailed = false; + + /// Tracks the last-applied mapTilesEnabled value so we can detect changes + /// in _buildMap and call setOffline() without a full style reload. + bool? _lastMapTilesEnabled; Timer? _tileLoadTimeoutTimer; static const _tileLoadTimeoutSeconds = 8; @@ -1038,10 +1042,23 @@ class _MapWidgetState extends State { Widget _buildMap(AppStateProvider appState, LatLng center) { final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); - // When mapTilesEnabled is false, use a blank style (just background) to save mobile data - final newStyleUrl = appState.preferences.mapTilesEnabled - ? mapStyle.styleUrl - : _blankStyleJson; + // Always use the real style so downloaded offline tiles can render from + // cache. Network access is controlled via setOffline() instead. + final newStyleUrl = mapStyle.styleUrl; + + // Detect mapTilesEnabled toggle changes and switch MapLibre between + // online (network tiles) and offline (cache-only) mode. This avoids + // a full style reload — the same style stays loaded but MapLibre stops + // or starts making network requests for tiles. + final tilesEnabled = appState.preferences.mapTilesEnabled; + if (_lastMapTilesEnabled != tilesEnabled && _isMapReady) { + _lastMapTilesEnabled = tilesEnabled; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setOffline(!tilesEnabled); + debugPrint('[MAP] setOffline(${!tilesEnabled}) — tiles ${tilesEnabled ? "enabled" : "disabled (cache only)"}'); + }); + } // Style changes flow through MapLibreMap.styleString — the plugin's // didUpdateWidget detects the new value and fires a native setStyle. @@ -1412,8 +1429,14 @@ class _MapWidgetState extends State { // Start tile-load timeout. If onMapIdle doesn't fire within N seconds, // we assume tiles are failing to load (network down, server error, etc.) // and surface a banner. Cleared as soon as onMapIdle fires. + // When tiles are disabled (cache-only mode), suppress the warning — cached + // tiles load instantly or not at all; a timeout would be misleading. _tileLoadTimeoutTimer?.cancel(); - if (appState.preferences.mapTilesEnabled) { + final tilesEnabled = appState.preferences.mapTilesEnabled; + _lastMapTilesEnabled = tilesEnabled; + // Ensure MapLibre offline mode matches the user's preference. + setOffline(!tilesEnabled); + if (tilesEnabled) { _tileLoadFailed = false; _tileLoadTimeoutTimer = Timer(const Duration(seconds: _tileLoadTimeoutSeconds), () { if (mounted && !_tileLoadFailed) { @@ -1422,7 +1445,7 @@ class _MapWidgetState extends State { } }); } else { - // Blank style — never show the warning + // Cache-only mode — never show the tile-load warning _tileLoadFailed = false; } From 26c67f555132a71f1e460377dc390c6df2e8888d Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Wed, 15 Apr 2026 12:10:11 -0700 Subject: [PATCH 04/10] format to match dev --- bin/test_message.dart | 42 +- lib/main.dart | 49 +- lib/models/api_queue_item.dart | 21 +- lib/models/connection_state.dart | 32 +- lib/models/device_model.dart | 15 +- lib/models/log_entry.dart | 74 +- lib/models/noise_floor_session.dart | 10 +- lib/models/ping_data.dart | 15 +- lib/models/user_preferences.dart | 42 +- lib/providers/app_state_provider.dart | 1029 ++++++++----- lib/screens/connection_screen.dart | 278 ++-- lib/screens/graph_screen.dart | 18 +- lib/screens/home_screen.dart | 139 +- lib/screens/log_screen.dart | 370 +++-- lib/screens/main_scaffold.dart | 16 +- lib/screens/settings_screen.dart | 517 ++++--- lib/services/api_queue_service.dart | 99 +- lib/services/api_service.dart | 252 +-- lib/services/audio_service.dart | 18 +- lib/services/background_service.dart | 6 +- lib/services/bluetooth/mobile_bluetooth.dart | 89 +- lib/services/bluetooth/web_bluetooth.dart | 35 +- lib/services/countdown_timer_service.dart | 12 +- lib/services/custom_api_service.dart | 21 +- lib/services/debug_file_logger.dart | 4 +- lib/services/debug_submit_service.dart | 159 +- lib/services/device_model_service.dart | 13 +- lib/services/gps_service.dart | 70 +- lib/services/gps_simulator_service.dart | 85 +- lib/services/meshcore/buffer_utils.dart | 7 +- lib/services/meshcore/channel_service.dart | 45 +- lib/services/meshcore/connection.dart | 204 ++- lib/services/meshcore/crypto_service.dart | 95 +- lib/services/meshcore/disc_tracker.dart | 58 +- lib/services/meshcore/packet_metadata.dart | 35 +- lib/services/meshcore/packet_parser.dart | 4 +- lib/services/meshcore/packet_validator.dart | 55 +- lib/services/meshcore/protocol_constants.dart | 22 +- lib/services/meshcore/rx_logger.dart | 113 +- lib/services/meshcore/trace_tracker.dart | 32 +- lib/services/meshcore/tx_tracker.dart | 144 +- lib/services/meshcore/unified_rx_handler.dart | 19 +- lib/services/offline_map_service.dart | 22 +- lib/services/offline_session_service.dart | 49 +- .../permission_disclosure_service.dart | 14 +- lib/services/ping_service.dart | 178 ++- lib/utils/debug_logger.dart | 44 +- lib/utils/debug_logger_io.dart | 4 +- lib/utils/debug_logger_stub.dart | 28 +- lib/utils/ping_colors.dart | 88 +- lib/widgets/bug_report_dialog.dart | 533 ++++--- lib/widgets/connection_panel.dart | 30 +- lib/widgets/map_widget.dart | 1351 +++++++++++------ lib/widgets/noise_floor_chart.dart | 173 ++- lib/widgets/offline_mode_toggle.dart | 15 +- lib/widgets/ping_controls.dart | 885 +++++++---- lib/widgets/regional_config_card.dart | 52 +- lib/widgets/repeater_id_chip.dart | 54 +- lib/widgets/repeater_picker_sheet.dart | 24 +- lib/widgets/status_bar.dart | 104 +- lib/widgets/upload_logs_dialog.dart | 109 +- 61 files changed, 5311 insertions(+), 2809 deletions(-) diff --git a/bin/test_message.dart b/bin/test_message.dart index 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 2745c9c..22d67ee 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -120,7 +120,8 @@ Future _requestPermissions() async { Future _requestiOSPermissions() async { // Note: Location permission is now requested AFTER showing the prominent disclosure // dialog in MainScaffold (required for Google Play compliance) - debugLog('[APP] iOS: Skipping location permission (handled after disclosure)'); + debugLog( + '[APP] iOS: Skipping location permission (handled after disclosure)'); // Trigger Core Bluetooth authorization by checking adapter state // This will cause iOS to show the Bluetooth permission prompt if not already granted @@ -138,7 +139,8 @@ Future _requestiOSPermissions() async { .where((state) => state == fbp.BluetoothAdapterState.on) .first .timeout(const Duration(seconds: 3), onTimeout: () { - debugLog('[APP] iOS: Bluetooth authorization timeout (user may have denied or BT is off)'); + debugLog( + '[APP] iOS: Bluetooth authorization timeout (user may have denied or BT is off)'); return fbp.BluetoothAdapterState.off; }); } @@ -171,36 +173,39 @@ Future _requestAndroidPermissions() async { // Dark theme - Tailwind Slate palette const darkColorScheme = ColorScheme.dark( - primary: Color(0xFF059669), // emerald-600 (main actions) + primary: Color(0xFF059669), // emerald-600 (main actions) onPrimary: Colors.white, - secondary: Color(0xFF0284C7), // sky-600 (TX ping) + secondary: Color(0xFF0284C7), // sky-600 (TX ping) onSecondary: Colors.white, - tertiary: Color(0xFF4F46E5), // indigo-600 (auto modes) + tertiary: Color(0xFF4F46E5), // indigo-600 (auto modes) onTertiary: Colors.white, - surface: Color(0xFF1E293B), // slate-800 (cards/panels) - onSurface: Color(0xFFF1F5F9), // slate-100 (primary text) - onSurfaceVariant: Color(0xFFCBD5E1), // slate-300 (muted text, brighter for contrast) + surface: Color(0xFF1E293B), // slate-800 (cards/panels) + onSurface: Color(0xFFF1F5F9), // slate-100 (primary text) + onSurfaceVariant: + Color(0xFFCBD5E1), // slate-300 (muted text, brighter for contrast) surfaceContainerHighest: Color(0xFF0F172A), // slate-900 (main bg) - outline: Color(0xFF334155), // slate-700 (borders) - error: Color(0xFFF87171), // red-400 + outline: Color(0xFF334155), // slate-700 (borders) + error: Color(0xFFF87171), // red-400 onError: Colors.white, ); // Light theme - Tailwind Slate palette (inverted) // Note: Using darker grays for better text contrast const lightColorScheme = ColorScheme.light( - primary: Color(0xFF059669), // emerald-600 + primary: Color(0xFF059669), // emerald-600 onPrimary: Colors.white, - secondary: Color(0xFF0284C7), // sky-600 + secondary: Color(0xFF0284C7), // sky-600 onSecondary: Colors.white, - tertiary: Color(0xFF4F46E5), // indigo-600 + tertiary: Color(0xFF4F46E5), // indigo-600 onTertiary: Colors.white, - surface: Color(0xFFF8FAFC), // slate-50 (cards/panels) - onSurface: Color(0xFF0F172A), // slate-900 (primary text - darker for contrast) - onSurfaceVariant: Color(0xFF475569), // slate-600 (muted text - darker for readability) + surface: Color(0xFFF8FAFC), // slate-50 (cards/panels) + onSurface: + Color(0xFF0F172A), // slate-900 (primary text - darker for contrast) + onSurfaceVariant: + Color(0xFF475569), // slate-600 (muted text - darker for readability) surfaceContainerHighest: Color(0xFFFFFFFF), // white (main bg) - outline: Color(0xFFCBD5E1), // slate-300 (borders) - error: Color(0xFFDC2626), // red-600 + outline: Color(0xFFCBD5E1), // slate-300 (borders) + error: Color(0xFFDC2626), // red-600 onError: Colors.white, ); @@ -212,9 +217,8 @@ class MeshMapperApp extends StatelessWidget { @override Widget build(BuildContext context) { // Create platform-appropriate Bluetooth service - final BluetoothService bluetoothService = kIsWeb - ? WebBluetoothService() - : MobileBluetoothService(); + final BluetoothService bluetoothService = + kIsWeb ? WebBluetoothService() : MobileBluetoothService(); return MultiProvider( providers: [ @@ -269,7 +273,8 @@ class _ThemedAppState extends State<_ThemedApp> { scaffoldBackgroundColor: const Color(0xFFF1F5F9), // slate-100 appBarTheme: const AppBarTheme( backgroundColor: Color(0xFFF8FAFC), // slate-50 - foregroundColor: Color(0xFF0F172A), // slate-900 (darker for contrast) + foregroundColor: + Color(0xFF0F172A), // slate-900 (darker for contrast) ), cardTheme: CardThemeData( color: const Color(0xFFF8FAFC), // slate-50 diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index 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 dc16045..84e4acc 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -179,7 +179,8 @@ class UserPreferences { backgroundModeEnabled: (json['backgroundModeEnabled'] as bool?) ?? false, developerModeEnabled: (json['developerModeEnabled'] as bool?) ?? false, mapStyle: (json['mapStyle'] as String?) ?? 'liberty', - closeAppAfterDisconnect: (json['closeAppAfterDisconnect'] as bool?) ?? false, + closeAppAfterDisconnect: + (json['closeAppAfterDisconnect'] as bool?) ?? false, themeMode: (json['themeMode'] as String?) ?? 'dark', unitSystem: (json['unitSystem'] as String?) ?? 'metric', hybridModeEnabled: (json['hybridModeEnabled'] as bool?) ?? true, @@ -189,7 +190,8 @@ class UserPreferences { disableRssiFilter: (json['disableRssiFilter'] as bool?) ?? false, anonymousMode: (json['anonymousMode'] as bool?) ?? false, discDropEnabled: (json['discDropEnabled'] as bool?) ?? false, - deleteChannelOnDisconnect: (json['deleteChannelOnDisconnect'] as bool?) ?? true, + deleteChannelOnDisconnect: + (json['deleteChannelOnDisconnect'] as bool?) ?? true, minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, @@ -197,13 +199,17 @@ class UserPreferences { gpsMarkerStyle: _migrateGpsMarkerStyle(json['gpsMarkerStyle'] as String?), colorVisionType: (json['colorVisionType'] as String?) ?? 'none', mapTilesEnabled: (json['mapTilesEnabled'] as bool?) ?? true, - coverageOverlayOpacity: (json['coverageOverlayOpacity'] as num?)?.toDouble() ?? 0.7, - disconnectAlertEnabled: (json['disconnectAlertEnabled'] as bool?) ?? false, + coverageOverlayOpacity: + (json['coverageOverlayOpacity'] as num?)?.toDouble() ?? 0.7, + disconnectAlertEnabled: + (json['disconnectAlertEnabled'] as bool?) ?? false, customApiEnabled: (json['customApiEnabled'] as bool?) ?? false, customApiUrl: json['customApiUrl'] as String?, customApiKey: json['customApiKey'] as String?, - customApiDisclaimerAccepted: (json['customApiDisclaimerAccepted'] as bool?) ?? false, - customApiIncludeContact: (json['customApiIncludeContact'] as bool?) ?? true, + customApiDisclaimerAccepted: + (json['customApiDisclaimerAccepted'] as bool?) ?? false, + customApiIncludeContact: + (json['customApiIncludeContact'] as bool?) ?? true, ); } @@ -313,10 +319,12 @@ class UserPreferences { powerLevelSet: powerLevelSet ?? this.powerLevelSet, offlineMode: offlineMode ?? this.offlineMode, iataCode: iataCode ?? this.iataCode, - backgroundModeEnabled: backgroundModeEnabled ?? this.backgroundModeEnabled, + backgroundModeEnabled: + backgroundModeEnabled ?? this.backgroundModeEnabled, developerModeEnabled: developerModeEnabled ?? this.developerModeEnabled, mapStyle: mapStyle ?? this.mapStyle, - closeAppAfterDisconnect: closeAppAfterDisconnect ?? this.closeAppAfterDisconnect, + closeAppAfterDisconnect: + closeAppAfterDisconnect ?? this.closeAppAfterDisconnect, themeMode: themeMode ?? this.themeMode, unitSystem: unitSystem ?? this.unitSystem, hybridModeEnabled: hybridModeEnabled ?? this.hybridModeEnabled, @@ -326,21 +334,27 @@ class UserPreferences { disableRssiFilter: disableRssiFilter ?? this.disableRssiFilter, anonymousMode: anonymousMode ?? this.anonymousMode, discDropEnabled: discDropEnabled ?? this.discDropEnabled, - deleteChannelOnDisconnect: deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, - minPingDistanceMeters: minPingDistanceMeters ?? this.minPingDistanceMeters, + deleteChannelOnDisconnect: + deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, + minPingDistanceMeters: + minPingDistanceMeters ?? this.minPingDistanceMeters, autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, markerStyle: markerStyle ?? this.markerStyle, gpsMarkerStyle: gpsMarkerStyle ?? this.gpsMarkerStyle, colorVisionType: colorVisionType ?? this.colorVisionType, mapTilesEnabled: mapTilesEnabled ?? this.mapTilesEnabled, - coverageOverlayOpacity: coverageOverlayOpacity ?? this.coverageOverlayOpacity, - disconnectAlertEnabled: disconnectAlertEnabled ?? this.disconnectAlertEnabled, + coverageOverlayOpacity: + coverageOverlayOpacity ?? this.coverageOverlayOpacity, + disconnectAlertEnabled: + disconnectAlertEnabled ?? this.disconnectAlertEnabled, customApiEnabled: customApiEnabled ?? this.customApiEnabled, customApiUrl: customApiUrl ?? this.customApiUrl, customApiKey: customApiKey ?? this.customApiKey, - customApiDisclaimerAccepted: customApiDisclaimerAccepted ?? this.customApiDisclaimerAccepted, - customApiIncludeContact: customApiIncludeContact ?? this.customApiIncludeContact, + customApiDisclaimerAccepted: + customApiDisclaimerAccepted ?? this.customApiDisclaimerAccepted, + customApiIncludeContact: + customApiIncludeContact ?? this.customApiIncludeContact, ); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7cb837c..9c14b96 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3,7 +3,8 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart' show SystemNavigator; -import 'package:flutter/widgets.dart' show WidgetsBinding, WidgetsBindingObserver, AppLifecycleState; +import 'package:flutter/widgets.dart' + show WidgetsBinding, WidgetsBindingObserver, AppLifecycleState; import 'package:geolocator/geolocator.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:uuid/uuid.dart'; @@ -30,7 +31,8 @@ import '../services/gps_simulator_service.dart'; import '../services/meshcore/channel_service.dart'; import '../services/meshcore/connection.dart'; import '../services/meshcore/crypto_service.dart'; -import '../services/meshcore/packet_validator.dart' show PacketValidator, ChannelInfo; +import '../services/meshcore/packet_validator.dart' + show PacketValidator, ChannelInfo; import '../services/meshcore/rx_logger.dart'; import '../services/meshcore/tx_tracker.dart'; import '../services/meshcore/unified_rx_handler.dart'; @@ -46,10 +48,13 @@ import '../utils/debug_logger_io.dart'; enum AutoMode { /// Active Mode: Sends pings on movement, listens for RX responses active, + /// Passive Mode: Listening only (no transmit) passive, + /// Hybrid Mode: Alternates Discovery + Active pings each interval hybrid, + /// Trace Mode: Zero-hop trace to specific repeater targeted, } @@ -61,16 +66,22 @@ enum OverlayPingType { tx, disc, trace, rx } enum OfflineUploadResult { /// Upload completed successfully success, + /// Session file not found notFound, + /// Session data is invalid or empty invalidSession, + /// API authentication failed authFailed, + /// Some pings failed to upload partialFailure, + /// Another upload is already in progress uploadInProgress, + /// GPS position required but not available gpsRequired, } @@ -90,11 +101,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { late final DeviceModelService _deviceModelService; late final CustomApiService _customApiService; final AudioService _audioService = AudioService(); - late final CooldownTimer _cooldownTimer; // Shared cooldown for TX Ping and Active Mode - late final ManualPingCooldownTimer _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) + late final CooldownTimer + _cooldownTimer; // Shared cooldown for TX Ping and Active Mode + late final ManualPingCooldownTimer + _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) late final AutoPingTimer _autoPingTimer; late final RxWindowTimer _rxWindowTimer; - late final DiscoveryWindowTimer _discoveryWindowTimer; // Discovery listening window (Passive Mode) + late final DiscoveryWindowTimer + _discoveryWindowTimer; // Discovery listening window (Passive Mode) MeshCoreConnection? _meshCoreConnection; PingService? _pingService; UnifiedRxHandler? _unifiedRxHandler; @@ -111,8 +125,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ConnectionStatus _connectionStatus = ConnectionStatus.disconnected; ConnectionStep _connectionStep = ConnectionStep.disconnected; String? _connectionError; - bool _isAuthError = false; // Track if connection failed due to auth - bool _isNetworkError = false; // Track if connection failed due to network + bool _isAuthError = false; // Track if connection failed due to auth + bool _isNetworkError = false; // Track if connection failed due to network // Bluetooth adapter state (on/off) BluetoothAdapterState _bluetoothAdapterState = BluetoothAdapterState.unknown; @@ -125,8 +139,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { GpsStatus _gpsStatus = GpsStatus.permissionDenied; Position? _currentPosition; ({double lat, double lon})? _lastKnownPosition; - DateTime? _lastPositionSaveTime; // Throttle position saves to every 30 seconds - bool _firstGpsLockLogged = false; // Track if we've logged first GPS lock message + DateTime? + _lastPositionSaveTime; // Throttle position saves to every 30 seconds + bool _firstGpsLockLogged = + false; // Track if we've logged first GPS lock message // Device info DeviceModel? _deviceModel; @@ -144,7 +160,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// The device name to display (prefers SelfInfo name over BLE advertisement name) /// SelfInfo name reflects user's chosen name in MeshCore; BLE name may be cached/stale - String? get displayDeviceName => _displayDeviceName ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); + String? get displayDeviceName => + _displayDeviceName ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); // Ping state PingStats _pingStats = const PingStats(); @@ -177,7 +194,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final List _traceLogEntries = []; // Top repeaters overlay — updated live on each ping event - List<({String repeaterId, double snr, OverlayPingType type})> _topRepeatersOverlay = []; + List<({String repeaterId, double snr, OverlayPingType type})> + _topRepeatersOverlay = []; ({String repeaterId, double snr})? _rxOverlaySlot; Timer? _rxOverlayWindowTimer; @@ -191,8 +209,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { UserPreferences _preferences = const UserPreferences(); // Anonymous mode state - String? _originalDeviceName; // Real name stored before rename - bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" + String? _originalDeviceName; // Real name stored before rename + bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" /// Per-device antenna preferences: maps companion name → external antenna bool Map _deviceAntennaPreferences = {}; @@ -228,11 +246,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _isCheckingZone = false; // Zone check retry state - String? _zoneCheckError; // Error message from last failed check (null = no error) - String? _zoneCheckErrorReason; // 'network', 'gps_inaccurate', 'gps_stale', 'server_error' - int _zoneCheckRetryCountdown = 0; // Seconds until next retry (0 = not counting) - Timer? _zoneCheckRetryTimer; // Fires to trigger the retry - Timer? _zoneCheckCountdownTimer; // Ticks every 1s for UI countdown + String? + _zoneCheckError; // Error message from last failed check (null = no error) + String? + _zoneCheckErrorReason; // 'network', 'gps_inaccurate', 'gps_stale', 'server_error' + int _zoneCheckRetryCountdown = + 0; // Seconds until next retry (0 = not counting) + Timer? _zoneCheckRetryTimer; // Fires to trigger the retry + Timer? _zoneCheckCountdownTimer; // Ticks every 1s for UI countdown // Maintenance mode state bool _maintenanceMode = false; @@ -274,9 +295,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Zone grace period — pauses wardriving when outside_zone, resumes on zone re-entry bool _isInZoneGracePeriod = false; - Timer? _zoneGraceTimer; // 5-minute overall timeout - Timer? _zoneGracePollingTimer; // 5-second zone polling - Timer? _zoneGraceCountdownTimer; // 1-second UI countdown tick + Timer? _zoneGraceTimer; // 5-minute overall timeout + Timer? _zoneGracePollingTimer; // 5-second zone polling + Timer? _zoneGraceCountdownTimer; // 1-second UI countdown tick int _zoneGraceSecondsRemaining = 0; bool _autoPingWasEnabledBeforeGrace = false; AutoMode _autoModeBeforeGrace = AutoMode.active; @@ -311,10 +332,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _scope; // Path hash mode tracking (for multi-byte path support) - int? _originalPathHashMode; // Device's mode BEFORE we changed it (from DeviceInfo) - bool _userChangedPathMode = false; // True if user manually changed hopBytes while connected - int _hopBytes = 1; // Runtime-only: current hop byte size (read from device, not persisted) - int _traceHopBytes = 1; // Runtime-only: trace byte size (1, 2, or 4 — bitshift encoding) + int? + _originalPathHashMode; // Device's mode BEFORE we changed it (from DeviceInfo) + bool _userChangedPathMode = + false; // True if user manually changed hopBytes while connected + int _hopBytes = + 1; // Runtime-only: current hop byte size (read from device, not persisted) + int _traceHopBytes = + 1; // Runtime-only: trace byte size (1, 2, or 4 — bitshift encoding) // Noise floor session tracking (for graph feature) NoiseFloorSession? _currentNoiseFloorSession; @@ -359,7 +384,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isNetworkError => _isNetworkError; BluetoothAdapterState get bluetoothAdapterState => _bluetoothAdapterState; bool get isBluetoothOn => _bluetoothAdapterState == BluetoothAdapterState.on; - bool get isBluetoothOff => _bluetoothAdapterState == BluetoothAdapterState.off; + bool get isBluetoothOff => + _bluetoothAdapterState == BluetoothAdapterState.off; GpsStatus get gpsStatus => _gpsStatus; Position? get currentPosition => _currentPosition; ({double lat, double lon})? get lastKnownPosition => _lastKnownPosition; @@ -371,14 +397,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get autoPingEnabled => _autoPingEnabled; AutoMode get autoMode => _autoMode; bool get isPingSending => _isPingSending; - bool get isPingInProgress => _pingService?.pingInProgress ?? false; // True during entire ping + RX window (for auto pings) - bool get isDiscoveryListening => _pingService?.isDiscoveryListening ?? false; // True during discovery listening window (for Passive Mode) + bool get isPingInProgress => + _pingService?.pingInProgress ?? + false; // True during entire ping + RX window (for auto pings) + bool get isDiscoveryListening => + _pingService?.isDiscoveryListening ?? + false; // True during discovery listening window (for Passive Mode) /// Check if auto-ping disable is pending (waiting for RX window) bool get isPendingDisable => _pingService?.pendingDisable ?? false; + /// True when running any mode that does TX (Active or Hybrid) - bool get isTxModeRunning => _autoPingEnabled && (_autoMode == AutoMode.active || _autoMode == AutoMode.hybrid); + bool get isTxModeRunning => + _autoPingEnabled && + (_autoMode == AutoMode.active || _autoMode == AutoMode.hybrid); + /// True when running Trace Mode (zero-hop trace) - bool get isTargetedModeRunning => _autoPingEnabled && _autoMode == AutoMode.targeted; + bool get isTargetedModeRunning => + _autoPingEnabled && _autoMode == AutoMode.targeted; String? get targetRepeaterId => _targetRepeaterId; int get queueSize => _queueSize; int? get currentNoiseFloor => _currentNoiseFloor; @@ -389,13 +424,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { List get rxPings => List.unmodifiable(_rxPings); /// Top 3 repeaters by best SNR from TX/DISC/Trace pings - List<({String repeaterId, double snr, OverlayPingType type})> get topRepeatersBySnr => _topRepeatersOverlay; + List<({String repeaterId, double snr, OverlayPingType type})> + get topRepeatersBySnr => _topRepeatersOverlay; + /// Best RX observation in the current 5-second window ({String repeaterId, double snr})? get rxOverlaySlot => _rxOverlaySlot; /// Update the top repeaters overlay with results from the latest TX/DISC/Trace ping. /// Replaces all 3 slots entirely (no carryover from previous pings). - void _updateTopRepeaters(List<({String repeaterId, double snr})> current, OverlayPingType type) { + void _updateTopRepeaters( + List<({String repeaterId, double snr})> current, OverlayPingType type) { final bestSnr = {}; for (final r in current) { final key = r.repeaterId.toUpperCase(); @@ -419,7 +457,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } else { _rxOverlaySlot = entry; - _rxOverlayWindowTimer = Timer(Duration(seconds: _preferences.autoPingInterval), () { + _rxOverlayWindowTimer = + Timer(Duration(seconds: _preferences.autoPingInterval), () { // Window closed — slot stays until next RX or cleared }); } @@ -432,21 +471,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxOverlayWindowTimer?.cancel(); _rxOverlayWindowTimer = null; } + List get txLogEntries => List.unmodifiable(_txLogEntries); List get rxLogEntries => List.unmodifiable(_rxLogEntries); List get discLogEntries => List.unmodifiable(_discLogEntries); - List get traceLogEntries => List.unmodifiable(_traceLogEntries); - List get errorLogEntries => List.unmodifiable(_errorLogEntries); + List get traceLogEntries => + List.unmodifiable(_traceLogEntries); + List get errorLogEntries => + List.unmodifiable(_errorLogEntries); List get unifiedPingLogEntries { final merged = [ - ..._txLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.tx, timestamp: e.timestamp, entry: e)), - ..._rxLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.rx, timestamp: e.timestamp, entry: e)), - ..._discLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.disc, timestamp: e.timestamp, entry: e)), - ..._traceLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.trace, timestamp: e.timestamp, entry: e)), + ..._txLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.tx, timestamp: e.timestamp, entry: e)), + ..._rxLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.rx, timestamp: e.timestamp, entry: e)), + ..._discLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.disc, timestamp: e.timestamp, entry: e)), + ..._traceLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.trace, timestamp: e.timestamp, entry: e)), ]; merged.sort(); return merged; } + ({double lat, double lon})? get mapNavigationTarget => _mapNavigationTarget; int get mapNavigationTrigger => _mapNavigationTrigger; bool get requestMapTabSwitch => _requestMapTabSwitch; @@ -476,7 +523,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int? get zoneSlotsMax => _currentZone?['slots_max'] as int?; String? get nearestZoneName => _nearestZone?['name'] as String?; String? get nearestZoneCode => _nearestZone?['code'] as String?; - double? get nearestZoneDistanceKm => (_nearestZone?['distance_km'] as num?)?.toDouble(); + double? get nearestZoneDistanceKm => + (_nearestZone?['distance_km'] as num?)?.toDouble(); // Zone check retry getters String? get zoneCheckError => _zoneCheckError; @@ -546,11 +594,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isApiRxOnlyMode => hasApiSession && !txAllowed && rxAllowed; bool get enforceHybrid => _apiService.enforceHybrid; bool get enforceDiscDrop => _apiService.enforceDiscDrop; - bool get discDropEnabled => _preferences.discDropEnabled || _apiService.enforceDiscDrop; + bool get discDropEnabled => + _preferences.discDropEnabled || _apiService.enforceDiscDrop; int get minModeInterval => _apiService.minModeInterval; bool get enforceHopBytes => _apiService.enforceHopBytes; int get hopBytes => _hopBytes; - int get effectiveHopBytes => enforceHopBytes ? _apiService.apiHopBytes : _hopBytes; + int get effectiveHopBytes => + enforceHopBytes ? _apiService.apiHopBytes : _hopBytes; int get traceHopBytes => _traceHopBytes; bool get supportsMultiBytePaths => _originalPathHashMode != null; @@ -573,11 +623,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Countdown timers - CooldownTimer get cooldownTimer => _cooldownTimer; // Shared cooldown for TX Ping and Active Mode - ManualPingCooldownTimer get manualPingCooldownTimer => _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) + CooldownTimer get cooldownTimer => + _cooldownTimer; // Shared cooldown for TX Ping and Active Mode + ManualPingCooldownTimer get manualPingCooldownTimer => + _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) AutoPingTimer get autoPingTimer => _autoPingTimer; RxWindowTimer get rxWindowTimer => _rxWindowTimer; - DiscoveryWindowTimer get discoveryWindowTimer => _discoveryWindowTimer; // Discovery listening window (Passive Mode) + DiscoveryWindowTimer get discoveryWindowTimer => + _discoveryWindowTimer; // Discovery listening window (Passive Mode) // ============================================ // Initialization @@ -596,11 +649,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Initialize custom API forwarding service _customApiService = CustomApiService(prefsGetter: () => _preferences); _customApiService.onError = (message) { - logError('Custom API: $message', severity: ErrorSeverity.warning, autoSwitch: false); + logError('Custom API: $message', + severity: ErrorSeverity.warning, autoSwitch: false); }; _customApiService.contactGetter = () { final pk = _devicePublicKey; - return (pk != null && pk.length >= 8) ? pk.substring(0, 8).toUpperCase() : null; + return (pk != null && pk.length >= 8) + ? pk.substring(0, 8).toUpperCase() + : null; }; _customApiService.iataGetter = () => zoneCode ?? _preferences.iataCode; _apiQueueService.customApiService = _customApiService; @@ -622,7 +678,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Initialize countdown timers with notifyListeners callback for smooth UI updates _cooldownTimer = CooldownTimer(onUpdate: notifyListeners); - _manualPingCooldownTimer = ManualPingCooldownTimer(onUpdate: notifyListeners); + _manualPingCooldownTimer = + ManualPingCooldownTimer(onUpdate: notifyListeners); _autoPingTimer = AutoPingTimer(onUpdate: notifyListeners); _rxWindowTimer = RxWindowTimer(onUpdate: notifyListeners); _discoveryWindowTimer = DiscoveryWindowTimer(onUpdate: notifyListeners); @@ -650,9 +707,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with queue size if (_autoPingEnabled) { - final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' - : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; + final modeName = _autoMode == AutoMode.passive + ? 'Passive Mode' + : _autoMode == AutoMode.hybrid + ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted + ? 'Trace Mode' + : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -666,7 +727,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingStats = _pingStats.copyWith( successfulUploads: _pingStats.successfulUploads + uploadedCount, ); - debugLog('[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); + debugLog( + '[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); notifyListeners(); // Schedule overlay tile refresh after server has time to regenerate tiles. @@ -709,7 +771,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to Bluetooth adapter state changes (on/off) debugLog('[INIT] Setting up Bluetooth adapter state listener...'); - _adapterStateSubscription = _bluetoothService.adapterStateStream.listen((state) { + _adapterStateSubscription = + _bluetoothService.adapterStateStream.listen((state) { final previousState = _bluetoothAdapterState; _bluetoothAdapterState = state; @@ -725,7 +788,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to Bluetooth connection changes debugLog('[INIT] Setting up BLE connection listener...'); await _connectionSubscription?.cancel(); - _connectionSubscription = _bluetoothService.connectionStream.listen((status) async { + _connectionSubscription = + _bluetoothService.connectionStream.listen((status) async { _connectionStatus = status; if (status == ConnectionStatus.disconnected) { // Check if this is an unexpected disconnect during active wardriving @@ -735,7 +799,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_isInZoneGracePeriod) { // BLE disconnected during zone grace period — abandon grace, full cleanup - debugLog('[CONN] BLE disconnect during zone grace period — full cleanup'); + debugLog( + '[CONN] BLE disconnect during zone grace period — full cleanup'); _cancelZoneGraceTimers(); _isInZoneGracePeriod = false; _zoneGraceSecondsRemaining = 0; @@ -743,14 +808,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _autoPingWasEnabledBeforeGrace = false; await _fullDisconnectCleanup(); } else if (wasConnected && hasRemembered && isUnexpected && !kIsWeb) { - debugLog('[CONN] Unexpected BLE disconnect detected - starting auto-reconnect'); + debugLog( + '[CONN] Unexpected BLE disconnect detected - starting auto-reconnect'); await _startAutoReconnect(); } else if (!_isAutoReconnecting) { // Normal disconnect (user-requested or no remembered device) await _fullDisconnectCleanup(); } else { // Disconnected during a reconnect attempt - _attemptReconnect handles retry - debugLog('[CONN] BLE disconnect during reconnect attempt - will retry'); + debugLog( + '[CONN] BLE disconnect during reconnect attempt - will retry'); } } notifyListeners(); @@ -769,23 +836,27 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Log when we transition to locked state (permission granted + GPS available) if (status == GpsStatus.locked) { - debugLog('[GPS] GPS lock acquired - zone check should trigger on first position'); + debugLog( + '[GPS] GPS lock acquired - zone check should trigger on first position'); } // Log when permission is denied or GPS disabled if (status == GpsStatus.permissionDenied) { - debugLog('[GPS] Location permission denied - zone checks will be blocked'); + debugLog( + '[GPS] Location permission denied - zone checks will be blocked'); } else if (status == GpsStatus.disabled) { - debugLog('[GPS] Location services disabled - zone checks will be blocked'); + debugLog( + '[GPS] Location services disabled - zone checks will be blocked'); } } notifyListeners(); }); - _gpsStatus = _gpsService.status; // Sync initial status + _gpsStatus = _gpsService.status; // Sync initial status debugLog('[INIT] Initial GPS status: $_gpsStatus'); debugLog('[INIT] Setting up GPS position listener...'); await _gpsPositionSubscription?.cancel(); - _gpsPositionSubscription = _gpsService.positionStream.listen((position) async { + _gpsPositionSubscription = + _gpsService.positionStream.listen((position) async { _currentPosition = position; notifyListeners(); @@ -798,7 +869,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] First GPS lock, triggering zone check'); await checkZoneStatus(); _firstGpsLockLogged = true; - } else if (_inZone == null && _preferences.offlineMode && !_firstGpsLockLogged) { + } else if (_inZone == null && + _preferences.offlineMode && + !_firstGpsLockLogged) { debugLog('[GEOFENCE] First GPS lock skipped: offline mode enabled'); _firstGpsLockLogged = true; } @@ -806,14 +879,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Check zone every 100m movement (while disconnected) // This allows users to know if they've entered/exited a zone while moving // Skip zone checks when offline mode is enabled - if (!isConnected && !_preferences.offlineMode && _shouldRecheckZone(position)) { + if (!isConnected && + !_preferences.offlineMode && + _shouldRecheckZone(position)) { // Throttle log to once per 30s to avoid spam while driving final now = DateTime.now(); - if (_lastZoneCheckLogTime == null || now.difference(_lastZoneCheckLogTime!) >= const Duration(seconds: 30)) { + if (_lastZoneCheckLogTime == null || + now.difference(_lastZoneCheckLogTime!) >= + const Duration(seconds: 30)) { if (_zoneCheckSuppressedCount > 0) { - debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone (suppressed $_zoneCheckSuppressedCount similar in last 30s)'); + debugLog( + '[GEOFENCE] Moved 100m+ while disconnected, rechecking zone (suppressed $_zoneCheckSuppressedCount similar in last 30s)'); } else { - debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); + debugLog( + '[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); } _lastZoneCheckLogTime = now; _zoneCheckSuppressedCount = 0; @@ -857,15 +936,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 'isCheckingZone=$_isCheckingZone, hasPosition=${_currentPosition != null}'); await _gpsService.startWatching(); - _gpsStatus = _gpsService.status; // Sync after restart + _gpsStatus = _gpsService.status; // Sync after restart debugLog('[GPS] GPS restarted, new status: $_gpsStatus'); - debugLog('[GPS] Post-restart state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + debugLog( + '[GPS] Post-restart state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' 'hasPosition=${_currentPosition != null}'); // If we now have a position and zone hasn't been checked, trigger check - if (_currentPosition != null && _inZone == null && !_preferences.offlineMode) { - debugLog('[GPS] Permission granted with existing position - triggering zone check'); + if (_currentPosition != null && + _inZone == null && + !_preferences.offlineMode) { + debugLog( + '[GPS] Permission granted with existing position - triggering zone check'); await checkZoneStatus(); } notifyListeners(); @@ -923,7 +1006,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (!isEnabled) { debugLog('[SCAN] Bluetooth still disabled after retries'); - _connectionError = 'Bluetooth is disabled. Please enable Bluetooth and try again.'; + _connectionError = + 'Bluetooth is disabled. Please enable Bluetooth and try again.'; notifyListeners(); return; } @@ -938,21 +1022,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen for discovered devices using subscription so stopScan() can cancel DiscoveredDevice? selectedDevice; final completer = Completer(); - _activeScanSubscription = _bluetoothService.scanForDevices( + _activeScanSubscription = _bluetoothService + .scanForDevices( timeout: const Duration(seconds: 15), - ).listen( + ) + .listen( (device) { if (!_discoveredDevices.any((d) => d.id == device.id)) { // Prefer remembered device name (from SelfInfo) over BLE cache var enrichedDevice = device; - if (_rememberedDevice != null && device.id == _rememberedDevice!.id && + if (_rememberedDevice != null && + device.id == _rememberedDevice!.id && device.name != _rememberedDevice!.name) { enrichedDevice = DiscoveredDevice( id: device.id, name: _rememberedDevice!.name, rssi: device.rssi, ); - debugLog('[SCAN] Using remembered name "${_rememberedDevice!.name}" instead of BLE name "${device.name}"'); + debugLog( + '[SCAN] Using remembered name "${_rememberedDevice!.name}" instead of BLE name "${device.name}"'); } _discoveredDevices.add(enrichedDevice); selectedDevice = enrichedDevice; @@ -1024,7 +1112,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final publicKey = _meshCoreConnection!.devicePublicKey; if (publicKey == null) { debugError('[APP] Cannot request auth: no public key'); - return {'success': false, 'reason': 'no_public_key', 'message': 'Device public key not available'}; + return { + 'success': false, + 'reason': 'no_public_key', + 'message': 'Device public key not available' + }; } // Anonymous mode: rename device before auth so mesh pings broadcast as "Anonymous" @@ -1036,7 +1128,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _meshCoreConnection!.setAdvertName('Anonymous'); _isAnonymousRenamed = true; _displayDeviceName = 'Anonymous'; - debugLog('[CONN] Anonymous mode: renamed from "$realName" to "Anonymous"'); + debugLog( + '[CONN] Anonymous mode: renamed from "$realName" to "Anonymous"'); // Short delay for firmware to process await Future.delayed(const Duration(milliseconds: 300)); } catch (e) { @@ -1049,16 +1142,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Resolve device name: use "Anonymous" if renamed, otherwise SelfInfo name final deviceName = _isAnonymousRenamed ? 'Anonymous' - : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + : (_meshCoreConnection!.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); if (deviceName == null || deviceName.isEmpty) { - debugError('[APP] Cannot request auth: could not retrieve device name'); - return {'success': false, 'reason': 'no_device_name', 'message': 'Could not retrieve device name'}; + debugError( + '[APP] Cannot request auth: could not retrieve device name'); + return { + 'success': false, + 'reason': 'no_device_name', + 'message': 'Could not retrieve device name' + }; } // ============================================================ // STAGE 1: Try existing public_key authentication // ============================================================ - debugLog('[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); + debugLog( + '[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); final result = await _apiService.requestAuth( reason: 'connect', @@ -1067,7 +1167,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { appVersion: _appVersion, power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection!.deviceModel?.manufacturer ?? _meshCoreConnection!.deviceInfo?.manufacturer ?? 'Unknown', + model: _meshCoreConnection!.deviceModel?.manufacturer ?? + _meshCoreConnection!.deviceInfo?.manufacturer ?? + 'Unknown', lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -1078,7 +1180,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _maintenanceMode = true; _maintenanceMessage = result['maintenance_message'] as String?; _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + debugLog( + '[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); _startMaintenancePolling(); notifyListeners(); return { @@ -1115,12 +1218,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }; } - debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + debugLog( + '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); // If Stage 1 failed due to GPS issues, Stage 2 will also fail with same bad data final stage1Reason = result['reason'] as String?; if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { - debugError('[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + debugError( + '[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); return { 'success': false, 'reason': stage1Reason, @@ -1137,13 +1242,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { debugLog('[APP] Requesting signed contact URI from device...'); contactUri = await _meshCoreConnection!.exportContact(); - debugLog('[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + debugLog( + '[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); } catch (e) { debugError('[APP] Failed to get contact URI from device: $e'); return { 'success': false, 'reason': 'registration_failed', - 'message': 'Companion not found in backend and failed to register via API' + 'message': + 'Companion not found in backend and failed to register via API' }; } @@ -1155,7 +1262,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { appVersion: _appVersion, power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection!.deviceModel?.manufacturer ?? _meshCoreConnection!.deviceInfo?.manufacturer ?? 'Unknown', + model: _meshCoreConnection!.deviceModel?.manufacturer ?? + _meshCoreConnection!.deviceInfo?.manufacturer ?? + 'Unknown', lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -1171,9 +1280,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (registerResult['success'] != true) { - final serverReason = registerResult['reason'] as String? ?? 'registration_failed'; + final serverReason = + registerResult['reason'] as String? ?? 'registration_failed'; final serverMessage = registerResult['message'] as String?; - debugError('[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + debugError( + '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); return { 'success': false, 'reason': serverReason, @@ -1208,10 +1319,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (step == ConnectionStep.connected) { // Update device info _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; - _firmwareVersionString = _meshCoreConnection!.deviceInfo?.firmwareVersionString; + _firmwareVersionString = + _meshCoreConnection!.deviceInfo?.firmwareVersionString; _deviceModel = _meshCoreConnection!.deviceModel; _devicePublicKey = _meshCoreConnection!.devicePublicKey; - debugLog('[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); + debugLog( + '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); // Persist device info for bug reports when disconnected // Use original name (not "Anonymous") for bug report identification @@ -1222,7 +1335,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Always strip MeshCore- prefix if present deviceName = deviceName.replaceFirst('MeshCore-', ''); } - if (deviceName != null && deviceName.isNotEmpty && _devicePublicKey != null) { + if (deviceName != null && + deviceName.isNotEmpty && + _devicePublicKey != null) { _saveLastConnectedDevice(deviceName, _devicePublicKey!); } @@ -1240,7 +1355,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }); // Listen for noise floor updates - _noiseFloorSubscription = _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { _currentNoiseFloor = noiseFloor; // Record sample to current noise floor session (if active) _recordNoiseFloorSample(noiseFloor); @@ -1248,7 +1364,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }); // Listen for battery updates - _batterySubscription = _meshCoreConnection!.batteryStream.listen((batteryPercent) { + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { _currentBatteryPercent = batteryPercent; notifyListeners(); }); @@ -1261,16 +1378,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update preferences if device model was recognized (for display/API reporting) // Note: This does NOT change the radio's TX power - it only sets what power level to REPORT - if (connectionResult.deviceModelMatched && connectionResult.deviceModel != null) { + if (connectionResult.deviceModelMatched && + connectionResult.deviceModel != null) { final device = connectionResult.deviceModel!; _preferences = _preferences.copyWith( powerLevel: device.power, txPower: device.txPower, - autoPowerSet: true, // Indicates power was auto-detected from device model + autoPowerSet: + true, // Indicates power was auto-detected from device model powerLevelSet: false, // Clear stale manual flag from previous session ); notifyListeners(); - debugLog('[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); + debugLog( + '[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); } // Note: API session acquisition is now handled by the auth callback @@ -1287,7 +1407,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update unified RX handler's validator with new channel configuration if (_unifiedRxHandler != null) { - final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); + final allowedChannelsData = + ChannelService.getAllowedChannelsForValidator(); final allowedChannels = {}; for (final entry in allowedChannelsData.entries) { allowedChannels[entry.key] = ChannelInfo( @@ -1301,7 +1422,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { disableRssiFilter: _preferences.disableRssiFilter, ); _unifiedRxHandler!.updateValidator(newValidator); - debugLog('[APP] PacketValidator updated with ${allowedChannels.length} channels: ' + debugLog( + '[APP] PacketValidator updated with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); } @@ -1310,7 +1432,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Any other value (e.g., "ottawa") → derive TransportKey and set scope final apiScopes = _apiService.scopes; final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; - final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; + final isWildcard = + firstScope == null || firstScope == '*' || firstScope == '#*'; if (!isWildcard) { final scopeName = firstScope; _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; @@ -1337,8 +1460,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Enforce minimum auto-ping interval if required by regional admin if (_preferences.autoPingInterval < _apiService.minModeInterval) { - _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); - debugLog('[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); + _preferences = _preferences.copyWith( + autoPingInterval: _apiService.minModeInterval); + debugLog( + '[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); } // Configure multi-byte path hash mode on radio @@ -1363,7 +1488,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { shouldIgnoreRepeater: (String repeaterId) { final prefs = _preferences; if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - return PacketValidator.isCarpeaterIdMatch(repeaterId, prefs.ignoreRepeaterId!); + return PacketValidator.isCarpeaterIdMatch( + repeaterId, prefs.ignoreRepeaterId!); } return false; }, @@ -1377,13 +1503,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // External antenna must be explicitly set (yes or no) before pinging return _preferences.externalAntennaSet; }; - + _pingService!.checkPowerLevelConfigured = () { // Power is configured if: // - Auto-detected from device model, OR // - Manually selected by user, OR // - Device model is known (has default power) - return _preferences.autoPowerSet || _preferences.powerLevelSet || _deviceModel != null; + return _preferences.autoPowerSet || + _preferences.powerLevelSet || + _deviceModel != null; }; // Get external antenna value for API payloads @@ -1450,9 +1578,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with current stats if (_autoPingEnabled) { - final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' - : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; + final modeName = _autoMode == AutoMode.passive + ? 'Passive Mode' + : _autoMode == AutoMode.hybrid + ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted + ? 'Trace Mode' + : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -1465,14 +1597,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Handle real-time echo updates - update TxLogEntry as echoes are received _pingService!.onEchoReceived = (txPing, repeater, isNew) { debugLog('[APP] ========== ECHO CALLBACK RECEIVED =========='); - debugLog('[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); + debugLog( + '[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); debugLog('[APP] TxLogEntries count: ${_txLogEntries.length}'); // Find the matching TxLogEntry and update its events if (_txLogEntries.isNotEmpty) { final lastEntry = _txLogEntries.last; // Verify it's the right entry by timestamp (should be within a few seconds) - final timeDiff = lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); + final timeDiff = + lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); if (timeDiff <= 10) { // Build updated events list final existingEvents = List.from(lastEntry.events); @@ -1489,7 +1623,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _audioService.playReceiveSound(); } else { // Update existing event's SNR - final idx = existingEvents.indexWhere((e) => e.repeaterId == repeater.repeaterId); + final idx = existingEvents + .indexWhere((e) => e.repeaterId == repeater.repeaterId); if (idx >= 0) { existingEvents[idx] = newEvent; } @@ -1504,19 +1639,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { events: existingEvents, ); _txLogEntries[_txLogEntries.length - 1] = updatedEntry; - debugLog('[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); + debugLog( + '[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); // Update top repeaters overlay with current TX echoes - _updateTopRepeaters(existingEvents - .where((e) => e.snr != null) - .map((e) => (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) - .toList(), OverlayPingType.tx); + _updateTopRepeaters( + existingEvents + .where((e) => e.snr != null) + .map((e) => + (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) + .toList(), + OverlayPingType.tx); debugLog('[APP] Calling notifyListeners() to update UI'); notifyListeners(); debugLog('[APP] notifyListeners() completed'); } else { - debugLog('[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); + debugLog( + '[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); } } else { debugLog('[APP] WARNING: _txLogEntries is empty, cannot update'); @@ -1533,7 +1673,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Track idle time for auto-stop if (skipReason != null) { // Ping was skipped — check if idle too long - if (_preferences.autoStopAfterIdle && _idleAutoStopReference != null) { + if (_preferences.autoStopAfterIdle && + _idleAutoStopReference != null) { final elapsed = DateTime.now().difference(_idleAutoStopReference!); if (elapsed >= _autoStopIdleTimeout) { _triggerIdleAutoStop(); @@ -1552,15 +1693,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Wire up real-time disc node discovery callback (like onEchoReceived) _pingService!.onDiscNodeDiscovered = (discPing, nodeEntry, isNew) { - debugLog('[APP] Real-time disc node: ${nodeEntry.repeaterId}, isNew=$isNew'); + debugLog( + '[APP] Real-time disc node: ${nodeEntry.repeaterId}, isNew=$isNew'); if (isNew) { _audioService.playReceiveSound(); } // Update top repeaters overlay with all discovered nodes from this ping - _updateTopRepeaters(discPing.discoveredNodes - .map((n) => (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) - .toList(), OverlayPingType.disc); + _updateTopRepeaters( + discPing.discoveredNodes + .map((n) => + (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) + .toList(), + OverlayPingType.disc); notifyListeners(); }; @@ -1577,11 +1722,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lat = lastTx.latitude; lon = lastTx.longitude; if (lastTx.events.isNotEmpty) { - repeaters = lastTx.events.map((e) => MarkerRepeaterInfo( - repeaterId: e.repeaterId, - snr: e.snr ?? 0.0, - rssi: e.rssi ?? 0, - )).toList(); + repeaters = lastTx.events + .map((e) => MarkerRepeaterInfo( + repeaterId: e.repeaterId, + snr: e.snr ?? 0.0, + rssi: e.rssi ?? 0, + )) + .toList(); } } @@ -1606,12 +1753,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lat = lastDisc.latitude; lon = lastDisc.longitude; if (lastDisc.discoveredNodes.isNotEmpty) { - repeaters = lastDisc.discoveredNodes.map((n) => MarkerRepeaterInfo( - repeaterId: n.repeaterId, - snr: n.localSnr, - rssi: n.localRssi, - pubkeyHex: n.pubkeyHex, - )).toList(); + repeaters = lastDisc.discoveredNodes + .map((n) => MarkerRepeaterInfo( + repeaterId: n.repeaterId, + snr: n.localSnr, + rssi: n.localRssi, + pubkeyHex: n.pubkeyHex, + )) + .toList(); } } @@ -1648,11 +1797,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lat = lastTrace.latitude; lon = lastTrace.longitude; if (result != null && result.success) { - repeaters = [MarkerRepeaterInfo( - repeaterId: result.targetRepeaterId, - snr: result.localSnr, - rssi: result.localRssi, - )]; + repeaters = [ + MarkerRepeaterInfo( + repeaterId: result.targetRepeaterId, + snr: result.localSnr, + rssi: result.localRssi, + ) + ]; // Update the log entry with success data _traceLogEntries[0] = TraceLogEntry( timestamp: lastTrace.timestamp, @@ -1681,7 +1832,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Wire up discovery carpeater drop callback (for DiscTracker RSSI failsafe) _pingService!.onDiscCarpeaterDrop = (String repeaterId, String reason) { - debugLog('[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); + debugLog( + '[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); logError('Discovery Dropped\nPossible carpeater: $repeaterId\n$reason', severity: ErrorSeverity.warning, autoSwitch: false); }; @@ -1735,20 +1887,26 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update remembered device with real name (not "Anonymous") // BLE advertisement name may be stale after device rename - final realName = _isAnonymousRenamed ? (_originalDeviceName ?? selfInfoName) : selfInfoName; + final realName = _isAnonymousRenamed + ? (_originalDeviceName ?? selfInfoName) + : selfInfoName; if (_rememberedDevice != null && _rememberedDevice!.id == device.id) { final updatedName = 'MeshCore-$realName'; if (_rememberedDevice!.name != updatedName) { - await _saveRememberedDevice(DiscoveredDevice(id: device.id, name: updatedName)); - debugLog('[APP] Updated remembered device name from SelfInfo: $updatedName'); + await _saveRememberedDevice( + DiscoveredDevice(id: device.id, name: updatedName)); + debugLog( + '[APP] Updated remembered device name from SelfInfo: $updatedName'); } } } // Restore per-device antenna preference if previously saved // Use original name for keying, not "Anonymous" - final resolvedName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; - if (resolvedName != null && _deviceAntennaPreferences.containsKey(resolvedName)) { + final resolvedName = + _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; + if (resolvedName != null && + _deviceAntennaPreferences.containsKey(resolvedName)) { final savedAntenna = _deviceAntennaPreferences[resolvedName]!; _preferences = _preferences.copyWith( externalAntenna: savedAntenna, @@ -1756,12 +1914,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _antennaRestoredFromDevice = true; _savePreferences(); - debugLog('[APP] Restored antenna preference for "$resolvedName": ${savedAntenna ? "external" : "device"}'); + debugLog( + '[APP] Restored antenna preference for "$resolvedName": ${savedAntenna ? "external" : "device"}'); notifyListeners(); } // Restore per-device power override if previously saved - if (resolvedName != null && _devicePowerOverrides.containsKey(resolvedName)) { + if (resolvedName != null && + _devicePowerOverrides.containsKey(resolvedName)) { final saved = _devicePowerOverrides[resolvedName]!; _preferences = _preferences.copyWith( powerLevel: (saved['powerLevel'] as num).toDouble(), @@ -1771,7 +1931,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _powerRestoredFromDevice = true; _savePreferences(); - debugLog('[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); + debugLog( + '[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); notifyListeners(); } @@ -1780,7 +1941,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (txAllowed && rxAllowed) { debugLog('[CONN] Connected with full access (TX + RX allowed)'); } else if (rxAllowed) { - debugLog('[CONN] Connected with RX-only access (TX not allowed, zone at TX capacity)'); + debugLog( + '[CONN] Connected with RX-only access (TX not allowed, zone at TX capacity)'); } else { debugLog('[CONN] Connected with limited access'); } @@ -1818,7 +1980,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (validation != PingValidation.valid) { debugLog('[CONN] Ping validation after connect: $validation'); } - } catch (e) { debugError('[APP] Connection failed: $e'); @@ -1849,7 +2010,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (parts.length > 1) { final errorParts = parts[1].split(':'); final reason = errorParts.isNotEmpty ? errorParts[0] : 'unknown'; - final serverMessage = errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; + final serverMessage = + errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; _isNetworkError = reason == 'network_error'; _connectionError = _getErrorMessage(reason, serverMessage); } else { @@ -1859,7 +2021,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isAuthError = false; _isNetworkError = false; // Provide clean user-facing messages for common BLE errors - if (errorStr.contains('timeout') || errorStr.contains('Timeout') || errorStr.contains('timed out')) { + if (errorStr.contains('timeout') || + errorStr.contains('Timeout') || + errorStr.contains('timed out')) { _connectionError = 'Bluetooth connection scan timed out'; } else { _connectionError = errorStr.replaceFirst('Exception: ', ''); @@ -1879,8 +2043,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _txTracker!.disableRssiFilter = _preferences.disableRssiFilter; // Set CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) - _txTracker!.carpeaterPrefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; - debugLog('[APP] TxTracker.carpeaterPrefix set to ${_txTracker!.carpeaterPrefix ?? 'null'}'); + _txTracker!.carpeaterPrefix = + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + debugLog( + '[APP] TxTracker.carpeaterPrefix set to ${_txTracker!.carpeaterPrefix ?? 'null'}'); // Log TX carpeater drops to error log (without navigating to error tab) _txTracker!.onCarpeaterDrop = (String repeaterId, String reason) { @@ -1893,16 +2059,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Create RX logger (stored for use when enabling Passive Mode) _rxLogger = RxLogger( // CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) - carpeaterPrefix: _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null, + carpeaterPrefix: + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null, // Immediate observation callback - fires when packet is first validated // Creates pin IMMEDIATELY for NEW repeaters (first time in current batch) onObservation: (observation) { try { - debugLog('[APP] Immediate RX observation: repeater=${observation.repeaterId}, ' + debugLog( + '[APP] Immediate RX observation: repeater=${observation.repeaterId}, ' 'snr=${observation.snr ?? 'null'}, location=${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)}'); // Log current batch tracking state for debugging - debugLog('[APP] Current batch tracking: ${_currentBatchRepeaters.length} repeaters: $_currentBatchRepeaters'); + debugLog( + '[APP] Current batch tracking: ${_currentBatchRepeaters.length} repeaters: $_currentBatchRepeaters'); // Check if repeater already has a pin in CURRENT BATCH (not all-time) // This allows new pins after batch flushes (25m movement) @@ -1924,7 +2093,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Increment RX count immediately when pin is created (not on batch flush) _pingStats = _pingStats.copyWith(rxCount: _pingStats.rxCount + 1); - debugLog('[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' + debugLog( + '[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' 'at ${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)} ' '(batch tracking: ${_currentBatchRepeaters.length} repeaters, rxCount: ${_pingStats.rxCount})'); // Update RX overlay slot immediately @@ -1948,7 +2118,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); notifyListeners(); } else { - debugLog('[APP] Repeater ${observation.repeaterId} already has pin in current batch, SNR will update on flush if better'); + debugLog( + '[APP] Repeater ${observation.repeaterId} already has pin in current batch, SNR will update on flush if better'); } } catch (e, stackTrace) { debugError('[APP] Error in immediate observation callback: $e'); @@ -1961,7 +2132,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { onRxEntry: (entry) async { try { debugLog('[APP] ========== BATCH FLUSH CALLBACK =========='); - debugLog('[APP] Finalized RX entry (best SNR): repeater=${entry.repeaterId}, ' + debugLog( + '[APP] Finalized RX entry (best SNR): repeater=${entry.repeaterId}, ' 'snr=${entry.snr ?? 'null'}, location=${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); final repeaterKey = entry.repeaterId.toUpperCase(); @@ -1980,20 +2152,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update the pin's SNR to the best from this batch final existingPin = _rxPings[lastPinIndex]; // Only update if new SNR is non-null and better (null never replaces non-null) - final shouldUpdateSnr = entry.snr != null && entry.snr! > existingPin.snr; + final shouldUpdateSnr = + entry.snr != null && entry.snr! > existingPin.snr; if (shouldUpdateSnr) { _rxPings[lastPinIndex] = RxPing( - latitude: existingPin.latitude, // KEEP batch start location + latitude: existingPin.latitude, // KEEP batch start location longitude: existingPin.longitude, // KEEP batch start location repeaterId: entry.repeaterId, timestamp: entry.timestamp, - snr: entry.snr ?? existingPin.snr, // UPDATE to best SNR from batch + snr: entry.snr ?? + existingPin.snr, // UPDATE to best SNR from batch rssi: entry.rssi ?? existingPin.rssi, ); - debugLog('[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' + debugLog( + '[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' '${existingPin.snr.toStringAsFixed(2)} -> ${entry.snr?.toStringAsFixed(2) ?? 'null'}'); } else { - debugLog('[APP] RX pin SNR unchanged for repeater=${entry.repeaterId}: ' + debugLog( + '[APP] RX pin SNR unchanged for repeater=${entry.repeaterId}: ' 'batch best ${entry.snr?.toStringAsFixed(2) ?? 'null'} <= pin ${existingPin.snr.toStringAsFixed(2)}'); } } else { @@ -2008,7 +2184,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _rxPings.add(newRxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); - debugLog('[APP] Created FALLBACK RX pin for repeater=${entry.repeaterId} ' + debugLog( + '[APP] Created FALLBACK RX pin for repeater=${entry.repeaterId} ' 'at ${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); } @@ -2080,7 +2257,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { severity: ErrorSeverity.warning, autoSwitch: false); }, ); - + // Create packet validator with ALL allowed channels (#wardriving, #testing, #ottawa, Public) final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); final allowedChannels = {}; @@ -2091,7 +2268,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { hash: entry.value.hash, ); } - debugLog('[APP] PacketValidator configured with ${allowedChannels.length} channels: ' + debugLog( + '[APP] PacketValidator configured with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); final validator = PacketValidator( allowedChannels: allowedChannels, @@ -2104,15 +2282,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { rxLogger: _rxLogger!, validator: validator, ); - + // Subscribe to LogRxData stream - _logRxDataSubscription = _meshCoreConnection!.logRxDataStream.listen((data) { + _logRxDataSubscription = + _meshCoreConnection!.logRxDataStream.listen((data) { _unifiedRxHandler!.handlePacket(data.raw, data.snr, data.rssi); }); - + // Start listening _unifiedRxHandler!.startListening(); - + debugLog('[APP] Unified RX handler created and listening'); } @@ -2134,14 +2313,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Map TX bytes to trace bytes (3-byte traces not possible, use 4) _traceHopBytes = deviceHopBytes == 3 ? 4 : deviceHopBytes; _pingService?.traceHopBytes = _traceHopBytes; - debugLog('[PATH] Read device path mode: $deviceHopBytes-byte (trace: $_traceHopBytes-byte)'); + debugLog( + '[PATH] Read device path mode: $deviceHopBytes-byte (trace: $_traceHopBytes-byte)'); } else { _hopBytes = 1; _traceHopBytes = 1; } final effective = effectiveHopBytes; - final deviceMode = _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) + final deviceMode = + _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) final deviceHopBytes = deviceMode + 1; if (effective != deviceHopBytes && _originalPathHashMode != null) { @@ -2151,7 +2332,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _hopBytes = effective; // Update runtime state to reflect new mode _traceHopBytes = effective == 3 ? 4 : effective; _pingService?.traceHopBytes = _traceHopBytes; - debugLog('[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); + debugLog( + '[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); // Show warning popup if changing from 1-byte to multi-byte if (deviceMode == 0 && effective > 1) { @@ -2166,13 +2348,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } else if (_originalPathHashMode == null && effective > 1) { // Old firmware doesn't support multi-byte paths — warn user, fall back to 1-byte - debugWarn('[PATH] Device firmware does not report path_hash_mode, cannot set $effective-byte paths'); + debugWarn( + '[PATH] Device firmware does not report path_hash_mode, cannot set $effective-byte paths'); if (enforceHopBytes) { - _pendingPathHashWarning = (hopBytes: effective, reason: 'firmware_unsupported'); + _pendingPathHashWarning = + (hopBytes: effective, reason: 'firmware_unsupported'); notifyListeners(); } } else { - debugLog('[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); + debugLog( + '[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); } } @@ -2182,7 +2367,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_originalPathHashMode == null) return; if (_userChangedPathMode) { - debugLog('[PATH] User manually changed path mode, not restoring on disconnect'); + debugLog( + '[PATH] User manually changed path mode, not restoring on disconnect'); _originalPathHashMode = null; _userChangedPathMode = false; return; @@ -2195,12 +2381,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_hopBytes != originalHopBytes) { try { await _meshCoreConnection?.setPathHashMode(originalMode); - debugLog('[PATH] Restored path hash mode to original: $originalHopBytes-byte'); + debugLog( + '[PATH] Restored path hash mode to original: $originalHopBytes-byte'); } catch (e) { debugError('[PATH] Failed to restore path hash mode: $e'); } } else { - debugLog('[PATH] Path mode unchanged from original ($originalHopBytes-byte), no restore needed'); + debugLog( + '[PATH] Path mode unchanged from original ($originalHopBytes-byte), no restore needed'); } _originalPathHashMode = null; _userChangedPathMode = false; @@ -2211,7 +2399,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_originalPathHashMode == null) { // Old firmware — can't send command, show warning debugWarn('[PATH] Cannot change path mode: firmware does not support it'); - _pendingPathHashWarning = (hopBytes: newHopBytes, reason: 'firmware_unsupported'); + _pendingPathHashWarning = + (hopBytes: newHopBytes, reason: 'firmware_unsupported'); _hopBytes = 1; // Force back to 1 notifyListeners(); return; @@ -2230,7 +2419,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } final mode = newHopBytes - 1; // Convert 1/2/3 → mode 0/1/2 _meshCoreConnection?.setPathHashMode(mode); - debugLog('[PATH] User changed path mode to $newHopBytes-byte (trace: $_traceHopBytes-byte, sent to radio)'); + debugLog( + '[PATH] User changed path mode to $newHopBytes-byte (trace: $_traceHopBytes-byte, sent to radio)'); notifyListeners(); } @@ -2261,7 +2451,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Pending path hash warning data (for UI to show dialog) ({int hopBytes, String reason})? _pendingPathHashWarning; - ({int hopBytes, String reason})? get pendingPathHashWarning => _pendingPathHashWarning; + ({int hopBytes, String reason})? get pendingPathHashWarning => + _pendingPathHashWarning; /// Clear the pending warning after UI has shown it void clearPathHashWarning() { @@ -2406,7 +2597,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService = null; // Do NOT release API session or clear API queue - debugLog('[CONN] Auto-reconnect: preserved API session, cleaned up BLE objects'); + debugLog( + '[CONN] Auto-reconnect: preserved API session, cleaned up BLE objects'); notifyListeners(); @@ -2423,40 +2615,48 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Attempt a single reconnection void _attemptReconnect() { if (_reconnectAttempt >= _maxReconnectAttempts) { - debugLog('[CONN] Auto-reconnect: max attempts reached ($_maxReconnectAttempts)'); + debugLog( + '[CONN] Auto-reconnect: max attempts reached ($_maxReconnectAttempts)'); _abandonAutoReconnect(); return; } _reconnectAttempt++; - debugLog('[CONN] Auto-reconnect attempt $_reconnectAttempt of $_maxReconnectAttempts'); + debugLog( + '[CONN] Auto-reconnect attempt $_reconnectAttempt of $_maxReconnectAttempts'); notifyListeners(); // Use longer delay after bond errors to give iOS time to clear stale keys - final delay = _lastReconnectWasBondError ? _reconnectDelayAfterBondError : _reconnectDelay; + final delay = _lastReconnectWasBondError + ? _reconnectDelayAfterBondError + : _reconnectDelay; // Delay before attempting reconnection _reconnectTimer = Timer(delay, () async { if (!_isAutoReconnecting) return; // Cancelled while waiting try { - debugLog('[CONN] Auto-reconnect: calling reconnectToRememberedDevice()'); + debugLog( + '[CONN] Auto-reconnect: calling reconnectToRememberedDevice()'); await reconnectToRememberedDevice(); // If we get here and connection step is 'connected', success! if (_connectionStep == ConnectionStep.connected) { - debugLog('[CONN] Auto-reconnect succeeded on attempt $_reconnectAttempt'); + debugLog( + '[CONN] Auto-reconnect succeeded on attempt $_reconnectAttempt'); _lastReconnectWasBondError = false; _onReconnectSuccess(); } else if (_isAutoReconnecting) { // Connection failed but didn't throw - try again - debugLog('[CONN] Auto-reconnect: connection did not complete, retrying...'); + debugLog( + '[CONN] Auto-reconnect: connection did not complete, retrying...'); _connectionStep = ConnectionStep.reconnecting; notifyListeners(); _attemptReconnect(); } } catch (e) { - debugError('[CONN] Auto-reconnect attempt $_reconnectAttempt failed: $e'); + debugError( + '[CONN] Auto-reconnect attempt $_reconnectAttempt failed: $e'); if (_isAutoReconnecting) { // Check for iOS apple-code 14 (Peer removed pairing information) // The MeshCore device cleared its bond keys — clear iOS stale bond before retrying @@ -2479,10 +2679,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _idleDisconnectTimer = Timer(_idleDisconnectTimeout, () { if (!isConnected || _autoPingEnabled) return; debugLog('[IDLE] 15-minute idle timeout reached — disconnecting'); - logError('Disconnected: 15 minutes of inactivity', severity: ErrorSeverity.warning); + logError('Disconnected: 15 minutes of inactivity', + severity: ErrorSeverity.warning); disconnect(); }); - debugLog('[IDLE] Idle disconnect timer started (${_idleDisconnectTimeout.inMinutes} min)'); + debugLog( + '[IDLE] Idle disconnect timer started (${_idleDisconnectTimeout.inMinutes} min)'); } /// Cancel the idle disconnect timer @@ -2497,11 +2699,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Detect iOS apple-code 14/15 bond errors and clear the stale bond before retry Future _handleBondErrorIfNeeded(Object error) async { final errorStr = error.toString(); - if (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || errorStr.contains('Peer removed pairing information')) { + if (errorStr.contains('apple-code: 14') || + errorStr.contains('apple-code: 15') || + errorStr.contains('Peer removed pairing information')) { _lastReconnectWasBondError = true; final deviceId = _rememberedDevice?.id; if (deviceId != null) { - debugLog('[CONN] Bond error detected (apple-code 14/15) — clearing stale bond for $deviceId'); + debugLog( + '[CONN] Bond error detected (apple-code 14/15) — clearing stale bond for $deviceId'); await _bluetoothService.removeBond(deviceId); } } @@ -2523,7 +2728,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectAttempt = 0; _autoPingWasEnabled = false; - debugLog('[CONN] Auto-reconnect complete, restoring state (autoPing=$wasAutoPing, mode=$previousMode)'); + debugLog( + '[CONN] Auto-reconnect complete, restoring state (autoPing=$wasAutoPing, mode=$previousMode)'); // Restore auto-ping if it was active if (wasAutoPing) { @@ -2537,13 +2743,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _userRequestedDisconnect || _connectionStep != ConnectionStep.connected || _pingService == null) { - debugLog('[CONN] Skipping delayed auto-ping restore (stale or disconnected state)'); + debugLog( + '[CONN] Skipping delayed auto-ping restore (stale or disconnected state)'); return; } if (!_autoPingEnabled) { toggleAutoPing(previousMode); - debugLog('[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); + debugLog( + '[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); } }); } else { @@ -2582,7 +2790,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Reset antenna and power settings so user must choose again on next connect _antennaRestoredFromDevice = false; _powerRestoredFromDevice = false; - _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); + _preferences = _preferences.copyWith( + externalAntenna: false, externalAntennaSet: false); _savePreferences(); // Reset anonymous mode state (BLE already gone, can't restore name) @@ -2671,11 +2880,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_isAnonymousRenamed && _originalDeviceName != null) { try { await _meshCoreConnection?.setAdvertName(_originalDeviceName!); - debugLog('[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); + debugLog( + '[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); } catch (e) { debugError('[CONN] Anonymous mode: failed to restore name: $e'); - logError('Anonymous Mode: Failed to restore device name. Device may still show as "Anonymous".', - severity: ErrorSeverity.warning, autoSwitch: false); + logError( + 'Anonymous Mode: Failed to restore device name. Device may still show as "Anonymous".', + severity: ErrorSeverity.warning, + autoSwitch: false); } _isAnonymousRenamed = false; _originalDeviceName = null; @@ -2709,7 +2921,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Disconnect BLE (don't call disconnect() twice - meshCoreConnection.disconnect() already does it) await _meshCoreConnection?.disconnect(); - + // Cancel stream subscriptions await _noiseFloorSubscription?.cancel(); _noiseFloorSubscription = null; @@ -2730,7 +2942,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _displayDeviceName = null; _antennaRestoredFromDevice = false; _powerRestoredFromDevice = false; - _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); + _preferences = _preferences.copyWith( + externalAntenna: false, externalAntennaSet: false); _savePreferences(); _currentNoiseFloor = null; _currentBatteryPercent = null; @@ -2830,7 +3043,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); if (!result.isValid) { - debugWarn('[API] Session check failed: ${result.reason} - ${result.message ?? "Session expired"}'); + debugWarn( + '[API] Session check failed: ${result.reason} - ${result.message ?? "Session expired"}'); // Note: onSessionError callback will trigger disconnect for critical errors return false; } @@ -2851,7 +3065,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ? DateTime.now().difference(_idleAutoStopReference!).inMinutes : 30; debugLog('[AUTO] Auto-stop triggered: idle for $elapsed minutes'); - logError('Auto-ping stopped: no movement for 30 minutes', severity: ErrorSeverity.warning, autoSwitch: false); + logError('Auto-ping stopped: no movement for 30 minutes', + severity: ErrorSeverity.warning, autoSwitch: false); _idleAutoStopReference = null; toggleAutoPing(_autoMode); } @@ -2919,7 +3134,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Passive Mode is listening only, no cooldown needed if (isTxMode) { _cooldownTimer.start(5000); - debugLog('[${mode.name.toUpperCase()} MODE] Shared cooldown started (5s) - blocks TX Ping and TX modes'); + debugLog( + '[${mode.name.toUpperCase()} MODE] Shared cooldown started (5s) - blocks TX Ping and TX modes'); } else { debugLog('[PASSIVE MODE] Stopped - no cooldown (listen-only mode)'); } @@ -2936,7 +3152,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Block starting if shared cooldown is active (TX modes only) // Passive Mode is listening only and can start during cooldown if (isTxMode && _cooldownTimer.isRunning) { - debugLog('[${mode.name.toUpperCase()} MODE] Start blocked by shared cooldown'); + debugLog( + '[${mode.name.toUpperCase()} MODE] Start blocked by shared cooldown'); return false; } @@ -2967,7 +3184,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Set interval from user preferences before starting final intervalMs = _preferences.autoPingInterval * 1000; _pingService!.setAutoPingInterval(intervalMs); - debugLog('[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); + debugLog( + '[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); final started = await _pingService!.enableAutoPing( passiveMode: isPassive, @@ -2978,7 +3196,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!started) { // Blocked by cooldown or already enabled if (_pingService!.isInCooldown()) { - debugLog('[PING] Auto mode start blocked by cooldown (${_pingService!.getRemainingCooldownSeconds()}s remaining)'); + debugLog( + '[PING] Auto mode start blocked by cooldown (${_pingService!.getRemainingCooldownSeconds()}s remaining)'); } else { debugLog('[PING] Auto mode start blocked'); } @@ -2991,7 +3210,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _idleAutoStopReference = DateTime.now(); // Start noise floor session for graph tracking - final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : isTargeted ? 'targeted' : 'active'; + final sessionLabel = isPassive + ? 'passive' + : isHybrid + ? 'hybrid' + : isTargeted + ? 'targeted' + : 'active'; _startNoiseFloorSession(sessionLabel); // Enable heartbeat for all auto-ping modes (not offline mode) @@ -3011,7 +3236,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Start background service for continuous operation - final modeName = isPassive ? 'Passive Mode' : isHybrid ? 'Hybrid Mode' : isTargeted ? 'Trace Mode' : 'Active Mode'; + final modeName = isPassive + ? 'Passive Mode' + : isHybrid + ? 'Hybrid Mode' + : isTargeted + ? 'Trace Mode' + : 'Active Mode'; await BackgroundServiceManager.startService( mode: modeName, txCount: _pingStats.txCount, @@ -3050,7 +3281,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_discLogEntries.length > _maxLogEntries) { _discLogEntries.removeLast(); } - debugLog('[APP] Discovery log entry added: ${entry.nodeCount} nodes discovered'); + debugLog( + '[APP] Discovery log entry added: ${entry.nodeCount} nodes discovered'); notifyListeners(); } @@ -3060,14 +3292,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_traceLogEntries.length > _maxLogEntries) { _traceLogEntries.removeLast(); } - debugLog('[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); + debugLog( + '[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); // Update top repeaters overlay with successful trace result if (entry.success && entry.localSnr != null) { // Truncate 4-byte trace IDs to 3 bytes (6 hex chars) to fit overlay final id = entry.targetRepeaterId.toUpperCase(); final displayId = id.length > 6 ? id.substring(0, 6) : id; - _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)], OverlayPingType.trace); + _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)], + OverlayPingType.trace); } notifyListeners(); @@ -3075,13 +3309,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Log a user-facing error message /// Set [autoSwitch] to false to log without navigating to error log tab - void logError(String message, {ErrorSeverity severity = ErrorSeverity.error, bool autoSwitch = true}) { + void logError(String message, + {ErrorSeverity severity = ErrorSeverity.error, bool autoSwitch = true}) { _errorLogEntries.add(UserErrorEntry( timestamp: DateTime.now(), message: message, severity: severity, )); - if (_errorLogEntries.length > _maxErrorEntries) _errorLogEntries.removeAt(0); + if (_errorLogEntries.length > _maxErrorEntries) + _errorLogEntries.removeAt(0); if (autoSwitch) { _requestErrorLogSwitch = true; // Auto-switch to error log } @@ -3129,9 +3365,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Hot-switch while connected - return enabled - ? await _switchToOfflineMode() - : await _switchToOnlineMode(); + return enabled ? await _switchToOfflineMode() : await _switchToOnlineMode(); } /// Simple offline mode change (when not connected) @@ -3158,7 +3392,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _stopOfflineAutoSaveTimer(); // Re-check zone status when exiting offline mode if (_currentPosition != null) { - debugLog('[GEOFENCE] Re-checking zone status after offline mode disabled'); + debugLog( + '[GEOFENCE] Re-checking zone status after offline mode disabled'); checkZoneStatus(); } } @@ -3257,13 +3492,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { connectedDeviceName?.replaceFirst('MeshCore-', '')); if (deviceName == null || deviceName.isEmpty) { - debugError('[APP] Cannot switch to online mode: no device name available'); + debugError( + '[APP] Cannot switch to online mode: no device name available'); _modeSwitchError = 'Device name not available'; return (success: false, error: _modeSwitchError); } if (_devicePublicKey == null) { - debugError('[APP] Cannot switch to online mode: no public key available'); + debugError( + '[APP] Cannot switch to online mode: no public key available'); _modeSwitchError = 'Device public key not available'; return (success: false, error: _modeSwitchError); } @@ -3280,17 +3517,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (zoneCode == null) { debugError('[APP] Cannot switch to online mode: not in a zone'); - _modeSwitchError = 'Could not determine your zone. Check GPS and internet connection.'; + _modeSwitchError = + 'Could not determine your zone. Check GPS and internet connection.'; return (success: false, error: _modeSwitchError); } // ============================================================ // STAGE 1: Try existing public_key authentication // ============================================================ - debugLog('[APP] Stage 1: Attempting auth with public_key: ${_devicePublicKey!.substring(0, 16)}...'); + debugLog( + '[APP] Stage 1: Attempting auth with public_key: ${_devicePublicKey!.substring(0, 16)}...'); final modelString = _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown'; + _meshCoreConnection?.deviceInfo?.manufacturer ?? + 'Unknown'; var result = await _apiService.requestAuth( reason: 'connect', @@ -3310,10 +3550,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _maintenanceMode = true; _maintenanceMessage = result['maintenance_message'] as String?; _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + debugLog( + '[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); _startMaintenancePolling(); notifyListeners(); - _modeSwitchError = _maintenanceMessage ?? 'Service is under maintenance'; + _modeSwitchError = + _maintenanceMessage ?? 'Service is under maintenance'; return (success: false, error: _modeSwitchError); } @@ -3333,11 +3575,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } else { // Stage 1 failed — check if Stage 2 is worth attempting - debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + debugLog( + '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); final stage1Reason = result['reason'] as String?; if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { - debugError('[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + debugError( + '[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); _modeSwitchError = result['message'] as String? ?? 'GPS error'; return (success: false, error: _modeSwitchError); } @@ -3351,10 +3595,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { debugLog('[APP] Requesting signed contact URI from device...'); contactUri = await _meshCoreConnection!.exportContact(); - debugLog('[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + debugLog( + '[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); } catch (e) { debugError('[APP] Failed to get contact URI from device: $e'); - _modeSwitchError = 'Companion not found in backend and failed to register via API'; + _modeSwitchError = + 'Companion not found in backend and failed to register via API'; return (success: false, error: _modeSwitchError); } @@ -3378,9 +3624,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (registerResult['success'] != true) { - final serverReason = registerResult['reason'] as String? ?? 'registration_failed'; + final serverReason = + registerResult['reason'] as String? ?? 'registration_failed'; final serverMessage = registerResult['message'] as String?; - debugError('[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + debugError( + '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); _modeSwitchError = serverMessage ?? 'Registration rejected by server'; return (success: false, error: _modeSwitchError); } @@ -3509,7 +3757,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Note: Connection already validates device name exists, so this should never be null final offlineDeviceName = _isAnonymousRenamed ? _originalDeviceName - : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); await _offlineSessionService.saveSession( pings, devicePublicKey: _devicePublicKey, @@ -3525,14 +3774,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Periodically auto-save offline pings to prevent data loss from app kill. /// Uses a non-destructive snapshot so in-memory accumulation continues. void _autoSaveOfflinePings() { - if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) return; + if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) + return; final pings = _apiQueueService.getOfflinePingsSnapshot(); if (pings.isEmpty) return; final offlineDeviceName = _isAnonymousRenamed ? _originalDeviceName - : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); _offlineSessionService.updateCurrentSession( pings, @@ -3582,7 +3833,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (success) { // Delete the session file on successful upload await _offlineSessionService.deleteSession(filename); - debugLog('[API] Uploaded and deleted offline session: $filename (${pings.length} pings)'); + debugLog( + '[API] Uploaded and deleted offline session: $filename (${pings.length} pings)'); } else { debugError('[API] Failed to upload offline session: $filename'); } @@ -3607,7 +3859,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }) async { // Concurrency guard — only one offline upload at a time if (_isUploadingOfflineSession) { - debugWarn('[OFFLINE] Upload already in progress, rejecting concurrent request'); + debugWarn( + '[OFFLINE] Upload already in progress, rejecting concurrent request'); return OfflineUploadResult.uploadInProgress; } @@ -3615,7 +3868,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); try { - return await _uploadOfflineSessionIsolated(filename, onProgress: onProgress); + return await _uploadOfflineSessionIsolated(filename, + onProgress: onProgress); } finally { _isUploadingOfflineSession = false; notifyListeners(); @@ -3662,13 +3916,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 3. Check GPS before auth — the server requires current coordinates for geo-auth if (_currentPosition == null) { - debugError('[OFFLINE] Upload requires GPS - location services not available'); + debugError( + '[OFFLINE] Upload requires GPS - location services not available'); return OfflineUploadResult.gpsRequired; } // 4. Authenticate with offline_mode: true, skipSessionStore: true // This prevents writing to shared _sessionId/_txAllowed/etc. - debugLog('[OFFLINE] Authenticating for offline upload with device: $deviceName'); + debugLog( + '[OFFLINE] Authenticating for offline upload with device: $deviceName'); final authResult = await _apiService.requestAuth( reason: 'connect', publicKey: publicKey, @@ -3697,7 +3953,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Stage 2: If unknown_device and we have a stored contactUri, attempt registration if (reason == 'unknown_device' && session.contactUri != null) { - debugLog('[OFFLINE] Stage 2: Attempting registration via stored contact URI...'); + debugLog( + '[OFFLINE] Stage 2: Attempting registration via stored contact URI...'); final registerResult = await _apiService.requestAuth( reason: 'register', contactUri: session.contactUri, @@ -3719,7 +3976,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return OfflineUploadResult.authFailed; } - debugLog('[OFFLINE] Stage 2 succeeded: device registered for offline upload'); + debugLog( + '[OFFLINE] Stage 2 succeeded: device registered for offline upload'); effectiveAuth = registerResult; } else { debugError('[OFFLINE] Auth failed: $reason'); @@ -3734,7 +3992,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return OfflineUploadResult.authFailed; } - debugLog('[OFFLINE] Authenticated with isolated session: $offlineSessionId'); + debugLog( + '[OFFLINE] Authenticated with isolated session: $offlineSessionId'); // Delay after auth before posting await Future.delayed(const Duration(seconds: 1)); @@ -3750,7 +4009,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { onProgress?.call('Batch $batchNum/$totalBatches'); final batch = pings.skip(i).take(batchSize).toList(); - final result = await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); + final result = + await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); if (result == UploadResult.success) { uploadedCount += batch.length; debugLog('[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); @@ -3779,7 +4039,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); return OfflineUploadResult.success; } else { - debugWarn('[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); + debugWarn( + '[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); notifyListeners(); return OfflineUploadResult.partialFailure; } @@ -3803,7 +4064,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Update user preferences void updatePreferences(UserPreferences preferences) { - debugLog('[APP] Preferences updated: externalAntennaSet=${preferences.externalAntennaSet}, ' + debugLog( + '[APP] Preferences updated: externalAntennaSet=${preferences.externalAntennaSet}, ' 'externalAntenna=${preferences.externalAntenna}, autoPowerSet=${preferences.autoPowerSet}'); _preferences = preferences; @@ -3813,26 +4075,32 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _powerRestoredFromDevice = false; // Persist antenna choice per device name (use original name, not "Anonymous") - final deviceName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; + final deviceName = + _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; if (deviceName != null && preferences.externalAntennaSet) { _deviceAntennaPreferences[deviceName] = preferences.externalAntenna; _saveDeviceAntennaPreferences(); - debugLog('[APP] Saved antenna preference for "$deviceName": ${preferences.externalAntenna ? "external" : "device"}'); + debugLog( + '[APP] Saved antenna preference for "$deviceName": ${preferences.externalAntenna ? "external" : "device"}'); } // Persist power override per device name - if (deviceName != null && preferences.powerLevelSet && !preferences.autoPowerSet) { + if (deviceName != null && + preferences.powerLevelSet && + !preferences.autoPowerSet) { _devicePowerOverrides[deviceName] = { 'powerLevel': preferences.powerLevel, 'txPower': preferences.txPower, }; _saveDevicePowerOverrides(); - debugLog('[APP] Saved power override for "$deviceName": ${preferences.powerLevel}W'); + debugLog( + '[APP] Saved power override for "$deviceName": ${preferences.powerLevel}W'); } else if (deviceName != null && preferences.autoPowerSet) { // User re-selected the auto-detected value — clear any saved override if (_devicePowerOverrides.remove(deviceName) != null) { _saveDevicePowerOverrides(); - debugLog('[APP] Cleared power override for "$deviceName" (auto-detected selected)'); + debugLog( + '[APP] Cleared power override for "$deviceName" (auto-detected selected)'); } } @@ -3843,7 +4111,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _syncCarpeaterPrefix(); // Propagate min ping distance to GpsService and PingService - _gpsService.setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); + _gpsService + .setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); PingService.currentMinDistance = preferences.minPingDistanceMeters; notifyListeners(); @@ -3859,7 +4128,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); // If connected, disconnect and reconnect for clean auth session - if (_connectionStatus == ConnectionStatus.connected && _meshCoreConnection != null) { + if (_connectionStatus == ConnectionStatus.connected && + _meshCoreConnection != null) { final deviceToReconnect = _bluetoothService.connectedDevice; if (deviceToReconnect != null) { _requestConnectionTabSwitch = true; @@ -3874,7 +4144,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Propagate carpeaterPrefix to live TxTracker and RxLogger void _syncCarpeaterPrefix() { - final prefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + final prefix = + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; if (_txTracker != null) { _txTracker!.carpeaterPrefix = prefix; debugLog('[APP] Synced TxTracker.carpeaterPrefix = ${prefix ?? 'null'}'); @@ -3927,7 +4198,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void setCoverageOverlayOpacity(double opacity) { final clamped = opacity.clamp(0.3, 1.0); _preferences = _preferences.copyWith(coverageOverlayOpacity: clamped); - debugLog('[MAP] Coverage overlay opacity set to ${clamped.toStringAsFixed(2)}'); + debugLog( + '[MAP] Coverage overlay opacity set to ${clamped.toStringAsFixed(2)}'); notifyListeners(); _savePreferences(); } @@ -3944,7 +4216,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void setColorVisionType(String type) { _preferences = _preferences.copyWith(colorVisionType: type); PingColors.setColorVisionType( - ColorVisionType.values.firstWhere((e) => e.name == type, orElse: () => ColorVisionType.none), + ColorVisionType.values.firstWhere((e) => e.name == type, + orElse: () => ColorVisionType.none), ); debugLog('[A11Y] Color vision type set to $type'); notifyListeners(); @@ -4025,7 +4298,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Play disconnect alert if enabled (triple beep for unexpected ping stop) void _playDisconnectAlert() { - if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) return; + if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) + return; debugLog('[AUDIO] Playing disconnect alert — pinging stopped unexpectedly'); _audioService.playAlertSound(); } @@ -4109,13 +4383,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Rate limiting should warn but not disconnect (per PORTED_APP behavior) if (reason == 'rate_limited') { - debugWarn('[API] Rate limited - continuing without disconnect: $userMessage'); + debugWarn( + '[API] Rate limited - continuing without disconnect: $userMessage'); return; } // Zone grace period: intercept outside_zone during active session if (reason == 'outside_zone' && _isInZoneGracePeriod) { - debugLog('[ZONE GRACE] outside_zone during grace period — already handling'); + debugLog( + '[ZONE GRACE] outside_zone during grace period — already handling'); return; } if (reason == 'outside_zone' && isConnected && !_isInZoneGracePeriod) { @@ -4171,7 +4447,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { deviceName: offlineDeviceName, contactUri: _offlineContactUri, ); - debugLog('[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); + debugLog( + '[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); } } catch (e) { debugError('[APP] Failed to preserve queue to offline storage: $e'); @@ -4185,7 +4462,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Handle maintenance mode while connected - end session and log error - Future _handleMaintenanceModeConnected(String message, String? url) async { + Future _handleMaintenanceModeConnected( + String message, String? url) async { debugLog('[MAINTENANCE] Ending session due to maintenance mode'); // Alert if auto-ping was running (maintenance is not user-initiated) @@ -4194,7 +4472,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Log to error log (this sets _requestErrorLogSwitch = true) - logError('Maintenance Mode Enabled: $message', severity: ErrorSeverity.warning); + logError('Maintenance Mode Enabled: $message', + severity: ErrorSeverity.warning); // Disconnect (ends session, cleans up) await disconnect(); @@ -4225,7 +4504,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Start periodic polling to check if maintenance mode has ended void _startMaintenancePolling() { _maintenanceCheckTimer?.cancel(); - _maintenanceCheckTimer = Timer.periodic(const Duration(seconds: 30), (_) async { + _maintenanceCheckTimer = + Timer.periodic(const Duration(seconds: 30), (_) async { if (!_maintenanceMode) { _maintenanceCheckTimer?.cancel(); _maintenanceCheckTimer = null; @@ -4255,7 +4535,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Validate GPS position for API calls /// Returns (isValid, errorMessage, errorCode) tuple - ({bool isValid, String? errorMessage, String? errorCode}) _validateGps(Position? position) { + ({bool isValid, String? errorMessage, String? errorCode}) _validateGps( + Position? position) { if (position == null) { return ( isValid: false, @@ -4269,7 +4550,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (ageSeconds > _maxGpsAgeSeconds) { return ( isValid: false, - errorMessage: 'GPS data is ${ageSeconds}s old (max ${_maxGpsAgeSeconds}s)', + errorMessage: + 'GPS data is ${ageSeconds}s old (max ${_maxGpsAgeSeconds}s)', errorCode: 'gps_stale', ); } @@ -4278,7 +4560,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (position.accuracy > _maxGpsAccuracyMeters) { return ( isValid: false, - errorMessage: 'GPS accuracy is ${position.accuracy.toStringAsFixed(0)}m (max ${_maxGpsAccuracyMeters.toStringAsFixed(0)}m)', + errorMessage: + 'GPS accuracy is ${position.accuracy.toStringAsFixed(0)}m (max ${_maxGpsAccuracyMeters.toStringAsFixed(0)}m)', errorCode: 'gps_inaccurate', ); } @@ -4317,7 +4600,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Schedule a zone check retry with countdown timer for UI feedback - void _scheduleZoneCheckRetry({required int seconds, required String error, required String reason}) { + void _scheduleZoneCheckRetry( + {required int seconds, required String error, required String reason}) { // Cancel any existing timers _zoneCheckRetryTimer?.cancel(); _zoneCheckCountdownTimer?.cancel(); @@ -4358,11 +4642,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Should be called on app launch and every 100m of GPS movement while disconnected Future checkZoneStatus() async { debugLog('[GEOFENCE] checkZoneStatus() called'); - debugLog('[GEOFENCE] Pre-check state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + debugLog( + '[GEOFENCE] Pre-check state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' 'hasPosition=${_currentPosition != null}, gpsStatus=$_gpsStatus'); if (_currentPosition == null) { - debugLog('[GEOFENCE] Cannot check zone status: no GPS position (gpsStatus=$_gpsStatus)'); + debugLog( + '[GEOFENCE] Cannot check zone status: no GPS position (gpsStatus=$_gpsStatus)'); return; } @@ -4372,18 +4658,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (_isCheckingZone) { - debugLog('[GEOFENCE] Zone check already in progress, skipping duplicate call'); + debugLog( + '[GEOFENCE] Zone check already in progress, skipping duplicate call'); return; } - debugLog('[GEOFENCE] Starting zone check - setting isCheckingZone=true (previous inZone=$_inZone)'); + debugLog( + '[GEOFENCE] Starting zone check - setting isCheckingZone=true (previous inZone=$_inZone)'); _isCheckingZone = true; // Don't clear error or notify here — keep current error view visible during retry // to avoid a full-screen flash. Error is cleared in finally block on success, // or overwritten by _scheduleZoneCheckRetry on failure. try { - debugLog('[GEOFENCE] Making API call to check zone at ${_currentPosition!.latitude.toStringAsFixed(5)}, ' + debugLog( + '[GEOFENCE] Making API call to check zone at ${_currentPosition!.latitude.toStringAsFixed(5)}, ' '${_currentPosition!.longitude.toStringAsFixed(5)} (accuracy: ${_currentPosition!.accuracy.toStringAsFixed(1)}m)'); final result = await _apiService.checkZoneStatus( @@ -4393,7 +4682,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { appVersion: _appVersion, ); - debugLog('[GEOFENCE] API response received: ${result != null ? 'valid' : 'null'}'); + debugLog( + '[GEOFENCE] API response received: ${result != null ? 'valid' : 'null'}'); if (result == null) { // Update position even on failure to prevent zone check flooding @@ -4416,7 +4706,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _maintenanceMode = true; _maintenanceMessage = result['maintenance_message'] as String?; _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Zone check returned maintenance: $_maintenanceMessage'); + debugLog( + '[MAINTENANCE] Zone check returned maintenance: $_maintenanceMessage'); // Start polling to detect when maintenance ends _startMaintenancePolling(); @@ -4433,29 +4724,36 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final success = result['success'] == true; if (!success) { final reason = result['reason'] as String?; - final message = result['message'] as String? ?? 'Zone status check failed'; - debugError('[GEOFENCE] Zone status check failed: reason=$reason, message=$message'); + final message = + result['message'] as String? ?? 'Zone status check failed'; + debugError( + '[GEOFENCE] Zone status check failed: reason=$reason, message=$message'); if (reason == 'gps_inaccurate') { logError('GPS Accuracy Error\n$message', autoSwitch: false); // Schedule a retry so we don't depend solely on the GPS stream firing // again — on first launch the stream may stall on a low-accuracy fix // and the coverage tile overlay would never load. - _scheduleZoneCheckRetry(seconds: 10, error: message, reason: 'gps_inaccurate'); + _scheduleZoneCheckRetry( + seconds: 10, error: message, reason: 'gps_inaccurate'); } else if (reason == 'gps_stale') { logError('GPS Stale Error\n$message', autoSwitch: false); - _scheduleZoneCheckRetry(seconds: 10, error: message, reason: 'gps_stale'); + _scheduleZoneCheckRetry( + seconds: 10, error: message, reason: 'gps_stale'); } else if (reason == 'zone_disabled') { final errorMsg = _getErrorMessage(reason, message); logError(errorMsg); - _scheduleZoneCheckRetry(seconds: 30, error: errorMsg, reason: reason!); + _scheduleZoneCheckRetry( + seconds: 30, error: errorMsg, reason: reason!); } else if (reason == 'bad_key' || reason == 'invalid_request') { final errorMsg = _getErrorMessage(reason, message); logError(errorMsg); - _scheduleZoneCheckRetry(seconds: 60, error: errorMsg, reason: reason!); + _scheduleZoneCheckRetry( + seconds: 60, error: errorMsg, reason: reason!); } else { // Unknown server errors — use server message - _scheduleZoneCheckRetry(seconds: 15, error: message, reason: 'server_error'); + _scheduleZoneCheckRetry( + seconds: 15, error: message, reason: 'server_error'); } return; @@ -4487,14 +4785,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] In zone: $newZoneName ($newZoneCode)'); if (newZoneCode.isNotEmpty) { - _fetchRepeatersForZone(newZoneCode); // fire-and-forget — don't block zone check + _fetchRepeatersForZone( + newZoneCode); // fire-and-forget — don't block zone check } } else { _currentZone = null; _nearestZone = result['nearest_zone'] as Map?; final nearestName = _nearestZone?['name'] ?? 'Unknown'; - final distanceKm = (_nearestZone?['distance_km'] as num?)?.toStringAsFixed(1) ?? '?'; - debugWarn('[GEOFENCE] Outside zone. Nearest: $nearestName (${distanceKm}km away)'); + final distanceKm = + (_nearestZone?['distance_km'] as num?)?.toStringAsFixed(1) ?? '?'; + debugWarn( + '[GEOFENCE] Outside zone. Nearest: $nearestName (${distanceKm}km away)'); // Clear repeaters when exiting zone _repeaters = []; @@ -4505,7 +4806,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugError('[GEOFENCE] Zone status check error: $e'); } finally { _isCheckingZone = false; - debugLog('[GEOFENCE] Zone check complete - final state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + debugLog( + '[GEOFENCE] Zone check complete - final state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' 'zoneName=${_currentZone?['name']}, zoneCode=${_currentZone?['code']}'); notifyListeners(); } @@ -4521,11 +4823,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // If auth response includes slot data, use it directly (forward-compatible) if (authResult.containsKey('slots_available')) { _currentZone!['slots_available'] = authResult['slots_available']; - debugLog('[CAPACITY] Updated slots_available from auth: ${authResult['slots_available']}'); + debugLog( + '[CAPACITY] Updated slots_available from auth: ${authResult['slots_available']}'); } if (authResult.containsKey('slots_max')) { _currentZone!['slots_max'] = authResult['slots_max']; - debugLog('[CAPACITY] Updated slots_max from auth: ${authResult['slots_max']}'); + debugLog( + '[CAPACITY] Updated slots_max from auth: ${authResult['slots_max']}'); } // Sync at_capacity with tx_allowed @@ -4535,7 +4839,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // If auth says TX not allowed and server didn't provide slot data, set slots to 0 if (!authTxAllowed && !authResult.containsKey('slots_available')) { _currentZone!['slots_available'] = 0; - debugLog('[CAPACITY] Zone at TX capacity per auth, set slots_available=0'); + debugLog( + '[CAPACITY] Zone at TX capacity per auth, set slots_available=0'); } // If auth says TX allowed and we have slot data but server didn't provide updated count, @@ -4593,8 +4898,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Future _startZoneGracePeriod() async { if (_isInZoneGracePeriod) return; _isInZoneGracePeriod = true; - debugLog('[ZONE GRACE] Entering zone grace period (${_zoneGraceTimeout.inMinutes}m timeout)'); - logError('Left wardriving zone. Searching for nearby zone...', severity: ErrorSeverity.warning, autoSwitch: false); + debugLog( + '[ZONE GRACE] Entering zone grace period (${_zoneGraceTimeout.inMinutes}m timeout)'); + logError('Left wardriving zone. Searching for nearby zone...', + severity: ErrorSeverity.warning, autoSwitch: false); // Save auto-ping state for restoration on zone re-entry _autoPingWasEnabledBeforeGrace = _autoPingEnabled; @@ -4676,18 +4983,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // checkZoneStatus updates _inZone and calls notifyListeners (overlay auto-updates) if (_inZone == true) { final reEnteredZoneCode = _currentZone?['code'] as String? ?? ''; - debugLog('[ZONE GRACE] Zone re-entered: ${_currentZone?['name']} ($reEnteredZoneCode)'); + debugLog( + '[ZONE GRACE] Zone re-entered: ${_currentZone?['name']} ($reEnteredZoneCode)'); // If re-entering a DIFFERENT zone, do a full zone transfer instead of simple resume if (_sessionZoneCode != null && reEnteredZoneCode.isNotEmpty && reEnteredZoneCode != _sessionZoneCode) { - debugLog('[ZONE GRACE] Re-entered different zone ($reEnteredZoneCode vs session $_sessionZoneCode) — transferring'); + debugLog( + '[ZONE GRACE] Re-entered different zone ($reEnteredZoneCode vs session $_sessionZoneCode) — transferring'); _cancelZoneGraceTimers(); _isInZoneGracePeriod = false; _zoneGraceSecondsRemaining = 0; _autoPingWasEnabledBeforeGrace = false; - await _handleZoneTransfer(reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown'); + await _handleZoneTransfer( + reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown'); return; } @@ -4708,8 +5018,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _zoneGraceSecondsRemaining = 0; _autoPingWasEnabledBeforeGrace = false; - debugLog('[ZONE GRACE] Resuming wardriving (autoPing=$wasAutoPing, mode=$previousMode)'); - logError('Re-entered wardriving zone. Resuming...', severity: ErrorSeverity.info, autoSwitch: false); + debugLog( + '[ZONE GRACE] Resuming wardriving (autoPing=$wasAutoPing, mode=$previousMode)'); + logError('Re-entered wardriving zone. Resuming...', + severity: ErrorSeverity.info, autoSwitch: false); // Re-enable heartbeat _apiService.enableHeartbeat( @@ -4735,7 +5047,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _userRequestedDisconnect || _connectionStep != ConnectionStep.connected || _pingService == null) { - debugLog('[ZONE GRACE] Skipping auto-ping restore (stale or disconnected state)'); + debugLog( + '[ZONE GRACE] Skipping auto-ping restore (stale or disconnected state)'); return; } if (!_autoPingEnabled) { @@ -4782,7 +5095,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Handle zone-to-zone transfer during active wardriving session. /// Releases old zone session and acquires new session for target zone. /// Preserves BLE connection and radio configuration. - Future _handleZoneTransfer(String newZoneCode, String newZoneName) async { + Future _handleZoneTransfer( + String newZoneCode, String newZoneName) async { if (_isZoneTransferInProgress) { debugLog('[ZONE] Transfer already in progress, skipping'); return; @@ -4845,7 +5159,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); - if (_devicePublicKey == null || deviceName == null || _currentPosition == null) { + if (_devicePublicKey == null || + deviceName == null || + _currentPosition == null) { debugError('[ZONE] Cannot transfer: missing device key, name, or GPS'); await disconnect(); return; @@ -4860,7 +5176,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: newZoneCode, model: _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown', + _meshCoreConnection?.deviceInfo?.manufacturer ?? + 'Unknown', lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, @@ -4869,7 +5186,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 10. Check auth result if (result == null) { debugError('[ZONE] Auth failed for zone $newZoneCode: network error'); - logError('Zone transfer failed: unable to reach server', severity: ErrorSeverity.error); + logError('Zone transfer failed: unable to reach server', + severity: ErrorSeverity.error); await disconnect(); return; } @@ -4887,8 +5205,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (result['success'] != true) { final reason = result['reason'] as String? ?? 'unknown'; final message = result['message'] as String? ?? 'Auth failed'; - debugError('[ZONE] Auth failed for zone $newZoneCode: $reason - $message'); - logError('Zone transfer failed: $message', severity: ErrorSeverity.error); + debugError( + '[ZONE] Auth failed for zone $newZoneCode: $reason - $message'); + logError('Zone transfer failed: $message', + severity: ErrorSeverity.error); await disconnect(); return; } @@ -4911,7 +5231,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 13. Update PacketValidator with new channel configuration if (_unifiedRxHandler != null) { - final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); + final allowedChannelsData = + ChannelService.getAllowedChannelsForValidator(); final allowedChannels = {}; for (final entry in allowedChannelsData.entries) { allowedChannels[entry.key] = ChannelInfo( @@ -4925,13 +5246,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { disableRssiFilter: _preferences.disableRssiFilter, ); _unifiedRxHandler!.updateValidator(newValidator); - debugLog('[ZONE] PacketValidator updated with ${allowedChannels.length} channels'); + debugLog( + '[ZONE] PacketValidator updated with ${allowedChannels.length} channels'); } // 14. Update flood scope from new auth response final apiScopes = _apiService.scopes; final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; - final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; + final isWildcard = + firstScope == null || firstScope == '*' || firstScope == '#*'; if (!isWildcard) { final scopeName = firstScope; _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; @@ -4960,8 +5283,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[ZONE] Discovery drop force-enabled by new zone admin'); } if (_preferences.autoPingInterval < _apiService.minModeInterval) { - _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); - debugLog('[ZONE] Auto-ping interval bumped to ${_apiService.minModeInterval}s by new zone admin'); + _preferences = _preferences.copyWith( + autoPingInterval: _apiService.minModeInterval); + debugLog( + '[ZONE] Auto-ping interval bumped to ${_apiService.minModeInterval}s by new zone admin'); } // 16. Reconfigure path hash mode if new zone requires different hop bytes @@ -5000,7 +5325,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _userRequestedDisconnect || _connectionStep != ConnectionStep.connected || _pingService == null) { - debugLog('[ZONE] Skipping auto-ping restore (stale or disconnected state)'); + debugLog( + '[ZONE] Skipping auto-ping restore (stale or disconnected state)'); return; } if (!_autoPingEnabled) { @@ -5053,7 +5379,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); notifyListeners(); } else { - debugWarn('[MAP] No repeaters returned for zone $iata — will retry on next zone check'); + debugWarn( + '[MAP] No repeaters returned for zone $iata — will retry on next zone check'); } } catch (e) { debugError('[MAP] Failed to fetch repeaters: $e'); @@ -5304,7 +5631,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Load a route file (KML or GPX) bool loadSimulatorRoute(String content, {String? filename}) { - final success = _gpsService.simulator.loadRoute(content, filename: filename); + final success = + _gpsService.simulator.loadRoute(content, filename: filename); if (success) { _gpsSimulatorPattern = SimulatorPattern.route; // If simulator is running, it will automatically use the new route @@ -5363,7 +5691,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Attempt to recover from Hive corruption - Future?> _attemptHiveRecovery(String boxName, Duration timeout) async { + Future?> _attemptHiveRecovery( + String boxName, Duration timeout) async { try { debugLog('[HIVE] Deleting corrupted box "$boxName"...'); await Hive.deleteBoxFromDisk(boxName); @@ -5377,7 +5706,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return box; } catch (e) { debugError('[HIVE] Recovery failed for "$boxName": $e'); - logError('Storage for "$boxName" unavailable - some settings may not persist'); + logError( + 'Storage for "$boxName" unavailable - some settings may not persist'); return null; } } @@ -5393,7 +5723,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { final json = box.get('device'); if (json != null) { - _rememberedDevice = RememberedDevice.fromJson(Map.from(json)); + _rememberedDevice = + RememberedDevice.fromJson(Map.from(json)); debugLog('[APP] Loaded remembered device: ${_rememberedDevice!.name}'); notifyListeners(); } @@ -5478,13 +5809,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { final json = box.get('preferences'); if (json != null) { - _preferences = UserPreferences.fromJson(Map.from(json)); - debugLog('[APP] Loaded preferences: interval=${_preferences.autoPingInterval}s, ' + _preferences = + UserPreferences.fromJson(Map.from(json)); + debugLog( + '[APP] Loaded preferences: interval=${_preferences.autoPingInterval}s, ' 'ignoreCarpeater=${_preferences.ignoreCarpeater}, ' 'ignoreRepeaterId=${_preferences.ignoreRepeaterId}'); // Apply saved min ping distance to GpsService and PingService - _gpsService.setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); + _gpsService + .setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); PingService.currentMinDistance = _preferences.minPingDistanceMeters; // Apply saved color vision type @@ -5528,7 +5862,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final raw = box.get('device_antenna_preferences'); if (raw != null) { _deviceAntennaPreferences = Map.from(raw as Map); - debugLog('[APP] Loaded antenna preferences for ${_deviceAntennaPreferences.length} device(s)'); + debugLog( + '[APP] Loaded antenna preferences for ${_deviceAntennaPreferences.length} device(s)'); } } catch (e) { debugLog('[APP] Failed to load device antenna preferences: $e'); @@ -5560,9 +5895,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final raw = box.get('device_power_overrides'); if (raw != null) { _devicePowerOverrides = (raw as Map).map( - (key, value) => MapEntry(key.toString(), Map.from(value as Map)), + (key, value) => + MapEntry(key.toString(), Map.from(value as Map)), ); - debugLog('[APP] Loaded power overrides for ${_devicePowerOverrides.length} device(s)'); + debugLog( + '[APP] Loaded power overrides for ${_devicePowerOverrides.length} device(s)'); } } catch (e) { debugLog('[APP] Failed to load device power overrides: $e'); @@ -5591,10 +5928,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (box == null) return; try { - _lastConnectedDeviceName = box.get('last_connected_device_name') as String?; + _lastConnectedDeviceName = + box.get('last_connected_device_name') as String?; _lastConnectedPublicKey = box.get('last_connected_public_key') as String?; if (_lastConnectedDeviceName != null) { - debugLog('[APP] Loaded last connected device: $_lastConnectedDeviceName'); + debugLog( + '[APP] Loaded last connected device: $_lastConnectedDeviceName'); } } catch (e) { debugLog('[APP] Failed to load last connected device: $e'); @@ -5602,7 +5941,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Save last connected device info to Hive storage - Future _saveLastConnectedDevice(String deviceName, String publicKey) async { + Future _saveLastConnectedDevice( + String deviceName, String publicKey) async { final box = await _openBoxSafely(_preferencesBoxName); if (box == null) return; @@ -5693,34 +6033,45 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[HIVE] Opening typed box "$_noiseFloorSessionBoxName"...'); try { - final box = await Hive.openBox(_noiseFloorSessionBoxName).timeout(timeout); - debugLog('[HIVE] Typed box "$_noiseFloorSessionBoxName" opened successfully'); + final box = + await Hive.openBox(_noiseFloorSessionBoxName) + .timeout(timeout); + debugLog( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" opened successfully'); return box; } on TimeoutException { - debugError('[HIVE] Typed box "$_noiseFloorSessionBoxName" timed out - attempting recovery'); + debugError( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" timed out - attempting recovery'); return _attemptNoiseFloorBoxRecovery(timeout); } catch (e) { - debugError('[HIVE] Typed box "$_noiseFloorSessionBoxName" failed: $e - attempting recovery'); + debugError( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" failed: $e - attempting recovery'); return _attemptNoiseFloorBoxRecovery(timeout); } } /// Attempt to recover from Hive corruption for noise floor box - Future?> _attemptNoiseFloorBoxRecovery(Duration timeout) async { + Future?> _attemptNoiseFloorBoxRecovery( + Duration timeout) async { try { debugLog('[HIVE] Deleting corrupted box "$_noiseFloorSessionBoxName"...'); await Hive.deleteBoxFromDisk(_noiseFloorSessionBoxName); debugLog('[HIVE] Retrying open...'); // Notify user that cleanup happened - logError('Storage for "$_noiseFloorSessionBoxName" was corrupted and has been reset'); - - final box = await Hive.openBox(_noiseFloorSessionBoxName).timeout(timeout); - debugLog('[HIVE] Typed box "$_noiseFloorSessionBoxName" opened after recovery'); + logError( + 'Storage for "$_noiseFloorSessionBoxName" was corrupted and has been reset'); + + final box = + await Hive.openBox(_noiseFloorSessionBoxName) + .timeout(timeout); + debugLog( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" opened after recovery'); return box; } catch (e) { debugError('[HIVE] Recovery failed for "$_noiseFloorSessionBoxName": $e'); - logError('Storage for "$_noiseFloorSessionBoxName" unavailable - noise floor graphs will not persist'); + logError( + 'Storage for "$_noiseFloorSessionBoxName" unavailable - noise floor graphs will not persist'); return null; } } @@ -5736,7 +6087,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { _storedNoiseFloorSessions = _noiseFloorSessionBox!.values.toList() ..sort((a, b) => b.startTime.compareTo(a.startTime)); // Newest first - debugLog('[GRAPH] Loaded ${_storedNoiseFloorSessions.length} stored noise floor sessions'); + debugLog( + '[GRAPH] Loaded ${_storedNoiseFloorSessions.length} stored noise floor sessions'); } catch (e) { debugError('[GRAPH] Failed to load noise floor sessions: $e'); _storedNoiseFloorSessions = []; @@ -5799,7 +6151,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_currentNoiseFloorSession == null) return; _currentNoiseFloorSession!.endTime = DateTime.now(); - debugLog('[GRAPH] Ended session: ${_currentNoiseFloorSession!.durationDisplay}, ' + debugLog( + '[GRAPH] Ended session: ${_currentNoiseFloorSession!.durationDisplay}, ' '${_currentNoiseFloorSession!.samples.length} samples, ' '${_currentNoiseFloorSession!.markers.length} markers'); diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 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..d95172c 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -16,7 +16,8 @@ class LogScreen extends StatefulWidget { State createState() => _LogScreenState(); } -class _LogScreenState extends State with SingleTickerProviderStateMixin { +class _LogScreenState extends State + with SingleTickerProviderStateMixin { late TabController _tabController; final _allPingsKey = GlobalKey<_AllPingsTabState>(); @@ -68,7 +69,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix }, itemBuilder: (context) => [ const PopupMenuItem(value: 'copy', child: Text('Copy CSV')), - const PopupMenuItem(value: 'clear', child: Text('Clear all logs')), + const PopupMenuItem( + value: 'clear', child: Text('Clear all logs')), ], ), ], @@ -80,8 +82,12 @@ class _LogScreenState extends State with SingleTickerProviderStateMix dividerHeight: 1, labelPadding: EdgeInsets.zero, tabs: [ - Tab(height: 32, text: 'All Pings${totalPings > 0 ? ' ($totalPings)' : ''}'), - Tab(height: 32, text: 'Errors${errorCount > 0 ? ' ($errorCount)' : ''}'), + Tab( + height: 32, + text: 'All Pings${totalPings > 0 ? ' ($totalPings)' : ''}'), + Tab( + height: 32, + text: 'Errors${errorCount > 0 ? ' ($errorCount)' : ''}'), ], ), ), @@ -120,7 +126,9 @@ class _LogScreenState extends State with SingleTickerProviderStateMix final filtered = tabState._filteredEntries; if (filtered.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No matching entries to copy'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('No matching entries to copy'), + duration: Duration(seconds: 2)), ); return; } @@ -131,7 +139,10 @@ class _LogScreenState extends State with SingleTickerProviderStateMix } Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${filtered.length} filtered entries copied to clipboard'), duration: const Duration(seconds: 2)), + SnackBar( + content: + Text('${filtered.length} filtered entries copied to clipboard'), + duration: const Duration(seconds: 2)), ); return; } @@ -143,7 +154,9 @@ class _LogScreenState extends State with SingleTickerProviderStateMix if (tx.isEmpty && rx.isEmpty && disc.isEmpty && trace.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No ping log entries to copy'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('No ping log entries to copy'), + duration: Duration(seconds: 2)), ); return; } @@ -161,7 +174,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix if (rx.isNotEmpty) { buffer.writeln('--- RX Log ---'); - buffer.writeln('timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); + buffer.writeln( + 'timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); for (final entry in rx) { buffer.writeln(entry.toCsv()); } @@ -170,7 +184,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix if (disc.isNotEmpty) { buffer.writeln('--- DISC Log ---'); - buffer.writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); + buffer + .writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); for (final entry in disc) { buffer.writeln(entry.toCsv()); } @@ -179,7 +194,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix if (trace.isNotEmpty) { buffer.writeln('--- TRC Log ---'); - buffer.writeln('timestamp,target_repeater,local_snr,local_rssi,remote_snr,latitude,longitude,noisefloor,success'); + buffer.writeln( + 'timestamp,target_repeater,local_snr,local_rssi,remote_snr,latitude,longitude,noisefloor,success'); for (final entry in trace) { buffer.writeln(entry.toCsv()); } @@ -187,14 +203,18 @@ class _LogScreenState extends State with SingleTickerProviderStateMix Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('All ping logs copied to clipboard'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('All ping logs copied to clipboard'), + duration: Duration(seconds: 2)), ); } void _copyErrorLogToCsv(BuildContext context, List entries) { if (entries.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No error log entries to copy'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('No error log entries to copy'), + duration: Duration(seconds: 2)), ); return; } @@ -206,7 +226,9 @@ class _LogScreenState extends State with SingleTickerProviderStateMix } Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Error log copied to clipboard'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('Error log copied to clipboard'), + duration: Duration(seconds: 2)), ); } @@ -215,7 +237,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix context: context, builder: (context) => AlertDialog( title: const Text('Clear All Logs?'), - content: const Text('This will clear TX, RX, DISC, TRC, and error logs.'), + content: + const Text('This will clear TX, RX, DISC, TRC, and error logs.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -299,7 +322,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { /// Resolve a short repeater ID to known repeater names via prefix matching. static ({List names, bool ambiguous}) _resolveRepeaterNames( - String repeaterId, List repeaters, + String repeaterId, + List repeaters, ) { final idLower = repeaterId.toLowerCase(); final matches = repeaters @@ -330,7 +354,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { for (final event in tx.events) { if (event.repeaterId.toLowerCase().startsWith(query)) return true; final resolved = _resolveRepeaterNames(event.repeaterId, repeaters); - if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; + if (resolved.names.any((n) => n.toLowerCase().contains(query))) + return true; } return false; case PingLogType.rx: @@ -342,31 +367,37 @@ class _AllPingsTabState extends State<_AllPingsTab> { final disc = entry.asDisc; for (final node in disc.discoveredNodes) { if (node.repeaterId.toLowerCase().startsWith(query)) return true; - if (node.pubkeyHex != null && node.pubkeyHex!.toLowerCase().startsWith(query)) return true; + if (node.pubkeyHex != null && + node.pubkeyHex!.toLowerCase().startsWith(query)) return true; final resolved = _resolveRepeaterNames(node.repeaterId, repeaters); - if (resolved.names.any((n) => n.toLowerCase().contains(query))) return true; + if (resolved.names.any((n) => n.toLowerCase().contains(query))) + return true; } return false; case PingLogType.trace: final trace = entry.asTrace; if (trace.targetRepeaterId.toLowerCase().startsWith(query)) return true; - final resolved = _resolveRepeaterNames(trace.targetRepeaterId, repeaters); + final resolved = + _resolveRepeaterNames(trace.targetRepeaterId, repeaters); return resolved.names.any((n) => n.toLowerCase().contains(query)); } } /// Whether an entry should show the ambiguity indicator. /// Only shown when searching by name (non-hex query) and a repeater ID is ambiguous. - bool _shouldShowAmbiguity(UnifiedPingLogEntry entry, List repeaters) { + bool _shouldShowAmbiguity( + UnifiedPingLogEntry entry, List repeaters) { if (_searchQuery.isEmpty || _isHexQuery(_searchQuery)) return false; switch (entry.type) { case PingLogType.tx: - return entry.asTx.events.any((e) => _isAmbiguousId(e.repeaterId, repeaters)); + return entry.asTx.events + .any((e) => _isAmbiguousId(e.repeaterId, repeaters)); case PingLogType.rx: return _isAmbiguousId(entry.asRx.repeaterId, repeaters); case PingLogType.disc: - return entry.asDisc.discoveredNodes.any((n) => _isAmbiguousId(n.repeaterId, repeaters)); + return entry.asDisc.discoveredNodes + .any((n) => _isAmbiguousId(n.repeaterId, repeaters)); case PingLogType.trace: return _isAmbiguousId(entry.asTrace.targetRepeaterId, repeaters); } @@ -412,11 +443,19 @@ class _AllPingsTabState extends State<_AllPingsTab> { contentPadding: const EdgeInsets.symmetric(vertical: 8), border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + borderSide: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + borderSide: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), ), ), onChanged: (value) => setState(() => _searchQuery = value.trim()), @@ -429,19 +468,29 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), ), clipBehavior: Clip.antiAlias, child: IntrinsicHeight( child: Row( children: [ - _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, PingColors.txSuccess, isFirst: true), + _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, + PingColors.txSuccess, + isFirst: true), _segmentDivider(context), - _buildFilterSegment(PingLogType.rx, 'RX', widget.rxCount, PingColors.rx), + _buildFilterSegment( + PingLogType.rx, 'RX', widget.rxCount, PingColors.rx), _segmentDivider(context), - _buildFilterSegment(PingLogType.disc, 'DISC', widget.discCount, PingColors.discSuccess), + _buildFilterSegment(PingLogType.disc, 'DISC', + widget.discCount, PingColors.discSuccess), _segmentDivider(context), - _buildFilterSegment(PingLogType.trace, 'TRC', widget.traceCount, PingColors.traceSuccess, isLast: true), + _buildFilterSegment(PingLogType.trace, 'TRC', + widget.traceCount, PingColors.traceSuccess, + isLast: true), ], ), ), @@ -464,7 +513,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { hasEntries && _searchQuery.isNotEmpty ? 'No results for \'$_searchQuery\'' : 'No pings logged yet', - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + style: TextStyle( + color: + Theme.of(context).colorScheme.onSurfaceVariant), ), ], ), @@ -474,12 +525,19 @@ class _AllPingsTabState extends State<_AllPingsTab> { itemCount: filtered.length, itemBuilder: (context, index) { final unified = filtered[index]; - final showAmbiguity = _shouldShowAmbiguity(unified, widget.repeaters); + final showAmbiguity = + _shouldShowAmbiguity(unified, widget.repeaters); return switch (unified.type) { - PingLogType.tx => _buildTxCard(context, unified.asTx, showAmbiguity: showAmbiguity), - PingLogType.rx => _buildRxCard(context, unified.asRx, showAmbiguity: showAmbiguity), - PingLogType.disc => _buildDiscCard(context, unified.asDisc, showAmbiguity: showAmbiguity), - PingLogType.trace => _buildTraceCard(context, unified.asTrace, showAmbiguity: showAmbiguity), + PingLogType.tx => _buildTxCard(context, unified.asTx, + showAmbiguity: showAmbiguity), + PingLogType.rx => _buildRxCard(context, unified.asRx, + showAmbiguity: showAmbiguity), + PingLogType.disc => _buildDiscCard( + context, unified.asDisc, + showAmbiguity: showAmbiguity), + PingLogType.trace => _buildTraceCard( + context, unified.asTrace, + showAmbiguity: showAmbiguity), }; }, ), @@ -488,7 +546,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - Widget _buildFilterSegment(PingLogType type, String label, int count, Color color, {bool isFirst = false, bool isLast = false}) { + Widget _buildFilterSegment( + PingLogType type, String label, int count, Color color, + {bool isFirst = false, bool isLast = false}) { final active = _activeFilters.contains(type); return Expanded( child: GestureDetector( @@ -504,16 +564,28 @@ class _AllPingsTabState extends State<_AllPingsTab> { style: TextStyle( fontSize: 13, fontWeight: active ? FontWeight.w600 : FontWeight.w500, - color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + color: active + ? color + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.6), ), ), if (count > 0) ...[ const SizedBox(width: 4), Container( - constraints: const BoxConstraints(minWidth: 18, minHeight: 16), - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + constraints: + const BoxConstraints(minWidth: 18, minHeight: 16), + padding: + const EdgeInsets.symmetric(horizontal: 5, vertical: 2), decoration: BoxDecoration( - color: active ? color : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + color: active + ? color + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, @@ -600,19 +672,23 @@ class _AllPingsTabState extends State<_AllPingsTab> { // TX Card // --------------------------------------------------------------------------- - Widget _buildTxCard(BuildContext context, TxLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildTxCard(BuildContext context, TxLogEntry entry, + {bool showAmbiguity = false}) { final appState = context.read(); return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.tx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + _buildCardHeader(context, PingLogType.tx, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), // Repeaters table if (entry.events.isNotEmpty) ...[ const SizedBox(height: 10), @@ -640,7 +716,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), child: Column( children: [ @@ -670,9 +748,17 @@ class _AllPingsTabState extends State<_AllPingsTab> { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 14, width: 60), - Expanded(child: Center(child: _buildChip(event.snr?.toStringAsFixed(1) ?? '-', snrColor))), - Expanded(child: Center(child: _buildChip(event.rssi != null ? '${event.rssi}' : '-', rssiColor))), + RepeaterIdChip( + repeaterId: event.repeaterId, fontSize: 14, width: 60), + Expanded( + child: Center( + child: _buildChip( + event.snr?.toStringAsFixed(1) ?? '-', snrColor))), + Expanded( + child: Center( + child: _buildChip( + event.rssi != null ? '${event.rssi}' : '-', + rssiColor))), ], ), ), @@ -683,7 +769,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { // RX Card // --------------------------------------------------------------------------- - Widget _buildRxCard(BuildContext context, RxLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildRxCard(BuildContext context, RxLogEntry entry, + {bool showAmbiguity = false}) { final appState = context.read(); final snrColor = _snrColor(entry.severity); final rssiColor = _rssiColor(entry.rssi); @@ -692,43 +779,71 @@ class _AllPingsTabState extends State<_AllPingsTab> { margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.rx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + _buildCardHeader(context, PingLogType.rx, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), const SizedBox(height: 10), // Repeater table (single row) Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: 60, child: _tableHeader(context, 'Node')), - Expanded(child: _tableHeader(context, 'SNR', center: true)), - Expanded(child: _tableHeader(context, 'RSSI', center: true)), + SizedBox( + width: 60, child: _tableHeader(context, 'Node')), + Expanded( + child: + _tableHeader(context, 'SNR', center: true)), + Expanded( + child: + _tableHeader(context, 'RSSI', center: true)), ], ), ), Divider(height: 1, color: Theme.of(context).dividerColor), InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.repeaterId), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, entry.repeaterId), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), child: Row( children: [ - RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 14, width: 60), - Expanded(child: Center(child: _buildChip(entry.snr?.toStringAsFixed(1) ?? '-', snrColor))), - Expanded(child: Center(child: _buildChip(entry.rssi != null ? '${entry.rssi}' : '-', rssiColor))), + RepeaterIdChip( + repeaterId: entry.repeaterId, + fontSize: 14, + width: 60), + Expanded( + child: Center( + child: _buildChip( + entry.snr?.toStringAsFixed(1) ?? '-', + snrColor))), + Expanded( + child: Center( + child: _buildChip( + entry.rssi != null + ? '${entry.rssi}' + : '-', + rssiColor))), ], ), ), @@ -747,44 +862,63 @@ class _AllPingsTabState extends State<_AllPingsTab> { // DISC Card // --------------------------------------------------------------------------- - Widget _buildDiscCard(BuildContext context, DiscLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildDiscCard(BuildContext context, DiscLogEntry entry, + {bool showAmbiguity = false}) { final appState = context.read(); return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.disc, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + _buildCardHeader(context, PingLogType.disc, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), // Nodes table if (entry.discoveredNodes.isNotEmpty) ...[ const SizedBox(height: 10), Container( decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: + Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: 70, child: _tableHeader(context, 'Node')), - Expanded(child: _tableHeader(context, 'RX SNR', center: true)), - Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), - Expanded(child: _tableHeader(context, 'TX SNR', center: true)), + SizedBox( + width: 70, + child: _tableHeader(context, 'Node')), + Expanded( + child: _tableHeader(context, 'RX SNR', + center: true)), + Expanded( + child: _tableHeader(context, 'RX RSSI', + center: true)), + Expanded( + child: _tableHeader(context, 'TX SNR', + center: true)), ], ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - ...entry.discoveredNodes.map((node) => _buildDiscNodeRow(context, node)), + ...entry.discoveredNodes + .map((node) => _buildDiscNodeRow(context, node)), ], ), ), @@ -812,7 +946,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), + onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, + fullHexId: node.pubkeyHex), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( @@ -821,7 +956,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { width: 70, child: Row( children: [ - Flexible(child: RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 14)), + Flexible( + child: RepeaterIdChip( + repeaterId: node.repeaterId, fontSize: 14)), Text( node.nodeTypeLabel, style: TextStyle( @@ -833,9 +970,17 @@ class _AllPingsTabState extends State<_AllPingsTab> { ], ), ), - Expanded(child: Center(child: _buildChip(node.localSnr.toStringAsFixed(1), rxSnrColor))), - Expanded(child: Center(child: _buildChip('${node.localRssi}', rssiColor))), - Expanded(child: Center(child: _buildChip(node.remoteSnr.toStringAsFixed(1), txSnrColor))), + Expanded( + child: Center( + child: _buildChip( + node.localSnr.toStringAsFixed(1), rxSnrColor))), + Expanded( + child: + Center(child: _buildChip('${node.localRssi}', rssiColor))), + Expanded( + child: Center( + child: _buildChip( + node.remoteSnr.toStringAsFixed(1), txSnrColor))), ], ), ), @@ -846,7 +991,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { // Trace Card // --------------------------------------------------------------------------- - Widget _buildTraceCard(BuildContext context, TraceLogEntry entry, {bool showAmbiguity = false}) { + Widget _buildTraceCard(BuildContext context, TraceLogEntry entry, + {bool showAmbiguity = false}) { final colorScheme = Theme.of(context).colorScheme; final appState = context.read(); @@ -854,13 +1000,16 @@ class _AllPingsTabState extends State<_AllPingsTab> { margin: const EdgeInsets.only(bottom: 8), color: colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () => appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCardHeader(context, PingLogType.trace, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), + _buildCardHeader(context, PingLogType.trace, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), // Results table if (entry.success) ...[ const SizedBox(height: 10), @@ -868,18 +1017,28 @@ class _AllPingsTabState extends State<_AllPingsTab> { decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.5)), ), child: Column( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: 70, child: _tableHeader(context, 'Node')), - Expanded(child: _tableHeader(context, 'RX SNR', center: true)), - Expanded(child: _tableHeader(context, 'RX RSSI', center: true)), - Expanded(child: _tableHeader(context, 'TX SNR', center: true)), + SizedBox( + width: 70, + child: _tableHeader(context, 'Node')), + Expanded( + child: _tableHeader(context, 'RX SNR', + center: true)), + Expanded( + child: _tableHeader(context, 'RX RSSI', + center: true)), + Expanded( + child: _tableHeader(context, 'TX SNR', + center: true)), ], ), ), @@ -915,10 +1074,23 @@ class _AllPingsTabState extends State<_AllPingsTab> { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - SizedBox(width: 70, child: RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 14)), - Expanded(child: Center(child: _buildChip(entry.localSnr?.toStringAsFixed(1) ?? '-', rxSnrColor))), - Expanded(child: Center(child: _buildChip(entry.localRssi != null ? '${entry.localRssi}' : '-', rssiColor))), - Expanded(child: Center(child: _buildChip(entry.remoteSnr?.toStringAsFixed(1) ?? '-', txSnrColor))), + SizedBox( + width: 70, + child: RepeaterIdChip( + repeaterId: entry.targetRepeaterId, fontSize: 14)), + Expanded( + child: Center( + child: _buildChip( + entry.localSnr?.toStringAsFixed(1) ?? '-', rxSnrColor))), + Expanded( + child: Center( + child: _buildChip( + entry.localRssi != null ? '${entry.localRssi}' : '-', + rssiColor))), + Expanded( + child: Center( + child: _buildChip( + entry.remoteSnr?.toStringAsFixed(1) ?? '-', txSnrColor))), ], ), ); @@ -928,7 +1100,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { // Shared helpers // --------------------------------------------------------------------------- - static Widget _buildCardHeader(BuildContext context, PingLogType type, String timeString, String locationString, {bool showAmbiguity = false}) { + static Widget _buildCardHeader(BuildContext context, PingLogType type, + String timeString, String locationString, + {bool showAmbiguity = false}) { return Row( children: [ _buildTypeBadge(type), @@ -936,7 +1110,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { const SizedBox(width: 2), Tooltip( message: 'Repeater ID matches multiple nodes', - child: Icon(Icons.help_outline, size: 14, color: Colors.amber.shade700), + child: Icon(Icons.help_outline, + size: 14, color: Colors.amber.shade700), ), ], const SizedBox(width: 6), @@ -950,7 +1125,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { ), ), const Spacer(), - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 2), Text( locationString, @@ -964,7 +1140,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - static Widget _tableHeader(BuildContext context, String text, {bool center = false}) { + static Widget _tableHeader(BuildContext context, String text, + {bool center = false}) { return Text( text, textAlign: center ? TextAlign.center : TextAlign.left, @@ -1014,9 +1191,12 @@ class _ErrorLogTab extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.check_circle_outline, size: 48, color: Colors.green), + const Icon(Icons.check_circle_outline, + size: 48, color: Colors.green), const SizedBox(height: 16), - Text('No errors logged', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), + Text('No errors logged', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant)), ], ), ); diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index 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 bfd2832..f098136 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,7 +2,8 @@ import 'dart:convert'; import 'dart:io' show File; import 'dart:math' as math; -import 'package:flutter/foundation.dart' show kIsWeb, defaultTargetPlatform, TargetPlatform; +import 'package:flutter/foundation.dart' + show kIsWeb, defaultTargetPlatform, TargetPlatform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -44,13 +45,15 @@ class _SettingsScreenState extends State { int _versionTapCount = 0; DateTime? _lastVersionTap; - Future _showUploadLogsDialog(BuildContext context, AppStateProvider appState) async { + Future _showUploadLogsDialog( + BuildContext context, AppStateProvider appState) async { final result = await showUploadLogsDialog(context, appState); if (!context.mounted || result == null) return; if (result.success) { - String message = 'Uploaded ${result.uploadedCount} log file${result.uploadedCount == 1 ? '' : 's'}'; + String message = + 'Uploaded ${result.uploadedCount} log file${result.uploadedCount == 1 ? '' : 's'}'; if (result.failedCount > 0) { message += ' (${result.failedCount} failed)'; } @@ -116,11 +119,13 @@ class _SettingsScreenState extends State { Padding( padding: const EdgeInsets.only(bottom: 8), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Colors.amber.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.amber.withValues(alpha: 0.3)), + border: + Border.all(color: Colors.amber.withValues(alpha: 0.3)), ), child: const Row( children: [ @@ -142,23 +147,25 @@ class _SettingsScreenState extends State { prefs.themeMode == 'dark' ? Icons.dark_mode : Icons.light_mode, ), title: const Text('Theme'), - subtitle: Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), + subtitle: + Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), value: prefs.themeMode == 'dark', onChanged: (isDark) { appState.setThemeMode(isDark ? 'dark' : 'light'); }, ), - if (!kIsWeb) - _BackgroundModeToggle(appState: appState), + if (!kIsWeb) _BackgroundModeToggle(appState: appState), SwitchListTile( - secondary: Icon(prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), + secondary: + Icon(prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), title: const Text('Disable Map Tiles'), subtitle: Text(prefs.mapTilesEnabled ? 'Map and coverage tiles load normally' : 'Network tiles disabled · downloaded regions still visible'), value: !prefs.mapTilesEnabled, onChanged: (value) { - appState.updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); + appState + .updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); }, ), if (!kIsWeb) @@ -186,9 +193,11 @@ class _SettingsScreenState extends State { max: 1.0, divisions: 7, label: '${(prefs.coverageOverlayOpacity * 100).round()}%', - onChanged: (value) => appState.setCoverageOverlayOpacity(value), + onChanged: (value) => + appState.setCoverageOverlayOpacity(value), ), - trailing: Text('${(prefs.coverageOverlayOpacity * 100).round()}%'), + trailing: + Text('${(prefs.coverageOverlayOpacity * 100).round()}%'), ), ListTile( leading: const Icon(Icons.visibility), @@ -202,7 +211,8 @@ class _SettingsScreenState extends State { prefs.isImperial ? Icons.square_foot : Icons.straighten, ), title: const Text('Units'), - subtitle: Text(prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), + subtitle: Text( + prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), value: prefs.isImperial, onChanged: (isImperial) { appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); @@ -211,10 +221,12 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const Icon(Icons.cell_tower), title: const Text('Top Repeaters on Map'), - subtitle: const Text('Show top 3 repeaters by SNR from last ping'), + subtitle: + const Text('Show top 3 repeaters by SNR from last ping'), value: prefs.showTopRepeaters, onChanged: (value) { - appState.updatePreferences(prefs.copyWith(showTopRepeaters: value)); + appState + .updatePreferences(prefs.copyWith(showTopRepeaters: value)); }, ), ListTile( @@ -232,9 +244,11 @@ class _SettingsScreenState extends State { onTap: () => _showGpsMarkerSelector(context, appState), ), SwitchListTile( - secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), + secondary: Icon( + appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), title: const Text('Sound Notifications'), - subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), + subtitle: Text( + appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), value: appState.isSoundEnabled, onChanged: (_) => appState.toggleSoundEnabled(), ), @@ -249,14 +263,16 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const SizedBox(width: 24), title: const Text('Response Received'), - subtitle: const Text('Sound when repeater echo or RX is received'), + subtitle: + const Text('Sound when repeater echo or RX is received'), value: appState.isRxSoundEnabled, onChanged: (value) => appState.setRxSoundEnabled(value), ), SwitchListTile( secondary: const SizedBox(width: 24), title: const Text('Disconnect Alert'), - subtitle: const Text('Triple beep when pinging stops unexpectedly'), + subtitle: + const Text('Triple beep when pinging stops unexpectedly'), value: appState.isDisconnectAlertEnabled, onChanged: (value) => appState.setDisconnectAlertEnabled(value), ), @@ -272,17 +288,20 @@ class _SettingsScreenState extends State { ? 'Device broadcasts as "Anonymous"' : 'Device uses its real name'), value: prefs.anonymousMode, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showEnableAnonymousConfirmation(context, appState); - } else { - if (appState.connectionStatus == ConnectionStatus.connected) { - _showDisableAnonymousConfirmation(context, appState); - } else { - appState.setAnonymousMode(false); - } - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showEnableAnonymousConfirmation(context, appState); + } else { + if (appState.connectionStatus == + ConnectionStatus.connected) { + _showDisableAnonymousConfirmation(context, appState); + } else { + appState.setAnonymousMode(false); + } + } + }, ), ListTile( leading: const Icon(Icons.timer), @@ -290,7 +309,9 @@ class _SettingsScreenState extends State { subtitle: Text(prefs.autoPingIntervalDisplay), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showIntervalSelector(context, appState), + onTap: isAutoMode + ? null + : () => _showIntervalSelector(context, appState), ), ListTile( leading: const Icon(Icons.straighten), @@ -298,16 +319,22 @@ class _SettingsScreenState extends State { subtitle: Text(prefs.minPingDistanceDisplay), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showDistanceSelector(context, appState), + onTap: isAutoMode + ? null + : () => _showDistanceSelector(context, appState), ), SwitchListTile( secondary: const Icon(Icons.timer_off), title: const Text('Auto-Stop After Idle'), - subtitle: const Text('Stops auto-ping after 30 min without movement'), + subtitle: + const Text('Stops auto-ping after 30 min without movement'), value: prefs.autoStopAfterIdle, - onChanged: isAutoMode ? null : (value) { - appState.updatePreferences(prefs.copyWith(autoStopAfterIdle: value)); - }, + onChanged: isAutoMode + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(autoStopAfterIdle: value)); + }, ), ]), @@ -317,7 +344,9 @@ class _SettingsScreenState extends State { secondary: const Icon(Icons.compare_arrows), title: Row( children: [ - const Flexible(child: Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), + const Flexible( + child: + Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showHybridModeInfo(context), @@ -339,15 +368,20 @@ class _SettingsScreenState extends State { ) : const Text('Combines Active and Passive modes'), value: appState.enforceHybrid ? true : prefs.hybridModeEnabled, - onChanged: (isAutoMode || appState.enforceHybrid) ? null : (value) { - appState.updatePreferences(prefs.copyWith(hybridModeEnabled: value)); - }, + onChanged: (isAutoMode || appState.enforceHybrid) + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(hybridModeEnabled: value)); + }, ), SwitchListTile( secondary: const Icon(Icons.signal_wifi_off), title: Row( children: [ - const Flexible(child: Text('Discovery Drop', overflow: TextOverflow.ellipsis)), + const Flexible( + child: Text('Discovery Drop', + overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showDiscDropInfo(context), @@ -369,13 +403,16 @@ class _SettingsScreenState extends State { ) : const Text('Count failed discoveries as failed pings'), value: appState.enforceDiscDrop ? true : prefs.discDropEnabled, - onChanged: (isAutoMode || appState.enforceDiscDrop) ? null : (value) { - if (value == true) { - _showDiscDropEnableConfirmation(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(discDropEnabled: false)); - } - }, + onChanged: (isAutoMode || appState.enforceDiscDrop) + ? null + : (value) { + if (value == true) { + _showDiscDropEnableConfirmation(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(discDropEnabled: false)); + } + }, ), ]), @@ -384,17 +421,21 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const Icon(Icons.filter_alt), title: const Text('CARpeater Filter'), - subtitle: Text(prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null - ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' - : 'Tap to set CARpeater repeater ID'), + subtitle: Text( + prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null + ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' + : 'Tap to set CARpeater repeater ID'), value: prefs.ignoreCarpeater, - onChanged: isAutoMode ? null : (value) { - if (value && prefs.ignoreRepeaterId == null) { - _showRepeaterIdDialog(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(ignoreCarpeater: value)); - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value && prefs.ignoreRepeaterId == null) { + _showRepeaterIdDialog(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(ignoreCarpeater: value)); + } + }, ), if (prefs.ignoreCarpeater) ListTile( @@ -405,7 +446,9 @@ class _SettingsScreenState extends State { : 'Not set'), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showRepeaterIdDialog(context, appState), + onTap: isAutoMode + ? null + : () => _showRepeaterIdDialog(context, appState), ), SwitchListTile( secondary: const Icon(Icons.shield_outlined), @@ -414,13 +457,16 @@ class _SettingsScreenState extends State { ? 'Allows all signal strengths' : 'Drops signals stronger than -30 dBm'), value: prefs.disableRssiFilter, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showDisableRssiFilterConfirmation(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(disableRssiFilter: false)); - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showDisableRssiFilterConfirmation(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(disableRssiFilter: false)); + } + }, ), ]), @@ -430,7 +476,8 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.linear_scale), title: Row( children: [ - const Flexible(child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), + const Flexible( + child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showHopBytesInfo(context), @@ -462,14 +509,19 @@ class _SettingsScreenState extends State { ) : const Text('Repeater ID size in TX/RX path hops'), trailing: DropdownButton( - value: appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes, + value: appState.enforceHopBytes + ? appState.effectiveHopBytes + : appState.hopBytes, underline: const SizedBox(), items: const [ DropdownMenuItem(value: 1, child: Text('1')), DropdownMenuItem(value: 2, child: Text('2')), DropdownMenuItem(value: 3, child: Text('3')), ], - onChanged: (!appState.isConnected || isAutoMode || appState.enforceHopBytes || !appState.supportsMultiBytePaths) + onChanged: (!appState.isConnected || + isAutoMode || + appState.enforceHopBytes || + !appState.supportsMultiBytePaths) ? null : (value) { if (value != null) appState.setHopBytes(value); @@ -480,7 +532,9 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.gps_fixed), title: Row( children: [ - const Flexible(child: Text('Trace Bytes', overflow: TextOverflow.ellipsis)), + const Flexible( + child: + Text('Trace Bytes', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showTraceBytesInfo(context), @@ -514,7 +568,9 @@ class _SettingsScreenState extends State { DropdownMenuItem(value: 2, child: Text('2')), DropdownMenuItem(value: 4, child: Text('4')), ], - onChanged: (!appState.isConnected || isAutoMode || !appState.supportsMultiBytePaths) + onChanged: (!appState.isConnected || + isAutoMode || + !appState.supportsMultiBytePaths) ? null : (value) { if (value != null) appState.setTraceHopBytes(value); @@ -545,7 +601,8 @@ class _SettingsScreenState extends State { : 'Keeps #wardriving channel on device'), value: prefs.deleteChannelOnDisconnect, onChanged: (value) { - appState.updatePreferences(prefs.copyWith(deleteChannelOnDisconnect: value)); + appState.updatePreferences( + prefs.copyWith(deleteChannelOnDisconnect: value)); }, ), ]), @@ -600,12 +657,15 @@ class _SettingsScreenState extends State { ) else ...appState.offlineSessions.map((session) => _OfflineSessionTile( - session: session, - uploadEnabled: !appState.isUploadingOfflineSession, - onUpload: () => _uploadOfflineSession(context, appState, session.filename), - onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), - onDownload: () => _downloadOfflineSession(context, appState, session.filename), - )), + session: session, + uploadEnabled: !appState.isUploadingOfflineSession, + onUpload: () => _uploadOfflineSession( + context, appState, session.filename), + onDelete: () => _confirmDeleteOfflineSession( + context, appState, session.filename), + onDownload: () => _downloadOfflineSession( + context, appState, session.filename), + )), ]), // API Endpoints @@ -622,13 +682,16 @@ class _SettingsScreenState extends State { ? (prefs.customApiUrl ?? 'Not configured') : 'Forward pings to a third-party server'), value: prefs.customApiEnabled, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showCustomApiDisclaimer(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(customApiEnabled: false)); - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showCustomApiDisclaimer(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(customApiEnabled: false)); + } + }, ), if (prefs.customApiEnabled) ...[ ListTile( @@ -636,29 +699,41 @@ class _SettingsScreenState extends State { title: const Text('Endpoint URL'), subtitle: Text(prefs.customApiUrl ?? 'Not set'), trailing: const Icon(Icons.chevron_right), - onTap: isAutoMode ? null : () => _showCustomApiUrlDialog(context, appState), + onTap: isAutoMode + ? null + : () => _showCustomApiUrlDialog(context, appState), ), ListTile( leading: const SizedBox(width: 24), title: const Text('API Key'), - subtitle: Text(prefs.customApiKey != null ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : 'Not set'), + subtitle: Text(prefs.customApiKey != null + ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' + : 'Not set'), trailing: const Icon(Icons.chevron_right), - onTap: isAutoMode ? null : () => _showCustomApiKeyDialog(context, appState), + onTap: isAutoMode + ? null + : () => _showCustomApiKeyDialog(context, appState), ), SwitchListTile( secondary: const SizedBox(width: 24), title: const Text('Include Contact Key'), - subtitle: const Text('Share device public key prefix with endpoint'), + subtitle: + const Text('Share device public key prefix with endpoint'), value: prefs.customApiIncludeContact, - onChanged: isAutoMode ? null : (value) { - appState.updatePreferences(prefs.copyWith(customApiIncludeContact: value)); - }, + onChanged: isAutoMode + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(customApiIncludeContact: value)); + }, ), ListTile( leading: const Icon(Icons.content_paste), title: const Text('Import from Clipboard'), subtitle: const Text('Paste a meshmapper:// config link'), - onTap: isAutoMode ? null : () => _importCustomApiFromClipboard(context, appState), + onTap: isAutoMode + ? null + : () => _importCustomApiFromClipboard(context, appState), ), ], ]), @@ -686,7 +761,8 @@ class _SettingsScreenState extends State { leading: const FaIcon(FontAwesomeIcons.github), title: const Text('GitHub'), subtitle: const Text('View issues and source code'), - onTap: () => _launchUrl('https://github.com/MeshMapper/MeshMapper_Project'), + onTap: () => _launchUrl( + 'https://github.com/MeshMapper/MeshMapper_Project'), ), ListTile( leading: const FaIcon(FontAwesomeIcons.discord), @@ -697,7 +773,8 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.groups), title: const Text('Community'), - subtitle: const Text('Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), + subtitle: const Text( + 'Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), onTap: () => _launchUrl('https://ottawamesh.ca/'), ), ListTile( @@ -714,12 +791,15 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const Icon(Icons.exit_to_app), title: const Text('Close App After Disconnect'), - subtitle: const Text('Automatically exit the app when disconnecting'), + subtitle: + const Text('Automatically exit the app when disconnecting'), value: prefs.closeAppAfterDisconnect, - onChanged: (value) => appState.setCloseAppAfterDisconnect(value), + onChanged: (value) => + appState.setCloseAppAfterDisconnect(value), ), ListTile( - leading: const Icon(Icons.power_settings_new, color: Colors.red), + leading: + const Icon(Icons.power_settings_new, color: Colors.red), title: const Text('Close App'), subtitle: const Text('Exit the app completely'), onTap: () => _showCloseAppConfirmation(context, appState), @@ -749,7 +829,8 @@ class _SettingsScreenState extends State { if (appState.isGpsSimulatorEnabled) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.orange, borderRadius: BorderRadius.circular(4), @@ -787,13 +868,15 @@ class _SettingsScreenState extends State { min: 10, max: 120, divisions: 11, - label: formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + label: formatSpeed(appState.gpsSimulatorSpeed, + isImperial: prefs.isImperial), onChanged: (value) { appState.setGpsSimulatorSpeed(value); }, ), trailing: Text( - formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + formatSpeed(appState.gpsSimulatorSpeed, + isImperial: prefs.isImperial), style: const TextStyle(fontWeight: FontWeight.bold), ), ), @@ -809,15 +892,18 @@ class _SettingsScreenState extends State { items: [ const DropdownMenuItem( value: SimulatorPattern.straight, - child: Text('Straight Line', overflow: TextOverflow.ellipsis), + child: Text('Straight Line', + overflow: TextOverflow.ellipsis), ), const DropdownMenuItem( value: SimulatorPattern.circle, - child: Text('Circle', overflow: TextOverflow.ellipsis), + child: + Text('Circle', overflow: TextOverflow.ellipsis), ), const DropdownMenuItem( value: SimulatorPattern.randomWalk, - child: Text('Random Walk', overflow: TextOverflow.ellipsis), + child: Text('Random Walk', + overflow: TextOverflow.ellipsis), ), if (appState.hasSimulatorRoute) DropdownMenuItem( @@ -898,7 +984,8 @@ class _SettingsScreenState extends State { if (appState.debugLogsEnabled) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.orange, borderRadius: BorderRadius.circular(4), @@ -929,7 +1016,8 @@ class _SettingsScreenState extends State { } }, ), - if (appState.debugLogsEnabled || appState.debugLogFiles.isNotEmpty) ...[ + if (appState.debugLogsEnabled || + appState.debugLogFiles.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), child: Row( @@ -945,12 +1033,14 @@ class _SettingsScreenState extends State { TextButton.icon( icon: const Icon(Icons.cloud_upload, size: 18), label: const Text('Upload'), - onPressed: () => _showUploadLogsDialog(context, appState), + onPressed: () => + _showUploadLogsDialog(context, appState), ), TextButton.icon( icon: const Icon(Icons.delete_sweep, size: 18), label: const Text('Delete All'), - onPressed: () => _confirmDeleteAllLogs(context, appState), + onPressed: () => + _confirmDeleteAllLogs(context, appState), ), ], ], @@ -961,7 +1051,8 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(16), child: Text( 'No debug logs yet', - style: TextStyle(color: Colors.grey.shade500, fontSize: 13), + style: + TextStyle(color: Colors.grey.shade500, fontSize: 13), ), ) else @@ -971,19 +1062,27 @@ class _SettingsScreenState extends State { final filename = file.path.split('/').last; final sizeBytes = file.lengthSync(); final isCurrentLog = index == 0; - final timestampMatch = RegExp(r'meshmapper-debug-(\d+)\.txt').firstMatch(filename); + final timestampMatch = + RegExp(r'meshmapper-debug-(\d+)\.txt') + .firstMatch(filename); final fileDate = timestampMatch != null - ? DateTime.fromMillisecondsSinceEpoch(int.parse(timestampMatch.group(1)!) * 1000) + ? DateTime.fromMillisecondsSinceEpoch( + int.parse(timestampMatch.group(1)!) * 1000) : null; - final dateStr = fileDate != null ? DateFormat('MMM d, h:mm a').format(fileDate) : filename; + final dateStr = fileDate != null + ? DateFormat('MMM d, h:mm a').format(fileDate) + : filename; String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); + final partCount = + DebugFileLogger.estimatePartCount(sizeBytes); if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); + final sizeMb = + (sizeBytes / 1024 / 1024).toStringAsFixed(1); sizeDisplay = '$sizeMb MB ($partCount parts)'; } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + sizeDisplay = + '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; } if (isCurrentLog) { sizeDisplay = '$sizeDisplay (current)'; @@ -991,7 +1090,8 @@ class _SettingsScreenState extends State { return ListTile( leading: const Icon(Icons.description, size: 20), - title: Text(dateStr, style: const TextStyle(fontSize: 13)), + title: + Text(dateStr, style: const TextStyle(fontSize: 13)), subtitle: Text( sizeDisplay, style: const TextStyle(fontSize: 11), @@ -1001,7 +1101,8 @@ class _SettingsScreenState extends State { children: [ IconButton( icon: const Icon(Icons.visibility, size: 20), - onPressed: () => _showLogViewer(context, appState, file), + onPressed: () => + _showLogViewer(context, appState, file), tooltip: 'View', ), IconButton( @@ -1022,27 +1123,38 @@ class _SettingsScreenState extends State { String _markerStyleLabel(String style) { switch (style) { - case 'circle': return 'Outlined Dot'; - case 'pin': return 'Pin'; - case 'diamond': return 'Diamond'; + case 'circle': + return 'Outlined Dot'; + case 'pin': + return 'Pin'; + case 'diamond': + return 'Diamond'; case 'dot': - default: return 'Dot'; + default: + return 'Dot'; } } String _gpsMarkerLabel(String style) { switch (style) { - case 'car': return 'Car'; - case 'bike': return 'Bike'; - case 'boat': return 'Boat'; - case 'walk': return 'Walk'; - case 'chomper': return 'Chomper'; + case 'car': + return 'Car'; + case 'bike': + return 'Bike'; + case 'boat': + return 'Boat'; + case 'walk': + return 'Walk'; + case 'chomper': + return 'Chomper'; case 'arrow': - default: return 'Arrow'; + default: + return 'Arrow'; } } - void _showMarkerStyleSelector(BuildContext context, AppStateProvider appState) { + void _showMarkerStyleSelector( + BuildContext context, AppStateProvider appState) { final options = [ ('dot', 'Dot', Icons.circle), ('circle', 'Outlined Dot', Icons.circle_outlined), @@ -1060,13 +1172,15 @@ class _SettingsScreenState extends State { children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Map Marker Style', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + child: Text('Map Marker Style', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), RadioGroup( groupValue: appState.preferences.markerStyle, onChanged: (v) { if (v != null) { - appState.updatePreferences(appState.preferences.copyWith(markerStyle: v)); + appState.updatePreferences( + appState.preferences.copyWith(markerStyle: v)); } Navigator.pop(context); }, @@ -1109,13 +1223,15 @@ class _SettingsScreenState extends State { children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('GPS Marker', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + child: Text('GPS Marker', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), RadioGroup( groupValue: appState.preferences.gpsMarkerStyle, onChanged: (v) { if (v != null) { - appState.updatePreferences(appState.preferences.copyWith(gpsMarkerStyle: v)); + appState.updatePreferences( + appState.preferences.copyWith(gpsMarkerStyle: v)); } Navigator.pop(context); }, @@ -1148,13 +1264,30 @@ class _SettingsScreenState extends State { }; } - void _showColorVisionSelector(BuildContext context, AppStateProvider appState) { + void _showColorVisionSelector( + BuildContext context, AppStateProvider appState) { final options = [ ('none', 'Default', 'Standard color palette'), - ('protanopia', 'Protanopia', 'Red-blind — difficulty distinguishing red and green'), - ('deuteranopia', 'Deuteranopia', 'Green-blind — difficulty distinguishing red and green'), - ('tritanopia', 'Tritanopia', 'Blue-blind — difficulty distinguishing blue and yellow'), - ('achromatopsia', 'Achromatopsia', 'Total color blindness — sees in greyscale'), + ( + 'protanopia', + 'Protanopia', + 'Red-blind — difficulty distinguishing red and green' + ), + ( + 'deuteranopia', + 'Deuteranopia', + 'Green-blind — difficulty distinguishing red and green' + ), + ( + 'tritanopia', + 'Tritanopia', + 'Blue-blind — difficulty distinguishing blue and yellow' + ), + ( + 'achromatopsia', + 'Achromatopsia', + 'Total color blindness — sees in greyscale' + ), ]; showModalBottomSheet( context: context, @@ -1167,7 +1300,8 @@ class _SettingsScreenState extends State { children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Color Vision', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + child: Text('Color Vision', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), RadioGroup( groupValue: appState.preferences.colorVisionType, @@ -1182,7 +1316,8 @@ class _SettingsScreenState extends State { RadioListTile( secondary: const Icon(Icons.visibility), title: Text(label), - subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)), + subtitle: + Text(subtitle, style: const TextStyle(fontSize: 12)), value: value, ), ], @@ -1195,7 +1330,8 @@ class _SettingsScreenState extends State { ); } - Widget _buildSection(BuildContext context, String title, List children) { + Widget _buildSection( + BuildContext context, String title, List children) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Card( @@ -1232,7 +1368,8 @@ class _SettingsScreenState extends State { } } - Future _showBugReportDialog(BuildContext context, AppStateProvider appState) async { + Future _showBugReportDialog( + BuildContext context, AppStateProvider appState) async { final result = await showBugReportDialog(context, appState); if (!context.mounted || result == null) return; @@ -1252,7 +1389,8 @@ class _SettingsScreenState extends State { message, duration: const Duration(seconds: 5), actionLabel: result.issueUrl != null ? 'View' : null, - onAction: result.issueUrl != null ? () => _launchUrl(result.issueUrl!) : null, + onAction: + result.issueUrl != null ? () => _launchUrl(result.issueUrl!) : null, ); } else if (result.errorMessage != null) { AppToast.error( @@ -1313,7 +1451,8 @@ class _SettingsScreenState extends State { ); } - void _showDisableRssiFilterConfirmation(BuildContext context, AppStateProvider appState) { + void _showDisableRssiFilterConfirmation( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1346,7 +1485,8 @@ class _SettingsScreenState extends State { ); } - void _showEnableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { + void _showEnableAnonymousConfirmation( + BuildContext context, AppStateProvider appState) { final isConnected = appState.connectionStatus == ConnectionStatus.connected; showDialog( context: context, @@ -1380,7 +1520,8 @@ class _SettingsScreenState extends State { ); } - void _showDisableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { + void _showDisableAnonymousConfirmation( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1425,21 +1566,24 @@ class _SettingsScreenState extends State { style: TextStyle(fontSize: 14), ), SizedBox(height: 12), - Text('How it works:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('How it works:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( 'Discovery \u2192 wait \u2192 TX Ping \u2192 wait \u2192 Discovery \u2192 ...', style: TextStyle(fontSize: 13, fontFamily: 'monospace'), ), SizedBox(height: 12), - Text('Interval timing:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('Interval timing:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( 'At 15s interval, each ping type fires every 30s. Discovery\'s 30s firmware rate limit is naturally respected.', style: TextStyle(fontSize: 13), ), SizedBox(height: 12), - Text('When enabled:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('When enabled:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( '\u2022 Replaces the Active button with Hybrid\n' @@ -1496,7 +1640,8 @@ class _SettingsScreenState extends State { ); } - void _showDiscDropEnableConfirmation(BuildContext context, AppStateProvider appState) { + void _showDiscDropEnableConfirmation( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1718,7 +1863,8 @@ class _SettingsScreenState extends State { final tile = RadioListTile( title: Text( '$interval seconds', - style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold), + style: const TextStyle( + fontSize: 17, fontWeight: FontWeight.bold), ), subtitle: isDisabled ? const Text( @@ -1826,7 +1972,8 @@ class _SettingsScreenState extends State { textCapitalization: TextCapitalization.characters, onChanged: (value) { // Keep only valid hex characters - final filtered = value.toUpperCase().replaceAll(RegExp(r'[^0-9A-F]'), ''); + final filtered = + value.toUpperCase().replaceAll(RegExp(r'[^0-9A-F]'), ''); if (filtered != value) { controller.value = controller.value.copyWith( text: filtered, @@ -1870,7 +2017,8 @@ class _SettingsScreenState extends State { ); Navigator.pop(context); } else { - AppToast.warning(context, 'Please enter exactly 6 hex digits (3-byte ID).'); + AppToast.warning( + context, 'Please enter exactly 6 hex digits (3-byte ID).'); } }, child: const Text('Save'), @@ -1880,7 +2028,8 @@ class _SettingsScreenState extends State { ); } - Future _pickRouteFile(BuildContext context, AppStateProvider appState) async { + Future _pickRouteFile( + BuildContext context, AppStateProvider appState) async { try { debugLog('[SETTINGS] Opening file picker...'); @@ -1898,9 +2047,8 @@ class _SettingsScreenState extends State { if (result != null && result.files.isNotEmpty) { debugLog('[SETTINGS] File picked: ${result.files.first.name}'); final file = result.files.first; - final content = file.bytes != null - ? String.fromCharCodes(file.bytes!) - : null; + final content = + file.bytes != null ? String.fromCharCodes(file.bytes!) : null; if (content != null && context.mounted) { debugLog('[SETTINGS] File content loaded, ${content.length} chars'); @@ -1934,7 +2082,8 @@ class _SettingsScreenState extends State { ); } - void _processRouteFile(BuildContext context, AppStateProvider appState, String content, String filename) { + void _processRouteFile(BuildContext context, AppStateProvider appState, + String content, String filename) { debugLog('[SETTINGS] Calling loadSimulatorRoute...'); final success = appState.loadSimulatorRoute( content, @@ -1955,7 +2104,8 @@ class _SettingsScreenState extends State { } } - Future _uploadOfflineSession(BuildContext context, AppStateProvider appState, String filename) async { + Future _uploadOfflineSession( + BuildContext context, AppStateProvider appState, String filename) async { // Progress text notifier for updating dialog without rebuilding screen final progressNotifier = ValueNotifier('Authenticating...'); @@ -2055,7 +2205,8 @@ class _SettingsScreenState extends State { } } - void _confirmDeleteOfflineSession(BuildContext context, AppStateProvider appState, String filename) { + void _confirmDeleteOfflineSession( + BuildContext context, AppStateProvider appState, String filename) { showDialog( context: context, builder: (context) => AlertDialog( @@ -2081,9 +2232,11 @@ class _SettingsScreenState extends State { ); } - void _downloadOfflineSession(BuildContext context, AppStateProvider appState, String filename) { + void _downloadOfflineSession( + BuildContext context, AppStateProvider appState, String filename) { try { - final sessionData = appState.offlineSessionService.getSessionData(filename); + final sessionData = + appState.offlineSessionService.getSessionData(filename); if (sessionData == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -2095,7 +2248,8 @@ class _SettingsScreenState extends State { } // Convert to pretty JSON - final jsonString = const JsonEncoder.withIndent(' ').convert(sessionData); + final jsonString = + const JsonEncoder.withIndent(' ').convert(sessionData); if (kIsWeb && isWebFileHelpersAvailable) { // Web: Create a blob and trigger download @@ -2162,7 +2316,8 @@ class _SettingsScreenState extends State { } /// Show debug log viewer dialog - void _showLogViewer(BuildContext context, AppStateProvider appState, File file) async { + void _showLogViewer( + BuildContext context, AppStateProvider appState, File file) async { await appState.viewDebugLog(file); if (!context.mounted) return; @@ -2194,7 +2349,8 @@ class _SettingsScreenState extends State { ); } - void _showCustomApiDisclaimer(BuildContext context, AppStateProvider appState) { + void _showCustomApiDisclaimer( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -2252,7 +2408,8 @@ class _SettingsScreenState extends State { ); } - void _showCustomApiUrlDialog(BuildContext context, AppStateProvider appState) { + void _showCustomApiUrlDialog( + BuildContext context, AppStateProvider appState) { final controller = TextEditingController( text: appState.preferences.customApiUrl ?? '', ); @@ -2312,7 +2469,8 @@ class _SettingsScreenState extends State { ); } - void _showCustomApiKeyDialog(BuildContext context, AppStateProvider appState) { + void _showCustomApiKeyDialog( + BuildContext context, AppStateProvider appState) { final controller = TextEditingController( text: appState.preferences.customApiKey ?? '', ); @@ -2351,7 +2509,8 @@ class _SettingsScreenState extends State { ); } - Future _importCustomApiFromClipboard(BuildContext context, AppStateProvider appState) async { + Future _importCustomApiFromClipboard( + BuildContext context, AppStateProvider appState) async { final clipData = await Clipboard.getData('text/plain'); final text = clipData?.text?.trim(); @@ -2374,11 +2533,13 @@ class _SettingsScreenState extends State { final key = uri.queryParameters['key']; if (rawUrl == null || rawUrl.isEmpty) { - if (context.mounted) AppToast.error(context, 'Link is missing the url parameter'); + if (context.mounted) + AppToast.error(context, 'Link is missing the url parameter'); return; } if (key == null || key.isEmpty) { - if (context.mounted) AppToast.error(context, 'Link is missing the key parameter'); + if (context.mounted) + AppToast.error(context, 'Link is missing the key parameter'); return; } @@ -2387,7 +2548,8 @@ class _SettingsScreenState extends State { // Validate the constructed URL final parsed = Uri.tryParse(fullUrl); if (parsed == null || !parsed.hasAuthority) { - if (context.mounted) AppToast.error(context, 'Invalid URL in link: $rawUrl'); + if (context.mounted) + AppToast.error(context, 'Invalid URL in link: $rawUrl'); return; } @@ -2404,11 +2566,13 @@ class _SettingsScreenState extends State { debugLog('[CUSTOM API] Imported endpoint from clipboard: $fullUrl'); } catch (e) { debugError('[CUSTOM API] Failed to parse clipboard link: $e'); - if (context.mounted) AppToast.error(context, 'Invalid meshmapper:// link'); + if (context.mounted) + AppToast.error(context, 'Invalid meshmapper:// link'); } } - void _showCloseAppConfirmation(BuildContext context, AppStateProvider appState) { + void _showCloseAppConfirmation( + BuildContext context, AppStateProvider appState) { final isConnected = appState.isConnected; showDialog( @@ -2488,7 +2652,9 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> Future _requestPermission() async { // Show prominent disclosure before requesting background location - final accepted = await PermissionDisclosureService.showBackgroundLocationDisclosure(context); + final accepted = + await PermissionDisclosureService.showBackgroundLocationDisclosure( + context); if (!accepted) { return; // User declined } @@ -2623,7 +2789,10 @@ class _OfflineSessionTile extends StatelessWidget { if (isUploaded) const Text( 'Uploaded', - style: TextStyle(color: Colors.green, fontSize: 12, fontWeight: FontWeight.w500), + style: TextStyle( + color: Colors.green, + fontSize: 12, + fontWeight: FontWeight.w500), ), if (session.deviceName != null) Text( diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 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..e147e94 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -33,7 +33,7 @@ class ApiService { static const Duration heartbeatBuffer = Duration(minutes: 1); final http.Client _client; - bool _heartbeatEnabled = false; // Track if heartbeat mode is active + bool _heartbeatEnabled = false; // Track if heartbeat mode is active String? _sessionId; bool _txAllowed = false; bool _rxAllowed = false; @@ -91,7 +91,8 @@ class ApiService { /// Check if response indicates maintenance mode, trigger callback if so bool _checkMaintenanceMode(Map response) { if (response['maintenance'] == true) { - final message = response['maintenance_message'] as String? ?? 'Service is under maintenance'; + final message = response['maintenance_message'] as String? ?? + 'Service is under maintenance'; final url = response['maintenance_url'] as String?; debugLog('[MAINTENANCE] Maintenance mode detected: $message'); onMaintenanceMode?.call(message, url); @@ -109,7 +110,8 @@ class ApiService { Map? request, dynamic response, }) { - final durationSec = (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(2); + final durationSec = + (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(2); String reqSummary; if (request != null) { @@ -136,13 +138,13 @@ class ApiService { /// Check if we have a valid session bool get hasSession => _sessionId != null; - + /// Check if TX is allowed bool get txAllowed => _txAllowed; - + /// Check if RX is allowed bool get rxAllowed => _rxAllowed; - + /// Get session ID String? get sessionId => _sessionId; @@ -174,17 +176,21 @@ class ApiService { 'key': apiKey, }; - final response = await _client.post( - Uri.parse(geoAuthStatusUrl), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 10)); + final response = await _client + .post( + Uri.parse(geoAuthStatusUrl), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/status returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/status returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); debugError('[API] Response headers: ${response.headers}'); } @@ -193,7 +199,8 @@ class ApiService { data = json.decode(response.body) as Map; } on FormatException { // CDN/proxy can return HTML error pages with HTTP 200 - debugError('[API] Non-JSON response from /status (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /status (HTTP ${response.statusCode}): ' '${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); } @@ -226,8 +233,8 @@ class ApiService { /// @returns Map with success, session_id, tx_allowed, rx_allowed, expires_at, reason, message Future?> requestAuth({ required String reason, - String? publicKey, // Now optional - either publicKey or contactUri required - String? contactUri, // NEW: for registration flow + String? publicKey, // Now optional - either publicKey or contactUri required + String? contactUri, // NEW: for registration flow String? who, String? appVersion, double? power, @@ -269,7 +276,8 @@ class ApiService { if (who != null) payload['who'] = who; payload['ver'] = appVersion ?? 'UNKNOWN'; - if (power != null) payload['power'] = '${power}w'; // Wattage (0.3w, 0.6w, 1.0w, 2.0w) + if (power != null) + payload['power'] = '${power}w'; // Wattage (0.3w, 0.6w, 1.0w, 2.0w) if (iataCode != null) payload['iata'] = iataCode; if (model != null) payload['model'] = model; payload['coords'] = { @@ -283,24 +291,29 @@ class ApiService { payload['session_id'] = sessionId ?? _sessionId; } - final response = await _client.post( - Uri.parse(geoAuthUrl), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 10)); + final response = await _client + .post( + Uri.parse(geoAuthUrl), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/auth returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/auth returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /auth (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /auth (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } @@ -316,7 +329,8 @@ class ApiService { // Store session info on successful connect or register // Note: 'register' now returns full auth response directly (no retry needed) - if ((reason == 'connect' || reason == 'register') && data['success'] == true) { + if ((reason == 'connect' || reason == 'register') && + data['success'] == true) { if (!skipSessionStore) { _sessionId = data['session_id'] as String?; _txAllowed = data['tx_allowed'] == true; @@ -367,7 +381,8 @@ class ApiService { if (hopBytes is int && hopBytes >= 1 && hopBytes <= 3) { _apiHopBytes = hopBytes; if (_apiHopBytes > 1) { - debugLog('[API] Regional admin enforces $_apiHopBytes-byte paths'); + debugLog( + '[API] Regional admin enforces $_apiHopBytes-byte paths'); } } else { _apiHopBytes = 1; @@ -397,7 +412,8 @@ class ApiService { /// /// @param entries List of wardrive entries (TX/RX) /// @returns Map with success, expires_at, reason, message - Future?> submitWardriveData(List> entries) async { + Future?> submitWardriveData( + List> entries) async { if (_sessionId == null) { throw Exception('Cannot submit: no session_id'); } @@ -410,32 +426,37 @@ class ApiService { 'data': entries, }; - final response = await _client.post( - Uri.parse(wardriveEndpoint), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/wardrive returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/wardrive returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /wardrive (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /wardrive (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } // Log with data summary including external_antenna values - final antennaSummary = entries.map((e) => - '${e['type']}:external_antenna=${e['external_antenna']}' - ).join(', '); + final antennaSummary = entries + .map((e) => '${e['type']}:external_antenna=${e['external_antenna']}') + .join(', '); _logApiCall( endpoint: '/wardrive-api.php/wardrive', method: 'POST', @@ -486,24 +507,29 @@ class ApiService { }; } - final response = await _client.post( - Uri.parse(wardriveEndpoint), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/wardrive (heartbeat) returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/wardrive (heartbeat) returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /wardrive heartbeat (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /wardrive heartbeat (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } @@ -533,7 +559,8 @@ class ApiService { return data; } catch (e) { stopwatch.stop(); - debugError('[API] POST /wardrive-api.php/wardrive (heartbeat) failed: $e'); + debugError( + '[API] POST /wardrive-api.php/wardrive (heartbeat) failed: $e'); return null; } } @@ -547,7 +574,11 @@ class ApiService { }) async { if (_sessionId == null) { debugWarn('[SESSION] No session to validate'); - return (isValid: false, reason: 'no_session', message: 'No active session'); + return ( + isValid: false, + reason: 'no_session', + message: 'No active session' + ); } debugLog('[SESSION] Checking session validity via heartbeat...'); @@ -555,11 +586,16 @@ class ApiService { if (result == null) { debugWarn('[SESSION] Session check failed: no response'); - return (isValid: false, reason: 'no_response', message: 'Server did not respond'); + return ( + isValid: false, + reason: 'no_response', + message: 'Server did not respond' + ); } if (result['success'] == true) { - debugLog('[SESSION] Session is valid (expires_at: ${result['expires_at']})'); + debugLog( + '[SESSION] Session is valid (expires_at: ${result['expires_at']})'); return (isValid: true, reason: null, message: null); } @@ -570,9 +606,15 @@ class ApiService { // Trigger session error callback for critical errors const criticalErrors = { - 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', - 'invalid_key', 'unauthorized', 'bad_key', - 'zone_full', 'zone_disabled', + 'session_expired', + 'session_invalid', + 'session_revoked', + 'bad_session', + 'invalid_key', + 'unauthorized', + 'bad_key', + 'zone_full', + 'zone_disabled', }; if (criticalErrors.contains(reason)) { _clearSession(); @@ -628,14 +670,17 @@ class ApiService { // Calculate when to send heartbeat (1 minute before expiry) final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final secondsUntilExpiry = expiresAt - now; - final secondsUntilHeartbeat = secondsUntilExpiry - heartbeatBuffer.inSeconds; + final secondsUntilHeartbeat = + secondsUntilExpiry - heartbeatBuffer.inSeconds; if (secondsUntilHeartbeat <= 0) { // Session is about to expire or already expired - send heartbeat immediately - debugWarn('[HEARTBEAT] Session expires in ${secondsUntilExpiry}s, sending immediately'); + debugWarn( + '[HEARTBEAT] Session expires in ${secondsUntilExpiry}s, sending immediately'); _sendScheduledHeartbeat(); } else { - debugLog('[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); + debugLog( + '[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); _heartbeatTimer = Timer(Duration(seconds: secondsUntilHeartbeat), () { debugLog('[HEARTBEAT] Timer fired, sending keepalive'); @@ -662,11 +707,14 @@ class ApiService { if (_heartbeatRetryCount < _maxHeartbeatRetries) { final delay = min(30 * pow(2, _heartbeatRetryCount).toInt(), 120); _heartbeatRetryCount++; - debugWarn('[HEARTBEAT] Network error, scheduling retry $_heartbeatRetryCount/$_maxHeartbeatRetries in ${delay}s'); + debugWarn( + '[HEARTBEAT] Network error, scheduling retry $_heartbeatRetryCount/$_maxHeartbeatRetries in ${delay}s'); _heartbeatRetryTimer?.cancel(); - _heartbeatRetryTimer = Timer(Duration(seconds: delay), _sendScheduledHeartbeat); + _heartbeatRetryTimer = + Timer(Duration(seconds: delay), _sendScheduledHeartbeat); } else { - debugError('[HEARTBEAT] Network error, all $_maxHeartbeatRetries retries exhausted'); + debugError( + '[HEARTBEAT] Network error, all $_maxHeartbeatRetries retries exhausted'); } _onSessionExpiring?.call(); } else { @@ -676,9 +724,15 @@ class ApiService { debugWarn('[HEARTBEAT] Heartbeat failed: $reason - $message'); const criticalErrors = { - 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', - 'invalid_key', 'unauthorized', 'bad_key', - 'zone_full', 'zone_disabled', + 'session_expired', + 'session_invalid', + 'session_revoked', + 'bad_session', + 'invalid_key', + 'unauthorized', + 'bad_key', + 'zone_full', + 'zone_disabled', }; if (criticalErrors.contains(reason)) { @@ -773,7 +827,8 @@ class ApiService { // outside_zone: preserve session (backend auto-transfers on zone re-entry), // but discard this batch (gap-GPS coords would be rejected again) if (reason == 'outside_zone') { - debugWarn('[API] Upload batch outside_zone — discarding batch, preserving session'); + debugWarn( + '[API] Upload batch outside_zone — discarding batch, preserving session'); final message = result['message'] as String?; onSessionError?.call(reason, message); return UploadResult.nonRetryable; @@ -781,10 +836,15 @@ class ApiService { // Errors where the batch data itself is invalid — retrying won't help const nonRetryableErrors = { - 'gps_inaccurate', 'gps_stale', 'invalid_request', 'zone_disabled', 'outofdate', + 'gps_inaccurate', + 'gps_stale', + 'invalid_request', + 'zone_disabled', + 'outofdate', }; if (nonRetryableErrors.contains(reason)) { - debugWarn('[API] Upload batch non-retryable error: $reason - discarding batch'); + debugWarn( + '[API] Upload batch non-retryable error: $reason - discarding batch'); return UploadResult.nonRetryable; } @@ -803,9 +863,11 @@ class ApiService { try { final url = 'https://${iata.toLowerCase()}.meshmapper.net$endpoint'; - final response = await _client.get( - Uri.parse(url), - ).timeout(const Duration(seconds: 15)); + final response = await _client + .get( + Uri.parse(url), + ) + .timeout(const Duration(seconds: 15)); stopwatch.stop(); @@ -820,7 +882,8 @@ class ApiService { return []; } - final List jsonList = json.decode(response.body) as List; + final List jsonList = + json.decode(response.body) as List; final repeaters = []; for (final item in jsonList) { @@ -865,31 +928,36 @@ class ApiService { 'data': entries, }; - final response = await _client.post( - Uri.parse(wardriveEndpoint), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/wardrive (offline) returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/wardrive (offline) returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /wardrive offline (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /wardrive offline (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } - final antennaSummary = entries.map((e) => - '${e['type']}:external_antenna=${e['external_antenna']}' - ).join(', '); + final antennaSummary = entries + .map((e) => '${e['type']}:external_antenna=${e['external_antenna']}') + .join(', '); _logApiCall( endpoint: '/wardrive-api.php/wardrive (offline)', method: 'POST', @@ -933,9 +1001,16 @@ class ApiService { // For offline uploads, session/auth errors are non-retryable but do NOT cascade const criticalErrors = { - 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', - 'invalid_key', 'unauthorized', 'bad_key', - 'outside_zone', 'zone_full', 'zone_disabled', + 'session_expired', + 'session_invalid', + 'session_revoked', + 'bad_session', + 'invalid_key', + 'unauthorized', + 'bad_key', + 'outside_zone', + 'zone_full', + 'zone_disabled', }; if (criticalErrors.contains(reason)) { debugError('[API] Offline upload batch session error: $reason'); @@ -943,10 +1018,15 @@ class ApiService { } const nonRetryableErrors = { - 'gps_inaccurate', 'gps_stale', 'invalid_request', 'zone_disabled', 'outofdate', + 'gps_inaccurate', + 'gps_stale', + 'invalid_request', + 'zone_disabled', + 'outofdate', }; if (nonRetryableErrors.contains(reason)) { - debugWarn('[API] Offline upload batch non-retryable error: $reason - discarding batch'); + debugWarn( + '[API] Offline upload batch non-retryable error: $reason - discarding batch'); return UploadResult.nonRetryable; } diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 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..cc3a441 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -35,10 +35,13 @@ class MobileBluetoothService implements BluetoothService { } void _ensureControllers() { - if (_isDisposed || _connectionController == null || _connectionController!.isClosed) { + if (_isDisposed || + _connectionController == null || + _connectionController!.isClosed) { _initControllers(); } } + DiscoveredDevice? _connectedDevice; fbp.BluetoothDevice? _bleDevice; fbp.BluetoothCharacteristic? _rxCharacteristic; @@ -135,19 +138,21 @@ class MobileBluetoothService implements BluetoothService { debugLog('[BLE] iOS location permission check: $locationPermission'); if (locationPermission == LocationPermission.deniedForever) { - debugLog('[BLE] iOS location permission permanently denied - user must enable in Settings'); + debugLog( + '[BLE] iOS location permission permanently denied - user must enable in Settings'); throw BlePermissionDeniedException( - 'Location permission required for Bluetooth scanning. ' - 'Please enable in Settings > Privacy & Security > Location Services > MeshMapper' - ); + 'Location permission required for Bluetooth scanning. ' + 'Please enable in Settings > Privacy & Security > Location Services > MeshMapper'); } if (locationPermission == LocationPermission.denied) { - debugLog('[BLE] iOS location permission not yet granted (disclosure flow will handle)'); + debugLog( + '[BLE] iOS location permission not yet granted (disclosure flow will handle)'); return false; } - debugLog('[BLE] iOS permissions OK - Core Bluetooth will prompt for Bluetooth access when scanning'); + debugLog( + '[BLE] iOS permissions OK - Core Bluetooth will prompt for Bluetooth access when scanning'); return true; } else { // Android: Use bluetoothScan and bluetoothConnect (Android 12+) @@ -155,18 +160,25 @@ class MobileBluetoothService implements BluetoothService { // Location requests are handled by the disclosure flow in MainScaffold. final bluetoothScan = await Permission.bluetoothScan.request(); final bluetoothConnect = await Permission.bluetoothConnect.request(); - final location = await Permission.locationWhenInUse.status; // CHECK only, don't request + final location = await Permission + .locationWhenInUse.status; // CHECK only, don't request - debugLog('[BLE] Android permission check - scan: $bluetoothScan, connect: $bluetoothConnect, location: $location'); + debugLog( + '[BLE] Android permission check - scan: $bluetoothScan, connect: $bluetoothConnect, location: $location'); // Check for permanently denied permissions - if (bluetoothScan.isPermanentlyDenied || bluetoothConnect.isPermanentlyDenied || location.isPermanentlyDenied) { + if (bluetoothScan.isPermanentlyDenied || + bluetoothConnect.isPermanentlyDenied || + location.isPermanentlyDenied) { final denied = []; if (bluetoothScan.isPermanentlyDenied) denied.add('Bluetooth Scan'); - if (bluetoothConnect.isPermanentlyDenied) denied.add('Bluetooth Connect'); + if (bluetoothConnect.isPermanentlyDenied) + denied.add('Bluetooth Connect'); if (location.isPermanentlyDenied) denied.add('Location'); - debugLog('[BLE] Android permissions permanently denied: ${denied.join(", ")}'); - throw BlePermissionDeniedException('${denied.join(", ")} permission(s) denied. Please enable in Settings'); + debugLog( + '[BLE] Android permissions permanently denied: ${denied.join(", ")}'); + throw BlePermissionDeniedException( + '${denied.join(", ")} permission(s) denied. Please enable in Settings'); } final granted = bluetoothScan.isGranted && @@ -185,7 +197,7 @@ class MobileBluetoothService implements BluetoothService { Stream scanForDevices({Duration? timeout}) async* { final controller = StreamController(); _scanController = controller; - + _updateStatus(ConnectionStatus.scanning); try { @@ -203,9 +215,11 @@ class MobileBluetoothService implements BluetoothService { _scanSubscription = fbp.FlutterBluePlus.scanResults.listen((results) { for (final result in results) { final hasName = result.device.platformName.isNotEmpty; - final deviceName = hasName ? result.device.platformName : 'MeshCore Device'; + final deviceName = + hasName ? result.device.platformName : 'MeshCore Device'; if (!hasName) { - debugLog('[BLE] WARNING: Device ${result.device.remoteId.str} has no platformName during scan, using fallback "MeshCore Device"'); + debugLog( + '[BLE] WARNING: Device ${result.device.remoteId.str} has no platformName during scan, using fallback "MeshCore Device"'); } final device = DiscoveredDevice( id: result.device.remoteId.str, @@ -222,7 +236,9 @@ class MobileBluetoothService implements BluetoothService { // Complete stream when scan naturally stops (timeout or platform stop) unawaited(() async { - await fbp.FlutterBluePlus.isScanning.where((isScanning) => !isScanning).first; + await fbp.FlutterBluePlus.isScanning + .where((isScanning) => !isScanning) + .first; if (!controller.isClosed) { await controller.close(); } @@ -296,7 +312,8 @@ class MobileBluetoothService implements BluetoothService { debugLog('[BLE] Connecting to GATT...'); await _bleDevice!.connect( timeout: const Duration(seconds: 15), - mtu: null, // Disable automatic MTU negotiation during connect to avoid race condition errors on Android + mtu: + null, // Disable automatic MTU negotiation during connect to avoid race condition errors on Android ); debugLog('[BLE] GATT connected'); @@ -313,7 +330,8 @@ class MobileBluetoothService implements BluetoothService { } catch (e) { // MTU negotiation failure is not fatal - continue with default MTU // Some older devices may not support MTU negotiation - debugLog('[BLE] MTU negotiation failed (continuing with default): $e'); + debugLog( + '[BLE] MTU negotiation failed (continuing with default): $e'); } } else { // iOS auto-negotiates MTU, just log the current value @@ -326,7 +344,8 @@ class MobileBluetoothService implements BluetoothService { // Flutter Blue Plus emits the current state immediately when you subscribe, // but we only want to react to CHANGES, not the initial state. // This prevents false disconnection triggers during connection setup. - _connectionStateSubscription = _bleDevice!.connectionState.skip(1).listen((state) { + _connectionStateSubscription = + _bleDevice!.connectionState.skip(1).listen((state) { debugLog('[BLE] Connection state changed: $state'); if (state == fbp.BluetoothConnectionState.disconnected) { _handleDisconnection(); @@ -364,8 +383,11 @@ class MobileBluetoothService implements BluetoothService { // Enable notifications on TX characteristic debugLog('[BLE] Enabling notifications...'); await _txCharacteristic!.setNotifyValue(true); - _notificationSubscription = _txCharacteristic!.lastValueStream.listen((value) { - if (value.isNotEmpty && _dataController != null && !_dataController!.isClosed) { + _notificationSubscription = + _txCharacteristic!.lastValueStream.listen((value) { + if (value.isNotEmpty && + _dataController != null && + !_dataController!.isClosed) { _dataController!.add(Uint8List.fromList(value)); } }); @@ -380,41 +402,48 @@ class MobileBluetoothService implements BluetoothService { deviceName = _bleDevice!.platformName; } else { deviceName = 'MeshCore Device'; - debugLog('[BLE] WARNING: No device name available for $deviceId during connect - no scan cache, empty platformName. Using fallback "MeshCore Device"'); + debugLog( + '[BLE] WARNING: No device name available for $deviceId during connect - no scan cache, empty platformName. Using fallback "MeshCore Device"'); } _connectedDevice = DiscoveredDevice( id: deviceId, name: deviceName, ); if (deviceName == 'MeshCore Device') { - debugLog('[BLE] WARNING: Connected device name is "MeshCore Device" (from scan: ${scannedDevice != null}, scanName: ${scannedDevice?.name}, platformName: ${_bleDevice!.platformName})'); + debugLog( + '[BLE] WARNING: Connected device name is "MeshCore Device" (from scan: ${scannedDevice != null}, scanName: ${scannedDevice?.name}, platformName: ${_bleDevice!.platformName})'); } else { - debugLog('[BLE] Device name: $deviceName (from scan: ${scannedDevice != null}, platformName: ${_bleDevice!.platformName})'); + debugLog( + '[BLE] Device name: $deviceName (from scan: ${scannedDevice != null}, platformName: ${_bleDevice!.platformName})'); } debugLog('[BLE] Connection complete'); _updateStatus(ConnectionStatus.connected); return; // Success - exit retry loop - } catch (e, stackTrace) { final errorStr = e.toString(); // Check for Android error 133 (GATT_ERROR) - a well-known Android BLE stack issue // that typically succeeds on retry - final isError133 = Platform.isAndroid && errorStr.contains('android-code: 133'); + final isError133 = + Platform.isAndroid && errorStr.contains('android-code: 133'); // Check for iOS apple-code 14 (Peer removed pairing information) or // apple-code 15 (Failed to encrypt the connection) — both indicate stale bond keys final isBondError = Platform.isIOS && - (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || errorStr.contains('Peer removed pairing information')); + (errorStr.contains('apple-code: 14') || + errorStr.contains('apple-code: 15') || + errorStr.contains('Peer removed pairing information')); if ((isError133 || isBondError) && attempt < _maxRetries) { if (isBondError) { - debugLog('[BLE] Bond error (apple-code 14/15) on attempt $attempt, removing bond and retrying...'); + debugLog( + '[BLE] Bond error (apple-code 14/15) on attempt $attempt, removing bond and retrying...'); await removeBond(deviceId); await Future.delayed(const Duration(seconds: 2)); } else { - debugLog('[BLE] Error 133 on attempt $attempt, retrying after delay...'); + debugLog( + '[BLE] Error 133 on attempt $attempt, retrying after delay...'); await Future.delayed(_retryDelay); } // Force cleanup before retry diff --git a/lib/services/bluetooth/web_bluetooth.dart b/lib/services/bluetooth/web_bluetooth.dart index 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_map_service.dart b/lib/services/offline_map_service.dart index 56df1f9..6f5d3a6 100644 --- a/lib/services/offline_map_service.dart +++ b/lib/services/offline_map_service.dart @@ -45,9 +45,9 @@ class OfflineMapRegion { bounds: region.definition.bounds, minZoom: region.definition.minZoom, maxZoom: region.definition.maxZoom, - createdAt: DateTime.tryParse( - (meta[_MetaKeys.createdAt] as String?) ?? '') ?? - DateTime.now(), + createdAt: + DateTime.tryParse((meta[_MetaKeys.createdAt] as String?) ?? '') ?? + DateTime.now(), // Platform channel JSON round-trip can return int as num/double. estimatedBytes: (meta[_MetaKeys.estimatedBytes] as num?)?.toInt() ?? 0, ); @@ -154,8 +154,7 @@ class OfflineMapService extends ChangeNotifier { try { await _initNotifications(); final prefs = await SharedPreferences.getInstance(); - _storageLimitMb = - prefs.getInt(_storageLimitKey) ?? defaultStorageLimitMb; + _storageLimitMb = prefs.getInt(_storageLimitKey) ?? defaultStorageLimitMb; await refreshRegions(); _initialized = true; notifyListeners(); @@ -332,13 +331,11 @@ class OfflineMapService extends ChangeNotifier { int total = 0; for (int z = minZoom.floor(); z <= maxZoom.ceil(); z++) { final tilesPerSide = 1 << z; // 2^z - final lonFraction = (bounds.northeast.longitude - - bounds.southwest.longitude) - .abs() / - 360.0; + final lonFraction = + (bounds.northeast.longitude - bounds.southwest.longitude).abs() / + 360.0; final latFraction = - (bounds.northeast.latitude - bounds.southwest.latitude).abs() / - 180.0; + (bounds.northeast.latitude - bounds.southwest.latitude).abs() / 180.0; final xTiles = (lonFraction * tilesPerSide).ceil().clamp(1, tilesPerSide); final yTiles = (latFraction * tilesPerSide).ceil().clamp(1, tilesPerSide); total += xTiles * yTiles; @@ -464,8 +461,7 @@ class OfflineMapService extends ChangeNotifier { // Throttle notification updates to every 2% to avoid flooding final percent = status.progress.round(); if (percent % 2 == 0) { - _showProgressNotification( - _downloadingRegionName ?? 'Region', percent); + _showProgressNotification(_downloadingRegionName ?? 'Region', percent); } } else { // Error status diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index 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 8496e19..3c349b2 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -24,13 +24,15 @@ import 'repeater_id_chip.dart'; /// use `textField`, and MapLibre iOS wedges its resource loader with /// NSURLError -1002 if it tries to resolve glyphs against a style that /// doesn't declare a glyphs URL. -const _satelliteStyleJson = '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{"satellite":{"type":"raster","tiles":["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"],"tileSize":256,"maxzoom":17}},"layers":[{"id":"satellite-layer","type":"raster","source":"satellite"}]}'; +const _satelliteStyleJson = + '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{"satellite":{"type":"raster","tiles":["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"],"tileSize":256,"maxzoom":17}},"layers":[{"id":"satellite-layer","type":"raster","source":"satellite"}]}'; /// Blank style with dark background — used when mapTilesEnabled is false /// (saves mobile data while still showing markers and overlays). /// Includes a `glyphs` URL so native annotations using textField (repeater /// hex IDs, distance labels) can render their text even when tiles are off. -const _blankStyleJson = '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{},"layers":[{"id":"background","type":"background","paint":{"background-color":"#0F172A"}}]}'; +const _blankStyleJson = + '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{},"layers":[{"id":"background","type":"background","paint":{"background-color":"#0F172A"}}]}'; /// Default font stack used for all native text labels (textField property). /// Available in OpenFreeMap glyph sets (Liberty, Bright, Dark, Positron). @@ -252,7 +254,6 @@ extension MapStyleExtension on MapStyle { } } - /// Resolved repeater with SNR and ambiguity info for ping focus mode. /// Line color is based on [snr] (green/yellow/red). When a short hex ID /// matches multiple repeaters, [ambiguous] is true and the line gets a @@ -302,11 +303,14 @@ class _MapWidgetState extends State { bool _prefsApplied = false; // Guard to load saved prefs only once bool _isMapReady = false; LatLng? _lastGpsPosition; - bool _hasInitialZoomed = false; // Track if we've done the one-time initial zoom to GPS - bool _hasZoomedToLastKnown = false; // Track if we've zoomed to last known position (before GPS) + bool _hasInitialZoomed = + false; // Track if we've done the one-time initial zoom to GPS + bool _hasZoomedToLastKnown = + false; // Track if we've zoomed to last known position (before GPS) // Map rotation mode - bool _alwaysNorth = true; // true = north always up, false = rotate with heading + bool _alwaysNorth = + true; // true = north always up, false = rotate with heading double? _lastHeading; // Track last heading for smooth rotation // Desired camera zoom while auto-follow is active. Set when the user taps @@ -323,8 +327,8 @@ class _MapWidgetState extends State { // effectively 0 or -1 when stationary or walking slowly. We keep our own // anchor-to-current bearing as a fallback so the arrow/walk marker and // heading-mode map rotation behave correctly at low speeds. - LatLng? _bearingAnchor; // last fix used as the bearing origin - double? _computedHeading; // last known-good bearing in degrees 0..360 + LatLng? _bearingAnchor; // last fix used as the bearing origin + double? _computedHeading; // last known-good bearing in degrees 0..360 // MeshMapper overlay toggle (on by default) bool _showMeshMapperOverlay = true; @@ -365,7 +369,8 @@ class _MapWidgetState extends State { // errors in the native log. bool _coverageRefreshScheduled = false; bool _styleLoaded = false; - bool _hasStyleLoadedOnce = false; // True after first onStyleLoadedCallback (prevents re-centering on style switch) + bool _hasStyleLoadedOnce = + false; // True after first onStyleLoadedCallback (prevents re-centering on style switch) // Tracks the last marker data version we synced to native annotations. // The build() method computes a version hash from app state and only triggers @@ -410,8 +415,9 @@ class _MapWidgetState extends State { // NOTE: repeaters do NOT use the annotation manager — they live in a custom // cluster-enabled GeoJSON source so MapLibre can group nearby markers into // count bubbles at low zoom. See _setupRepeaterClusterLayers(). - final Map _coverageSymbols = {}; // key: "{type}_{ts.ms}" - final Map _distanceLabelSymbols = {}; // key: focused repeater id + final Map _coverageSymbols = {}; // key: "{type}_{ts.ms}" + final Map _distanceLabelSymbols = + {}; // key: focused repeater id // Per focused-repeater metadata used by the collision-avoidance reflow: // the image size (for hit-box overlap tests) and the repeater lat/lon (so // we can slide the label along the ping→repeater line at a new parameter t). @@ -421,7 +427,7 @@ class _MapWidgetState extends State { // style-reload path can drop stale names from the map's image cache if ever // needed. Right now we just re-addImage on each sync (idempotent). final Set _registeredDistanceLabelImages = {}; - Symbol? _gpsSymbol; // single GPS marker + Symbol? _gpsSymbol; // single GPS marker // Repeater cluster source/layer IDs (custom GeoJSON layer with cluster: true) static const _repeaterSourceId = 'repeaters-source'; @@ -454,8 +460,12 @@ class _MapWidgetState extends State { // clear. Remove them explicitly so an in-flight tap that gets queued // before the platform channel is torn down can't reach into a disposed // State. try/catch swallows the edge case where _onMapCreated never ran. - try { controller.onSymbolTapped.remove(_handleSymbolTap); } catch (_) {} - try { controller.onFeatureTapped.remove(_handleFeatureTap); } catch (_) {} + try { + controller.onSymbolTapped.remove(_handleSymbolTap); + } catch (_) {} + try { + controller.onFeatureTapped.remove(_handleFeatureTap); + } catch (_) {} } super.dispose(); } @@ -483,15 +493,16 @@ class _MapWidgetState extends State { super.didUpdateWidget(oldWidget); // When padding changes (panel opened/closed/minimized/orientation change), re-center if auto-following if ((widget.bottomPaddingPixels != oldWidget.bottomPaddingPixels || - widget.rightPaddingPixels != oldWidget.rightPaddingPixels) && + widget.rightPaddingPixels != oldWidget.rightPaddingPixels) && _autoFollow && _isMapReady && _lastGpsPosition != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _autoFollow && _lastGpsPosition != null) { - final double targetBearing = (!_alwaysNorth && _computedHeading != null) - ? _computedHeading! - : 0.0; + final double targetBearing = + (!_alwaysNorth && _computedHeading != null) + ? _computedHeading! + : 0.0; final double targetZoom = _autoFollowDesiredZoom ?? _mapController?.cameraPosition?.zoom ?? _defaultZoom; @@ -548,10 +559,14 @@ class _MapWidgetState extends State { } /// Zoom to fit a focused ping and its connected repeaters on screen - void _zoomToFocusBounds(LatLng pingLocation, List<_ResolvedRepeater> repeaters) { + void _zoomToFocusBounds( + LatLng pingLocation, List<_ResolvedRepeater> repeaters) { if (_mapController == null || !_isMapReady || !mounted) return; - final points = [pingLocation, ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon))]; + final points = [ + pingLocation, + ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon)) + ]; if (points.length < 2) return; // Build bounding box from all points @@ -570,7 +585,8 @@ class _MapWidgetState extends State { final bottomPad = MediaQuery.of(context).size.height * 0.4; _mapController!.animateCamera( - CameraUpdate.newLatLngBounds(bounds, left: 60, top: 60, right: 60, bottom: bottomPad), + CameraUpdate.newLatLngBounds(bounds, + left: 60, top: 60, right: 60, bottom: bottomPad), duration: const Duration(milliseconds: 500), ); } @@ -578,14 +594,19 @@ class _MapWidgetState extends State { /// Smoothly animate the map rotation to match heading /// MapLibre bearing is clockwise from north (same as GPS heading) void _animateToRotation(double targetHeading) { - if (_mapController == null || !_isMapReady || !mounted || _alwaysNorth) return; + if (_mapController == null || !_isMapReady || !mounted || _alwaysNorth) + return; final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; // Calculate shortest rotation path double delta = targetHeading - currentBearing; - while (delta > 180) { delta -= 360; } - while (delta < -180) { delta += 360; } + while (delta > 180) { + delta -= 360; + } + while (delta < -180) { + delta += 360; + } // Skip if rotation change is very small (less than 2 degrees) if (delta.abs() < 2) return; @@ -623,13 +644,17 @@ class _MapWidgetState extends State { _bearingAnchor = here; } else { final moved = Geolocator.distanceBetween( - _bearingAnchor!.latitude, _bearingAnchor!.longitude, - here.latitude, here.longitude, + _bearingAnchor!.latitude, + _bearingAnchor!.longitude, + here.latitude, + here.longitude, ); if (moved >= 5.0) { final bearing = Geolocator.bearingBetween( - _bearingAnchor!.latitude, _bearingAnchor!.longitude, - here.latitude, here.longitude, + _bearingAnchor!.latitude, + _bearingAnchor!.longitude, + here.latitude, + here.longitude, ); // bearingBetween returns -180..180; normalize to 0..360. _computedHeading = (bearing + 360) % 360; @@ -665,7 +690,8 @@ class _MapWidgetState extends State { // Get meters per pixel at the target zoom (or current camera zoom). // Approx: 40075km / (256 * 2^zoom) at equator, adjusted by cos(lat) final zoom = atZoom ?? _mapController!.cameraPosition?.zoom ?? _defaultZoom; - final metersPerPixel = 40075000 / (256 * math.pow(2, zoom)) * + final metersPerPixel = 40075000 / + (256 * math.pow(2, zoom)) * math.cos(position.latitude * math.pi / 180); // Start with the offset expressed as if the map were north-up @@ -679,7 +705,8 @@ class _MapWidgetState extends State { } if (rightPadding > 0) { final meterOffset = (rightPadding / 2) * metersPerPixel; - lonOffset = -(meterOffset / (111000 * math.cos(position.latitude * math.pi / 180))); + lonOffset = -(meterOffset / + (111000 * math.cos(position.latitude * math.pi / 180))); } // When the map is rotated, "screen-down" no longer points geographic @@ -691,7 +718,8 @@ class _MapWidgetState extends State { // the world direction that corresponds to screen-down at the given // bearing, we rotate it clockwise by `bearing` — i.e. by +bearing, not // -bearing as the previous implementation did. - final bearingDeg = atBearing ?? _mapController!.cameraPosition?.bearing ?? 0; + final bearingDeg = + atBearing ?? _mapController!.cameraPosition?.bearing ?? 0; if (bearingDeg.abs() > 0.1) { final rotationRad = bearingDeg * math.pi / 180; final cosR = math.cos(rotationRad); @@ -702,7 +730,8 @@ class _MapWidgetState extends State { lonOffset = rotatedLon; } - return LatLng(position.latitude + latOffset, position.longitude + lonOffset); + return LatLng( + position.latitude + latOffset, position.longitude + lonOffset); } @override @@ -768,9 +797,14 @@ class _MapWidgetState extends State { if (_autoFollow) { // Auto-follow is on and panel may be open — apply panel offset so // the marker appears centered in the visible map area. - final adjustedPosition = _offsetPositionForPadding(initialPosition, widget.bottomPaddingPixels, widget.rightPaddingPixels, 16.0); + final adjustedPosition = _offsetPositionForPadding( + initialPosition, + widget.bottomPaddingPixels, + widget.rightPaddingPixels, + 16.0); _animateToPositionWithZoom(adjustedPosition, 16.0); - debugLog('[MAP] Initial zoom to GPS position (with panel offset)'); + debugLog( + '[MAP] Initial zoom to GPS position (with panel offset)'); } else { _animateToPositionWithZoom(initialPosition, 16.0); debugLog('[MAP] Initial zoom to GPS position'); @@ -791,9 +825,10 @@ class _MapWidgetState extends State { _lastGpsPosition!.latitude != newPosition.latitude || _lastGpsPosition!.longitude != newPosition.longitude) { _lastGpsPosition = newPosition; - final double targetBearing = (!_alwaysNorth && _computedHeading != null) - ? _computedHeading! - : 0.0; + final double targetBearing = + (!_alwaysNorth && _computedHeading != null) + ? _computedHeading! + : 0.0; final double targetZoom = _autoFollowDesiredZoom ?? _mapController?.cameraPosition?.zoom ?? _defaultZoom; @@ -825,7 +860,10 @@ class _MapWidgetState extends State { // Handle map rotation based on heading when NOT auto-following. // When auto-follow is on, rotation is bundled into the combined // camera update above so we don't race two animateCamera calls. - if (!_autoFollow && !_alwaysNorth && _isMapReady && _computedHeading != null) { + if (!_autoFollow && + !_alwaysNorth && + _isMapReady && + _computedHeading != null) { final heading = _computedHeading!; if (_lastHeading == null) { // First heading after startup — store without rotating so the @@ -833,7 +871,8 @@ class _MapWidgetState extends State { // panel offset was computed). Heading mode will begin rotating // on the next GPS update when heading changes. _lastHeading = heading; - debugLog('[MAP] First heading after startup (${heading.toStringAsFixed(1)}°) — stored without rotating'); + debugLog( + '[MAP] First heading after startup (${heading.toStringAsFixed(1)}°) — stored without rotating'); } else if ((heading - _lastHeading!).abs() > 2) { _lastHeading = heading; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -852,17 +891,18 @@ class _MapWidgetState extends State { // Handle navigation trigger from log screen or graph // Reset map state and navigate to the target location - if (_isMapReady && appState.mapNavigationTrigger != _lastNavigationTrigger) { + if (_isMapReady && + appState.mapNavigationTrigger != _lastNavigationTrigger) { _lastNavigationTrigger = appState.mapNavigationTrigger; final target = appState.mapNavigationTarget; if (target != null) { // Reset map controls to default state - _autoFollow = false; // Disable center on GPS + _autoFollow = false; // Disable center on GPS _autoFollowDesiredZoom = null; - _alwaysNorth = true; // Set to north-up mode - _rotationLocked = false; // Unlock rotation - _lastHeading = null; // Reset heading tracking - _bearingAnchor = null; // Reset derived-heading anchor + _alwaysNorth = true; // Set to north-up mode + _rotationLocked = false; // Unlock rotation + _lastHeading = null; // Reset heading tracking + _bearingAnchor = null; // Reset derived-heading anchor _computedHeading = null; // Navigate to the coordinates with close zoom (18 = street level view) @@ -895,7 +935,10 @@ class _MapWidgetState extends State { // window between _registerMapImages and _setupRepeaterClusterLayers // (inside _onStyleLoaded) would race ahead and call setGeoJsonSource on // a not-yet-created source, throwing "sourceNotFound". - if (_isMapReady && _styleLoaded && _imagesRegistered && _clusterLayersReady) { + if (_isMapReady && + _styleLoaded && + _imagesRegistered && + _clusterLayersReady) { final dataVersion = _computeMarkerDataVersion(appState); if (dataVersion != _lastMarkerDataVersion) { _lastMarkerDataVersion = dataVersion; @@ -919,7 +962,8 @@ class _MapWidgetState extends State { } } - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; // Get safe area padding for dynamic island/notch in landscape final safePadding = MediaQuery.of(context).padding; final topPadding = isLandscape ? 16.0 : 8.0; @@ -1010,7 +1054,8 @@ class _MapWidgetState extends State { Widget _buildCollapsibleMapControls(AppStateProvider appState) { // Use external state if provided, otherwise use internal state final isExpanded = widget.mapControlsExpanded ?? _mapControlsExpanded; - final onToggle = widget.onMapControlsToggle ?? () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); + final onToggle = widget.onMapControlsToggle ?? + () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); return Column( mainAxisSize: MainAxisSize.min, @@ -1034,14 +1079,14 @@ class _MapWidgetState extends State { ), ), // Map controls (only when expanded) - below the toggle button - if (isExpanded) - _buildMapControls(appState), + if (isExpanded) _buildMapControls(appState), ], ); } Widget _buildMap(AppStateProvider appState, LatLng center) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + final mapStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); // Always use the real style so downloaded offline tiles can render from // cache. Network access is controlled via setOffline() instead. final newStyleUrl = mapStyle.styleUrl; @@ -1056,7 +1101,8 @@ class _MapWidgetState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; setOffline(!tilesEnabled); - debugPrint('[MAP] setOffline(${!tilesEnabled}) — tiles ${tilesEnabled ? "enabled" : "disabled (cache only)"}'); + debugPrint( + '[MAP] setOffline(${!tilesEnabled}) — tiles ${tilesEnabled ? "enabled" : "disabled (cache only)"}'); }); } @@ -1076,10 +1122,12 @@ class _MapWidgetState extends State { // gps_inaccurate, the style loads with zoneCode=null and the overlay is // skipped. When a later retry sets the zone, nothing else would trigger // the raster layer. - final cacheBustChanged = - appState.overlayCacheBust != _lastCacheBust && _isMapReady && _styleLoaded; - final zoneChanged = - appState.zoneCode != _lastOverlayZoneCode && _isMapReady && _styleLoaded; + final cacheBustChanged = appState.overlayCacheBust != _lastCacheBust && + _isMapReady && + _styleLoaded; + final zoneChanged = appState.zoneCode != _lastOverlayZoneCode && + _isMapReady && + _styleLoaded; if (cacheBustChanged || zoneChanged) { if (cacheBustChanged) _lastCacheBust = appState.overlayCacheBust; if (zoneChanged) _lastOverlayZoneCode = appState.zoneCode; @@ -1123,7 +1171,7 @@ class _MapWidgetState extends State { scrollGesturesEnabled: true, zoomGesturesEnabled: true, tiltGesturesEnabled: false, // 2D wardriving map - compassEnabled: false, // We have our own controls + compassEnabled: false, // We have our own controls // CRITICAL: must be true so the controller's `cameraPosition` getter // stays synced with the platform side. Without this, the Dart-side // _cameraPosition is set once at construction and never updated, which @@ -1251,8 +1299,7 @@ class _MapWidgetState extends State { // finishes in 200ms, making the tap feel "instant" rather than delayed. if (layerId == _repeaterClusterBubbleLayerId || layerId == _repeaterClusterCountLayerId) { - final currentZoom = - _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final currentZoom = _mapController?.cameraPosition?.zoom ?? _defaultZoom; final newZoom = math.min(currentZoom + 2, 17.0); _mapController?.animateCamera( CameraUpdate.newLatLngZoom(coordinates, newZoom), @@ -1369,7 +1416,8 @@ class _MapWidgetState extends State { // double-registers images. Bail any nested call so the first invocation // runs to completion uninterrupted. if (_styleLoadInProgress) { - debugLog('[MAP] _onStyleLoaded re-entered while already running, skipping'); + debugLog( + '[MAP] _onStyleLoaded re-entered while already running, skipping'); return; } _styleLoadInProgress = true; @@ -1438,9 +1486,11 @@ class _MapWidgetState extends State { setOffline(!tilesEnabled); if (tilesEnabled) { _tileLoadFailed = false; - _tileLoadTimeoutTimer = Timer(const Duration(seconds: _tileLoadTimeoutSeconds), () { + _tileLoadTimeoutTimer = + Timer(const Duration(seconds: _tileLoadTimeoutSeconds), () { if (mounted && !_tileLoadFailed) { - debugWarn('[MAP] Tile load timeout — tiles did not finish loading within ${_tileLoadTimeoutSeconds}s'); + debugWarn( + '[MAP] Tile load timeout — tiles did not finish loading within ${_tileLoadTimeoutSeconds}s'); setState(() => _tileLoadFailed = true); } }); @@ -1522,7 +1572,8 @@ class _MapWidgetState extends State { final cvdParam = appState.preferences.colorVisionType != 'none' ? '&cvd=${appState.preferences.colorVisionType}' : ''; - final url = 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}$cvdParam'; + final url = + 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}$cvdParam'; try { await _mapController!.addSource( @@ -1557,7 +1608,8 @@ class _MapWidgetState extends State { belowLayerId: belowLayer, ); _lastAppliedCoverageOpacity = opacity; - debugLog('[MAP] Coverage overlay added (below ${belowLayer ?? "top"}, opacity ${opacity.toStringAsFixed(2)})'); + debugLog( + '[MAP] Coverage overlay added (below ${belowLayer ?? "top"}, opacity ${opacity.toStringAsFixed(2)})'); } catch (e) { debugLog('[MAP] Failed to add coverage overlay: $e'); } @@ -1573,7 +1625,8 @@ class _MapWidgetState extends State { RasterLayerProperties(rasterOpacity: opacity), ); _lastAppliedCoverageOpacity = opacity; - debugLog('[MAP] Coverage overlay opacity updated to ${opacity.toStringAsFixed(2)}'); + debugLog( + '[MAP] Coverage overlay opacity updated to ${opacity.toStringAsFixed(2)}'); } catch (e) { // Layer may not exist yet (e.g. before first style load or when the // overlay is hidden). Safe to ignore — next _addCoverageOverlay call @@ -1718,7 +1771,8 @@ class _MapWidgetState extends State { } _imagesRegistered = true; - debugLog('[MAP] Registered ${_MapImages.repeaterStatuses.length * _MapImages.repeaterHopBytes.length} repeater + 8 coverage + ${gpsPainters.length} GPS marker images'); + debugLog( + '[MAP] Registered ${_MapImages.repeaterStatuses.length * _MapImages.repeaterHopBytes.length} repeater + 8 coverage + ${gpsPainters.length} GPS marker images'); // NOTE: do NOT trigger _syncAllAnnotations here. The repeater cluster // source/layers haven't been created yet — _onStyleLoaded calls // _setupRepeaterClusterLayers AFTER us, then triggers the initial sync @@ -1781,7 +1835,8 @@ class _MapWidgetState extends State { /// per-feature properties used by the data-driven symbol layer expressions /// (iconImage, color, opacity, hex). Re-pushed to the cluster source whenever /// the marker data version changes — MapLibre handles re-clustering natively. - Map _buildRepeaterFeatureCollection(AppStateProvider appState) { + Map _buildRepeaterFeatureCollection( + AppStateProvider appState) { final duplicates = _getDuplicateRepeaterIds(appState.repeaters); final hopOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; @@ -1862,7 +1917,10 @@ class _MapWidgetState extends State { await _mapController!.addSource( _repeaterSourceId, const GeojsonSourceProperties( - data: {'type': 'FeatureCollection', 'features': []}, + data: { + 'type': 'FeatureCollection', + 'features': [] + }, cluster: true, clusterRadius: 50, clusterMaxZoom: 14, @@ -1893,7 +1951,10 @@ class _MapWidgetState extends State { textIgnorePlacement: true, textFont: _defaultFontStack, ), - filter: ['!', ['has', 'point_count']], + filter: [ + '!', + ['has', 'point_count'] + ], belowLayerId: belowLayer, ); @@ -1911,8 +1972,10 @@ class _MapWidgetState extends State { 'step', ['get', 'point_count'], 18, - 10, 22, - 50, 26, + 10, + 22, + 50, + 26, ], circleStrokeColor: '#FFFFFF', circleStrokeWidth: 2, @@ -2096,7 +2159,8 @@ class _MapWidgetState extends State { } // Remove symbols for pings that no longer exist (e.g., user cleared markers) - final toRemove = _coverageSymbols.keys.where((k) => !wantedKeys.contains(k)).toList(); + final toRemove = + _coverageSymbols.keys.where((k) => !wantedKeys.contains(k)).toList(); for (final key in toRemove) { final sym = _coverageSymbols.remove(key); if (sym != null) { @@ -2292,7 +2356,11 @@ class _MapWidgetState extends State { lineDasharray: [2, 4], lineCap: 'round', ), - filter: ['==', ['get', 'ambiguous'], true], + filter: [ + '==', + ['get', 'ambiguous'], + true + ], belowLayerId: belowLayer, ); @@ -2395,8 +2463,7 @@ class _MapWidgetState extends State { } } _distanceLabelImageSize[key] = imageSize; - _distanceLabelRepeaterPos[key] = - LatLng(r.repeater.lat, r.repeater.lon); + _distanceLabelRepeaterPos[key] = LatLng(r.repeater.lat, r.repeater.lon); final options = SymbolOptions( geometry: LatLng(midLat, midLon), @@ -2425,8 +2492,9 @@ class _MapWidgetState extends State { } // Remove labels for repeaters no longer in focus - final toRemove = - _distanceLabelSymbols.keys.where((k) => !wantedKeys.contains(k)).toList(); + final toRemove = _distanceLabelSymbols.keys + .where((k) => !wantedKeys.contains(k)) + .toList(); for (final key in toRemove) { final sym = _distanceLabelSymbols.remove(key); _distanceLabelImageSize.remove(key); @@ -2492,8 +2560,7 @@ class _MapWidgetState extends State { var cursor = 0; for (final id in orderedIds) { final repeaterPos = _distanceLabelRepeaterPos[id]; - final labelSize = - _distanceLabelImageSize[id] ?? const Size(60, 18); + final labelSize = _distanceLabelImageSize[id] ?? const Size(60, 18); if (repeaterPos == null) { cursor += candidateTs.length; continue; @@ -2693,14 +2760,15 @@ class _MapWidgetState extends State { columnWidths: const { 0: IntrinsicColumnWidth(), // dot 1: IntrinsicColumnWidth(), // ID - 2: FixedColumnWidth(8), // spacer + 2: FixedColumnWidth(8), // spacer 3: IntrinsicColumnWidth(), // SNR }, children: [ for (final r in topRepeaters) _overlayRow(r.repeaterId, r.snr, _overlayTypeColor(r.type)), if (rxSlot != null) - _overlayRow(rxSlot.repeaterId, rxSlot.snr, _overlayTypeColor(OverlayPingType.rx)), + _overlayRow(rxSlot.repeaterId, rxSlot.snr, + _overlayTypeColor(OverlayPingType.rx)), ], ), ], @@ -2733,11 +2801,15 @@ class _MapWidgetState extends State { ), const SizedBox(width: 6), Text( - hasGps ? formatMeters(position.accuracy, isImperial: appState.preferences.isImperial) : 'No GPS', + hasGps + ? formatMeters(position.accuracy, + isImperial: appState.preferences.isImperial) + : 'No GPS', style: TextStyle( fontSize: 11, fontFamily: 'monospace', - color: hasGps ? _getAccuracyColor(position.accuracy) : Colors.grey, + color: + hasGps ? _getAccuracyColor(position.accuracy) : Colors.grey, ), ), // Distance since last TX ping (like wardrive.js) @@ -2750,7 +2822,8 @@ class _MapWidgetState extends State { ), const SizedBox(width: 4), Text( - formatMeters(distanceFromLastPing, isImperial: appState.preferences.isImperial), + formatMeters(distanceFromLastPing, + isImperial: appState.preferences.isImperial), style: const TextStyle( fontSize: 11, fontFamily: 'monospace', @@ -2771,7 +2844,8 @@ class _MapWidgetState extends State { /// Map controls (always vertical, used inside collapsible wrapper) Widget _buildMapControls(AppStateProvider appState) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + final mapStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); return Container( decoration: BoxDecoration( @@ -2793,7 +2867,9 @@ class _MapWidgetState extends State { _buildControlDivider(), _buildControlButton( icon: Icons.layers, - tooltip: _showMeshMapperOverlay ? 'Hide Coverage Overlay' : 'Show Coverage Overlay', + tooltip: _showMeshMapperOverlay + ? 'Hide Coverage Overlay' + : 'Show Coverage Overlay', onPressed: _toggleMeshMapperOverlay, isActive: _showMeshMapperOverlay, ), @@ -2803,14 +2879,17 @@ class _MapWidgetState extends State { _buildControlButton( icon: _autoFollow ? Icons.my_location : Icons.location_searching, tooltip: _autoFollow ? 'Following GPS' : 'Center on Position', - onPressed: appState.currentPosition != null ? _centerOnPosition : null, + onPressed: + appState.currentPosition != null ? _centerOnPosition : null, isActive: _autoFollow, ), _buildControlDivider(), // Always North toggle _buildControlButton( icon: _alwaysNorth ? Icons.navigation : Icons.explore, - tooltip: _alwaysNorth ? 'Always North (Click to Rotate with Heading)' : 'Rotating with Heading (Click for Always North)', + tooltip: _alwaysNorth + ? 'Always North (Click to Rotate with Heading)' + : 'Rotating with Heading (Click for Always North)', onPressed: _toggleNorthMode, isActive: !_alwaysNorth, ), @@ -2872,7 +2951,8 @@ class _MapWidgetState extends State { void _cycleMapStyle(AppStateProvider appState) { const styles = MapStyle.values; - final currentStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + final currentStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); final currentIndex = styles.indexOf(currentStyle); final newStyle = styles[(currentIndex + 1) % styles.length]; appState.setMapStyle(newStyle.name); @@ -2905,9 +2985,8 @@ class _MapWidgetState extends State { appState.setMapAutoFollow(true); // Bundle target + zoom + bearing into one animation so the // initial centering can't be half-cancelled by a racing GPS tick. - final double targetBearing = (!_alwaysNorth && _computedHeading != null) - ? _computedHeading! - : 0.0; + final double targetBearing = + (!_alwaysNorth && _computedHeading != null) ? _computedHeading! : 0.0; final adjustedPosition = _offsetPositionForPadding( targetPosition, widget.bottomPaddingPixels, @@ -2955,7 +3034,8 @@ class _MapWidgetState extends State { _lastHeading = null; // Force initial rotation // Prefer our derived heading; fall back to whatever GPS reports (may // be 0 if we haven't moved yet — better than no rotation at all). - final initialHeading = _computedHeading ?? appState.currentPosition!.heading; + final initialHeading = + _computedHeading ?? appState.currentPosition!.heading; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && !_alwaysNorth && appState.currentPosition != null) { _animateToRotation(initialHeading); @@ -2972,7 +3052,10 @@ class _MapWidgetState extends State { _rotationLocked = !_rotationLocked; // When enabling lock in "Always North" mode, rotate back to north - if (_rotationLocked && _isMapReady && _alwaysNorth && _mapController != null) { + if (_rotationLocked && + _isMapReady && + _alwaysNorth && + _mapController != null) { final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; if (currentBearing.abs() > 2) { _mapController!.animateCamera( @@ -3009,7 +3092,8 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.4)), ), child: const Icon(Icons.map, color: Colors.blue, size: 24), ), @@ -3018,8 +3102,8 @@ class _MapWidgetState extends State { child: Text( 'Legend & Info', style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), ), IconButton( @@ -3041,246 +3125,374 @@ class _MapWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Map Markers section - Text( - 'Map Markers', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildLegendItem( - context: context, - color: PingColors.txSuccessLegend, - label: 'TX', - description: 'Location where you sent a ping and heard a repeater', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.txFail, - label: 'TX', - description: 'Location where you sent a ping but no repeater was heard', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.rx, - label: 'RX', - description: 'Location where you received a message from the mesh', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.discSuccess, - label: 'DISC', - description: 'Location where you sent a discovery request and a repeater responded', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.traceSuccess, - label: 'TRC', - description: 'Location where a trace reached the repeater', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), - _buildLegendItem( - context: context, - color: PingColors.discFail, - label: 'DISC', - description: 'Location where you sent a discovery request but no repeater responded', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), - _buildLegendItem( - context: context, - color: PingColors.noResponse, - label: 'TRC', - description: 'Location where a trace got no response', - ), - ], - ), - ), - const SizedBox(height: 20), - - // Coverage Layer section - Text( - 'Coverage Layer', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildLayerItem( - context: context, - color: PingColors.coverageBidir, - label: 'BIDIR', - description: 'Heard repeats from the mesh AND successfully routed through it', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageDisc, - label: 'DISC', - description: 'Wardriving app sent a discovery packet and heard a reply', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageTx, - label: 'TX', - description: 'Successfully routed through, but no repeats heard back', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageRx, - label: 'RX', - description: 'Heard mesh traffic but did not transmit', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageDead, - label: 'DEAD', - description: 'Repeater heard it, but no other radio received the repeat', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageDrop, - label: 'DROP', - description: 'No repeats heard AND no successful route', - ), - ], - ), - ), - const SizedBox(height: 20), - - // Sound Notifications section - Text( - 'Sound Notifications', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildSoundItem( - context: context, - icon: Icons.cell_tower, - label: 'TX Sound', - description: 'Plays when sending a ping or discovery request', - onPlay: () { - final appState = context.read(); - appState.audioService.playTransmitSound(); - }, - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildSoundItem( - context: context, - icon: Icons.hearing, - label: 'RX Sound', - description: 'Plays when a repeater echo or mesh message is received', - onPlay: () { - final appState = context.read(); - appState.audioService.playReceiveSound(); - }, - ), - ], - ), - ), - const SizedBox(height: 20), - - // Map Controls section - Text( - 'Map Controls', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildHelpItem( - context: context, - icon: Icons.dark_mode, - label: 'Map Style', - description: 'Cycle between Dark, Light, and Satellite map styles', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.layers, - label: 'Coverage Overlay', - description: 'Toggle MeshMapper coverage overlay showing community-reported mesh coverage', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.my_location, - label: 'Center/Follow', - description: 'Center map on GPS position. Tap again to toggle auto-follow mode', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.navigation, - label: 'Always North', - description: 'Toggle between always-north orientation or rotate with heading', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.sync_disabled, - label: 'Lock Rotation', - description: 'Prevent accidental rotation of the map', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.info_outline, - label: 'Legend & Info', - description: 'Show this help popup with legend and control explanations', - ), - ], - ), - ), + Text( + 'Map Markers', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildLegendItem( + context: context, + color: PingColors.txSuccessLegend, + label: 'TX', + description: + 'Location where you sent a ping and heard a repeater', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.txFail, + label: 'TX', + description: + 'Location where you sent a ping but no repeater was heard', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.rx, + label: 'RX', + description: + 'Location where you received a message from the mesh', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.discSuccess, + label: 'DISC', + description: + 'Location where you sent a discovery request and a repeater responded', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.traceSuccess, + label: 'TRC', + description: + 'Location where a trace reached the repeater', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.2)), + _buildLegendItem( + context: context, + color: PingColors.discFail, + label: 'DISC', + description: + 'Location where you sent a discovery request but no repeater responded', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.2)), + _buildLegendItem( + context: context, + color: PingColors.noResponse, + label: 'TRC', + description: + 'Location where a trace got no response', + ), + ], + ), + ), + const SizedBox(height: 20), + + // Coverage Layer section + Text( + 'Coverage Layer', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildLayerItem( + context: context, + color: PingColors.coverageBidir, + label: 'BIDIR', + description: + 'Heard repeats from the mesh AND successfully routed through it', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDisc, + label: 'DISC', + description: + 'Wardriving app sent a discovery packet and heard a reply', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageTx, + label: 'TX', + description: + 'Successfully routed through, but no repeats heard back', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageRx, + label: 'RX', + description: + 'Heard mesh traffic but did not transmit', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDead, + label: 'DEAD', + description: + 'Repeater heard it, but no other radio received the repeat', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDrop, + label: 'DROP', + description: + 'No repeats heard AND no successful route', + ), + ], + ), + ), + const SizedBox(height: 20), + + // Sound Notifications section + Text( + 'Sound Notifications', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildSoundItem( + context: context, + icon: Icons.cell_tower, + label: 'TX Sound', + description: + 'Plays when sending a ping or discovery request', + onPlay: () { + final appState = + context.read(); + appState.audioService.playTransmitSound(); + }, + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildSoundItem( + context: context, + icon: Icons.hearing, + label: 'RX Sound', + description: + 'Plays when a repeater echo or mesh message is received', + onPlay: () { + final appState = + context.read(); + appState.audioService.playReceiveSound(); + }, + ), + ], + ), + ), + const SizedBox(height: 20), + + // Map Controls section + Text( + 'Map Controls', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildHelpItem( + context: context, + icon: Icons.dark_mode, + label: 'Map Style', + description: + 'Cycle between Dark, Light, and Satellite map styles', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.layers, + label: 'Coverage Overlay', + description: + 'Toggle MeshMapper coverage overlay showing community-reported mesh coverage', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.my_location, + label: 'Center/Follow', + description: + 'Center map on GPS position. Tap again to toggle auto-follow mode', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.navigation, + label: 'Always North', + description: + 'Toggle between always-north orientation or rotate with heading', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.sync_disabled, + label: 'Lock Rotation', + description: + 'Prevent accidental rotation of the map', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.info_outline, + label: 'Legend & Info', + description: + 'Show this help popup with legend and control explanations', + ), + ], + ), + ), ], ), ), @@ -3297,8 +3509,13 @@ class _MapWidgetState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0), - Theme.of(context).colorScheme.surfaceContainerHighest, + Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0), + Theme.of(context) + .colorScheme + .surfaceContainerHighest, ], ), ), @@ -3510,7 +3727,8 @@ class _MapWidgetState extends State { snrValues: [entry.localSnr], ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); } } @@ -3531,7 +3749,8 @@ class _MapWidgetState extends State { ), child: SingleChildScrollView( child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -3544,9 +3763,11 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Colors.cyan.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.cyan.withValues(alpha: 0.4)), + border: Border.all( + color: Colors.cyan.withValues(alpha: 0.4)), ), - child: const Icon(Icons.gps_fixed, color: Colors.cyan, size: 24), + child: const Icon(Icons.gps_fixed, + color: Colors.cyan, size: 24), ), const SizedBox(width: 12), Expanded( @@ -3555,15 +3776,20 @@ class _MapWidgetState extends State { children: [ Text( 'Trace', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(entry.timestamp), style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], @@ -3581,15 +3807,23 @@ class _MapWidgetState extends State { // Location chip Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -3626,13 +3860,18 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -3642,7 +3881,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -3653,7 +3894,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -3664,7 +3907,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -3675,14 +3920,17 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data row Builder(builder: (context) { final localSnr = entry.localSnr ?? 0; @@ -3691,15 +3939,24 @@ class _MapWidgetState extends State { final rxSnrColor = PingColors.snrColor(localSnr); final rssiColor = PingColors.rssiColor(localRssi); - final txSnrColor = PingColors.snrColor(remoteSnr.toDouble()); + final txSnrColor = + PingColors.snrColor(remoteSnr.toDouble()); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.targetRepeaterId, fromLatLng: (lat: entry.latitude, lon: entry.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, entry.targetRepeaterId, fromLatLng: ( + lat: entry.latitude, + lon: entry.longitude + )), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ - RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 13, width: _nodeColumnWidth()), + RepeaterIdChip( + repeaterId: entry.targetRepeaterId, + fontSize: 13, + width: _nodeColumnWidth()), // RX SNR Expanded( child: Center( @@ -3777,7 +4034,8 @@ class _MapWidgetState extends State { ? fullHex.substring(0, 8) : hexIds[i]; final matches = allRepeaters - .where((r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) + .where( + (r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) .toList(); final ambiguous = matches.length > 1; resolved.addAll(matches.map((r) => _ResolvedRepeater(r, snr, ambiguous))); @@ -3786,7 +4044,8 @@ class _MapWidgetState extends State { } /// Activate ping focus mode — draw lines, fade markers, zoom to fit. - void _activatePingFocus(LatLng pingLocation, DateTime timestamp, List<_ResolvedRepeater> repeaters) { + void _activatePingFocus(LatLng pingLocation, DateTime timestamp, + List<_ResolvedRepeater> repeaters) { final pos = _mapController?.cameraPosition; _preFocusCenter = pos?.target; _preFocusZoom = pos?.zoom; @@ -3888,10 +4147,7 @@ class _MapWidgetState extends State { for (final repeater in repeaters) { idCounts[repeater.id] = (idCounts[repeater.id] ?? 0) + 1; } - return idCounts.entries - .where((e) => e.value > 1) - .map((e) => e.key) - .toSet(); + return idCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet(); } /// Get marker color for a repeater based on status priority: @@ -3910,7 +4166,9 @@ class _MapWidgetState extends State { /// [extraPadding] adds space for additional content (e.g. nodeTypeLabel in DISC popup). double _nodeColumnWidth({double extraPadding = 0}) { final appState = context.read(); - final hopBytes = appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes; + final hopBytes = appState.enforceHopBytes + ? appState.effectiveHopBytes + : appState.hopBytes; switch (hopBytes) { case 2: return 70 + extraPadding; @@ -3933,7 +4191,8 @@ class _MapWidgetState extends State { snrValues: heardRepeaters.map((r) => r.snr).toList(), ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); } } @@ -3954,7 +4213,8 @@ class _MapWidgetState extends State { ), child: SingleChildScrollView( child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -3968,9 +4228,11 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: PingColors.txSuccess.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: PingColors.txSuccess.withValues(alpha: 0.4)), + border: Border.all( + color: PingColors.txSuccess.withValues(alpha: 0.4)), ), - child: Icon(Icons.arrow_upward, color: PingColors.txSuccess, size: 24), + child: Icon(Icons.arrow_upward, + color: PingColors.txSuccess, size: 24), ), const SizedBox(width: 12), Expanded( @@ -3979,15 +4241,20 @@ class _MapWidgetState extends State { children: [ Text( 'TX Ping', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(ping.timestamp), style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], @@ -4005,15 +4272,23 @@ class _MapWidgetState extends State { // Location chip Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4032,7 +4307,9 @@ class _MapWidgetState extends State { // Repeaters section header Text( - heardRepeaters.isEmpty ? 'No repeaters heard' : 'Heard Repeaters (${heardRepeaters.length})', + heardRepeaters.isEmpty + ? 'No repeaters heard' + : 'Heard Repeaters (${heardRepeaters.length})', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -4048,13 +4325,18 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -4064,7 +4346,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4075,7 +4359,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4086,32 +4372,49 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data rows ...heardRepeaters.map((repeater) { - final snrColor = repeater.snr != null ? PingColors.snrColor(repeater.snr!) : Colors.grey; - final rssiColor = repeater.rssi != null ? PingColors.rssiColor(repeater.rssi!) : Colors.grey; + final snrColor = repeater.snr != null + ? PingColors.snrColor(repeater.snr!) + : Colors.grey; + final rssiColor = repeater.rssi != null + ? PingColors.rssiColor(repeater.rssi!) + : Colors.grey; return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId, fromLatLng: (lat: ping.latitude, lon: ping.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, repeater.repeaterId, fromLatLng: ( + lat: ping.latitude, + lon: ping.longitude + )), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 13, width: _nodeColumnWidth()), + RepeaterIdChip( + repeaterId: repeater.repeaterId, + fontSize: 13, + width: _nodeColumnWidth()), // SNR Expanded( child: Center( child: _buildStatChip( - value: repeater.snr?.toStringAsFixed(1) ?? '-', + value: + repeater.snr?.toStringAsFixed(1) ?? + '-', color: snrColor, ), ), @@ -4120,7 +4423,9 @@ class _MapWidgetState extends State { Expanded( child: Center( child: _buildStatChip( - value: repeater.rssi != null ? '${repeater.rssi}' : '-', + value: repeater.rssi != null + ? '${repeater.rssi}' + : '-', color: rssiColor, ), ), @@ -4153,7 +4458,8 @@ class _MapWidgetState extends State { snrValues: [ping.snr], ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); } showModalBottomSheet( @@ -4167,7 +4473,8 @@ class _MapWidgetState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -4181,9 +4488,11 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.4)), ), - child: const Icon(Icons.arrow_downward, color: Colors.blue, size: 24), + child: const Icon(Icons.arrow_downward, + color: Colors.blue, size: 24), ), const SizedBox(width: 12), Expanded( @@ -4193,8 +4502,8 @@ class _MapWidgetState extends State { Text( 'RX Ping', style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(ping.timestamp), @@ -4222,11 +4531,17 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4260,13 +4575,18 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -4276,7 +4596,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4287,7 +4609,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4298,7 +4622,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4308,13 +4634,19 @@ class _MapWidgetState extends State { Divider(height: 1, color: Theme.of(context).dividerColor), // Data row InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, ping.repeaterId, fromLatLng: (lat: ping.latitude, lon: ping.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, ping.repeaterId, + fromLatLng: (lat: ping.latitude, lon: ping.longitude)), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: ping.repeaterId, fontSize: 13, width: _nodeColumnWidth()), + RepeaterIdChip( + repeaterId: ping.repeaterId, + fontSize: 13, + width: _nodeColumnWidth()), // SNR Expanded( child: Center( @@ -4329,12 +4661,12 @@ class _MapWidgetState extends State { child: Center( child: _buildStatChip( value: '${ping.rssi}', - color: rssiColor, + color: rssiColor, + ), ), ), - ), - ], - ), + ], + ), ), ), ], @@ -4353,10 +4685,12 @@ class _MapWidgetState extends State { final resolved = _resolveRepeatersByHexIds( entry.discoveredNodes.map((n) => n.repeaterId).toList(), fullHexIds: entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), - snrValues: entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), + snrValues: + entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); } } @@ -4377,7 +4711,8 @@ class _MapWidgetState extends State { ), child: SingleChildScrollView( child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -4391,9 +4726,11 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: _discMarkerColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), + border: Border.all( + color: _discMarkerColor.withValues(alpha: 0.4)), ), - child: Icon(Icons.radar, color: _discMarkerColor, size: 24), + child: + Icon(Icons.radar, color: _discMarkerColor, size: 24), ), const SizedBox(width: 12), Expanded( @@ -4402,15 +4739,20 @@ class _MapWidgetState extends State { children: [ Text( 'Disc Request', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(entry.timestamp), style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], @@ -4428,15 +4770,23 @@ class _MapWidgetState extends State { // Location chip Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4473,13 +4823,18 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -4489,7 +4844,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4500,7 +4857,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4511,7 +4870,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4522,24 +4883,36 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data rows ...entry.discoveredNodes.map((node) { final rxSnrColor = PingColors.snrColor(node.localSnr); - final rssiColor = PingColors.rssiColor(node.localRssi); - final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); + final rssiColor = + PingColors.rssiColor(node.localRssi); + final txSnrColor = + PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex, fromLatLng: (lat: entry.latitude, lon: entry.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, node.repeaterId, + fullHexId: node.pubkeyHex, + fromLatLng: ( + lat: entry.latitude, + lon: entry.longitude + )), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ // Node ID with type @@ -4547,7 +4920,9 @@ class _MapWidgetState extends State { width: _nodeColumnWidth(extraPadding: 20), child: Row( children: [ - RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), + RepeaterIdChip( + repeaterId: node.repeaterId, + fontSize: 13), Text( node.nodeTypeLabel, style: TextStyle( @@ -4581,7 +4956,8 @@ class _MapWidgetState extends State { Expanded( child: Center( child: _buildStatChip( - value: node.remoteSnr.toStringAsFixed(1), + value: + node.remoteSnr.toStringAsFixed(1), color: txSnrColor, ), ), @@ -4624,7 +5000,8 @@ class _MapWidgetState extends State { } /// Show repeater details popup - void _showRepeaterDetails(Repeater repeater, {bool isDuplicate = false, int? regionHopBytesOverride}) { + void _showRepeaterDetails(Repeater repeater, + {bool isDuplicate = false, int? regionHopBytesOverride}) { // Determine icon badge color based on primary status final iconColor = _getRepeaterMarkerColor(repeater, isDuplicate); @@ -4650,7 +5027,8 @@ class _MapWidgetState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -4660,7 +5038,8 @@ class _MapWidgetState extends State { children: [ // Icon badge with hex ID (mirrors map marker) Builder(builder: (context) { - final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); + final displayId = repeater.displayHexId( + overrideHopBytes: regionHopBytesOverride); final isLongId = displayId.length > 2; return Container( constraints: const BoxConstraints(minWidth: 44), @@ -4690,8 +5069,8 @@ class _MapWidgetState extends State { child: Text( repeater.name, style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -4711,7 +5090,8 @@ class _MapWidgetState extends State { Row( children: [ if (isDuplicate) ...[ - _buildRepeaterStatusChip('Duplicate', _repeaterDuplicateColor), + _buildRepeaterStatusChip( + 'Duplicate', _repeaterDuplicateColor), const SizedBox(width: 8), ], _buildRepeaterStatusChip(statusLabel, statusColor), @@ -4725,14 +5105,21 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Location row Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4750,7 +5137,10 @@ class _MapWidgetState extends State { // Last heard row Row( children: [ - Icon(Icons.access_time, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.access_time, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4930,7 +5320,13 @@ class _BikeMarkerPainter extends CustomPainter { ..lineTo(rightWheel.dx, rightWheel.dy) // Down to rear ..moveTo(cx, cy - 5) ..lineTo(cx + 2, cy - 7); // Handlebar - canvas.drawPath(framePath, Paint()..color = Colors.white..style = PaintingStyle.stroke..strokeWidth = 3.5..strokeCap = StrokeCap.round); + canvas.drawPath( + framePath, + Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5 + ..strokeCap = StrokeCap.round); // Blue wheels canvas.drawCircle(leftWheel, wheelR, bikePaint); @@ -4984,11 +5380,21 @@ class _BoatMarkerPainter extends CustomPainter { canvas.drawPath(hull, fillPaint); // Mast outline - canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), - Paint()..color = Colors.white..strokeWidth = 3..strokeCap = StrokeCap.round); + canvas.drawLine( + Offset(cx, cy + 2), + Offset(cx, cy - 9), + Paint() + ..color = Colors.white + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round); // Mast - canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), - Paint()..color = const Color(0xFF2196F3)..strokeWidth = 1.5..strokeCap = StrokeCap.round); + canvas.drawLine( + Offset(cx, cy + 2), + Offset(cx, cy - 9), + Paint() + ..color = const Color(0xFF2196F3) + ..strokeWidth = 1.5 + ..strokeCap = StrokeCap.round); // Sail outline final sailOutline = ui.Path() @@ -5004,7 +5410,11 @@ class _BoatMarkerPainter extends CustomPainter { ..lineTo(cx + 6, cy - 0.5) ..lineTo(cx + 1, cy - 0.5) ..close(); - canvas.drawPath(sail, Paint()..color = const Color(0xFF64B5F6)..style = PaintingStyle.fill); + canvas.drawPath( + sail, + Paint() + ..color = const Color(0xFF64B5F6) + ..style = PaintingStyle.fill); } @override @@ -5037,7 +5447,12 @@ class _WalkMarkerPainter extends CustomPainter { ..style = PaintingStyle.fill; // Head outline + fill - canvas.drawCircle(Offset(cx, cy - 7), 3.5, Paint()..color = Colors.white..style = PaintingStyle.fill); + canvas.drawCircle( + Offset(cx, cy - 7), + 3.5, + Paint() + ..color = Colors.white + ..style = PaintingStyle.fill); canvas.drawCircle(Offset(cx, cy - 7), 2.5, fillPaint); // Body outline @@ -5046,9 +5461,11 @@ class _WalkMarkerPainter extends CustomPainter { canvas.drawLine(Offset(cx, cy - 4), Offset(cx, cy + 3), personPaint); // Arms outline - canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), outlinePaint); + canvas.drawLine( + Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), outlinePaint); // Arms - canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), personPaint); + canvas.drawLine( + Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), personPaint); // Left leg outline canvas.drawLine(Offset(cx, cy + 3), Offset(cx - 4, cy + 10), outlinePaint); @@ -5128,7 +5545,9 @@ class _PinMarkerPainter extends CustomPainter { final cx = size.width / 2; final cy = size.height / 2; - final fillPaint = Paint()..color = color..style = PaintingStyle.fill; + final fillPaint = Paint() + ..color = color + ..style = PaintingStyle.fill; final outlinePaint = Paint() ..color = Colors.white.withValues(alpha: 0.8) ..style = PaintingStyle.stroke @@ -5164,11 +5583,13 @@ class _PinMarkerPainter extends CustomPainter { canvas.drawCircle(headCenter, headRadius, outlinePaint); // Inner dot - canvas.drawCircle(headCenter, 2.0, Paint()..color = Colors.white.withValues(alpha: 0.9)); + canvas.drawCircle( + headCenter, 2.0, Paint()..color = Colors.white.withValues(alpha: 0.9)); } @override - bool shouldRepaint(covariant _PinMarkerPainter oldDelegate) => oldDelegate.color != color; + bool shouldRepaint(covariant _PinMarkerPainter oldDelegate) => + oldDelegate.color != color; } /// Paints a diamond marker for coverage dots @@ -5208,7 +5629,8 @@ class _DiamondMarkerPainter extends CustomPainter { } @override - bool shouldRepaint(covariant _DiamondMarkerPainter oldDelegate) => oldDelegate.color != color; + bool shouldRepaint(covariant _DiamondMarkerPainter oldDelegate) => + oldDelegate.color != color; } /// Paints a repeater marker shape (filled colored rounded box with white border @@ -5275,7 +5697,7 @@ class _RepeaterShapePainter extends CustomPainter { /// styles. Used at startup to generate bitmap variants for native MapLibre /// symbols. Reuses _PinMarkerPainter and _DiamondMarkerPainter for those styles. class _CoverageMarkerPainter extends CustomPainter { - final String style; // 'circle' / 'pin' / 'diamond' / 'dot' + final String style; // 'circle' / 'pin' / 'diamond' / 'dot' final Color color; const _CoverageMarkerPainter({required this.style, required this.color}); @@ -5308,7 +5730,8 @@ class _CoverageMarkerPainter extends CustomPainter { canvas.restore(); } - void _paintCircle(Canvas canvas, Size size, {required double borderAlpha, required double borderWidth}) { + void _paintCircle(Canvas canvas, Size size, + {required double borderAlpha, required double borderWidth}) { final center = Offset(size.width / 2, size.height / 2); final radius = math.min(size.width, size.height) / 2 - 2; @@ -5387,7 +5810,9 @@ class _SoundItemWidgetState extends State<_SoundItemWidget> { : Colors.blue.withValues(alpha: 0.2), shape: BoxShape.circle, border: Border.all( - color: _isPlaying ? Colors.blue : Colors.blue.withValues(alpha: 0.5), + color: _isPlaying + ? Colors.blue + : Colors.blue.withValues(alpha: 0.5), width: _isPlaying ? 2 : 1, ), ), diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index 568807c..dc92a09 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -12,13 +12,16 @@ class InteractiveNoiseFloorChart extends StatefulWidget { final NoiseFloorSession session; final bool isLive; - const InteractiveNoiseFloorChart({super.key, required this.session, this.isLive = false}); + const InteractiveNoiseFloorChart( + {super.key, required this.session, this.isLive = false}); @override - State createState() => InteractiveNoiseFloorChartState(); + State createState() => + InteractiveNoiseFloorChartState(); } -class InteractiveNoiseFloorChartState extends State { +class InteractiveNoiseFloorChartState + extends State { // View window in seconds late double _viewStart; late double _viewEnd; @@ -68,7 +71,8 @@ class InteractiveNoiseFloorChartState extends State final effectiveTotal = newTotal < 60 ? 60.0 : newTotal; // Detect if user is at full (unzoomed) view: start near 0 and end near total - final isFullView = _viewStart < 2.0 && (_totalDuration - _viewEnd).abs() < 2.0; + final isFullView = + _viewStart < 2.0 && (_totalDuration - _viewEnd).abs() < 2.0; _totalDuration = effectiveTotal; @@ -92,14 +96,18 @@ class InteractiveNoiseFloorChartState extends State double get _visibleDuration => _viewEnd - _viewStart; double get _zoomLevel => _totalDuration / _visibleDuration; - void _handleScaleStart(ScaleStartDetails details, double chartWidth, double chartLeft) { + void _handleScaleStart( + ScaleStartDetails details, double chartWidth, double chartLeft) { _gestureStartViewStart = _viewStart; _gestureStartViewEnd = _viewEnd; _gestureStartFocalX = details.localFocalPoint.dx; } - void _handleScaleUpdate(ScaleUpdateDetails details, double chartWidth, double chartLeft) { - if (_gestureStartViewStart == null || _gestureStartViewEnd == null || _gestureStartFocalX == null) { + void _handleScaleUpdate( + ScaleUpdateDetails details, double chartWidth, double chartLeft) { + if (_gestureStartViewStart == null || + _gestureStartViewEnd == null || + _gestureStartFocalX == null) { return; } @@ -110,7 +118,8 @@ class InteractiveNoiseFloorChartState extends State newDuration = newDuration.clamp(_minVisibleSeconds, _totalDuration); // Calculate focal point ratio in chart space - final focalRatio = ((_gestureStartFocalX! - chartLeft) / chartWidth).clamp(0.0, 1.0); + final focalRatio = + ((_gestureStartFocalX! - chartLeft) / chartWidth).clamp(0.0, 1.0); // Time at focal point in original view final focalTime = _gestureStartViewStart! + (startDuration * focalRatio); @@ -150,7 +159,8 @@ class InteractiveNoiseFloorChartState extends State } /// Check if tap hit a marker and show popup if so - void _handleTap(TapUpDetails details, double chartWidth, double chartLeft, double chartHeight, double chartTop) { + void _handleTap(TapUpDetails details, double chartWidth, double chartLeft, + double chartHeight, double chartTop) { final session = widget.session; if (session.markers.isEmpty || session.samples.isEmpty) return; @@ -161,7 +171,8 @@ class InteractiveNoiseFloorChartState extends State // Find if tap is within any marker for (final marker in session.markers) { - final elapsed = marker.timestamp.difference(session.startTime).inSeconds.toDouble(); + final elapsed = + marker.timestamp.difference(session.startTime).inSeconds.toDouble(); if (elapsed < _viewStart || elapsed > _viewEnd) continue; @@ -176,7 +187,8 @@ class InteractiveNoiseFloorChartState extends State final tapX = details.localPosition.dx; final tapY = details.localPosition.dy; - final distance = ((tapX - markerX) * (tapX - markerX) + (tapY - markerY) * (tapY - markerY)); + final distance = ((tapX - markerX) * (tapX - markerX) + + (tapY - markerY) * (tapY - markerY)); if (distance <= _markerTapRadius * _markerTapRadius) { _showMarkerDetails(marker, noiseFloorOnLine.round()); return; @@ -185,9 +197,12 @@ class InteractiveNoiseFloorChartState extends State } /// Interpolate noise floor at given elapsed time - double _interpolateNoiseFloor(double elapsedSeconds, NoiseFloorSession session) { - if (session.samples.isEmpty) return widget.session.noiseFloorRange.min.toDouble(); - if (session.samples.length == 1) return session.samples.first.noiseFloor.toDouble(); + double _interpolateNoiseFloor( + double elapsedSeconds, NoiseFloorSession session) { + if (session.samples.isEmpty) + return widget.session.noiseFloorRange.min.toDouble(); + if (session.samples.length == 1) + return session.samples.first.noiseFloor.toDouble(); NoiseFloorSample? before; NoiseFloorSample? after; @@ -195,7 +210,8 @@ class InteractiveNoiseFloorChartState extends State double afterElapsed = 0; for (final sample in session.samples) { - final sampleElapsed = sample.timestamp.difference(session.startTime).inSeconds.toDouble(); + final sampleElapsed = + sample.timestamp.difference(session.startTime).inSeconds.toDouble(); if (sampleElapsed <= elapsedSeconds) { before = sample; @@ -210,8 +226,10 @@ class InteractiveNoiseFloorChartState extends State if (before == null) return session.samples.first.noiseFloor.toDouble(); if (after == null) return before.noiseFloor.toDouble(); - final timeFraction = (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); - return before.noiseFloor + (after.noiseFloor - before.noiseFloor) * timeFraction; + final timeFraction = + (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); + return before.noiseFloor + + (after.noiseFloor - before.noiseFloor) * timeFraction; } /// Show marker details popup as a modern bottom sheet @@ -260,7 +278,10 @@ class InteractiveNoiseFloorChartState extends State width: 40, height: 4, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(2), ), ), @@ -282,8 +303,8 @@ class InteractiveNoiseFloorChartState extends State Text( eventTypeLabel, style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), const Spacer(), Text( @@ -325,7 +346,8 @@ class InteractiveNoiseFloorChartState extends State context, icon: Icons.location_on, label: 'Location', - value: '${marker.latitude!.toStringAsFixed(4)}, ${marker.longitude!.toStringAsFixed(4)}', + value: + '${marker.latitude!.toStringAsFixed(4)}, ${marker.longitude!.toStringAsFixed(4)}', compact: true, ), ), @@ -334,19 +356,26 @@ class InteractiveNoiseFloorChartState extends State ), // Repeaters section (table format like TX log) - if (marker.repeaters != null && marker.repeaters!.isNotEmpty) ...[ + if (marker.repeaters != null && + marker.repeaters!.isNotEmpty) ...[ const SizedBox(height: 16), Container( decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: + Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ SizedBox( @@ -356,7 +385,9 @@ class InteractiveNoiseFloorChartState extends State style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -367,7 +398,9 @@ class InteractiveNoiseFloorChartState extends State style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -378,16 +411,20 @@ class InteractiveNoiseFloorChartState extends State style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data rows - ...marker.repeaters!.map((r) => _buildRepeaterRow(context, r)), + ...marker.repeaters! + .map((r) => _buildRepeaterRow(context, r)), ], ), ), @@ -401,7 +438,8 @@ class InteractiveNoiseFloorChartState extends State child: FilledButton.icon( onPressed: () { // Get references before popping - final appState = Provider.of(context, listen: false); + final appState = Provider.of(context, + listen: false); final navigator = Navigator.of(context); // Pop the bottom sheet first @@ -412,7 +450,8 @@ class InteractiveNoiseFloorChartState extends State navigator.popUntil((route) => route.isFirst); // Navigate to map and center on location - appState.navigateToMapCoordinates(marker.latitude!, marker.longitude!); + appState.navigateToMapCoordinates( + marker.latitude!, marker.longitude!); }, icon: const Icon(Icons.map, size: 18), label: const Text('View on Map'), @@ -444,7 +483,10 @@ class InteractiveNoiseFloorChartState extends State return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), ), child: Column( @@ -487,17 +529,21 @@ class InteractiveNoiseFloorChartState extends State final rssiColor = PingColors.rssiColor(repeater.rssi); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId, fullHexId: repeater.pubkeyHex), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, repeater.repeaterId, + fullHexId: repeater.pubkeyHex), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ // Node ID - RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 11, width: 50), + RepeaterIdChip( + repeaterId: repeater.repeaterId, fontSize: 11, width: 50), // SNR chip Expanded( child: Center( - child: _buildValueChip(repeater.snr.toStringAsFixed(1), snrColor), + child: + _buildValueChip(repeater.snr.toStringAsFixed(1), snrColor), ), ), // RSSI chip @@ -575,24 +621,32 @@ class InteractiveNoiseFloorChartState extends State Expanded( child: LayoutBuilder( builder: (context, constraints) { - final chartWidth = constraints.maxWidth - leftPadding - rightPadding; + final chartWidth = + constraints.maxWidth - leftPadding - rightPadding; - final chartHeight = constraints.maxHeight - topPadding - 36.0; // 36 = bottom axis reserved + final chartHeight = constraints.maxHeight - + topPadding - + 36.0; // 36 = bottom axis reserved return RawGestureDetector( gestures: { - ScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers( + ScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers< + ScaleGestureRecognizer>( () => ScaleGestureRecognizer(), (ScaleGestureRecognizer instance) { - instance.onStart = (details) => _handleScaleStart(details, chartWidth, leftPadding); - instance.onUpdate = (details) => _handleScaleUpdate(details, chartWidth, leftPadding); + instance.onStart = (details) => + _handleScaleStart(details, chartWidth, leftPadding); + instance.onUpdate = (details) => + _handleScaleUpdate(details, chartWidth, leftPadding); instance.onEnd = _handleScaleEnd; }, ), - TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( + TapGestureRecognizer: GestureRecognizerFactoryWithHandlers< + TapGestureRecognizer>( () => TapGestureRecognizer(), (TapGestureRecognizer instance) { - instance.onTapUp = (details) => _handleTap(details, chartWidth, leftPadding, chartHeight, topPadding); + instance.onTapUp = (details) => _handleTap(details, + chartWidth, leftPadding, chartHeight, topPadding); }, ), }, @@ -601,7 +655,8 @@ class InteractiveNoiseFloorChartState extends State children: [ // Line chart - wrapped in IgnorePointer so it doesn't steal gestures Padding( - padding: const EdgeInsets.only(top: topPadding, right: rightPadding), + padding: const EdgeInsets.only( + top: topPadding, right: rightPadding), child: IgnorePointer( child: LineChart( LineChartData( @@ -622,7 +677,8 @@ class InteractiveNoiseFloorChartState extends State ), // Marker overlay Padding( - padding: const EdgeInsets.only(top: topPadding, right: rightPadding), + padding: const EdgeInsets.only( + top: topPadding, right: rightPadding), child: IgnorePointer( child: CustomPaint( size: Size.infinite, @@ -664,13 +720,15 @@ class InteractiveNoiseFloorChartState extends State LineChartBarData _buildLineData(NoiseFloorSession session) { // Return cached data if session hasn't changed (prevents rebuilding during zoom) - if (_cachedLineData != null && _cachedSession == session && + if (_cachedLineData != null && + _cachedSession == session && _cachedSampleCount == session.samples.length) { return _cachedLineData!; } final spots = session.samples.map((s) { - final elapsed = s.timestamp.difference(session.startTime).inSeconds.toDouble(); + final elapsed = + s.timestamp.difference(session.startTime).inSeconds.toDouble(); return FlSpot(elapsed, s.noiseFloor.toDouble()); }).toList(); @@ -703,9 +761,9 @@ class InteractiveNoiseFloorChartState extends State ]; final stops = [ 0.0, - yToStop(-100), // Start fading from green - yToStop(-90), // Orange in middle - yToStop(-80), // Fade to red + yToStop(-100), // Start fading from green + yToStop(-90), // Orange in middle + yToStop(-80), // Fade to red 1.0, ]; @@ -919,7 +977,8 @@ class _MarkerPainter extends CustomPainter { if (visibleRange <= 0 || chartWidth <= 0 || chartHeight <= 0) return; for (final marker in session.markers) { - final elapsed = marker.timestamp.difference(session.startTime).inSeconds.toDouble(); + final elapsed = + marker.timestamp.difference(session.startTime).inSeconds.toDouble(); if (elapsed < minX || elapsed > maxX) continue; @@ -948,7 +1007,8 @@ class _MarkerPainter extends CustomPainter { double _interpolateNoiseFloor(double elapsedSeconds) { if (session.samples.isEmpty) return minY; - if (session.samples.length == 1) return session.samples.first.noiseFloor.toDouble(); + if (session.samples.length == 1) + return session.samples.first.noiseFloor.toDouble(); NoiseFloorSample? before; NoiseFloorSample? after; @@ -956,7 +1016,8 @@ class _MarkerPainter extends CustomPainter { double afterElapsed = 0; for (final sample in session.samples) { - final sampleElapsed = sample.timestamp.difference(session.startTime).inSeconds.toDouble(); + final sampleElapsed = + sample.timestamp.difference(session.startTime).inSeconds.toDouble(); if (sampleElapsed <= elapsedSeconds) { before = sample; @@ -971,8 +1032,10 @@ class _MarkerPainter extends CustomPainter { if (before == null) return session.samples.first.noiseFloor.toDouble(); if (after == null) return before.noiseFloor.toDouble(); - final timeFraction = (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); - return before.noiseFloor + (after.noiseFloor - before.noiseFloor) * timeFraction; + final timeFraction = + (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); + return before.noiseFloor + + (after.noiseFloor - before.noiseFloor) * timeFraction; } @override diff --git a/lib/widgets/offline_mode_toggle.dart b/lib/widgets/offline_mode_toggle.dart index 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..fe44f97 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -15,29 +15,44 @@ class PingControls extends StatelessWidget { Widget build(BuildContext context) { final appState = context.watch(); final validation = appState.pingValidation; - final manualValidation = appState.manualPingValidation; // Manual ping validation (no distance check) + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) final autoValidation = appState.autoModeValidation; - final canPingManual = manualValidation == PingValidation.valid; // For Send Ping button + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; - final isPendingDisable = appState.isPendingDisable; // Disable pending, waiting for RX window to complete - final cooldownActive = appState.cooldownTimer.isRunning; // Shared cooldown after disabling Active Mode + final isPendingDisable = appState + .isPendingDisable; // Disable pending, waiting for RX window to complete + final cooldownActive = appState + .cooldownTimer.isRunning; // Shared cooldown after disabling Active Mode final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState.manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = appState.manualPingCooldownTimer.remainingSec; - final rxWindowActive = appState.rxWindowTimer.isRunning; // RX listening window after ping + final manualCooldownActive = appState + .manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; + final rxWindowActive = + appState.rxWindowTimer.isRunning; // RX listening window after ping final rxWindowRemaining = appState.rxWindowTimer.remainingSec; - final isPingSending = appState.isPingSending; // True immediately when manual ping button clicked - final isPingInProgress = appState.isPingInProgress; // True during entire ping + RX window (includes auto pings) - final autoPingWaiting = appState.autoPingTimer.isRunning; // Waiting for next auto ping + final isPingSending = appState + .isPingSending; // True immediately when manual ping button clicked + final isPingInProgress = appState + .isPingInProgress; // True during entire ping + RX window (includes auto pings) + final autoPingWaiting = + appState.autoPingTimer.isRunning; // Waiting for next auto ping final autoPingRemaining = appState.autoPingTimer.remainingSec; - final autoPingSkipped = appState.autoPingTimer.skipReason != null; // Last ping was skipped (e.g. distance) - final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; // Discovery listening window countdown (Passive Mode) + final autoPingSkipped = appState.autoPingTimer.skipReason != + null; // Last ping was skipped (e.g. distance) + final discoveryWindowActive = appState.discoveryWindowTimer + .isRunning; // Discovery listening window countdown (Passive Mode) final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; // TX is blocked when offline mode is active and connected @@ -53,7 +68,9 @@ class PingControls extends StatelessWidget { Color? blockingColor; final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; if (!appState.isConnected) { // Don't show hint when disconnected - buttons are obviously disabled @@ -87,89 +104,135 @@ class PingControls extends StatelessWidget { Row( children: [ if (!txNotAllowed) ...[ - // Send Ping button - // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" - // Manual pings use 15-second cooldown, no distance requirement - // When Active/Passive Mode is running, just shows "Send Ping" (disabled) - Expanded( - child: _ActionButton( - icon: Icons.cell_tower, - label: txBlockedByOffline - ? 'TX Disabled' - : txNotAllowed - ? 'Zone Full' - : isTxModeRunning - ? 'Send Ping' // Just disabled when Active/Hybrid Mode is running - : isPingSending - ? 'Sending...' - : rxWindowActive - ? 'Listening ${rxWindowRemaining}s' // Manual ping listening (works during Passive Mode too) - : manualCooldownActive - ? 'Cooldown ${manualCooldownRemaining}s' // Manual ping 15-second cooldown - : discoveryWindowActive - ? 'Cooldown ${discoveryWindowRemaining}s' // Cooldown during Passive Mode listening - : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled - : 'Send Ping', - color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && - !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, - isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, // Only active during manual ping flow - onPressed: () => _sendPing(context, appState), - showCooldown: false, // No longer needed - countdown shown in label - subtitle: txBlockedByOffline ? 'Offline Mode' : txNotAllowed ? 'Passive Only' : null, // No "Move Xm" - manual pings have no distance requirement - subtitleColor: txBlockedByOffline ? Colors.orange : txNotAllowed ? Colors.red : null, + // Send Ping button + // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" + // Manual pings use 15-second cooldown, no distance requirement + // When Active/Passive Mode is running, just shows "Send Ping" (disabled) + Expanded( + child: _ActionButton( + icon: Icons.cell_tower, + label: txBlockedByOffline + ? 'TX Disabled' + : txNotAllowed + ? 'Zone Full' + : isTxModeRunning + ? 'Send Ping' // Just disabled when Active/Hybrid Mode is running + : isPingSending + ? 'Sending...' + : rxWindowActive + ? 'Listening ${rxWindowRemaining}s' // Manual ping listening (works during Passive Mode too) + : manualCooldownActive + ? 'Cooldown ${manualCooldownRemaining}s' // Manual ping 15-second cooldown + : discoveryWindowActive + ? 'Cooldown ${discoveryWindowRemaining}s' // Cooldown during Passive Mode listening + : cooldownActive + ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled + : 'Send Ping', + color: const Color(0xFF0EA5E9), // sky-500 + enabled: canPingManual && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable, + isActive: (isPingSending || rxWindowActive) && + !isTxModeRunning, // Only active during manual ping flow + onPressed: () => _sendPing(context, appState), + showCooldown: + false, // No longer needed - countdown shown in label + subtitle: txBlockedByOffline + ? 'Offline Mode' + : txNotAllowed + ? 'Passive Only' + : null, // No "Move Xm" - manual pings have no distance requirement + subtitleColor: txBlockedByOffline + ? Colors.orange + : txNotAllowed + ? Colors.red + : null, + ), ), - ), - const SizedBox(width: 10), + const SizedBox(width: 10), - // Active/Hybrid Mode button (toggle) - // When hybridEnabled: shows as "Hybrid Mode" with compare_arrows icon - // When ON: shows "Sending..."/"Discovering..." → "Listening Xs" → "Next ping Xs" cycle - // When OFF after being ON: shows "Cooldown Xs" like other buttons - // During manual ping: shows "Cooldown Xs" (disabled) - Expanded( - child: _ActionButton( - icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, - label: txBlockedByOffline - ? 'TX Disabled' - : txNotAllowed - ? 'Zone Full' - : isPendingDisable - ? (rxWindowActive - ? 'Stopping ${rxWindowRemaining}s' - : discoveryWindowActive - ? 'Stopping ${discoveryWindowRemaining}s' - : 'Stopping...') - : isTxModeRunning - ? (isPingInProgress && !rxWindowActive && !discoveryWindowActive - ? 'Sending...' - : discoveryWindowActive - ? 'Listening ${discoveryWindowRemaining}s' // Discovery listening window - : rxWindowActive - ? 'Listening ${rxWindowRemaining}s' // TX RX window - : autoPingWaiting - ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next ping ${autoPingRemaining}s') - : hybridEnabled ? 'Hybrid Mode' : 'Active Mode') - : rxWindowActive - ? 'Cooldown ${rxWindowRemaining}s' - : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' - : hybridEnabled ? 'Hybrid Mode' : 'Active Mode', - color: isPendingDisable - ? Colors.orange - : isTxModeRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), - isActive: isPendingDisable || isTxModeRunning, - onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), - showCooldown: false, - subtitle: txBlockedByOffline ? 'Offline Mode' : txNotAllowed ? 'Passive Only' : (isPendingDisable ? 'Stopping' : null), - subtitleColor: txBlockedByOffline ? Colors.orange : txNotAllowed ? Colors.red : Colors.orange, + // Active/Hybrid Mode button (toggle) + // When hybridEnabled: shows as "Hybrid Mode" with compare_arrows icon + // When ON: shows "Sending..."/"Discovering..." → "Listening Xs" → "Next ping Xs" cycle + // When OFF after being ON: shows "Cooldown Xs" like other buttons + // During manual ping: shows "Cooldown Xs" (disabled) + Expanded( + child: _ActionButton( + icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, + label: txBlockedByOffline + ? 'TX Disabled' + : txNotAllowed + ? 'Zone Full' + : isPendingDisable + ? (rxWindowActive + ? 'Stopping ${rxWindowRemaining}s' + : discoveryWindowActive + ? 'Stopping ${discoveryWindowRemaining}s' + : 'Stopping...') + : isTxModeRunning + ? (isPingInProgress && + !rxWindowActive && + !discoveryWindowActive + ? 'Sending...' + : discoveryWindowActive + ? 'Listening ${discoveryWindowRemaining}s' // Discovery listening window + : rxWindowActive + ? 'Listening ${rxWindowRemaining}s' // TX RX window + : autoPingWaiting + ? (autoPingSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Next ping ${autoPingRemaining}s') + : hybridEnabled + ? 'Hybrid Mode' + : 'Active Mode') + : rxWindowActive + ? 'Cooldown ${rxWindowRemaining}s' + : cooldownActive + ? 'Cooldown ${cooldownRemaining}s' + : hybridEnabled + ? 'Hybrid Mode' + : 'Active Mode', + color: isPendingDisable + ? Colors.orange + : isTxModeRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF6366F1), // indigo-500 + enabled: !isPendingDisable && + !isTargetedRunning && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed), + isActive: isPendingDisable || isTxModeRunning, + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), + showCooldown: false, + subtitle: txBlockedByOffline + ? 'Offline Mode' + : txNotAllowed + ? 'Passive Only' + : (isPendingDisable ? 'Stopping' : null), + subtitleColor: txBlockedByOffline + ? Colors.orange + : txNotAllowed + ? Colors.red + : Colors.orange, + ), ), - ), - const SizedBox(width: 10), + const SizedBox(width: 10), ], // Passive Mode button (toggle) @@ -182,24 +245,35 @@ class PingControls extends StatelessWidget { icon: Icons.hearing, label: isPassiveModeRunning ? (discoveryWindowActive - ? 'Listening ${discoveryWindowRemaining}s' // During discovery listening window + ? 'Listening ${discoveryWindowRemaining}s' // During discovery listening window : autoPingWaiting - ? (autoPingSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next Disc ${autoPingRemaining}s') // Waiting for next discovery - : 'Passive Mode') // Initial state before first discovery + ? (autoPingSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Next Disc ${autoPingRemaining}s') // Waiting for next discovery + : 'Passive Mode') // Initial state before first discovery : isTxModeRunning || isPendingDisable - ? 'Passive Mode' // Just disabled when Active/Hybrid Mode is running or stopping + ? 'Passive Mode' // Just disabled when Active/Hybrid Mode is running or stopping : rxWindowActive - ? 'Cooldown ${rxWindowRemaining}s' // During manual ping listening + ? 'Cooldown ${rxWindowRemaining}s' // During manual ping listening : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled + ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled : 'Passive Mode', color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && - !isPingSending && !rxWindowActive && !cooldownActive && - prefs.externalAntennaSet && isPowerSet), - isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), // Active during listening/waiting phases + enabled: isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet), + isActive: isPassiveModeRunning && + (discoveryWindowActive || + autoPingWaiting), // Active during listening/waiting phases onPressed: () => _toggleRxAuto(context, appState), ), ), @@ -231,7 +305,9 @@ class PingControls extends StatelessWidget { // Targeted Ping controls _TargetedPingSection( - isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, + isAnyModeRunning: isActiveModeRunning || + isPassiveModeRunning || + isHybridModeRunning, cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, ), @@ -239,7 +315,8 @@ class PingControls extends StatelessWidget { ); } - Future _sendPing(BuildContext context, AppStateProvider appState) async { + Future _sendPing( + BuildContext context, AppStateProvider appState) async { HapticFeedback.mediumImpact(); final success = await appState.sendPing(); @@ -249,17 +326,20 @@ class PingControls extends StatelessWidget { } } - Future _toggleTxRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleTxRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.active); } - Future _toggleHybridAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleHybridAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.hybrid); } - Future _toggleRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.passive); } @@ -274,8 +354,8 @@ class _ActionButton extends StatefulWidget { final bool isActive; final bool showCooldown; final VoidCallback onPressed; - final String? subtitle; // Optional subtitle text (e.g., "Move 5m") - final Color? subtitleColor; // Optional subtitle color + final String? subtitle; // Optional subtitle text (e.g., "Move 5m") + final Color? subtitleColor; // Optional subtitle color const _ActionButton({ required this.icon, @@ -338,7 +418,8 @@ class _ActionButtonState extends State<_ActionButton> // Use color when enabled, active (RX listening), or during cooldown // This prevents the button from going grey during cooldown final showColor = widget.enabled || widget.isActive || widget.showCooldown; - final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; + final effectiveColor = + showColor ? widget.color : colorScheme.onSurfaceVariant; final borderOpacity = widget.isActive ? 0.6 : 0.3; return AnimatedBuilder( @@ -378,7 +459,8 @@ class _ActionButtonState extends State<_ActionButton> size: 26, color: showColor ? effectiveColor - : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), // Active indicator dot if (widget.isActive) @@ -407,9 +489,12 @@ class _ActionButtonState extends State<_ActionButton> widget.label, style: TextStyle( fontSize: 11, - fontWeight: widget.isActive ? FontWeight.w600 : FontWeight.w500, + fontWeight: + widget.isActive ? FontWeight.w600 : FontWeight.w500, color: showColor - ? (widget.isActive ? effectiveColor : colorScheme.onSurface) + ? (widget.isActive + ? effectiveColor + : colorScheme.onSurface) : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), ), @@ -431,7 +516,8 @@ class _ActionButtonState extends State<_ActionButton> style: TextStyle( fontSize: 9, fontWeight: FontWeight.w500, - color: widget.subtitleColor ?? Colors.orange.shade600, + color: widget.subtitleColor ?? + Colors.orange.shade600, ), ) : null, @@ -475,7 +561,9 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { WidgetsBinding.instance.addPostFrameCallback((_) { final appState = context.read(); final existing = appState.targetRepeaterId; - if (existing != null && existing.isNotEmpty && _controller.text != existing) { + if (existing != null && + existing.isNotEmpty && + _controller.text != existing) { _controller.text = existing; } }); @@ -545,14 +633,17 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { final buttonColor = (isTargetedRunning || _isStarting) ? const Color(0xFF22C55E) // green-500 when running/starting : Colors.cyan; - final effectiveColor = isEnabled ? buttonColor : colorScheme.onSurfaceVariant; + final effectiveColor = + isEnabled ? buttonColor : colorScheme.onSurfaceVariant; return Container( decoration: BoxDecoration( - color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.15 : 0.08), + color: + effectiveColor.withValues(alpha: isTargetedRunning ? 0.15 : 0.08), borderRadius: BorderRadius.circular(10), border: Border.all( - color: effectiveColor.withValues(alpha: isTargetedRunning ? 0.5 : 0.25), + color: + effectiveColor.withValues(alpha: isTargetedRunning ? 0.5 : 0.25), width: isTargetedRunning ? 1.5 : 1, ), ), @@ -567,7 +658,8 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { HapticFeedback.lightImpact(); if (!isTargetedRunning) { setState(() => _isStarting = true); - appState.setTargetRepeaterId(_controller.text.trim().toUpperCase()); + appState.setTargetRepeaterId( + _controller.text.trim().toUpperCase()); } await appState.toggleAutoPing(AutoMode.targeted); if (mounted) setState(() => _isStarting = false); @@ -594,8 +686,13 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { : 'Trace Mode', style: TextStyle( fontSize: 13, - fontWeight: isTargetedRunning ? FontWeight.w600 : FontWeight.w500, - color: isEnabled ? colorScheme.onSurface : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + fontWeight: isTargetedRunning + ? FontWeight.w600 + : FontWeight.w500, + color: isEnabled + ? colorScheme.onSurface + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), overflow: TextOverflow.ellipsis, ), @@ -622,14 +719,16 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { : colorScheme.onSurface, ), decoration: InputDecoration( - hintText: 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : maxLen == 8 ? '4E7A3B00' : '4E7A3B'}', + hintText: + 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : maxLen == 8 ? '4E7A3B00' : '4E7A3B'}', hintStyle: TextStyle( fontSize: 12, color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), counterText: '', isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + contentPadding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 8), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), @@ -705,21 +804,28 @@ class _CompactPingControlsState extends State { @override Widget build(BuildContext context) { final appState = context.watch(); - final manualValidation = appState.manualPingValidation; // Manual ping validation (no distance check) + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) final autoValidation = appState.autoModeValidation; - final canPingManual = manualValidation == PingValidation.valid; // For Send Ping button + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState.manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = appState.manualPingCooldownTimer.remainingSec; + final manualCooldownActive = appState + .manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; final rxWindowActive = appState.rxWindowTimer.isRunning; final rxWindowRemaining = appState.rxWindowTimer.remainingSec; final isPingSending = appState.isPingSending; @@ -737,12 +843,17 @@ class _CompactPingControlsState extends State { final txNotAllowed = appState.isConnected && !appState.txAllowed; final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; // Determine which button is currently active (not during cooldown) - final sendPingCurrentlyActive = (isPingSending || rxWindowActive || manualCooldownActive) && !isTxModeRunning; + final sendPingCurrentlyActive = + (isPingSending || rxWindowActive || manualCooldownActive) && + !isTxModeRunning; final activeModeCurrentlyActive = isPendingDisable || isTxModeRunning; - final passiveModeCurrentlyActive = isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); + final passiveModeCurrentlyActive = + isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); // Track the last active button for cooldown if (sendPingCurrentlyActive) { @@ -755,14 +866,20 @@ class _CompactPingControlsState extends State { _lastActiveButton = _LastActiveButton.targeted; } // Reset when no cooldown and no activity - if (!cooldownActive && !manualCooldownActive && !sendPingCurrentlyActive && !activeModeCurrentlyActive && !passiveModeCurrentlyActive && !isTargetedRunning) { + if (!cooldownActive && + !manualCooldownActive && + !sendPingCurrentlyActive && + !activeModeCurrentlyActive && + !passiveModeCurrentlyActive && + !isTargetedRunning) { _lastActiveButton = _LastActiveButton.none; } // Determine which button should be expanded // During cooldown, the last active button stays expanded final sendPingExpanded = sendPingCurrentlyActive || - (manualCooldownActive && _lastActiveButton == _LastActiveButton.sendPing) || + (manualCooldownActive && + _lastActiveButton == _LastActiveButton.sendPing) || (cooldownActive && _lastActiveButton == _LastActiveButton.sendPing); final activeModeExpanded = activeModeCurrentlyActive || (cooldownActive && _lastActiveButton == _LastActiveButton.activeMode); @@ -770,36 +887,80 @@ class _CompactPingControlsState extends State { (cooldownActive && _lastActiveButton == _LastActiveButton.passiveMode); // Determine which buttons are colored (enabled or active) - final sendPingEnabled = canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && - !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable; - final sendPingActive = (isPingSending || rxWindowActive) && !isTxModeRunning && !cooldownActive && !manualCooldownActive; + final sendPingEnabled = canPingManual && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable; + final sendPingActive = (isPingSending || rxWindowActive) && + !isTxModeRunning && + !cooldownActive && + !manualCooldownActive; final sendPingShowColor = sendPingEnabled || sendPingActive; - final activeModeEnabled = !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed); + final activeModeEnabled = !isPendingDisable && + !isTargetedRunning && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed); final activeModeActive = isPendingDisable || isTxModeRunning; final activeModeShowColor = activeModeEnabled || activeModeActive; - final passiveModeEnabled = isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && - !isPingSending && !rxWindowActive && !cooldownActive && - prefs.externalAntennaSet && isPowerSet); - final passiveModeActive = isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); + final passiveModeEnabled = isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet); + final passiveModeActive = + isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); final passiveModeShowColor = passiveModeEnabled || passiveModeActive; // Trace Mode (only relevant when a repeater ID has been entered) - final hasTargetRepeaterId = appState.targetRepeaterId != null && appState.targetRepeaterId!.isNotEmpty; + final hasTargetRepeaterId = appState.targetRepeaterId != null && + appState.targetRepeaterId!.isNotEmpty; final targetedCurrentlyActive = isTargetedRunning; final traceModeExpanded = targetedCurrentlyActive || (cooldownActive && _lastActiveButton == _LastActiveButton.targeted); - final traceModeEnabled = hasTargetRepeaterId && !isTxModeRunning && !isPassiveModeRunning && - !isPendingDisable && !isPingSending && !rxWindowActive && !cooldownActive && - !manualCooldownActive && appState.isConnected && prefs.externalAntennaSet && isPowerSet; + final traceModeEnabled = hasTargetRepeaterId && + !isTxModeRunning && + !isPassiveModeRunning && + !isPendingDisable && + !isPingSending && + !rxWindowActive && + !cooldownActive && + !manualCooldownActive && + appState.isConnected && + prefs.externalAntennaSet && + isPowerSet; final traceModeActive = isTargetedRunning; final traceModeShowColor = traceModeEnabled || traceModeActive; // Check if any button is actively expanded (showing label) - final anyExpanded = sendPingExpanded || activeModeExpanded || passiveModeExpanded || traceModeExpanded; + final anyExpanded = sendPingExpanded || + activeModeExpanded || + passiveModeExpanded || + traceModeExpanded; // Check if all buttons are disabled (no color) - used to split space equally in initial state - final allDisabled = !sendPingShowColor && !activeModeShowColor && !passiveModeShowColor && (!hasTargetRepeaterId || !traceModeShowColor); + final allDisabled = !sendPingShowColor && + !activeModeShowColor && + !passiveModeShowColor && + (!hasTargetRepeaterId || !traceModeShowColor); // Build the buttons final sendPingButton = _CompactActionButton( @@ -822,9 +983,11 @@ class _CompactPingControlsState extends State { isExpanded: sendPingExpanded, progress: rxWindowActive && !isTxModeRunning ? appState.rxWindowTimer.progress - : manualCooldownActive && _lastActiveButton == _LastActiveButton.sendPing + : manualCooldownActive && + _lastActiveButton == _LastActiveButton.sendPing ? appState.manualPingCooldownTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.sendPing + : cooldownActive && + _lastActiveButton == _LastActiveButton.sendPing ? appState.cooldownTimer.progress : null, onPressed: () => _sendPing(context, appState), @@ -857,13 +1020,18 @@ class _CompactPingControlsState extends State { isActive: activeModeActive, isExpanded: activeModeExpanded, progress: (rxWindowActive || discoveryWindowActive) && isTxModeRunning - ? (discoveryWindowActive ? appState.discoveryWindowTimer.progress : appState.rxWindowTimer.progress) + ? (discoveryWindowActive + ? appState.discoveryWindowTimer.progress + : appState.rxWindowTimer.progress) : autoPingWaiting && isTxModeRunning ? appState.autoPingTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.activeMode + : cooldownActive && + _lastActiveButton == _LastActiveButton.activeMode ? appState.cooldownTimer.progress : null, - onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), ); final passiveModeButton = _CompactActionButton( @@ -890,7 +1058,8 @@ class _CompactPingControlsState extends State { ? appState.discoveryWindowTimer.progress : autoPingWaiting && isPassiveModeRunning ? appState.autoPingTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.passiveMode + : cooldownActive && + _lastActiveButton == _LastActiveButton.passiveMode ? appState.cooldownTimer.progress : null, onPressed: () => _toggleRxAuto(context, appState), @@ -921,7 +1090,8 @@ class _CompactPingControlsState extends State { ? appState.discoveryWindowTimer.progress : autoPingWaiting && isTargetedRunning ? appState.autoPingTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.targeted + : cooldownActive && + _lastActiveButton == _LastActiveButton.targeted ? appState.cooldownTimer.progress : null, onPressed: () { @@ -937,23 +1107,23 @@ class _CompactPingControlsState extends State { return Row( children: [ if (!txNotAllowed) ...[ - // Send Ping - expanded buttons stay big even when grey (cooldown) - if (sendPingExpanded) - Expanded(child: sendPingButton) - else if (!anyExpanded && (sendPingShowColor || allDisabled)) - Expanded(child: sendPingButton) - else - sendPingButton, - const SizedBox(width: 6), - - // Active Mode - if (activeModeExpanded) - Expanded(child: activeModeButton) - else if (!anyExpanded && (activeModeShowColor || allDisabled)) - Expanded(child: activeModeButton) - else - activeModeButton, - const SizedBox(width: 6), + // Send Ping - expanded buttons stay big even when grey (cooldown) + if (sendPingExpanded) + Expanded(child: sendPingButton) + else if (!anyExpanded && (sendPingShowColor || allDisabled)) + Expanded(child: sendPingButton) + else + sendPingButton, + const SizedBox(width: 6), + + // Active Mode + if (activeModeExpanded) + Expanded(child: activeModeButton) + else if (!anyExpanded && (activeModeShowColor || allDisabled)) + Expanded(child: activeModeButton) + else + activeModeButton, + const SizedBox(width: 6), ], // Passive Mode @@ -993,10 +1163,22 @@ class _CompactPingControlsState extends State { required bool showFullText, }) { if (isPingSending) return showFullText ? 'Sending...' : '...'; - if (rxWindowActive) return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (manualCooldownActive) return showFullText ? 'Cooldown ${manualCooldownRemaining}s' : '${manualCooldownRemaining}s'; - if (discoveryWindowActive) return showFullText ? 'Cooldown ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (cooldownActive) return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + if (rxWindowActive) + return showFullText + ? 'Listening ${rxWindowRemaining}s' + : '${rxWindowRemaining}s'; + if (manualCooldownActive) + return showFullText + ? 'Cooldown ${manualCooldownRemaining}s' + : '${manualCooldownRemaining}s'; + if (discoveryWindowActive) + return showFullText + ? 'Cooldown ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + if (cooldownActive) + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; return null; } @@ -1019,19 +1201,39 @@ class _CompactPingControlsState extends State { int discoveryWindowRemaining = 0, }) { if (isPendingDisable) { - if (rxWindowActive) return showFullText ? 'Stopping ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (discoveryWindowActive) return showFullText ? 'Stopping ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; + if (rxWindowActive) + return showFullText + ? 'Stopping ${rxWindowRemaining}s' + : '${rxWindowRemaining}s'; + if (discoveryWindowActive) + return showFullText + ? 'Stopping ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; return showFullText ? 'Stopping...' : '...'; } if (isActiveModeRunning) { - if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (isPingInProgress && !rxWindowActive) return showFullText ? 'Sending...' : '...'; - if (rxWindowActive) return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; + if (discoveryWindowActive) + return showFullText + ? 'Listening ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + if (isPingInProgress && !rxWindowActive) + return showFullText ? 'Sending...' : '...'; + if (rxWindowActive) + return showFullText + ? 'Listening ${rxWindowRemaining}s' + : '${rxWindowRemaining}s'; + if (autoPingWaiting) + return showFullText + ? (isSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Waiting ${autoPingRemaining}s') + : '${autoPingRemaining}s'; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { - return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; } return null; } @@ -1051,12 +1253,22 @@ class _CompactPingControlsState extends State { required bool isSkipped, }) { if (isPassiveModeRunning) { - if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; + if (discoveryWindowActive) + return showFullText + ? 'Listening ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + if (autoPingWaiting) + return showFullText + ? (isSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Waiting ${autoPingRemaining}s') + : '${autoPingRemaining}s'; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { - return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; } return null; } @@ -1076,18 +1288,29 @@ class _CompactPingControlsState extends State { required bool isSkipped, }) { if (isTargetedRunning) { - if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next in ${autoPingRemaining}s') : '${autoPingRemaining}s'; + if (discoveryWindowActive) + return showFullText + ? 'Listening ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + if (autoPingWaiting) + return showFullText + ? (isSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Next in ${autoPingRemaining}s') + : '${autoPingRemaining}s'; return showFullText ? 'Stop' : null; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { - return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; } return null; } - Future _sendPing(BuildContext context, AppStateProvider appState) async { + Future _sendPing( + BuildContext context, AppStateProvider appState) async { HapticFeedback.mediumImpact(); final success = await appState.sendPing(); @@ -1097,17 +1320,20 @@ class _CompactPingControlsState extends State { } } - Future _toggleTxRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleTxRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.active); } - Future _toggleHybridAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleHybridAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.hybrid); } - Future _toggleRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.passive); } @@ -1123,21 +1349,28 @@ class LandscapePingControls extends StatelessWidget { @override Widget build(BuildContext context) { final appState = context.watch(); - final manualValidation = appState.manualPingValidation; // Manual ping validation (no distance check) + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) final autoValidation = appState.autoModeValidation; - final canPingManual = manualValidation == PingValidation.valid; // For Send Ping button + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; final cooldownActive = appState.cooldownTimer.isRunning; final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState.manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = appState.manualPingCooldownTimer.remainingSec; + final manualCooldownActive = appState + .manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; final rxWindowActive = appState.rxWindowTimer.isRunning; final rxWindowRemaining = appState.rxWindowTimer.remainingSec; final isPingSending = appState.isPingSending; @@ -1153,7 +1386,9 @@ class LandscapePingControls extends StatelessWidget { final txNotAllowed = appState.isConnected && !appState.txAllowed; final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -1172,58 +1407,85 @@ class LandscapePingControls extends StatelessWidget { Row( children: [ if (!txNotAllowed) ...[ - // TX Ping button - Expanded( - child: _LandscapeIconButton( - icon: Icons.cell_tower, - tooltip: txNotAllowed ? 'Zone Full (Passive Only)' : 'Send Ping', - color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !isTargetedRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && - !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, - isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, - countdown: isPingSending - ? null - : rxWindowActive && !isTxModeRunning - ? rxWindowRemaining - : manualCooldownActive - ? manualCooldownRemaining - : discoveryWindowActive - ? discoveryWindowRemaining - : cooldownActive - ? cooldownRemaining - : null, - onPressed: () => _sendPing(context, appState), + // TX Ping button + Expanded( + child: _LandscapeIconButton( + icon: Icons.cell_tower, + tooltip: + txNotAllowed ? 'Zone Full (Passive Only)' : 'Send Ping', + color: const Color(0xFF0EA5E9), // sky-500 + enabled: canPingManual && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable, + isActive: + (isPingSending || rxWindowActive) && !isTxModeRunning, + countdown: isPingSending + ? null + : rxWindowActive && !isTxModeRunning + ? rxWindowRemaining + : manualCooldownActive + ? manualCooldownRemaining + : discoveryWindowActive + ? discoveryWindowRemaining + : cooldownActive + ? cooldownRemaining + : null, + onPressed: () => _sendPing(context, appState), + ), ), - ), - const SizedBox(width: 8), + const SizedBox(width: 8), - // Active/Hybrid Mode button - Expanded( - child: _LandscapeIconButton( - icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, - tooltip: txNotAllowed ? 'Zone Full (Passive Only)' : (hybridEnabled ? 'Hybrid Mode' : 'Active Mode'), - color: isPendingDisable - ? Colors.orange - : isTxModeRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && !isTargetedRunning && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), - isActive: isPendingDisable || isTxModeRunning, - countdown: isTxModeRunning - ? (discoveryWindowActive - ? discoveryWindowRemaining - : rxWindowActive - ? rxWindowRemaining - : autoPingWaiting - ? autoPingRemaining - : null) - : isPendingDisable && (rxWindowActive || discoveryWindowActive) - ? (rxWindowActive ? rxWindowRemaining : discoveryWindowRemaining) - : null, - onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), + // Active/Hybrid Mode button + Expanded( + child: _LandscapeIconButton( + icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, + tooltip: txNotAllowed + ? 'Zone Full (Passive Only)' + : (hybridEnabled ? 'Hybrid Mode' : 'Active Mode'), + color: isPendingDisable + ? Colors.orange + : isTxModeRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF6366F1), // indigo-500 + enabled: !isPendingDisable && + !isTargetedRunning && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed), + isActive: isPendingDisable || isTxModeRunning, + countdown: isTxModeRunning + ? (discoveryWindowActive + ? discoveryWindowRemaining + : rxWindowActive + ? rxWindowRemaining + : autoPingWaiting + ? autoPingRemaining + : null) + : isPendingDisable && + (rxWindowActive || discoveryWindowActive) + ? (rxWindowActive + ? rxWindowRemaining + : discoveryWindowRemaining) + : null, + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), + ), ), - ), - const SizedBox(width: 8), + const SizedBox(width: 8), ], // Passive Mode button @@ -1234,10 +1496,18 @@ class LandscapePingControls extends StatelessWidget { color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isTargetedRunning && !isPendingDisable && - !isPingSending && !rxWindowActive && !cooldownActive && - prefs.externalAntennaSet && isPowerSet), - isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), + enabled: isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet), + isActive: isPassiveModeRunning && + (discoveryWindowActive || autoPingWaiting), countdown: isPassiveModeRunning ? (discoveryWindowActive ? discoveryWindowRemaining @@ -1254,7 +1524,9 @@ class LandscapePingControls extends StatelessWidget { // Targeted Ping controls (Trace Mode) _TargetedPingSection( - isAnyModeRunning: isActiveModeRunning || isPassiveModeRunning || isHybridModeRunning, + isAnyModeRunning: isActiveModeRunning || + isPassiveModeRunning || + isHybridModeRunning, cooldownActive: cooldownActive, cooldownRemaining: cooldownRemaining, compact: true, @@ -1263,22 +1535,26 @@ class LandscapePingControls extends StatelessWidget { ); } - Future _sendPing(BuildContext context, AppStateProvider appState) async { + Future _sendPing( + BuildContext context, AppStateProvider appState) async { HapticFeedback.mediumImpact(); await appState.sendPing(); } - Future _toggleTxRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleTxRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.active); } - Future _toggleHybridAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleHybridAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.hybrid); } - Future _toggleRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.passive); } @@ -1321,20 +1597,26 @@ class _LandscapeAntennaSelector extends StatelessWidget { style: TextStyle( fontSize: 10, fontWeight: FontWeight.w500, - color: externalAntennaSet ? colorScheme.onSurfaceVariant : notSetColor, + color: externalAntennaSet + ? colorScheme.onSurfaceVariant + : notSetColor, ), ), if (!externalAntennaSet) ...[ const SizedBox(width: 4), Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + padding: + const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( color: notSetColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), ), child: const Text( 'Required', - style: TextStyle(fontSize: 8, fontWeight: FontWeight.w600, color: notSetColor), + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.w600, + color: notSetColor), ), ), ], @@ -1347,7 +1629,8 @@ class _LandscapeAntennaSelector extends StatelessWidget { decoration: BoxDecoration( color: colorScheme.onSurface.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.outline.withValues(alpha: 0.2)), + border: + Border.all(color: colorScheme.outline.withValues(alpha: 0.2)), ), child: Row( children: [ @@ -1361,22 +1644,30 @@ class _LandscapeAntennaSelector extends StatelessWidget { color: (!externalAntenna && externalAntennaSet) ? Colors.orange.withValues(alpha: 0.25) : Colors.transparent, - borderRadius: const BorderRadius.horizontal(left: Radius.circular(7)), + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(7)), ), alignment: Alignment.center, child: Text( 'Internal', style: TextStyle( fontSize: 11, - fontWeight: (!externalAntenna && externalAntennaSet) ? FontWeight.w600 : FontWeight.w500, - color: (!externalAntenna && externalAntennaSet) ? Colors.orange : colorScheme.onSurfaceVariant, + fontWeight: (!externalAntenna && externalAntennaSet) + ? FontWeight.w600 + : FontWeight.w500, + color: (!externalAntenna && externalAntennaSet) + ? Colors.orange + : colorScheme.onSurfaceVariant, ), ), ), ), ), // Divider - Container(width: 1, height: 18, color: colorScheme.outline.withValues(alpha: 0.3)), + Container( + width: 1, + height: 18, + color: colorScheme.outline.withValues(alpha: 0.3)), // External option Expanded( child: GestureDetector( @@ -1387,15 +1678,20 @@ class _LandscapeAntennaSelector extends StatelessWidget { color: (externalAntenna && externalAntennaSet) ? Colors.orange.withValues(alpha: 0.25) : Colors.transparent, - borderRadius: const BorderRadius.horizontal(right: Radius.circular(7)), + borderRadius: const BorderRadius.horizontal( + right: Radius.circular(7)), ), alignment: Alignment.center, child: Text( 'External', style: TextStyle( fontSize: 11, - fontWeight: (externalAntenna && externalAntennaSet) ? FontWeight.w600 : FontWeight.w500, - color: (externalAntenna && externalAntennaSet) ? Colors.orange : colorScheme.onSurfaceVariant, + fontWeight: (externalAntenna && externalAntennaSet) + ? FontWeight.w600 + : FontWeight.w500, + color: (externalAntenna && externalAntennaSet) + ? Colors.orange + : colorScheme.onSurfaceVariant, ), ), ), @@ -1475,7 +1771,8 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final showColor = widget.enabled || widget.isActive; - final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; + final effectiveColor = + showColor ? widget.color : colorScheme.onSurfaceVariant; return AnimatedBuilder( animation: _pulseAnimation, @@ -1495,7 +1792,8 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> color: effectiveColor.withValues(alpha: bgOpacity), borderRadius: BorderRadius.circular(12), border: Border.all( - color: effectiveColor.withValues(alpha: widget.isActive ? 0.5 : 0.25), + color: effectiveColor.withValues( + alpha: widget.isActive ? 0.5 : 0.25), width: widget.isActive ? 1.5 : 1, ), ), @@ -1506,7 +1804,9 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> Icon( widget.icon, size: 24, - color: showColor ? effectiveColor : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), // Countdown badge (bottom right) if (widget.countdown != null) @@ -1514,7 +1814,8 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> bottom: 4, right: 4, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + padding: const EdgeInsets.symmetric( + horizontal: 4, vertical: 1), decoration: BoxDecoration( color: effectiveColor, borderRadius: BorderRadius.circular(6), @@ -1540,7 +1841,8 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> decoration: BoxDecoration( color: const Color(0xFF22C55E), shape: BoxShape.circle, - border: Border.all(color: colorScheme.surface, width: 1.5), + border: Border.all( + color: colorScheme.surface, width: 1.5), ), ), ), @@ -1566,7 +1868,8 @@ class _CompactActionButton extends StatefulWidget { final bool isActive; final bool isExpanded; // When true, show icon + label with wider width final VoidCallback onPressed; - final double? progress; // 0.0 to 1.0 for progress bar fill, null = no progress bar + final double? + progress; // 0.0 to 1.0 for progress bar fill, null = no progress bar const _CompactActionButton({ required this.icon, @@ -1625,7 +1928,8 @@ class _CompactActionButtonState extends State<_CompactActionButton> Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final showColor = widget.enabled || widget.isActive; - final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; + final effectiveColor = + showColor ? widget.color : colorScheme.onSurfaceVariant; // Show label if colored OR if expanded (shows countdown on grey button during cooldown) final hasLabel = widget.label != null && (showColor || widget.isExpanded); @@ -1647,7 +1951,8 @@ class _CompactActionButtonState extends State<_CompactActionButton> color: effectiveColor.withValues(alpha: bgOpacity), borderRadius: BorderRadius.circular(16), border: Border.all( - color: effectiveColor.withValues(alpha: widget.isActive ? 0.5 : 0.3), + color: effectiveColor.withValues( + alpha: widget.isActive ? 0.5 : 0.3), width: widget.isActive ? 1.5 : 1, ), ), @@ -1683,7 +1988,10 @@ class _CompactActionButtonState extends State<_CompactActionButton> Icon( widget.icon, size: 18, - color: showColor ? effectiveColor : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), // Animated label - show when label is provided AnimatedSize( @@ -1698,8 +2006,13 @@ class _CompactActionButtonState extends State<_CompactActionButton> widget.label!, style: TextStyle( fontSize: 11, - fontWeight: widget.isActive ? FontWeight.w600 : FontWeight.w500, - color: showColor ? effectiveColor : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + fontWeight: widget.isActive + ? FontWeight.w600 + : FontWeight.w500, + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), ), ], diff --git a/lib/widgets/regional_config_card.dart b/lib/widgets/regional_config_card.dart index 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, ), From 41f0adb3ac250b6821755d2178ef0b97fb2b3862 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 15 Apr 2026 21:29:34 -0400 Subject: [PATCH 05/10] format with dart format using `dart format .` manual add missing curly braces in control flow statements with dartls adds check in ci to confirm formatting and remove tests and there are none # Conflicts: # lib/models/user_preferences.dart # lib/widgets/map_widget.dart --- .github/workflows/ci.yml | 4 +- bin/test_message.dart | 42 +- lib/main.dart | 49 +- lib/models/api_queue_item.dart | 21 +- lib/models/connection_state.dart | 32 +- lib/models/device_model.dart | 15 +- lib/models/log_entry.dart | 74 +- lib/models/noise_floor_session.dart | 10 +- lib/models/ping_data.dart | 15 +- lib/models/user_preferences.dart | 43 +- lib/providers/app_state_provider.dart | 1023 ++++++--- lib/screens/connection_screen.dart | 278 ++- lib/screens/graph_screen.dart | 18 +- lib/screens/home_screen.dart | 139 +- lib/screens/log_screen.dart | 378 +++- lib/screens/main_scaffold.dart | 16 +- lib/screens/settings_screen.dart | 515 +++-- lib/services/api_queue_service.dart | 99 +- lib/services/api_service.dart | 253 ++- lib/services/audio_service.dart | 18 +- lib/services/background_service.dart | 6 +- lib/services/bluetooth/mobile_bluetooth.dart | 90 +- lib/services/bluetooth/web_bluetooth.dart | 35 +- lib/services/countdown_timer_service.dart | 12 +- lib/services/custom_api_service.dart | 21 +- lib/services/debug_file_logger.dart | 4 +- lib/services/debug_submit_service.dart | 159 +- lib/services/device_model_service.dart | 13 +- lib/services/gps_service.dart | 70 +- lib/services/gps_simulator_service.dart | 85 +- lib/services/meshcore/buffer_utils.dart | 7 +- lib/services/meshcore/channel_service.dart | 45 +- lib/services/meshcore/connection.dart | 204 +- lib/services/meshcore/crypto_service.dart | 95 +- lib/services/meshcore/disc_tracker.dart | 58 +- lib/services/meshcore/packet_metadata.dart | 35 +- lib/services/meshcore/packet_parser.dart | 4 +- lib/services/meshcore/packet_validator.dart | 55 +- lib/services/meshcore/protocol_constants.dart | 22 +- lib/services/meshcore/rx_logger.dart | 113 +- lib/services/meshcore/trace_tracker.dart | 32 +- lib/services/meshcore/tx_tracker.dart | 144 +- lib/services/meshcore/unified_rx_handler.dart | 19 +- lib/services/offline_session_service.dart | 49 +- .../permission_disclosure_service.dart | 14 +- lib/services/ping_service.dart | 178 +- lib/utils/debug_logger.dart | 44 +- lib/utils/debug_logger_io.dart | 4 +- lib/utils/debug_logger_stub.dart | 28 +- lib/utils/ping_colors.dart | 88 +- lib/widgets/bug_report_dialog.dart | 533 ++--- lib/widgets/connection_panel.dart | 30 +- lib/widgets/map_widget.dart | 1921 +++++++++++++---- lib/widgets/noise_floor_chart.dart | 176 +- lib/widgets/offline_mode_toggle.dart | 15 +- lib/widgets/ping_controls.dart | 899 +++++--- lib/widgets/regional_config_card.dart | 52 +- lib/widgets/repeater_id_chip.dart | 54 +- lib/widgets/repeater_picker_sheet.dart | 24 +- lib/widgets/status_bar.dart | 104 +- lib/widgets/upload_logs_dialog.dart | 109 +- 61 files changed, 5978 insertions(+), 2714 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 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 dc16045..5849d7a 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -178,8 +178,14 @@ class UserPreferences { iataCode: json['iataCode'] as String?, backgroundModeEnabled: (json['backgroundModeEnabled'] as bool?) ?? false, developerModeEnabled: (json['developerModeEnabled'] as bool?) ?? false, +<<<<<<< HEAD mapStyle: (json['mapStyle'] as String?) ?? 'liberty', closeAppAfterDisconnect: (json['closeAppAfterDisconnect'] as bool?) ?? false, +======= + mapStyle: (json['mapStyle'] as String?) ?? 'dark', + closeAppAfterDisconnect: + (json['closeAppAfterDisconnect'] as bool?) ?? false, +>>>>>>> a431a6a (format with dart) themeMode: (json['themeMode'] as String?) ?? 'dark', unitSystem: (json['unitSystem'] as String?) ?? 'metric', hybridModeEnabled: (json['hybridModeEnabled'] as bool?) ?? true, @@ -189,7 +195,8 @@ class UserPreferences { disableRssiFilter: (json['disableRssiFilter'] as bool?) ?? false, anonymousMode: (json['anonymousMode'] as bool?) ?? false, discDropEnabled: (json['discDropEnabled'] as bool?) ?? false, - deleteChannelOnDisconnect: (json['deleteChannelOnDisconnect'] as bool?) ?? true, + deleteChannelOnDisconnect: + (json['deleteChannelOnDisconnect'] as bool?) ?? true, minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, @@ -197,13 +204,20 @@ class UserPreferences { gpsMarkerStyle: _migrateGpsMarkerStyle(json['gpsMarkerStyle'] as String?), colorVisionType: (json['colorVisionType'] as String?) ?? 'none', mapTilesEnabled: (json['mapTilesEnabled'] as bool?) ?? true, +<<<<<<< HEAD coverageOverlayOpacity: (json['coverageOverlayOpacity'] as num?)?.toDouble() ?? 0.7, disconnectAlertEnabled: (json['disconnectAlertEnabled'] as bool?) ?? false, +======= + disconnectAlertEnabled: + (json['disconnectAlertEnabled'] as bool?) ?? false, +>>>>>>> a431a6a (format with dart) customApiEnabled: (json['customApiEnabled'] as bool?) ?? false, customApiUrl: json['customApiUrl'] as String?, customApiKey: json['customApiKey'] as String?, - customApiDisclaimerAccepted: (json['customApiDisclaimerAccepted'] as bool?) ?? false, - customApiIncludeContact: (json['customApiIncludeContact'] as bool?) ?? true, + customApiDisclaimerAccepted: + (json['customApiDisclaimerAccepted'] as bool?) ?? false, + customApiIncludeContact: + (json['customApiIncludeContact'] as bool?) ?? true, ); } @@ -313,10 +327,12 @@ class UserPreferences { powerLevelSet: powerLevelSet ?? this.powerLevelSet, offlineMode: offlineMode ?? this.offlineMode, iataCode: iataCode ?? this.iataCode, - backgroundModeEnabled: backgroundModeEnabled ?? this.backgroundModeEnabled, + backgroundModeEnabled: + backgroundModeEnabled ?? this.backgroundModeEnabled, developerModeEnabled: developerModeEnabled ?? this.developerModeEnabled, mapStyle: mapStyle ?? this.mapStyle, - closeAppAfterDisconnect: closeAppAfterDisconnect ?? this.closeAppAfterDisconnect, + closeAppAfterDisconnect: + closeAppAfterDisconnect ?? this.closeAppAfterDisconnect, themeMode: themeMode ?? this.themeMode, unitSystem: unitSystem ?? this.unitSystem, hybridModeEnabled: hybridModeEnabled ?? this.hybridModeEnabled, @@ -326,21 +342,30 @@ class UserPreferences { disableRssiFilter: disableRssiFilter ?? this.disableRssiFilter, anonymousMode: anonymousMode ?? this.anonymousMode, discDropEnabled: discDropEnabled ?? this.discDropEnabled, - deleteChannelOnDisconnect: deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, - minPingDistanceMeters: minPingDistanceMeters ?? this.minPingDistanceMeters, + deleteChannelOnDisconnect: + deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, + minPingDistanceMeters: + minPingDistanceMeters ?? this.minPingDistanceMeters, autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, markerStyle: markerStyle ?? this.markerStyle, gpsMarkerStyle: gpsMarkerStyle ?? this.gpsMarkerStyle, colorVisionType: colorVisionType ?? this.colorVisionType, mapTilesEnabled: mapTilesEnabled ?? this.mapTilesEnabled, +<<<<<<< HEAD coverageOverlayOpacity: coverageOverlayOpacity ?? this.coverageOverlayOpacity, disconnectAlertEnabled: disconnectAlertEnabled ?? this.disconnectAlertEnabled, +======= + disconnectAlertEnabled: + disconnectAlertEnabled ?? this.disconnectAlertEnabled, +>>>>>>> a431a6a (format with dart) customApiEnabled: customApiEnabled ?? this.customApiEnabled, customApiUrl: customApiUrl ?? this.customApiUrl, customApiKey: customApiKey ?? this.customApiKey, - customApiDisclaimerAccepted: customApiDisclaimerAccepted ?? this.customApiDisclaimerAccepted, - customApiIncludeContact: customApiIncludeContact ?? this.customApiIncludeContact, + customApiDisclaimerAccepted: + customApiDisclaimerAccepted ?? this.customApiDisclaimerAccepted, + customApiIncludeContact: + customApiIncludeContact ?? this.customApiIncludeContact, ); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7cb837c..efcb20f 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3,7 +3,8 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart' show SystemNavigator; -import 'package:flutter/widgets.dart' show WidgetsBinding, WidgetsBindingObserver, AppLifecycleState; +import 'package:flutter/widgets.dart' + show WidgetsBinding, WidgetsBindingObserver, AppLifecycleState; import 'package:geolocator/geolocator.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:uuid/uuid.dart'; @@ -30,7 +31,8 @@ import '../services/gps_simulator_service.dart'; import '../services/meshcore/channel_service.dart'; import '../services/meshcore/connection.dart'; import '../services/meshcore/crypto_service.dart'; -import '../services/meshcore/packet_validator.dart' show PacketValidator, ChannelInfo; +import '../services/meshcore/packet_validator.dart' + show PacketValidator, ChannelInfo; import '../services/meshcore/rx_logger.dart'; import '../services/meshcore/tx_tracker.dart'; import '../services/meshcore/unified_rx_handler.dart'; @@ -46,10 +48,13 @@ import '../utils/debug_logger_io.dart'; enum AutoMode { /// Active Mode: Sends pings on movement, listens for RX responses active, + /// Passive Mode: Listening only (no transmit) passive, + /// Hybrid Mode: Alternates Discovery + Active pings each interval hybrid, + /// Trace Mode: Zero-hop trace to specific repeater targeted, } @@ -61,16 +66,22 @@ enum OverlayPingType { tx, disc, trace, rx } enum OfflineUploadResult { /// Upload completed successfully success, + /// Session file not found notFound, + /// Session data is invalid or empty invalidSession, + /// API authentication failed authFailed, + /// Some pings failed to upload partialFailure, + /// Another upload is already in progress uploadInProgress, + /// GPS position required but not available gpsRequired, } @@ -90,11 +101,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { late final DeviceModelService _deviceModelService; late final CustomApiService _customApiService; final AudioService _audioService = AudioService(); - late final CooldownTimer _cooldownTimer; // Shared cooldown for TX Ping and Active Mode - late final ManualPingCooldownTimer _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) + late final CooldownTimer + _cooldownTimer; // Shared cooldown for TX Ping and Active Mode + late final ManualPingCooldownTimer + _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) late final AutoPingTimer _autoPingTimer; late final RxWindowTimer _rxWindowTimer; - late final DiscoveryWindowTimer _discoveryWindowTimer; // Discovery listening window (Passive Mode) + late final DiscoveryWindowTimer + _discoveryWindowTimer; // Discovery listening window (Passive Mode) MeshCoreConnection? _meshCoreConnection; PingService? _pingService; UnifiedRxHandler? _unifiedRxHandler; @@ -111,8 +125,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ConnectionStatus _connectionStatus = ConnectionStatus.disconnected; ConnectionStep _connectionStep = ConnectionStep.disconnected; String? _connectionError; - bool _isAuthError = false; // Track if connection failed due to auth - bool _isNetworkError = false; // Track if connection failed due to network + bool _isAuthError = false; // Track if connection failed due to auth + bool _isNetworkError = false; // Track if connection failed due to network // Bluetooth adapter state (on/off) BluetoothAdapterState _bluetoothAdapterState = BluetoothAdapterState.unknown; @@ -125,8 +139,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { GpsStatus _gpsStatus = GpsStatus.permissionDenied; Position? _currentPosition; ({double lat, double lon})? _lastKnownPosition; - DateTime? _lastPositionSaveTime; // Throttle position saves to every 30 seconds - bool _firstGpsLockLogged = false; // Track if we've logged first GPS lock message + DateTime? + _lastPositionSaveTime; // Throttle position saves to every 30 seconds + bool _firstGpsLockLogged = + false; // Track if we've logged first GPS lock message // Device info DeviceModel? _deviceModel; @@ -144,7 +160,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// The device name to display (prefers SelfInfo name over BLE advertisement name) /// SelfInfo name reflects user's chosen name in MeshCore; BLE name may be cached/stale - String? get displayDeviceName => _displayDeviceName ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); + String? get displayDeviceName => + _displayDeviceName ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); // Ping state PingStats _pingStats = const PingStats(); @@ -177,7 +194,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final List _traceLogEntries = []; // Top repeaters overlay — updated live on each ping event - List<({String repeaterId, double snr, OverlayPingType type})> _topRepeatersOverlay = []; + List<({String repeaterId, double snr, OverlayPingType type})> + _topRepeatersOverlay = []; ({String repeaterId, double snr})? _rxOverlaySlot; Timer? _rxOverlayWindowTimer; @@ -191,8 +209,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { UserPreferences _preferences = const UserPreferences(); // Anonymous mode state - String? _originalDeviceName; // Real name stored before rename - bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" + String? _originalDeviceName; // Real name stored before rename + bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" /// Per-device antenna preferences: maps companion name → external antenna bool Map _deviceAntennaPreferences = {}; @@ -228,11 +246,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _isCheckingZone = false; // Zone check retry state - String? _zoneCheckError; // Error message from last failed check (null = no error) - String? _zoneCheckErrorReason; // 'network', 'gps_inaccurate', 'gps_stale', 'server_error' - int _zoneCheckRetryCountdown = 0; // Seconds until next retry (0 = not counting) - Timer? _zoneCheckRetryTimer; // Fires to trigger the retry - Timer? _zoneCheckCountdownTimer; // Ticks every 1s for UI countdown + String? + _zoneCheckError; // Error message from last failed check (null = no error) + String? + _zoneCheckErrorReason; // 'network', 'gps_inaccurate', 'gps_stale', 'server_error' + int _zoneCheckRetryCountdown = + 0; // Seconds until next retry (0 = not counting) + Timer? _zoneCheckRetryTimer; // Fires to trigger the retry + Timer? _zoneCheckCountdownTimer; // Ticks every 1s for UI countdown // Maintenance mode state bool _maintenanceMode = false; @@ -274,9 +295,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Zone grace period — pauses wardriving when outside_zone, resumes on zone re-entry bool _isInZoneGracePeriod = false; - Timer? _zoneGraceTimer; // 5-minute overall timeout - Timer? _zoneGracePollingTimer; // 5-second zone polling - Timer? _zoneGraceCountdownTimer; // 1-second UI countdown tick + Timer? _zoneGraceTimer; // 5-minute overall timeout + Timer? _zoneGracePollingTimer; // 5-second zone polling + Timer? _zoneGraceCountdownTimer; // 1-second UI countdown tick int _zoneGraceSecondsRemaining = 0; bool _autoPingWasEnabledBeforeGrace = false; AutoMode _autoModeBeforeGrace = AutoMode.active; @@ -311,10 +332,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _scope; // Path hash mode tracking (for multi-byte path support) - int? _originalPathHashMode; // Device's mode BEFORE we changed it (from DeviceInfo) - bool _userChangedPathMode = false; // True if user manually changed hopBytes while connected - int _hopBytes = 1; // Runtime-only: current hop byte size (read from device, not persisted) - int _traceHopBytes = 1; // Runtime-only: trace byte size (1, 2, or 4 — bitshift encoding) + int? + _originalPathHashMode; // Device's mode BEFORE we changed it (from DeviceInfo) + bool _userChangedPathMode = + false; // True if user manually changed hopBytes while connected + int _hopBytes = + 1; // Runtime-only: current hop byte size (read from device, not persisted) + int _traceHopBytes = + 1; // Runtime-only: trace byte size (1, 2, or 4 — bitshift encoding) // Noise floor session tracking (for graph feature) NoiseFloorSession? _currentNoiseFloorSession; @@ -359,7 +384,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isNetworkError => _isNetworkError; BluetoothAdapterState get bluetoothAdapterState => _bluetoothAdapterState; bool get isBluetoothOn => _bluetoothAdapterState == BluetoothAdapterState.on; - bool get isBluetoothOff => _bluetoothAdapterState == BluetoothAdapterState.off; + bool get isBluetoothOff => + _bluetoothAdapterState == BluetoothAdapterState.off; GpsStatus get gpsStatus => _gpsStatus; Position? get currentPosition => _currentPosition; ({double lat, double lon})? get lastKnownPosition => _lastKnownPosition; @@ -371,14 +397,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get autoPingEnabled => _autoPingEnabled; AutoMode get autoMode => _autoMode; bool get isPingSending => _isPingSending; - bool get isPingInProgress => _pingService?.pingInProgress ?? false; // True during entire ping + RX window (for auto pings) - bool get isDiscoveryListening => _pingService?.isDiscoveryListening ?? false; // True during discovery listening window (for Passive Mode) + bool get isPingInProgress => + _pingService?.pingInProgress ?? + false; // True during entire ping + RX window (for auto pings) + bool get isDiscoveryListening => + _pingService?.isDiscoveryListening ?? + false; // True during discovery listening window (for Passive Mode) /// Check if auto-ping disable is pending (waiting for RX window) bool get isPendingDisable => _pingService?.pendingDisable ?? false; + /// True when running any mode that does TX (Active or Hybrid) - bool get isTxModeRunning => _autoPingEnabled && (_autoMode == AutoMode.active || _autoMode == AutoMode.hybrid); + bool get isTxModeRunning => + _autoPingEnabled && + (_autoMode == AutoMode.active || _autoMode == AutoMode.hybrid); + /// True when running Trace Mode (zero-hop trace) - bool get isTargetedModeRunning => _autoPingEnabled && _autoMode == AutoMode.targeted; + bool get isTargetedModeRunning => + _autoPingEnabled && _autoMode == AutoMode.targeted; String? get targetRepeaterId => _targetRepeaterId; int get queueSize => _queueSize; int? get currentNoiseFloor => _currentNoiseFloor; @@ -389,13 +424,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { List get rxPings => List.unmodifiable(_rxPings); /// Top 3 repeaters by best SNR from TX/DISC/Trace pings - List<({String repeaterId, double snr, OverlayPingType type})> get topRepeatersBySnr => _topRepeatersOverlay; + List<({String repeaterId, double snr, OverlayPingType type})> + get topRepeatersBySnr => _topRepeatersOverlay; + /// Best RX observation in the current 5-second window ({String repeaterId, double snr})? get rxOverlaySlot => _rxOverlaySlot; /// Update the top repeaters overlay with results from the latest TX/DISC/Trace ping. /// Replaces all 3 slots entirely (no carryover from previous pings). - void _updateTopRepeaters(List<({String repeaterId, double snr})> current, OverlayPingType type) { + void _updateTopRepeaters( + List<({String repeaterId, double snr})> current, OverlayPingType type) { final bestSnr = {}; for (final r in current) { final key = r.repeaterId.toUpperCase(); @@ -419,7 +457,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } else { _rxOverlaySlot = entry; - _rxOverlayWindowTimer = Timer(Duration(seconds: _preferences.autoPingInterval), () { + _rxOverlayWindowTimer = + Timer(Duration(seconds: _preferences.autoPingInterval), () { // Window closed — slot stays until next RX or cleared }); } @@ -432,21 +471,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxOverlayWindowTimer?.cancel(); _rxOverlayWindowTimer = null; } + List get txLogEntries => List.unmodifiable(_txLogEntries); List get rxLogEntries => List.unmodifiable(_rxLogEntries); List get discLogEntries => List.unmodifiable(_discLogEntries); - List get traceLogEntries => List.unmodifiable(_traceLogEntries); - List get errorLogEntries => List.unmodifiable(_errorLogEntries); + List get traceLogEntries => + List.unmodifiable(_traceLogEntries); + List get errorLogEntries => + List.unmodifiable(_errorLogEntries); List get unifiedPingLogEntries { final merged = [ - ..._txLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.tx, timestamp: e.timestamp, entry: e)), - ..._rxLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.rx, timestamp: e.timestamp, entry: e)), - ..._discLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.disc, timestamp: e.timestamp, entry: e)), - ..._traceLogEntries.map((e) => UnifiedPingLogEntry(type: PingLogType.trace, timestamp: e.timestamp, entry: e)), + ..._txLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.tx, timestamp: e.timestamp, entry: e)), + ..._rxLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.rx, timestamp: e.timestamp, entry: e)), + ..._discLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.disc, timestamp: e.timestamp, entry: e)), + ..._traceLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.trace, timestamp: e.timestamp, entry: e)), ]; merged.sort(); return merged; } + ({double lat, double lon})? get mapNavigationTarget => _mapNavigationTarget; int get mapNavigationTrigger => _mapNavigationTrigger; bool get requestMapTabSwitch => _requestMapTabSwitch; @@ -476,7 +523,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int? get zoneSlotsMax => _currentZone?['slots_max'] as int?; String? get nearestZoneName => _nearestZone?['name'] as String?; String? get nearestZoneCode => _nearestZone?['code'] as String?; - double? get nearestZoneDistanceKm => (_nearestZone?['distance_km'] as num?)?.toDouble(); + double? get nearestZoneDistanceKm => + (_nearestZone?['distance_km'] as num?)?.toDouble(); // Zone check retry getters String? get zoneCheckError => _zoneCheckError; @@ -546,11 +594,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isApiRxOnlyMode => hasApiSession && !txAllowed && rxAllowed; bool get enforceHybrid => _apiService.enforceHybrid; bool get enforceDiscDrop => _apiService.enforceDiscDrop; - bool get discDropEnabled => _preferences.discDropEnabled || _apiService.enforceDiscDrop; + bool get discDropEnabled => + _preferences.discDropEnabled || _apiService.enforceDiscDrop; int get minModeInterval => _apiService.minModeInterval; bool get enforceHopBytes => _apiService.enforceHopBytes; int get hopBytes => _hopBytes; - int get effectiveHopBytes => enforceHopBytes ? _apiService.apiHopBytes : _hopBytes; + int get effectiveHopBytes => + enforceHopBytes ? _apiService.apiHopBytes : _hopBytes; int get traceHopBytes => _traceHopBytes; bool get supportsMultiBytePaths => _originalPathHashMode != null; @@ -573,11 +623,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Countdown timers - CooldownTimer get cooldownTimer => _cooldownTimer; // Shared cooldown for TX Ping and Active Mode - ManualPingCooldownTimer get manualPingCooldownTimer => _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) + CooldownTimer get cooldownTimer => + _cooldownTimer; // Shared cooldown for TX Ping and Active Mode + ManualPingCooldownTimer get manualPingCooldownTimer => + _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) AutoPingTimer get autoPingTimer => _autoPingTimer; RxWindowTimer get rxWindowTimer => _rxWindowTimer; - DiscoveryWindowTimer get discoveryWindowTimer => _discoveryWindowTimer; // Discovery listening window (Passive Mode) + DiscoveryWindowTimer get discoveryWindowTimer => + _discoveryWindowTimer; // Discovery listening window (Passive Mode) // ============================================ // Initialization @@ -596,11 +649,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Initialize custom API forwarding service _customApiService = CustomApiService(prefsGetter: () => _preferences); _customApiService.onError = (message) { - logError('Custom API: $message', severity: ErrorSeverity.warning, autoSwitch: false); + logError('Custom API: $message', + severity: ErrorSeverity.warning, autoSwitch: false); }; _customApiService.contactGetter = () { final pk = _devicePublicKey; - return (pk != null && pk.length >= 8) ? pk.substring(0, 8).toUpperCase() : null; + return (pk != null && pk.length >= 8) + ? pk.substring(0, 8).toUpperCase() + : null; }; _customApiService.iataGetter = () => zoneCode ?? _preferences.iataCode; _apiQueueService.customApiService = _customApiService; @@ -622,7 +678,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Initialize countdown timers with notifyListeners callback for smooth UI updates _cooldownTimer = CooldownTimer(onUpdate: notifyListeners); - _manualPingCooldownTimer = ManualPingCooldownTimer(onUpdate: notifyListeners); + _manualPingCooldownTimer = + ManualPingCooldownTimer(onUpdate: notifyListeners); _autoPingTimer = AutoPingTimer(onUpdate: notifyListeners); _rxWindowTimer = RxWindowTimer(onUpdate: notifyListeners); _discoveryWindowTimer = DiscoveryWindowTimer(onUpdate: notifyListeners); @@ -650,9 +707,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with queue size if (_autoPingEnabled) { - final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' - : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; + final modeName = _autoMode == AutoMode.passive + ? 'Passive Mode' + : _autoMode == AutoMode.hybrid + ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted + ? 'Trace Mode' + : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -666,7 +727,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingStats = _pingStats.copyWith( successfulUploads: _pingStats.successfulUploads + uploadedCount, ); - debugLog('[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); + debugLog( + '[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); notifyListeners(); // Schedule overlay tile refresh after server has time to regenerate tiles. @@ -709,7 +771,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to Bluetooth adapter state changes (on/off) debugLog('[INIT] Setting up Bluetooth adapter state listener...'); - _adapterStateSubscription = _bluetoothService.adapterStateStream.listen((state) { + _adapterStateSubscription = + _bluetoothService.adapterStateStream.listen((state) { final previousState = _bluetoothAdapterState; _bluetoothAdapterState = state; @@ -725,7 +788,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to Bluetooth connection changes debugLog('[INIT] Setting up BLE connection listener...'); await _connectionSubscription?.cancel(); - _connectionSubscription = _bluetoothService.connectionStream.listen((status) async { + _connectionSubscription = + _bluetoothService.connectionStream.listen((status) async { _connectionStatus = status; if (status == ConnectionStatus.disconnected) { // Check if this is an unexpected disconnect during active wardriving @@ -735,7 +799,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_isInZoneGracePeriod) { // BLE disconnected during zone grace period — abandon grace, full cleanup - debugLog('[CONN] BLE disconnect during zone grace period — full cleanup'); + debugLog( + '[CONN] BLE disconnect during zone grace period — full cleanup'); _cancelZoneGraceTimers(); _isInZoneGracePeriod = false; _zoneGraceSecondsRemaining = 0; @@ -743,14 +808,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _autoPingWasEnabledBeforeGrace = false; await _fullDisconnectCleanup(); } else if (wasConnected && hasRemembered && isUnexpected && !kIsWeb) { - debugLog('[CONN] Unexpected BLE disconnect detected - starting auto-reconnect'); + debugLog( + '[CONN] Unexpected BLE disconnect detected - starting auto-reconnect'); await _startAutoReconnect(); } else if (!_isAutoReconnecting) { // Normal disconnect (user-requested or no remembered device) await _fullDisconnectCleanup(); } else { // Disconnected during a reconnect attempt - _attemptReconnect handles retry - debugLog('[CONN] BLE disconnect during reconnect attempt - will retry'); + debugLog( + '[CONN] BLE disconnect during reconnect attempt - will retry'); } } notifyListeners(); @@ -769,23 +836,27 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Log when we transition to locked state (permission granted + GPS available) if (status == GpsStatus.locked) { - debugLog('[GPS] GPS lock acquired - zone check should trigger on first position'); + debugLog( + '[GPS] GPS lock acquired - zone check should trigger on first position'); } // Log when permission is denied or GPS disabled if (status == GpsStatus.permissionDenied) { - debugLog('[GPS] Location permission denied - zone checks will be blocked'); + debugLog( + '[GPS] Location permission denied - zone checks will be blocked'); } else if (status == GpsStatus.disabled) { - debugLog('[GPS] Location services disabled - zone checks will be blocked'); + debugLog( + '[GPS] Location services disabled - zone checks will be blocked'); } } notifyListeners(); }); - _gpsStatus = _gpsService.status; // Sync initial status + _gpsStatus = _gpsService.status; // Sync initial status debugLog('[INIT] Initial GPS status: $_gpsStatus'); debugLog('[INIT] Setting up GPS position listener...'); await _gpsPositionSubscription?.cancel(); - _gpsPositionSubscription = _gpsService.positionStream.listen((position) async { + _gpsPositionSubscription = + _gpsService.positionStream.listen((position) async { _currentPosition = position; notifyListeners(); @@ -798,7 +869,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] First GPS lock, triggering zone check'); await checkZoneStatus(); _firstGpsLockLogged = true; - } else if (_inZone == null && _preferences.offlineMode && !_firstGpsLockLogged) { + } else if (_inZone == null && + _preferences.offlineMode && + !_firstGpsLockLogged) { debugLog('[GEOFENCE] First GPS lock skipped: offline mode enabled'); _firstGpsLockLogged = true; } @@ -806,14 +879,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Check zone every 100m movement (while disconnected) // This allows users to know if they've entered/exited a zone while moving // Skip zone checks when offline mode is enabled - if (!isConnected && !_preferences.offlineMode && _shouldRecheckZone(position)) { + if (!isConnected && + !_preferences.offlineMode && + _shouldRecheckZone(position)) { // Throttle log to once per 30s to avoid spam while driving final now = DateTime.now(); - if (_lastZoneCheckLogTime == null || now.difference(_lastZoneCheckLogTime!) >= const Duration(seconds: 30)) { + if (_lastZoneCheckLogTime == null || + now.difference(_lastZoneCheckLogTime!) >= + const Duration(seconds: 30)) { if (_zoneCheckSuppressedCount > 0) { - debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone (suppressed $_zoneCheckSuppressedCount similar in last 30s)'); + debugLog( + '[GEOFENCE] Moved 100m+ while disconnected, rechecking zone (suppressed $_zoneCheckSuppressedCount similar in last 30s)'); } else { - debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); + debugLog( + '[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); } _lastZoneCheckLogTime = now; _zoneCheckSuppressedCount = 0; @@ -857,15 +936,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 'isCheckingZone=$_isCheckingZone, hasPosition=${_currentPosition != null}'); await _gpsService.startWatching(); - _gpsStatus = _gpsService.status; // Sync after restart + _gpsStatus = _gpsService.status; // Sync after restart debugLog('[GPS] GPS restarted, new status: $_gpsStatus'); - debugLog('[GPS] Post-restart state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + debugLog( + '[GPS] Post-restart state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' 'hasPosition=${_currentPosition != null}'); // If we now have a position and zone hasn't been checked, trigger check - if (_currentPosition != null && _inZone == null && !_preferences.offlineMode) { - debugLog('[GPS] Permission granted with existing position - triggering zone check'); + if (_currentPosition != null && + _inZone == null && + !_preferences.offlineMode) { + debugLog( + '[GPS] Permission granted with existing position - triggering zone check'); await checkZoneStatus(); } notifyListeners(); @@ -923,7 +1006,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (!isEnabled) { debugLog('[SCAN] Bluetooth still disabled after retries'); - _connectionError = 'Bluetooth is disabled. Please enable Bluetooth and try again.'; + _connectionError = + 'Bluetooth is disabled. Please enable Bluetooth and try again.'; notifyListeners(); return; } @@ -938,21 +1022,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen for discovered devices using subscription so stopScan() can cancel DiscoveredDevice? selectedDevice; final completer = Completer(); - _activeScanSubscription = _bluetoothService.scanForDevices( + _activeScanSubscription = _bluetoothService + .scanForDevices( timeout: const Duration(seconds: 15), - ).listen( + ) + .listen( (device) { if (!_discoveredDevices.any((d) => d.id == device.id)) { // Prefer remembered device name (from SelfInfo) over BLE cache var enrichedDevice = device; - if (_rememberedDevice != null && device.id == _rememberedDevice!.id && + if (_rememberedDevice != null && + device.id == _rememberedDevice!.id && device.name != _rememberedDevice!.name) { enrichedDevice = DiscoveredDevice( id: device.id, name: _rememberedDevice!.name, rssi: device.rssi, ); - debugLog('[SCAN] Using remembered name "${_rememberedDevice!.name}" instead of BLE name "${device.name}"'); + debugLog( + '[SCAN] Using remembered name "${_rememberedDevice!.name}" instead of BLE name "${device.name}"'); } _discoveredDevices.add(enrichedDevice); selectedDevice = enrichedDevice; @@ -1024,7 +1112,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final publicKey = _meshCoreConnection!.devicePublicKey; if (publicKey == null) { debugError('[APP] Cannot request auth: no public key'); - return {'success': false, 'reason': 'no_public_key', 'message': 'Device public key not available'}; + return { + 'success': false, + 'reason': 'no_public_key', + 'message': 'Device public key not available' + }; } // Anonymous mode: rename device before auth so mesh pings broadcast as "Anonymous" @@ -1036,7 +1128,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _meshCoreConnection!.setAdvertName('Anonymous'); _isAnonymousRenamed = true; _displayDeviceName = 'Anonymous'; - debugLog('[CONN] Anonymous mode: renamed from "$realName" to "Anonymous"'); + debugLog( + '[CONN] Anonymous mode: renamed from "$realName" to "Anonymous"'); // Short delay for firmware to process await Future.delayed(const Duration(milliseconds: 300)); } catch (e) { @@ -1049,16 +1142,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Resolve device name: use "Anonymous" if renamed, otherwise SelfInfo name final deviceName = _isAnonymousRenamed ? 'Anonymous' - : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + : (_meshCoreConnection!.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); if (deviceName == null || deviceName.isEmpty) { - debugError('[APP] Cannot request auth: could not retrieve device name'); - return {'success': false, 'reason': 'no_device_name', 'message': 'Could not retrieve device name'}; + debugError( + '[APP] Cannot request auth: could not retrieve device name'); + return { + 'success': false, + 'reason': 'no_device_name', + 'message': 'Could not retrieve device name' + }; } // ============================================================ // STAGE 1: Try existing public_key authentication // ============================================================ - debugLog('[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); + debugLog( + '[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); final result = await _apiService.requestAuth( reason: 'connect', @@ -1067,7 +1167,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { appVersion: _appVersion, power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection!.deviceModel?.manufacturer ?? _meshCoreConnection!.deviceInfo?.manufacturer ?? 'Unknown', + model: _meshCoreConnection!.deviceModel?.manufacturer ?? + _meshCoreConnection!.deviceInfo?.manufacturer ?? + 'Unknown', lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -1078,7 +1180,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _maintenanceMode = true; _maintenanceMessage = result['maintenance_message'] as String?; _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + debugLog( + '[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); _startMaintenancePolling(); notifyListeners(); return { @@ -1115,12 +1218,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }; } - debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + debugLog( + '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); // If Stage 1 failed due to GPS issues, Stage 2 will also fail with same bad data final stage1Reason = result['reason'] as String?; if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { - debugError('[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + debugError( + '[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); return { 'success': false, 'reason': stage1Reason, @@ -1137,13 +1242,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { debugLog('[APP] Requesting signed contact URI from device...'); contactUri = await _meshCoreConnection!.exportContact(); - debugLog('[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + debugLog( + '[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); } catch (e) { debugError('[APP] Failed to get contact URI from device: $e'); return { 'success': false, 'reason': 'registration_failed', - 'message': 'Companion not found in backend and failed to register via API' + 'message': + 'Companion not found in backend and failed to register via API' }; } @@ -1155,7 +1262,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { appVersion: _appVersion, power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection!.deviceModel?.manufacturer ?? _meshCoreConnection!.deviceInfo?.manufacturer ?? 'Unknown', + model: _meshCoreConnection!.deviceModel?.manufacturer ?? + _meshCoreConnection!.deviceInfo?.manufacturer ?? + 'Unknown', lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -1171,9 +1280,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (registerResult['success'] != true) { - final serverReason = registerResult['reason'] as String? ?? 'registration_failed'; + final serverReason = + registerResult['reason'] as String? ?? 'registration_failed'; final serverMessage = registerResult['message'] as String?; - debugError('[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + debugError( + '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); return { 'success': false, 'reason': serverReason, @@ -1208,10 +1319,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (step == ConnectionStep.connected) { // Update device info _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; - _firmwareVersionString = _meshCoreConnection!.deviceInfo?.firmwareVersionString; + _firmwareVersionString = + _meshCoreConnection!.deviceInfo?.firmwareVersionString; _deviceModel = _meshCoreConnection!.deviceModel; _devicePublicKey = _meshCoreConnection!.devicePublicKey; - debugLog('[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); + debugLog( + '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); // Persist device info for bug reports when disconnected // Use original name (not "Anonymous") for bug report identification @@ -1222,7 +1335,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Always strip MeshCore- prefix if present deviceName = deviceName.replaceFirst('MeshCore-', ''); } - if (deviceName != null && deviceName.isNotEmpty && _devicePublicKey != null) { + if (deviceName != null && + deviceName.isNotEmpty && + _devicePublicKey != null) { _saveLastConnectedDevice(deviceName, _devicePublicKey!); } @@ -1240,7 +1355,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }); // Listen for noise floor updates - _noiseFloorSubscription = _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { _currentNoiseFloor = noiseFloor; // Record sample to current noise floor session (if active) _recordNoiseFloorSample(noiseFloor); @@ -1248,7 +1364,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }); // Listen for battery updates - _batterySubscription = _meshCoreConnection!.batteryStream.listen((batteryPercent) { + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { _currentBatteryPercent = batteryPercent; notifyListeners(); }); @@ -1261,16 +1378,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update preferences if device model was recognized (for display/API reporting) // Note: This does NOT change the radio's TX power - it only sets what power level to REPORT - if (connectionResult.deviceModelMatched && connectionResult.deviceModel != null) { + if (connectionResult.deviceModelMatched && + connectionResult.deviceModel != null) { final device = connectionResult.deviceModel!; _preferences = _preferences.copyWith( powerLevel: device.power, txPower: device.txPower, - autoPowerSet: true, // Indicates power was auto-detected from device model + autoPowerSet: + true, // Indicates power was auto-detected from device model powerLevelSet: false, // Clear stale manual flag from previous session ); notifyListeners(); - debugLog('[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); + debugLog( + '[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); } // Note: API session acquisition is now handled by the auth callback @@ -1287,7 +1407,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update unified RX handler's validator with new channel configuration if (_unifiedRxHandler != null) { - final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); + final allowedChannelsData = + ChannelService.getAllowedChannelsForValidator(); final allowedChannels = {}; for (final entry in allowedChannelsData.entries) { allowedChannels[entry.key] = ChannelInfo( @@ -1301,7 +1422,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { disableRssiFilter: _preferences.disableRssiFilter, ); _unifiedRxHandler!.updateValidator(newValidator); - debugLog('[APP] PacketValidator updated with ${allowedChannels.length} channels: ' + debugLog( + '[APP] PacketValidator updated with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); } @@ -1310,7 +1432,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Any other value (e.g., "ottawa") → derive TransportKey and set scope final apiScopes = _apiService.scopes; final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; - final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; + final isWildcard = + firstScope == null || firstScope == '*' || firstScope == '#*'; if (!isWildcard) { final scopeName = firstScope; _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; @@ -1337,8 +1460,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Enforce minimum auto-ping interval if required by regional admin if (_preferences.autoPingInterval < _apiService.minModeInterval) { - _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); - debugLog('[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); + _preferences = _preferences.copyWith( + autoPingInterval: _apiService.minModeInterval); + debugLog( + '[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); } // Configure multi-byte path hash mode on radio @@ -1363,7 +1488,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { shouldIgnoreRepeater: (String repeaterId) { final prefs = _preferences; if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - return PacketValidator.isCarpeaterIdMatch(repeaterId, prefs.ignoreRepeaterId!); + return PacketValidator.isCarpeaterIdMatch( + repeaterId, prefs.ignoreRepeaterId!); } return false; }, @@ -1377,13 +1503,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // External antenna must be explicitly set (yes or no) before pinging return _preferences.externalAntennaSet; }; - + _pingService!.checkPowerLevelConfigured = () { // Power is configured if: // - Auto-detected from device model, OR // - Manually selected by user, OR // - Device model is known (has default power) - return _preferences.autoPowerSet || _preferences.powerLevelSet || _deviceModel != null; + return _preferences.autoPowerSet || + _preferences.powerLevelSet || + _deviceModel != null; }; // Get external antenna value for API payloads @@ -1450,9 +1578,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update background service notification with current stats if (_autoPingEnabled) { - final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' - : _autoMode == AutoMode.targeted ? 'Trace Mode' : 'Active Mode'; + final modeName = _autoMode == AutoMode.passive + ? 'Passive Mode' + : _autoMode == AutoMode.hybrid + ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted + ? 'Trace Mode' + : 'Active Mode'; BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, @@ -1465,14 +1597,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Handle real-time echo updates - update TxLogEntry as echoes are received _pingService!.onEchoReceived = (txPing, repeater, isNew) { debugLog('[APP] ========== ECHO CALLBACK RECEIVED =========='); - debugLog('[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); + debugLog( + '[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); debugLog('[APP] TxLogEntries count: ${_txLogEntries.length}'); // Find the matching TxLogEntry and update its events if (_txLogEntries.isNotEmpty) { final lastEntry = _txLogEntries.last; // Verify it's the right entry by timestamp (should be within a few seconds) - final timeDiff = lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); + final timeDiff = + lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); if (timeDiff <= 10) { // Build updated events list final existingEvents = List.from(lastEntry.events); @@ -1489,7 +1623,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _audioService.playReceiveSound(); } else { // Update existing event's SNR - final idx = existingEvents.indexWhere((e) => e.repeaterId == repeater.repeaterId); + final idx = existingEvents + .indexWhere((e) => e.repeaterId == repeater.repeaterId); if (idx >= 0) { existingEvents[idx] = newEvent; } @@ -1504,19 +1639,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { events: existingEvents, ); _txLogEntries[_txLogEntries.length - 1] = updatedEntry; - debugLog('[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); + debugLog( + '[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); // Update top repeaters overlay with current TX echoes - _updateTopRepeaters(existingEvents - .where((e) => e.snr != null) - .map((e) => (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) - .toList(), OverlayPingType.tx); + _updateTopRepeaters( + existingEvents + .where((e) => e.snr != null) + .map((e) => + (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) + .toList(), + OverlayPingType.tx); debugLog('[APP] Calling notifyListeners() to update UI'); notifyListeners(); debugLog('[APP] notifyListeners() completed'); } else { - debugLog('[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); + debugLog( + '[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); } } else { debugLog('[APP] WARNING: _txLogEntries is empty, cannot update'); @@ -1533,7 +1673,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Track idle time for auto-stop if (skipReason != null) { // Ping was skipped — check if idle too long - if (_preferences.autoStopAfterIdle && _idleAutoStopReference != null) { + if (_preferences.autoStopAfterIdle && + _idleAutoStopReference != null) { final elapsed = DateTime.now().difference(_idleAutoStopReference!); if (elapsed >= _autoStopIdleTimeout) { _triggerIdleAutoStop(); @@ -1552,15 +1693,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Wire up real-time disc node discovery callback (like onEchoReceived) _pingService!.onDiscNodeDiscovered = (discPing, nodeEntry, isNew) { - debugLog('[APP] Real-time disc node: ${nodeEntry.repeaterId}, isNew=$isNew'); + debugLog( + '[APP] Real-time disc node: ${nodeEntry.repeaterId}, isNew=$isNew'); if (isNew) { _audioService.playReceiveSound(); } // Update top repeaters overlay with all discovered nodes from this ping - _updateTopRepeaters(discPing.discoveredNodes - .map((n) => (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) - .toList(), OverlayPingType.disc); + _updateTopRepeaters( + discPing.discoveredNodes + .map((n) => + (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) + .toList(), + OverlayPingType.disc); notifyListeners(); }; @@ -1577,11 +1722,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lat = lastTx.latitude; lon = lastTx.longitude; if (lastTx.events.isNotEmpty) { - repeaters = lastTx.events.map((e) => MarkerRepeaterInfo( - repeaterId: e.repeaterId, - snr: e.snr ?? 0.0, - rssi: e.rssi ?? 0, - )).toList(); + repeaters = lastTx.events + .map((e) => MarkerRepeaterInfo( + repeaterId: e.repeaterId, + snr: e.snr ?? 0.0, + rssi: e.rssi ?? 0, + )) + .toList(); } } @@ -1606,12 +1753,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lat = lastDisc.latitude; lon = lastDisc.longitude; if (lastDisc.discoveredNodes.isNotEmpty) { - repeaters = lastDisc.discoveredNodes.map((n) => MarkerRepeaterInfo( - repeaterId: n.repeaterId, - snr: n.localSnr, - rssi: n.localRssi, - pubkeyHex: n.pubkeyHex, - )).toList(); + repeaters = lastDisc.discoveredNodes + .map((n) => MarkerRepeaterInfo( + repeaterId: n.repeaterId, + snr: n.localSnr, + rssi: n.localRssi, + pubkeyHex: n.pubkeyHex, + )) + .toList(); } } @@ -1648,11 +1797,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lat = lastTrace.latitude; lon = lastTrace.longitude; if (result != null && result.success) { - repeaters = [MarkerRepeaterInfo( - repeaterId: result.targetRepeaterId, - snr: result.localSnr, - rssi: result.localRssi, - )]; + repeaters = [ + MarkerRepeaterInfo( + repeaterId: result.targetRepeaterId, + snr: result.localSnr, + rssi: result.localRssi, + ) + ]; // Update the log entry with success data _traceLogEntries[0] = TraceLogEntry( timestamp: lastTrace.timestamp, @@ -1681,7 +1832,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Wire up discovery carpeater drop callback (for DiscTracker RSSI failsafe) _pingService!.onDiscCarpeaterDrop = (String repeaterId, String reason) { - debugLog('[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); + debugLog( + '[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); logError('Discovery Dropped\nPossible carpeater: $repeaterId\n$reason', severity: ErrorSeverity.warning, autoSwitch: false); }; @@ -1735,20 +1887,26 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update remembered device with real name (not "Anonymous") // BLE advertisement name may be stale after device rename - final realName = _isAnonymousRenamed ? (_originalDeviceName ?? selfInfoName) : selfInfoName; + final realName = _isAnonymousRenamed + ? (_originalDeviceName ?? selfInfoName) + : selfInfoName; if (_rememberedDevice != null && _rememberedDevice!.id == device.id) { final updatedName = 'MeshCore-$realName'; if (_rememberedDevice!.name != updatedName) { - await _saveRememberedDevice(DiscoveredDevice(id: device.id, name: updatedName)); - debugLog('[APP] Updated remembered device name from SelfInfo: $updatedName'); + await _saveRememberedDevice( + DiscoveredDevice(id: device.id, name: updatedName)); + debugLog( + '[APP] Updated remembered device name from SelfInfo: $updatedName'); } } } // Restore per-device antenna preference if previously saved // Use original name for keying, not "Anonymous" - final resolvedName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; - if (resolvedName != null && _deviceAntennaPreferences.containsKey(resolvedName)) { + final resolvedName = + _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; + if (resolvedName != null && + _deviceAntennaPreferences.containsKey(resolvedName)) { final savedAntenna = _deviceAntennaPreferences[resolvedName]!; _preferences = _preferences.copyWith( externalAntenna: savedAntenna, @@ -1756,12 +1914,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _antennaRestoredFromDevice = true; _savePreferences(); - debugLog('[APP] Restored antenna preference for "$resolvedName": ${savedAntenna ? "external" : "device"}'); + debugLog( + '[APP] Restored antenna preference for "$resolvedName": ${savedAntenna ? "external" : "device"}'); notifyListeners(); } // Restore per-device power override if previously saved - if (resolvedName != null && _devicePowerOverrides.containsKey(resolvedName)) { + if (resolvedName != null && + _devicePowerOverrides.containsKey(resolvedName)) { final saved = _devicePowerOverrides[resolvedName]!; _preferences = _preferences.copyWith( powerLevel: (saved['powerLevel'] as num).toDouble(), @@ -1771,7 +1931,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _powerRestoredFromDevice = true; _savePreferences(); - debugLog('[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); + debugLog( + '[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); notifyListeners(); } @@ -1780,7 +1941,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (txAllowed && rxAllowed) { debugLog('[CONN] Connected with full access (TX + RX allowed)'); } else if (rxAllowed) { - debugLog('[CONN] Connected with RX-only access (TX not allowed, zone at TX capacity)'); + debugLog( + '[CONN] Connected with RX-only access (TX not allowed, zone at TX capacity)'); } else { debugLog('[CONN] Connected with limited access'); } @@ -1818,7 +1980,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (validation != PingValidation.valid) { debugLog('[CONN] Ping validation after connect: $validation'); } - } catch (e) { debugError('[APP] Connection failed: $e'); @@ -1849,7 +2010,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (parts.length > 1) { final errorParts = parts[1].split(':'); final reason = errorParts.isNotEmpty ? errorParts[0] : 'unknown'; - final serverMessage = errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; + final serverMessage = + errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; _isNetworkError = reason == 'network_error'; _connectionError = _getErrorMessage(reason, serverMessage); } else { @@ -1859,7 +2021,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isAuthError = false; _isNetworkError = false; // Provide clean user-facing messages for common BLE errors - if (errorStr.contains('timeout') || errorStr.contains('Timeout') || errorStr.contains('timed out')) { + if (errorStr.contains('timeout') || + errorStr.contains('Timeout') || + errorStr.contains('timed out')) { _connectionError = 'Bluetooth connection scan timed out'; } else { _connectionError = errorStr.replaceFirst('Exception: ', ''); @@ -1879,8 +2043,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _txTracker!.disableRssiFilter = _preferences.disableRssiFilter; // Set CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) - _txTracker!.carpeaterPrefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; - debugLog('[APP] TxTracker.carpeaterPrefix set to ${_txTracker!.carpeaterPrefix ?? 'null'}'); + _txTracker!.carpeaterPrefix = + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + debugLog( + '[APP] TxTracker.carpeaterPrefix set to ${_txTracker!.carpeaterPrefix ?? 'null'}'); // Log TX carpeater drops to error log (without navigating to error tab) _txTracker!.onCarpeaterDrop = (String repeaterId, String reason) { @@ -1893,16 +2059,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Create RX logger (stored for use when enabling Passive Mode) _rxLogger = RxLogger( // CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) - carpeaterPrefix: _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null, + carpeaterPrefix: + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null, // Immediate observation callback - fires when packet is first validated // Creates pin IMMEDIATELY for NEW repeaters (first time in current batch) onObservation: (observation) { try { - debugLog('[APP] Immediate RX observation: repeater=${observation.repeaterId}, ' + debugLog( + '[APP] Immediate RX observation: repeater=${observation.repeaterId}, ' 'snr=${observation.snr ?? 'null'}, location=${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)}'); // Log current batch tracking state for debugging - debugLog('[APP] Current batch tracking: ${_currentBatchRepeaters.length} repeaters: $_currentBatchRepeaters'); + debugLog( + '[APP] Current batch tracking: ${_currentBatchRepeaters.length} repeaters: $_currentBatchRepeaters'); // Check if repeater already has a pin in CURRENT BATCH (not all-time) // This allows new pins after batch flushes (25m movement) @@ -1924,7 +2093,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Increment RX count immediately when pin is created (not on batch flush) _pingStats = _pingStats.copyWith(rxCount: _pingStats.rxCount + 1); - debugLog('[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' + debugLog( + '[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' 'at ${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)} ' '(batch tracking: ${_currentBatchRepeaters.length} repeaters, rxCount: ${_pingStats.rxCount})'); // Update RX overlay slot immediately @@ -1948,7 +2118,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); notifyListeners(); } else { - debugLog('[APP] Repeater ${observation.repeaterId} already has pin in current batch, SNR will update on flush if better'); + debugLog( + '[APP] Repeater ${observation.repeaterId} already has pin in current batch, SNR will update on flush if better'); } } catch (e, stackTrace) { debugError('[APP] Error in immediate observation callback: $e'); @@ -1961,7 +2132,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { onRxEntry: (entry) async { try { debugLog('[APP] ========== BATCH FLUSH CALLBACK =========='); - debugLog('[APP] Finalized RX entry (best SNR): repeater=${entry.repeaterId}, ' + debugLog( + '[APP] Finalized RX entry (best SNR): repeater=${entry.repeaterId}, ' 'snr=${entry.snr ?? 'null'}, location=${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); final repeaterKey = entry.repeaterId.toUpperCase(); @@ -1980,20 +2152,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update the pin's SNR to the best from this batch final existingPin = _rxPings[lastPinIndex]; // Only update if new SNR is non-null and better (null never replaces non-null) - final shouldUpdateSnr = entry.snr != null && entry.snr! > existingPin.snr; + final shouldUpdateSnr = + entry.snr != null && entry.snr! > existingPin.snr; if (shouldUpdateSnr) { _rxPings[lastPinIndex] = RxPing( - latitude: existingPin.latitude, // KEEP batch start location + latitude: existingPin.latitude, // KEEP batch start location longitude: existingPin.longitude, // KEEP batch start location repeaterId: entry.repeaterId, timestamp: entry.timestamp, - snr: entry.snr ?? existingPin.snr, // UPDATE to best SNR from batch + snr: entry.snr ?? + existingPin.snr, // UPDATE to best SNR from batch rssi: entry.rssi ?? existingPin.rssi, ); - debugLog('[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' + debugLog( + '[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' '${existingPin.snr.toStringAsFixed(2)} -> ${entry.snr?.toStringAsFixed(2) ?? 'null'}'); } else { - debugLog('[APP] RX pin SNR unchanged for repeater=${entry.repeaterId}: ' + debugLog( + '[APP] RX pin SNR unchanged for repeater=${entry.repeaterId}: ' 'batch best ${entry.snr?.toStringAsFixed(2) ?? 'null'} <= pin ${existingPin.snr.toStringAsFixed(2)}'); } } else { @@ -2008,7 +2184,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _rxPings.add(newRxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); - debugLog('[APP] Created FALLBACK RX pin for repeater=${entry.repeaterId} ' + debugLog( + '[APP] Created FALLBACK RX pin for repeater=${entry.repeaterId} ' 'at ${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); } @@ -2080,7 +2257,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { severity: ErrorSeverity.warning, autoSwitch: false); }, ); - + // Create packet validator with ALL allowed channels (#wardriving, #testing, #ottawa, Public) final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); final allowedChannels = {}; @@ -2091,7 +2268,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { hash: entry.value.hash, ); } - debugLog('[APP] PacketValidator configured with ${allowedChannels.length} channels: ' + debugLog( + '[APP] PacketValidator configured with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); final validator = PacketValidator( allowedChannels: allowedChannels, @@ -2104,15 +2282,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { rxLogger: _rxLogger!, validator: validator, ); - + // Subscribe to LogRxData stream - _logRxDataSubscription = _meshCoreConnection!.logRxDataStream.listen((data) { + _logRxDataSubscription = + _meshCoreConnection!.logRxDataStream.listen((data) { _unifiedRxHandler!.handlePacket(data.raw, data.snr, data.rssi); }); - + // Start listening _unifiedRxHandler!.startListening(); - + debugLog('[APP] Unified RX handler created and listening'); } @@ -2134,14 +2313,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Map TX bytes to trace bytes (3-byte traces not possible, use 4) _traceHopBytes = deviceHopBytes == 3 ? 4 : deviceHopBytes; _pingService?.traceHopBytes = _traceHopBytes; - debugLog('[PATH] Read device path mode: $deviceHopBytes-byte (trace: $_traceHopBytes-byte)'); + debugLog( + '[PATH] Read device path mode: $deviceHopBytes-byte (trace: $_traceHopBytes-byte)'); } else { _hopBytes = 1; _traceHopBytes = 1; } final effective = effectiveHopBytes; - final deviceMode = _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) + final deviceMode = + _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) final deviceHopBytes = deviceMode + 1; if (effective != deviceHopBytes && _originalPathHashMode != null) { @@ -2151,7 +2332,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _hopBytes = effective; // Update runtime state to reflect new mode _traceHopBytes = effective == 3 ? 4 : effective; _pingService?.traceHopBytes = _traceHopBytes; - debugLog('[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); + debugLog( + '[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); // Show warning popup if changing from 1-byte to multi-byte if (deviceMode == 0 && effective > 1) { @@ -2166,13 +2348,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } else if (_originalPathHashMode == null && effective > 1) { // Old firmware doesn't support multi-byte paths — warn user, fall back to 1-byte - debugWarn('[PATH] Device firmware does not report path_hash_mode, cannot set $effective-byte paths'); + debugWarn( + '[PATH] Device firmware does not report path_hash_mode, cannot set $effective-byte paths'); if (enforceHopBytes) { - _pendingPathHashWarning = (hopBytes: effective, reason: 'firmware_unsupported'); + _pendingPathHashWarning = + (hopBytes: effective, reason: 'firmware_unsupported'); notifyListeners(); } } else { - debugLog('[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); + debugLog( + '[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); } } @@ -2182,7 +2367,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_originalPathHashMode == null) return; if (_userChangedPathMode) { - debugLog('[PATH] User manually changed path mode, not restoring on disconnect'); + debugLog( + '[PATH] User manually changed path mode, not restoring on disconnect'); _originalPathHashMode = null; _userChangedPathMode = false; return; @@ -2195,12 +2381,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_hopBytes != originalHopBytes) { try { await _meshCoreConnection?.setPathHashMode(originalMode); - debugLog('[PATH] Restored path hash mode to original: $originalHopBytes-byte'); + debugLog( + '[PATH] Restored path hash mode to original: $originalHopBytes-byte'); } catch (e) { debugError('[PATH] Failed to restore path hash mode: $e'); } } else { - debugLog('[PATH] Path mode unchanged from original ($originalHopBytes-byte), no restore needed'); + debugLog( + '[PATH] Path mode unchanged from original ($originalHopBytes-byte), no restore needed'); } _originalPathHashMode = null; _userChangedPathMode = false; @@ -2211,7 +2399,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_originalPathHashMode == null) { // Old firmware — can't send command, show warning debugWarn('[PATH] Cannot change path mode: firmware does not support it'); - _pendingPathHashWarning = (hopBytes: newHopBytes, reason: 'firmware_unsupported'); + _pendingPathHashWarning = + (hopBytes: newHopBytes, reason: 'firmware_unsupported'); _hopBytes = 1; // Force back to 1 notifyListeners(); return; @@ -2230,7 +2419,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } final mode = newHopBytes - 1; // Convert 1/2/3 → mode 0/1/2 _meshCoreConnection?.setPathHashMode(mode); - debugLog('[PATH] User changed path mode to $newHopBytes-byte (trace: $_traceHopBytes-byte, sent to radio)'); + debugLog( + '[PATH] User changed path mode to $newHopBytes-byte (trace: $_traceHopBytes-byte, sent to radio)'); notifyListeners(); } @@ -2261,7 +2451,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Pending path hash warning data (for UI to show dialog) ({int hopBytes, String reason})? _pendingPathHashWarning; - ({int hopBytes, String reason})? get pendingPathHashWarning => _pendingPathHashWarning; + ({int hopBytes, String reason})? get pendingPathHashWarning => + _pendingPathHashWarning; /// Clear the pending warning after UI has shown it void clearPathHashWarning() { @@ -2406,7 +2597,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService = null; // Do NOT release API session or clear API queue - debugLog('[CONN] Auto-reconnect: preserved API session, cleaned up BLE objects'); + debugLog( + '[CONN] Auto-reconnect: preserved API session, cleaned up BLE objects'); notifyListeners(); @@ -2423,40 +2615,48 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Attempt a single reconnection void _attemptReconnect() { if (_reconnectAttempt >= _maxReconnectAttempts) { - debugLog('[CONN] Auto-reconnect: max attempts reached ($_maxReconnectAttempts)'); + debugLog( + '[CONN] Auto-reconnect: max attempts reached ($_maxReconnectAttempts)'); _abandonAutoReconnect(); return; } _reconnectAttempt++; - debugLog('[CONN] Auto-reconnect attempt $_reconnectAttempt of $_maxReconnectAttempts'); + debugLog( + '[CONN] Auto-reconnect attempt $_reconnectAttempt of $_maxReconnectAttempts'); notifyListeners(); // Use longer delay after bond errors to give iOS time to clear stale keys - final delay = _lastReconnectWasBondError ? _reconnectDelayAfterBondError : _reconnectDelay; + final delay = _lastReconnectWasBondError + ? _reconnectDelayAfterBondError + : _reconnectDelay; // Delay before attempting reconnection _reconnectTimer = Timer(delay, () async { if (!_isAutoReconnecting) return; // Cancelled while waiting try { - debugLog('[CONN] Auto-reconnect: calling reconnectToRememberedDevice()'); + debugLog( + '[CONN] Auto-reconnect: calling reconnectToRememberedDevice()'); await reconnectToRememberedDevice(); // If we get here and connection step is 'connected', success! if (_connectionStep == ConnectionStep.connected) { - debugLog('[CONN] Auto-reconnect succeeded on attempt $_reconnectAttempt'); + debugLog( + '[CONN] Auto-reconnect succeeded on attempt $_reconnectAttempt'); _lastReconnectWasBondError = false; _onReconnectSuccess(); } else if (_isAutoReconnecting) { // Connection failed but didn't throw - try again - debugLog('[CONN] Auto-reconnect: connection did not complete, retrying...'); + debugLog( + '[CONN] Auto-reconnect: connection did not complete, retrying...'); _connectionStep = ConnectionStep.reconnecting; notifyListeners(); _attemptReconnect(); } } catch (e) { - debugError('[CONN] Auto-reconnect attempt $_reconnectAttempt failed: $e'); + debugError( + '[CONN] Auto-reconnect attempt $_reconnectAttempt failed: $e'); if (_isAutoReconnecting) { // Check for iOS apple-code 14 (Peer removed pairing information) // The MeshCore device cleared its bond keys — clear iOS stale bond before retrying @@ -2479,10 +2679,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _idleDisconnectTimer = Timer(_idleDisconnectTimeout, () { if (!isConnected || _autoPingEnabled) return; debugLog('[IDLE] 15-minute idle timeout reached — disconnecting'); - logError('Disconnected: 15 minutes of inactivity', severity: ErrorSeverity.warning); + logError('Disconnected: 15 minutes of inactivity', + severity: ErrorSeverity.warning); disconnect(); }); - debugLog('[IDLE] Idle disconnect timer started (${_idleDisconnectTimeout.inMinutes} min)'); + debugLog( + '[IDLE] Idle disconnect timer started (${_idleDisconnectTimeout.inMinutes} min)'); } /// Cancel the idle disconnect timer @@ -2497,11 +2699,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Detect iOS apple-code 14/15 bond errors and clear the stale bond before retry Future _handleBondErrorIfNeeded(Object error) async { final errorStr = error.toString(); - if (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || errorStr.contains('Peer removed pairing information')) { + if (errorStr.contains('apple-code: 14') || + errorStr.contains('apple-code: 15') || + errorStr.contains('Peer removed pairing information')) { _lastReconnectWasBondError = true; final deviceId = _rememberedDevice?.id; if (deviceId != null) { - debugLog('[CONN] Bond error detected (apple-code 14/15) — clearing stale bond for $deviceId'); + debugLog( + '[CONN] Bond error detected (apple-code 14/15) — clearing stale bond for $deviceId'); await _bluetoothService.removeBond(deviceId); } } @@ -2523,7 +2728,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectAttempt = 0; _autoPingWasEnabled = false; - debugLog('[CONN] Auto-reconnect complete, restoring state (autoPing=$wasAutoPing, mode=$previousMode)'); + debugLog( + '[CONN] Auto-reconnect complete, restoring state (autoPing=$wasAutoPing, mode=$previousMode)'); // Restore auto-ping if it was active if (wasAutoPing) { @@ -2537,13 +2743,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _userRequestedDisconnect || _connectionStep != ConnectionStep.connected || _pingService == null) { - debugLog('[CONN] Skipping delayed auto-ping restore (stale or disconnected state)'); + debugLog( + '[CONN] Skipping delayed auto-ping restore (stale or disconnected state)'); return; } if (!_autoPingEnabled) { toggleAutoPing(previousMode); - debugLog('[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); + debugLog( + '[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); } }); } else { @@ -2582,7 +2790,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Reset antenna and power settings so user must choose again on next connect _antennaRestoredFromDevice = false; _powerRestoredFromDevice = false; - _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); + _preferences = _preferences.copyWith( + externalAntenna: false, externalAntennaSet: false); _savePreferences(); // Reset anonymous mode state (BLE already gone, can't restore name) @@ -2671,11 +2880,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_isAnonymousRenamed && _originalDeviceName != null) { try { await _meshCoreConnection?.setAdvertName(_originalDeviceName!); - debugLog('[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); + debugLog( + '[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); } catch (e) { debugError('[CONN] Anonymous mode: failed to restore name: $e'); - logError('Anonymous Mode: Failed to restore device name. Device may still show as "Anonymous".', - severity: ErrorSeverity.warning, autoSwitch: false); + logError( + 'Anonymous Mode: Failed to restore device name. Device may still show as "Anonymous".', + severity: ErrorSeverity.warning, + autoSwitch: false); } _isAnonymousRenamed = false; _originalDeviceName = null; @@ -2709,7 +2921,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Disconnect BLE (don't call disconnect() twice - meshCoreConnection.disconnect() already does it) await _meshCoreConnection?.disconnect(); - + // Cancel stream subscriptions await _noiseFloorSubscription?.cancel(); _noiseFloorSubscription = null; @@ -2730,7 +2942,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _displayDeviceName = null; _antennaRestoredFromDevice = false; _powerRestoredFromDevice = false; - _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); + _preferences = _preferences.copyWith( + externalAntenna: false, externalAntennaSet: false); _savePreferences(); _currentNoiseFloor = null; _currentBatteryPercent = null; @@ -2830,7 +3043,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); if (!result.isValid) { - debugWarn('[API] Session check failed: ${result.reason} - ${result.message ?? "Session expired"}'); + debugWarn( + '[API] Session check failed: ${result.reason} - ${result.message ?? "Session expired"}'); // Note: onSessionError callback will trigger disconnect for critical errors return false; } @@ -2851,7 +3065,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ? DateTime.now().difference(_idleAutoStopReference!).inMinutes : 30; debugLog('[AUTO] Auto-stop triggered: idle for $elapsed minutes'); - logError('Auto-ping stopped: no movement for 30 minutes', severity: ErrorSeverity.warning, autoSwitch: false); + logError('Auto-ping stopped: no movement for 30 minutes', + severity: ErrorSeverity.warning, autoSwitch: false); _idleAutoStopReference = null; toggleAutoPing(_autoMode); } @@ -2919,7 +3134,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Passive Mode is listening only, no cooldown needed if (isTxMode) { _cooldownTimer.start(5000); - debugLog('[${mode.name.toUpperCase()} MODE] Shared cooldown started (5s) - blocks TX Ping and TX modes'); + debugLog( + '[${mode.name.toUpperCase()} MODE] Shared cooldown started (5s) - blocks TX Ping and TX modes'); } else { debugLog('[PASSIVE MODE] Stopped - no cooldown (listen-only mode)'); } @@ -2936,7 +3152,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Block starting if shared cooldown is active (TX modes only) // Passive Mode is listening only and can start during cooldown if (isTxMode && _cooldownTimer.isRunning) { - debugLog('[${mode.name.toUpperCase()} MODE] Start blocked by shared cooldown'); + debugLog( + '[${mode.name.toUpperCase()} MODE] Start blocked by shared cooldown'); return false; } @@ -2967,7 +3184,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Set interval from user preferences before starting final intervalMs = _preferences.autoPingInterval * 1000; _pingService!.setAutoPingInterval(intervalMs); - debugLog('[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); + debugLog( + '[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); final started = await _pingService!.enableAutoPing( passiveMode: isPassive, @@ -2978,7 +3196,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!started) { // Blocked by cooldown or already enabled if (_pingService!.isInCooldown()) { - debugLog('[PING] Auto mode start blocked by cooldown (${_pingService!.getRemainingCooldownSeconds()}s remaining)'); + debugLog( + '[PING] Auto mode start blocked by cooldown (${_pingService!.getRemainingCooldownSeconds()}s remaining)'); } else { debugLog('[PING] Auto mode start blocked'); } @@ -2991,7 +3210,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _idleAutoStopReference = DateTime.now(); // Start noise floor session for graph tracking - final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : isTargeted ? 'targeted' : 'active'; + final sessionLabel = isPassive + ? 'passive' + : isHybrid + ? 'hybrid' + : isTargeted + ? 'targeted' + : 'active'; _startNoiseFloorSession(sessionLabel); // Enable heartbeat for all auto-ping modes (not offline mode) @@ -3011,7 +3236,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Start background service for continuous operation - final modeName = isPassive ? 'Passive Mode' : isHybrid ? 'Hybrid Mode' : isTargeted ? 'Trace Mode' : 'Active Mode'; + final modeName = isPassive + ? 'Passive Mode' + : isHybrid + ? 'Hybrid Mode' + : isTargeted + ? 'Trace Mode' + : 'Active Mode'; await BackgroundServiceManager.startService( mode: modeName, txCount: _pingStats.txCount, @@ -3050,7 +3281,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_discLogEntries.length > _maxLogEntries) { _discLogEntries.removeLast(); } - debugLog('[APP] Discovery log entry added: ${entry.nodeCount} nodes discovered'); + debugLog( + '[APP] Discovery log entry added: ${entry.nodeCount} nodes discovered'); notifyListeners(); } @@ -3060,14 +3292,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_traceLogEntries.length > _maxLogEntries) { _traceLogEntries.removeLast(); } - debugLog('[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); + debugLog( + '[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); // Update top repeaters overlay with successful trace result if (entry.success && entry.localSnr != null) { // Truncate 4-byte trace IDs to 3 bytes (6 hex chars) to fit overlay final id = entry.targetRepeaterId.toUpperCase(); final displayId = id.length > 6 ? id.substring(0, 6) : id; - _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)], OverlayPingType.trace); + _updateTopRepeaters([(repeaterId: displayId, snr: entry.localSnr!)], + OverlayPingType.trace); } notifyListeners(); @@ -3075,13 +3309,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Log a user-facing error message /// Set [autoSwitch] to false to log without navigating to error log tab - void logError(String message, {ErrorSeverity severity = ErrorSeverity.error, bool autoSwitch = true}) { + void logError(String message, + {ErrorSeverity severity = ErrorSeverity.error, bool autoSwitch = true}) { _errorLogEntries.add(UserErrorEntry( timestamp: DateTime.now(), message: message, severity: severity, )); - if (_errorLogEntries.length > _maxErrorEntries) _errorLogEntries.removeAt(0); + if (_errorLogEntries.length > _maxErrorEntries) { + _errorLogEntries.removeAt(0); + } if (autoSwitch) { _requestErrorLogSwitch = true; // Auto-switch to error log } @@ -3129,9 +3366,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Hot-switch while connected - return enabled - ? await _switchToOfflineMode() - : await _switchToOnlineMode(); + return enabled ? await _switchToOfflineMode() : await _switchToOnlineMode(); } /// Simple offline mode change (when not connected) @@ -3158,7 +3393,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _stopOfflineAutoSaveTimer(); // Re-check zone status when exiting offline mode if (_currentPosition != null) { - debugLog('[GEOFENCE] Re-checking zone status after offline mode disabled'); + debugLog( + '[GEOFENCE] Re-checking zone status after offline mode disabled'); checkZoneStatus(); } } @@ -3257,13 +3493,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { connectedDeviceName?.replaceFirst('MeshCore-', '')); if (deviceName == null || deviceName.isEmpty) { - debugError('[APP] Cannot switch to online mode: no device name available'); + debugError( + '[APP] Cannot switch to online mode: no device name available'); _modeSwitchError = 'Device name not available'; return (success: false, error: _modeSwitchError); } if (_devicePublicKey == null) { - debugError('[APP] Cannot switch to online mode: no public key available'); + debugError( + '[APP] Cannot switch to online mode: no public key available'); _modeSwitchError = 'Device public key not available'; return (success: false, error: _modeSwitchError); } @@ -3280,17 +3518,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (zoneCode == null) { debugError('[APP] Cannot switch to online mode: not in a zone'); - _modeSwitchError = 'Could not determine your zone. Check GPS and internet connection.'; + _modeSwitchError = + 'Could not determine your zone. Check GPS and internet connection.'; return (success: false, error: _modeSwitchError); } // ============================================================ // STAGE 1: Try existing public_key authentication // ============================================================ - debugLog('[APP] Stage 1: Attempting auth with public_key: ${_devicePublicKey!.substring(0, 16)}...'); + debugLog( + '[APP] Stage 1: Attempting auth with public_key: ${_devicePublicKey!.substring(0, 16)}...'); final modelString = _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown'; + _meshCoreConnection?.deviceInfo?.manufacturer ?? + 'Unknown'; var result = await _apiService.requestAuth( reason: 'connect', @@ -3310,10 +3551,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _maintenanceMode = true; _maintenanceMessage = result['maintenance_message'] as String?; _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + debugLog( + '[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); _startMaintenancePolling(); notifyListeners(); - _modeSwitchError = _maintenanceMessage ?? 'Service is under maintenance'; + _modeSwitchError = + _maintenanceMessage ?? 'Service is under maintenance'; return (success: false, error: _modeSwitchError); } @@ -3333,11 +3576,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } else { // Stage 1 failed — check if Stage 2 is worth attempting - debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + debugLog( + '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); final stage1Reason = result['reason'] as String?; if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { - debugError('[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + debugError( + '[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); _modeSwitchError = result['message'] as String? ?? 'GPS error'; return (success: false, error: _modeSwitchError); } @@ -3351,10 +3596,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { debugLog('[APP] Requesting signed contact URI from device...'); contactUri = await _meshCoreConnection!.exportContact(); - debugLog('[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + debugLog( + '[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); } catch (e) { debugError('[APP] Failed to get contact URI from device: $e'); - _modeSwitchError = 'Companion not found in backend and failed to register via API'; + _modeSwitchError = + 'Companion not found in backend and failed to register via API'; return (success: false, error: _modeSwitchError); } @@ -3378,9 +3625,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (registerResult['success'] != true) { - final serverReason = registerResult['reason'] as String? ?? 'registration_failed'; + final serverReason = + registerResult['reason'] as String? ?? 'registration_failed'; final serverMessage = registerResult['message'] as String?; - debugError('[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + debugError( + '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); _modeSwitchError = serverMessage ?? 'Registration rejected by server'; return (success: false, error: _modeSwitchError); } @@ -3509,7 +3758,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Note: Connection already validates device name exists, so this should never be null final offlineDeviceName = _isAnonymousRenamed ? _originalDeviceName - : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); await _offlineSessionService.saveSession( pings, devicePublicKey: _devicePublicKey, @@ -3525,14 +3775,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Periodically auto-save offline pings to prevent data loss from app kill. /// Uses a non-destructive snapshot so in-memory accumulation continues. void _autoSaveOfflinePings() { - if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) return; + if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) { + return; + } final pings = _apiQueueService.getOfflinePingsSnapshot(); if (pings.isEmpty) return; final offlineDeviceName = _isAnonymousRenamed ? _originalDeviceName - : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); _offlineSessionService.updateCurrentSession( pings, @@ -3582,7 +3835,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (success) { // Delete the session file on successful upload await _offlineSessionService.deleteSession(filename); - debugLog('[API] Uploaded and deleted offline session: $filename (${pings.length} pings)'); + debugLog( + '[API] Uploaded and deleted offline session: $filename (${pings.length} pings)'); } else { debugError('[API] Failed to upload offline session: $filename'); } @@ -3607,7 +3861,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }) async { // Concurrency guard — only one offline upload at a time if (_isUploadingOfflineSession) { - debugWarn('[OFFLINE] Upload already in progress, rejecting concurrent request'); + debugWarn( + '[OFFLINE] Upload already in progress, rejecting concurrent request'); return OfflineUploadResult.uploadInProgress; } @@ -3615,7 +3870,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); try { - return await _uploadOfflineSessionIsolated(filename, onProgress: onProgress); + return await _uploadOfflineSessionIsolated(filename, + onProgress: onProgress); } finally { _isUploadingOfflineSession = false; notifyListeners(); @@ -3662,13 +3918,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 3. Check GPS before auth — the server requires current coordinates for geo-auth if (_currentPosition == null) { - debugError('[OFFLINE] Upload requires GPS - location services not available'); + debugError( + '[OFFLINE] Upload requires GPS - location services not available'); return OfflineUploadResult.gpsRequired; } // 4. Authenticate with offline_mode: true, skipSessionStore: true // This prevents writing to shared _sessionId/_txAllowed/etc. - debugLog('[OFFLINE] Authenticating for offline upload with device: $deviceName'); + debugLog( + '[OFFLINE] Authenticating for offline upload with device: $deviceName'); final authResult = await _apiService.requestAuth( reason: 'connect', publicKey: publicKey, @@ -3697,7 +3955,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Stage 2: If unknown_device and we have a stored contactUri, attempt registration if (reason == 'unknown_device' && session.contactUri != null) { - debugLog('[OFFLINE] Stage 2: Attempting registration via stored contact URI...'); + debugLog( + '[OFFLINE] Stage 2: Attempting registration via stored contact URI...'); final registerResult = await _apiService.requestAuth( reason: 'register', contactUri: session.contactUri, @@ -3719,7 +3978,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return OfflineUploadResult.authFailed; } - debugLog('[OFFLINE] Stage 2 succeeded: device registered for offline upload'); + debugLog( + '[OFFLINE] Stage 2 succeeded: device registered for offline upload'); effectiveAuth = registerResult; } else { debugError('[OFFLINE] Auth failed: $reason'); @@ -3734,7 +3994,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return OfflineUploadResult.authFailed; } - debugLog('[OFFLINE] Authenticated with isolated session: $offlineSessionId'); + debugLog( + '[OFFLINE] Authenticated with isolated session: $offlineSessionId'); // Delay after auth before posting await Future.delayed(const Duration(seconds: 1)); @@ -3750,7 +4011,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { onProgress?.call('Batch $batchNum/$totalBatches'); final batch = pings.skip(i).take(batchSize).toList(); - final result = await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); + final result = + await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); if (result == UploadResult.success) { uploadedCount += batch.length; debugLog('[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); @@ -3779,7 +4041,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); return OfflineUploadResult.success; } else { - debugWarn('[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); + debugWarn( + '[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); notifyListeners(); return OfflineUploadResult.partialFailure; } @@ -3803,7 +4066,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Update user preferences void updatePreferences(UserPreferences preferences) { - debugLog('[APP] Preferences updated: externalAntennaSet=${preferences.externalAntennaSet}, ' + debugLog( + '[APP] Preferences updated: externalAntennaSet=${preferences.externalAntennaSet}, ' 'externalAntenna=${preferences.externalAntenna}, autoPowerSet=${preferences.autoPowerSet}'); _preferences = preferences; @@ -3813,26 +4077,32 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _powerRestoredFromDevice = false; // Persist antenna choice per device name (use original name, not "Anonymous") - final deviceName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; + final deviceName = + _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; if (deviceName != null && preferences.externalAntennaSet) { _deviceAntennaPreferences[deviceName] = preferences.externalAntenna; _saveDeviceAntennaPreferences(); - debugLog('[APP] Saved antenna preference for "$deviceName": ${preferences.externalAntenna ? "external" : "device"}'); + debugLog( + '[APP] Saved antenna preference for "$deviceName": ${preferences.externalAntenna ? "external" : "device"}'); } // Persist power override per device name - if (deviceName != null && preferences.powerLevelSet && !preferences.autoPowerSet) { + if (deviceName != null && + preferences.powerLevelSet && + !preferences.autoPowerSet) { _devicePowerOverrides[deviceName] = { 'powerLevel': preferences.powerLevel, 'txPower': preferences.txPower, }; _saveDevicePowerOverrides(); - debugLog('[APP] Saved power override for "$deviceName": ${preferences.powerLevel}W'); + debugLog( + '[APP] Saved power override for "$deviceName": ${preferences.powerLevel}W'); } else if (deviceName != null && preferences.autoPowerSet) { // User re-selected the auto-detected value — clear any saved override if (_devicePowerOverrides.remove(deviceName) != null) { _saveDevicePowerOverrides(); - debugLog('[APP] Cleared power override for "$deviceName" (auto-detected selected)'); + debugLog( + '[APP] Cleared power override for "$deviceName" (auto-detected selected)'); } } @@ -3843,7 +4113,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _syncCarpeaterPrefix(); // Propagate min ping distance to GpsService and PingService - _gpsService.setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); + _gpsService + .setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); PingService.currentMinDistance = preferences.minPingDistanceMeters; notifyListeners(); @@ -3859,7 +4130,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); // If connected, disconnect and reconnect for clean auth session - if (_connectionStatus == ConnectionStatus.connected && _meshCoreConnection != null) { + if (_connectionStatus == ConnectionStatus.connected && + _meshCoreConnection != null) { final deviceToReconnect = _bluetoothService.connectedDevice; if (deviceToReconnect != null) { _requestConnectionTabSwitch = true; @@ -3874,7 +4146,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Propagate carpeaterPrefix to live TxTracker and RxLogger void _syncCarpeaterPrefix() { - final prefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + final prefix = + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; if (_txTracker != null) { _txTracker!.carpeaterPrefix = prefix; debugLog('[APP] Synced TxTracker.carpeaterPrefix = ${prefix ?? 'null'}'); @@ -3944,7 +4217,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void setColorVisionType(String type) { _preferences = _preferences.copyWith(colorVisionType: type); PingColors.setColorVisionType( - ColorVisionType.values.firstWhere((e) => e.name == type, orElse: () => ColorVisionType.none), + ColorVisionType.values.firstWhere((e) => e.name == type, + orElse: () => ColorVisionType.none), ); debugLog('[A11Y] Color vision type set to $type'); notifyListeners(); @@ -4025,7 +4299,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Play disconnect alert if enabled (triple beep for unexpected ping stop) void _playDisconnectAlert() { - if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) return; + if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) { + return; + } debugLog('[AUDIO] Playing disconnect alert — pinging stopped unexpectedly'); _audioService.playAlertSound(); } @@ -4109,13 +4385,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Rate limiting should warn but not disconnect (per PORTED_APP behavior) if (reason == 'rate_limited') { - debugWarn('[API] Rate limited - continuing without disconnect: $userMessage'); + debugWarn( + '[API] Rate limited - continuing without disconnect: $userMessage'); return; } // Zone grace period: intercept outside_zone during active session if (reason == 'outside_zone' && _isInZoneGracePeriod) { - debugLog('[ZONE GRACE] outside_zone during grace period — already handling'); + debugLog( + '[ZONE GRACE] outside_zone during grace period — already handling'); return; } if (reason == 'outside_zone' && isConnected && !_isInZoneGracePeriod) { @@ -4171,7 +4449,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { deviceName: offlineDeviceName, contactUri: _offlineContactUri, ); - debugLog('[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); + debugLog( + '[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); } } catch (e) { debugError('[APP] Failed to preserve queue to offline storage: $e'); @@ -4185,7 +4464,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Handle maintenance mode while connected - end session and log error - Future _handleMaintenanceModeConnected(String message, String? url) async { + Future _handleMaintenanceModeConnected( + String message, String? url) async { debugLog('[MAINTENANCE] Ending session due to maintenance mode'); // Alert if auto-ping was running (maintenance is not user-initiated) @@ -4194,7 +4474,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Log to error log (this sets _requestErrorLogSwitch = true) - logError('Maintenance Mode Enabled: $message', severity: ErrorSeverity.warning); + logError('Maintenance Mode Enabled: $message', + severity: ErrorSeverity.warning); // Disconnect (ends session, cleans up) await disconnect(); @@ -4225,7 +4506,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Start periodic polling to check if maintenance mode has ended void _startMaintenancePolling() { _maintenanceCheckTimer?.cancel(); - _maintenanceCheckTimer = Timer.periodic(const Duration(seconds: 30), (_) async { + _maintenanceCheckTimer = + Timer.periodic(const Duration(seconds: 30), (_) async { if (!_maintenanceMode) { _maintenanceCheckTimer?.cancel(); _maintenanceCheckTimer = null; @@ -4255,7 +4537,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Validate GPS position for API calls /// Returns (isValid, errorMessage, errorCode) tuple - ({bool isValid, String? errorMessage, String? errorCode}) _validateGps(Position? position) { + ({bool isValid, String? errorMessage, String? errorCode}) _validateGps( + Position? position) { if (position == null) { return ( isValid: false, @@ -4269,7 +4552,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (ageSeconds > _maxGpsAgeSeconds) { return ( isValid: false, - errorMessage: 'GPS data is ${ageSeconds}s old (max ${_maxGpsAgeSeconds}s)', + errorMessage: + 'GPS data is ${ageSeconds}s old (max ${_maxGpsAgeSeconds}s)', errorCode: 'gps_stale', ); } @@ -4278,7 +4562,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (position.accuracy > _maxGpsAccuracyMeters) { return ( isValid: false, - errorMessage: 'GPS accuracy is ${position.accuracy.toStringAsFixed(0)}m (max ${_maxGpsAccuracyMeters.toStringAsFixed(0)}m)', + errorMessage: + 'GPS accuracy is ${position.accuracy.toStringAsFixed(0)}m (max ${_maxGpsAccuracyMeters.toStringAsFixed(0)}m)', errorCode: 'gps_inaccurate', ); } @@ -4317,7 +4602,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Schedule a zone check retry with countdown timer for UI feedback - void _scheduleZoneCheckRetry({required int seconds, required String error, required String reason}) { + void _scheduleZoneCheckRetry( + {required int seconds, required String error, required String reason}) { // Cancel any existing timers _zoneCheckRetryTimer?.cancel(); _zoneCheckCountdownTimer?.cancel(); @@ -4358,11 +4644,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Should be called on app launch and every 100m of GPS movement while disconnected Future checkZoneStatus() async { debugLog('[GEOFENCE] checkZoneStatus() called'); - debugLog('[GEOFENCE] Pre-check state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + debugLog( + '[GEOFENCE] Pre-check state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' 'hasPosition=${_currentPosition != null}, gpsStatus=$_gpsStatus'); if (_currentPosition == null) { - debugLog('[GEOFENCE] Cannot check zone status: no GPS position (gpsStatus=$_gpsStatus)'); + debugLog( + '[GEOFENCE] Cannot check zone status: no GPS position (gpsStatus=$_gpsStatus)'); return; } @@ -4372,18 +4660,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (_isCheckingZone) { - debugLog('[GEOFENCE] Zone check already in progress, skipping duplicate call'); + debugLog( + '[GEOFENCE] Zone check already in progress, skipping duplicate call'); return; } - debugLog('[GEOFENCE] Starting zone check - setting isCheckingZone=true (previous inZone=$_inZone)'); + debugLog( + '[GEOFENCE] Starting zone check - setting isCheckingZone=true (previous inZone=$_inZone)'); _isCheckingZone = true; // Don't clear error or notify here — keep current error view visible during retry // to avoid a full-screen flash. Error is cleared in finally block on success, // or overwritten by _scheduleZoneCheckRetry on failure. try { - debugLog('[GEOFENCE] Making API call to check zone at ${_currentPosition!.latitude.toStringAsFixed(5)}, ' + debugLog( + '[GEOFENCE] Making API call to check zone at ${_currentPosition!.latitude.toStringAsFixed(5)}, ' '${_currentPosition!.longitude.toStringAsFixed(5)} (accuracy: ${_currentPosition!.accuracy.toStringAsFixed(1)}m)'); final result = await _apiService.checkZoneStatus( @@ -4393,7 +4684,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { appVersion: _appVersion, ); - debugLog('[GEOFENCE] API response received: ${result != null ? 'valid' : 'null'}'); + debugLog( + '[GEOFENCE] API response received: ${result != null ? 'valid' : 'null'}'); if (result == null) { // Update position even on failure to prevent zone check flooding @@ -4416,7 +4708,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _maintenanceMode = true; _maintenanceMessage = result['maintenance_message'] as String?; _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Zone check returned maintenance: $_maintenanceMessage'); + debugLog( + '[MAINTENANCE] Zone check returned maintenance: $_maintenanceMessage'); // Start polling to detect when maintenance ends _startMaintenancePolling(); @@ -4433,8 +4726,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final success = result['success'] == true; if (!success) { final reason = result['reason'] as String?; - final message = result['message'] as String? ?? 'Zone status check failed'; - debugError('[GEOFENCE] Zone status check failed: reason=$reason, message=$message'); + final message = + result['message'] as String? ?? 'Zone status check failed'; + debugError( + '[GEOFENCE] Zone status check failed: reason=$reason, message=$message'); if (reason == 'gps_inaccurate') { logError('GPS Accuracy Error\n$message', autoSwitch: false); @@ -4448,14 +4743,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } else if (reason == 'zone_disabled') { final errorMsg = _getErrorMessage(reason, message); logError(errorMsg); - _scheduleZoneCheckRetry(seconds: 30, error: errorMsg, reason: reason!); + _scheduleZoneCheckRetry( + seconds: 30, error: errorMsg, reason: reason!); } else if (reason == 'bad_key' || reason == 'invalid_request') { final errorMsg = _getErrorMessage(reason, message); logError(errorMsg); - _scheduleZoneCheckRetry(seconds: 60, error: errorMsg, reason: reason!); + _scheduleZoneCheckRetry( + seconds: 60, error: errorMsg, reason: reason!); } else { // Unknown server errors — use server message - _scheduleZoneCheckRetry(seconds: 15, error: message, reason: 'server_error'); + _scheduleZoneCheckRetry( + seconds: 15, error: message, reason: 'server_error'); } return; @@ -4487,14 +4785,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] In zone: $newZoneName ($newZoneCode)'); if (newZoneCode.isNotEmpty) { - _fetchRepeatersForZone(newZoneCode); // fire-and-forget — don't block zone check + _fetchRepeatersForZone( + newZoneCode); // fire-and-forget — don't block zone check } } else { _currentZone = null; _nearestZone = result['nearest_zone'] as Map?; final nearestName = _nearestZone?['name'] ?? 'Unknown'; - final distanceKm = (_nearestZone?['distance_km'] as num?)?.toStringAsFixed(1) ?? '?'; - debugWarn('[GEOFENCE] Outside zone. Nearest: $nearestName (${distanceKm}km away)'); + final distanceKm = + (_nearestZone?['distance_km'] as num?)?.toStringAsFixed(1) ?? '?'; + debugWarn( + '[GEOFENCE] Outside zone. Nearest: $nearestName (${distanceKm}km away)'); // Clear repeaters when exiting zone _repeaters = []; @@ -4505,7 +4806,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugError('[GEOFENCE] Zone status check error: $e'); } finally { _isCheckingZone = false; - debugLog('[GEOFENCE] Zone check complete - final state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + debugLog( + '[GEOFENCE] Zone check complete - final state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' 'zoneName=${_currentZone?['name']}, zoneCode=${_currentZone?['code']}'); notifyListeners(); } @@ -4521,11 +4823,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // If auth response includes slot data, use it directly (forward-compatible) if (authResult.containsKey('slots_available')) { _currentZone!['slots_available'] = authResult['slots_available']; - debugLog('[CAPACITY] Updated slots_available from auth: ${authResult['slots_available']}'); + debugLog( + '[CAPACITY] Updated slots_available from auth: ${authResult['slots_available']}'); } if (authResult.containsKey('slots_max')) { _currentZone!['slots_max'] = authResult['slots_max']; - debugLog('[CAPACITY] Updated slots_max from auth: ${authResult['slots_max']}'); + debugLog( + '[CAPACITY] Updated slots_max from auth: ${authResult['slots_max']}'); } // Sync at_capacity with tx_allowed @@ -4535,7 +4839,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // If auth says TX not allowed and server didn't provide slot data, set slots to 0 if (!authTxAllowed && !authResult.containsKey('slots_available')) { _currentZone!['slots_available'] = 0; - debugLog('[CAPACITY] Zone at TX capacity per auth, set slots_available=0'); + debugLog( + '[CAPACITY] Zone at TX capacity per auth, set slots_available=0'); } // If auth says TX allowed and we have slot data but server didn't provide updated count, @@ -4593,8 +4898,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Future _startZoneGracePeriod() async { if (_isInZoneGracePeriod) return; _isInZoneGracePeriod = true; - debugLog('[ZONE GRACE] Entering zone grace period (${_zoneGraceTimeout.inMinutes}m timeout)'); - logError('Left wardriving zone. Searching for nearby zone...', severity: ErrorSeverity.warning, autoSwitch: false); + debugLog( + '[ZONE GRACE] Entering zone grace period (${_zoneGraceTimeout.inMinutes}m timeout)'); + logError('Left wardriving zone. Searching for nearby zone...', + severity: ErrorSeverity.warning, autoSwitch: false); // Save auto-ping state for restoration on zone re-entry _autoPingWasEnabledBeforeGrace = _autoPingEnabled; @@ -4676,18 +4983,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // checkZoneStatus updates _inZone and calls notifyListeners (overlay auto-updates) if (_inZone == true) { final reEnteredZoneCode = _currentZone?['code'] as String? ?? ''; - debugLog('[ZONE GRACE] Zone re-entered: ${_currentZone?['name']} ($reEnteredZoneCode)'); + debugLog( + '[ZONE GRACE] Zone re-entered: ${_currentZone?['name']} ($reEnteredZoneCode)'); // If re-entering a DIFFERENT zone, do a full zone transfer instead of simple resume if (_sessionZoneCode != null && reEnteredZoneCode.isNotEmpty && reEnteredZoneCode != _sessionZoneCode) { - debugLog('[ZONE GRACE] Re-entered different zone ($reEnteredZoneCode vs session $_sessionZoneCode) — transferring'); + debugLog( + '[ZONE GRACE] Re-entered different zone ($reEnteredZoneCode vs session $_sessionZoneCode) — transferring'); _cancelZoneGraceTimers(); _isInZoneGracePeriod = false; _zoneGraceSecondsRemaining = 0; _autoPingWasEnabledBeforeGrace = false; - await _handleZoneTransfer(reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown'); + await _handleZoneTransfer( + reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown'); return; } @@ -4708,8 +5018,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _zoneGraceSecondsRemaining = 0; _autoPingWasEnabledBeforeGrace = false; - debugLog('[ZONE GRACE] Resuming wardriving (autoPing=$wasAutoPing, mode=$previousMode)'); - logError('Re-entered wardriving zone. Resuming...', severity: ErrorSeverity.info, autoSwitch: false); + debugLog( + '[ZONE GRACE] Resuming wardriving (autoPing=$wasAutoPing, mode=$previousMode)'); + logError('Re-entered wardriving zone. Resuming...', + severity: ErrorSeverity.info, autoSwitch: false); // Re-enable heartbeat _apiService.enableHeartbeat( @@ -4735,7 +5047,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _userRequestedDisconnect || _connectionStep != ConnectionStep.connected || _pingService == null) { - debugLog('[ZONE GRACE] Skipping auto-ping restore (stale or disconnected state)'); + debugLog( + '[ZONE GRACE] Skipping auto-ping restore (stale or disconnected state)'); return; } if (!_autoPingEnabled) { @@ -4782,7 +5095,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Handle zone-to-zone transfer during active wardriving session. /// Releases old zone session and acquires new session for target zone. /// Preserves BLE connection and radio configuration. - Future _handleZoneTransfer(String newZoneCode, String newZoneName) async { + Future _handleZoneTransfer( + String newZoneCode, String newZoneName) async { if (_isZoneTransferInProgress) { debugLog('[ZONE] Transfer already in progress, skipping'); return; @@ -4845,7 +5159,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); - if (_devicePublicKey == null || deviceName == null || _currentPosition == null) { + if (_devicePublicKey == null || + deviceName == null || + _currentPosition == null) { debugError('[ZONE] Cannot transfer: missing device key, name, or GPS'); await disconnect(); return; @@ -4860,7 +5176,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: newZoneCode, model: _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown', + _meshCoreConnection?.deviceInfo?.manufacturer ?? + 'Unknown', lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, @@ -4869,7 +5186,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 10. Check auth result if (result == null) { debugError('[ZONE] Auth failed for zone $newZoneCode: network error'); - logError('Zone transfer failed: unable to reach server', severity: ErrorSeverity.error); + logError('Zone transfer failed: unable to reach server', + severity: ErrorSeverity.error); await disconnect(); return; } @@ -4887,8 +5205,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (result['success'] != true) { final reason = result['reason'] as String? ?? 'unknown'; final message = result['message'] as String? ?? 'Auth failed'; - debugError('[ZONE] Auth failed for zone $newZoneCode: $reason - $message'); - logError('Zone transfer failed: $message', severity: ErrorSeverity.error); + debugError( + '[ZONE] Auth failed for zone $newZoneCode: $reason - $message'); + logError('Zone transfer failed: $message', + severity: ErrorSeverity.error); await disconnect(); return; } @@ -4911,7 +5231,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 13. Update PacketValidator with new channel configuration if (_unifiedRxHandler != null) { - final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); + final allowedChannelsData = + ChannelService.getAllowedChannelsForValidator(); final allowedChannels = {}; for (final entry in allowedChannelsData.entries) { allowedChannels[entry.key] = ChannelInfo( @@ -4925,13 +5246,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { disableRssiFilter: _preferences.disableRssiFilter, ); _unifiedRxHandler!.updateValidator(newValidator); - debugLog('[ZONE] PacketValidator updated with ${allowedChannels.length} channels'); + debugLog( + '[ZONE] PacketValidator updated with ${allowedChannels.length} channels'); } // 14. Update flood scope from new auth response final apiScopes = _apiService.scopes; final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; - final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; + final isWildcard = + firstScope == null || firstScope == '*' || firstScope == '#*'; if (!isWildcard) { final scopeName = firstScope; _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; @@ -4960,8 +5283,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[ZONE] Discovery drop force-enabled by new zone admin'); } if (_preferences.autoPingInterval < _apiService.minModeInterval) { - _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); - debugLog('[ZONE] Auto-ping interval bumped to ${_apiService.minModeInterval}s by new zone admin'); + _preferences = _preferences.copyWith( + autoPingInterval: _apiService.minModeInterval); + debugLog( + '[ZONE] Auto-ping interval bumped to ${_apiService.minModeInterval}s by new zone admin'); } // 16. Reconfigure path hash mode if new zone requires different hop bytes @@ -5000,7 +5325,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _userRequestedDisconnect || _connectionStep != ConnectionStep.connected || _pingService == null) { - debugLog('[ZONE] Skipping auto-ping restore (stale or disconnected state)'); + debugLog( + '[ZONE] Skipping auto-ping restore (stale or disconnected state)'); return; } if (!_autoPingEnabled) { @@ -5053,7 +5379,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); notifyListeners(); } else { - debugWarn('[MAP] No repeaters returned for zone $iata — will retry on next zone check'); + debugWarn( + '[MAP] No repeaters returned for zone $iata — will retry on next zone check'); } } catch (e) { debugError('[MAP] Failed to fetch repeaters: $e'); @@ -5304,7 +5631,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Load a route file (KML or GPX) bool loadSimulatorRoute(String content, {String? filename}) { - final success = _gpsService.simulator.loadRoute(content, filename: filename); + final success = + _gpsService.simulator.loadRoute(content, filename: filename); if (success) { _gpsSimulatorPattern = SimulatorPattern.route; // If simulator is running, it will automatically use the new route @@ -5363,7 +5691,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Attempt to recover from Hive corruption - Future?> _attemptHiveRecovery(String boxName, Duration timeout) async { + Future?> _attemptHiveRecovery( + String boxName, Duration timeout) async { try { debugLog('[HIVE] Deleting corrupted box "$boxName"...'); await Hive.deleteBoxFromDisk(boxName); @@ -5377,7 +5706,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return box; } catch (e) { debugError('[HIVE] Recovery failed for "$boxName": $e'); - logError('Storage for "$boxName" unavailable - some settings may not persist'); + logError( + 'Storage for "$boxName" unavailable - some settings may not persist'); return null; } } @@ -5393,7 +5723,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { final json = box.get('device'); if (json != null) { - _rememberedDevice = RememberedDevice.fromJson(Map.from(json)); + _rememberedDevice = + RememberedDevice.fromJson(Map.from(json)); debugLog('[APP] Loaded remembered device: ${_rememberedDevice!.name}'); notifyListeners(); } @@ -5478,13 +5809,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { final json = box.get('preferences'); if (json != null) { - _preferences = UserPreferences.fromJson(Map.from(json)); - debugLog('[APP] Loaded preferences: interval=${_preferences.autoPingInterval}s, ' + _preferences = + UserPreferences.fromJson(Map.from(json)); + debugLog( + '[APP] Loaded preferences: interval=${_preferences.autoPingInterval}s, ' 'ignoreCarpeater=${_preferences.ignoreCarpeater}, ' 'ignoreRepeaterId=${_preferences.ignoreRepeaterId}'); // Apply saved min ping distance to GpsService and PingService - _gpsService.setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); + _gpsService + .setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); PingService.currentMinDistance = _preferences.minPingDistanceMeters; // Apply saved color vision type @@ -5528,7 +5862,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final raw = box.get('device_antenna_preferences'); if (raw != null) { _deviceAntennaPreferences = Map.from(raw as Map); - debugLog('[APP] Loaded antenna preferences for ${_deviceAntennaPreferences.length} device(s)'); + debugLog( + '[APP] Loaded antenna preferences for ${_deviceAntennaPreferences.length} device(s)'); } } catch (e) { debugLog('[APP] Failed to load device antenna preferences: $e'); @@ -5560,9 +5895,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final raw = box.get('device_power_overrides'); if (raw != null) { _devicePowerOverrides = (raw as Map).map( - (key, value) => MapEntry(key.toString(), Map.from(value as Map)), + (key, value) => + MapEntry(key.toString(), Map.from(value as Map)), ); - debugLog('[APP] Loaded power overrides for ${_devicePowerOverrides.length} device(s)'); + debugLog( + '[APP] Loaded power overrides for ${_devicePowerOverrides.length} device(s)'); } } catch (e) { debugLog('[APP] Failed to load device power overrides: $e'); @@ -5591,10 +5928,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (box == null) return; try { - _lastConnectedDeviceName = box.get('last_connected_device_name') as String?; + _lastConnectedDeviceName = + box.get('last_connected_device_name') as String?; _lastConnectedPublicKey = box.get('last_connected_public_key') as String?; if (_lastConnectedDeviceName != null) { - debugLog('[APP] Loaded last connected device: $_lastConnectedDeviceName'); + debugLog( + '[APP] Loaded last connected device: $_lastConnectedDeviceName'); } } catch (e) { debugLog('[APP] Failed to load last connected device: $e'); @@ -5602,7 +5941,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Save last connected device info to Hive storage - Future _saveLastConnectedDevice(String deviceName, String publicKey) async { + Future _saveLastConnectedDevice( + String deviceName, String publicKey) async { final box = await _openBoxSafely(_preferencesBoxName); if (box == null) return; @@ -5693,34 +6033,45 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[HIVE] Opening typed box "$_noiseFloorSessionBoxName"...'); try { - final box = await Hive.openBox(_noiseFloorSessionBoxName).timeout(timeout); - debugLog('[HIVE] Typed box "$_noiseFloorSessionBoxName" opened successfully'); + final box = + await Hive.openBox(_noiseFloorSessionBoxName) + .timeout(timeout); + debugLog( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" opened successfully'); return box; } on TimeoutException { - debugError('[HIVE] Typed box "$_noiseFloorSessionBoxName" timed out - attempting recovery'); + debugError( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" timed out - attempting recovery'); return _attemptNoiseFloorBoxRecovery(timeout); } catch (e) { - debugError('[HIVE] Typed box "$_noiseFloorSessionBoxName" failed: $e - attempting recovery'); + debugError( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" failed: $e - attempting recovery'); return _attemptNoiseFloorBoxRecovery(timeout); } } /// Attempt to recover from Hive corruption for noise floor box - Future?> _attemptNoiseFloorBoxRecovery(Duration timeout) async { + Future?> _attemptNoiseFloorBoxRecovery( + Duration timeout) async { try { debugLog('[HIVE] Deleting corrupted box "$_noiseFloorSessionBoxName"...'); await Hive.deleteBoxFromDisk(_noiseFloorSessionBoxName); debugLog('[HIVE] Retrying open...'); // Notify user that cleanup happened - logError('Storage for "$_noiseFloorSessionBoxName" was corrupted and has been reset'); - - final box = await Hive.openBox(_noiseFloorSessionBoxName).timeout(timeout); - debugLog('[HIVE] Typed box "$_noiseFloorSessionBoxName" opened after recovery'); + logError( + 'Storage for "$_noiseFloorSessionBoxName" was corrupted and has been reset'); + + final box = + await Hive.openBox(_noiseFloorSessionBoxName) + .timeout(timeout); + debugLog( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" opened after recovery'); return box; } catch (e) { debugError('[HIVE] Recovery failed for "$_noiseFloorSessionBoxName": $e'); - logError('Storage for "$_noiseFloorSessionBoxName" unavailable - noise floor graphs will not persist'); + logError( + 'Storage for "$_noiseFloorSessionBoxName" unavailable - noise floor graphs will not persist'); return null; } } @@ -5736,7 +6087,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { _storedNoiseFloorSessions = _noiseFloorSessionBox!.values.toList() ..sort((a, b) => b.startTime.compareTo(a.startTime)); // Newest first - debugLog('[GRAPH] Loaded ${_storedNoiseFloorSessions.length} stored noise floor sessions'); + debugLog( + '[GRAPH] Loaded ${_storedNoiseFloorSessions.length} stored noise floor sessions'); } catch (e) { debugError('[GRAPH] Failed to load noise floor sessions: $e'); _storedNoiseFloorSessions = []; @@ -5799,7 +6151,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_currentNoiseFloorSession == null) return; _currentNoiseFloorSession!.endTime = DateTime.now(); - debugLog('[GRAPH] Ended session: ${_currentNoiseFloorSession!.durationDisplay}, ' + debugLog( + '[GRAPH] Ended session: ${_currentNoiseFloorSession!.durationDisplay}, ' '${_currentNoiseFloorSession!.samples.length} samples, ' '${_currentNoiseFloorSession!.markers.length} markers'); diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 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 4cfe78e..61a0b95 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,7 +2,8 @@ import 'dart:convert'; import 'dart:io' show File; import 'dart:math' as math; -import 'package:flutter/foundation.dart' show kIsWeb, defaultTargetPlatform, TargetPlatform; +import 'package:flutter/foundation.dart' + show kIsWeb, defaultTargetPlatform, TargetPlatform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -43,13 +44,15 @@ class _SettingsScreenState extends State { int _versionTapCount = 0; DateTime? _lastVersionTap; - Future _showUploadLogsDialog(BuildContext context, AppStateProvider appState) async { + Future _showUploadLogsDialog( + BuildContext context, AppStateProvider appState) async { final result = await showUploadLogsDialog(context, appState); if (!context.mounted || result == null) return; if (result.success) { - String message = 'Uploaded ${result.uploadedCount} log file${result.uploadedCount == 1 ? '' : 's'}'; + String message = + 'Uploaded ${result.uploadedCount} log file${result.uploadedCount == 1 ? '' : 's'}'; if (result.failedCount > 0) { message += ' (${result.failedCount} failed)'; } @@ -115,11 +118,13 @@ class _SettingsScreenState extends State { Padding( padding: const EdgeInsets.only(bottom: 8), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Colors.amber.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.amber.withValues(alpha: 0.3)), + border: + Border.all(color: Colors.amber.withValues(alpha: 0.3)), ), child: const Row( children: [ @@ -141,23 +146,25 @@ class _SettingsScreenState extends State { prefs.themeMode == 'dark' ? Icons.dark_mode : Icons.light_mode, ), title: const Text('Theme'), - subtitle: Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), + subtitle: + Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), value: prefs.themeMode == 'dark', onChanged: (isDark) { appState.setThemeMode(isDark ? 'dark' : 'light'); }, ), - if (!kIsWeb) - _BackgroundModeToggle(appState: appState), + if (!kIsWeb) _BackgroundModeToggle(appState: appState), SwitchListTile( - secondary: Icon(prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), + secondary: + Icon(prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), title: const Text('Disable Map Tiles'), subtitle: Text(prefs.mapTilesEnabled ? 'Map and coverage tiles load normally' : 'Disabled to save mobile data'), value: !prefs.mapTilesEnabled, onChanged: (value) { - appState.updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); + appState + .updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); }, ), if (prefs.mapTilesEnabled) @@ -186,7 +193,8 @@ class _SettingsScreenState extends State { prefs.isImperial ? Icons.square_foot : Icons.straighten, ), title: const Text('Units'), - subtitle: Text(prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), + subtitle: Text( + prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), value: prefs.isImperial, onChanged: (isImperial) { appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); @@ -195,10 +203,12 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const Icon(Icons.cell_tower), title: const Text('Top Repeaters on Map'), - subtitle: const Text('Show top 3 repeaters by SNR from last ping'), + subtitle: + const Text('Show top 3 repeaters by SNR from last ping'), value: prefs.showTopRepeaters, onChanged: (value) { - appState.updatePreferences(prefs.copyWith(showTopRepeaters: value)); + appState + .updatePreferences(prefs.copyWith(showTopRepeaters: value)); }, ), ListTile( @@ -216,9 +226,11 @@ class _SettingsScreenState extends State { onTap: () => _showGpsMarkerSelector(context, appState), ), SwitchListTile( - secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), + secondary: Icon( + appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), title: const Text('Sound Notifications'), - subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), + subtitle: Text( + appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), value: appState.isSoundEnabled, onChanged: (_) => appState.toggleSoundEnabled(), ), @@ -233,14 +245,16 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const SizedBox(width: 24), title: const Text('Response Received'), - subtitle: const Text('Sound when repeater echo or RX is received'), + subtitle: + const Text('Sound when repeater echo or RX is received'), value: appState.isRxSoundEnabled, onChanged: (value) => appState.setRxSoundEnabled(value), ), SwitchListTile( secondary: const SizedBox(width: 24), title: const Text('Disconnect Alert'), - subtitle: const Text('Triple beep when pinging stops unexpectedly'), + subtitle: + const Text('Triple beep when pinging stops unexpectedly'), value: appState.isDisconnectAlertEnabled, onChanged: (value) => appState.setDisconnectAlertEnabled(value), ), @@ -256,17 +270,20 @@ class _SettingsScreenState extends State { ? 'Device broadcasts as "Anonymous"' : 'Device uses its real name'), value: prefs.anonymousMode, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showEnableAnonymousConfirmation(context, appState); - } else { - if (appState.connectionStatus == ConnectionStatus.connected) { - _showDisableAnonymousConfirmation(context, appState); - } else { - appState.setAnonymousMode(false); - } - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showEnableAnonymousConfirmation(context, appState); + } else { + if (appState.connectionStatus == + ConnectionStatus.connected) { + _showDisableAnonymousConfirmation(context, appState); + } else { + appState.setAnonymousMode(false); + } + } + }, ), ListTile( leading: const Icon(Icons.timer), @@ -274,7 +291,9 @@ class _SettingsScreenState extends State { subtitle: Text(prefs.autoPingIntervalDisplay), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showIntervalSelector(context, appState), + onTap: isAutoMode + ? null + : () => _showIntervalSelector(context, appState), ), ListTile( leading: const Icon(Icons.straighten), @@ -282,16 +301,22 @@ class _SettingsScreenState extends State { subtitle: Text(prefs.minPingDistanceDisplay), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showDistanceSelector(context, appState), + onTap: isAutoMode + ? null + : () => _showDistanceSelector(context, appState), ), SwitchListTile( secondary: const Icon(Icons.timer_off), title: const Text('Auto-Stop After Idle'), - subtitle: const Text('Stops auto-ping after 30 min without movement'), + subtitle: + const Text('Stops auto-ping after 30 min without movement'), value: prefs.autoStopAfterIdle, - onChanged: isAutoMode ? null : (value) { - appState.updatePreferences(prefs.copyWith(autoStopAfterIdle: value)); - }, + onChanged: isAutoMode + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(autoStopAfterIdle: value)); + }, ), ]), @@ -301,7 +326,9 @@ class _SettingsScreenState extends State { secondary: const Icon(Icons.compare_arrows), title: Row( children: [ - const Flexible(child: Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), + const Flexible( + child: + Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showHybridModeInfo(context), @@ -323,15 +350,20 @@ class _SettingsScreenState extends State { ) : const Text('Combines Active and Passive modes'), value: appState.enforceHybrid ? true : prefs.hybridModeEnabled, - onChanged: (isAutoMode || appState.enforceHybrid) ? null : (value) { - appState.updatePreferences(prefs.copyWith(hybridModeEnabled: value)); - }, + onChanged: (isAutoMode || appState.enforceHybrid) + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(hybridModeEnabled: value)); + }, ), SwitchListTile( secondary: const Icon(Icons.signal_wifi_off), title: Row( children: [ - const Flexible(child: Text('Discovery Drop', overflow: TextOverflow.ellipsis)), + const Flexible( + child: Text('Discovery Drop', + overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showDiscDropInfo(context), @@ -353,13 +385,16 @@ class _SettingsScreenState extends State { ) : const Text('Count failed discoveries as failed pings'), value: appState.enforceDiscDrop ? true : prefs.discDropEnabled, - onChanged: (isAutoMode || appState.enforceDiscDrop) ? null : (value) { - if (value == true) { - _showDiscDropEnableConfirmation(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(discDropEnabled: false)); - } - }, + onChanged: (isAutoMode || appState.enforceDiscDrop) + ? null + : (value) { + if (value == true) { + _showDiscDropEnableConfirmation(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(discDropEnabled: false)); + } + }, ), ]), @@ -368,17 +403,21 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const Icon(Icons.filter_alt), title: const Text('CARpeater Filter'), - subtitle: Text(prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null - ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' - : 'Tap to set CARpeater repeater ID'), + subtitle: Text( + prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null + ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' + : 'Tap to set CARpeater repeater ID'), value: prefs.ignoreCarpeater, - onChanged: isAutoMode ? null : (value) { - if (value && prefs.ignoreRepeaterId == null) { - _showRepeaterIdDialog(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(ignoreCarpeater: value)); - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value && prefs.ignoreRepeaterId == null) { + _showRepeaterIdDialog(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(ignoreCarpeater: value)); + } + }, ), if (prefs.ignoreCarpeater) ListTile( @@ -389,7 +428,9 @@ class _SettingsScreenState extends State { : 'Not set'), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showRepeaterIdDialog(context, appState), + onTap: isAutoMode + ? null + : () => _showRepeaterIdDialog(context, appState), ), SwitchListTile( secondary: const Icon(Icons.shield_outlined), @@ -398,13 +439,16 @@ class _SettingsScreenState extends State { ? 'Allows all signal strengths' : 'Drops signals stronger than -30 dBm'), value: prefs.disableRssiFilter, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showDisableRssiFilterConfirmation(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(disableRssiFilter: false)); - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showDisableRssiFilterConfirmation(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(disableRssiFilter: false)); + } + }, ), ]), @@ -414,7 +458,8 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.linear_scale), title: Row( children: [ - const Flexible(child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), + const Flexible( + child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showHopBytesInfo(context), @@ -446,14 +491,19 @@ class _SettingsScreenState extends State { ) : const Text('Repeater ID size in TX/RX path hops'), trailing: DropdownButton( - value: appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes, + value: appState.enforceHopBytes + ? appState.effectiveHopBytes + : appState.hopBytes, underline: const SizedBox(), items: const [ DropdownMenuItem(value: 1, child: Text('1')), DropdownMenuItem(value: 2, child: Text('2')), DropdownMenuItem(value: 3, child: Text('3')), ], - onChanged: (!appState.isConnected || isAutoMode || appState.enforceHopBytes || !appState.supportsMultiBytePaths) + onChanged: (!appState.isConnected || + isAutoMode || + appState.enforceHopBytes || + !appState.supportsMultiBytePaths) ? null : (value) { if (value != null) appState.setHopBytes(value); @@ -464,7 +514,9 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.gps_fixed), title: Row( children: [ - const Flexible(child: Text('Trace Bytes', overflow: TextOverflow.ellipsis)), + const Flexible( + child: + Text('Trace Bytes', overflow: TextOverflow.ellipsis)), const SizedBox(width: 4), IconButton( onPressed: () => _showTraceBytesInfo(context), @@ -498,7 +550,9 @@ class _SettingsScreenState extends State { DropdownMenuItem(value: 2, child: Text('2')), DropdownMenuItem(value: 4, child: Text('4')), ], - onChanged: (!appState.isConnected || isAutoMode || !appState.supportsMultiBytePaths) + onChanged: (!appState.isConnected || + isAutoMode || + !appState.supportsMultiBytePaths) ? null : (value) { if (value != null) appState.setTraceHopBytes(value); @@ -529,7 +583,8 @@ class _SettingsScreenState extends State { : 'Keeps #wardriving channel on device'), value: prefs.deleteChannelOnDisconnect, onChanged: (value) { - appState.updatePreferences(prefs.copyWith(deleteChannelOnDisconnect: value)); + appState.updatePreferences( + prefs.copyWith(deleteChannelOnDisconnect: value)); }, ), ]), @@ -584,12 +639,15 @@ class _SettingsScreenState extends State { ) else ...appState.offlineSessions.map((session) => _OfflineSessionTile( - session: session, - uploadEnabled: !appState.isUploadingOfflineSession, - onUpload: () => _uploadOfflineSession(context, appState, session.filename), - onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), - onDownload: () => _downloadOfflineSession(context, appState, session.filename), - )), + session: session, + uploadEnabled: !appState.isUploadingOfflineSession, + onUpload: () => _uploadOfflineSession( + context, appState, session.filename), + onDelete: () => _confirmDeleteOfflineSession( + context, appState, session.filename), + onDownload: () => _downloadOfflineSession( + context, appState, session.filename), + )), ]), // API Endpoints @@ -606,13 +664,16 @@ class _SettingsScreenState extends State { ? (prefs.customApiUrl ?? 'Not configured') : 'Forward pings to a third-party server'), value: prefs.customApiEnabled, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showCustomApiDisclaimer(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(customApiEnabled: false)); - } - }, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showCustomApiDisclaimer(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(customApiEnabled: false)); + } + }, ), if (prefs.customApiEnabled) ...[ ListTile( @@ -620,29 +681,41 @@ class _SettingsScreenState extends State { title: const Text('Endpoint URL'), subtitle: Text(prefs.customApiUrl ?? 'Not set'), trailing: const Icon(Icons.chevron_right), - onTap: isAutoMode ? null : () => _showCustomApiUrlDialog(context, appState), + onTap: isAutoMode + ? null + : () => _showCustomApiUrlDialog(context, appState), ), ListTile( leading: const SizedBox(width: 24), title: const Text('API Key'), - subtitle: Text(prefs.customApiKey != null ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : 'Not set'), + subtitle: Text(prefs.customApiKey != null + ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' + : 'Not set'), trailing: const Icon(Icons.chevron_right), - onTap: isAutoMode ? null : () => _showCustomApiKeyDialog(context, appState), + onTap: isAutoMode + ? null + : () => _showCustomApiKeyDialog(context, appState), ), SwitchListTile( secondary: const SizedBox(width: 24), title: const Text('Include Contact Key'), - subtitle: const Text('Share device public key prefix with endpoint'), + subtitle: + const Text('Share device public key prefix with endpoint'), value: prefs.customApiIncludeContact, - onChanged: isAutoMode ? null : (value) { - appState.updatePreferences(prefs.copyWith(customApiIncludeContact: value)); - }, + onChanged: isAutoMode + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(customApiIncludeContact: value)); + }, ), ListTile( leading: const Icon(Icons.content_paste), title: const Text('Import from Clipboard'), subtitle: const Text('Paste a meshmapper:// config link'), - onTap: isAutoMode ? null : () => _importCustomApiFromClipboard(context, appState), + onTap: isAutoMode + ? null + : () => _importCustomApiFromClipboard(context, appState), ), ], ]), @@ -670,7 +743,8 @@ class _SettingsScreenState extends State { leading: const FaIcon(FontAwesomeIcons.github), title: const Text('GitHub'), subtitle: const Text('View issues and source code'), - onTap: () => _launchUrl('https://github.com/MeshMapper/MeshMapper_Project'), + onTap: () => _launchUrl( + 'https://github.com/MeshMapper/MeshMapper_Project'), ), ListTile( leading: const FaIcon(FontAwesomeIcons.discord), @@ -681,7 +755,8 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.groups), title: const Text('Community'), - subtitle: const Text('Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), + subtitle: const Text( + 'Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), onTap: () => _launchUrl('https://ottawamesh.ca/'), ), ListTile( @@ -698,12 +773,15 @@ class _SettingsScreenState extends State { SwitchListTile( secondary: const Icon(Icons.exit_to_app), title: const Text('Close App After Disconnect'), - subtitle: const Text('Automatically exit the app when disconnecting'), + subtitle: + const Text('Automatically exit the app when disconnecting'), value: prefs.closeAppAfterDisconnect, - onChanged: (value) => appState.setCloseAppAfterDisconnect(value), + onChanged: (value) => + appState.setCloseAppAfterDisconnect(value), ), ListTile( - leading: const Icon(Icons.power_settings_new, color: Colors.red), + leading: + const Icon(Icons.power_settings_new, color: Colors.red), title: const Text('Close App'), subtitle: const Text('Exit the app completely'), onTap: () => _showCloseAppConfirmation(context, appState), @@ -733,7 +811,8 @@ class _SettingsScreenState extends State { if (appState.isGpsSimulatorEnabled) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.orange, borderRadius: BorderRadius.circular(4), @@ -771,13 +850,15 @@ class _SettingsScreenState extends State { min: 10, max: 120, divisions: 11, - label: formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + label: formatSpeed(appState.gpsSimulatorSpeed, + isImperial: prefs.isImperial), onChanged: (value) { appState.setGpsSimulatorSpeed(value); }, ), trailing: Text( - formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + formatSpeed(appState.gpsSimulatorSpeed, + isImperial: prefs.isImperial), style: const TextStyle(fontWeight: FontWeight.bold), ), ), @@ -793,15 +874,18 @@ class _SettingsScreenState extends State { items: [ const DropdownMenuItem( value: SimulatorPattern.straight, - child: Text('Straight Line', overflow: TextOverflow.ellipsis), + child: Text('Straight Line', + overflow: TextOverflow.ellipsis), ), const DropdownMenuItem( value: SimulatorPattern.circle, - child: Text('Circle', overflow: TextOverflow.ellipsis), + child: + Text('Circle', overflow: TextOverflow.ellipsis), ), const DropdownMenuItem( value: SimulatorPattern.randomWalk, - child: Text('Random Walk', overflow: TextOverflow.ellipsis), + child: Text('Random Walk', + overflow: TextOverflow.ellipsis), ), if (appState.hasSimulatorRoute) DropdownMenuItem( @@ -882,7 +966,8 @@ class _SettingsScreenState extends State { if (appState.debugLogsEnabled) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.orange, borderRadius: BorderRadius.circular(4), @@ -913,7 +998,8 @@ class _SettingsScreenState extends State { } }, ), - if (appState.debugLogsEnabled || appState.debugLogFiles.isNotEmpty) ...[ + if (appState.debugLogsEnabled || + appState.debugLogFiles.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), child: Row( @@ -929,12 +1015,14 @@ class _SettingsScreenState extends State { TextButton.icon( icon: const Icon(Icons.cloud_upload, size: 18), label: const Text('Upload'), - onPressed: () => _showUploadLogsDialog(context, appState), + onPressed: () => + _showUploadLogsDialog(context, appState), ), TextButton.icon( icon: const Icon(Icons.delete_sweep, size: 18), label: const Text('Delete All'), - onPressed: () => _confirmDeleteAllLogs(context, appState), + onPressed: () => + _confirmDeleteAllLogs(context, appState), ), ], ], @@ -945,7 +1033,8 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(16), child: Text( 'No debug logs yet', - style: TextStyle(color: Colors.grey.shade500, fontSize: 13), + style: + TextStyle(color: Colors.grey.shade500, fontSize: 13), ), ) else @@ -955,19 +1044,27 @@ class _SettingsScreenState extends State { final filename = file.path.split('/').last; final sizeBytes = file.lengthSync(); final isCurrentLog = index == 0; - final timestampMatch = RegExp(r'meshmapper-debug-(\d+)\.txt').firstMatch(filename); + final timestampMatch = + RegExp(r'meshmapper-debug-(\d+)\.txt') + .firstMatch(filename); final fileDate = timestampMatch != null - ? DateTime.fromMillisecondsSinceEpoch(int.parse(timestampMatch.group(1)!) * 1000) + ? DateTime.fromMillisecondsSinceEpoch( + int.parse(timestampMatch.group(1)!) * 1000) : null; - final dateStr = fileDate != null ? DateFormat('MMM d, h:mm a').format(fileDate) : filename; + final dateStr = fileDate != null + ? DateFormat('MMM d, h:mm a').format(fileDate) + : filename; String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); + final partCount = + DebugFileLogger.estimatePartCount(sizeBytes); if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); + final sizeMb = + (sizeBytes / 1024 / 1024).toStringAsFixed(1); sizeDisplay = '$sizeMb MB ($partCount parts)'; } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + sizeDisplay = + '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; } if (isCurrentLog) { sizeDisplay = '$sizeDisplay (current)'; @@ -975,7 +1072,8 @@ class _SettingsScreenState extends State { return ListTile( leading: const Icon(Icons.description, size: 20), - title: Text(dateStr, style: const TextStyle(fontSize: 13)), + title: + Text(dateStr, style: const TextStyle(fontSize: 13)), subtitle: Text( sizeDisplay, style: const TextStyle(fontSize: 11), @@ -985,7 +1083,8 @@ class _SettingsScreenState extends State { children: [ IconButton( icon: const Icon(Icons.visibility, size: 20), - onPressed: () => _showLogViewer(context, appState, file), + onPressed: () => + _showLogViewer(context, appState, file), tooltip: 'View', ), IconButton( @@ -1006,27 +1105,38 @@ class _SettingsScreenState extends State { String _markerStyleLabel(String style) { switch (style) { - case 'circle': return 'Outlined Dot'; - case 'pin': return 'Pin'; - case 'diamond': return 'Diamond'; + case 'circle': + return 'Outlined Dot'; + case 'pin': + return 'Pin'; + case 'diamond': + return 'Diamond'; case 'dot': - default: return 'Dot'; + default: + return 'Dot'; } } String _gpsMarkerLabel(String style) { switch (style) { - case 'car': return 'Car'; - case 'bike': return 'Bike'; - case 'boat': return 'Boat'; - case 'walk': return 'Walk'; - case 'chomper': return 'Chomper'; + case 'car': + return 'Car'; + case 'bike': + return 'Bike'; + case 'boat': + return 'Boat'; + case 'walk': + return 'Walk'; + case 'chomper': + return 'Chomper'; case 'arrow': - default: return 'Arrow'; + default: + return 'Arrow'; } } - void _showMarkerStyleSelector(BuildContext context, AppStateProvider appState) { + void _showMarkerStyleSelector( + BuildContext context, AppStateProvider appState) { final options = [ ('dot', 'Dot', Icons.circle), ('circle', 'Outlined Dot', Icons.circle_outlined), @@ -1044,13 +1154,15 @@ class _SettingsScreenState extends State { children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Map Marker Style', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + child: Text('Map Marker Style', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), RadioGroup( groupValue: appState.preferences.markerStyle, onChanged: (v) { if (v != null) { - appState.updatePreferences(appState.preferences.copyWith(markerStyle: v)); + appState.updatePreferences( + appState.preferences.copyWith(markerStyle: v)); } Navigator.pop(context); }, @@ -1093,13 +1205,15 @@ class _SettingsScreenState extends State { children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('GPS Marker', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + child: Text('GPS Marker', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), RadioGroup( groupValue: appState.preferences.gpsMarkerStyle, onChanged: (v) { if (v != null) { - appState.updatePreferences(appState.preferences.copyWith(gpsMarkerStyle: v)); + appState.updatePreferences( + appState.preferences.copyWith(gpsMarkerStyle: v)); } Navigator.pop(context); }, @@ -1132,13 +1246,30 @@ class _SettingsScreenState extends State { }; } - void _showColorVisionSelector(BuildContext context, AppStateProvider appState) { + void _showColorVisionSelector( + BuildContext context, AppStateProvider appState) { final options = [ ('none', 'Default', 'Standard color palette'), - ('protanopia', 'Protanopia', 'Red-blind — difficulty distinguishing red and green'), - ('deuteranopia', 'Deuteranopia', 'Green-blind — difficulty distinguishing red and green'), - ('tritanopia', 'Tritanopia', 'Blue-blind — difficulty distinguishing blue and yellow'), - ('achromatopsia', 'Achromatopsia', 'Total color blindness — sees in greyscale'), + ( + 'protanopia', + 'Protanopia', + 'Red-blind — difficulty distinguishing red and green' + ), + ( + 'deuteranopia', + 'Deuteranopia', + 'Green-blind — difficulty distinguishing red and green' + ), + ( + 'tritanopia', + 'Tritanopia', + 'Blue-blind — difficulty distinguishing blue and yellow' + ), + ( + 'achromatopsia', + 'Achromatopsia', + 'Total color blindness — sees in greyscale' + ), ]; showModalBottomSheet( context: context, @@ -1151,7 +1282,8 @@ class _SettingsScreenState extends State { children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Color Vision', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + child: Text('Color Vision', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), RadioGroup( groupValue: appState.preferences.colorVisionType, @@ -1166,7 +1298,8 @@ class _SettingsScreenState extends State { RadioListTile( secondary: const Icon(Icons.visibility), title: Text(label), - subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)), + subtitle: + Text(subtitle, style: const TextStyle(fontSize: 12)), value: value, ), ], @@ -1179,7 +1312,8 @@ class _SettingsScreenState extends State { ); } - Widget _buildSection(BuildContext context, String title, List children) { + Widget _buildSection( + BuildContext context, String title, List children) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Card( @@ -1216,7 +1350,8 @@ class _SettingsScreenState extends State { } } - Future _showBugReportDialog(BuildContext context, AppStateProvider appState) async { + Future _showBugReportDialog( + BuildContext context, AppStateProvider appState) async { final result = await showBugReportDialog(context, appState); if (!context.mounted || result == null) return; @@ -1236,7 +1371,8 @@ class _SettingsScreenState extends State { message, duration: const Duration(seconds: 5), actionLabel: result.issueUrl != null ? 'View' : null, - onAction: result.issueUrl != null ? () => _launchUrl(result.issueUrl!) : null, + onAction: + result.issueUrl != null ? () => _launchUrl(result.issueUrl!) : null, ); } else if (result.errorMessage != null) { AppToast.error( @@ -1297,7 +1433,8 @@ class _SettingsScreenState extends State { ); } - void _showDisableRssiFilterConfirmation(BuildContext context, AppStateProvider appState) { + void _showDisableRssiFilterConfirmation( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1330,7 +1467,8 @@ class _SettingsScreenState extends State { ); } - void _showEnableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { + void _showEnableAnonymousConfirmation( + BuildContext context, AppStateProvider appState) { final isConnected = appState.connectionStatus == ConnectionStatus.connected; showDialog( context: context, @@ -1364,7 +1502,8 @@ class _SettingsScreenState extends State { ); } - void _showDisableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { + void _showDisableAnonymousConfirmation( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1409,21 +1548,24 @@ class _SettingsScreenState extends State { style: TextStyle(fontSize: 14), ), SizedBox(height: 12), - Text('How it works:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('How it works:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( 'Discovery \u2192 wait \u2192 TX Ping \u2192 wait \u2192 Discovery \u2192 ...', style: TextStyle(fontSize: 13, fontFamily: 'monospace'), ), SizedBox(height: 12), - Text('Interval timing:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('Interval timing:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( 'At 15s interval, each ping type fires every 30s. Discovery\'s 30s firmware rate limit is naturally respected.', style: TextStyle(fontSize: 13), ), SizedBox(height: 12), - Text('When enabled:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('When enabled:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( '\u2022 Replaces the Active button with Hybrid\n' @@ -1480,7 +1622,8 @@ class _SettingsScreenState extends State { ); } - void _showDiscDropEnableConfirmation(BuildContext context, AppStateProvider appState) { + void _showDiscDropEnableConfirmation( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1702,7 +1845,8 @@ class _SettingsScreenState extends State { final tile = RadioListTile( title: Text( '$interval seconds', - style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold), + style: const TextStyle( + fontSize: 17, fontWeight: FontWeight.bold), ), subtitle: isDisabled ? const Text( @@ -1810,7 +1954,8 @@ class _SettingsScreenState extends State { textCapitalization: TextCapitalization.characters, onChanged: (value) { // Keep only valid hex characters - final filtered = value.toUpperCase().replaceAll(RegExp(r'[^0-9A-F]'), ''); + final filtered = + value.toUpperCase().replaceAll(RegExp(r'[^0-9A-F]'), ''); if (filtered != value) { controller.value = controller.value.copyWith( text: filtered, @@ -1854,7 +1999,8 @@ class _SettingsScreenState extends State { ); Navigator.pop(context); } else { - AppToast.warning(context, 'Please enter exactly 6 hex digits (3-byte ID).'); + AppToast.warning( + context, 'Please enter exactly 6 hex digits (3-byte ID).'); } }, child: const Text('Save'), @@ -1864,7 +2010,8 @@ class _SettingsScreenState extends State { ); } - Future _pickRouteFile(BuildContext context, AppStateProvider appState) async { + Future _pickRouteFile( + BuildContext context, AppStateProvider appState) async { try { debugLog('[SETTINGS] Opening file picker...'); @@ -1882,9 +2029,8 @@ class _SettingsScreenState extends State { if (result != null && result.files.isNotEmpty) { debugLog('[SETTINGS] File picked: ${result.files.first.name}'); final file = result.files.first; - final content = file.bytes != null - ? String.fromCharCodes(file.bytes!) - : null; + final content = + file.bytes != null ? String.fromCharCodes(file.bytes!) : null; if (content != null && context.mounted) { debugLog('[SETTINGS] File content loaded, ${content.length} chars'); @@ -1918,7 +2064,8 @@ class _SettingsScreenState extends State { ); } - void _processRouteFile(BuildContext context, AppStateProvider appState, String content, String filename) { + void _processRouteFile(BuildContext context, AppStateProvider appState, + String content, String filename) { debugLog('[SETTINGS] Calling loadSimulatorRoute...'); final success = appState.loadSimulatorRoute( content, @@ -1939,7 +2086,8 @@ class _SettingsScreenState extends State { } } - Future _uploadOfflineSession(BuildContext context, AppStateProvider appState, String filename) async { + Future _uploadOfflineSession( + BuildContext context, AppStateProvider appState, String filename) async { // Progress text notifier for updating dialog without rebuilding screen final progressNotifier = ValueNotifier('Authenticating...'); @@ -2039,7 +2187,8 @@ class _SettingsScreenState extends State { } } - void _confirmDeleteOfflineSession(BuildContext context, AppStateProvider appState, String filename) { + void _confirmDeleteOfflineSession( + BuildContext context, AppStateProvider appState, String filename) { showDialog( context: context, builder: (context) => AlertDialog( @@ -2065,9 +2214,11 @@ class _SettingsScreenState extends State { ); } - void _downloadOfflineSession(BuildContext context, AppStateProvider appState, String filename) { + void _downloadOfflineSession( + BuildContext context, AppStateProvider appState, String filename) { try { - final sessionData = appState.offlineSessionService.getSessionData(filename); + final sessionData = + appState.offlineSessionService.getSessionData(filename); if (sessionData == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -2079,7 +2230,8 @@ class _SettingsScreenState extends State { } // Convert to pretty JSON - final jsonString = const JsonEncoder.withIndent(' ').convert(sessionData); + final jsonString = + const JsonEncoder.withIndent(' ').convert(sessionData); if (kIsWeb && isWebFileHelpersAvailable) { // Web: Create a blob and trigger download @@ -2146,7 +2298,8 @@ class _SettingsScreenState extends State { } /// Show debug log viewer dialog - void _showLogViewer(BuildContext context, AppStateProvider appState, File file) async { + void _showLogViewer( + BuildContext context, AppStateProvider appState, File file) async { await appState.viewDebugLog(file); if (!context.mounted) return; @@ -2178,7 +2331,8 @@ class _SettingsScreenState extends State { ); } - void _showCustomApiDisclaimer(BuildContext context, AppStateProvider appState) { + void _showCustomApiDisclaimer( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -2236,7 +2390,8 @@ class _SettingsScreenState extends State { ); } - void _showCustomApiUrlDialog(BuildContext context, AppStateProvider appState) { + void _showCustomApiUrlDialog( + BuildContext context, AppStateProvider appState) { final controller = TextEditingController( text: appState.preferences.customApiUrl ?? '', ); @@ -2296,7 +2451,8 @@ class _SettingsScreenState extends State { ); } - void _showCustomApiKeyDialog(BuildContext context, AppStateProvider appState) { + void _showCustomApiKeyDialog( + BuildContext context, AppStateProvider appState) { final controller = TextEditingController( text: appState.preferences.customApiKey ?? '', ); @@ -2335,7 +2491,8 @@ class _SettingsScreenState extends State { ); } - Future _importCustomApiFromClipboard(BuildContext context, AppStateProvider appState) async { + Future _importCustomApiFromClipboard( + BuildContext context, AppStateProvider appState) async { final clipData = await Clipboard.getData('text/plain'); final text = clipData?.text?.trim(); @@ -2358,11 +2515,15 @@ class _SettingsScreenState extends State { final key = uri.queryParameters['key']; if (rawUrl == null || rawUrl.isEmpty) { - if (context.mounted) AppToast.error(context, 'Link is missing the url parameter'); + if (context.mounted) { + AppToast.error(context, 'Link is missing the url parameter'); + } return; } if (key == null || key.isEmpty) { - if (context.mounted) AppToast.error(context, 'Link is missing the key parameter'); + if (context.mounted) { + AppToast.error(context, 'Link is missing the key parameter'); + } return; } @@ -2371,7 +2532,9 @@ class _SettingsScreenState extends State { // Validate the constructed URL final parsed = Uri.tryParse(fullUrl); if (parsed == null || !parsed.hasAuthority) { - if (context.mounted) AppToast.error(context, 'Invalid URL in link: $rawUrl'); + if (context.mounted) { + AppToast.error(context, 'Invalid URL in link: $rawUrl'); + } return; } @@ -2388,11 +2551,14 @@ class _SettingsScreenState extends State { debugLog('[CUSTOM API] Imported endpoint from clipboard: $fullUrl'); } catch (e) { debugError('[CUSTOM API] Failed to parse clipboard link: $e'); - if (context.mounted) AppToast.error(context, 'Invalid meshmapper:// link'); + if (context.mounted) { + AppToast.error(context, 'Invalid meshmapper:// link'); + } } } - void _showCloseAppConfirmation(BuildContext context, AppStateProvider appState) { + void _showCloseAppConfirmation( + BuildContext context, AppStateProvider appState) { final isConnected = appState.isConnected; showDialog( @@ -2472,7 +2638,9 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> Future _requestPermission() async { // Show prominent disclosure before requesting background location - final accepted = await PermissionDisclosureService.showBackgroundLocationDisclosure(context); + final accepted = + await PermissionDisclosureService.showBackgroundLocationDisclosure( + context); if (!accepted) { return; // User declined } @@ -2607,7 +2775,10 @@ class _OfflineSessionTile extends StatelessWidget { if (isUploaded) const Text( 'Uploaded', - style: TextStyle(color: Colors.green, fontSize: 12, fontWeight: FontWeight.w500), + style: TextStyle( + color: Colors.green, + fontSize: 12, + fontWeight: FontWeight.w500), ), if (session.deviceName != null) Text( diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 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 848f432..2e83858 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -252,6 +252,22 @@ extension MapStyleExtension on MapStyle { } } +<<<<<<< HEAD +======= +/// Custom tile provider that silently handles HTTP errors (404, 503, etc.) +/// instead of flooding the console with exceptions +final class SilentCancellableNetworkTileProvider + extends CancellableNetworkTileProvider { + SilentCancellableNetworkTileProvider() + : super( + dioClient: Dio( + BaseOptions( + validateStatus: (status) => true, // Accept all status codes + ), + ), + ); +} +>>>>>>> a431a6a (format with dart) /// Resolved repeater with SNR and ambiguity info for ping focus mode. /// Line color is based on [snr] (green/yellow/red). When a short hex ID @@ -302,11 +318,14 @@ class _MapWidgetState extends State { bool _prefsApplied = false; // Guard to load saved prefs only once bool _isMapReady = false; LatLng? _lastGpsPosition; - bool _hasInitialZoomed = false; // Track if we've done the one-time initial zoom to GPS - bool _hasZoomedToLastKnown = false; // Track if we've zoomed to last known position (before GPS) + bool _hasInitialZoomed = + false; // Track if we've done the one-time initial zoom to GPS + bool _hasZoomedToLastKnown = + false; // Track if we've zoomed to last known position (before GPS) // Map rotation mode - bool _alwaysNorth = true; // true = north always up, false = rotate with heading + bool _alwaysNorth = + true; // true = north always up, false = rotate with heading double? _lastHeading; // Track last heading for smooth rotation // Desired camera zoom while auto-follow is active. Set when the user taps @@ -479,7 +498,7 @@ class _MapWidgetState extends State { super.didUpdateWidget(oldWidget); // When padding changes (panel opened/closed/minimized/orientation change), re-center if auto-following if ((widget.bottomPaddingPixels != oldWidget.bottomPaddingPixels || - widget.rightPaddingPixels != oldWidget.rightPaddingPixels) && + widget.rightPaddingPixels != oldWidget.rightPaddingPixels) && _autoFollow && _isMapReady && _lastGpsPosition != null) { @@ -508,6 +527,66 @@ class _MapWidgetState extends State { } } +<<<<<<< HEAD +======= + /// Smoothly animate the map to a new position + void _animateToPosition(LatLng target) { + if (!_isMapReady || !mounted) return; + + // Get current position + final currentCenter = _mapController.camera.center; + + // Skip if already at target (within small threshold) + final distance = + const Distance().as(LengthUnit.Meter, currentCenter, target); + if (distance < 1) return; // Less than 1 meter, don't animate + + // Cancel any running animation + _animationController?.stop(); + _animationController?.dispose(); + + // Create new animation controller + // Duration based on distance - shorter for small movements, longer for big jumps + final duration = Duration(milliseconds: distance < 100 ? 200 : 300); + + _animationController = AnimationController( + duration: duration, + vsync: this, + ); + + _animation = CurvedAnimation( + parent: _animationController!, + curve: Curves.easeOutCubic, // Smooth deceleration + ); + + _animationStartPosition = currentCenter; + _animationEndPosition = target; + + _animation!.addListener(() { + if (!mounted || + _animationStartPosition == null || + _animationEndPosition == null) { + return; + } + + // Interpolate between start and end positions + final t = _animation!.value; + final lat = _animationStartPosition!.latitude + + ((_animationEndPosition!.latitude - + _animationStartPosition!.latitude) * + t); + final lng = _animationStartPosition!.longitude + + ((_animationEndPosition!.longitude - + _animationStartPosition!.longitude) * + t); + + _mapController.move(LatLng(lat, lng), _mapController.camera.zoom); + }); + + _animationController!.forward(); + } + +>>>>>>> a431a6a (format with dart) /// Smoothly animate the map to a new position with zoom void _animateToPositionWithZoom(LatLng target, double targetZoom) { if (_mapController == null || !_isMapReady || !mounted) return; @@ -541,15 +620,57 @@ class _MapWidgetState extends State { )), duration: Duration(milliseconds: durationMs), ); +<<<<<<< HEAD } /// Zoom to fit a focused ping and its connected repeaters on screen void _zoomToFocusBounds(LatLng pingLocation, List<_ResolvedRepeater> repeaters) { if (_mapController == null || !_isMapReady || !mounted) return; +======= - final points = [pingLocation, ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon))]; + _animationStartPosition = currentCenter; + _animationEndPosition = target; + + _animation!.addListener(() { + if (!mounted || + _animationStartPosition == null || + _animationEndPosition == null) { + return; + } + + // Interpolate between start and end positions + final t = _animation!.value; + final lat = _animationStartPosition!.latitude + + ((_animationEndPosition!.latitude - + _animationStartPosition!.latitude) * + t); + final lng = _animationStartPosition!.longitude + + ((_animationEndPosition!.longitude - + _animationStartPosition!.longitude) * + t); + + // Interpolate zoom + final zoom = currentZoom + ((targetZoom - currentZoom) * t); + + _mapController.move(LatLng(lat, lng), zoom); + }); + + _animationController!.forward(); + } + + /// Zoom to fit a focused ping and its connected repeaters on screen + void _zoomToFocusBounds( + LatLng pingLocation, List<_ResolvedRepeater> repeaters) { + if (!_isMapReady || !mounted) return; +>>>>>>> a431a6a (format with dart) + + final points = [ + pingLocation, + ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon)) + ]; if (points.length < 2) return; +<<<<<<< HEAD // Build bounding box from all points double minLat = points[0].latitude, maxLat = points[0].latitude; double minLon = points[0].longitude, maxLon = points[0].longitude; @@ -563,6 +684,14 @@ class _MapWidgetState extends State { southwest: LatLng(minLat, minLon), northeast: LatLng(maxLat, maxLon), ); +======= + final fitted = CameraFit.coordinates( + coordinates: points, + padding: EdgeInsets.fromLTRB( + 60, 60, 60, MediaQuery.of(context).size.height * 0.4), + maxZoom: 15, + ).fit(_mapController.camera); +>>>>>>> a431a6a (format with dart) final bottomPad = MediaQuery.of(context).size.height * 0.4; _mapController!.animateCamera( @@ -590,6 +719,7 @@ class _MapWidgetState extends State { CameraUpdate.bearingTo(targetHeading), duration: Duration(milliseconds: delta.abs() < 45 ? 300 : 500), ); +<<<<<<< HEAD } /// Produce a reliable heading in degrees (0..360) from successive GPS fixes. @@ -656,12 +786,54 @@ class _MapWidgetState extends State { double? atBearing, ]) { if (_mapController == null || !_isMapReady) return position; +======= + + _rotationAnimation = CurvedAnimation( + parent: _rotationAnimationController!, + curve: Curves.easeInOutCubic, // Smooth acceleration and deceleration + ); + + _rotationStartAngle = currentRotation; + _rotationEndAngle = currentRotation + delta; + + _rotationAnimation!.addListener(() { + if (!mounted || + _rotationStartAngle == null || + _rotationEndAngle == null) { + return; + } + + // Interpolate between start and end angles + final t = _rotationAnimation!.value; + final rotation = _rotationStartAngle! + + ((_rotationEndAngle! - _rotationStartAngle!) * t); + + _mapController.rotate(rotation); + }); + + _rotationAnimationController!.forward(); + } + + /// Offset a lat/lon position by screen pixels (to account for UI overlays) + /// Shifts the map center to keep the GPS marker centered in the visible map area + /// - bottomPadding: shifts center down (portrait mode with bottom panel) + /// - rightPadding: shifts center left (landscape mode with side panel) + LatLng _offsetPositionForPadding(LatLng position, double bottomPadding, + [double rightPadding = 0, double? atZoom]) { + if (!_isMapReady) return position; +>>>>>>> a431a6a (format with dart) if (bottomPadding <= 0 && rightPadding <= 0) return position; // Get meters per pixel at the target zoom (or current camera zoom). // Approx: 40075km / (256 * 2^zoom) at equator, adjusted by cos(lat) +<<<<<<< HEAD final zoom = atZoom ?? _mapController!.cameraPosition?.zoom ?? _defaultZoom; final metersPerPixel = 40075000 / (256 * math.pow(2, zoom)) * +======= + final zoom = atZoom ?? _mapController.camera.zoom; + final metersPerPixel = 40075000 / + (256 * math.pow(2, zoom)) * +>>>>>>> a431a6a (format with dart) math.cos(position.latitude * math.pi / 180); // Start with the offset expressed as if the map were north-up @@ -675,7 +847,13 @@ class _MapWidgetState extends State { } if (rightPadding > 0) { final meterOffset = (rightPadding / 2) * metersPerPixel; +<<<<<<< HEAD lonOffset = -(meterOffset / (111000 * math.cos(position.latitude * math.pi / 180))); +======= + // Longitude degrees per meter varies with latitude + lonOffset = -(meterOffset / + (111000 * math.cos(position.latitude * math.pi / 180))); +>>>>>>> a431a6a (format with dart) } // When the map is rotated, "screen-down" no longer points geographic @@ -698,7 +876,8 @@ class _MapWidgetState extends State { lonOffset = rotatedLon; } - return LatLng(position.latitude + latOffset, position.longitude + lonOffset); + return LatLng( + position.latitude + latOffset, position.longitude + lonOffset); } @override @@ -764,9 +943,14 @@ class _MapWidgetState extends State { if (_autoFollow) { // Auto-follow is on and panel may be open — apply panel offset so // the marker appears centered in the visible map area. - final adjustedPosition = _offsetPositionForPadding(initialPosition, widget.bottomPaddingPixels, widget.rightPaddingPixels, 16.0); + final adjustedPosition = _offsetPositionForPadding( + initialPosition, + widget.bottomPaddingPixels, + widget.rightPaddingPixels, + 16.0); _animateToPositionWithZoom(adjustedPosition, 16.0); - debugLog('[MAP] Initial zoom to GPS position (with panel offset)'); + debugLog( + '[MAP] Initial zoom to GPS position (with panel offset)'); } else { _animateToPositionWithZoom(initialPosition, 16.0); debugLog('[MAP] Initial zoom to GPS position'); @@ -801,6 +985,7 @@ class _MapWidgetState extends State { } WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _autoFollow) { +<<<<<<< HEAD final adjustedPosition = _offsetPositionForPadding( newPosition, widget.bottomPaddingPixels, @@ -813,6 +998,13 @@ class _MapWidgetState extends State { zoom: targetZoom, bearing: targetBearing, ); +======= + // Apply offset for bottom padding when control panel is open + final adjustedPosition = _offsetPositionForPadding(newPosition, + widget.bottomPaddingPixels, widget.rightPaddingPixels); + _animateToPosition( + adjustedPosition); // Smooth animation instead of jump +>>>>>>> a431a6a (format with dart) } }); } @@ -829,7 +1021,8 @@ class _MapWidgetState extends State { // panel offset was computed). Heading mode will begin rotating // on the next GPS update when heading changes. _lastHeading = heading; - debugLog('[MAP] First heading after startup (${heading.toStringAsFixed(1)}°) — stored without rotating'); + debugLog( + '[MAP] First heading after startup (${heading.toStringAsFixed(1)}°) — stored without rotating'); } else if ((heading - _lastHeading!).abs() > 2) { _lastHeading = heading; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -848,11 +1041,13 @@ class _MapWidgetState extends State { // Handle navigation trigger from log screen or graph // Reset map state and navigate to the target location - if (_isMapReady && appState.mapNavigationTrigger != _lastNavigationTrigger) { + if (_isMapReady && + appState.mapNavigationTrigger != _lastNavigationTrigger) { _lastNavigationTrigger = appState.mapNavigationTrigger; final target = appState.mapNavigationTarget; if (target != null) { // Reset map controls to default state +<<<<<<< HEAD _autoFollow = false; // Disable center on GPS _autoFollowDesiredZoom = null; _alwaysNorth = true; // Set to north-up mode @@ -860,6 +1055,12 @@ class _MapWidgetState extends State { _lastHeading = null; // Reset heading tracking _bearingAnchor = null; // Reset derived-heading anchor _computedHeading = null; +======= + _autoFollow = false; // Disable center on GPS + _alwaysNorth = true; // Set to north-up mode + _rotationLocked = false; // Unlock rotation + _lastHeading = null; // Reset heading tracking +>>>>>>> a431a6a (format with dart) // Navigate to the coordinates with close zoom (18 = street level view) // Center directly on target without offset - we want the pin in the middle @@ -880,6 +1081,7 @@ class _MapWidgetState extends State { } } +<<<<<<< HEAD // Sync native annotations whenever marker data changes (provider triggers // a rebuild). The version hash detects changes to ping/repeater counts, // GPS position, focus state, prefs, etc. Native annotations stay in sync @@ -916,6 +1118,10 @@ class _MapWidgetState extends State { } final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; +======= + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; +>>>>>>> a431a6a (format with dart) // Get safe area padding for dynamic island/notch in landscape final safePadding = MediaQuery.of(context).padding; final topPadding = isLandscape ? 16.0 : 8.0; @@ -1006,7 +1212,8 @@ class _MapWidgetState extends State { Widget _buildCollapsibleMapControls(AppStateProvider appState) { // Use external state if provided, otherwise use internal state final isExpanded = widget.mapControlsExpanded ?? _mapControlsExpanded; - final onToggle = widget.onMapControlsToggle ?? () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); + final onToggle = widget.onMapControlsToggle ?? + () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); return Column( mainAxisSize: MainAxisSize.min, @@ -1030,8 +1237,7 @@ class _MapWidgetState extends State { ), ), // Map controls (only when expanded) - below the toggle button - if (isExpanded) - _buildMapControls(appState), + if (isExpanded) _buildMapControls(appState), ], ); } @@ -1126,6 +1332,7 @@ class _MapWidgetState extends State { // listener on `controller.onFeatureTapped` in _onMapCreated // instead — that fires for taps on custom layer features. ), +<<<<<<< HEAD // No widget marker overlay — markers are now native MapLibre // annotations rendered by the platform view itself. ], @@ -1286,6 +1493,149 @@ class _MapWidgetState extends State { _repeaterClusterCountLayerId, _repeaterClusterBubbleLayerId, _repeaterIndividualLayerId, +======= + children: [ + // Tile layer (dynamic based on selected style from preferences) + // Skipped entirely when map tiles are disabled to save mobile data + if (appState.preferences.mapTilesEnabled) + Builder( + builder: (context) { + final mapStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); + return TileLayer( + urlTemplate: mapStyle.urlTemplate, + subdomains: mapStyle.subdomains ?? const [], + userAgentPackageName: 'com.meshmapper.app', + maxZoom: 17, + retinaMode: mapStyle.supportsRetina && + RetinaMode.isHighDensity(context), + tileDisplay: const TileDisplay.fadeIn( + reloadStartOpacity: 1.0, + ), + tileProvider: SilentCancellableNetworkTileProvider(), + ); + }, + ), + + // MeshMapper coverage overlay (only when zone code available, overlay enabled, and tiles enabled) + if (appState.preferences.mapTilesEnabled && + appState.zoneCode != null && + _showMeshMapperOverlay) + TileLayer( + urlTemplate: + 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}${appState.preferences.colorVisionType != 'none' ? '&cvd=${appState.preferences.colorVisionType}' : ''}', + userAgentPackageName: 'com.meshmapper.app', + minZoom: 3, + maxZoom: 17, + tileDisplay: const TileDisplay.fadeIn( + reloadStartOpacity: 1.0, + ), + tileProvider: SilentCancellableNetworkTileProvider(), + ), + + // Coverage markers (TX, RX, DISC, Trace) — sorted by timestamp, newest on top + // During focus mode, the focused marker is excluded and rendered in its own top layer + MarkerLayer( + markers: _buildCoverageMarkers( + txPings: appState.txPings, + rxPings: appState.rxPings, + discEntries: appState.discLogEntries, + discDropEnabled: appState.discDropEnabled, + traceEntries: appState.traceLogEntries, + excludeFocused: _focusedPingLocation != null, + ), + ), + + // Focus mode: polylines from focused ping to each connected repeater + // Line color = SNR (green/yellow/red). Ambiguous matches get a white border. + if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) + PolylineLayer( + polylines: _focusedRepeaters.map((r) { + final lineColor = + r.snr != null ? PingColors.snrColor(r.snr!) : Colors.grey; + return Polyline( + points: [ + _focusedPingLocation!, + LatLng(r.repeater.lat, r.repeater.lon) + ], + color: lineColor.withValues(alpha: 0.9), + strokeWidth: 3.5, + isDotted: true, + borderStrokeWidth: r.ambiguous ? 1.5 : 0, + borderColor: + r.ambiguous ? Colors.white.withValues(alpha: 0.6) : null, + ); + }).toList(), + ), + + // Repeater markers (magenta with ID, rotate with map) + // During focus mode, split into two layers: faded repeaters below, connected on top + if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) ...[ + // Faded non-connected repeaters (below) + MarkerLayer( + rotate: true, + markers: _buildRepeaterMarkers( + appState.repeaters, + appState.enforceHopBytes ? appState.effectiveHopBytes : null, + onlyFaded: true, + ), + ), + // Distance labels (middle) + MarkerLayer( + rotate: true, + markers: _buildFocusDistanceLabels(appState), + ), + // Connected repeaters (on top) + MarkerLayer( + rotate: true, + markers: _buildRepeaterMarkers( + appState.repeaters, + appState.enforceHopBytes ? appState.effectiveHopBytes : null, + onlyConnected: true, + ), + ), + // Focused ping marker (above everything except GPS) + MarkerLayer( + markers: _buildFocusedPingMarker( + txPings: appState.txPings, + rxPings: appState.rxPings, + discEntries: appState.discLogEntries, + discDropEnabled: appState.discDropEnabled, + traceEntries: appState.traceLogEntries, + ), + ), + ] else + // Normal mode: single layer with all repeaters + MarkerLayer( + rotate: true, + markers: _buildRepeaterMarkers( + appState.repeaters, + appState.enforceHopBytes ? appState.effectiveHopBytes : null, + ), + ), + + // Current position marker + if (appState.currentPosition != null) + MarkerLayer( + // Vehicle/boat icons stay upright by counter-rotating against map rotation; + // arrow, walk, and chomper rotate with heading (handled by Transform.rotate in the painter) + rotate: appState.preferences.gpsMarkerStyle != 'arrow' && + appState.preferences.gpsMarkerStyle != 'walk' && + appState.preferences.gpsMarkerStyle != 'chomper', + markers: [ + Marker( + point: LatLng( + appState.currentPosition!.latitude, + appState.currentPosition!.longitude, + ), + width: 48, + height: 48, + child: _buildCurrentPositionMarker( + appState.currentPosition!.heading), + ), + ], + ), +>>>>>>> a431a6a (format with dart) ], null, ); @@ -2670,14 +3020,15 @@ class _MapWidgetState extends State { columnWidths: const { 0: IntrinsicColumnWidth(), // dot 1: IntrinsicColumnWidth(), // ID - 2: FixedColumnWidth(8), // spacer + 2: FixedColumnWidth(8), // spacer 3: IntrinsicColumnWidth(), // SNR }, children: [ for (final r in topRepeaters) _overlayRow(r.repeaterId, r.snr, _overlayTypeColor(r.type)), if (rxSlot != null) - _overlayRow(rxSlot.repeaterId, rxSlot.snr, _overlayTypeColor(OverlayPingType.rx)), + _overlayRow(rxSlot.repeaterId, rxSlot.snr, + _overlayTypeColor(OverlayPingType.rx)), ], ), ], @@ -2710,11 +3061,15 @@ class _MapWidgetState extends State { ), const SizedBox(width: 6), Text( - hasGps ? formatMeters(position.accuracy, isImperial: appState.preferences.isImperial) : 'No GPS', + hasGps + ? formatMeters(position.accuracy, + isImperial: appState.preferences.isImperial) + : 'No GPS', style: TextStyle( fontSize: 11, fontFamily: 'monospace', - color: hasGps ? _getAccuracyColor(position.accuracy) : Colors.grey, + color: + hasGps ? _getAccuracyColor(position.accuracy) : Colors.grey, ), ), // Distance since last TX ping (like wardrive.js) @@ -2727,7 +3082,8 @@ class _MapWidgetState extends State { ), const SizedBox(width: 4), Text( - formatMeters(distanceFromLastPing, isImperial: appState.preferences.isImperial), + formatMeters(distanceFromLastPing, + isImperial: appState.preferences.isImperial), style: const TextStyle( fontSize: 11, fontFamily: 'monospace', @@ -2748,7 +3104,8 @@ class _MapWidgetState extends State { /// Map controls (always vertical, used inside collapsible wrapper) Widget _buildMapControls(AppStateProvider appState) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + final mapStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); return Container( decoration: BoxDecoration( @@ -2770,7 +3127,9 @@ class _MapWidgetState extends State { _buildControlDivider(), _buildControlButton( icon: Icons.layers, - tooltip: _showMeshMapperOverlay ? 'Hide Coverage Overlay' : 'Show Coverage Overlay', + tooltip: _showMeshMapperOverlay + ? 'Hide Coverage Overlay' + : 'Show Coverage Overlay', onPressed: _toggleMeshMapperOverlay, isActive: _showMeshMapperOverlay, ), @@ -2780,14 +3139,17 @@ class _MapWidgetState extends State { _buildControlButton( icon: _autoFollow ? Icons.my_location : Icons.location_searching, tooltip: _autoFollow ? 'Following GPS' : 'Center on Position', - onPressed: appState.currentPosition != null ? _centerOnPosition : null, + onPressed: + appState.currentPosition != null ? _centerOnPosition : null, isActive: _autoFollow, ), _buildControlDivider(), // Always North toggle _buildControlButton( icon: _alwaysNorth ? Icons.navigation : Icons.explore, - tooltip: _alwaysNorth ? 'Always North (Click to Rotate with Heading)' : 'Rotating with Heading (Click for Always North)', + tooltip: _alwaysNorth + ? 'Always North (Click to Rotate with Heading)' + : 'Rotating with Heading (Click for Always North)', onPressed: _toggleNorthMode, isActive: !_alwaysNorth, ), @@ -2849,7 +3211,8 @@ class _MapWidgetState extends State { void _cycleMapStyle(AppStateProvider appState) { const styles = MapStyle.values; - final currentStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + final currentStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); final currentIndex = styles.indexOf(currentStyle); final newStyle = styles[(currentIndex + 1) % styles.length]; appState.setMapStyle(newStyle.name); @@ -2880,6 +3243,7 @@ class _MapWidgetState extends State { _autoFollowDesiredZoom = targetZoom; }); appState.setMapAutoFollow(true); +<<<<<<< HEAD // Bundle target + zoom + bearing into one animation so the // initial centering can't be half-cancelled by a racing GPS tick. final double targetBearing = (!_alwaysNorth && _computedHeading != null) @@ -2898,6 +3262,13 @@ class _MapWidgetState extends State { bearing: targetBearing, durationMs: 500, ); +======= + // Apply offset for bottom padding when control panel is open + final adjustedPosition = _offsetPositionForPadding(targetPosition, + widget.bottomPaddingPixels, widget.rightPaddingPixels); + _animateToPositionWithZoom( + adjustedPosition, 17.0); // Street level zoom when enabling follow +>>>>>>> a431a6a (format with dart) } } @@ -2926,6 +3297,33 @@ class _MapWidgetState extends State { CameraUpdate.bearingTo(0), duration: const Duration(milliseconds: 500), ); +<<<<<<< HEAD +======= + + _rotationAnimation = CurvedAnimation( + parent: _rotationAnimationController!, + curve: Curves.easeInOutCubic, + ); + + _rotationStartAngle = currentRotation; + _rotationEndAngle = 0.0; // North + + _rotationAnimation!.addListener(() { + if (!mounted || + _rotationStartAngle == null || + _rotationEndAngle == null) { + return; + } + + final t = _rotationAnimation!.value; + final rotation = _rotationStartAngle! + + ((_rotationEndAngle! - _rotationStartAngle!) * t); + + _mapController.rotate(rotation); + }); + + _rotationAnimationController!.forward(); +>>>>>>> a431a6a (format with dart) } } else if (!_alwaysNorth && appState.currentPosition != null) { // If switching to heading mode, immediately start rotating to current heading @@ -2956,6 +3354,33 @@ class _MapWidgetState extends State { CameraUpdate.bearingTo(0), duration: const Duration(milliseconds: 500), ); +<<<<<<< HEAD +======= + + _rotationAnimation = CurvedAnimation( + parent: _rotationAnimationController!, + curve: Curves.easeInOutCubic, + ); + + _rotationStartAngle = currentRotation; + _rotationEndAngle = 0.0; // North + + _rotationAnimation!.addListener(() { + if (!mounted || + _rotationStartAngle == null || + _rotationEndAngle == null) { + return; + } + + final t = _rotationAnimation!.value; + final rotation = _rotationStartAngle! + + ((_rotationEndAngle! - _rotationStartAngle!) * t); + + _mapController.rotate(rotation); + }); + + _rotationAnimationController!.forward(); +>>>>>>> a431a6a (format with dart) } } }); @@ -2986,7 +3411,8 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.4)), ), child: const Icon(Icons.map, color: Colors.blue, size: 24), ), @@ -2995,8 +3421,8 @@ class _MapWidgetState extends State { child: Text( 'Legend & Info', style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), ), IconButton( @@ -3018,246 +3444,374 @@ class _MapWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Map Markers section - Text( - 'Map Markers', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildLegendItem( - context: context, - color: PingColors.txSuccessLegend, - label: 'TX', - description: 'Location where you sent a ping and heard a repeater', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.txFail, - label: 'TX', - description: 'Location where you sent a ping but no repeater was heard', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.rx, - label: 'RX', - description: 'Location where you received a message from the mesh', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.discSuccess, - label: 'DISC', - description: 'Location where you sent a discovery request and a repeater responded', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: PingColors.traceSuccess, - label: 'TRC', - description: 'Location where a trace reached the repeater', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), - _buildLegendItem( - context: context, - color: PingColors.discFail, - label: 'DISC', - description: 'Location where you sent a discovery request but no repeater responded', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), - _buildLegendItem( - context: context, - color: PingColors.noResponse, - label: 'TRC', - description: 'Location where a trace got no response', - ), - ], - ), - ), - const SizedBox(height: 20), - - // Coverage Layer section - Text( - 'Coverage Layer', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildLayerItem( - context: context, - color: PingColors.coverageBidir, - label: 'BIDIR', - description: 'Heard repeats from the mesh AND successfully routed through it', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageDisc, - label: 'DISC', - description: 'Wardriving app sent a discovery packet and heard a reply', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageTx, - label: 'TX', - description: 'Successfully routed through, but no repeats heard back', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageRx, - label: 'RX', - description: 'Heard mesh traffic but did not transmit', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageDead, - label: 'DEAD', - description: 'Repeater heard it, but no other radio received the repeat', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: PingColors.coverageDrop, - label: 'DROP', - description: 'No repeats heard AND no successful route', - ), - ], - ), - ), - const SizedBox(height: 20), - - // Sound Notifications section - Text( - 'Sound Notifications', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildSoundItem( - context: context, - icon: Icons.cell_tower, - label: 'TX Sound', - description: 'Plays when sending a ping or discovery request', - onPlay: () { - final appState = context.read(); - appState.audioService.playTransmitSound(); - }, - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildSoundItem( - context: context, - icon: Icons.hearing, - label: 'RX Sound', - description: 'Plays when a repeater echo or mesh message is received', - onPlay: () { - final appState = context.read(); - appState.audioService.playReceiveSound(); - }, - ), - ], - ), - ), - const SizedBox(height: 20), - - // Map Controls section - Text( - 'Map Controls', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildHelpItem( - context: context, - icon: Icons.dark_mode, - label: 'Map Style', - description: 'Cycle between Dark, Light, and Satellite map styles', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.layers, - label: 'Coverage Overlay', - description: 'Toggle MeshMapper coverage overlay showing community-reported mesh coverage', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.my_location, - label: 'Center/Follow', - description: 'Center map on GPS position. Tap again to toggle auto-follow mode', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.navigation, - label: 'Always North', - description: 'Toggle between always-north orientation or rotate with heading', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.sync_disabled, - label: 'Lock Rotation', - description: 'Prevent accidental rotation of the map', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.info_outline, - label: 'Legend & Info', - description: 'Show this help popup with legend and control explanations', - ), - ], - ), - ), + Text( + 'Map Markers', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildLegendItem( + context: context, + color: PingColors.txSuccessLegend, + label: 'TX', + description: + 'Location where you sent a ping and heard a repeater', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.txFail, + label: 'TX', + description: + 'Location where you sent a ping but no repeater was heard', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.rx, + label: 'RX', + description: + 'Location where you received a message from the mesh', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.discSuccess, + label: 'DISC', + description: + 'Location where you sent a discovery request and a repeater responded', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.traceSuccess, + label: 'TRC', + description: + 'Location where a trace reached the repeater', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.2)), + _buildLegendItem( + context: context, + color: PingColors.discFail, + label: 'DISC', + description: + 'Location where you sent a discovery request but no repeater responded', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.2)), + _buildLegendItem( + context: context, + color: PingColors.noResponse, + label: 'TRC', + description: + 'Location where a trace got no response', + ), + ], + ), + ), + const SizedBox(height: 20), + + // Coverage Layer section + Text( + 'Coverage Layer', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildLayerItem( + context: context, + color: PingColors.coverageBidir, + label: 'BIDIR', + description: + 'Heard repeats from the mesh AND successfully routed through it', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDisc, + label: 'DISC', + description: + 'Wardriving app sent a discovery packet and heard a reply', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageTx, + label: 'TX', + description: + 'Successfully routed through, but no repeats heard back', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageRx, + label: 'RX', + description: + 'Heard mesh traffic but did not transmit', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDead, + label: 'DEAD', + description: + 'Repeater heard it, but no other radio received the repeat', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDrop, + label: 'DROP', + description: + 'No repeats heard AND no successful route', + ), + ], + ), + ), + const SizedBox(height: 20), + + // Sound Notifications section + Text( + 'Sound Notifications', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildSoundItem( + context: context, + icon: Icons.cell_tower, + label: 'TX Sound', + description: + 'Plays when sending a ping or discovery request', + onPlay: () { + final appState = + context.read(); + appState.audioService.playTransmitSound(); + }, + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildSoundItem( + context: context, + icon: Icons.hearing, + label: 'RX Sound', + description: + 'Plays when a repeater echo or mesh message is received', + onPlay: () { + final appState = + context.read(); + appState.audioService.playReceiveSound(); + }, + ), + ], + ), + ), + const SizedBox(height: 20), + + // Map Controls section + Text( + 'Map Controls', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildHelpItem( + context: context, + icon: Icons.dark_mode, + label: 'Map Style', + description: + 'Cycle between Dark, Light, and Satellite map styles', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.layers, + label: 'Coverage Overlay', + description: + 'Toggle MeshMapper coverage overlay showing community-reported mesh coverage', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.my_location, + label: 'Center/Follow', + description: + 'Center map on GPS position. Tap again to toggle auto-follow mode', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.navigation, + label: 'Always North', + description: + 'Toggle between always-north orientation or rotate with heading', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.sync_disabled, + label: 'Lock Rotation', + description: + 'Prevent accidental rotation of the map', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.info_outline, + label: 'Legend & Info', + description: + 'Show this help popup with legend and control explanations', + ), + ], + ), + ), ], ), ), @@ -3274,8 +3828,13 @@ class _MapWidgetState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0), - Theme.of(context).colorScheme.surfaceContainerHighest, + Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0), + Theme.of(context) + .colorScheme + .surfaceContainerHighest, ], ), ), @@ -3469,6 +4028,117 @@ class _MapWidgetState extends State { } /// Build a coverage marker child widget based on the user's marker style preference. +<<<<<<< HEAD +======= + Widget _buildCoverageMarkerChild(Color color) { + final style = context.read().preferences.markerStyle; + switch (style) { + case 'circle': + return Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2.0), + boxShadow: const [ + BoxShadow( + color: Colors.black12, blurRadius: 2, offset: Offset(0, 1)) + ], + ), + ); + case 'pin': + return CustomPaint( + size: const Size(20, 20), + painter: _PinMarkerPainter(color), + ); + case 'diamond': + return CustomPaint( + size: const Size(20, 20), + painter: _DiamondMarkerPainter(color), + ); + case 'dot': + default: + return Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withValues(alpha: 0.6), width: 1.5), + boxShadow: const [ + BoxShadow( + color: Colors.black12, blurRadius: 2, offset: Offset(0, 1)) + ], + ), + ); + } + } + + /// Build all coverage dot markers sorted by timestamp (oldest first = drawn underneath). + /// Newer pings always render on top regardless of type. + List _buildCoverageMarkers({ + required List txPings, + required List rxPings, + required List discEntries, + required bool discDropEnabled, + required List traceEntries, + bool excludeFocused = false, + }) { + final timestamped = <(DateTime, Marker)>[ + for (final ping in txPings) + if (!excludeFocused || + !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) + (ping.timestamp, _buildTxMarker(ping)), + for (final ping in rxPings) + if (!excludeFocused || + !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) + (ping.timestamp, _buildRxMarker(ping)), + for (final entry in discEntries) + if (!excludeFocused || + !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) + (entry.timestamp, _buildDiscMarker(entry, discDropEnabled)), + for (final entry in traceEntries) + if (!excludeFocused || + !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) + (entry.timestamp, _buildTraceMarker(entry)), + ]; + + timestamped.sort((a, b) => a.$1.compareTo(b.$1)); + return timestamped.map((e) => e.$2).toList(); + } + + /// Build just the focused ping marker for rendering in its own top layer. + List _buildFocusedPingMarker({ + required List txPings, + required List rxPings, + required List discEntries, + required bool discDropEnabled, + required List traceEntries, + }) { + if (_focusedPingLocation == null) return []; + + for (final ping in txPings) { + if (_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) { + return [_buildTxMarker(ping)]; + } + } + for (final ping in rxPings) { + if (_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) { + return [_buildRxMarker(ping)]; + } + } + for (final entry in discEntries) { + if (_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) { + return [_buildDiscMarker(entry, discDropEnabled)]; + } + } + for (final entry in traceEntries) { + if (_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) { + return [_buildTraceMarker(entry)]; + } + } + return []; + } + +>>>>>>> a431a6a (format with dart) /// Check if a ping at given lat/lon/timestamp is the currently focused ping. /// Used by the native annotation sync to apply focus-mode styling (size, /// opacity) to the focused ping vs other pings. @@ -3479,6 +4149,83 @@ class _MapWidgetState extends State { _focusedPingLocation!.longitude == lon; } +<<<<<<< HEAD +======= + /// Apply focus fade to a marker color. Returns dimmed color if focus is active + /// and this marker is not the focused one. + Color _applyFocusFade(Color color, bool isFocused) { + if (_focusedPingLocation == null || isFocused) return color; + return color.withValues(alpha: 0.15); + } + + Marker _buildTxMarker(TxPing ping) { + final isFocused = + _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); + final color = + ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess; + final size = isFocused ? 24.0 : 20.0; + return Marker( + point: LatLng(ping.latitude, ping.longitude), + width: size, + height: size, + child: GestureDetector( + onTap: () => _showTxPingDetails(ping), + child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), + ), + ); + } + + Marker _buildRxMarker(RxPing ping) { + final isFocused = + _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); + final size = isFocused ? 24.0 : 20.0; + return Marker( + point: LatLng(ping.latitude, ping.longitude), + width: size, + height: size, + child: GestureDetector( + onTap: () => _showRxPingDetails(ping), + child: _buildCoverageMarkerChild( + _applyFocusFade(PingColors.rx, isFocused)), + ), + ); + } + + Marker _buildDiscMarker(DiscLogEntry entry, bool discDropEnabled) { + final isFocused = + _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); + final color = entry.nodeCount == 0 + ? (discDropEnabled ? PingColors.txFail : PingColors.discFail) + : _discMarkerColor; + final size = isFocused ? 24.0 : 20.0; + return Marker( + point: LatLng(entry.latitude, entry.longitude), + width: size, + height: size, + child: GestureDetector( + onTap: () => _showDiscPingDetails(entry), + child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), + ), + ); + } + + Marker _buildTraceMarker(TraceLogEntry entry) { + final isFocused = + _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); + final color = entry.success ? Colors.cyan : Colors.grey; + final size = isFocused ? 24.0 : 20.0; + return Marker( + point: LatLng(entry.latitude, entry.longitude), + width: size, + height: size, + child: GestureDetector( + onTap: () => _showTraceDetails(entry), + child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), + ), + ); + } + +>>>>>>> a431a6a (format with dart) void _showTraceDetails(TraceLogEntry entry) { // Activate focus mode for successful traces with a known repeater if (entry.success) { @@ -3487,7 +4234,8 @@ class _MapWidgetState extends State { snrValues: [entry.localSnr], ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); } } @@ -3508,7 +4256,8 @@ class _MapWidgetState extends State { ), child: SingleChildScrollView( child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -3521,9 +4270,11 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Colors.cyan.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.cyan.withValues(alpha: 0.4)), + border: Border.all( + color: Colors.cyan.withValues(alpha: 0.4)), ), - child: const Icon(Icons.gps_fixed, color: Colors.cyan, size: 24), + child: const Icon(Icons.gps_fixed, + color: Colors.cyan, size: 24), ), const SizedBox(width: 12), Expanded( @@ -3532,15 +4283,20 @@ class _MapWidgetState extends State { children: [ Text( 'Trace', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(entry.timestamp), style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], @@ -3558,15 +4314,23 @@ class _MapWidgetState extends State { // Location chip Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -3603,13 +4367,18 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -3619,7 +4388,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -3630,7 +4401,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -3641,7 +4414,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -3652,14 +4427,17 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data row Builder(builder: (context) { final localSnr = entry.localSnr ?? 0; @@ -3668,15 +4446,24 @@ class _MapWidgetState extends State { final rxSnrColor = PingColors.snrColor(localSnr); final rssiColor = PingColors.rssiColor(localRssi); - final txSnrColor = PingColors.snrColor(remoteSnr.toDouble()); + final txSnrColor = + PingColors.snrColor(remoteSnr.toDouble()); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.targetRepeaterId, fromLatLng: (lat: entry.latitude, lon: entry.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, entry.targetRepeaterId, fromLatLng: ( + lat: entry.latitude, + lon: entry.longitude + )), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ - RepeaterIdChip(repeaterId: entry.targetRepeaterId, fontSize: 13, width: _nodeColumnWidth()), + RepeaterIdChip( + repeaterId: entry.targetRepeaterId, + fontSize: 13, + width: _nodeColumnWidth()), // RX SNR Expanded( child: Center( @@ -3721,6 +4508,56 @@ class _MapWidgetState extends State { ).whenComplete(() => _dismissPingFocus()); } +<<<<<<< HEAD +======= + /// Build distance label markers at the midpoint of each focus line. + List _buildFocusDistanceLabels(AppStateProvider appState) { + if (_focusedPingLocation == null) return []; + final isImperial = appState.preferences.isImperial; + final ping = _focusedPingLocation!; + + return _focusedRepeaters.map((r) { + final repeaterPos = LatLng(r.repeater.lat, r.repeater.lon); + // Midpoint of the line + final midLat = (ping.latitude + repeaterPos.latitude) / 2; + final midLon = (ping.longitude + repeaterPos.longitude) / 2; + // Distance in meters — use GpsService for consistency with repeater popup + final meters = GpsService.distanceBetween( + ping.latitude, + ping.longitude, + repeaterPos.latitude, + repeaterPos.longitude, + ); + final label = meters < 1000 + ? formatMeters(meters, isImperial: isImperial) + : formatKilometers(meters / 1000, isImperial: isImperial); + + return Marker( + point: LatLng(midLat, midLon), + width: 70, + height: 22, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Colors.white, + ), + ), + ), + ); + }).toList(); + } + +>>>>>>> a431a6a (format with dart) /// DISC marker color (delegates to active palette) static Color get _discMarkerColor => PingColors.discSuccess; @@ -3754,7 +4591,8 @@ class _MapWidgetState extends State { ? fullHex.substring(0, 8) : hexIds[i]; final matches = allRepeaters - .where((r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) + .where( + (r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) .toList(); final ambiguous = matches.length > 1; resolved.addAll(matches.map((r) => _ResolvedRepeater(r, snr, ambiguous))); @@ -3763,10 +4601,17 @@ class _MapWidgetState extends State { } /// Activate ping focus mode — draw lines, fade markers, zoom to fit. +<<<<<<< HEAD void _activatePingFocus(LatLng pingLocation, DateTime timestamp, List<_ResolvedRepeater> repeaters) { final pos = _mapController?.cameraPosition; _preFocusCenter = pos?.target; _preFocusZoom = pos?.zoom; +======= + void _activatePingFocus(LatLng pingLocation, DateTime timestamp, + List<_ResolvedRepeater> repeaters) { + _preFocusCenter = _mapController.camera.center; + _preFocusZoom = _mapController.camera.zoom; +>>>>>>> a431a6a (format with dart) _wasAutoFollowBeforeFocus = _autoFollow; _wasRotatingBeforeFocus = !_alwaysNorth; @@ -3865,10 +4710,7 @@ class _MapWidgetState extends State { for (final repeater in repeaters) { idCounts[repeater.id] = (idCounts[repeater.id] ?? 0) + 1; } - return idCounts.entries - .where((e) => e.value > 1) - .map((e) => e.key) - .toSet(); + return idCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet(); } /// Get marker color for a repeater based on status priority: @@ -3883,11 +4725,146 @@ class _MapWidgetState extends State { return _repeaterMarkerColor; // Active (default) } +<<<<<<< HEAD +======= + List _buildRepeaterMarkers( + List repeaters, + int? regionHopBytesOverride, { + bool onlyFaded = false, + bool onlyConnected = false, + }) { + final duplicateIds = _getDuplicateRepeaterIds(repeaters); + final hasFocus = _focusedPingLocation != null; + + return repeaters.where((repeater) { + if (!hasFocus) return true; // No focus — include all + final isConnected = + _focusedRepeaters.any((r) => r.repeater.id == repeater.id); + if (onlyConnected) return isConnected; + if (onlyFaded) return !isConnected; + return true; + }).map((repeater) { + final isDuplicate = duplicateIds.contains(repeater.id); + final markerColor = _getRepeaterMarkerColor(repeater, isDuplicate); + + // During focus mode, fade repeaters not connected to the focused ping + final isConnected = hasFocus && + _focusedRepeaters.any((r) => r.repeater.id == repeater.id); + final effectiveColor = (hasFocus && !isConnected) + ? markerColor.withValues(alpha: 0.15) + : markerColor; + final effectiveBorderColor = (hasFocus && !isConnected) + ? Colors.white.withValues(alpha: 0.15) + : Colors.white; + final effectiveTextColor = (hasFocus && !isConnected) + ? Colors.white.withValues(alpha: 0.15) + : Colors.white; + + // Display hex ID based on per-repeater hop_bytes (or regional admin override) + final displayId = + repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); + final effectiveBytes = regionHopBytesOverride ?? repeater.hopBytes; + final isLongId = displayId.length > 2; + final markerWidth = displayId.length > 4 + ? 48.0 + : isLongId + ? 40.0 + : 28.0; + + // Shape varies by hop bytes: 1=square, 2=rounded rect, 3=more rounded + final borderRadius = effectiveBytes >= 3 + ? BorderRadius.circular(8) + : effectiveBytes == 2 + ? BorderRadius.circular(6) + : BorderRadius.circular(4); + + return Marker( + point: LatLng(repeater.lat, repeater.lon), + width: markerWidth, + height: 28, + child: GestureDetector( + onTap: () => _showRepeaterDetails(repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: regionHopBytesOverride), + child: Container( + padding: isLongId + ? const EdgeInsets.symmetric(horizontal: 4) + : EdgeInsets.zero, + decoration: BoxDecoration( + color: effectiveColor, + borderRadius: borderRadius, + border: Border.all(color: effectiveBorderColor, width: 2), + boxShadow: (hasFocus && !isConnected) + ? null + : const [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + alignment: Alignment.center, + child: Text( + displayId, + style: TextStyle( + fontSize: displayId.length > 4 + ? 8 + : isLongId + ? 9 + : 10, + fontWeight: FontWeight.bold, + color: effectiveTextColor, + fontFamily: 'monospace', + ), + ), + ), + ), + ); + }).toList(); + } + + Widget _buildCurrentPositionMarker(double heading) { + // Convert heading from degrees to radians + // heading is 0-360 degrees, 0 = North, 90 = East + final headingRadians = heading * (math.pi / 180); + final style = context.read().preferences.gpsMarkerStyle; + + // Arrow, walk, and chomper rotate with heading; vehicle/boat icons don't (they face up) + final shouldRotate = + style == 'arrow' || style == 'walk' || style == 'chomper'; + + final CustomPainter painter; + switch (style) { + case 'car': + painter = const _CarMarkerPainter(); + case 'bike': + painter = const _BikeMarkerPainter(); + case 'boat': + painter = const _BoatMarkerPainter(); + case 'walk': + painter = const _WalkMarkerPainter(); + case 'chomper': + painter = const _ChomperMarkerPainter(); + case 'arrow': + default: + painter = const _ArrowPainter(); + } + + final child = CustomPaint(size: const Size(24, 24), painter: painter); + return shouldRotate + ? Transform.rotate(angle: headingRadians, child: child) + : child; + } + +>>>>>>> a431a6a (format with dart) /// Compute node column width based on hop byte count. /// [extraPadding] adds space for additional content (e.g. nodeTypeLabel in DISC popup). double _nodeColumnWidth({double extraPadding = 0}) { final appState = context.read(); - final hopBytes = appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes; + final hopBytes = appState.enforceHopBytes + ? appState.effectiveHopBytes + : appState.hopBytes; switch (hopBytes) { case 2: return 70 + extraPadding; @@ -3910,7 +4887,8 @@ class _MapWidgetState extends State { snrValues: heardRepeaters.map((r) => r.snr).toList(), ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); } } @@ -3931,7 +4909,8 @@ class _MapWidgetState extends State { ), child: SingleChildScrollView( child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -3945,9 +4924,11 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: PingColors.txSuccess.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: PingColors.txSuccess.withValues(alpha: 0.4)), + border: Border.all( + color: PingColors.txSuccess.withValues(alpha: 0.4)), ), - child: Icon(Icons.arrow_upward, color: PingColors.txSuccess, size: 24), + child: Icon(Icons.arrow_upward, + color: PingColors.txSuccess, size: 24), ), const SizedBox(width: 12), Expanded( @@ -3956,15 +4937,20 @@ class _MapWidgetState extends State { children: [ Text( 'TX Ping', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(ping.timestamp), style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], @@ -3982,15 +4968,23 @@ class _MapWidgetState extends State { // Location chip Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4009,7 +5003,9 @@ class _MapWidgetState extends State { // Repeaters section header Text( - heardRepeaters.isEmpty ? 'No repeaters heard' : 'Heard Repeaters (${heardRepeaters.length})', + heardRepeaters.isEmpty + ? 'No repeaters heard' + : 'Heard Repeaters (${heardRepeaters.length})', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -4025,13 +5021,18 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -4041,7 +5042,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4052,7 +5055,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4063,32 +5068,49 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data rows ...heardRepeaters.map((repeater) { - final snrColor = repeater.snr != null ? PingColors.snrColor(repeater.snr!) : Colors.grey; - final rssiColor = repeater.rssi != null ? PingColors.rssiColor(repeater.rssi!) : Colors.grey; + final snrColor = repeater.snr != null + ? PingColors.snrColor(repeater.snr!) + : Colors.grey; + final rssiColor = repeater.rssi != null + ? PingColors.rssiColor(repeater.rssi!) + : Colors.grey; return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId, fromLatLng: (lat: ping.latitude, lon: ping.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, repeater.repeaterId, fromLatLng: ( + lat: ping.latitude, + lon: ping.longitude + )), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 13, width: _nodeColumnWidth()), + RepeaterIdChip( + repeaterId: repeater.repeaterId, + fontSize: 13, + width: _nodeColumnWidth()), // SNR Expanded( child: Center( child: _buildStatChip( - value: repeater.snr?.toStringAsFixed(1) ?? '-', + value: + repeater.snr?.toStringAsFixed(1) ?? + '-', color: snrColor, ), ), @@ -4097,7 +5119,9 @@ class _MapWidgetState extends State { Expanded( child: Center( child: _buildStatChip( - value: repeater.rssi != null ? '${repeater.rssi}' : '-', + value: repeater.rssi != null + ? '${repeater.rssi}' + : '-', color: rssiColor, ), ), @@ -4130,7 +5154,8 @@ class _MapWidgetState extends State { snrValues: [ping.snr], ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); } showModalBottomSheet( @@ -4144,7 +5169,8 @@ class _MapWidgetState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -4158,9 +5184,11 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.4)), ), - child: const Icon(Icons.arrow_downward, color: Colors.blue, size: 24), + child: const Icon(Icons.arrow_downward, + color: Colors.blue, size: 24), ), const SizedBox(width: 12), Expanded( @@ -4170,8 +5198,8 @@ class _MapWidgetState extends State { Text( 'RX Ping', style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(ping.timestamp), @@ -4199,11 +5227,17 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4237,13 +5271,18 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -4253,7 +5292,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4264,7 +5305,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4275,7 +5318,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4285,13 +5330,19 @@ class _MapWidgetState extends State { Divider(height: 1, color: Theme.of(context).dividerColor), // Data row InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, ping.repeaterId, fromLatLng: (lat: ping.latitude, lon: ping.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, ping.repeaterId, + fromLatLng: (lat: ping.latitude, lon: ping.longitude)), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ // Repeater ID - RepeaterIdChip(repeaterId: ping.repeaterId, fontSize: 13, width: _nodeColumnWidth()), + RepeaterIdChip( + repeaterId: ping.repeaterId, + fontSize: 13, + width: _nodeColumnWidth()), // SNR Expanded( child: Center( @@ -4306,12 +5357,12 @@ class _MapWidgetState extends State { child: Center( child: _buildStatChip( value: '${ping.rssi}', - color: rssiColor, + color: rssiColor, + ), ), ), - ), - ], - ), + ], + ), ), ), ], @@ -4330,10 +5381,12 @@ class _MapWidgetState extends State { final resolved = _resolveRepeatersByHexIds( entry.discoveredNodes.map((n) => n.repeaterId).toList(), fullHexIds: entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), - snrValues: entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), + snrValues: + entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), ); if (resolved.isNotEmpty) { - _activatePingFocus(LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); } } @@ -4354,7 +5407,8 @@ class _MapWidgetState extends State { ), child: SingleChildScrollView( child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -4368,9 +5422,11 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: _discMarkerColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), + border: Border.all( + color: _discMarkerColor.withValues(alpha: 0.4)), ), - child: Icon(Icons.radar, color: _discMarkerColor, size: 24), + child: + Icon(Icons.radar, color: _discMarkerColor, size: 24), ), const SizedBox(width: 12), Expanded( @@ -4379,15 +5435,20 @@ class _MapWidgetState extends State { children: [ Text( 'Disc Request', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(entry.timestamp), style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], @@ -4405,15 +5466,23 @@ class _MapWidgetState extends State { // Location chip Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4450,13 +5519,18 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), child: Row( children: [ SizedBox( @@ -4466,7 +5540,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4477,7 +5553,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4488,7 +5566,9 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -4499,24 +5579,36 @@ class _MapWidgetState extends State { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), + Divider( + height: 1, color: Theme.of(context).dividerColor), // Data rows ...entry.discoveredNodes.map((node) { final rxSnrColor = PingColors.snrColor(node.localSnr); - final rssiColor = PingColors.rssiColor(node.localRssi); - final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); + final rssiColor = + PingColors.rssiColor(node.localRssi); + final txSnrColor = + PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex, fromLatLng: (lat: entry.latitude, lon: entry.longitude)), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, node.repeaterId, + fullHexId: node.pubkeyHex, + fromLatLng: ( + lat: entry.latitude, + lon: entry.longitude + )), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), child: Row( children: [ // Node ID with type @@ -4524,7 +5616,9 @@ class _MapWidgetState extends State { width: _nodeColumnWidth(extraPadding: 20), child: Row( children: [ - RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), + RepeaterIdChip( + repeaterId: node.repeaterId, + fontSize: 13), Text( node.nodeTypeLabel, style: TextStyle( @@ -4558,7 +5652,8 @@ class _MapWidgetState extends State { Expanded( child: Center( child: _buildStatChip( - value: node.remoteSnr.toStringAsFixed(1), + value: + node.remoteSnr.toStringAsFixed(1), color: txSnrColor, ), ), @@ -4601,7 +5696,8 @@ class _MapWidgetState extends State { } /// Show repeater details popup - void _showRepeaterDetails(Repeater repeater, {bool isDuplicate = false, int? regionHopBytesOverride}) { + void _showRepeaterDetails(Repeater repeater, + {bool isDuplicate = false, int? regionHopBytesOverride}) { // Determine icon badge color based on primary status final iconColor = _getRepeaterMarkerColor(repeater, isDuplicate); @@ -4627,7 +5723,8 @@ class _MapWidgetState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -4637,7 +5734,8 @@ class _MapWidgetState extends State { children: [ // Icon badge with hex ID (mirrors map marker) Builder(builder: (context) { - final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); + final displayId = repeater.displayHexId( + overrideHopBytes: regionHopBytesOverride); final isLongId = displayId.length > 2; return Container( constraints: const BoxConstraints(minWidth: 44), @@ -4667,8 +5765,8 @@ class _MapWidgetState extends State { child: Text( repeater.name, style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -4688,7 +5786,8 @@ class _MapWidgetState extends State { Row( children: [ if (isDuplicate) ...[ - _buildRepeaterStatusChip('Duplicate', _repeaterDuplicateColor), + _buildRepeaterStatusChip( + 'Duplicate', _repeaterDuplicateColor), const SizedBox(width: 8), ], _buildRepeaterStatusChip(statusLabel, statusColor), @@ -4702,14 +5801,21 @@ class _MapWidgetState extends State { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ // Location row Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4727,7 +5833,10 @@ class _MapWidgetState extends State { // Last heard row Row( children: [ - Icon(Icons.access_time, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.access_time, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -4907,7 +6016,13 @@ class _BikeMarkerPainter extends CustomPainter { ..lineTo(rightWheel.dx, rightWheel.dy) // Down to rear ..moveTo(cx, cy - 5) ..lineTo(cx + 2, cy - 7); // Handlebar - canvas.drawPath(framePath, Paint()..color = Colors.white..style = PaintingStyle.stroke..strokeWidth = 3.5..strokeCap = StrokeCap.round); + canvas.drawPath( + framePath, + Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5 + ..strokeCap = StrokeCap.round); // Blue wheels canvas.drawCircle(leftWheel, wheelR, bikePaint); @@ -4961,11 +6076,21 @@ class _BoatMarkerPainter extends CustomPainter { canvas.drawPath(hull, fillPaint); // Mast outline - canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), - Paint()..color = Colors.white..strokeWidth = 3..strokeCap = StrokeCap.round); + canvas.drawLine( + Offset(cx, cy + 2), + Offset(cx, cy - 9), + Paint() + ..color = Colors.white + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round); // Mast - canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), - Paint()..color = const Color(0xFF2196F3)..strokeWidth = 1.5..strokeCap = StrokeCap.round); + canvas.drawLine( + Offset(cx, cy + 2), + Offset(cx, cy - 9), + Paint() + ..color = const Color(0xFF2196F3) + ..strokeWidth = 1.5 + ..strokeCap = StrokeCap.round); // Sail outline final sailOutline = ui.Path() @@ -4981,7 +6106,11 @@ class _BoatMarkerPainter extends CustomPainter { ..lineTo(cx + 6, cy - 0.5) ..lineTo(cx + 1, cy - 0.5) ..close(); - canvas.drawPath(sail, Paint()..color = const Color(0xFF64B5F6)..style = PaintingStyle.fill); + canvas.drawPath( + sail, + Paint() + ..color = const Color(0xFF64B5F6) + ..style = PaintingStyle.fill); } @override @@ -5014,7 +6143,12 @@ class _WalkMarkerPainter extends CustomPainter { ..style = PaintingStyle.fill; // Head outline + fill - canvas.drawCircle(Offset(cx, cy - 7), 3.5, Paint()..color = Colors.white..style = PaintingStyle.fill); + canvas.drawCircle( + Offset(cx, cy - 7), + 3.5, + Paint() + ..color = Colors.white + ..style = PaintingStyle.fill); canvas.drawCircle(Offset(cx, cy - 7), 2.5, fillPaint); // Body outline @@ -5023,9 +6157,11 @@ class _WalkMarkerPainter extends CustomPainter { canvas.drawLine(Offset(cx, cy - 4), Offset(cx, cy + 3), personPaint); // Arms outline - canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), outlinePaint); + canvas.drawLine( + Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), outlinePaint); // Arms - canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), personPaint); + canvas.drawLine( + Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), personPaint); // Left leg outline canvas.drawLine(Offset(cx, cy + 3), Offset(cx - 4, cy + 10), outlinePaint); @@ -5105,7 +6241,9 @@ class _PinMarkerPainter extends CustomPainter { final cx = size.width / 2; final cy = size.height / 2; - final fillPaint = Paint()..color = color..style = PaintingStyle.fill; + final fillPaint = Paint() + ..color = color + ..style = PaintingStyle.fill; final outlinePaint = Paint() ..color = Colors.white.withValues(alpha: 0.8) ..style = PaintingStyle.stroke @@ -5141,11 +6279,13 @@ class _PinMarkerPainter extends CustomPainter { canvas.drawCircle(headCenter, headRadius, outlinePaint); // Inner dot - canvas.drawCircle(headCenter, 2.0, Paint()..color = Colors.white.withValues(alpha: 0.9)); + canvas.drawCircle( + headCenter, 2.0, Paint()..color = Colors.white.withValues(alpha: 0.9)); } @override - bool shouldRepaint(covariant _PinMarkerPainter oldDelegate) => oldDelegate.color != color; + bool shouldRepaint(covariant _PinMarkerPainter oldDelegate) => + oldDelegate.color != color; } /// Paints a diamond marker for coverage dots @@ -5185,7 +6325,8 @@ class _DiamondMarkerPainter extends CustomPainter { } @override - bool shouldRepaint(covariant _DiamondMarkerPainter oldDelegate) => oldDelegate.color != color; + bool shouldRepaint(covariant _DiamondMarkerPainter oldDelegate) => + oldDelegate.color != color; } /// Paints a repeater marker shape (filled colored rounded box with white border @@ -5364,7 +6505,9 @@ class _SoundItemWidgetState extends State<_SoundItemWidget> { : Colors.blue.withValues(alpha: 0.2), shape: BoxShape.circle, border: Border.all( - color: _isPlaying ? Colors.blue : Colors.blue.withValues(alpha: 0.5), + color: _isPlaying + ? Colors.blue + : Colors.blue.withValues(alpha: 0.5), width: _isPlaying ? 2 : 1, ), ), diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index 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, ), From a7cfd44a748eb5c117f9f4452857643ad253ac53 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 15 Apr 2026 21:43:42 -0400 Subject: [PATCH 06/10] Fix power level hint directing users to Settings instead of Connect tab --- Build.sh | 1 + ios/Podfile.lock | 2 +- ios/Runner/Info.plist | 2 ++ lib/widgets/ping_controls.dart | 2 +- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Build.sh b/Build.sh index 202b62c..f682816 100755 --- a/Build.sh +++ b/Build.sh @@ -153,6 +153,7 @@ echo "" # Build iOS IPA echo "[3/3] Building iOS IPA..." +(cd ios && pod install) flutter build ipa --release --build-name="$VERSION_NUMBER" --build-number="$EPOCH" --dart-define="APP_VERSION=$APP_VERSION" --dart-define="API_KEY=$MESHMAPPER_API_KEY" cp build/ios/ipa/mesh_mapper.ipa "$IOS_DIR/MeshMapper-$FILE_TAG.ipa" echo "✓ Built: MeshMapper-$FILE_TAG.ipa" diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ebb426d..21b9577 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -29,6 +29,6 @@ SPEC CHECKSUMS: flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d -PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e +PODFILE CHECKSUM: 5f6d31cc7a922ccb43b951411657266fcae3377c COCOAPODS: 1.16.2 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index bdea062..251402f 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -32,6 +32,8 @@ NSBluetoothAlwaysUsageDescription This app needs Bluetooth to connect to MeshCore devices for wardriving + NSCameraUsageDescription + This app does not use the camera. This entry is required by a file picker library. NSBluetoothPeripheralUsageDescription This app needs Bluetooth to connect to MeshCore devices for wardriving NSLocationAlwaysAndWhenInUseUsageDescription diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index b20aee8..f080ac0 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -79,7 +79,7 @@ class PingControls extends StatelessWidget { blockingIcon = Icons.settings_input_antenna; blockingColor = Colors.orange; } else if (!isPowerSet) { - blockingHint = 'Select power level in Settings'; + blockingHint = 'Select power level in Connect tab'; blockingIcon = Icons.bolt; blockingColor = Colors.orange; } else if (validation == PingValidation.noGpsLock) { From 1f4a0fdbe0026ef4b3f9febdf0b589772db7f104 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 15 Apr 2026 22:15:45 -0400 Subject: [PATCH 07/10] =?UTF-8?q?Fix=20power=20level=20hint:=20Settings=20?= =?UTF-8?q?=E2=86=92=20Connect=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/widgets/ping_controls.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index fe44f97..3f76b67 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -79,7 +79,7 @@ class PingControls extends StatelessWidget { blockingIcon = Icons.settings_input_antenna; blockingColor = Colors.orange; } else if (!isPowerSet) { - blockingHint = 'Select power level in Settings'; + blockingHint = 'Select power level in Connect tab'; blockingIcon = Icons.bolt; blockingColor = Colors.orange; } else if (validation == PingValidation.noGpsLock) { From 26cf1fa087dd4c1430757424378f2b50a0545da8 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Thu, 16 Apr 2026 08:26:17 -0700 Subject: [PATCH 08/10] Center download selection map on user last known or current location clean up small issues --- lib/screens/offline_maps_screen.dart | 35 +++++++++++++++++++++------ lib/services/offline_map_service.dart | 1 - 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart index ca4ac1e..65619bb 100644 --- a/lib/screens/offline_maps_screen.dart +++ b/lib/screens/offline_maps_screen.dart @@ -449,7 +449,7 @@ class _OfflineMapsScreenState extends State { if (result != null) { await service.setStorageLimit(result); - if (mounted) { + if (context.mounted) { AppToast.success(context, 'Storage limit set to $result MB'); } } @@ -469,7 +469,7 @@ class _OfflineMapsScreenState extends State { // Toast is handled by the _onServiceUpdate listener when the download // completes (which may happen long after this page returns). - if (started == true && mounted) { + if (started == true && context.mounted) { AppToast.simple( context, 'Download started — check notifications for progress'); } @@ -506,7 +506,7 @@ class _OfflineMapsScreenState extends State { if (confirmed == true) { final success = await service.deleteRegion(region.id); - if (mounted) { + if (context.mounted) { if (success) { AppToast.success(context, '"${region.name}" deleted'); } else { @@ -543,7 +543,7 @@ class _OfflineMapsScreenState extends State { if (confirmed == true) { await service.deleteAllRegions(); - if (mounted) { + if (context.mounted) { AppToast.success(context, 'All regions deleted'); } } @@ -612,10 +612,13 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { final _nameController = TextEditingController(); String _selectedStyle = 'Liberty'; double _minZoom = 6; - double _maxZoom = 14; + double _maxZoom = 15; bool _submitting = false; String? _error; + // Default center (Ottawa) + static const LatLng _defaultCenter = LatLng(45.4215, -75.6972); + // Bounds selection via interactive map MapLibreMapController? _mapController; LatLng? _boundsNE; @@ -670,9 +673,24 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { @override Widget build(BuildContext context) { + final appState = context.watch(); final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; + // Determine map center - prefer current GPS, fallback to last known, then Ottawa + LatLng center = _defaultCenter; + if (appState.currentPosition != null) { + center = LatLng( + appState.currentPosition!.latitude, + appState.currentPosition!.longitude, + ); + } else if (appState.lastKnownPosition != null) { + center = LatLng( + appState.lastKnownPosition!.lat, + appState.lastKnownPosition!.lon, + ); + } + return Scaffold( appBar: AppBar( toolbarHeight: 40, @@ -687,8 +705,8 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { children: [ MapLibreMap( styleString: _downloadStyles[_selectedStyle]!, - initialCameraPosition: const CameraPosition( - target: LatLng(49.28, -123.12), // Vancouver default + initialCameraPosition: CameraPosition( + target: center, // Vancouver default zoom: 10, ), onMapCreated: (controller) { @@ -1019,8 +1037,9 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { } Future _drawBoundsOverlay() async { - if (_mapController == null || _boundsSW == null || _boundsNE == null) + if (_mapController == null || _boundsSW == null || _boundsNE == null) { return; + } final sw = _boundsSW!; final ne = _boundsNE!; diff --git a/lib/services/offline_map_service.dart b/lib/services/offline_map_service.dart index 6f5d3a6..8040866 100644 --- a/lib/services/offline_map_service.dart +++ b/lib/services/offline_map_service.dart @@ -160,7 +160,6 @@ class OfflineMapService extends ChangeNotifier { notifyListeners(); } catch (e) { debugPrint('[OFFLINE_MAP] Init error: $e'); - _initialized = true; notifyListeners(); } } From e8a46f125c0eca8f01ecf6fe62cb323481d4e3eb Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Thu, 16 Apr 2026 10:09:55 -0700 Subject: [PATCH 09/10] update latest deps --- pubspec.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 243980a..17e8082 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -724,18 +724,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -1153,10 +1153,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: transitive description: From e57ddf79983c5f7f2f307773c8424ef20eac86b2 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Thu, 16 Apr 2026 10:13:24 -0700 Subject: [PATCH 10/10] clean up missing braces comment out unused _blankStyleJson URL --- lib/providers/app_state_provider.dart | 9 +++-- lib/screens/log_screen.dart | 10 +++-- lib/screens/settings_screen.dart | 12 ++++-- lib/services/api_service.dart | 3 +- lib/services/bluetooth/mobile_bluetooth.dart | 3 +- lib/widgets/map_widget.dart | 7 ++-- lib/widgets/noise_floor_chart.dart | 9 +++-- lib/widgets/ping_controls.dart | 42 +++++++++++++------- 8 files changed, 63 insertions(+), 32 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 9c14b96..4ecc88f 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3316,8 +3316,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { message: message, severity: severity, )); - if (_errorLogEntries.length > _maxErrorEntries) + if (_errorLogEntries.length > _maxErrorEntries) { _errorLogEntries.removeAt(0); + } if (autoSwitch) { _requestErrorLogSwitch = true; // Auto-switch to error log } @@ -3774,8 +3775,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Periodically auto-save offline pings to prevent data loss from app kill. /// Uses a non-destructive snapshot so in-memory accumulation continues. void _autoSaveOfflinePings() { - if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) + if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) { return; + } final pings = _apiQueueService.getOfflinePingsSnapshot(); if (pings.isEmpty) return; @@ -4298,8 +4300,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Play disconnect alert if enabled (triple beep for unexpected ping stop) void _playDisconnectAlert() { - if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) + if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) { return; + } debugLog('[AUDIO] Playing disconnect alert — pinging stopped unexpectedly'); _audioService.playAlertSound(); } diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index d95172c..425a0a1 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -354,8 +354,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { for (final event in tx.events) { if (event.repeaterId.toLowerCase().startsWith(query)) return true; final resolved = _resolveRepeaterNames(event.repeaterId, repeaters); - if (resolved.names.any((n) => n.toLowerCase().contains(query))) + if (resolved.names.any((n) => n.toLowerCase().contains(query))) { return true; + } } return false; case PingLogType.rx: @@ -368,10 +369,13 @@ class _AllPingsTabState extends State<_AllPingsTab> { for (final node in disc.discoveredNodes) { if (node.repeaterId.toLowerCase().startsWith(query)) return true; if (node.pubkeyHex != null && - node.pubkeyHex!.toLowerCase().startsWith(query)) return true; + node.pubkeyHex!.toLowerCase().startsWith(query)) { + return true; + } final resolved = _resolveRepeaterNames(node.repeaterId, repeaters); - if (resolved.names.any((n) => n.toLowerCase().contains(query))) + if (resolved.names.any((n) => n.toLowerCase().contains(query))) { return true; + } } return false; case PingLogType.trace: diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index f098136..29ecb17 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2533,13 +2533,15 @@ class _SettingsScreenState extends State { final key = uri.queryParameters['key']; if (rawUrl == null || rawUrl.isEmpty) { - if (context.mounted) + if (context.mounted) { AppToast.error(context, 'Link is missing the url parameter'); + } return; } if (key == null || key.isEmpty) { - if (context.mounted) + if (context.mounted) { AppToast.error(context, 'Link is missing the key parameter'); + } return; } @@ -2548,8 +2550,9 @@ class _SettingsScreenState extends State { // Validate the constructed URL final parsed = Uri.tryParse(fullUrl); if (parsed == null || !parsed.hasAuthority) { - if (context.mounted) + if (context.mounted) { AppToast.error(context, 'Invalid URL in link: $rawUrl'); + } return; } @@ -2566,8 +2569,9 @@ class _SettingsScreenState extends State { debugLog('[CUSTOM API] Imported endpoint from clipboard: $fullUrl'); } catch (e) { debugError('[CUSTOM API] Failed to parse clipboard link: $e'); - if (context.mounted) + if (context.mounted) { AppToast.error(context, 'Invalid meshmapper:// link'); + } } } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index e147e94..1cdd432 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -276,8 +276,9 @@ class ApiService { if (who != null) payload['who'] = who; payload['ver'] = appVersion ?? 'UNKNOWN'; - if (power != null) + if (power != null) { payload['power'] = '${power}w'; // Wattage (0.3w, 0.6w, 1.0w, 2.0w) + } if (iataCode != null) payload['iata'] = iataCode; if (model != null) payload['model'] = model; payload['coords'] = { diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index cc3a441..47a5f23 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -172,8 +172,9 @@ class MobileBluetoothService implements BluetoothService { location.isPermanentlyDenied) { final denied = []; if (bluetoothScan.isPermanentlyDenied) denied.add('Bluetooth Scan'); - if (bluetoothConnect.isPermanentlyDenied) + if (bluetoothConnect.isPermanentlyDenied) { denied.add('Bluetooth Connect'); + } if (location.isPermanentlyDenied) denied.add('Location'); debugLog( '[BLE] Android permissions permanently denied: ${denied.join(", ")}'); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 3c349b2..d9d2ca7 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -31,8 +31,8 @@ const _satelliteStyleJson = /// (saves mobile data while still showing markers and overlays). /// Includes a `glyphs` URL so native annotations using textField (repeater /// hex IDs, distance labels) can render their text even when tiles are off. -const _blankStyleJson = - '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{},"layers":[{"id":"background","type":"background","paint":{"background-color":"#0F172A"}}]}'; +// const _blankStyleJson = +// '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{},"layers":[{"id":"background","type":"background","paint":{"background-color":"#0F172A"}}]}'; /// Default font stack used for all native text labels (textField property). /// Available in OpenFreeMap glyph sets (Liberty, Bright, Dark, Positron). @@ -594,8 +594,9 @@ class _MapWidgetState extends State { /// Smoothly animate the map rotation to match heading /// MapLibre bearing is clockwise from north (same as GPS heading) void _animateToRotation(double targetHeading) { - if (_mapController == null || !_isMapReady || !mounted || _alwaysNorth) + if (_mapController == null || !_isMapReady || !mounted || _alwaysNorth) { return; + } final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index dc92a09..77510bc 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -199,10 +199,12 @@ class InteractiveNoiseFloorChartState /// Interpolate noise floor at given elapsed time double _interpolateNoiseFloor( double elapsedSeconds, NoiseFloorSession session) { - if (session.samples.isEmpty) + if (session.samples.isEmpty) { return widget.session.noiseFloorRange.min.toDouble(); - if (session.samples.length == 1) + } + if (session.samples.length == 1) { return session.samples.first.noiseFloor.toDouble(); + } NoiseFloorSample? before; NoiseFloorSample? after; @@ -1007,8 +1009,9 @@ class _MarkerPainter extends CustomPainter { double _interpolateNoiseFloor(double elapsedSeconds) { if (session.samples.isEmpty) return minY; - if (session.samples.length == 1) + if (session.samples.length == 1) { return session.samples.first.noiseFloor.toDouble(); + } NoiseFloorSample? before; NoiseFloorSample? after; diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index 3f76b67..f080ac0 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -1163,22 +1163,26 @@ class _CompactPingControlsState extends State { required bool showFullText, }) { if (isPingSending) return showFullText ? 'Sending...' : '...'; - if (rxWindowActive) + if (rxWindowActive) { return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (manualCooldownActive) + } + if (manualCooldownActive) { return showFullText ? 'Cooldown ${manualCooldownRemaining}s' : '${manualCooldownRemaining}s'; - if (discoveryWindowActive) + } + if (discoveryWindowActive) { return showFullText ? 'Cooldown ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (cooldownActive) + } + if (cooldownActive) { return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + } return null; } @@ -1201,33 +1205,39 @@ class _CompactPingControlsState extends State { int discoveryWindowRemaining = 0, }) { if (isPendingDisable) { - if (rxWindowActive) + if (rxWindowActive) { return showFullText ? 'Stopping ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (discoveryWindowActive) + } + if (discoveryWindowActive) { return showFullText ? 'Stopping ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; + } return showFullText ? 'Stopping...' : '...'; } if (isActiveModeRunning) { - if (discoveryWindowActive) + if (discoveryWindowActive) { return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (isPingInProgress && !rxWindowActive) + } + if (isPingInProgress && !rxWindowActive) { return showFullText ? 'Sending...' : '...'; - if (rxWindowActive) + } + if (rxWindowActive) { return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (autoPingWaiting) + } + if (autoPingWaiting) { return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; + } } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { @@ -1253,16 +1263,18 @@ class _CompactPingControlsState extends State { required bool isSkipped, }) { if (isPassiveModeRunning) { - if (discoveryWindowActive) + if (discoveryWindowActive) { return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) + } + if (autoPingWaiting) { return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Waiting ${autoPingRemaining}s') : '${autoPingRemaining}s'; + } } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { @@ -1288,16 +1300,18 @@ class _CompactPingControlsState extends State { required bool isSkipped, }) { if (isTargetedRunning) { - if (discoveryWindowActive) + if (discoveryWindowActive) { return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) + } + if (autoPingWaiting) { return showFullText ? (isSkipped ? 'Skipped ${autoPingRemaining}s' : 'Next in ${autoPingRemaining}s') : '${autoPingRemaining}s'; + } return showFullText ? 'Stop' : null; } // Show cooldown if this button caused it