From 07196974664ff3d9af3c3ebe5a5ba17b2f9b1dfb Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 11 Apr 2026 14:48:26 -0400 Subject: [PATCH 001/100] 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 002/100] 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 190cb62ea0b716a40e434d1e5598873a2a3a40b6 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Wed, 15 Apr 2026 12:09:38 -0700 Subject: [PATCH 003/100] 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 82ee9d75f8c6e48e25a7b2b54d6c391c7516bfb8 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Wed, 15 Apr 2026 12:10:11 -0700 Subject: [PATCH 004/100] 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 005/100] 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 006/100] 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 a7a0c325c4a4f3f76a7b61fb53d6802013936078 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 15 Apr 2026 22:15:45 -0400 Subject: [PATCH 007/100] =?UTF-8?q?Fix=20power=20level=20hint:=20Settings?= =?UTF-8?q?=20=E2=86=92=20Connect=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/widgets/ping_controls.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index 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 8f3c2da7d8ff246b96ad612f70c1afdf2307cb98 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Thu, 16 Apr 2026 08:26:17 -0700 Subject: [PATCH 008/100] 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 f16c4594f001605c812b302f23d4ed26e2b17efa Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Thu, 16 Apr 2026 10:09:55 -0700 Subject: [PATCH 009/100] 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 cbaf99fc36389ff004c843c8baa30c3a873287e5 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Thu, 16 Apr 2026 10:13:24 -0700 Subject: [PATCH 010/100] 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 From 9f8f5ac9c349224862c7cc8d7bc79ba1c865cc4f Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 19 Apr 2026 16:23:43 -0400 Subject: [PATCH 011/100] feat(offline-maps): add download cancellation, queueing, and storage safeguards - Add "Cancel" button to in-progress download card; discards partial tiles and frees the slot for queued downloads - Queue multiple offline map downloads; starting a second download while one is running enqueues it, and the next begins automatically when the active download finishes or is cancelled. Progress card shows pending count - Check device free space before starting; reject downloads up front if less than 1.5x the estimated region size is available - Label storage usage on Offline Maps screen as an estimate (derived from tile-count heuristics) rather than presenting it as an exact figure - Surface real error causes from the native layer (network, style, IO) instead of the generic "Download error occurred" message - Cap offline zoom slider at z15 (was z18) and set default max zoom to z14; OpenFreeMap vector tiles max out at z14, so higher zooms produced duplicate overzoom tiles and could accidentally explode storage usage - Reject offline regions that cross the antimeridian with a clear message instead of silently producing a 357-degree-wide region - Fix race where the offline download dialog could close before validation errors were shown to the user --- .../plugins/GeneratedPluginRegistrant.java | 5 + ios/Podfile.lock | 8 +- lib/screens/home_screen.dart | 2 + lib/screens/offline_maps_screen.dart | 190 +++++++--- lib/screens/settings_screen.dart | 37 +- lib/services/offline_map_service.dart | 350 +++++++++++++++--- lib/widgets/map_widget.dart | 49 ++- pubspec.lock | 24 +- pubspec.yaml | 1 + 9 files changed, 526 insertions(+), 140 deletions(-) diff --git a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index aa77ae3..b8be97d 100644 --- a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -20,6 +20,11 @@ public static void registerWith(@NonNull FlutterEngine flutterEngine) { } catch (Exception e) { Log.e(TAG, "Error registering plugin audio_session, com.ryanheise.audio_session.AudioSessionPlugin", e); } + try { + flutterEngine.getPlugins().add(new com.piccmaq.disk_space_plus.DiskSpacePlusPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin disk_space_plus, com.piccmaq.disk_space_plus.DiskSpacePlusPlugin", e); + } try { flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin()); } catch (Exception e) { diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 21b9577..3eb0bde 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - disk_space_plus (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_background_service_ios (0.0.3): - Flutter @@ -8,12 +10,15 @@ PODS: - Flutter DEPENDENCIES: + - disk_space_plus (from `.symlinks/plugins/disk_space_plus/ios`) - Flutter (from `Flutter`) - flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) EXTERNAL SOURCES: + disk_space_plus: + :path: ".symlinks/plugins/disk_space_plus/ios" Flutter: :path: Flutter flutter_background_service_ios: @@ -24,11 +29,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/permission_handler_apple/ios" SPEC CHECKSUMS: + disk_space_plus: a36391fb5f732dbdd29628b0bac5a1acbd43aaef Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d -PODFILE CHECKSUM: 5f6d31cc7a922ccb43b951411657266fcae3377c +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e COCOAPODS: 1.16.2 diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 90c9403..bd65adc 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -115,6 +115,7 @@ class _HomeScreenState extends State { body: _buildLayout(appState, isLandscape: false), floatingActionButton: !_showControlPanel ? FloatingActionButton.extended( + heroTag: null, onPressed: () => setState(() => _showControlPanel = true), icon: const Icon(Icons.tune), label: const Text('Controls'), @@ -498,6 +499,7 @@ class _HomeScreenState extends State { bottom: 16, left: leftInset, child: FloatingActionButton.small( + heroTag: null, onPressed: _toggleControlPanel, child: const Icon(Icons.tune), ), diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart index 65619bb..ba0992c 100644 --- a/lib/screens/offline_maps_screen.dart +++ b/lib/screens/offline_maps_screen.dart @@ -7,15 +7,15 @@ import 'package:provider/provider.dart'; import '../providers/app_state_provider.dart'; import '../services/offline_map_service.dart'; +import '../utils/debug_logger_io.dart'; import '../widgets/app_toast.dart'; +import '../widgets/map_widget.dart' show MapStyle, MapStyleExtension; -/// Available map styles for offline download. -/// Satellite uses inline raster JSON which doesn't work well with the offline -/// region downloader, so we only offer the vector tile styles. -const _downloadStyles = { - 'Liberty': 'https://tiles.openfreemap.org/styles/liberty', - 'Dark': 'https://tiles.openfreemap.org/styles/dark', - 'Light': 'https://tiles.openfreemap.org/styles/bright', +/// Label → URL map for styles the user can download, derived from +/// [MapStyle.downloadable]. Satellite is excluded (inline raster JSON +/// doesn't work with MapLibre's offline region downloader). +final Map _downloadStyles = { + for (final s in MapStyleExtension.downloadable) s.label: s.styleUrl, }; /// Screen for managing offline map tile downloads. @@ -88,6 +88,7 @@ class _OfflineMapsScreenState extends State { floatingActionButton: (service.initialized && !kIsWeb && !service.isDownloading) ? FloatingActionButton.extended( + heroTag: null, onPressed: () => _showDownloadDialog(context), icon: const Icon(Icons.download), label: const Text('Download Region'), @@ -188,7 +189,7 @@ class _OfflineMapsScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '${service.totalUsedDisplay} used', + '~${service.totalUsedDisplay} used (estimated)', style: theme.textTheme.bodySmall?.copyWith( color: barColor, fontWeight: FontWeight.w600, @@ -202,6 +203,15 @@ class _OfflineMapsScreenState extends State { ), ], ), + const SizedBox(height: 2), + Text( + 'Based on tile count heuristic; actual disk use may differ.', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), const SizedBox(height: 4), Text( '${service.regions.length} region${service.regions.length == 1 ? '' : 's'} downloaded', @@ -365,12 +375,68 @@ class _OfflineMapsScreenState extends State { 'Download continues in the background if you leave this screen', style: TextStyle(fontSize: 11, color: Colors.grey.shade500), ), + if (service.queueLength > 0) ...[ + const SizedBox(height: 4), + Text( + '${service.queueLength} more queued after this', + style: TextStyle( + fontSize: 11, + color: Colors.grey.shade500, + fontStyle: FontStyle.italic, + ), + ), + ], + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () => _confirmCancelDownload(context, service), + icon: const Icon(Icons.close, size: 16, color: Colors.red), + label: const Text('Cancel', + style: TextStyle(color: Colors.red)), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ), + ), ], ), ), ); } + Future _confirmCancelDownload( + BuildContext context, OfflineMapService service) async { + final name = service.downloadingRegionName ?? 'this download'; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Cancel download?'), + content: Text( + 'Tiles downloaded so far for "$name" will be discarded. ' + 'Queued downloads (if any) will continue automatically.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Keep downloading'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Cancel download'), + ), + ], + ), + ); + if (confirmed != true) return; + final ok = await service.cancelActiveDownload(); + if (context.mounted && ok) { + AppToast.info(context, 'Download cancelled'); + } + } + // ────────────────────────────────────────────── // Storage limit dialog // ────────────────────────────────────────────── @@ -424,7 +490,7 @@ class _OfflineMapsScreenState extends State { ), const SizedBox(height: 12), Text( - 'Currently using ${service.totalUsedDisplay}', + 'Currently using ~${service.totalUsedDisplay} (estimated)', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey, ), @@ -511,7 +577,7 @@ class _OfflineMapsScreenState extends State { AppToast.success(context, '"${region.name}" deleted'); } else { AppToast.error( - context, service.lastError ?? 'Failed to delete region'); + context, service.consumeLastError() ?? 'Failed to delete region'); } } } @@ -612,7 +678,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { final _nameController = TextEditingController(); String _selectedStyle = 'Liberty'; double _minZoom = 6; - double _maxZoom = 15; + double _maxZoom = 14; bool _submitting = false; String? _error; @@ -879,9 +945,13 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { ), RangeSlider( values: RangeValues(_minZoom, _maxZoom), + // OpenFreeMap vector tiles max out at z14; z15+ is pure + // overzoom (duplicate tile data). Slider caps at 15 to + // leave one overzoom step reachable without letting users + // blow out storage at z16+. min: 0, - max: 18, - divisions: 18, + max: 15, + divisions: 15, labels: RangeLabels( _minZoom.round().toString(), _maxZoom.round().toString(), @@ -942,30 +1012,55 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { } void _onMapTap(Point point, LatLng coordinates) { - setState(() { - if (_tapCount == 0) { + if (_tapCount == 0) { + setState(() { _boundsSW = coordinates; _boundsNE = null; _tapCount = 1; + _error = null; _clearBoundsOverlay(); - } else if (_tapCount == 1) { - // Ensure SW is actually southwest and NE is northeast - final lat1 = _boundsSW!.latitude; - final lng1 = _boundsSW!.longitude; - final lat2 = coordinates.latitude; - final lng2 = coordinates.longitude; - - _boundsSW = LatLng( - lat1 < lat2 ? lat1 : lat2, - lng1 < lng2 ? lng1 : lng2, - ); - _boundsNE = LatLng( - lat1 > lat2 ? lat1 : lat2, - lng1 > lng2 ? lng1 : lng2, - ); - _tapCount = 2; - _drawBoundsOverlay(); - } + }); + return; + } + if (_tapCount != 1) return; + + // Ensure SW is actually southwest and NE is northeast + final lat1 = _boundsSW!.latitude; + final lng1 = _boundsSW!.longitude; + final lat2 = coordinates.latitude; + final lng2 = coordinates.longitude; + + final sw = LatLng( + lat1 < lat2 ? lat1 : lat2, + lng1 < lng2 ? lng1 : lng2, + ); + final ne = LatLng( + lat1 > lat2 ? lat1 : lat2, + lng1 > lng2 ? lng1 : lng2, + ); + + // Reject selections that cross the antimeridian (lon span > 180°). + // The SW/NE min-max normalization above silently inverts such boxes + // into a ~357° wide region, which explodes tile count and MapLibre + // would refuse anyway. + if ((ne.longitude - sw.longitude).abs() > 180) { + setState(() { + _error = 'Selected region crosses the antimeridian. ' + 'Split into two regions (one per hemisphere).'; + _boundsSW = null; + _boundsNE = null; + _tapCount = 0; + _clearBoundsOverlay(); + }); + return; + } + + setState(() { + _boundsSW = sw; + _boundsNE = ne; + _tapCount = 2; + _error = null; + _drawBoundsOverlay(); }); } @@ -1005,7 +1100,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { _existingFills.add(fill); _existingLines.add(line); } catch (e) { - debugPrint( + debugWarn( '[OFFLINE_MAP] Failed to draw existing region ${region.name}: $e'); } } @@ -1060,7 +1155,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { lineOpacity: 0.8, )); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to draw bounds overlay: $e'); + debugWarn('[OFFLINE_MAP] Failed to draw bounds overlay: $e'); } } @@ -1076,7 +1171,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { _boundsLine = null; } } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to clear bounds overlay: $e'); + debugWarn('[OFFLINE_MAP] Failed to clear bounds overlay: $e'); } } @@ -1105,10 +1200,12 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { _error = null; }); - // Fire-and-forget: the service runs the download in the background - // and shows a system notification for progress. We just kick it off - // and return to the management screen. - service.downloadRegion( + // Await the service call. downloadRegion returns once the native + // downloader has accepted (or rejected) the job — actual tile fetches + // continue in the background after this returns. If validation fails + // (quota, free-space, style, antimeridian, etc.), lastError is set and + // isDownloading is false; otherwise the download is queued. + await service.downloadRegion( name: name, bounds: bounds, styleUrl: styleUrl, @@ -1117,19 +1214,16 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { maxZoom: _maxZoom, ); - // Give the service a tick to validate and start - await Future.delayed(const Duration(milliseconds: 100)); - if (!mounted) return; - if (service.lastError != null && !service.isDownloading) { + if (!service.isDownloading && service.lastError != null) { setState(() { _submitting = false; - _error = service.lastError; + _error = service.consumeLastError(); }); - } else { - // Download is queued — return to the management screen - Navigator.pop(context, true); + return; } + // Download is queued — return to the management screen. + Navigator.pop(context, true); } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 29ecb17..a7a2909 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -155,19 +155,30 @@ class _SettingsScreenState extends State { }, ), if (!kIsWeb) _BackgroundModeToggle(appState: appState), - SwitchListTile( - secondary: - Icon(prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), - title: const Text('Disable Map Tiles'), - subtitle: Text(prefs.mapTilesEnabled - ? 'Map and coverage tiles load normally' - : 'Network tiles disabled · downloaded regions still visible'), - value: !prefs.mapTilesEnabled, - onChanged: (value) { - appState - .updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); - }, - ), + Builder(builder: (_) { + // iOS plugin (maplibre_gl 0.25.0) doesn't implement setOffline, + // and MapLibre-iOS has no public equivalent. Disable the toggle + // there so it doesn't lie to the user. + final iosUnsupported = !kIsWeb && + defaultTargetPlatform == TargetPlatform.iOS; + return SwitchListTile( + secondary: Icon( + prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), + title: const Text('Disable Map Tiles'), + subtitle: iosUnsupported + ? const Text('Not available on iOS') + : Text(prefs.mapTilesEnabled + ? 'Map and coverage tiles load normally' + : 'Network tiles disabled · downloaded regions still visible'), + value: !prefs.mapTilesEnabled, + onChanged: iosUnsupported + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(mapTilesEnabled: !value)); + }, + ); + }), if (!kIsWeb) ListTile( leading: const Icon(Icons.download_for_offline), diff --git a/lib/services/offline_map_service.dart b/lib/services/offline_map_service.dart index 8040866..e14d816 100644 --- a/lib/services/offline_map_service.dart +++ b/lib/services/offline_map_service.dart @@ -1,8 +1,36 @@ +import 'dart:async'; + +import 'package:disk_space_plus/disk_space_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../utils/debug_logger_io.dart'; + +/// A pending download waiting in the FIFO queue. +class _QueuedDownload { + final String name; + final LatLngBounds bounds; + final String styleUrl; + final String styleName; + final double minZoom; + final double maxZoom; + final int estBytes; + final Completer completer; + + _QueuedDownload({ + required this.name, + required this.bounds, + required this.styleUrl, + required this.styleName, + required this.minZoom, + required this.maxZoom, + required this.estBytes, + required this.completer, + }); +} + /// Metadata keys stored with each offline region. class _MetaKeys { _MetaKeys._(); @@ -48,8 +76,13 @@ class OfflineMapRegion { createdAt: DateTime.tryParse((meta[_MetaKeys.createdAt] as String?) ?? '') ?? DateTime.now(), - // Platform channel JSON round-trip can return int as num/double. - estimatedBytes: (meta[_MetaKeys.estimatedBytes] as num?)?.toInt() ?? 0, + // Platform channel JSON round-trip can return int as num, double, or + // (on some Android paths) a stringified form. Tolerate all three. + estimatedBytes: switch (meta[_MetaKeys.estimatedBytes]) { + final num n => n.toInt(), + final String s => int.tryParse(s) ?? 0, + _ => 0, + }, ); } @@ -129,9 +162,36 @@ class OfflineMapService extends ChangeNotifier { bool get isDownloading => _downloadProgress != null; + /// ID of the region currently being downloaded. Set right after MapLibre + /// accepts the download; null when idle. Used by [cancelActiveDownload] to + /// delete the partial region via `deleteOfflineRegion`. + int? _activeRegionId; + + /// Completer tied to the currently-active download. Resolved when + /// [_onDownloadEvent] sees Success or Error, or when the download is + /// cancelled. + Completer? _activeCompleter; + + /// Estimated total bytes for the currently-active download, used by the + /// quota pre-check so queued jobs can't jointly bust the limit. + int _activeEstBytes = 0; + + /// FIFO queue of pending downloads. Drained one at a time after the + /// current download finishes (or is cancelled). + final List<_QueuedDownload> _queue = []; + int get queueLength => _queue.length; + String? _lastError; String? get lastError => _lastError; + /// Read [lastError] and clear it. Use this in UI that displays the error + /// once (banner, toast) so it doesn't linger after dismissal. + String? consumeLastError() { + final e = _lastError; + _lastError = null; + return e; + } + /// Name of the most recently completed download (for one-shot UI toast). /// Call [consumeLastCompletedName] to read and clear. String? _lastCompletedName; @@ -159,7 +219,7 @@ class OfflineMapService extends ChangeNotifier { _initialized = true; notifyListeners(); } catch (e) { - debugPrint('[OFFLINE_MAP] Init error: $e'); + debugError('[OFFLINE_MAP] Init error: $e'); notifyListeners(); } } @@ -193,7 +253,7 @@ class OfflineMapService extends ChangeNotifier { ); _notifInitialized = true; } catch (e) { - debugPrint('[OFFLINE_MAP] Notification init error: $e'); + debugError('[OFFLINE_MAP] Notification init error: $e'); } } @@ -224,7 +284,7 @@ class OfflineMapService extends ChangeNotifier { NotificationDetails(android: androidDetails), ); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to show progress notification: $e'); + debugWarn('[OFFLINE_MAP] Failed to show progress notification: $e'); } } @@ -248,7 +308,7 @@ class OfflineMapService extends ChangeNotifier { const NotificationDetails(android: androidDetails), ); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to show complete notification: $e'); + debugWarn('[OFFLINE_MAP] Failed to show complete notification: $e'); } } @@ -272,7 +332,7 @@ class OfflineMapService extends ChangeNotifier { const NotificationDetails(android: androidDetails), ); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to show error notification: $e'); + debugWarn('[OFFLINE_MAP] Failed to show error notification: $e'); } } @@ -280,7 +340,7 @@ class OfflineMapService extends ChangeNotifier { try { await _notifPlugin.cancel(_progressNotifId); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to dismiss notification: $e'); + debugWarn('[OFFLINE_MAP] Failed to dismiss notification: $e'); } } @@ -296,14 +356,14 @@ class OfflineMapService extends ChangeNotifier { try { parsed.add(OfflineMapRegion.fromOfflineRegion(r)); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to parse region ${r.id}: $e'); + debugWarn('[OFFLINE_MAP] Failed to parse region ${r.id}: $e'); } } _regions = parsed; _regions.sort((a, b) => b.createdAt.compareTo(a.createdAt)); notifyListeners(); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to list regions: $e'); + debugError('[OFFLINE_MAP] Failed to list regions: $e'); } } @@ -316,7 +376,7 @@ class OfflineMapService extends ChangeNotifier { final prefs = await SharedPreferences.getInstance(); await prefs.setInt(_storageLimitKey, _storageLimitMb); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to save storage limit: $e'); + debugError('[OFFLINE_MAP] Failed to save storage limit: $e'); } notifyListeners(); } @@ -347,9 +407,24 @@ class OfflineMapService extends ChangeNotifier { /// We use 20 KB as a middle estimate. static int estimateSizeBytes(int tileCount) => tileCount * 20 * 1024; - /// Check if downloading a region of [estimatedBytes] would exceed the limit. - bool wouldExceedLimit(int estimatedBytes) => - (totalUsedBytes + estimatedBytes) > storageLimitBytes; + /// Check if downloading a region of [estimatedBytes] would exceed the + /// limit. Accounts for the currently-active download and any queued jobs + /// so the UI's pre-check agrees with the service's internal validation. + bool wouldExceedLimit(int estimatedBytes) { + final pending = (isDownloading ? _activePendingBytes : 0) + + _queue.fold(0, (sum, q) => sum + q.estBytes); + return (totalUsedBytes + pending + estimatedBytes) > storageLimitBytes; + } + + /// Rough bytes remaining in the currently-active download. We don't know + /// the real size until completion — use the job's estimate as a proxy by + /// subtracting progress. + int get _activePendingBytes { + final progress = _downloadProgress ?? 0; + return ((1 - progress) * _activeEstBytes) + .clamp(0, _activeEstBytes) + .toInt(); + } // ── Download ── @@ -360,7 +435,14 @@ class OfflineMapService extends ChangeNotifier { /// receives progress callbacks and forwards them to both [notifyListeners] /// and a system notification. /// - /// Returns the new [OfflineMapRegion] on success, null on failure. + /// If another download is already active, the request is appended to a + /// FIFO queue and will start automatically when the current one finishes + /// or is cancelled. + /// + /// Returns the new [OfflineMapRegion] on success, null on failure or if + /// validation rejects the request (quota, free space, antimeridian, etc.). + /// The returned future resolves once the region's native download fully + /// completes — not when it's merely queued. Future downloadRegion({ required String name, required LatLngBounds bounds, @@ -370,43 +452,88 @@ class OfflineMapService extends ChangeNotifier { double maxZoom = 14, }) async { if (kIsWeb) return null; - if (isDownloading) { - _lastError = 'A download is already in progress'; - notifyListeners(); - return null; - } final tileCount = estimateTileCount(bounds, minZoom, maxZoom); final estBytes = estimateSizeBytes(tileCount); if (wouldExceedLimit(estBytes)) { _lastError = - 'Download would exceed storage limit (${_formatBytes(estBytes)} needed, ' - '${_formatBytes(storageLimitBytes - totalUsedBytes)} remaining)'; + 'Download would exceed storage limit (${_formatBytes(estBytes)} ' + 'needed)'; notifyListeners(); return null; } + // Device free-space check. The app-level quota above only caps estimated + // tile usage across our regions — the device itself may be nearly full + // regardless. We require 1.5× the estimate to leave headroom for the + // heuristic being low. Failures of the platform call fall through to + // the existing quota logic. + try { + final freeMb = await DiskSpacePlus().getFreeDiskSpace; + if (freeMb != null) { + final freeBytes = (freeMb * 1024 * 1024).toInt(); + final required = (estBytes * 1.5).toInt(); + if (freeBytes < required) { + _lastError = 'Not enough free space on device ' + '(${_formatBytes(required)} needed, ' + '${_formatBytes(freeBytes)} free)'; + notifyListeners(); + return null; + } + } + } catch (e) { + debugWarn('[OFFLINE_MAP] Free-space check failed: $e'); + } + + final job = _QueuedDownload( + name: name, + bounds: bounds, + styleUrl: styleUrl, + styleName: styleName, + minZoom: minZoom, + maxZoom: maxZoom, + estBytes: estBytes, + completer: Completer(), + ); + + if (isDownloading) { + _queue.add(job); + debugLog('[OFFLINE_MAP] Queued "$name" ' + '(position ${_queue.length} in queue)'); + notifyListeners(); + } else { + unawaited(_startJob(job)); + } + + return job.completer.future; + } + + /// Start a queued job. Also invoked recursively from [_onDownloadEvent] + /// after the active download finishes, to drain the queue. + Future _startJob(_QueuedDownload job) async { _downloadProgress = 0; - _downloadingRegionName = name; + _downloadingRegionName = job.name; + _activeCompleter = job.completer; + _activeEstBytes = job.estBytes; _lastError = null; _lastCompletedName = null; notifyListeners(); - _showProgressNotification(name, 0); + _showProgressNotification(job.name, 0); try { final definition = OfflineRegionDefinition( - bounds: bounds, - mapStyleUrl: styleUrl, - minZoom: minZoom, - maxZoom: maxZoom, + bounds: job.bounds, + mapStyleUrl: job.styleUrl, + minZoom: job.minZoom, + maxZoom: job.maxZoom, ); final metadata = { - _MetaKeys.name: name, - _MetaKeys.styleName: styleName, + _MetaKeys.name: job.name, + _MetaKeys.styleName: job.styleName, _MetaKeys.createdAt: DateTime.now().toIso8601String(), - _MetaKeys.estimatedBytes: estBytes, + _MetaKeys.estimatedBytes: job.estBytes, }; final region = await downloadOfflineRegion( @@ -414,45 +541,70 @@ class OfflineMapService extends ChangeNotifier { metadata: metadata, onEvent: _onDownloadEvent, ); - - // downloadOfflineRegion resolves once the native download is queued, - // not necessarily when it finishes. The _onDownloadEvent callback - // handles completion. But if progress is already null (Success fired - // synchronously), the download completed inline. - if (_downloadProgress != null) { - // Still in progress — the event callback will finalize. - return null; + _activeRegionId = region.id; + + // downloadOfflineRegion resolves once MapLibre has accepted the job; + // progress and completion are delivered via _onDownloadEvent. + // In the rare case Success already fired synchronously before this + // returns, _downloadProgress will be null — resolve the completer now + // so the caller doesn't hang waiting for an event that already fired. + if (_downloadProgress == null && !job.completer.isCompleted) { + final finalized = _regions.firstWhere((r) => r.id == region.id, + orElse: () => OfflineMapRegion.fromOfflineRegion(region)); + job.completer.complete(finalized); } - - // Completed synchronously (small region / cached tiles) - _downloadProgress = null; - _downloadingRegionName = null; - _lastCompletedName = name; - await refreshRegions(); - _showCompleteNotification(name); - return _regions.firstWhere((r) => r.id == region.id, - orElse: () => OfflineMapRegion.fromOfflineRegion(region)); } catch (e) { + debugError('[OFFLINE_MAP] downloadRegion threw: $e'); _downloadProgress = null; _downloadingRegionName = null; - _lastError = 'Download failed: $e'; + _activeRegionId = null; + _activeCompleter = null; + _activeEstBytes = 0; + _lastError = 'Download failed (${e.runtimeType}): $e'; notifyListeners(); - _showErrorNotification(name); - return null; + _showErrorNotification(job.name); + if (!job.completer.isCompleted) job.completer.complete(null); + _drainQueue(); } } + /// Start the next queued download if any, once the current one has + /// finished, errored, or been cancelled. + void _drainQueue() { + if (_queue.isEmpty) return; + final next = _queue.removeAt(0); + unawaited(_startJob(next)); + } + void _onDownloadEvent(DownloadRegionStatus status) { if (status is Success) { final name = _downloadingRegionName ?? 'Region'; + final completer = _activeCompleter; + final regionId = _activeRegionId; _downloadProgress = null; _downloadingRegionName = null; + _activeRegionId = null; + _activeCompleter = null; + _activeEstBytes = 0; _lastCompletedName = name; - notifyListeners(); // Immediately clear progress state + notifyListeners(); _showCompleteNotification(name); // Small delay lets the native DB commit before we query it. - Future.delayed(const Duration(milliseconds: 500), () { - refreshRegions(); + Future.delayed(const Duration(milliseconds: 500), () async { + await refreshRegions(); + if (completer != null && !completer.isCompleted) { + OfflineMapRegion? finalized; + if (regionId != null) { + for (final r in _regions) { + if (r.id == regionId) { + finalized = r; + break; + } + } + } + completer.complete(finalized); + } + _drainQueue(); }); } else if (status is InProgress) { _downloadProgress = status.progress / 100.0; @@ -463,16 +615,96 @@ class OfflineMapService extends ChangeNotifier { _showProgressNotification(_downloadingRegionName ?? 'Region', percent); } } else { - // Error status + // DownloadRegionStatus is sealed to Success / InProgress / Error. The + // concrete Error class is named `Error` in maplibre_gl, which collides + // with dart:core.Error — so rather than `status is Error` we reach the + // `cause` field dynamically. `cause` is a PlatformException with + // code/message/details we can surface to the user. final name = _downloadingRegionName ?? 'Region'; + final completer = _activeCompleter; + String detail = 'unknown error'; + try { + final cause = (status as dynamic).cause; + if (cause != null) { + final msg = (cause as dynamic).message; + final code = (cause as dynamic).code; + if (msg is String && msg.isNotEmpty) { + detail = msg; + } else if (code is String && code.isNotEmpty) { + detail = code; + } else { + detail = cause.toString(); + } + } + } catch (_) { + // Swallow — keep the generic 'unknown error' fallback. + } + debugError('[OFFLINE_MAP] Download failed: $detail'); _downloadProgress = null; _downloadingRegionName = null; - _lastError = 'Download error occurred'; + _activeRegionId = null; + _activeCompleter = null; + _activeEstBytes = 0; + _lastError = 'Download failed: $detail'; _showErrorNotification(name); notifyListeners(); + if (completer != null && !completer.isCompleted) { + completer.complete(null); + } + _drainQueue(); } } + /// Cancel the currently-active download, if any. Deletes the partial + /// region from MapLibre's cache. Queued downloads are left in place — + /// the next one will start automatically. Call [clearQueue] to discard + /// queued jobs too. + Future cancelActiveDownload() async { + if (kIsWeb) return false; + final regionId = _activeRegionId; + final completer = _activeCompleter; + final name = _downloadingRegionName; + if (regionId == null && !isDownloading) return false; + + debugLog('[OFFLINE_MAP] Cancelling active download' + '${name != null ? " \"$name\"" : ""}'); + + if (regionId != null) { + try { + await deleteOfflineRegion(regionId); + } catch (e) { + debugWarn('[OFFLINE_MAP] Cancel delete failed: $e'); + } + } + + _downloadProgress = null; + _downloadingRegionName = null; + _activeRegionId = null; + _activeCompleter = null; + _activeEstBytes = 0; + await _dismissProgressNotification(); + notifyListeners(); + + if (completer != null && !completer.isCompleted) { + completer.complete(null); + } + + await refreshRegions(); + _drainQueue(); + return true; + } + + /// Discard all queued (but not yet active) downloads. Does not affect + /// the active download; call [cancelActiveDownload] for that. + void clearQueue() { + if (_queue.isEmpty) return; + for (final job in _queue) { + if (!job.completer.isCompleted) job.completer.complete(null); + } + _queue.clear(); + notifyListeners(); + } + // ── Deletion ── /// Delete a downloaded region by ID. @@ -483,7 +715,7 @@ class OfflineMapService extends ChangeNotifier { await refreshRegions(); return true; } catch (e) { - debugPrint('[OFFLINE_MAP] Delete failed: $e'); + debugError('[OFFLINE_MAP] Delete failed: $e'); _lastError = 'Failed to delete region: $e'; notifyListeners(); return false; @@ -498,7 +730,7 @@ class OfflineMapService extends ChangeNotifier { try { await deleteOfflineRegion(id); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to delete region $id: $e'); + debugError('[OFFLINE_MAP] Failed to delete region $id: $e'); } } await refreshRegions(); @@ -514,7 +746,7 @@ class OfflineMapService extends ChangeNotifier { await _initNotifications(); await _notifPlugin.cancel(_progressNotifId); } catch (e) { - debugPrint('[OFFLINE_MAP] Failed to cleanup orphaned notification: $e'); + debugWarn('[OFFLINE_MAP] Failed to cleanup orphaned notification: $e'); } } diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index d9d2ca7..fe7b9e8 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'dart:io' show Platform; import 'dart:math' as math; import 'dart:typed_data'; import 'dart:ui' as ui; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -27,13 +29,6 @@ import 'repeater_id_chip.dart'; const _satelliteStyleJson = '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{"satellite":{"type":"raster","tiles":["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"],"tileSize":256,"maxzoom":17}},"layers":[{"id":"satellite-layer","type":"raster","source":"satellite"}]}'; -/// Blank style with dark background — used when mapTilesEnabled is false -/// (saves mobile data while still showing markers and overlays). -/// Includes a `glyphs` URL so native annotations using textField (repeater -/// hex IDs, distance labels) can render their text even when tiles are off. -// const _blankStyleJson = -// '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{},"layers":[{"id":"background","type":"background","paint":{"background-color":"#0F172A"}}]}'; - /// Default font stack used for all native text labels (textField property). /// Available in OpenFreeMap glyph sets (Liberty, Bright, Dark, Positron). const _defaultFontStack = ['Noto Sans Regular']; @@ -252,6 +247,23 @@ extension MapStyleExtension on MapStyle { return _satelliteStyleJson; } } + + /// Whether this style can be packaged as an offline region. Satellite uses + /// inline raster JSON which MapLibre's offline downloader doesn't support. + bool get isDownloadable { + switch (this) { + case MapStyle.dark: + case MapStyle.light: + case MapStyle.liberty: + return true; + case MapStyle.satellite: + return false; + } + } + + /// Styles offered in the offline download picker. + static List get downloadable => + MapStyle.values.where((s) => s.isDownloadable).toList(); } /// Resolved repeater with SNR and ambiguity info for ping focus mode. @@ -1085,6 +1097,23 @@ class _MapWidgetState extends State { ); } + /// Toggle MapLibre between online (network tiles) and offline (cache-only). + /// + /// No-op on iOS: maplibre_gl 0.25.0 never implemented the `setOffline` + /// method handler on iOS, and MapLibre-iOS itself doesn't expose an + /// equivalent of Android's `ConnectivityReceiver` hook. The "Disable Map + /// Tiles" Settings switch is marked unavailable on iOS for the same reason. + Future _setOfflineIfSupported(bool offline) async { + if (kIsWeb || Platform.isIOS) return; + try { + await setOffline(offline); + debugLog('[MAP] setOffline($offline) — ' + 'tiles ${offline ? "cache-only" : "enabled"}'); + } catch (e) { + debugWarn('[MAP] setOffline failed: $e'); + } + } + Widget _buildMap(AppStateProvider appState, LatLng center) { final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); @@ -1101,9 +1130,7 @@ class _MapWidgetState extends State { _lastMapTilesEnabled = tilesEnabled; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - setOffline(!tilesEnabled); - debugPrint( - '[MAP] setOffline(${!tilesEnabled}) — tiles ${tilesEnabled ? "enabled" : "disabled (cache only)"}'); + _setOfflineIfSupported(!tilesEnabled); }); } @@ -1484,7 +1511,7 @@ class _MapWidgetState extends State { final tilesEnabled = appState.preferences.mapTilesEnabled; _lastMapTilesEnabled = tilesEnabled; // Ensure MapLibre offline mode matches the user's preference. - setOffline(!tilesEnabled); + _setOfflineIfSupported(!tilesEnabled); if (tilesEnabled) { _tileLoadFailed = false; _tileLoadTimeoutTimer = diff --git a/pubspec.lock b/pubspec.lock index 17e8082..9a5dbf7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -249,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + disk_space_plus: + dependency: "direct main" + description: + name: disk_space_plus + sha256: "1e454599a7dcec0c0bc1ad0b7f9f563b3fffbc988175bd452c4d3c7503ed3423" + url: "https://pub.dev" + source: hosted + version: "0.2.6" equatable: dependency: transitive description: @@ -724,18 +732,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -1153,10 +1161,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" timezone: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a0dcd0c..fed05ed 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: font_awesome_flutter: ^10.7.0 fl_chart: ^0.69.0 web: ^1.1.0 + disk_space_plus: ^0.2.3 dev_dependencies: flutter_test: From 524435da71197dfb06747dd261b4d29e0ac09c8e Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 19 Apr 2026 18:13:39 -0400 Subject: [PATCH 012/100] Making download tiles easier. --- lib/providers/app_state_provider.dart | 52 +++++ lib/screens/offline_maps_screen.dart | 275 +++++++++++++++++++++----- lib/services/api_service.dart | 74 +++++++ lib/widgets/map_widget.dart | 162 +++++++++++++++ 4 files changed, 513 insertions(+), 50 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 4ecc88f..fe609f3 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -325,6 +325,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _repeatersLoaded = false; String? _repeatersLoadedForIata; + // Regional boundary polygons (from /border API — always displayed on map) + List> _regionBorders = []; + String? _bordersLoadedForZone; + bool _bordersFetchInProgress = false; + // Regional channels from API (for UI display) List _regionalChannels = []; @@ -565,6 +570,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Repeater markers getters List get repeaters => List.unmodifiable(_repeaters); + /// Regional boundary polygons loaded from the /border API. + /// Each entry is a `{code: String, polygon: List>}` map where + /// `polygon` holds `[lat, lon]` pairs in the server's original order. + List> get regionBorders => + List.unmodifiable(_regionBorders); + // Regional channels getter (for UI) List get regionalChannels => List.unmodifiable(_regionalChannels); @@ -3387,6 +3398,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _currentZone = null; _nearestZone = null; _lastZoneCheckPosition = null; + _regionBorders = []; + _bordersLoadedForZone = null; debugLog('[GEOFENCE] Cleared zone data for offline mode'); } else { // Stop auto-save timer when leaving offline mode @@ -3457,6 +3470,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _currentZone = null; _nearestZone = null; _lastZoneCheckPosition = null; + _regionBorders = []; + _bordersLoadedForZone = null; debugLog('[GEOFENCE] Cleared zone data for offline mode'); debugLog('[APP] Successfully switched to offline mode'); @@ -4790,8 +4805,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (newZoneCode.isNotEmpty) { _fetchRepeatersForZone( newZoneCode); // fire-and-forget — don't block zone check + _fetchBorderPolygons(newZoneCode); // fire-and-forget } } else { + _regionBorders = []; + _bordersLoadedForZone = null; _currentZone = null; _nearestZone = result['nearest_zone'] as Map?; final nearestName = _nearestZone?['name'] ?? 'Unknown'; @@ -5304,6 +5322,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _repeatersLoadedForIata = null; await _fetchRepeatersForZone(newZoneCode); + // Fetch updated boundary polygons for the new zone + _bordersLoadedForZone = null; + _regionBorders = []; + _fetchBorderPolygons(newZoneCode); // fire-and-forget + // 18. Re-enable heartbeat _apiService.enableHeartbeat( gpsProvider: () { @@ -5390,6 +5413,35 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// Fetch regional boundary polygons for the current zone. + /// Called after a successful zone check; idempotent per IATA so the + /// /border endpoint is only hit once per zone transition. + Future _fetchBorderPolygons(String iata) async { + if (_bordersLoadedForZone == iata) return; + if (_bordersFetchInProgress) return; + if (_currentPosition == null) return; + + _bordersFetchInProgress = true; + try { + final result = await _apiService.fetchBorderPolygons( + lat: _currentPosition!.latitude, + lon: _currentPosition!.longitude, + appVersion: _appVersion, + ); + if (result != null && result.isNotEmpty) { + _regionBorders = result; + _bordersLoadedForZone = iata; + debugLog('[BORDER] Loaded ${result.length} polygon(s) for $iata'); + notifyListeners(); + } else { + debugWarn( + '[BORDER] No polygons returned for zone $iata — will retry on next zone check'); + } + } finally { + _bordersFetchInProgress = false; + } + } + // ============================================ // Debug File Logging (Mobile Only) // ============================================ diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart index ba0992c..6cf329a 100644 --- a/lib/screens/offline_maps_screen.dart +++ b/lib/screens/offline_maps_screen.dart @@ -1,4 +1,4 @@ -import 'dart:math' show Point; +import 'dart:math' show Point, max, min; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; @@ -689,10 +689,19 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { MapLibreMapController? _mapController; LatLng? _boundsNE; LatLng? _boundsSW; - int _tapCount = 0; Line? _boundsLine; Fill? _boundsFill; + // Draggable corner resize handles. Each corner has two stacked circles: + // a larger invisible-ish "halo" below and a smaller visible handle on top. + // Both are draggable so fingers landing on the halo still start a drag — + // maplibre's iOS pan gesture only hit-tests after crossing its activation + // threshold (~10pt of movement), so the touch target has to be bigger than + // the visual. `_handleIdToCorner` maps each Circle id back to its corner + // so onFeatureDrag resolves either shape to the correct bounds update. + final Map> _cornerHandles = {}; + final Map _handleIdToCorner = {}; + // Existing region overlays final List _existingFills = []; final List _existingLines = []; @@ -717,7 +726,17 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { LatLngBounds? get _selectedBounds { if (_boundsNE == null || _boundsSW == null) return null; - return LatLngBounds(southwest: _boundsSW!, northeast: _boundsNE!); + // Normalize so tile estimates stay valid mid-drag when a corner has been + // pulled past its opposite (stored SW/NE may be inverted). + final sw = LatLng( + min(_boundsSW!.latitude, _boundsNE!.latitude), + min(_boundsSW!.longitude, _boundsNE!.longitude), + ); + final ne = LatLng( + max(_boundsSW!.latitude, _boundsNE!.latitude), + max(_boundsSW!.longitude, _boundsNE!.longitude), + ); + return LatLngBounds(southwest: sw, northeast: ne); } bool get _canSubmit => @@ -777,6 +796,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { ), onMapCreated: (controller) { _mapController = controller; + controller.onFeatureDrag.add(_onFeatureDrag); }, onStyleLoadedCallback: () { if (_showExisting) _drawExistingRegions(); @@ -800,10 +820,8 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { ), child: Text( _selectedBounds != null - ? 'Region selected · ~$_estimatedTiles tiles · $_estimatedSize' - : _tapCount == 1 - ? 'Tap the opposite corner to complete the region' - : 'Tap two corners on the map to select a region', + ? 'Drag corners to resize · ~$_estimatedTiles tiles · $_estimatedSize' + : 'Tap the map to place a region', style: theme.textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w500, ), @@ -945,13 +963,15 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { ), RangeSlider( values: RangeValues(_minZoom, _maxZoom), - // OpenFreeMap vector tiles max out at z14; z15+ is pure - // overzoom (duplicate tile data). Slider caps at 15 to - // leave one overzoom step reachable without letting users - // blow out storage at z16+. - min: 0, + // Min matches the app's MapLibreMap minMaxZoomPreference + // floor (3) so users can't cache tiles the app won't + // display. Max stays at 15: OpenFreeMap vector tiles max + // out at z14, z15+ is pure overzoom (duplicate tile data), + // so 15 leaves one overzoom step reachable without + // blowing out storage. + min: 3, max: 15, - divisions: 15, + divisions: 12, labels: RangeLabels( _minZoom.round().toString(), _maxZoom.round().toString(), @@ -1011,46 +1031,42 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { ); } - void _onMapTap(Point point, LatLng coordinates) { - if (_tapCount == 0) { - setState(() { - _boundsSW = coordinates; - _boundsNE = null; - _tapCount = 1; - _error = null; - _clearBoundsOverlay(); - }); + Future _onMapTap(Point point, LatLng coordinates) async { + // Once a region exists, taps on the map do nothing — user resizes via + // the corner handles or resets to start over. + if (_boundsSW != null && _boundsNE != null) return; + if (_mapController == null) return; + + // Size the default box to ~40% of the visible map area centered on the + // tap, so the result feels proportional regardless of zoom level. + LatLngBounds visible; + try { + visible = await _mapController!.getVisibleRegion(); + } catch (e) { + debugWarn('[OFFLINE_MAP] getVisibleRegion failed: $e'); return; } - if (_tapCount != 1) return; - // Ensure SW is actually southwest and NE is northeast - final lat1 = _boundsSW!.latitude; - final lng1 = _boundsSW!.longitude; - final lat2 = coordinates.latitude; - final lng2 = coordinates.longitude; + final halfLat = (visible.northeast.latitude - visible.southwest.latitude) + .abs() * + 0.2; + final halfLng = (visible.northeast.longitude - visible.southwest.longitude) + .abs() * + 0.2; final sw = LatLng( - lat1 < lat2 ? lat1 : lat2, - lng1 < lng2 ? lng1 : lng2, + coordinates.latitude - halfLat, + coordinates.longitude - halfLng, ); final ne = LatLng( - lat1 > lat2 ? lat1 : lat2, - lng1 > lng2 ? lng1 : lng2, + coordinates.latitude + halfLat, + coordinates.longitude + halfLng, ); - // Reject selections that cross the antimeridian (lon span > 180°). - // The SW/NE min-max normalization above silently inverts such boxes - // into a ~357° wide region, which explodes tile count and MapLibre - // would refuse anyway. if ((ne.longitude - sw.longitude).abs() > 180) { setState(() { - _error = 'Selected region crosses the antimeridian. ' - 'Split into two regions (one per hemisphere).'; - _boundsSW = null; - _boundsNE = null; - _tapCount = 0; - _clearBoundsOverlay(); + _error = 'Visible area spans more than half the globe. ' + 'Zoom in before placing a region.'; }); return; } @@ -1058,10 +1074,10 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { setState(() { _boundsSW = sw; _boundsNE = ne; - _tapCount = 2; _error = null; - _drawBoundsOverlay(); }); + await _drawBoundsOverlay(); + if (mounted) setState(() {}); } void _resetBounds() { @@ -1069,7 +1085,6 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { setState(() { _boundsSW = null; _boundsNE = null; - _tapCount = 0; }); } @@ -1136,11 +1151,14 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { return; } - final sw = _boundsSW!; - final ne = _boundsNE!; - final nw = LatLng(ne.latitude, sw.longitude); - final se = LatLng(sw.latitude, ne.longitude); - final ring = [sw, se, ne, nw, sw]; + final corners = _cornerPositions(); + final ring = [ + corners['SW']!, + corners['SE']!, + corners['NE']!, + corners['NW']!, + corners['SW']!, + ]; try { _boundsFill = await _mapController!.addFill(FillOptions( @@ -1154,6 +1172,29 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { lineWidth: 2.0, lineOpacity: 0.8, )); + for (final entry in corners.entries) { + // Halo first so it renders below the visible handle. ~44pt diameter + // gives the finger a comfortable touch target even after iOS's pan + // activation threshold eats ~10pt of travel before hit-testing. + final halo = await _mapController!.addCircle(CircleOptions( + geometry: entry.value, + circleRadius: 22, + circleColor: '#4A90D9', + circleOpacity: 0.18, + draggable: true, + )); + final handle = await _mapController!.addCircle(CircleOptions( + geometry: entry.value, + circleRadius: 7, + circleColor: '#FFFFFF', + circleStrokeColor: '#4A90D9', + circleStrokeWidth: 2.5, + draggable: true, + )); + _cornerHandles[entry.key] = [halo, handle]; + _handleIdToCorner[halo.id] = entry.key; + _handleIdToCorner[handle.id] = entry.key; + } } catch (e) { debugWarn('[OFFLINE_MAP] Failed to draw bounds overlay: $e'); } @@ -1170,11 +1211,145 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { await _mapController!.removeLine(_boundsLine!); _boundsLine = null; } + for (final circles in _cornerHandles.values) { + for (final circle in circles) { + try { + await _mapController!.removeCircle(circle); + } catch (_) {} + } + } + _cornerHandles.clear(); + _handleIdToCorner.clear(); } catch (e) { debugWarn('[OFFLINE_MAP] Failed to clear bounds overlay: $e'); } } + /// The four visual corner LatLngs derived from the stored SW/NE pair. + /// Uses raw (non-normalized) values so handle semantics stay stable + /// mid-drag; the polygon wraps correctly either way. + Map _cornerPositions() { + final sw = _boundsSW!; + final ne = _boundsNE!; + return { + 'SW': sw, + 'SE': LatLng(sw.latitude, ne.longitude), + 'NE': ne, + 'NW': LatLng(ne.latitude, sw.longitude), + }; + } + + Future _refreshBoundsGeometry() async { + if (_mapController == null || + _boundsFill == null || + _boundsLine == null || + _boundsSW == null || + _boundsNE == null) { + return; + } + final corners = _cornerPositions(); + final ring = [ + corners['SW']!, + corners['SE']!, + corners['NE']!, + corners['NW']!, + corners['SW']!, + ]; + try { + await _mapController! + .updateFill(_boundsFill!, FillOptions(geometry: [ring])); + await _mapController! + .updateLine(_boundsLine!, LineOptions(geometry: ring)); + } catch (e) { + debugWarn('[OFFLINE_MAP] Failed to refresh bounds geometry: $e'); + } + } + + /// Repositions every handle circle except the one maplibre is already + /// dragging. Each corner has a halo + visible pair, and the non-dragged + /// circle in the dragged corner still needs to follow its partner. + Future _refreshHandles({String? skipId}) async { + if (_mapController == null) return; + final corners = _cornerPositions(); + for (final entry in corners.entries) { + final circles = _cornerHandles[entry.key]; + if (circles == null) continue; + for (final circle in circles) { + if (circle.id == skipId) continue; + try { + await _mapController! + .updateCircle(circle, CircleOptions(geometry: entry.value)); + } catch (e) { + debugWarn('[OFFLINE_MAP] Failed to update ${entry.key} handle: $e'); + } + } + } + } + + void _onFeatureDrag( + Point point, + LatLng origin, + LatLng current, + LatLng delta, + String id, + Annotation? annotation, + DragEventType eventType, + ) { + final corner = _handleIdToCorner[id]; + if (corner == null || + _boundsSW == null || + _boundsNE == null) { + return; + } + + switch (corner) { + case 'SW': + _boundsSW = current; + break; + case 'NE': + _boundsNE = current; + break; + case 'NW': + _boundsSW = LatLng(_boundsSW!.latitude, current.longitude); + _boundsNE = LatLng(current.latitude, _boundsNE!.longitude); + break; + case 'SE': + _boundsSW = LatLng(current.latitude, _boundsSW!.longitude); + _boundsNE = LatLng(_boundsNE!.latitude, current.longitude); + break; + } + + _refreshBoundsGeometry(); + _refreshHandles(skipId: id); + + if (eventType == DragEventType.end) { + // Snap stored SW/NE to normalized orientation and realign every + // handle (including the one just released) to its semantic corner. + final sw = LatLng( + min(_boundsSW!.latitude, _boundsNE!.latitude), + min(_boundsSW!.longitude, _boundsNE!.longitude), + ); + final ne = LatLng( + max(_boundsSW!.latitude, _boundsNE!.latitude), + max(_boundsSW!.longitude, _boundsNE!.longitude), + ); + _boundsSW = sw; + _boundsNE = ne; + + if ((ne.longitude - sw.longitude).abs() > 180) { + _error = 'Selected region crosses the antimeridian. ' + 'Split into two regions (one per hemisphere).'; + } else { + _error = null; + } + + _refreshHandles(); + _refreshBoundsGeometry(); + } + + if (mounted) setState(() {}); + } + Future _startDownload() async { final bounds = _selectedBounds; if (bounds == null) return; diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 1cdd432..2901016 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -25,6 +25,7 @@ class ApiService { static const String wardriveEndpoint = '$baseUrl/wardrive-api.php/wardrive'; static const String geoAuthStatusUrl = '$baseUrl/wardrive-api.php/status'; static const String geoAuthUrl = '$baseUrl/wardrive-api.php/auth'; + static const String borderUrl = '$baseUrl/wardrive-api.php/border'; /// API key — injected at build time via --dart-define=API_KEY=... static const String apiKey = String.fromEnvironment('API_KEY'); @@ -221,6 +222,79 @@ class ApiService { } } + /// Fetch regional boundary polygons from the MeshMapper API. + /// + /// Returns a list of `{code, polygon}` maps where `polygon` is a list of + /// `[lat, lon]` pairs (as sent by the server). Returns `null` on failure + /// or maintenance mode. + Future>?> fetchBorderPolygons({ + required double lat, + required double lon, + required String appVersion, + }) async { + final stopwatch = Stopwatch()..start(); + try { + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final payload = { + 'lat': lat, + 'lng': lon, + 'ver': appVersion, + 'timestamp': timestamp, + 'key': apiKey, + }; + + final response = await _client + .post( + Uri.parse(borderUrl), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); + + stopwatch.stop(); + + if (response.statusCode != 200) { + debugError( + '[BORDER] /wardrive-api.php/border returned HTTP ${response.statusCode}'); + debugError( + '[BORDER] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + } + + Map? data; + try { + data = json.decode(response.body) as Map; + } on FormatException { + debugError( + '[BORDER] Non-JSON response from /border (HTTP ${response.statusCode}): ' + '${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); + } + + _logApiCall( + endpoint: '/wardrive-api.php/border', + method: 'POST', + stopwatch: stopwatch, + statusCode: response.statusCode, + request: payload, + response: data, + ); + + if (data == null) return null; + if (_checkMaintenanceMode(data)) return null; + + final polygons = data['polygons']; + if (polygons is! List) return null; + + return polygons + .whereType() + .map((p) => Map.from(p)) + .toList(); + } catch (e) { + stopwatch.stop(); + debugError('[BORDER] POST /wardrive-api.php/border failed: $e'); + return null; + } + } + /// Request authentication with MeshMapper geo-auth API /// Matches requestAuth() in wardrive.js /// diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index fe7b9e8..4485c36 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -447,6 +447,12 @@ class _MapWidgetState extends State { static const _repeaterClusterBubbleLayerId = 'repeaters-cluster-bubble'; static const _repeaterClusterCountLayerId = 'repeaters-cluster-count'; + // Regional boundary (from /border API — always visible) + static const _regionBorderSourceId = 'region-border-source'; + static const _regionBorderLineLayerId = 'region-border-line'; + static const _regionBorderLabelLayerId = 'region-border-label'; + int _lastBordersSignature = -1; + // Tracks which marker style preference the coverage images are currently // registered for. When the user changes their preference, we re-register. String? _registeredCoverageStyle; @@ -1169,6 +1175,20 @@ class _MapWidgetState extends State { } } + // Watch for region boundary polygon changes. Signature is derived from + // the polygon codes AND point counts so zone transfers (same code, new + // shape) and refreshes both trigger a rebuild. + final borders = appState.regionBorders; + final bordersSig = Object.hashAll(borders.map( + (p) => Object.hash(p['code'], (p['polygon'] as List?)?.length ?? 0), + )); + if (bordersSig != _lastBordersSignature && _isMapReady && _styleLoaded) { + _lastBordersSignature = bordersSig; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _refreshRegionBorders(appState); + }); + } + // Detect coverage overlay opacity change (user dragged the slider in // Settings → General) and push it to the live raster layer without // rebuilding the whole overlay. Skipped while ping focus mode is active — @@ -1345,6 +1365,13 @@ class _MapWidgetState extends State { return; } + // Regional boundary: either the line or the label → info dialog. + if (layerId == _regionBorderLineLayerId || + layerId == _regionBorderLabelLayerId) { + _showBorderInfoDialog(); + return; + } + // GPS marker tap: the GPS marker is a non-interactive symbol on the // annotation manager layer (which sits ON TOP of all custom layers in // paint order). Without intervention, taps on the GPS marker hit the @@ -1502,6 +1529,12 @@ class _MapWidgetState extends State { await _refreshCoverageOverlay(appState); _lastOverlayZoneCode = appState.zoneCode; + // Regional boundary layer — style reload wipes custom sources/layers. + // Reset the signature so the build()-driven watcher will repaint even + // if the polygon list hasn't changed (it almost always hasn't). + _lastBordersSignature = -1; + await _refreshRegionBorders(appState); + // Start tile-load timeout. If onMapIdle doesn't fire within N seconds, // we assume tiles are failing to load (network down, server error, etc.) // and surface a banner. Cleared as soon as onMapIdle fires. @@ -2410,6 +2443,135 @@ class _MapWidgetState extends State { } } + /// Rebuilds the regional boundary layer from `appState.regionBorders`. + /// Always-on: renders whenever polygons are present, independent of BLE + /// or auth state. Idempotent — safe to call repeatedly (removes existing + /// source/layers first). + Future _refreshRegionBorders(AppStateProvider appState) async { + if (_mapController == null || !_styleLoaded) return; + + // Remove existing layers (label, line) and source. Order matters: layers + // reference the source, so they must go first. Each try/catch tolerates + // a missing layer on the first call. + try { + await _mapController!.removeLayer(_regionBorderLabelLayerId); + } catch (_) {} + try { + await _mapController!.removeLayer(_regionBorderLineLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_regionBorderSourceId); + } catch (_) {} + + final borders = appState.regionBorders; + if (borders.isEmpty) return; + + // Build a FeatureCollection. API sends `[lat, lon]` pairs; GeoJSON wants + // `[lon, lat]` — flip during conversion. Polygon rings must be closed, + // so append the first point if the last doesn't already match. + final features = >[]; + for (final entry in borders) { + final code = entry['code']?.toString() ?? ''; + final raw = entry['polygon']; + if (raw is! List || raw.length < 3) continue; + + final ring = >[]; + for (final pt in raw) { + if (pt is! List || pt.length < 2) continue; + final lat = (pt[0] as num).toDouble(); + final lon = (pt[1] as num).toDouble(); + ring.add([lon, lat]); + } + if (ring.length < 3) continue; + if (ring.first[0] != ring.last[0] || ring.first[1] != ring.last[1]) { + ring.add([ring.first[0], ring.first[1]]); + } + + features.add({ + 'type': 'Feature', + 'properties': { + 'iata': code, + 'label': '$code BOUNDARY', + }, + 'geometry': { + 'type': 'Polygon', + 'coordinates': [ring], + }, + }); + } + + if (features.isEmpty) return; + + final geojson = { + 'type': 'FeatureCollection', + 'features': features, + }; + + try { + await _mapController!.addSource( + _regionBorderSourceId, + GeojsonSourceProperties(data: geojson), + ); + + // Render beneath the repeater cluster so repeaters stay tappable on top. + // Fallback gracefully if cluster layer isn't ready yet. + final belowLayer = + _clusterLayersReady ? _repeaterClusterBubbleLayerId : null; + + await _mapController!.addLineLayer( + _regionBorderSourceId, + _regionBorderLineLayerId, + const LineLayerProperties( + lineColor: '#FF6A00', + lineOpacity: 0.9, + lineWidth: 3.0, + lineCap: 'round', + lineJoin: 'round', + ), + belowLayerId: belowLayer, + ); + + await _mapController!.addSymbolLayer( + _regionBorderSourceId, + _regionBorderLabelLayerId, + const SymbolLayerProperties( + symbolPlacement: 'line', + textField: ['get', 'label'], + textSize: 12, + textColor: '#FF6A00', + textHaloColor: '#FFFFFF', + textHaloWidth: 1.5, + textKeepUpright: true, + textFont: _defaultFontStack, + ), + minzoom: 13, + belowLayerId: belowLayer, + ); + } catch (e) { + debugError('[MAP] Failed to add region border layer: $e'); + } + } + + /// Shows the dialog explaining how to expand the regional boundary. + void _showBorderInfoDialog() { + if (!mounted) return; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Region Boundary'), + content: const Text( + 'To expand the boundary, talk to your MeshMapper regional admin.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + /// Diff-syncs the distance label symbols shown in focus mode. Each label is /// a bitmap pill (white text on a dark rounded rectangle background, baked /// into an addImage icon) placed at the midpoint of the ping→repeater line. From 8b0fe334a55f43666cb69b942e5bdd5d086df09a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 19 Apr 2026 22:13:39 -0400 Subject: [PATCH 013/100] Modifications to offline maps and cache management --- android/app/build.gradle.kts | 6 + .../kotlin/net/meshmapper/app/MainActivity.kt | 42 ++- ios/Runner/AppDelegate.swift | 138 ++++++++++ lib/screens/offline_maps_screen.dart | 255 +++++++++++++++++- lib/screens/settings_screen.dart | 153 +++++------ lib/services/offline_map_service.dart | 44 ++- lib/services/tile_cache_service.dart | 54 ++++ lib/widgets/map_widget.dart | 23 +- 8 files changed, 614 insertions(+), 101 deletions(-) create mode 100644 lib/services/tile_cache_service.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d7c6a8d..4f06511 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -71,4 +71,10 @@ flutter { dependencies { // Required for flutter_local_notifications core library desugaring coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") + + // MapLibre Android SDK — already pulled transitively by maplibre_gl, but + // declaring it explicitly gives MainActivity.kt compile-time access to + // OfflineManager for the tile cache MethodChannel handlers. Version must + // match maplibre_gl-0.25.0's transitive dep. + implementation("org.maplibre.gl:android-sdk:12.3.1") } diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt index e7ff4c7..36efd10 100644 --- a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt +++ b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt @@ -1,5 +1,45 @@ package net.meshmapper.app import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import org.maplibre.android.offline.OfflineManager +import java.io.File -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // MapLibre tile cache management. Mirrors AppDelegate.swift's iOS + // implementation. Called from Dart's TileCacheService by the Offline + // Maps screen's Tile Cache card. + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "meshmapper/tile_cache") + .setMethodCallHandler { call, result -> + when (call.method) { + "getCacheSize" -> { + val dbFile = File(applicationContext.getDatabasePath("mbgl-offline.db").absolutePath) + result.success(if (dbFile.exists()) dbFile.length() else 0L) + } + "clearAmbientCache" -> { + OfflineManager.getInstance(applicationContext).clearAmbientCache( + object : OfflineManager.FileSourceCallback { + override fun onSuccess() = result.success(null) + override fun onError(message: String) = + result.error("clear_failed", message, null) + } + ) + } + "invalidateAmbientCache" -> { + OfflineManager.getInstance(applicationContext).invalidateAmbientCache( + object : OfflineManager.FileSourceCallback { + override fun onSuccess() = result.success(null) + override fun onError(message: String) = + result.error("invalidate_failed", message, null) + } + ) + } + else -> result.notImplemented() + } + } + } +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 0504495..a5066af 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,9 +1,65 @@ import Flutter +import MapLibre import UIKit import flutter_background_service_ios +/// URLProtocol that fails fast for MapLibre tile/style/glyph/sprite requests +/// while iOS offline-map mode is engaged. MapLibre-iOS reacts to the failure +/// by serving whatever it has in its internal tile cache (downloaded offline +/// regions + opportunistically cached tiles) and renders everything else as +/// the style's background layer, which matches Android's setOffline(true) +/// behavior closely enough for the "Use Downloaded Tiles Only" toggle. +/// +/// Hosts are kept in sync with the map style URLs in map_widget.dart's +/// MapStyleExtension.styleUrl (tiles.openfreemap.org) and the inline satellite +/// style JSON (server.arcgisonline.com). The meshmapper.net coverage overlay +/// isn't listed because _addCoverageOverlay already short-circuits when the +/// preference is off — no network requests are made in that state. +class TileBlockingURLProtocol: URLProtocol { + static let blockedHosts: Set = [ + "tiles.openfreemap.org", + "server.arcgisonline.com", + ] + + override class func canInit(with request: URLRequest) -> Bool { + guard let host = request.url?.host else { return false } + return blockedHosts.contains(host) + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + let error = NSError( + domain: NSURLErrorDomain, + code: NSURLErrorNotConnectedToInternet, + userInfo: [NSLocalizedDescriptionKey: "Offline map mode active"] + ) + client?.urlProtocol(self, didFailWithError: error) + } + + override func stopLoading() {} +} + +class IOSMapOfflineBridge { + private var registered = false + + func setOffline(_ offline: Bool) { + if offline, !registered { + URLProtocol.registerClass(TileBlockingURLProtocol.self) + registered = true + } else if !offline, registered { + URLProtocol.unregisterClass(TileBlockingURLProtocol.self) + registered = false + } + } +} + @main @objc class AppDelegate: FlutterAppDelegate { + private let mapOfflineBridge = IOSMapOfflineBridge() + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -19,6 +75,88 @@ import flutter_background_service_ios // Register background service SwiftFlutterBackgroundServicePlugin.taskIdentifier = "net.meshmapper.app.background" + if let controller = window?.rootViewController as? FlutterViewController { + // Method channel: iOS map offline mode bridge. Dart calls + // `setOffline` from map_widget.dart to toggle the URLProtocol + // interceptor that forces tile requests to fail fast, letting + // MapLibre-iOS serve only cached/downloaded tiles. + let offlineChannel = FlutterMethodChannel( + name: "meshmapper/ios_map_offline", + binaryMessenger: controller.binaryMessenger + ) + offlineChannel.setMethodCallHandler { [weak self] call, result in + guard let self = self else { + result(FlutterError(code: "unavailable", message: "bridge deallocated", details: nil)) + return + } + switch call.method { + case "setOffline": + let args = call.arguments as? [String: Any] + let offline = args?["offline"] as? Bool ?? false + self.mapOfflineBridge.setOffline(offline) + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + // Method channel: MapLibre tile cache management. Mirrors the Android + // handler in MainActivity.kt. Dart's TileCacheService calls into these + // from the Offline Maps screen's Tile Cache card. + let tileCacheChannel = FlutterMethodChannel( + name: "meshmapper/tile_cache", + binaryMessenger: controller.binaryMessenger + ) + tileCacheChannel.setMethodCallHandler { call, result in + switch call.method { + case "getCacheSize": + result(AppDelegate.mapCacheSizeBytes()) + case "clearAmbientCache": + MLNOfflineStorage.shared.clearAmbientCache { error in + if let error = error { + result(FlutterError( + code: "clear_failed", + message: error.localizedDescription, + details: nil)) + } else { + result(nil) + } + } + case "invalidateAmbientCache": + MLNOfflineStorage.shared.invalidateAmbientCache { error in + if let error = error { + result(FlutterError( + code: "invalidate_failed", + message: error.localizedDescription, + details: nil)) + } else { + result(nil) + } + } + default: + result(FlutterMethodNotImplemented) + } + } + } + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + /// Mirrors `MapLibreMapsPlugin.getTilesUrl()`: the maplibre_gl iOS plugin + /// stores its tile cache at `//.mapbox/cache.db`. + /// Returns 0 if the file doesn't exist. + private static func mapCacheSizeBytes() -> Int64 { + guard let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask).first, + let bundleId = Bundle.main.bundleIdentifier else { + return 0 + } + let cacheUrl = appSupport + .appendingPathComponent(bundleId) + .appendingPathComponent(".mapbox") + .appendingPathComponent("cache.db") + let attrs = try? FileManager.default.attributesOfItem(atPath: cacheUrl.path) + return (attrs?[.size] as? Int64) ?? 0 + } } diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart index 6cf329a..7ef471f 100644 --- a/lib/screens/offline_maps_screen.dart +++ b/lib/screens/offline_maps_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async' show unawaited; import 'dart:math' show Point, max, min; import 'package:flutter/foundation.dart' show kIsWeb; @@ -7,6 +8,7 @@ import 'package:provider/provider.dart'; import '../providers/app_state_provider.dart'; import '../services/offline_map_service.dart'; +import '../services/tile_cache_service.dart'; import '../utils/debug_logger_io.dart'; import '../widgets/app_toast.dart'; import '../widgets/map_widget.dart' show MapStyle, MapStyleExtension; @@ -31,12 +33,16 @@ class OfflineMapsScreen extends StatefulWidget { } class _OfflineMapsScreenState extends State { + int? _tileCacheBytes; + bool _tileCacheBusy = false; + @override void initState() { super.initState(); // Listen for background download completions to show a toast. final service = context.read(); service.addListener(_onServiceUpdate); + _refreshTileCacheSize(); } @override @@ -48,6 +54,12 @@ class _OfflineMapsScreenState extends State { super.dispose(); } + Future _refreshTileCacheSize() async { + final bytes = await TileCacheService.instance.getCacheSizeBytes(); + if (!mounted) return; + setState(() => _tileCacheBytes = bytes); + } + void _onServiceUpdate() { if (!mounted) return; final service = context.read(); @@ -77,6 +89,8 @@ class _OfflineMapsScreenState extends State { children: [ _buildStorageCard(context, service, theme, isDark), const SizedBox(height: 8), + _buildTileCacheCard(theme), + const SizedBox(height: 8), _buildDownloadedRegionsCard( context, service, theme, isDark), const SizedBox(height: 8), @@ -226,6 +240,164 @@ class _OfflineMapsScreenState extends State { ); } + // ────────────────────────────────────────────── + // Tile cache card (ambient cache — opportunistically cached tiles) + // ────────────────────────────────────────────── + + Widget _buildTileCacheCard(ThemeData theme) { + final bytes = _tileCacheBytes; + final sizeDisplay = bytes == null + ? '—' + : bytes < 1024 + ? '$bytes B' + : bytes < 1024 * 1024 + ? '${(bytes / 1024).toStringAsFixed(1)} KB' + : '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Tile Cache', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.refresh, size: 20), + onPressed: _tileCacheBusy ? null : _refreshTileCacheSize, + tooltip: 'Refresh size', + visualDensity: VisualDensity.compact, + ), + ], + ), + const SizedBox(height: 4), + Text( + 'On-disk size: $sizeDisplay', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 4), + Text( + 'Includes both downloaded regions and opportunistically cached ' + 'tiles from normal map panning.', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _tileCacheBusy ? null : _onInvalidateTileCache, + icon: const Icon(Icons.refresh_outlined, size: 18), + label: const Text('Invalidate'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: _tileCacheBusy ? null : _onClearTileCache, + icon: const Icon(Icons.delete_sweep_outlined, size: 18), + label: const Text('Clear'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Future _onInvalidateTileCache() async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Invalidate tile cache?'), + content: const Text( + 'Marks cached tiles as stale so they refresh on next view. ' + 'Downloaded regions are not affected.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Invalidate'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _tileCacheBusy = true); + try { + await TileCacheService.instance.invalidateAmbientCache(); + if (!mounted) return; + AppToast.success(context, 'Tile cache invalidated'); + } catch (e) { + if (!mounted) return; + AppToast.error(context, 'Invalidate failed: $e'); + } finally { + if (mounted) setState(() => _tileCacheBusy = false); + await _refreshTileCacheSize(); + } + } + + Future _onClearTileCache() async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Clear tile cache?'), + content: const Text( + 'Removes opportunistically cached tiles from disk. ' + 'Downloaded regions are preserved.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + TextButton( + style: TextButton.styleFrom(foregroundColor: Colors.red), + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Clear'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _tileCacheBusy = true); + try { + await TileCacheService.instance.clearAmbientCache(); + if (!mounted) return; + AppToast.success(context, 'Tile cache cleared'); + } catch (e) { + if (!mounted) return; + AppToast.error(context, 'Clear failed: $e'); + } finally { + if (mounted) setState(() => _tileCacheBusy = false); + await _refreshTileCacheSize(); + } + } + // ────────────────────────────────────────────── // Downloaded regions list // ────────────────────────────────────────────── @@ -316,6 +488,19 @@ class _OfflineMapsScreenState extends State { Widget _buildDownloadProgressCard(BuildContext context, OfflineMapService service, ThemeData theme, bool isDark) { + final progress = service.downloadProgress ?? 0; + final isPreparing = progress == 0; + final speed = service.downloadBytesPerSecond; + final eta = service.downloadEta; + + String? stats; + if (!isPreparing) { + final parts = []; + if (speed != null) parts.add(_formatSpeed(speed)); + if (eta != null) parts.add('${_formatDuration(eta)} remaining'); + if (parts.isNotEmpty) stats = parts.join(' · '); + } + return Card( margin: EdgeInsets.zero, clipBehavior: Clip.antiAlias, @@ -354,7 +539,7 @@ class _OfflineMapsScreenState extends State { ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( - value: service.downloadProgress ?? 0, + value: isPreparing ? null : progress, minHeight: 8, ), ), @@ -363,14 +548,24 @@ class _OfflineMapsScreenState extends State { ), const SizedBox(width: 12), Text( - '${((service.downloadProgress ?? 0) * 100).round()}%', + isPreparing ? '—' : '${(progress * 100).round()}%', style: theme.textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w600, ), ), ], ), - const SizedBox(height: 8), + const SizedBox(height: 6), + Text( + isPreparing + ? 'Preparing download…' + : (stats ?? 'Calculating speed…'), + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey.shade600, + fontSize: 12, + ), + ), + const SizedBox(height: 6), Text( 'Download continues in the background if you leave this screen', style: TextStyle(fontSize: 11, color: Colors.grey.shade500), @@ -406,6 +601,30 @@ class _OfflineMapsScreenState extends State { ); } + /// Formats bytes/sec into a short display string (e.g. "1.2 MB/s"). + String _formatSpeed(double bytesPerSecond) { + if (bytesPerSecond < 1024) { + return '${bytesPerSecond.toStringAsFixed(0)} B/s'; + } + if (bytesPerSecond < 1024 * 1024) { + return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s'; + } + return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(1)} MB/s'; + } + + /// Formats a Duration as a short human-readable string (e.g. "4m 20s"). + String _formatDuration(Duration d) { + if (d.inSeconds < 60) return '${d.inSeconds}s'; + if (d.inMinutes < 60) { + final s = d.inSeconds % 60; + return s == 0 ? '${d.inMinutes}m' : '${d.inMinutes}m ${s}s'; + } + final minutes = d.inMinutes % 60; + return minutes == 0 + ? '${d.inHours}h' + : '${d.inHours}h ${minutes}m'; + } + Future _confirmCancelDownload( BuildContext context, OfflineMapService service) async { final name = service.downloadingRegionName ?? 'this download'; @@ -1375,30 +1594,42 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { _error = null; }); - // Await the service call. downloadRegion returns once the native - // downloader has accepted (or rejected) the job — actual tile fetches - // continue in the background after this returns. If validation fails - // (quota, free-space, style, antimeridian, etc.), lastError is set and - // isDownloading is false; otherwise the download is queued. - await service.downloadRegion( + // Kick off the download without awaiting the completer — that future + // resolves only on full completion (minutes later). We just need the + // download to reach the "active" state so we can pop back to the main + // screen where the progress card is visible. + unawaited(service.downloadRegion( name: name, bounds: bounds, styleUrl: styleUrl, styleName: _selectedStyle, minZoom: _minZoom, maxZoom: _maxZoom, - ); + )); + + // Wait briefly for the service to enter `isDownloading` or surface an + // early validation error (free-space, quota). The pre-download async + // checks complete in well under a second; cap at 2s to stay responsive. + final stopwatch = Stopwatch()..start(); + while (mounted && + stopwatch.elapsed < const Duration(seconds: 2) && + !service.isDownloading && + service.lastError == null) { + await Future.delayed(const Duration(milliseconds: 100)); + } if (!mounted) return; - if (!service.isDownloading && service.lastError != null) { + if (service.lastError != null) { setState(() { _submitting = false; _error = service.consumeLastError(); }); return; } - // Download is queued — return to the management screen. + + // Either actively downloading, still starting, or queued behind another — + // in every case the user should see the main screen's progress card. Navigator.pop(context, true); } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index a7a2909..3a8326a 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -155,35 +155,61 @@ class _SettingsScreenState extends State { }, ), if (!kIsWeb) _BackgroundModeToggle(appState: appState), - Builder(builder: (_) { - // iOS plugin (maplibre_gl 0.25.0) doesn't implement setOffline, - // and MapLibre-iOS has no public equivalent. Disable the toggle - // there so it doesn't lie to the user. - final iosUnsupported = !kIsWeb && - defaultTargetPlatform == TargetPlatform.iOS; - return SwitchListTile( - secondary: Icon( - prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), - title: const Text('Disable Map Tiles'), - subtitle: iosUnsupported - ? const Text('Not available on iOS') - : Text(prefs.mapTilesEnabled - ? 'Map and coverage tiles load normally' - : 'Network tiles disabled · downloaded regions still visible'), - value: !prefs.mapTilesEnabled, - onChanged: iosUnsupported - ? null - : (value) { - appState.updatePreferences( - prefs.copyWith(mapTilesEnabled: !value)); - }, - ); - }), + SwitchListTile( + secondary: Icon( + prefs.isImperial ? Icons.square_foot : Icons.straighten, + ), + title: const Text('Units'), + subtitle: Text( + prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), + value: prefs.isImperial, + onChanged: (isImperial) { + appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); + }, + ), + SwitchListTile( + secondary: Icon( + appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), + title: const Text('Sound Notifications'), + subtitle: Text( + appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), + value: appState.isSoundEnabled, + onChanged: (_) => appState.toggleSoundEnabled(), + ), + if (appState.isSoundEnabled) ...[ + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Ping Sent'), + subtitle: const Text('Sound when TX ping or discovery is sent'), + value: appState.isTxSoundEnabled, + onChanged: (value) => appState.setTxSoundEnabled(value), + ), + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Response Received'), + subtitle: + const Text('Sound when repeater echo or RX is received'), + value: appState.isRxSoundEnabled, + onChanged: (value) => appState.setRxSoundEnabled(value), + ), + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Disconnect Alert'), + subtitle: + const Text('Triple beep when pinging stops unexpectedly'), + value: appState.isDisconnectAlertEnabled, + onChanged: (value) => appState.setDisconnectAlertEnabled(value), + ), + ], + ]), + + // Map Management + _buildSection(context, 'Map Management', [ if (!kIsWeb) ListTile( leading: const Icon(Icons.download_for_offline), title: const Text('Offline Maps'), - subtitle: const Text('Download map tiles for offline use'), + subtitle: const Text('Download map regions for offline use'), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.push( @@ -194,6 +220,19 @@ class _SettingsScreenState extends State { ); }, ), + SwitchListTile( + secondary: Icon( + prefs.mapTilesEnabled ? Icons.cloud : Icons.cloud_off), + title: const Text('Use Downloaded Tiles Only'), + subtitle: Text(prefs.mapTilesEnabled + ? 'Online tiles load normally' + : 'Only downloaded regions are shown · no network tile requests'), + value: !prefs.mapTilesEnabled, + onChanged: (value) { + appState.updatePreferences( + prefs.copyWith(mapTilesEnabled: !value)); + }, + ), if (prefs.mapTilesEnabled) ListTile( leading: const Icon(Icons.opacity), @@ -217,29 +256,6 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.chevron_right), onTap: () => _showColorVisionSelector(context, appState), ), - SwitchListTile( - secondary: Icon( - prefs.isImperial ? Icons.square_foot : Icons.straighten, - ), - title: const Text('Units'), - subtitle: Text( - prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), - value: prefs.isImperial, - onChanged: (isImperial) { - appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); - }, - ), - SwitchListTile( - secondary: const Icon(Icons.cell_tower), - title: const Text('Top Repeaters on Map'), - subtitle: - const Text('Show top 3 repeaters by SNR from last ping'), - value: prefs.showTopRepeaters, - onChanged: (value) { - appState - .updatePreferences(prefs.copyWith(showTopRepeaters: value)); - }, - ), ListTile( leading: const Icon(Icons.place), title: const Text('Map Marker Style'), @@ -255,39 +271,16 @@ class _SettingsScreenState extends State { onTap: () => _showGpsMarkerSelector(context, appState), ), SwitchListTile( - secondary: Icon( - appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), - title: const Text('Sound Notifications'), - subtitle: Text( - appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), - value: appState.isSoundEnabled, - onChanged: (_) => appState.toggleSoundEnabled(), + secondary: const Icon(Icons.cell_tower), + title: const Text('Top Repeaters on Map'), + subtitle: + const Text('Show top 3 repeaters by SNR from last ping'), + value: prefs.showTopRepeaters, + onChanged: (value) { + appState + .updatePreferences(prefs.copyWith(showTopRepeaters: value)); + }, ), - if (appState.isSoundEnabled) ...[ - SwitchListTile( - secondary: const SizedBox(width: 24), - title: const Text('Ping Sent'), - subtitle: const Text('Sound when TX ping or discovery is sent'), - value: appState.isTxSoundEnabled, - onChanged: (value) => appState.setTxSoundEnabled(value), - ), - SwitchListTile( - secondary: const SizedBox(width: 24), - title: const Text('Response Received'), - subtitle: - const Text('Sound when repeater echo or RX is received'), - value: appState.isRxSoundEnabled, - onChanged: (value) => appState.setRxSoundEnabled(value), - ), - SwitchListTile( - secondary: const SizedBox(width: 24), - title: const Text('Disconnect Alert'), - subtitle: - const Text('Triple beep when pinging stops unexpectedly'), - value: appState.isDisconnectAlertEnabled, - onChanged: (value) => appState.setDisconnectAlertEnabled(value), - ), - ], ]), // Ping Settings diff --git a/lib/services/offline_map_service.dart b/lib/services/offline_map_service.dart index e14d816..130b5da 100644 --- a/lib/services/offline_map_service.dart +++ b/lib/services/offline_map_service.dart @@ -176,6 +176,38 @@ class OfflineMapService extends ChangeNotifier { /// quota pre-check so queued jobs can't jointly bust the limit. int _activeEstBytes = 0; + /// Rolling window of recent (timestamp, progress 0-1) samples used to + /// compute smoothed download speed and ETA. Oldest-first. Cleared on + /// download start/end/cancel. + final List<({DateTime at, double progress})> _progressSamples = []; + static const int _progressWindowSize = 5; + + /// Instantaneous download speed in bytes/sec, computed over the last few + /// progress events. Null until at least two samples are available. + double? get downloadBytesPerSecond { + if (_progressSamples.length < 2 || _activeEstBytes <= 0) return null; + final first = _progressSamples.first; + final last = _progressSamples.last; + final seconds = last.at.difference(first.at).inMilliseconds / 1000.0; + if (seconds <= 0) return null; + final deltaBytes = (last.progress - first.progress) * _activeEstBytes; + if (deltaBytes <= 0) return null; + return deltaBytes / seconds; + } + + /// Estimated time remaining based on the current smoothed speed. Null if + /// no speed data yet, or if the download is effectively stalled. + /// Capped at 99 minutes to keep the display sane during warm-up wobbles. + Duration? get downloadEta { + final bps = downloadBytesPerSecond; + final progress = _downloadProgress; + if (bps == null || bps <= 0 || progress == null) return null; + final remainingBytes = (1.0 - progress) * _activeEstBytes; + if (remainingBytes <= 0) return Duration.zero; + final seconds = (remainingBytes / bps).round(); + return Duration(seconds: seconds.clamp(0, 99 * 60)); + } + /// FIFO queue of pending downloads. Drained one at a time after the /// current download finishes (or is cancelled). final List<_QueuedDownload> _queue = []; @@ -516,6 +548,7 @@ class OfflineMapService extends ChangeNotifier { _downloadingRegionName = job.name; _activeCompleter = job.completer; _activeEstBytes = job.estBytes; + _progressSamples.clear(); _lastError = null; _lastCompletedName = null; notifyListeners(); @@ -560,6 +593,7 @@ class OfflineMapService extends ChangeNotifier { _activeRegionId = null; _activeCompleter = null; _activeEstBytes = 0; + _progressSamples.clear(); _lastError = 'Download failed (${e.runtimeType}): $e'; notifyListeners(); _showErrorNotification(job.name); @@ -586,6 +620,7 @@ class OfflineMapService extends ChangeNotifier { _activeRegionId = null; _activeCompleter = null; _activeEstBytes = 0; + _progressSamples.clear(); _lastCompletedName = name; notifyListeners(); _showCompleteNotification(name); @@ -607,7 +642,12 @@ class OfflineMapService extends ChangeNotifier { _drainQueue(); }); } else if (status is InProgress) { - _downloadProgress = status.progress / 100.0; + final progress = status.progress / 100.0; + _downloadProgress = progress; + _progressSamples.add((at: DateTime.now(), progress: progress)); + if (_progressSamples.length > _progressWindowSize) { + _progressSamples.removeAt(0); + } notifyListeners(); // Throttle notification updates to every 2% to avoid flooding final percent = status.progress.round(); @@ -645,6 +685,7 @@ class OfflineMapService extends ChangeNotifier { _activeRegionId = null; _activeCompleter = null; _activeEstBytes = 0; + _progressSamples.clear(); _lastError = 'Download failed: $detail'; _showErrorNotification(name); notifyListeners(); @@ -682,6 +723,7 @@ class OfflineMapService extends ChangeNotifier { _activeRegionId = null; _activeCompleter = null; _activeEstBytes = 0; + _progressSamples.clear(); await _dismissProgressNotification(); notifyListeners(); diff --git a/lib/services/tile_cache_service.dart b/lib/services/tile_cache_service.dart new file mode 100644 index 0000000..c5c1301 --- /dev/null +++ b/lib/services/tile_cache_service.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/services.dart'; + +import '../utils/debug_logger_io.dart'; + +/// Manages MapLibre's ambient tile cache — tiles opportunistically stored +/// during normal map use, separate from the explicitly downloaded offline +/// regions tracked by [OfflineMapService]. +/// +/// Routed through a platform MethodChannel on `meshmapper/tile_cache`: +/// - iOS handler: AppDelegate.swift (uses `MLNOfflineStorage.shared`) +/// - Android handler: MainActivity.kt (uses `OfflineManager.getInstance(ctx)`) +/// +/// Both handlers operate on MapLibre's global offline storage singleton, so +/// they don't depend on any map widget being alive. +/// +/// Size reporting reads the underlying cache database file directly. It +/// includes BOTH ambient cache and downloaded regions — MapLibre stores them +/// in the same SQLite file. +class TileCacheService { + TileCacheService._(); + static final TileCacheService instance = TileCacheService._(); + + static const _channel = MethodChannel('meshmapper/tile_cache'); + + /// Total size in bytes of the MapLibre cache database. Includes both + /// ambient cache and downloaded offline regions. Returns 0 on web or on + /// unexpected channel errors. + Future getCacheSizeBytes() async { + if (kIsWeb) return 0; + try { + final raw = await _channel.invokeMethod('getCacheSize'); + // iOS returns Int64 → Dart int; Android returns Long → Dart int. + if (raw is int) return raw; + if (raw is num) return raw.toInt(); + return 0; + } catch (e) { + debugWarn('[OFFLINE_MAP] Cache size read failed: $e'); + return 0; + } + } + + Future clearAmbientCache() async { + if (kIsWeb) throw UnsupportedError('Not supported on web'); + await _channel.invokeMethod('clearAmbientCache'); + debugLog('[OFFLINE_MAP] Ambient cache cleared'); + } + + Future invalidateAmbientCache() async { + if (kIsWeb) throw UnsupportedError('Not supported on web'); + await _channel.invokeMethod('invalidateAmbientCache'); + debugLog('[OFFLINE_MAP] Ambient cache invalidated'); + } +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 4485c36..1c61b9c 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -6,6 +6,7 @@ import 'dart:ui' as ui; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show MethodChannel; import 'package:geolocator/geolocator.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:provider/provider.dart'; @@ -1103,16 +1104,24 @@ class _MapWidgetState extends State { ); } + /// Method channel to the iOS native bridge (AppDelegate.swift). iOS + /// maplibre_gl 0.25.0 has no `setOffline` implementation, so we ship our + /// own: a URLProtocol that fails tile requests fast while offline mode is + /// engaged, letting MapLibre-iOS render only its cached tiles. + static const _iosOfflineChannel = + MethodChannel('meshmapper/ios_map_offline'); + /// Toggle MapLibre between online (network tiles) and offline (cache-only). - /// - /// No-op on iOS: maplibre_gl 0.25.0 never implemented the `setOffline` - /// method handler on iOS, and MapLibre-iOS itself doesn't expose an - /// equivalent of Android's `ConnectivityReceiver` hook. The "Disable Map - /// Tiles" Settings switch is marked unavailable on iOS for the same reason. + /// Android uses the plugin's native `setOffline`; iOS uses our bridge. Future _setOfflineIfSupported(bool offline) async { - if (kIsWeb || Platform.isIOS) return; + if (kIsWeb) return; try { - await setOffline(offline); + if (Platform.isIOS) { + await _iosOfflineChannel + .invokeMethod('setOffline', {'offline': offline}); + } else { + await setOffline(offline); + } debugLog('[MAP] setOffline($offline) — ' 'tiles ${offline ? "cache-only" : "enabled"}'); } catch (e) { From 3e5242fdc412c722bc1c2bca89404078cbb7ef41 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 20 Apr 2026 19:14:08 -0400 Subject: [PATCH 014/100] feat(offline-maps): update offline region management and cache handling - Bump version to 1.3.0 in .build_version. - Modify Build.sh to include export-options-plist for iOS IPA builds. - Enhance MainActivity.kt to support fetching per-region downloaded byte counts. - Update AppDelegate.swift to handle region sizes and improve cache management. - Refactor offline_maps_screen.dart to refresh region sizes and update UI accordingly. - Adjust settings_screen.dart to reflect changes in terminology from "regions" to "areas". - Improve offline_map_service.dart to track actual bytes used by downloaded areas. - Add tile_cache_service.dart method to retrieve per-region sizes from the native SDK. - Modify map_widget.dart to manage coverage overlay based on tile availability. - Create ExportOptions.plist for iOS export settings. --- .build_version | 2 +- Build.sh | 2 +- .../kotlin/net/meshmapper/app/MainActivity.kt | 48 +++ ios/ExportOptions.plist | 18 ++ ios/Runner/AppDelegate.swift | 47 ++- lib/screens/offline_maps_screen.dart | 285 ++++++++++-------- lib/screens/settings_screen.dart | 4 +- lib/services/offline_map_service.dart | 167 ++++++++-- lib/services/tile_cache_service.dart | 22 ++ lib/widgets/map_widget.dart | 17 +- 10 files changed, 448 insertions(+), 164 deletions(-) create mode 100644 ios/ExportOptions.plist diff --git a/.build_version b/.build_version index 23aa839..f0bb29e 100644 --- a/.build_version +++ b/.build_version @@ -1 +1 @@ -1.2.2 +1.3.0 diff --git a/Build.sh b/Build.sh index f682816..3cf9475 100755 --- a/Build.sh +++ b/Build.sh @@ -154,7 +154,7 @@ echo "" # Build iOS IPA echo "[3/3] Building iOS IPA..." (cd ios && pod install) -flutter build ipa --release --build-name="$VERSION_NUMBER" --build-number="$EPOCH" --dart-define="APP_VERSION=$APP_VERSION" --dart-define="API_KEY=$MESHMAPPER_API_KEY" +flutter build ipa --release --build-name="$VERSION_NUMBER" --build-number="$EPOCH" --dart-define="APP_VERSION=$APP_VERSION" --dart-define="API_KEY=$MESHMAPPER_API_KEY" --export-options-plist=ios/ExportOptions.plist cp build/ios/ipa/mesh_mapper.ipa "$IOS_DIR/MeshMapper-$FILE_TAG.ipa" echo "✓ Built: MeshMapper-$FILE_TAG.ipa" echo "" diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt index 36efd10..d529fc6 100644 --- a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt +++ b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt @@ -4,6 +4,8 @@ import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import org.maplibre.android.offline.OfflineManager +import org.maplibre.android.offline.OfflineRegion +import org.maplibre.android.offline.OfflineRegionStatus import java.io.File class MainActivity : FlutterActivity() { @@ -20,6 +22,9 @@ class MainActivity : FlutterActivity() { val dbFile = File(applicationContext.getDatabasePath("mbgl-offline.db").absolutePath) result.success(if (dbFile.exists()) dbFile.length() else 0L) } + "getRegionSizes" -> { + getRegionSizes(result) + } "clearAmbientCache" -> { OfflineManager.getInstance(applicationContext).clearAmbientCache( object : OfflineManager.FileSourceCallback { @@ -42,4 +47,47 @@ class MainActivity : FlutterActivity() { } } } + + /// Per-region downloaded byte counts, keyed by OfflineRegion.getID() which + /// is the same long ID the maplibre_gl plugin returns to Dart. Reads each + /// region's completedResourceSize (the tile+resource byte total for that + /// region as tracked by the SDK). Responds on the main thread with a + /// Map over the platform channel. + private fun getRegionSizes(result: MethodChannel.Result) { + OfflineManager.getInstance(applicationContext).listOfflineRegions( + object : OfflineManager.ListOfflineRegionsCallback { + override fun onList(regions: Array?) { + val list = regions ?: emptyArray() + if (list.isEmpty()) { + result.success(HashMap()) + return + } + val sizes = HashMap() + var remaining = list.size + for (region in list) { + region.getStatus(object : OfflineRegion.OfflineRegionStatusCallback { + override fun onStatus(status: OfflineRegionStatus?) { + if (status != null) { + sizes[region.id] = status.completedResourceSize + } + remaining -= 1 + if (remaining == 0) { + result.success(sizes) + } + } + override fun onError(error: String?) { + remaining -= 1 + if (remaining == 0) { + result.success(sizes) + } + } + }) + } + } + override fun onError(error: String) { + result.error("region_sizes_failed", error, null) + } + } + ) + } } diff --git a/ios/ExportOptions.plist b/ios/ExportOptions.plist new file mode 100644 index 0000000..eb3a79e --- /dev/null +++ b/ios/ExportOptions.plist @@ -0,0 +1,18 @@ + + + + + method + app-store-connect + teamID + DQC6TNKG5P + signingStyle + automatic + stripSwiftSymbols + + uploadSymbols + + destination + export + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index a5066af..34f4799 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -12,18 +12,26 @@ import flutter_background_service_ios /// /// Hosts are kept in sync with the map style URLs in map_widget.dart's /// MapStyleExtension.styleUrl (tiles.openfreemap.org) and the inline satellite -/// style JSON (server.arcgisonline.com). The meshmapper.net coverage overlay -/// isn't listed because _addCoverageOverlay already short-circuits when the -/// preference is off — no network requests are made in that state. +/// style JSON (server.arcgisonline.com). The coverage overlay uses per-zone +/// subdomains of meshmapper.net (e.g. `on.meshmapper.net`, `qc.meshmapper.net`) +/// so it's matched by suffix rather than an exact host entry. The Dart-side +/// add-time guard in _addCoverageOverlay covers the "toggle on at startup" +/// case; this suffix match covers the "toggle flipped while overlay is +/// already on the map" case (the Dart code doesn't remove the layer on flip). class TileBlockingURLProtocol: URLProtocol { static let blockedHosts: Set = [ "tiles.openfreemap.org", "server.arcgisonline.com", ] + static let blockedHostSuffixes: [String] = [ + ".meshmapper.net", + ] + override class func canInit(with request: URLRequest) -> Bool { guard let host = request.url?.host else { return false } - return blockedHosts.contains(host) + if blockedHosts.contains(host) { return true } + return blockedHostSuffixes.contains { host.hasSuffix($0) } } override class func canonicalRequest(for request: URLRequest) -> URLRequest { @@ -111,6 +119,8 @@ class IOSMapOfflineBridge { switch call.method { case "getCacheSize": result(AppDelegate.mapCacheSizeBytes()) + case "getRegionSizes": + AppDelegate.regionSizes(result: result) case "clearAmbientCache": MLNOfflineStorage.shared.clearAmbientCache { error in if let error = error { @@ -142,6 +152,35 @@ class IOSMapOfflineBridge { return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + /// Per-region downloaded byte counts, keyed by the Dart-side region ID that + /// maplibre_gl embeds in each pack's context JSON (`{"id": Int, "metadata": …}` + /// — see OfflineRegion.swift in the maplibre_gl plugin). Reads + /// `pack.progress.countOfTileBytesCompleted`, which is the byte size of tile + /// resources (style/sprite/glyph resources live outside this number but are + /// included in the overall cache.db file total reported by `getCacheSize`). + /// + /// Returns `{ idInt64 : bytesInt64 }` over the platform channel. Packs whose + /// context can't be decoded are skipped. + private static func regionSizes(result: @escaping FlutterResult) { + // `.packs` is nil until MLNOfflineStorage finishes its initial database + // load. In practice the maplibre_gl plugin forces that load early, but we + // return an empty map rather than failing if we're called first. + guard let packs = MLNOfflineStorage.shared.packs else { + result([Int64: Int64]()) + return + } + var sizes: [Int64: Int64] = [:] + for pack in packs { + guard let ctx = try? JSONSerialization.jsonObject(with: pack.context), + let dict = ctx as? [String: Any], + let id = dict["id"] as? Int else { + continue + } + sizes[Int64(id)] = Int64(pack.progress.countOfTileBytesCompleted) + } + result(sizes) + } + /// Mirrors `MapLibreMapsPlugin.getTilesUrl()`: the maplibre_gl iOS plugin /// stores its tile cache at `//.mapbox/cache.db`. /// Returns 0 if the file doesn't exist. diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart index 7ef471f..4173d44 100644 --- a/lib/screens/offline_maps_screen.dart +++ b/lib/screens/offline_maps_screen.dart @@ -33,7 +33,6 @@ class OfflineMapsScreen extends StatefulWidget { } class _OfflineMapsScreenState extends State { - int? _tileCacheBytes; bool _tileCacheBusy = false; @override @@ -42,7 +41,10 @@ class _OfflineMapsScreenState extends State { // Listen for background download completions to show a toast. final service = context.read(); service.addListener(_onServiceUpdate); - _refreshTileCacheSize(); + // Pull fresh per-region + total cache sizes so the storage bar reflects + // current on-disk reality (including any ambient-cache growth since the + // service last refreshed). + service.refreshRegions(); } @override @@ -54,12 +56,6 @@ class _OfflineMapsScreenState extends State { super.dispose(); } - Future _refreshTileCacheSize() async { - final bytes = await TileCacheService.instance.getCacheSizeBytes(); - if (!mounted) return; - setState(() => _tileCacheBytes = bytes); - } - void _onServiceUpdate() { if (!mounted) return; final service = context.read(); @@ -89,8 +85,6 @@ class _OfflineMapsScreenState extends State { children: [ _buildStorageCard(context, service, theme, isDark), const SizedBox(height: 8), - _buildTileCacheCard(theme), - const SizedBox(height: 8), _buildDownloadedRegionsCard( context, service, theme, isDark), const SizedBox(height: 8), @@ -105,7 +99,7 @@ class _OfflineMapsScreenState extends State { heroTag: null, onPressed: () => _showDownloadDialog(context), icon: const Icon(Icons.download), - label: const Text('Download Region'), + label: const Text('Download Area'), ) : null, ); @@ -128,7 +122,7 @@ class _OfflineMapsScreenState extends State { ), const SizedBox(height: 8), Text( - 'Use the mobile app to download map regions for offline use', + 'Use the mobile app to download map areas for offline use', style: theme.textTheme.bodySmall ?.copyWith(color: Colors.grey.shade400), textAlign: TextAlign.center, @@ -145,12 +139,23 @@ class _OfflineMapsScreenState extends State { Widget _buildStorageCard(BuildContext context, OfflineMapService service, ThemeData theme, bool isDark) { + final downloadsRatio = service.downloadsRatio; + final ambientRatio = service.ambientRatio; final usageRatio = service.usageRatio; - final barColor = usageRatio > 0.9 + + // Over-quota colors the total-used text red; the bar itself stays + // segmented so the user can still see which bucket is driving the limit. + final overQuota = usageRatio >= 1.0; + final totalColor = overQuota ? Colors.red - : usageRatio > 0.7 - ? Colors.orange - : theme.colorScheme.primary; + : (usageRatio > 0.9 + ? Colors.red + : (usageRatio > 0.7 ? Colors.orange : theme.colorScheme.primary)); + + final downloadsColor = theme.colorScheme.primary; + final ambientColor = theme.colorScheme.tertiary; + final trackColor = + isDark ? Colors.white.withValues(alpha: 0.08) : Colors.grey.shade200; return Card( margin: EdgeInsets.zero, @@ -171,6 +176,14 @@ class _OfflineMapsScreenState extends State { ), ), const Spacer(), + IconButton( + icon: const Icon(Icons.refresh, size: 18), + onPressed: _tileCacheBusy + ? null + : () => service.refreshRegions(), + tooltip: 'Refresh sizes', + visualDensity: VisualDensity.compact, + ), TextButton.icon( onPressed: () => _showStorageLimitDialog(context, service), icon: const Icon(Icons.tune, size: 16), @@ -184,28 +197,55 @@ class _OfflineMapsScreenState extends State { ), const SizedBox(height: 12), - // Usage bar + // Segmented usage bar: downloads (primary) + ambient (tertiary) + // stacked left-to-right against the storage limit. ClipRRect( borderRadius: BorderRadius.circular(6), - child: LinearProgressIndicator( - value: usageRatio, - minHeight: 20, - backgroundColor: isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.grey.shade200, - color: barColor, + child: SizedBox( + height: 20, + child: LayoutBuilder( + builder: (context, constraints) { + final total = constraints.maxWidth; + final downloadsWidth = + (downloadsRatio.clamp(0.0, 1.0)) * total; + // Ambient is clamped so the two segments can never exceed + // the track width even if the real total briefly overshoots. + final ambientWidth = + ((ambientRatio.clamp(0.0, 1.0)) * total) + .clamp(0.0, total - downloadsWidth); + return Stack( + children: [ + Positioned.fill(child: Container(color: trackColor)), + Positioned( + left: 0, + top: 0, + bottom: 0, + width: downloadsWidth, + child: Container(color: downloadsColor), + ), + Positioned( + left: downloadsWidth, + top: 0, + bottom: 0, + width: ambientWidth, + child: Container(color: ambientColor), + ), + ], + ); + }, + ), ), ), const SizedBox(height: 8), - // Usage text + // Totals Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '~${service.totalUsedDisplay} used (estimated)', + '${service.totalUsedDisplay} used', style: theme.textTheme.bodySmall?.copyWith( - color: barColor, + color: totalColor, fontWeight: FontWeight.w600, ), ), @@ -217,93 +257,36 @@ class _OfflineMapsScreenState extends State { ), ], ), - const SizedBox(height: 2), - Text( - 'Based on tile count heuristic; actual disk use may differ.', - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.grey, - fontSize: 11, - fontStyle: FontStyle.italic, - ), + const SizedBox(height: 10), + + // Legend + _buildLegendRow( + theme, + color: downloadsColor, + label: 'Downloads', + value: service.downloadsDisplay, + sublabel: '${service.regions.length} ' + 'area${service.regions.length == 1 ? '' : 's'}', ), const SizedBox(height: 4), - Text( - '${service.regions.length} region${service.regions.length == 1 ? '' : 's'} downloaded', - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.grey.shade500, - fontSize: 11, - ), + _buildLegendRow( + theme, + color: ambientColor, + label: 'Ambient cache', + value: service.ambientDisplay, + sublabel: 'auto-cached while panning', ), - ], - ), - ), - ); - } - - // ────────────────────────────────────────────── - // Tile cache card (ambient cache — opportunistically cached tiles) - // ────────────────────────────────────────────── - Widget _buildTileCacheCard(ThemeData theme) { - final bytes = _tileCacheBytes; - final sizeDisplay = bytes == null - ? '—' - : bytes < 1024 - ? '$bytes B' - : bytes < 1024 * 1024 - ? '${(bytes / 1024).toStringAsFixed(1)} KB' - : '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; - - return Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'Tile Cache', - style: theme.textTheme.titleSmall?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.refresh, size: 20), - onPressed: _tileCacheBusy ? null : _refreshTileCacheSize, - tooltip: 'Refresh size', - visualDensity: VisualDensity.compact, - ), - ], - ), - const SizedBox(height: 4), - Text( - 'On-disk size: $sizeDisplay', - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 4), - Text( - 'Includes both downloaded regions and opportunistically cached ' - 'tiles from normal map panning.', - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.grey, - fontSize: 11, - fontStyle: FontStyle.italic, - ), - ), const SizedBox(height: 12), + + // Ambient cache actions (downloads are managed below). Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: _tileCacheBusy ? null : _onInvalidateTileCache, icon: const Icon(Icons.refresh_outlined, size: 18), - label: const Text('Invalidate'), + label: const Text('Invalidate cache'), ), ), const SizedBox(width: 8), @@ -311,7 +294,7 @@ class _OfflineMapsScreenState extends State { child: OutlinedButton.icon( onPressed: _tileCacheBusy ? null : _onClearTileCache, icon: const Icon(Icons.delete_sweep_outlined, size: 18), - label: const Text('Clear'), + label: const Text('Clear cache'), style: OutlinedButton.styleFrom( foregroundColor: Colors.red, ), @@ -325,6 +308,51 @@ class _OfflineMapsScreenState extends State { ); } + Widget _buildLegendRow( + ThemeData theme, { + required Color color, + required String label, + required String value, + required String sublabel, + }) { + return Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + '· $sublabel', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey.shade500, + fontSize: 11, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + value, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } + Future _onInvalidateTileCache() async { final confirmed = await showDialog( context: context, @@ -332,7 +360,7 @@ class _OfflineMapsScreenState extends State { title: const Text('Invalidate tile cache?'), content: const Text( 'Marks cached tiles as stale so they refresh on next view. ' - 'Downloaded regions are not affected.', + 'Downloaded areas are not affected.', ), actions: [ TextButton( @@ -357,7 +385,7 @@ class _OfflineMapsScreenState extends State { AppToast.error(context, 'Invalidate failed: $e'); } finally { if (mounted) setState(() => _tileCacheBusy = false); - await _refreshTileCacheSize(); + if (mounted) await context.read().refreshCacheSize(); } } @@ -368,7 +396,7 @@ class _OfflineMapsScreenState extends State { title: const Text('Clear tile cache?'), content: const Text( 'Removes opportunistically cached tiles from disk. ' - 'Downloaded regions are preserved.', + 'Downloaded areas are preserved.', ), actions: [ TextButton( @@ -394,7 +422,7 @@ class _OfflineMapsScreenState extends State { AppToast.error(context, 'Clear failed: $e'); } finally { if (mounted) setState(() => _tileCacheBusy = false); - await _refreshTileCacheSize(); + if (mounted) await context.read().refreshCacheSize(); } } @@ -416,7 +444,7 @@ class _OfflineMapsScreenState extends State { child: Row( children: [ Text( - 'Downloaded Regions', + 'Downloaded Areas', style: theme.textTheme.titleSmall?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.bold, @@ -442,12 +470,12 @@ class _OfflineMapsScreenState extends State { size: 48, color: Colors.grey.shade400), const SizedBox(height: 8), Text( - 'No offline regions downloaded', + 'No offline areas downloaded', style: TextStyle(color: Colors.grey.shade500), ), const SizedBox(height: 4), Text( - 'Tap "Download Region" to save map tiles for offline use', + 'Tap "Download Area" to save map tiles for offline use', style: TextStyle(fontSize: 12, color: Colors.grey.shade400), textAlign: TextAlign.center, ), @@ -531,7 +559,7 @@ class _OfflineMapsScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - service.downloadingRegionName ?? 'Region', + service.downloadingRegionName ?? 'Area', style: theme.textTheme.bodyMedium, overflow: TextOverflow.ellipsis, ), @@ -709,7 +737,7 @@ class _OfflineMapsScreenState extends State { ), const SizedBox(height: 12), Text( - 'Currently using ~${service.totalUsedDisplay} (estimated)', + 'Currently using ${service.totalUsedDisplay}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey, ), @@ -769,11 +797,11 @@ class _OfflineMapsScreenState extends State { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Delete Region?'), + title: const Text('Delete Area?'), content: Text( - 'Delete "${region.name}"? This will free approximately ' + 'Delete "${region.name}"? This will free ' '${region.sizeDisplay} of storage.\n\n' - 'Note: shared tiles used by other regions may not be freed immediately.', + 'Note: shared tiles used by other areas may not be freed immediately.', ), actions: [ TextButton( @@ -796,7 +824,7 @@ class _OfflineMapsScreenState extends State { AppToast.success(context, '"${region.name}" deleted'); } else { AppToast.error( - context, service.consumeLastError() ?? 'Failed to delete region'); + context, service.consumeLastError() ?? 'Failed to delete area'); } } } @@ -807,10 +835,11 @@ class _OfflineMapsScreenState extends State { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Delete All Regions?'), + title: const Text('Delete All Areas?'), content: Text( - 'Delete all ${service.regions.length} downloaded regions? ' - 'This will free approximately ${service.totalUsedDisplay}.', + 'Delete all ${service.regions.length} downloaded areas? ' + 'This will free ${service.downloadsDisplay} of downloaded tiles ' + '(ambient cache is preserved).', ), actions: [ TextButton( @@ -829,7 +858,7 @@ class _OfflineMapsScreenState extends State { if (confirmed == true) { await service.deleteAllRegions(); if (context.mounted) { - AppToast.success(context, 'All regions deleted'); + AppToast.success(context, 'All areas deleted'); } } } @@ -998,7 +1027,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { return Scaffold( appBar: AppBar( toolbarHeight: 40, - title: const Text('Download Region', style: TextStyle(fontSize: 18)), + title: const Text('Download Area', style: TextStyle(fontSize: 18)), ), body: Column( children: [ @@ -1040,7 +1069,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { child: Text( _selectedBounds != null ? 'Drag corners to resize · ~$_estimatedTiles tiles · $_estimatedSize' - : 'Tap the map to place a region', + : 'Tap the map to place an area', style: theme.textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w500, ), @@ -1126,7 +1155,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { TextField( controller: _nameController, decoration: InputDecoration( - labelText: 'Region Name', + labelText: 'Area Name', hintText: 'e.g. Downtown Vancouver', border: OutlineInputBorder( borderRadius: BorderRadius.circular(8)), @@ -1236,7 +1265,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { ) : const Icon(Icons.download), label: - Text(_submitting ? 'Starting...' : 'Download Region'), + Text(_submitting ? 'Starting...' : 'Download Area'), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(44), ), @@ -1285,7 +1314,7 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { if ((ne.longitude - sw.longitude).abs() > 180) { setState(() { _error = 'Visible area spans more than half the globe. ' - 'Zoom in before placing a region.'; + 'Zoom in before placing an area.'; }); return; } @@ -1556,8 +1585,8 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { _boundsNE = ne; if ((ne.longitude - sw.longitude).abs() > 180) { - _error = 'Selected region crosses the antimeridian. ' - 'Split into two regions (one per hemisphere).'; + _error = 'Selected area crosses the antimeridian. ' + 'Split into two areas (one per hemisphere).'; } else { _error = null; } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 3a8326a..76b9f51 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -209,7 +209,7 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.download_for_offline), title: const Text('Offline Maps'), - subtitle: const Text('Download map regions for offline use'), + subtitle: const Text('Download map areas for offline use'), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.push( @@ -226,7 +226,7 @@ class _SettingsScreenState extends State { title: const Text('Use Downloaded Tiles Only'), subtitle: Text(prefs.mapTilesEnabled ? 'Online tiles load normally' - : 'Only downloaded regions are shown · no network tile requests'), + : 'Only downloaded areas are shown · no network tile requests'), value: !prefs.mapTilesEnabled, onChanged: (value) { appState.updatePreferences( diff --git a/lib/services/offline_map_service.dart b/lib/services/offline_map_service.dart index 130b5da..c5f0e75 100644 --- a/lib/services/offline_map_service.dart +++ b/lib/services/offline_map_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:disk_space_plus/disk_space_plus.dart'; import 'package:flutter/foundation.dart'; @@ -7,6 +8,7 @@ import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../utils/debug_logger_io.dart'; +import 'tile_cache_service.dart'; /// A pending download waiting in the FIFO queue. class _QueuedDownload { @@ -51,8 +53,17 @@ class OfflineMapRegion { final double minZoom; final double maxZoom; final DateTime createdAt; + + /// Heuristic size from tile count × 20 KB, captured at download time. + /// Used as a fallback when the native SDK hasn't reported actual bytes yet. final int estimatedBytes; + /// Real bytes consumed by this region as reported by the native MapLibre + /// SDK (`MLNOfflinePack.progress.countOfTileBytesCompleted` on iOS, + /// `OfflineRegionStatus.completedResourceSize` on Android). `null` when + /// the native map hasn't reported a size for this region yet. + final int? actualBytes; + const OfflineMapRegion({ required this.id, required this.name, @@ -62,9 +73,13 @@ class OfflineMapRegion { required this.maxZoom, required this.createdAt, required this.estimatedBytes, + this.actualBytes, }); - factory OfflineMapRegion.fromOfflineRegion(OfflineRegion region) { + factory OfflineMapRegion.fromOfflineRegion( + OfflineRegion region, { + int? actualBytes, + }) { final meta = region.metadata; return OfflineMapRegion( id: region.id, @@ -83,16 +98,20 @@ class OfflineMapRegion { final String s => int.tryParse(s) ?? 0, _ => 0, }, + actualBytes: actualBytes, ); } + /// Size to show in the UI — real bytes when the native SDK has reported + /// them, falling back to the download-time estimate otherwise. + int get sizeBytes => actualBytes ?? estimatedBytes; + /// Human-readable size string. String get sizeDisplay { - if (estimatedBytes < 1024) return '$estimatedBytes B'; - if (estimatedBytes < 1024 * 1024) { - return '${(estimatedBytes / 1024).toStringAsFixed(1)} KB'; - } - return '${(estimatedBytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + final b = sizeBytes; + if (b < 1024) return '$b B'; + if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB'; + return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB'; } /// Short bounds description (e.g. "49.2°N, 123.1°W"). @@ -139,16 +158,49 @@ class OfflineMapService extends ChangeNotifier { int get storageLimitMb => _storageLimitMb; int get storageLimitBytes => _storageLimitMb * 1024 * 1024; - /// Total estimated bytes across all downloaded regions. - int get totalUsedBytes => - _regions.fold(0, (sum, r) => sum + r.estimatedBytes); + /// Actual on-disk cache.db size as reported by the native platform + /// (MapLibre stores downloaded regions and ambient tiles in one SQLite + /// file). Refreshed alongside [refreshRegions]. + int _totalCacheBytes = 0; + + /// Actual bytes used by downloaded regions alone (sum of per-region sizes + /// from the native SDK). The difference between this and [_totalCacheBytes] + /// is the ambient/auto-cached tile portion. + int _downloadsBytes = 0; + + /// Total on-disk cache size (downloads + ambient, bytes). + int get totalUsedBytes => _totalCacheBytes; + + /// Bytes attributed to explicit offline downloads. + int get downloadsBytes => _downloadsBytes; + + /// Bytes attributed to tiles cached opportunistically while panning/zooming + /// the map (total minus explicit downloads, clamped ≥ 0 because the two + /// numbers come from different native queries and can briefly diverge). + int get ambientBytes => + (_totalCacheBytes - _downloadsBytes).clamp(0, _totalCacheBytes); double get usageRatio { if (storageLimitBytes == 0) return 0; return (totalUsedBytes / storageLimitBytes).clamp(0.0, 1.0); } + /// Ratio of the storage bar filled by each bucket. Both clamp to [0,1] and + /// are computed against the limit so they visually add up against the same + /// denominator as [usageRatio]. + double get downloadsRatio { + if (storageLimitBytes == 0) return 0; + return (_downloadsBytes / storageLimitBytes).clamp(0.0, 1.0); + } + + double get ambientRatio { + if (storageLimitBytes == 0) return 0; + return (ambientBytes / storageLimitBytes).clamp(0.0, 1.0); + } + String get totalUsedDisplay => _formatBytes(totalUsedBytes); + String get downloadsDisplay => _formatBytes(_downloadsBytes); + String get ambientDisplay => _formatBytes(ambientBytes); String get storageLimitDisplay => '$_storageLimitMb MB'; // ── Download state ── @@ -378,27 +430,51 @@ class OfflineMapService extends ChangeNotifier { // ── Region queries ── - /// Refresh the list of downloaded regions from MapLibre native storage. + /// Refresh the list of downloaded regions from MapLibre native storage, + /// along with per-region byte counts and the overall on-disk cache size. Future refreshRegions() async { if (kIsWeb) return; try { final rawRegions = await getListOfRegions(); + // Pull sizes and the total cache footprint in parallel. Both come from + // our platform channel so they share the same round-trip cost. + final results = await Future.wait([ + TileCacheService.instance.getRegionSizes(), + TileCacheService.instance.getCacheSizeBytes(), + ]); + final sizes = results[0] as Map; + final totalBytes = results[1] as int; + final parsed = []; + int downloadsSum = 0; for (final r in rawRegions) { try { - parsed.add(OfflineMapRegion.fromOfflineRegion(r)); + final actual = sizes[r.id]; + parsed.add( + OfflineMapRegion.fromOfflineRegion(r, actualBytes: actual)); + downloadsSum += actual ?? 0; } catch (e) { debugWarn('[OFFLINE_MAP] Failed to parse region ${r.id}: $e'); } } _regions = parsed; _regions.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + _totalCacheBytes = totalBytes; + _downloadsBytes = downloadsSum; notifyListeners(); } catch (e) { debugError('[OFFLINE_MAP] Failed to list regions: $e'); } } + /// Refresh only the overall cache size (fast path, no region enumeration). + /// Useful after ambient cache operations that don't change region state. + Future refreshCacheSize() async { + if (kIsWeb) return; + _totalCacheBytes = await TileCacheService.instance.getCacheSizeBytes(); + notifyListeners(); + } + // ── Storage limit ── /// Update the storage limit (in MB) and persist it. @@ -415,29 +491,68 @@ class OfflineMapService extends ChangeNotifier { // ── Tile estimation ── - /// Estimate tile count for a region (rough heuristic). - /// Uses the standard 2^z tile count formula for each zoom level. + /// Native max zoom of the OpenFreeMap vector tileset. z15+ in the slider is + /// reachable but it's pure overzoom — MapLibre rasterizes z14 source tiles + /// for higher zooms without fetching new data. Capping the estimator loop + /// here keeps overzoom from being counted as additional downloads. + static const int _vectorTilesetMaxZoom = 14; + + /// Web Mercator clamps latitude to ±~85.0511° (atan(sinh(π))); beyond that + /// the projection diverges. Using the literal here keeps the tile-Y math + /// stable if a user drags a corner near a pole. + static const double _mercatorLatLimit = 85.0511; + + /// Average bytes per OpenFreeMap vector tile (MVT/PBF). Empirically ~2.4 KB + /// globally; we round up to 3 KB to leave a small margin without grossly + /// overestimating. Raster tiles would need a higher number (20–40 KB) but + /// the app only offers vector styles for offline download — if that ever + /// changes, this needs to be style-aware. + static const int _bytesPerVectorTile = 3 * 1024; + + /// Estimate tile count for a region using proper Web Mercator tile math. + /// Iterates over each zoom level the SDK will actually fetch source tiles + /// for (capped at the tileset's native max zoom). static int estimateTileCount( LatLngBounds bounds, double minZoom, double maxZoom) { + final zMin = minZoom.floor(); + final zMax = math.min(maxZoom.ceil(), _vectorTilesetMaxZoom); + if (zMax < zMin) return 0; + + final latMin = + bounds.southwest.latitude.clamp(-_mercatorLatLimit, _mercatorLatLimit); + final latMax = + bounds.northeast.latitude.clamp(-_mercatorLatLimit, _mercatorLatLimit); + final lngMin = bounds.southwest.longitude; + final lngMax = bounds.northeast.longitude; + int total = 0; - for (int z = minZoom.floor(); z <= maxZoom.ceil(); z++) { - final tilesPerSide = 1 << z; // 2^z - final lonFraction = - (bounds.northeast.longitude - bounds.southwest.longitude).abs() / - 360.0; - final latFraction = - (bounds.northeast.latitude - bounds.southwest.latitude).abs() / 180.0; - final xTiles = (lonFraction * tilesPerSide).ceil().clamp(1, tilesPerSide); - final yTiles = (latFraction * tilesPerSide).ceil().clamp(1, tilesPerSide); + for (int z = zMin; z <= zMax; z++) { + final n = 1 << z; + final x0 = ((lngMin + 180.0) / 360.0 * n).floor().clamp(0, n - 1); + final x1 = ((lngMax + 180.0) / 360.0 * n).floor().clamp(0, n - 1); + final xTiles = (x1 - x0 + 1).clamp(1, n); + // North latitude → smaller tile Y in Mercator, so yNorth uses latMax. + final yNorth = _mercatorTileY(latMax, n); + final ySouth = _mercatorTileY(latMin, n); + final yTiles = (ySouth - yNorth + 1).clamp(1, n); total += xTiles * yTiles; } return total; } - /// Rough estimate of download size in bytes from tile count. - /// Vector tiles average ~15-25 KB each; raster tiles ~20-40 KB. - /// We use 20 KB as a middle estimate. - static int estimateSizeBytes(int tileCount) => tileCount * 20 * 1024; + /// Web Mercator tile-Y index for a given latitude at zoom level `2^z = n`. + static int _mercatorTileY(double latDeg, int n) { + final latRad = latDeg * math.pi / 180.0; + final y = (1 - + math.log(math.tan(latRad) + 1 / math.cos(latRad)) / math.pi) / + 2 * + n; + return y.floor().clamp(0, n - 1); + } + + /// Estimate download size in bytes from tile count. + static int estimateSizeBytes(int tileCount) => + tileCount * _bytesPerVectorTile; /// Check if downloading a region of [estimatedBytes] would exceed the /// limit. Accounts for the currently-active download and any queued jobs diff --git a/lib/services/tile_cache_service.dart b/lib/services/tile_cache_service.dart index c5c1301..ee9407e 100644 --- a/lib/services/tile_cache_service.dart +++ b/lib/services/tile_cache_service.dart @@ -40,6 +40,28 @@ class TileCacheService { } } + /// Per-region downloaded byte counts keyed by the Dart-side region ID that + /// maplibre_gl assigns. Regions missing from the result (e.g. because the + /// native store hasn't finished loading yet) should be treated as unknown, + /// not zero. Returns an empty map on web or unexpected channel errors. + Future> getRegionSizes() async { + if (kIsWeb) return const {}; + try { + final raw = await _channel.invokeMethod('getRegionSizes'); + if (raw is! Map) return const {}; + final result = {}; + raw.forEach((k, v) { + final id = k is int ? k : (k is num ? k.toInt() : int.tryParse('$k')); + final bytes = v is int ? v : (v is num ? v.toInt() : null); + if (id != null && bytes != null) result[id] = bytes; + }); + return result; + } catch (e) { + debugWarn('[OFFLINE_MAP] Region sizes read failed: $e'); + return const {}; + } + } + Future clearAmbientCache() async { if (kIsWeb) throw UnsupportedError('Not supported on web'); await _channel.invokeMethod('clearAmbientCache'); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 1c61b9c..213d30b 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1140,12 +1140,25 @@ class _MapWidgetState extends State { // online (network tiles) and offline (cache-only) mode. This avoids // a full style reload — the same style stays loaded but MapLibre stops // or starts making network requests for tiles. + // + // When disabling tiles, also drop the coverage overlay source/layer: + // MapLibre's ambient cache would still serve previously-fetched tiles, + // but the overlay is a live-data layer and shouldn't linger on the map + // in an indeterminate half-cached state once the user opted out. When + // re-enabling, re-add it so it reappears without waiting for a style + // reload or cache-bust. final tilesEnabled = appState.preferences.mapTilesEnabled; if (_lastMapTilesEnabled != tilesEnabled && _isMapReady) { + final wasEnabled = _lastMapTilesEnabled; _lastMapTilesEnabled = tilesEnabled; - WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) return; - _setOfflineIfSupported(!tilesEnabled); + await _setOfflineIfSupported(!tilesEnabled); + if (wasEnabled == true && !tilesEnabled) { + await _removeCoverageOverlay(); + } else if (wasEnabled == false && tilesEnabled && _styleLoaded) { + await _addCoverageOverlay(appState); + } }); } From bec7fd30135b6537fb3909d9bedc0d954b55c8d2 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 20 Apr 2026 22:09:30 -0400 Subject: [PATCH 015/100] feat(repeater-popup): enhance logging and match count for repeater ID popup --- lib/widgets/repeater_id_chip.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 2144f04..e93db01 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -88,6 +88,17 @@ class RepeaterIdChip extends StatelessWidget { final appState = Provider.of(context, listen: false); final repeaters = appState.repeaters; + final matchKey = fullHexId != null && fullHexId.length >= 8 + ? fullHexId.substring(0, 8) + : repeaterId; + final matchCount = repeaters + .where((r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) + .length; + debugLog('[UI] Repeater popup tap: id=$repeaterId, ' + 'fullHex=${fullHexId ?? "(none)"}, matchKey=$matchKey, ' + 'repeaters=${repeaters.length}, matches=$matchCount, ' + 'zone=${appState.zoneCode ?? "(none)"}'); + final Widget content; if (repeaters.isEmpty) { @@ -103,12 +114,6 @@ class RepeaterIdChip extends StatelessWidget { ), ); } else { - // DISC pings provide the full public key — match first 8 hex chars - // (4 bytes) against repeater hexId for exact identification. - // TX/RX pings only have 1-byte IDs so fall back to prefix matching. - final matchKey = fullHexId != null && fullHexId.length >= 8 - ? fullHexId.substring(0, 8) - : repeaterId; final matches = repeaters .where( (r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) From 41b5c4ac9790bc2ff0806ff88c9be212837baf4d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 21 Apr 2026 22:47:53 -0400 Subject: [PATCH 016/100] =?UTF-8?q?-=20New=20Flood=20Traffic=20toggle=20at?= =?UTF-8?q?=20the=20top=20of=20Settings=20=E2=86=92=20Modes=20(default=20O?= =?UTF-8?q?FF).=20When=20OFF,=20Send=20Ping,=20Active,=20and=20Hybrid=20co?= =?UTF-8?q?ntrols=20are=20hidden=20across=20all=20three=20ping-control=20v?= =?UTF-8?q?ariants=20(standard,=20compact,=20landscape).=20Passive=20and?= =?UTF-8?q?=20Trace=20stay=20visible.=20-=20MeshMapper=20now=20parses=20`f?= =?UTF-8?q?lood=5Fdisabled`=20from=20the=20auth=20response=20and=20auto-sy?= =?UTF-8?q?ncs=20the=20toggle=20on=20connect=20and=20zone=20change.=20If?= =?UTF-8?q?=20the=20region=20permits=20flooding,=20the=20toggle=20flips=20?= =?UTF-8?q?ON.=20If=20the=20region=20forbids=20it,=20the=20toggle=20is=20f?= =?UTF-8?q?orced=20OFF,=20greyed=20out,=20with=20an=20amber=20"Set=20by=20?= =?UTF-8?q?Regional=20Admin"=20subtitle.=20-=20One-shot=20popup=20when=20a?= =?UTF-8?q?=20user-enabled=20flood=20toggle=20gets=20overridden=20by=20reg?= =?UTF-8?q?ional=20policy=20on=20auth=20or=20zone=20change,=20explaining?= =?UTF-8?q?=20that=20Active=20and=20Hybrid=20modes=20have=20been=20disable?= =?UTF-8?q?d=20for=20this=20session.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/models/user_preferences.dart | 12 +++++++ lib/providers/app_state_provider.dart | 47 +++++++++++++++++++++++++++ lib/screens/main_scaffold.dart | 36 ++++++++++++++++++++ lib/screens/settings_screen.dart | 18 ++++++++++ lib/services/api_service.dart | 17 ++++++++++ lib/services/meshcore/tx_tracker.dart | 27 +++++++++++++++ lib/widgets/ping_controls.dart | 12 +++++-- 7 files changed, 166 insertions(+), 3 deletions(-) diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 84e4acc..d26d4c2 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -73,6 +73,11 @@ class UserPreferences { /// Discovery drop: count failed discoveries as failed pings and report to API final bool discDropEnabled; + /// Flood traffic enabled — gates display of Send Ping + Active + Hybrid + /// controls. Default off; auto-flipped by the auth response's flood_disabled + /// flag when the region allows flooding. + final bool floodTrafficEnabled; + /// Delete wardriving channel from radio on disconnect final bool deleteChannelOnDisconnect; @@ -145,6 +150,7 @@ class UserPreferences { this.disableRssiFilter = false, this.anonymousMode = false, this.discDropEnabled = false, + this.floodTrafficEnabled = false, this.deleteChannelOnDisconnect = true, this.minPingDistanceMeters = 25, this.autoStopAfterIdle = true, @@ -190,6 +196,7 @@ class UserPreferences { disableRssiFilter: (json['disableRssiFilter'] as bool?) ?? false, anonymousMode: (json['anonymousMode'] as bool?) ?? false, discDropEnabled: (json['discDropEnabled'] as bool?) ?? false, + floodTrafficEnabled: (json['floodTrafficEnabled'] as bool?) ?? false, deleteChannelOnDisconnect: (json['deleteChannelOnDisconnect'] as bool?) ?? true, minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, @@ -247,6 +254,7 @@ class UserPreferences { 'disableRssiFilter': disableRssiFilter, 'anonymousMode': anonymousMode, 'discDropEnabled': discDropEnabled, + 'floodTrafficEnabled': floodTrafficEnabled, 'deleteChannelOnDisconnect': deleteChannelOnDisconnect, 'minPingDistanceMeters': minPingDistanceMeters, 'autoStopAfterIdle': autoStopAfterIdle, @@ -291,6 +299,7 @@ class UserPreferences { bool? disableRssiFilter, bool? anonymousMode, bool? discDropEnabled, + bool? floodTrafficEnabled, bool? deleteChannelOnDisconnect, int? minPingDistanceMeters, bool? autoStopAfterIdle, @@ -334,6 +343,7 @@ class UserPreferences { disableRssiFilter: disableRssiFilter ?? this.disableRssiFilter, anonymousMode: anonymousMode ?? this.anonymousMode, discDropEnabled: discDropEnabled ?? this.discDropEnabled, + floodTrafficEnabled: floodTrafficEnabled ?? this.floodTrafficEnabled, deleteChannelOnDisconnect: deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, minPingDistanceMeters: @@ -410,6 +420,7 @@ class UserPreferences { other.disableRssiFilter == disableRssiFilter && other.anonymousMode == anonymousMode && other.discDropEnabled == discDropEnabled && + other.floodTrafficEnabled == floodTrafficEnabled && other.deleteChannelOnDisconnect == deleteChannelOnDisconnect && other.minPingDistanceMeters == minPingDistanceMeters && other.autoStopAfterIdle == autoStopAfterIdle && @@ -453,6 +464,7 @@ class UserPreferences { disableRssiFilter, anonymousMode, discDropEnabled, + floodTrafficEnabled, deleteChannelOnDisconnect, minPingDistanceMeters, autoStopAfterIdle, diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index fe609f3..f6af59d 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -607,6 +607,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get enforceDiscDrop => _apiService.enforceDiscDrop; bool get discDropEnabled => _preferences.discDropEnabled || _apiService.enforceDiscDrop; + + /// Whether the current region forbids flood traffic (region override). + bool get floodDisabled => _apiService.floodDisabled; + + /// Effective flood-traffic visibility: region veto wins over user pref. + bool get floodTrafficEnabled => + !_apiService.floodDisabled && _preferences.floodTrafficEnabled; + + /// One-shot flag: true when the user had flood traffic enabled and the + /// region forced it off on auth/zone-change. UI shows a dialog, then calls + /// [clearFloodDisabledAlert]. + bool _floodDisabledAlertPending = false; + bool get floodDisabledAlertPending => _floodDisabledAlertPending; + void clearFloodDisabledAlert() { + if (!_floodDisabledAlertPending) return; + _floodDisabledAlertPending = false; + notifyListeners(); + } int get minModeInterval => _apiService.minModeInterval; bool get enforceHopBytes => _apiService.enforceHopBytes; int get hopBytes => _hopBytes; @@ -1469,6 +1487,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[CONN] Discovery drop force-enabled by regional admin'); } + // Sync Flood Traffic preference with regional policy: + // - flood_disabled=true → force OFF (region forbids) + // - flood_disabled=false → force ON (region permits, user lands ready) + // Fire a one-shot alert only on user-on → region-off transition. + final wasFloodEnabledByUser = _preferences.floodTrafficEnabled; + final shouldEnableFlood = !_apiService.floodDisabled; + if (_preferences.floodTrafficEnabled != shouldEnableFlood) { + _preferences = + _preferences.copyWith(floodTrafficEnabled: shouldEnableFlood); + debugLog(shouldEnableFlood + ? '[CONN] Flood traffic auto-enabled (region permits)' + : '[CONN] Flood traffic disabled by regional admin'); + } + if (wasFloodEnabledByUser && _apiService.floodDisabled) { + _floodDisabledAlertPending = true; + } + // Enforce minimum auto-ping interval if required by regional admin if (_preferences.autoPingInterval < _apiService.minModeInterval) { _preferences = _preferences.copyWith( @@ -5303,6 +5338,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _preferences = _preferences.copyWith(discDropEnabled: true); debugLog('[ZONE] Discovery drop force-enabled by new zone admin'); } + final wasFloodEnabledByUser = _preferences.floodTrafficEnabled; + final shouldEnableFlood = !_apiService.floodDisabled; + if (_preferences.floodTrafficEnabled != shouldEnableFlood) { + _preferences = + _preferences.copyWith(floodTrafficEnabled: shouldEnableFlood); + debugLog(shouldEnableFlood + ? '[ZONE] Flood traffic auto-enabled (new zone permits)' + : '[ZONE] Flood traffic disabled by new zone admin'); + } + if (wasFloodEnabledByUser && _apiService.floodDisabled) { + _floodDisabledAlertPending = true; + } if (_preferences.autoPingInterval < _apiService.minModeInterval) { _preferences = _preferences.copyWith( autoPingInterval: _apiService.minModeInterval); diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index a3cdfef..375a6a9 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -27,6 +27,7 @@ class _MainScaffoldState extends State { int _selectedIndex = 0; bool _hasCheckedDisclosure = false; bool _hasShownLocationSettingsPrompt = false; + bool _floodDisabledDialogOpen = false; final List _screens = [ const HomeScreen(), @@ -133,6 +134,31 @@ class _MainScaffoldState extends State { } } + Future _showFloodDisabledDialog() async { + final appState = context.read(); + debugLog('[APP] Showing flood-traffic-disabled-by-region alert'); + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Flood Traffic Unavailable'), + content: const Text( + 'Your regional MeshMapper admin has disabled flood traffic in this ' + 'area, so Active and Hybrid modes have been turned off for this ' + 'session. Passive Mode and Trace Mode remain available. Please ' + 'reach out to your regional admin if you have questions.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('OK'), + ), + ], + ), + ); + appState.clearFloodDisabledAlert(); + _floodDisabledDialogOpen = false; + } + void _showLocationSettingsPrompt() { if (!mounted || _hasShownLocationSettingsPrompt) return; _hasShownLocationSettingsPrompt = true; @@ -188,6 +214,16 @@ class _MainScaffoldState extends State { }); } + // Listen for flood-traffic-disabled-by-region alert (user had it on, + // region forced it off on auth/zone-change) + if (appState.floodDisabledAlertPending && !_floodDisabledDialogOpen) { + _floodDisabledDialogOpen = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _showFloodDisabledDialog(); + }); + } + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 76b9f51..12a3343 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -344,6 +344,24 @@ class _SettingsScreenState extends State { // Modes _buildSection(context, 'Modes', [ + SwitchListTile( + secondary: const Icon(Icons.waves), + title: const Text('Flood Traffic'), + subtitle: appState.floodDisabled + ? const Text( + 'Set by Regional Admin — flood traffic is disabled in this region. Active and Hybrid modes are unavailable here.', + style: TextStyle(color: Colors.amber), + ) + : const Text( + 'Show Active, Hybrid, and manual Send Ping controls'), + value: appState.floodDisabled ? false : prefs.floodTrafficEnabled, + onChanged: (isAutoMode || appState.floodDisabled) + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(floodTrafficEnabled: value)); + }, + ), SwitchListTile( secondary: const Icon(Icons.compare_arrows), title: Row( diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 2901016..5175392 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -49,6 +49,7 @@ class ApiService { List _scopes = []; bool _enforceHybrid = false; bool _enforceDiscDrop = false; + bool _floodDisabled = false; int _minModeInterval = 15; int _apiHopBytes = 1; @@ -68,6 +69,9 @@ class ApiService { /// Whether discovery drop is enforced by regional admin bool get enforceDiscDrop => _enforceDiscDrop; + /// Whether flood traffic (Active/Hybrid modes) is disabled by regional admin + bool get floodDisabled => _floodDisabled; + /// Minimum auto-ping interval enforced by regional admin (seconds) int get minModeInterval => _minModeInterval; @@ -442,6 +446,18 @@ class ApiService { debugLog('[API] Regional admin enforces discovery drop'); } + // Parse flood_disabled flag from auth response + _floodDisabled = data['flood_disabled'] == true; + if (_floodDisabled) { + debugLog('[API] Regional admin has disabled flood traffic'); + } + + // Parse flood_disabled flag from auth response + _floodDisabled = data['flood_disabled'] == true; + if (_floodDisabled) { + debugLog('[API] Regional admin has disabled flood traffic'); + } + // Parse min_mode_interval from auth response final minInterval = data['min_mode_interval']; if (minInterval is int && minInterval > 0) { @@ -832,6 +848,7 @@ class ApiService { _scopes = []; _enforceHybrid = false; _enforceDiscDrop = false; + _floodDisabled = false; _minModeInterval = 15; _apiHopBytes = 1; _heartbeatTimer?.cancel(); diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index b4090e2..9092814 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -7,9 +7,36 @@ import 'crypto_service.dart'; import 'packet_metadata.dart'; import 'packet_validator.dart'; +/// Classification result from [TxTracker.handlePacket]. +/// +/// [ownPingNotTracked] is the key signal the unified RX handler needs to +/// suppress passive RX logging of our own ping's echo when it can't be +/// credited as a TX echo (multi-hop, RSSI failsafe, user-filtered, or +/// arrived after the 5s echo window closed). +enum TxTrackerResult { + /// Valid repeater echo — recorded as a TX success for this ping. + tracked, + + /// Confirmed echo of our own recently-sent ping, but not trackable as a + /// TX success. Caller MUST NOT route this to the passive RX logger — + /// doing so produces misleading blue RX markers and polluted API payloads + /// where we post our own ping back to MeshMapper as a passive observation. + ownPingNotTracked, + + /// Packet is not an echo of our ping (different channel, different + /// content, or no recent ping context). Caller may route to the passive + /// RX logger as usual. + notOurPing, +} + /// TX echo tracker for repeater detection during 5-second window /// Reference: handleTxLogging() in wardrive.js (lines 3561-3710) class TxTracker { + /// Window during which we still identify our own ping's echoes, even after + /// [stopTracking] has fired. Covers echoes that arrive between the 5s echo + /// window closing and the RX listening window closing (BLE ack latency + + /// multi-hop propagation can easily push echoes past the 5s mark). + static const Duration _ownEchoAbsorbWindow = Duration(seconds: 10); bool isListening = false; DateTime? sentTimestamp; String? sentPayload; diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index f080ac0..fcfac93 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -97,13 +97,15 @@ class PingControls extends StatelessWidget { } // Note: cooldown and tooClose are shown on button itself + final floodTrafficVisible = appState.floodTrafficEnabled; + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Action buttons row Row( children: [ - if (!txNotAllowed) ...[ + if (!txNotAllowed && floodTrafficVisible) ...[ // Send Ping button // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" // Manual pings use 15-second cooldown, no distance requirement @@ -1100,13 +1102,15 @@ class _CompactPingControlsState extends State { }, ); + final floodTrafficVisible = appState.floodTrafficEnabled; + // Layout logic: // - If button is expanded (including during cooldown): stays big // - If no button is expanded: all colored buttons share space equally // - Grey non-expanded buttons are icon-only return Row( children: [ - if (!txNotAllowed) ...[ + if (!txNotAllowed && floodTrafficVisible) ...[ // Send Ping - expanded buttons stay big even when grey (cooldown) if (sendPingExpanded) Expanded(child: sendPingButton) @@ -1404,6 +1408,8 @@ class LandscapePingControls extends StatelessWidget { prefs.powerLevelSet || appState.deviceModel != null; + final floodTrafficVisible = appState.floodTrafficEnabled; + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1420,7 +1426,7 @@ class LandscapePingControls extends StatelessWidget { // Action buttons row (icon-only) Row( children: [ - if (!txNotAllowed) ...[ + if (!txNotAllowed && floodTrafficVisible) ...[ // TX Ping button Expanded( child: _LandscapeIconButton( From c43ac046041143b3303335e2376231725a070161 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 25 Apr 2026 13:43:25 -0400 Subject: [PATCH 017/100] =?UTF-8?q?=20-=20Fixed=20Hive=20preferences=20bei?= =?UTF-8?q?ng=20wiped=20by=20transient=20open=20failures.=20The=20startup?= =?UTF-8?q?=20theme=20loader=20was=20treating=20a=20non-fatal=20open=20tim?= =?UTF-8?q?eout=20as=20corruption=20and=20deleting=20the=20=20entire=20use?= =?UTF-8?q?r=5Fpreferences=20box,=20taking=20every=20saved=20setting=20wit?= =?UTF-8?q?h=20it.=20The=20early-startup=20deletion=20is=20gone=20?= =?UTF-8?q?=E2=80=94=20real=20corruption=20is=20still=20caught=20later=20b?= =?UTF-8?q?y=20=20AppStateProvider.=5FattemptHiveRecovery=20with=20a=20use?= =?UTF-8?q?r-visible=20error.=20=20-=20Force=20a=20box.flush()=20after=20e?= =?UTF-8?q?very=20preference=20write=20(general=20preferences,=20device=20?= =?UTF-8?q?antenna=20preferences,=20device=20power=20overrides,=20audio=20?= =?UTF-8?q?settings)=20so=20settings=20are=20=20persisted=20to=20disk=20im?= =?UTF-8?q?mediately=20instead=20of=20relying=20on=20Hive's=20lazy=20flush?= =?UTF-8?q?=20=E2=80=94=20prevents=20loss=20on=20crash=20or=20abrupt=20ter?= =?UTF-8?q?mination.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 11 ++++------- lib/providers/app_state_provider.dart | 3 +++ lib/services/audio_service.dart | 2 ++ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 22d67ee..37354b4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -91,13 +91,10 @@ Future _loadInitialThemeMode() async { } } } catch (e) { - debugLog('[HIVE] Failed to load initial theme: $e - deleting corrupt box'); - // Delete corrupt box so AppStateProvider gets a clean start - try { - await Hive.deleteBoxFromDisk('user_preferences'); - } catch (e) { - debugLog('[INIT] Failed to delete corrupt preferences box: $e'); - } + debugLog('[HIVE] Initial theme load failed (non-fatal): $e'); + // Do NOT delete the box here. A transient open timeout would wipe every + // saved setting. AppStateProvider's _attemptHiveRecovery handles real + // corruption later with a user-visible logError() notification. } return 'dark'; // Default to dark mode } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index f6af59d..7c6d272 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -5945,6 +5945,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { await box.put('preferences', _preferences.toJson()); + await box.flush(); debugLog('[APP] Saved preferences'); } catch (e) { debugLog('[APP] Failed to save preferences: $e'); @@ -5979,6 +5980,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { await box.put('device_antenna_preferences', _deviceAntennaPreferences); + await box.flush(); } catch (e) { debugLog('[APP] Failed to save device antenna preferences: $e'); } @@ -6015,6 +6017,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { await box.put('device_power_overrides', _devicePowerOverrides); + await box.flush(); } catch (e) { debugLog('[APP] Failed to save device power overrides: $e'); } diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 27cc57b..64f3707 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -130,6 +130,7 @@ class AudioService { try { await box.put(_enabledKey, _enabled); + await box.flush(); debugLog('[AUDIO] Saved enabled state: $_enabled'); } catch (e) { debugError('[AUDIO] Failed to save enabled state: $e'); @@ -345,6 +346,7 @@ class AudioService { if (box == null) return; try { await box.put(key, value); + await box.flush(); } catch (e) { debugError('[AUDIO] Failed to save $key: $e'); } From dcb57a7e58eab6776d579d3f152069a000429009 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 26 Apr 2026 20:56:42 -0400 Subject: [PATCH 018/100] - Fixed an iOS crash where backgrounded sessions could be killed hours later by a MapLibre camera animation error. Camera animations now suspend while the app is in the background. --- Build.sh | 11 ++++ lib/widgets/map_widget.dart | 105 ++++++++++++++++++++++++++---------- 2 files changed, 88 insertions(+), 28 deletions(-) diff --git a/Build.sh b/Build.sh index 3cf9475..a62e4ea 100755 --- a/Build.sh +++ b/Build.sh @@ -5,6 +5,17 @@ set -e # Exit on any error +# maplibre_gl 0.25.0 plugin requires JDK 21 to compile. +# Force the build to use Homebrew openjdk@21, regardless of the user's shell JAVA_HOME. +JDK21_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home" +if [ ! -d "$JDK21_HOME" ]; then + echo "Error: JDK 21 not found at $JDK21_HOME" + echo "Install with: brew install openjdk@21" + exit 1 +fi +export JAVA_HOME="$JDK21_HOME" +export PATH="$JAVA_HOME/bin:$PATH" + # Semver comparison: returns 0 (true) if $1 >= $2 version_gte() { local IFS=. diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 213d30b..a7cbd7e 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -308,9 +308,20 @@ class MapWidget extends StatefulWidget { State createState() => _MapWidgetState(); } -class _MapWidgetState extends State { +class _MapWidgetState extends State with WidgetsBindingObserver { MapLibreMapController? _mapController; + // Tracks the app lifecycle so we can suppress every animateCamera() call + // while the app is not in the foreground. MapLibre's native flyTo throws + // an uncaught C++ exception (mbgl::LatLng domain_error → SIGABRT) when + // the underlying MLNMapView's GL context is degraded, which is what + // happens on iOS while the app is backgrounded. The Flutter frame + // pipeline keeps running in our background-mode app (bluetooth-central + + // location), so GPS-driven rebuilds would otherwise still fire animateCamera. + AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; + bool get _canAnimateCamera => + _appLifecycleState == AppLifecycleState.resumed; + // Auto-follow GPS like a navigation app bool _autoFollow = false; // Disabled by default - users often zoom out first bool _prefsApplied = false; // Guard to load saved prefs only once @@ -468,8 +479,22 @@ class _MapWidgetState extends State { static const LatLng _defaultCenter = LatLng(45.4215, -75.6972); static const double _defaultZoom = 15.0; // Closer zoom for driving + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _appLifecycleState = + WidgetsBinding.instance.lifecycleState ?? AppLifecycleState.resumed; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _appLifecycleState = state; + } + @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _tileLoadTimeoutTimer?.cancel(); final controller = _mapController; if (controller != null) { @@ -544,7 +569,12 @@ class _MapWidgetState extends State { /// Smoothly animate the map to a new position with zoom void _animateToPositionWithZoom(LatLng target, double targetZoom) { - if (_mapController == null || !_isMapReady || !mounted) return; + if (_mapController == null || + !_isMapReady || + !mounted || + !_canAnimateCamera) { + return; + } _mapController!.animateCamera( CameraUpdate.newLatLngZoom(target, targetZoom), duration: const Duration(milliseconds: 500), @@ -566,7 +596,12 @@ class _MapWidgetState extends State { required double bearing, int durationMs = 300, }) { - if (_mapController == null || !_isMapReady || !mounted) return; + if (_mapController == null || + !_isMapReady || + !mounted || + !_canAnimateCamera) { + return; + } _mapController!.animateCamera( CameraUpdate.newCameraPosition(CameraPosition( target: target, @@ -580,7 +615,12 @@ class _MapWidgetState extends State { /// Zoom to fit a focused ping and its connected repeaters on screen void _zoomToFocusBounds( LatLng pingLocation, List<_ResolvedRepeater> repeaters) { - if (_mapController == null || !_isMapReady || !mounted) return; + if (_mapController == null || + !_isMapReady || + !mounted || + !_canAnimateCamera) { + return; + } final points = [ pingLocation, @@ -613,7 +653,11 @@ class _MapWidgetState extends State { /// Smoothly animate the map rotation to match heading /// MapLibre bearing is clockwise from north (same as GPS heading) void _animateToRotation(double targetHeading) { - if (_mapController == null || !_isMapReady || !mounted || _alwaysNorth) { + if (_mapController == null || + !_isMapReady || + !mounted || + _alwaysNorth || + !_canAnimateCamera) { return; } @@ -933,7 +977,7 @@ class _MapWidgetState extends State { // Rotate map back to north (0 degrees) first final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; - if (currentBearing.abs() > 2) { + if (currentBearing.abs() > 2 && _canAnimateCamera) { _mapController!.animateCamera(CameraUpdate.bearingTo(0)); } @@ -1369,12 +1413,15 @@ class _MapWidgetState extends State { // finishes in 200ms, making the tap feel "instant" rather than delayed. if (layerId == _repeaterClusterBubbleLayerId || layerId == _repeaterClusterCountLayerId) { - final currentZoom = _mapController?.cameraPosition?.zoom ?? _defaultZoom; - final newZoom = math.min(currentZoom + 2, 17.0); - _mapController?.animateCamera( - CameraUpdate.newLatLngZoom(coordinates, newZoom), - duration: const Duration(milliseconds: 200), - ); + if (_canAnimateCamera) { + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, 17.0); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + } return; } @@ -1442,13 +1489,15 @@ class _MapWidgetState extends State { // Same explicit 200ms duration as the direct cluster path in // _handleFeatureTap so both tap routes feel identical. if (properties['cluster'] == true) { - final currentZoom = - _mapController?.cameraPosition?.zoom ?? _defaultZoom; - final newZoom = math.min(currentZoom + 2, 17.0); - _mapController?.animateCamera( - CameraUpdate.newLatLngZoom(coordinates, newZoom), - duration: const Duration(milliseconds: 200), - ); + if (_canAnimateCamera) { + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, 17.0); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + } return; } @@ -1588,7 +1637,7 @@ class _MapWidgetState extends State { _hasStyleLoadedOnce = true; // Center on GPS if available (initial centering) - if (appState.currentPosition != null) { + if (appState.currentPosition != null && _canAnimateCamera) { final center = LatLng( appState.currentPosition!.latitude, appState.currentPosition!.longitude, @@ -3235,7 +3284,7 @@ class _MapWidgetState extends State { if (_alwaysNorth && _isMapReady && _mapController != null) { _lastHeading = null; final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; - if (currentBearing.abs() > 2) { + if (currentBearing.abs() > 2 && _canAnimateCamera) { _mapController!.animateCamera( CameraUpdate.bearingTo(0), duration: const Duration(milliseconds: 500), @@ -3269,7 +3318,7 @@ class _MapWidgetState extends State { _alwaysNorth && _mapController != null) { final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; - if (currentBearing.abs() > 2) { + if (currentBearing.abs() > 2 && _canAnimateCamera) { _mapController!.animateCamera( CameraUpdate.bearingTo(0), duration: const Duration(milliseconds: 500), @@ -4272,7 +4321,7 @@ class _MapWidgetState extends State { if (!_alwaysNorth) { _alwaysNorth = true; // Snap rotation to north (instant — avoids wobble before zoom-to-fit animation) - if (_isMapReady && _mapController != null) { + if (_isMapReady && _mapController != null && _canAnimateCamera) { _mapController!.animateCamera( CameraUpdate.bearingTo(0), duration: const Duration(milliseconds: 1), @@ -4698,13 +4747,13 @@ class _MapWidgetState extends State { Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.15), + color: PingColors.rx.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: - Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: Border.all( + color: PingColors.rx.withValues(alpha: 0.4)), ), - child: const Icon(Icons.arrow_downward, - color: Colors.blue, size: 24), + child: Icon(Icons.arrow_downward, + color: PingColors.rx, size: 24), ), const SizedBox(width: 12), Expanded( From 170e55f592f454b47cc6b2ef98d8d9b1c4f0594d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 26 Apr 2026 22:22:29 -0400 Subject: [PATCH 019/100] - **Repeater Marker Clustering:** Stacked repeater markers at the same (or near-same) GPS coordinates now collapse into a cluster bubble with a count instead of rendering as an overlapping pile with illegible labels. Clustering runs at every zoom level (was previously dropped past zoom 14), so a stack stays as a single tappable target even fully zoomed in. - **Spiderfy on Tap:** Tapping a cluster at max zoom fans the members out in a ring (or spiral past 20 markers) connected by thin leader lines, each independently tappable and opening the repeater detail sheet. Below max zoom, taps zoom in further first so you can separate the stack visually. - Spider auto-collapses when you tap an empty area, tap a different cluster, or zoom past a 0.25-level delta. Panning keeps the spread open since leader lines live in geographic coordinates and follow the map naturally. On iOS, spread markers covered by the GPS dot are now tappable as well. --- lib/widgets/map_widget.dart | 658 +++++++++++++++++++++++++++++++++--- 1 file changed, 612 insertions(+), 46 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index a7cbd7e..8beb9ef 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -459,6 +459,24 @@ class _MapWidgetState extends State with WidgetsBindingObserver { static const _repeaterClusterBubbleLayerId = 'repeaters-cluster-bubble'; static const _repeaterClusterCountLayerId = 'repeaters-cluster-count'; + // Spiderfy source/layer IDs — non-clustered shadow source rendering spread + // markers + leader lines for stacked repeaters that won't separate by zoom. + static const _spiderSourceId = 'spider-source'; + static const _spiderLineLayerId = 'spider-leader-lines'; + static const _spiderSymbolLayerId = 'spider-symbols'; + // Matches the main source's clusterRadius (50px). Reused by the spiderfy + // group-detection logic: pairs of repeaters within `clusterRadius × m/px + // at the user's max zoom` of each other will visually overlap even when + // fully zoomed in, so they're the candidates that won't be separated by + // additional zoom and need to be spread apart instead. + static const double _clusterRadiusPx = 50; + static const double _spiderInnerRadiusPx = 44; + static const double _spiderOuterRadiusPx = 80; + static const double _leaderLineEndShortenPx = 8; + // Camera-zoom delta past which an open spider must collapse (positions are + // pixel-radius derived and become wrong if the user zooms far enough). + static const double _spiderCollapseZoomDelta = 0.25; + // Regional boundary (from /border API — always visible) static const _regionBorderSourceId = 'region-border-source'; static const _regionBorderLineLayerId = 'region-border-line'; @@ -475,6 +493,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Last bearing seen by camera listener (for non-rotating GPS counter-rotation) double _lastBearing = 0; + // Spiderfy state — when non-null, a stack of stacked repeaters has been + // fanned out around _spiderCenter into the shadow `spider-source`. + // Lifecycle: set by _spiderfy(), cleared by _collapseSpider(). + LatLng? _spiderCenter; + List _spiderRepeaters = const []; + // Captured on _spiderfy() so _onCameraChanged can detect a zoom delta past + // _spiderCollapseZoomDelta and collapse (positions become invalid). + double? _spiderOpenedAtZoom; + // Default center (Ottawa) static const LatLng _defaultCenter = LatLng(45.4215, -75.6972); static const double _defaultZoom = 15.0; // Closer zoom for driving @@ -527,6 +554,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (!mounted || _mapController == null) return; final pos = _mapController!.cameraPosition; if (pos == null) return; + + // If a spider is open and the user has zoomed past the collapse-delta + // threshold, drop the spider — pixel-radius spread positions are now wrong + // for the new zoom. Pure pan never crosses this threshold (zoom doesn't + // change), so the spider follows pan naturally via its geo coordinates. + // Once `_spiderCenter` is null after collapse, this branch no-ops. + if (_spiderCenter != null && _spiderOpenedAtZoom != null) { + if ((pos.zoom - _spiderOpenedAtZoom!).abs() > _spiderCollapseZoomDelta) { + _collapseSpider(); + } + } + if ((pos.bearing - _lastBearing).abs() < 0.5) return; _lastBearing = pos.bearing; _updateGpsSymbolRotation(); @@ -1298,12 +1337,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { onStyleLoadedCallback: () => _onStyleLoaded(appState), onMapIdle: _onMapIdle, onCameraIdle: _onCameraIdle, - // NOTE: we do NOT pass onMapClick here. The iOS plugin's - // handleMapTap fires `feature#onTap` when a tap hits any - // interactive layer (including our cluster source layers) and - // does NOT fire `map#onMapClick` in that case. We register a - // listener on `controller.onFeatureTapped` in _onMapCreated - // instead — that fires for taps on custom layer features. + // onMapClick fires ONLY for taps that DON'T hit an interactive + // layer (the iOS/Android plugin routes feature hits to + // `feature#onTap` and doesn't dispatch onMapClick in that case). + // That's exactly what we need for the empty-area-collapse path — + // tapping the map background closes any open spider. + // Custom-layer feature taps still flow through + // `controller.onFeatureTapped` (registered in _onMapCreated). + onMapClick: _onMapEmptyTap, ), // No widget marker overlay — markers are now native MapLibre // annotations rendered by the platform view itself. @@ -1371,6 +1412,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + /// Fires for taps on the map background that don't hit any interactive + /// custom-layer feature. Used to close an open spider when the user taps + /// somewhere outside the spread / cluster — the standard "click empty + /// area to dismiss" interaction. Wired via the MapLibreMap `onMapClick` + /// parameter. + void _onMapEmptyTap(math.Point point, LatLng coordinates) { + if (!mounted) return; + if (_spiderCenter != null) { + _collapseSpider(); + } + } + /// Handles taps on custom layer features (repeater cluster bubbles and /// individual repeaters). Wired in [_onMapCreated] via /// `controller.onFeatureTapped.add(_handleFeatureTap)`. @@ -1398,13 +1451,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ) { if (!mounted) return; - // Cluster tap: just zoom in. We accept hits on EITHER the bubble circle - // layer OR the count-text symbol layer that sits on top of it. The - // platform-side hit-test iterates layers top-down and returns the first - // feature it finds; for cluster taps, the centered count text usually - // gets hit before the underlying bubble, so we have to recognise both - // layer IDs as "user tapped a cluster". Either way the action is the - // same: animate-zoom in 2 levels around the tap point. + // Spider spread marker: open the detail sheet for the tapped repeater. + // Spider stays open — users frequently compare stacked repeaters back to + // back, so collapsing on every selection would be annoying. + if (layerId == _spiderSymbolLayerId) { + _showRepeaterDetailsById(id); + return; + } + + // Cluster tap: zoom in by default, but spiderfy first when the cluster + // contains markers stacked tightly enough that no further zoom would + // separate them. We accept hits on either the bubble circle layer or + // the count-text symbol layer (either may win the platform-side + // top-down hit-test depending on tap position). // // The explicit 200ms duration is important for perceived responsiveness. // Without it, iOS uses setCamera(animated: true) which has a slow ease-in @@ -1413,23 +1472,70 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // finishes in 200ms, making the tap feel "instant" rather than delayed. if (layerId == _repeaterClusterBubbleLayerId || layerId == _repeaterClusterCountLayerId) { - if (_canAnimateCamera) { - final currentZoom = - _mapController?.cameraPosition?.zoom ?? _defaultZoom; - final newZoom = math.min(currentZoom + 2, 17.0); - _mapController?.animateCamera( - CameraUpdate.newLatLngZoom(coordinates, newZoom), - duration: const Duration(milliseconds: 200), - ); + // Spiderfy is gated to max zoom. Below that, give the user a chance + // to separate the stack by zooming further before we resort to the + // spread UI. Note that `_spiderCenter` is always null at non-max + // zoom (the camera-change collapse fires when the user zooms out + // from max), so no collapse-handling is needed in this branch. + if (!_isAtMaxZoom()) { + if (_canAnimateCamera) { + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, _maxUserZoom); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + } + return; } + + final appState = context.read(); + final group = _findSpiderGroup(coordinates, appState); + + // Re-tap on the open spider's own group → collapse instead of churn. + if (_spiderCenter != null) { + final spiderIds = _spiderRepeaters.map((r) => r.id).toSet(); + if (group.any((r) => spiderIds.contains(r.id))) { + _collapseSpider(); + return; + } + // Tapped a different cluster — close the existing spider before + // evaluating the new tap. + _collapseSpider(); + } + + if (group.length >= 2) { + _spiderfy(coordinates, group); + } + // Already at max zoom with a single-marker group: nothing useful to + // zoom into and no stack to spread. Silent no-op. return; } - // Individual repeater: look up by id (which is repeater.id) and open the - // existing detail sheet. We recompute isDuplicate and hopOverride from - // app state rather than carrying them in feature properties — the values - // are cheap to derive and always reflect the latest data. + // Individual repeater: open the detail sheet. At max zoom we ALSO check + // for stacked siblings within the spider stick threshold and spread them + // out (covers the rare case where clustering didn't pick them up — e.g. + // identical-coordinate markers that just slipped past clusterRadius + // due to a recent data update). Below max zoom, spiderfy is disabled — + // the user is expected to zoom further first. if (layerId == _repeaterIndividualLayerId) { + // If a spider is open and the user tapped a non-spider individual + // (originals are filtered out via `inSpider`, so this can only be a + // marker outside the spider's group), collapse the existing spider. + if (_spiderCenter != null) { + _collapseSpider(); + } + + if (_isAtMaxZoom()) { + final appState = context.read(); + final group = _findSpiderGroup(coordinates, appState); + if (group.length >= 2) { + _spiderfy(coordinates, group); + return; + } + } + _showRepeaterDetailsById(id); return; } @@ -1468,9 +1574,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ) async { if (_mapController == null) return; try { + // Query the spider symbols FIRST so a tap on a spread marker hidden + // under the GPS overlay still routes to the spider's detail sheet + // (rather than the original repeater layer beneath it). final features = await _mapController!.queryRenderedFeatures( point, const [ + _spiderSymbolLayerId, _repeaterClusterCountLayerId, _repeaterClusterBubbleLayerId, _repeaterIndividualLayerId, @@ -1486,28 +1596,65 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final properties = (feature['properties'] as Map?) ?? {}; // Cluster (auto-tagged by MapLibre when cluster: true is set on source). - // Same explicit 200ms duration as the direct cluster path in - // _handleFeatureTap so both tap routes feel identical. + // Mirrors the cluster path in _handleFeatureTap, including the + // max-zoom gate on spiderfy. if (properties['cluster'] == true) { - if (_canAnimateCamera) { - final currentZoom = - _mapController?.cameraPosition?.zoom ?? _defaultZoom; - final newZoom = math.min(currentZoom + 2, 17.0); - _mapController?.animateCamera( - CameraUpdate.newLatLngZoom(coordinates, newZoom), - duration: const Duration(milliseconds: 200), - ); + if (!_isAtMaxZoom()) { + if (_canAnimateCamera) { + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, _maxUserZoom); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + } + return; + } + final appState = context.read(); + final group = _findSpiderGroup(coordinates, appState); + if (_spiderCenter != null) { + final spiderIds = _spiderRepeaters.map((r) => r.id).toSet(); + if (group.any((r) => spiderIds.contains(r.id))) { + _collapseSpider(); + return; + } + _collapseSpider(); + } + if (group.length >= 2) { + _spiderfy(coordinates, group); } return; } - // Individual repeater. The feature `id` field is the repeater.id we set - // in _buildRepeaterFeatureCollection (or fall back to the property). + // Individual repeater (cluster or spider symbol). The feature `id` + // field is the repeater.id we set in our feature builders. Spider + // symbols never need spiderfy expansion — they ARE the spread; just + // open the detail sheet and leave the spider open. final repeaterId = (feature['id'] ?? properties['repeaterId'])?.toString(); - if (repeaterId != null) { - _showRepeaterDetailsById(repeaterId); + if (repeaterId == null) return; + + // For an individual layer hit (not a spider symbol), apply the same + // stacked-siblings test as the direct tap path — but ONLY at max zoom. + // queryRenderedFeatures doesn't expose layerId per result in 0.25, so + // we infer: if a spider is open, the original individuals are filtered + // out, so any individual hit must be a non-stacked marker → just open + // the detail sheet. If no spider is open AND we're at max zoom, run + // the spiderfy test. + if (_spiderCenter != null) { + // User tapped outside the open spider's group — collapse + show + // detail sheet for the tapped marker. + _collapseSpider(); + } else if (_isAtMaxZoom()) { + final appState = context.read(); + final group = _findSpiderGroup(coordinates, appState); + if (group.length >= 2) { + _spiderfy(coordinates, group); + return; + } } + _showRepeaterDetailsById(repeaterId); } catch (e) { debugError('[MAP] queryRenderedFeatures fall-through failed: $e'); } @@ -1973,6 +2120,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final hopOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; final focusActive = _focusedPingLocation != null; + // While a spider is open, repeaters in the spread set are tagged with + // `inSpider:true`. The individual symbol layer's filter excludes those + // features so the spread markers from `_spiderSourceId` render in their + // place. Cluster aggregation is not affected — the cluster bubble keeps + // its full point_count. + final spiderIds = _spiderRepeaters.map((r) => r.id).toSet(); final features = >[]; for (final repeater in appState.repeaters) { @@ -2005,6 +2158,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { 'hex': hex, 'isDuplicate': isDuplicate, if (hopOverride != null) 'hopOverride': hopOverride, + if (spiderIds.contains(repeater.id)) 'inSpider': true, }, 'geometry': { 'type': 'Point', @@ -2024,8 +2178,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { Future _setupRepeaterClusterLayers() async { if (_mapController == null) return; - // Idempotent: tear down any existing source/layers from a previous style load + // Idempotent: tear down any existing source/layers from a previous style load. + // Spider layers are torn down first because they reference `_spiderSourceId`. for (final layerId in [ + _spiderSymbolLayerId, + _spiderLineLayerId, _repeaterClusterCountLayerId, _repeaterClusterBubbleLayerId, _repeaterIndividualLayerId, @@ -2037,6 +2194,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { try { await _mapController!.removeSource(_repeaterSourceId); } catch (_) {} + try { + await _mapController!.removeSource(_spiderSourceId); + } catch (_) {} // Empty source with cluster enabled. We'll push real data via setGeoJsonSource // from _syncRepeaterSymbols whenever the marker data version changes. @@ -2055,7 +2215,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { }, cluster: true, clusterRadius: 50, - clusterMaxZoom: 14, + // Cluster at every reachable zoom (max user zoom is 17). Stacked + // markers — those within `clusterRadius` pixels at the current + // zoom — stay as a cluster bubble + count instead of degenerating + // into a pile of overlapping individual symbols when the user is + // zoomed all the way in. Tap a cluster → spiderfy. Markers far + // enough apart that they exceed `clusterRadius` pixels at higher + // zooms still separate into individuals naturally on zoom. + clusterMaxZoom: 17, ), ); @@ -2065,6 +2232,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Layer 1: individual repeater markers (when not part of a cluster). // Data-driven properties read from each feature's `properties` map. + // Filter excludes both clustered features AND any feature tagged + // `inSpider` (spiderfy hides the originals so the spread markers from + // _spiderSourceId render in their place). await _mapController!.addSymbolLayer( _repeaterSourceId, _repeaterIndividualLayerId, @@ -2084,8 +2254,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { textFont: _defaultFontStack, ), filter: [ - '!', - ['has', 'point_count'] + 'all', + [ + '!', + ['has', 'point_count'] + ], + [ + '!=', + ['get', 'inSpider'], + true + ], ], belowLayerId: belowLayer, ); @@ -2137,9 +2315,82 @@ class _MapWidgetState extends State with WidgetsBindingObserver { belowLayerId: belowLayer, ); - // All 3 layers + source created successfully — mark ready so the - // build()-triggered post-frame sync can run, and so _syncRepeaterSymbols - // is allowed to push data via setGeoJsonSource. + // Spider shadow source + layers — non-clustered. Carries spread Point + // features (one per spiderfied repeater) and LineString features for + // leader lines from the cluster centre to each spread position. + // Cluster on this source MUST stay false; we want every Point to render + // verbatim where _computeSpiderRing placed it. + await _mapController!.addSource( + _spiderSourceId, + const GeojsonSourceProperties( + data: { + 'type': 'FeatureCollection', + 'features': [] + }, + ), + ); + + // Layer 4: spider leader lines (LineString features). Inserted just + // below the individual repeater layer so lines render BENEATH every + // repeater layer — the cluster bubble (which sits above individuals) + // visually contains the lines' inner endpoints, and the spread markers + // (added next, above the annotation manager's siblings) hide the outer + // endpoints. Static filter selects LineString geometry only so Point + // features in the same source don't try to render as zero-length lines. + await _mapController!.addLineLayer( + _spiderSourceId, + _spiderLineLayerId, + const LineLayerProperties( + lineColor: '#888888', + lineWidth: 1.0, + lineOpacity: 0.7, + lineCap: 'round', + ), + filter: [ + '==', + ['geometry-type'], + 'LineString' + ], + belowLayerId: _repeaterIndividualLayerId, + ); + + // Layer 5: spider symbols (Point features). Same icon/text styling as + // the individual repeater layer so spread markers look identical to the + // originals. Inserted just below the symbol annotation manager — that + // puts it ABOVE the individual repeater layer (so spread markers win + // hit-tests against the now-hidden originals) but BELOW the GPS marker + // / coverage symbols on the annotation manager. iconAllowOverlap + + // iconIgnorePlacement are critical: without them MapLibre's collision + // detector hides adjacent spread markers and defeats the spread. + await _mapController!.addSymbolLayer( + _spiderSourceId, + _spiderSymbolLayerId, + const SymbolLayerProperties( + iconImage: ['get', 'iconImage'], + iconColor: ['get', 'color'], + iconSize: 1.4, + iconAllowOverlap: true, + iconIgnorePlacement: true, + textField: ['get', 'hex'], + textColor: '#FFFFFF', + textHaloColor: '#000000', + textHaloWidth: 1.5, + textSize: 13, + textAllowOverlap: true, + textIgnorePlacement: true, + textFont: _defaultFontStack, + ), + filter: [ + '==', + ['geometry-type'], + 'Point' + ], + belowLayerId: belowLayer, + ); + + // All 3 layers + source + spider source/layers created successfully — + // mark ready so the build()-triggered post-frame sync can run, and so + // _syncRepeaterSymbols is allowed to push data via setGeoJsonSource. _clusterLayersReady = true; } catch (e) { debugError('[MAP] Failed to set up repeater cluster layers: $e'); @@ -2149,6 +2400,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Pushes the current repeater state into the cluster source. MapLibre /// re-clusters natively whenever the source data changes. Replaces the /// previous per-symbol addSymbol/updateSymbol/removeSymbol diff loop. + /// + /// When a spider is open, also schedules a post-frame push of the spider + /// shadow source. Deferring one frame avoids an iOS race where the main + /// source's reclustering and the spider source's symbol render arrive in + /// different frames, briefly showing originals + spread together. Future _syncRepeaterSymbols(AppStateProvider appState) async { if (_mapController == null || !_styleLoaded || @@ -2162,6 +2418,316 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } catch (e) { debugError('[MAP] Failed to update repeater source: $e'); } + // Also push the spider source. Empty FeatureCollection if no spider open. + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _syncSpiderSymbols(appState, currentZoom); + }); + } + + // --------------------------------------------------------------------------- + // Spiderfy helpers — see plan: stacked repeaters fan out around the centroid + // when a tap can't be resolved by zooming further. + // --------------------------------------------------------------------------- + + /// Web-Mercator metres-per-pixel at the given latitude and zoom. + double _metersPerPxAtZoom(double latDeg, num zoom) { + return 156543.03392 * + math.cos(latDeg * math.pi / 180) / + math.pow(2, zoom); + } + + /// Great-circle distance between two LatLngs in metres (haversine). + /// Used for the spider candidate / connectivity tests — accurate at any + /// latitude, including the poles. + double _haversineMeters(LatLng a, LatLng b) { + const earthRadiusM = 6378137.0; + final lat1 = a.latitude * math.pi / 180; + final lat2 = b.latitude * math.pi / 180; + final dLat = (b.latitude - a.latitude) * math.pi / 180; + final dLon = (b.longitude - a.longitude) * math.pi / 180; + final h = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(lat1) * + math.cos(lat2) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + return 2 * + earthRadiusM * + math.asin(math.min(1.0, math.sqrt(h))); + } + + /// Maximum zoom the camera can reach (matches `minMaxZoomPreference`). + static const double _maxUserZoom = 17.0; + + /// True when the camera is at (or floating-point close to) the user's + /// hard zoom cap. Spider expansion is gated on this — at any lower zoom + /// taps just zoom in further so the user has a chance to separate the + /// stack visually before falling back to the spider UI. + bool _isAtMaxZoom() { + final z = _mapController?.cameraPosition?.zoom; + if (z == null) return false; + // Small epsilon: zoom can settle at e.g. 16.997 even when the user has + // pinched all the way in; treat that as max. + return z >= _maxUserZoom - 0.05; + } + + /// Find the connected component of repeaters around [anchor] that would + /// still visually overlap at [_maxUserZoom] (the "won't break apart by + /// more zoom" group). + /// + /// Two repeaters are linked when their geographic distance is ≤ the + /// "stick threshold" — `_clusterRadiusPx × metres-per-pixel at the max + /// zoom`. At lat 45° this is ~42 m. Markers within this distance of + /// each other are within the cluster radius even at the user's deepest + /// zoom, so zooming will not separate them visually. + /// + /// BFS seed: repeater closest to [anchor]. Returns at least the seed when + /// any repeater is within the broad search disc; caller should treat a + /// result of length < 2 as "no spiderfy needed". + List _findSpiderGroup(LatLng anchor, AppStateProvider appState) { + final mPerPxMaxZoom = + _metersPerPxAtZoom(anchor.latitude, _maxUserZoom); + final stickThresholdM = _clusterRadiusPx * mPerPxMaxZoom; + + // Broad initial radius: 10× the stick threshold so we don't miss an + // indirect CC member that's near another member but far from the anchor. + // Bounded fixed multiplier — keeps the candidate set small even when a + // user taps a continent-scale cluster at low zoom. + final broadRadiusM = stickThresholdM * 10; + final candidates = []; + for (final r in appState.repeaters) { + if (_haversineMeters(anchor, LatLng(r.lat, r.lon)) <= broadRadiusM) { + candidates.add(r); + } + } + if (candidates.isEmpty) return const []; + + // Pick the closest-to-anchor as the BFS seed. + Repeater seed = candidates.first; + var bestD = double.infinity; + for (final r in candidates) { + final d = _haversineMeters(anchor, LatLng(r.lat, r.lon)); + if (d < bestD) { + bestD = d; + seed = r; + } + } + + // Connected component (single-link clustering at stick threshold). + final visited = {seed.id}; + final queue = [seed]; + final result = [seed]; + while (queue.isNotEmpty) { + final cur = queue.removeAt(0); + final curPos = LatLng(cur.lat, cur.lon); + for (final r in candidates) { + if (visited.contains(r.id)) continue; + if (_haversineMeters(curPos, LatLng(r.lat, r.lon)) <= + stickThresholdM) { + visited.add(r.id); + result.add(r); + queue.add(r); + } + } + } + return result; + } + + /// Layout the [n] spread positions around [center]. Uses a single ring up to + /// 8 markers, two concentric rings up to 20, and a Fermat / golden-angle + /// spiral past 20. + List _computeSpiderRing( + LatLng center, int n, double currentZoom) { + final mPerPx = _metersPerPxAtZoom(center.latitude, currentZoom); + final lat0 = center.latitude; + final lon0 = center.longitude; + final cosLat = math.cos(lat0 * math.pi / 180); + + // Convert (dx_px, dy_px) — screen-space offset from centre — back to a + // geographic LatLng. Screen y grows downward; flip dy so positive screen-y + // maps to a southward (lower-latitude) offset. + LatLng offset(double dxPx, double dyPx) { + final dxM = dxPx * mPerPx; + final dyM = dyPx * mPerPx; + final dLat = -dyM / 111320; + final dLon = dxM / (111320 * cosLat); + return LatLng(lat0 + dLat, lon0 + dLon); + } + + final positions = []; + if (n <= 8) { + // Single ring at the inner radius, evenly spaced from top (-π/2). + for (var i = 0; i < n; i++) { + final angle = -math.pi / 2 + 2 * math.pi * i / n; + positions.add(offset( + _spiderInnerRadiusPx * math.cos(angle), + _spiderInnerRadiusPx * math.sin(angle), + )); + } + } else if (n <= 20) { + // Two concentric rings: 7 inner + (n−7) outer with a half-angle stagger + // so outer markers don't sit directly behind inner ones. + const innerCount = 7; + final outerCount = n - innerCount; + for (var i = 0; i < innerCount; i++) { + final angle = -math.pi / 2 + 2 * math.pi * i / innerCount; + positions.add(offset( + _spiderInnerRadiusPx * math.cos(angle), + _spiderInnerRadiusPx * math.sin(angle), + )); + } + for (var i = 0; i < outerCount; i++) { + final angle = -math.pi / 2 + + math.pi / outerCount + + 2 * math.pi * i / outerCount; + positions.add(offset( + _spiderOuterRadiusPx * math.cos(angle), + _spiderOuterRadiusPx * math.sin(angle), + )); + } + } else { + // Golden-angle spiral — markers self-space without overlap. + const goldenAngle = 137.508 * math.pi / 180; + for (var i = 0; i < n; i++) { + final angle = i * goldenAngle - math.pi / 2; + final r = 30 + 9 * math.sqrt(i + 1); + positions.add(offset( + r * math.cos(angle), + r * math.sin(angle), + )); + } + } + return positions; + } + + /// Build the spider shadow source's FeatureCollection — Point features for + /// every spread marker plus LineString features for the leader lines. + /// Returns an empty collection when no spider is open. + Map _buildSpiderFeatureCollection( + AppStateProvider appState, double currentZoom) { + if (_spiderCenter == null || _spiderRepeaters.isEmpty) { + return { + 'type': 'FeatureCollection', + 'features': >[] + }; + } + final center = _spiderCenter!; + final positions = + _computeSpiderRing(center, _spiderRepeaters.length, currentZoom); + final mPerPx = _metersPerPxAtZoom(center.latitude, currentZoom); + final cosLat = math.cos(center.latitude * math.pi / 180); + + final duplicates = _getDuplicateRepeaterIds(appState.repeaters); + final hopOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; + + final features = >[]; + for (var i = 0; i < _spiderRepeaters.length; i++) { + final repeater = _spiderRepeaters[i]; + final pos = positions[i]; + + final isDuplicate = duplicates.contains(repeater.id); + final statusKey = _repeaterStatusKey(repeater, isDuplicate); + final effectiveBytes = hopOverride ?? repeater.hopBytes; + final shapeBytes = effectiveBytes >= 3 + ? 3 + : effectiveBytes == 2 + ? 2 + : 1; + final iconImage = _MapImages.repeater(statusKey, shapeBytes); + final hex = repeater.displayHexId(overrideHopBytes: hopOverride); + final colorHex = _colorToHex(_repeaterStatusColor(statusKey)); + + features.add({ + 'type': 'Feature', + 'id': repeater.id, + 'properties': { + 'repeaterId': repeater.id, + 'iconImage': iconImage, + 'color': colorHex, + 'hex': hex, + 'isDuplicate': isDuplicate, + if (hopOverride != null) 'hopOverride': hopOverride, + }, + 'geometry': { + 'type': 'Point', + 'coordinates': [pos.longitude, pos.latitude], + }, + }); + + // Leader line — shortened by `_leaderLineEndShortenPx` at the marker + // end so it doesn't punch through the icon / label halo. Compute the + // shortening in screen-pixel space, then convert back to lat/lon. + final dxM = + (pos.longitude - center.longitude) * 111320 * cosLat; + final dyM = (pos.latitude - center.latitude) * 111320; + final dxPx = dxM / mPerPx; + final dyPx = dyM / mPerPx; + final lenPx = math.sqrt(dxPx * dxPx + dyPx * dyPx); + if (lenPx <= _leaderLineEndShortenPx) continue; + final scale = (lenPx - _leaderLineEndShortenPx) / lenPx; + final endLon = + center.longitude + (pos.longitude - center.longitude) * scale; + final endLat = + center.latitude + (pos.latitude - center.latitude) * scale; + features.add({ + 'type': 'Feature', + 'properties': {'repeaterId': repeater.id}, + 'geometry': { + 'type': 'LineString', + 'coordinates': [ + [center.longitude, center.latitude], + [endLon, endLat], + ], + }, + }); + } + + return {'type': 'FeatureCollection', 'features': features}; + } + + /// Push the current spider state to the spider GeoJSON source. Called from + /// `_syncRepeaterSymbols` (post-frame) and after spider state mutations. + Future _syncSpiderSymbols( + AppStateProvider appState, double currentZoom) async { + if (_mapController == null || !_clusterLayersReady) return; + try { + final geojson = + _buildSpiderFeatureCollection(appState, currentZoom); + await _mapController!.setGeoJsonSource(_spiderSourceId, geojson); + } catch (e) { + debugError('[MAP] Failed to update spider source: $e'); + } + } + + /// Open the spider — fan out [repeaters] around [center]. No-op for groups + /// of fewer than 2 (caller should fall through to detail-sheet/zoom paths). + void _spiderfy(LatLng center, List repeaters) { + if (repeaters.length < 2 || _mapController == null || !mounted) return; + setState(() { + _spiderCenter = center; + _spiderRepeaters = List.unmodifiable(repeaters); + _spiderOpenedAtZoom = _mapController!.cameraPosition?.zoom; + }); + debugLog( + '[MAP] Spider opened with ${repeaters.length} markers at ${center.latitude.toStringAsFixed(5)},${center.longitude.toStringAsFixed(5)}'); + // Resync the main source (so the inSpider tag hides originals on the + // individual layer) and the spider source (post-frame inside the sync). + _syncRepeaterSymbols(context.read()); + } + + /// Close the spider — clears state and resyncs both sources. + void _collapseSpider() { + if (_spiderCenter == null || !mounted) return; + setState(() { + _spiderCenter = null; + _spiderRepeaters = const []; + _spiderOpenedAtZoom = null; + }); + debugLog('[MAP] Spider collapsed'); + _syncRepeaterSymbols(context.read()); } /// Composite key for a coverage marker symbol — kind + timestamp ms + lat/lon. From 6995cdcdb5cb40e02403be23299d982c79625fd8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 26 Apr 2026 23:09:34 -0400 Subject: [PATCH 020/100] - Fixed the GPS marker drifting toward the top of the map (and off-screen entirely in heading mode) when the bottom controls panel is expanded with auto-follow enabled. The centring offset was using a 256px tile assumption while MapLibre projects onto a 512px tile grid, so every padding-aware shift was 2x too large. --- lib/widgets/map_widget.dart | 103 ++++++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 35 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 8beb9ef..3d16117 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -331,6 +331,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { false; // Track if we've done the one-time initial zoom to GPS bool _hasZoomedToLastKnown = false; // Track if we've zoomed to last known position (before GPS) + bool _loggedMpxSanityCheck = + false; // One-time log comparing formula vs MapLibre m/px // Map rotation mode bool _alwaysNorth = @@ -575,8 +577,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { void didUpdateWidget(MapWidget oldWidget) { super.didUpdateWidget(oldWidget); // When padding changes (panel opened/closed/minimized/orientation change), re-center if auto-following - if ((widget.bottomPaddingPixels != oldWidget.bottomPaddingPixels || - widget.rightPaddingPixels != oldWidget.rightPaddingPixels) && + final paddingChanged = + widget.bottomPaddingPixels != oldWidget.bottomPaddingPixels || + widget.rightPaddingPixels != oldWidget.rightPaddingPixels; + if (paddingChanged) { + debugLog('[MAP CENTER] didUpdateWidget padding change: ' + 'bottom ${oldWidget.bottomPaddingPixels}->${widget.bottomPaddingPixels} ' + 'right ${oldWidget.rightPaddingPixels}->${widget.rightPaddingPixels} ' + '_autoFollow=$_autoFollow _isMapReady=$_isMapReady ' + '_lastGpsPosition=${_lastGpsPosition != null}'); + } + if (paddingChanged && _autoFollow && _isMapReady && _lastGpsPosition != null) { @@ -596,6 +607,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { targetZoom, targetBearing, ); + final cam = _mapController?.cameraPosition; + debugLog('[MAP CENTER] re-center after padding change: ' + 'gps=(${_lastGpsPosition!.latitude.toStringAsFixed(6)},${_lastGpsPosition!.longitude.toStringAsFixed(6)}) ' + 'target=(${adjustedPosition.latitude.toStringAsFixed(6)},${adjustedPosition.longitude.toStringAsFixed(6)}) ' + 'deltaLat=${(adjustedPosition.latitude - _lastGpsPosition!.latitude).toStringAsFixed(6)} ' + 'deltaLon=${(adjustedPosition.longitude - _lastGpsPosition!.longitude).toStringAsFixed(6)} ' + 'zoom=${targetZoom.toStringAsFixed(2)} bearing=${targetBearing.toStringAsFixed(2)} ' + 'curZoom=${cam?.zoom.toStringAsFixed(2)} curBearing=${cam?.bearing.toStringAsFixed(2)} ' + 'alwaysNorth=$_alwaysNorth'); _animateAutoFollowCamera( target: adjustedPosition, zoom: targetZoom, @@ -790,48 +810,61 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (_mapController == null || !_isMapReady) return position; if (bottomPadding <= 0 && rightPadding <= 0) return position; - // Get meters per pixel at the target zoom (or current camera zoom). - // Approx: 40075km / (256 * 2^zoom) at equator, adjusted by cos(lat) + // MapLibre's internal projection uses 512-logical-px tile units (see + // MapLibre style spec — vector and raster sources are reprojected onto a + // 512px grid regardless of source tile size). The previous formula here + // assumed 256-px tiles, which made every offset 2× too large and pushed + // the GPS marker far above the visible-area centre. final zoom = atZoom ?? _mapController!.cameraPosition?.zoom ?? _defaultZoom; final metersPerPixel = 40075000 / - (256 * math.pow(2, zoom)) * + (512 * math.pow(2, zoom)) * math.cos(position.latitude * math.pi / 180); - // Start with the offset expressed as if the map were north-up - // (bearing = 0): bottom padding shifts the target geographic-south, - // right padding shifts the target geographic-west. - double latOffset = 0; - double lonOffset = 0; - if (bottomPadding > 0) { - final meterOffset = (bottomPadding / 2) * metersPerPixel; - latOffset = -(meterOffset / 111000); // ~111km per degree latitude - } - if (rightPadding > 0) { - final meterOffset = (rightPadding / 2) * metersPerPixel; - lonOffset = -(meterOffset / - (111000 * math.cos(position.latitude * math.pi / 180))); + // One-time sanity check: log MapLibre's authoritative m/px and compare + // to our formula. If they differ, the tile-size assumption is wrong. + if (!_loggedMpxSanityCheck) { + _loggedMpxSanityCheck = true; + _mapController! + .getMetersPerPixelAtLatitude(position.latitude) + .then((mapLibreMpx) { + debugLog('[MAP CENTER] m/px sanity: formula=${metersPerPixel.toStringAsFixed(4)} ' + 'maplibre=${mapLibreMpx.toStringAsFixed(4)} ' + 'ratio=${(metersPerPixel / mapLibreMpx).toStringAsFixed(3)} ' + '(zoom=${zoom.toStringAsFixed(2)} lat=${position.latitude.toStringAsFixed(4)})'); + }).catchError((e) { + debugLog('[MAP CENTER] m/px sanity check failed: $e'); + }); } - // When the map is rotated, "screen-down" no longer points geographic - // south — it points wherever bearing + 180° aims. Rotate the offset - // vector so the shift still lands in the correct screen direction. + // Compute the desired camera shift in WORLD METERS along the + // (north, east) axes. Working in metres up front avoids the previous + // unit-mixing bug, where lat-degrees and lon-degrees were rotated as if + // they were the same unit (1° lat ≠ 1° lon away from the equator). // - // MapLibre bearing is clockwise from north (heading east => bearing 90, - // screen-down => world-west). To send a south-pointing input vector to - // the world direction that corresponds to screen-down at the given - // bearing, we rotate it clockwise by `bearing` — i.e. by +bearing, not - // -bearing as the previous implementation did. + // We want the marker (at `position`) to appear shifted "screen-up" by + // `bottomPadding/2` and "screen-left" by `rightPadding/2` relative to + // screen centre, so the camera target itself shifts in the opposite + // direction (screen-down + screen-right). final bearingDeg = atBearing ?? _mapController!.cameraPosition?.bearing ?? 0; - if (bearingDeg.abs() > 0.1) { - final rotationRad = bearingDeg * math.pi / 180; - final cosR = math.cos(rotationRad); - final sinR = math.sin(rotationRad); - final rotatedLat = latOffset * cosR - lonOffset * sinR; - final rotatedLon = latOffset * sinR + lonOffset * cosR; - latOffset = rotatedLat; - lonOffset = rotatedLon; - } + final bearingRad = bearingDeg * math.pi / 180; + final cosB = math.cos(bearingRad); + final sinB = math.sin(bearingRad); + + // At bearing β (clockwise from north), world unit-vectors of the + // screen axes are: + // screen-down = (-cosβ, -sinβ) in (north, east) + // screen-right = (-sinβ, cosβ) + final downMetres = bottomPadding / 2 * metersPerPixel; + final rightMetres = rightPadding / 2 * metersPerPixel; + final northShift = downMetres * -cosB + rightMetres * -sinB; + final eastShift = downMetres * -sinB + rightMetres * cosB; + + // Convert metres → degrees. 1° latitude ≈ 111 km everywhere; 1° longitude + // shrinks by cos(latitude). + final latOffset = northShift / 111000; + final lonOffset = + eastShift / (111000 * math.cos(position.latitude * math.pi / 180)); return LatLng( position.latitude + latOffset, position.longitude + lonOffset); From 6d13f45c45785061d53b2b00d970b1ba9b2b7817 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Apr 2026 22:25:54 -0400 Subject: [PATCH 021/100] =?UTF-8?q?-=20**RX=20Hop=20Path:**=20Tapping=20an?= =?UTF-8?q?=20RX=20marker=20now=20shows=20the=20full=20mesh=20path=20the?= =?UTF-8?q?=20packet=20travelled=20(origin=20=E2=86=92=20...=20=E2=86=92?= =?UTF-8?q?=20us)=20as=20a=20tappable=20chain=20of=20repeater=20chips,=20w?= =?UTF-8?q?ith=20the=20receiving=20repeater=20tagged=20"(heard)".=20Each?= =?UTF-8?q?=20hop=20chip=20opens=20the=20standard=20repeater=20info=20popu?= =?UTF-8?q?p=20so=20you=20can=20resolve=20names=20and=20distances=20along?= =?UTF-8?q?=20the=20path=20without=20leaving=20the=20sheet.=20The=20same?= =?UTF-8?q?=20chain=20renders=20on=20every=20RX=20entry=20in=20the=20Log?= =?UTF-8?q?=20tab.=20If=20your=20own=20carpeater=20was=20on=20the=20tail,?= =?UTF-8?q?=20it's=20stripped=20from=20the=20chain=20just=20like=20it's=20?= =?UTF-8?q?stripped=20from=20the=20displayed=20last-hop=20ID.=20CSV=20expo?= =?UTF-8?q?rt=20gains=20a=20`path=5Fhops`=20column=20on=20RX=20rows.=20Pat?= =?UTF-8?q?h=20data=20is=20transient=20and=20lost=20on=20app=20restart,=20?= =?UTF-8?q?matching=20how=20TX=20heard-repeater=20data=20already=20works.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/models/log_entry.dart | 6 ++- lib/models/ping_data.dart | 5 ++ lib/providers/app_state_provider.dart | 4 ++ lib/screens/log_screen.dart | 61 ++++++++++++++++++--- lib/services/meshcore/rx_logger.dart | 22 ++++++++ lib/widgets/map_widget.dart | 35 ++++++++++++ lib/widgets/rx_path_chain.dart | 78 +++++++++++++++++++++++++++ 7 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 lib/widgets/rx_path_chain.dart diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart index 2fe84af..9afe565 100644 --- a/lib/models/log_entry.dart +++ b/lib/models/log_entry.dart @@ -81,6 +81,8 @@ class RxLogEntry { final int header; // Packet header byte final double latitude; final double longitude; + /// Display path hops, origin → ... → us. Already CARpeater-stripped. + final List pathHops; RxLogEntry({ required this.timestamp, @@ -91,6 +93,7 @@ class RxLogEntry { required this.header, required this.latitude, required this.longitude, + this.pathHops = const [], }); /// Get formatted timestamp (HH:MM:SS) @@ -120,9 +123,10 @@ class RxLogEntry { /// Get CSV row String toCsv() { + final pathStr = pathHops.isEmpty ? '' : pathHops.join('|'); return '${timestamp.toIso8601String()},$repeaterId,${snr ?? 'null'},${rssi ?? 'null'},' '$pathLength,0x${header.toRadixString(16).padLeft(2, '0')},' - '$latitude,$longitude'; + '$latitude,$longitude,$pathStr'; } } diff --git a/lib/models/ping_data.dart b/lib/models/ping_data.dart index 9d7e105..9cd326e 100644 --- a/lib/models/ping_data.dart +++ b/lib/models/ping_data.dart @@ -87,6 +87,10 @@ class RxPing { @HiveField(5) final int rssi; + /// Display path hops, origin → ... → us. Already CARpeater-stripped. + /// Transient (not persisted to Hive); empty when reloaded from disk. + final List pathHops; + const RxPing({ required this.latitude, required this.longitude, @@ -94,6 +98,7 @@ class RxPing { required this.timestamp, required this.snr, required this.rssi, + this.pathHops = const [], }); Map toApiJson() { diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7c6d272..21424b2 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2131,6 +2131,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { timestamp: DateTime.now(), snr: observation.snr ?? 0.0, rssi: observation.rssi ?? 0, + pathHops: observation.displayHops, ); _rxPings.add(rxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); @@ -2209,6 +2210,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { snr: entry.snr ?? existingPin.snr, // UPDATE to best SNR from batch rssi: entry.rssi ?? existingPin.rssi, + pathHops: existingPin.pathHops, ); debugLog( '[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' @@ -2227,6 +2229,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { timestamp: entry.timestamp, snr: entry.snr ?? 0.0, rssi: entry.rssi ?? 0, + pathHops: entry.displayHops, ); _rxPings.add(newRxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); @@ -2251,6 +2254,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { header: entry.header, latitude: entry.lat, longitude: entry.lon, + pathHops: entry.displayHops, ); // Add to RX log entries diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 425a0a1..16a88b3 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -7,6 +7,7 @@ import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../utils/ping_colors.dart'; import '../widgets/repeater_id_chip.dart'; +import '../widgets/rx_path_chain.dart'; /// Log screen with two tabs: All Pings (unified TX+RX+DISC+TRC) and Errors class LogScreen extends StatefulWidget { @@ -175,7 +176,7 @@ class _LogScreenState extends State if (rx.isNotEmpty) { buffer.writeln('--- RX Log ---'); buffer.writeln( - 'timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); + 'timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude,path_hops'); for (final entry in rx) { buffer.writeln(entry.toCsv()); } @@ -676,6 +677,15 @@ class _AllPingsTabState extends State<_AllPingsTab> { // TX Card // --------------------------------------------------------------------------- + /// Width of the "Node" column sized from the hex-ID length actually being + /// rendered. RX paths can carry longer IDs than the global TX hop-byte + /// setting, so we measure per-card instead of per-session. + double _nodeColumnWidthForLength(int idLength) { + if (idLength <= 2) return 60; + if (idLength <= 4) return 70; + return 80; + } + Widget _buildTxCard(BuildContext context, TxLogEntry entry, {bool showAmbiguity = false}) { final appState = context.read(); @@ -716,6 +726,12 @@ class _AllPingsTabState extends State<_AllPingsTab> { } Widget _buildRepeaterTable(BuildContext context, List events) { + var maxIdLen = 0; + for (final e in events) { + if (e.repeaterId.length > maxIdLen) maxIdLen = e.repeaterId.length; + } + final nodeWidth = _nodeColumnWidthForLength(maxIdLen); + return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, @@ -730,20 +746,22 @@ class _AllPingsTabState extends State<_AllPingsTab> { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: 60, child: _tableHeader(context, 'Node')), + SizedBox(width: nodeWidth, child: _tableHeader(context, 'Node')), Expanded(child: _tableHeader(context, 'SNR', center: true)), Expanded(child: _tableHeader(context, 'RSSI', center: true)), ], ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - ...events.map((event) => _buildTxRepeaterRow(context, event)), + ...events.map( + (event) => _buildTxRepeaterRow(context, event, nodeWidth)), ], ), ); } - Widget _buildTxRepeaterRow(BuildContext context, RxEvent event) { + Widget _buildTxRepeaterRow( + BuildContext context, RxEvent event, double nodeWidth) { final snrColor = _snrColor(event.severity); final rssiColor = _rssiColor(event.rssi); return InkWell( @@ -753,7 +771,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Row( children: [ RepeaterIdChip( - repeaterId: event.repeaterId, fontSize: 14, width: 60), + repeaterId: event.repeaterId, + fontSize: 14, + width: nodeWidth), Expanded( child: Center( child: _buildChip( @@ -778,6 +798,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { final appState = context.read(); final snrColor = _snrColor(entry.severity); final rssiColor = _rssiColor(entry.rssi); + final nodeWidth = _nodeColumnWidthForLength(entry.repeaterId.length); return Card( margin: const EdgeInsets.only(bottom: 8), @@ -813,7 +834,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Row( children: [ SizedBox( - width: 60, child: _tableHeader(context, 'Node')), + width: nodeWidth, + child: _tableHeader(context, 'Node')), Expanded( child: _tableHeader(context, 'SNR', center: true)), @@ -835,7 +857,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { RepeaterIdChip( repeaterId: entry.repeaterId, fontSize: 14, - width: 60), + width: nodeWidth), Expanded( child: Center( child: _buildChip( @@ -855,6 +877,31 @@ class _AllPingsTabState extends State<_AllPingsTab> { ], ), ), + if (entry.pathHops.isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.route, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 6), + Expanded( + child: RxPathChain( + hops: entry.pathHops, + fromLatLng: + (lat: entry.latitude, lon: entry.longitude), + fontSize: 12, + ), + ), + ], + ), + ], ], ), ), diff --git a/lib/services/meshcore/rx_logger.dart b/lib/services/meshcore/rx_logger.dart index c78829a..9307445 100644 --- a/lib/services/meshcore/rx_logger.dart +++ b/lib/services/meshcore/rx_logger.dart @@ -150,6 +150,15 @@ class RxLogger { debugLog('[RX LOG] ✅ Packet validated and passed filter'); + // Build display path: full hop chain, minus the last hop when it was a + // carpeater pass-through (so the user's own carpeater is not shown). + final displayHopCount = carpeaterStripped + ? metadata.pathHashCount - 1 + : metadata.pathHashCount; + final displayHops = [ + for (var i = 0; i < displayHopCount; i++) metadata.getHopHex(i)!, + ]; + // Create observation for this packet final observation = RxObservation( repeaterId: repeaterId, @@ -161,6 +170,7 @@ class RxLogger { lon: gpsLocation.lon, timestamp: DateTime.now(), metadata: metadata, + displayHops: displayHops, ); // Handle tracking for API (best SNR with distance trigger) @@ -173,6 +183,7 @@ class RxLogger { header: metadata.header, currentLocation: gpsLocation, metadata: metadata, + displayHops: displayHops, ); // Only fire immediate callback if this observation was actually kept @@ -211,6 +222,7 @@ class RxLogger { required int header, required ({double lat, double lon}) currentLocation, required PacketMetadata metadata, + required List displayHops, }) async { // Get or create buffer entry for this repeater RxBatch? buffer = _batchBuffer[repeaterId]; @@ -230,6 +242,7 @@ class RxLogger { lon: currentLocation.lon, timestamp: DateTime.now(), metadata: metadata, + displayHops: displayHops, ), ); _batchBuffer[repeaterId] = buffer; @@ -266,6 +279,7 @@ class RxLogger { lon: buffer.firstLocation.lon, // Keep original location timestamp: DateTime.now(), metadata: metadata, + displayHops: displayHops, ); wasKept = true; // Better SNR, observation is kept } else { @@ -371,6 +385,7 @@ class RxLogger { header: best.header, timestamp: best.timestamp, metadata: best.metadata, + displayHops: best.displayHops, ); debugLog('[RX BATCH] Posting repeater $repeaterId: snr=${best.snr}, ' @@ -477,6 +492,9 @@ class RxObservation { final double lon; final DateTime timestamp; final PacketMetadata metadata; + /// Display path hops, origin → ... → us. Already CARpeater-stripped (the + /// user's own carpeater, when present, has been removed from the tail). + final List displayHops; RxObservation({ required this.repeaterId, @@ -488,6 +506,7 @@ class RxObservation { required this.lon, required this.timestamp, required this.metadata, + this.displayHops = const [], }); } @@ -502,6 +521,8 @@ class RxApiEntry { final int header; final DateTime timestamp; final PacketMetadata metadata; + /// Display path hops, origin → ... → us. Already CARpeater-stripped. + final List displayHops; RxApiEntry({ required this.repeaterId, @@ -513,5 +534,6 @@ class RxApiEntry { required this.header, required this.timestamp, required this.metadata, + this.displayHops = const [], }); } diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 3d16117..1990532 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -20,6 +20,7 @@ import '../utils/debug_logger_io.dart'; import '../utils/distance_formatter.dart'; import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; +import 'rx_path_chain.dart'; /// Satellite style as inline MapLibre style JSON (ArcGIS raster source). /// The `glyphs` URL is required because our native symbol layers @@ -5532,6 +5533,40 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ], ), ), + + // Path section (origin → ... → us). Skipped when the path is + // unavailable, e.g. RxPings reloaded from Hive (transient field). + if (ping.pathHops.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + 'Path', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: RxPathChain( + hops: ping.pathHops, + fromLatLng: (lat: ping.latitude, lon: ping.longitude), + fontSize: 13, + ), + ), + ], ], ), ), diff --git a/lib/widgets/rx_path_chain.dart b/lib/widgets/rx_path_chain.dart new file mode 100644 index 0000000..9962d2c --- /dev/null +++ b/lib/widgets/rx_path_chain.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import 'repeater_id_chip.dart'; + +/// Compact arrow-chain renderer for an RX packet's hop path. +/// +/// Renders `[hop] → [hop] → [hop] (heard)` with each hop as a tappable +/// [RepeaterIdChip]. Long paths wrap to multiple lines via [Wrap]. +class RxPathChain extends StatelessWidget { + /// Hop list ordered origin → ... → us. Already CARpeater-stripped. + final List hops; + + /// Source GPS coordinates passed through to the repeater info popup so + /// distances are measured from the ping location, not the user's current + /// position. + final ({double lat, double lon})? fromLatLng; + + /// Font size for hop chips (11 for log screens, 13 for map popups). + final double fontSize; + + const RxPathChain({ + super.key, + required this.hops, + this.fromLatLng, + this.fontSize = 12, + }); + + @override + Widget build(BuildContext context) { + if (hops.isEmpty) return const SizedBox.shrink(); + + final scheme = Theme.of(context).colorScheme; + final arrow = Icon( + Icons.arrow_forward, + size: fontSize, + color: scheme.onSurfaceVariant.withValues(alpha: 0.7), + ); + + final children = []; + for (var i = 0; i < hops.length; i++) { + if (i > 0) children.add(arrow); + children.add( + InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, + hops[i], + fromLatLng: fromLatLng, + ), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: RepeaterIdChip(repeaterId: hops[i], fontSize: fontSize), + ), + ), + ); + } + children.add( + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + '(heard)', + style: TextStyle( + fontSize: fontSize - 2, + fontStyle: FontStyle.italic, + color: scheme.onSurfaceVariant, + ), + ), + ), + ); + + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 2, + runSpacing: 4, + children: children, + ); + } +} From dba07f9f4128844e79037067426817f0c427d030 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Apr 2026 22:41:14 -0400 Subject: [PATCH 022/100] - Fixed spiderfy pulling in markers from neighbouring clusters when tapping a bubble at max zoom. The fan now matches the count shown on the bubble exactly. --- lib/widgets/map_widget.dart | 165 +++++++++++++++++++++++++++--------- 1 file changed, 125 insertions(+), 40 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 1990532..e7202d9 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1506,44 +1506,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // finishes in 200ms, making the tap feel "instant" rather than delayed. if (layerId == _repeaterClusterBubbleLayerId || layerId == _repeaterClusterCountLayerId) { - // Spiderfy is gated to max zoom. Below that, give the user a chance - // to separate the stack by zooming further before we resort to the - // spread UI. Note that `_spiderCenter` is always null at non-max - // zoom (the camera-change collapse fires when the user zooms out - // from max), so no collapse-handling is needed in this branch. - if (!_isAtMaxZoom()) { - if (_canAnimateCamera) { - final currentZoom = - _mapController?.cameraPosition?.zoom ?? _defaultZoom; - final newZoom = math.min(currentZoom + 2, _maxUserZoom); - _mapController?.animateCamera( - CameraUpdate.newLatLngZoom(coordinates, newZoom), - duration: const Duration(milliseconds: 200), - ); - } - return; - } - - final appState = context.read(); - final group = _findSpiderGroup(coordinates, appState); - - // Re-tap on the open spider's own group → collapse instead of churn. - if (_spiderCenter != null) { - final spiderIds = _spiderRepeaters.map((r) => r.id).toSet(); - if (group.any((r) => spiderIds.contains(r.id))) { - _collapseSpider(); - return; - } - // Tapped a different cluster — close the existing spider before - // evaluating the new tap. - _collapseSpider(); - } - - if (group.length >= 2) { - _spiderfy(coordinates, group); - } - // Already at max zoom with a single-marker group: nothing useful to - // zoom into and no stack to spread. Silent no-op. + _handleClusterBubbleTap(point, coordinates); return; } @@ -1597,6 +1560,92 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + /// Handle a tap on a cluster bubble (or its count label). Spiderfy is gated + /// to max zoom; below that we just zoom in. + /// + /// At max zoom, the spider group is computed from the actual MapLibre + /// cluster's `point_count` (queried back from the rendered feature) — we + /// take the N nearest repeaters to the tapped coordinate. This deliberately + /// avoids the transitive single-link clustering used by [_findSpiderGroup], + /// which could chain across separate visual clusters when markers form a + /// 42m-spaced trail and pull every chained marker into a single spider. + Future _handleClusterBubbleTap( + math.Point point, LatLng coordinates) async { + if (!mounted) return; + + // Below max zoom: zoom in further so the user has a chance to separate + // the stack visually before we resort to the spread UI. _spiderCenter is + // always null at non-max zoom (the camera-change collapse fires when the + // user zooms out), so no collapse-handling is needed here. + if (!_isAtMaxZoom()) { + if (_canAnimateCamera) { + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, _maxUserZoom); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + } + return; + } + + // Read the tapped cluster's point_count from MapLibre. This is the + // authoritative count of leaves Supercluster grouped into this bubble — + // matching it ensures the spider expands exactly the markers represented + // by the tapped bubble, not a chained connected component. + int? pointCount; + try { + final features = await _mapController?.queryRenderedFeatures( + point, + const [ + _repeaterClusterBubbleLayerId, + _repeaterClusterCountLayerId, + ], + null, + ); + if (features != null) { + for (final f in features) { + final props = ((f as Map)['properties'] as Map?) ?? const {}; + if (props['cluster'] == true) { + final pc = props['point_count']; + if (pc is num) { + pointCount = pc.toInt(); + break; + } + } + } + } + } catch (e) { + debugError('[MAP] cluster point_count query failed: $e'); + } + + if (!mounted) return; + + final appState = context.read(); + // If we couldn't read point_count (race with style reload, etc.), fall + // back to the BFS-based group — better to spiderfy something than nothing. + final group = pointCount != null + ? _findSpiderGroupForCluster(coordinates, pointCount, appState) + : _findSpiderGroup(coordinates, appState); + + // Re-tap on the open spider's own group → collapse instead of churn. + if (_spiderCenter != null) { + final spiderIds = _spiderRepeaters.map((r) => r.id).toSet(); + if (group.any((r) => spiderIds.contains(r.id))) { + _collapseSpider(); + return; + } + _collapseSpider(); + } + + if (group.length >= 2) { + _spiderfy(coordinates, group); + } + // Already at max zoom with a single-marker group: nothing useful to + // zoom into and no stack to spread. Silent no-op. + } + /// When a tap hits the GPS marker (which has no detail sheet), try to find /// any repeater cluster or individual repeater under the same point and /// dispatch THAT instead. We use [queryRenderedFeatures] explicitly scoped @@ -1631,7 +1680,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Cluster (auto-tagged by MapLibre when cluster: true is set on source). // Mirrors the cluster path in _handleFeatureTap, including the - // max-zoom gate on spiderfy. + // max-zoom gate on spiderfy. We already have the feature in hand here, + // so read `point_count` directly instead of re-querying. if (properties['cluster'] == true) { if (!_isAtMaxZoom()) { if (_canAnimateCamera) { @@ -1646,7 +1696,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return; } final appState = context.read(); - final group = _findSpiderGroup(coordinates, appState); + final pcRaw = properties['point_count']; + final pointCount = pcRaw is num ? pcRaw.toInt() : null; + final group = pointCount != null + ? _findSpiderGroupForCluster(coordinates, pointCount, appState) + : _findSpiderGroup(coordinates, appState); if (_spiderCenter != null) { final spiderIds = _spiderRepeaters.map((r) => r.id).toSet(); if (group.any((r) => spiderIds.contains(r.id))) { @@ -2569,6 +2623,37 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return result; } + /// Find the [pointCount] repeaters nearest to [anchor], used when expanding + /// a tapped MapLibre cluster bubble. + /// + /// Unlike [_findSpiderGroup] (transitive BFS connected component), this is + /// a non-transitive proximity query — it cannot chain across separate + /// visual clusters. The count comes from Supercluster's `point_count` on + /// the tapped feature, so the result matches the bubble exactly in every + /// realistic case (centroid drift in extreme layouts may shuffle a 1–2 + /// marker boundary, but the spider count is always correct). + List _findSpiderGroupForCluster( + LatLng anchor, int pointCount, AppStateProvider appState) { + if (pointCount <= 0) return const []; + // Bound the candidate set with a generous radius. Supercluster's max + // cluster diameter at maxZoom is ~2× the cluster radius (centroid + // drift); pointCount × stickThreshold is a comfortable upper bound for + // any plausible cluster size. + final mPerPxMaxZoom = + _metersPerPxAtZoom(anchor.latitude, _maxUserZoom); + final stickThresholdM = _clusterRadiusPx * mPerPxMaxZoom; + final broadRadiusM = stickThresholdM * math.max(10, pointCount); + final candidates = >[]; + for (final r in appState.repeaters) { + final d = _haversineMeters(anchor, LatLng(r.lat, r.lon)); + if (d <= broadRadiusM) { + candidates.add(MapEntry(r, d)); + } + } + candidates.sort((a, b) => a.value.compareTo(b.value)); + return candidates.take(pointCount).map((e) => e.key).toList(); + } + /// Layout the [n] spread positions around [center]. Uses a single ring up to /// 8 markers, two concentric rings up to 20, and a Fermat / golden-angle /// spiral past 20. From c05cbeafc7cb3aca2a53ab6d3bd3af98e07fd412 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Apr 2026 22:51:54 -0400 Subject: [PATCH 023/100] - **Stale Repeater Filtering:** Repeaters not heard in the past 30 days are now hidden from the map. They don't render as markers, don't contribute to cluster bubble counts, and don't get pulled into spider expansions. The repeater picker (trace mode) and Log tab still list every repeater the server returns, so nothing is lost from the data view, it's a map-only declutter. --- lib/models/repeater.dart | 9 +++++++++ lib/widgets/map_widget.dart | 21 +++++++++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/models/repeater.dart b/lib/models/repeater.dart index bbf76bd..4d67139 100644 --- a/lib/models/repeater.dart +++ b/lib/models/repeater.dart @@ -136,6 +136,15 @@ class Repeater { /// Check if the repeater has not been heard in the past 24 hours bool get isDead => !isActive; + /// True if the repeater has been heard within the past 30 days. Used by + /// the map to hide long-stale repeaters. Returns false when [lastHeard] + /// is 0 (never heard). + bool get isHeardRecently { + if (lastHeard == 0) return false; + final heard = DateTime.fromMillisecondsSinceEpoch(lastHeard * 1000); + return DateTime.now().difference(heard).inDays < 30; + } + /// Get display hex ID based on hop bytes (or override). /// [overrideHopBytes] is used when regional admin enforces a byte size. String displayHexId({int? overrideHopBytes}) { diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index e7202d9..0044442 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1758,7 +1758,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { appState.repeaters.where((r) => r.id == repeaterId).firstOrNull; if (repeater == null) return; - final duplicates = _getDuplicateRepeaterIds(appState.repeaters); + final duplicates = _getDuplicateRepeaterIds(_mapVisibleRepeaters(appState)); final isDuplicate = duplicates.contains(repeater.id); final hopOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; @@ -2204,7 +2204,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// the marker data version changes — MapLibre handles re-clustering natively. Map _buildRepeaterFeatureCollection( AppStateProvider appState) { - final duplicates = _getDuplicateRepeaterIds(appState.repeaters); + final visible = _mapVisibleRepeaters(appState); + final duplicates = _getDuplicateRepeaterIds(visible); final hopOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; final focusActive = _focusedPingLocation != null; @@ -2216,7 +2217,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final spiderIds = _spiderRepeaters.map((r) => r.id).toSet(); final features = >[]; - for (final repeater in appState.repeaters) { + for (final repeater in visible) { final isDuplicate = duplicates.contains(repeater.id); final statusKey = _repeaterStatusKey(repeater, isDuplicate); final isConnected = focusActive && @@ -2585,7 +2586,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // user taps a continent-scale cluster at low zoom. final broadRadiusM = stickThresholdM * 10; final candidates = []; - for (final r in appState.repeaters) { + for (final r in _mapVisibleRepeaters(appState)) { if (_haversineMeters(anchor, LatLng(r.lat, r.lon)) <= broadRadiusM) { candidates.add(r); } @@ -2644,7 +2645,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final stickThresholdM = _clusterRadiusPx * mPerPxMaxZoom; final broadRadiusM = stickThresholdM * math.max(10, pointCount); final candidates = >[]; - for (final r in appState.repeaters) { + for (final r in _mapVisibleRepeaters(appState)) { final d = _haversineMeters(anchor, LatLng(r.lat, r.lon)); if (d <= broadRadiusM) { candidates.add(MapEntry(r, d)); @@ -2738,7 +2739,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final mPerPx = _metersPerPxAtZoom(center.latitude, currentZoom); final cosLat = math.cos(center.latitude * math.pi / 180); - final duplicates = _getDuplicateRepeaterIds(appState.repeaters); + final duplicates = _getDuplicateRepeaterIds(_mapVisibleRepeaters(appState)); final hopOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; @@ -5096,6 +5097,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return idCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet(); } + /// Repeaters eligible for map rendering — excludes anything not heard in + /// the past 30 days so long-stale entries don't appear, contribute to + /// clusters, or get pulled into spider expansions. All map-rendering + /// paths route through this; non-map consumers (log, picker) keep using + /// `appState.repeaters` directly. + List _mapVisibleRepeaters(AppStateProvider appState) => + appState.repeaters.where((r) => r.isHeardRecently).toList(); + /// Get marker color for a repeater based on status priority: /// 1. Duplicate → Red (always takes priority) /// 2. Dead → Grey (not heard in 24 hours) From db29741d8186e673fc7f14bde9abf58a68202d1c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 29 Apr 2026 22:50:15 -0400 Subject: [PATCH 024/100] Improvements - Auto-follow zoom is now one-shot: zooms to street level once, then only pans. Pinch-zooming while following no longer fights you. - Reduced per-GPS-tick map work for smoother performance Bug Fixes - Coverage overlay no longer flashes on tile refresh - Repeaters without GPS now show a location-off icon instead of dragging focus mode to (0, 0) - Fixed map labels flickering at max zoom during follow mode --- lib/models/repeater.dart | 7 + lib/providers/app_state_provider.dart | 74 +++- lib/widgets/map_widget.dart | 528 ++++++++++++++++++++++---- 3 files changed, 522 insertions(+), 87 deletions(-) diff --git a/lib/models/repeater.dart b/lib/models/repeater.dart index 4d67139..fdfef51 100644 --- a/lib/models/repeater.dart +++ b/lib/models/repeater.dart @@ -112,6 +112,13 @@ class Repeater { /// Check if the repeater is enabled (any non-zero value) bool get isEnabled => enabled != 0; + /// True when the repeater has known GPS coordinates. The API uses + /// `(0, 0)` as a sentinel for "location not yet published" — those + /// repeaters are excluded from map focus geometry (no line, no + /// distance label, not part of the bounds-fit) but still appear in + /// heard-repeater listings with a `location_off` indicator. + bool get hasLocation => lat != 0.0 || lon != 0.0; + /// Check if the repeater was created within the past 7 days bool get isNew { if (createdAt == null) return false; diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 21424b2..fc64618 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -5228,16 +5228,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } debugLog('[ZONE] Requesting auth for zone $newZoneCode'); - final result = await _apiService.requestAuth( + final modelString = _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? + 'Unknown'; + var result = await _apiService.requestAuth( reason: 'connect', publicKey: _devicePublicKey!, who: deviceName, appVersion: _appVersion, power: _preferences.powerLevel, iataCode: newZoneCode, - model: _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? - 'Unknown', + model: modelString, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, @@ -5267,10 +5268,67 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final message = result['message'] as String? ?? 'Auth failed'; debugError( '[ZONE] Auth failed for zone $newZoneCode: $reason - $message'); - logError('Zone transfer failed: $message', - severity: ErrorSeverity.error); - await disconnect(); - return; + + // Stage 2: unknown_device → register via signed contact URI (mirrors initial connect) + if (reason == 'unknown_device' && _meshCoreConnection != null) { + debugLog( + '[ZONE] Stage 2: Attempting registration via contact_uri for zone $newZoneCode'); + + String? contactUri; + try { + contactUri = await _meshCoreConnection!.exportContact(); + debugLog( + '[ZONE] Received contact URI: ${contactUri.substring(0, contactUri.length < 50 ? contactUri.length : 50)}...'); + } catch (e) { + debugError('[ZONE] Stage 2 failed: could not export contact: $e'); + logError('Zone transfer failed: $message', + severity: ErrorSeverity.error); + await disconnect(); + return; + } + + final registerResult = await _apiService.requestAuth( + reason: 'register', + contactUri: contactUri, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: newZoneCode, + model: modelString, + lat: _currentPosition!.latitude, + lon: _currentPosition!.longitude, + accuracyMeters: _currentPosition!.accuracy, + ); + + if (registerResult == null) { + debugError('[ZONE] Stage 2 failed: network error'); + logError('Zone transfer failed: unable to register with server', + severity: ErrorSeverity.error); + await disconnect(); + return; + } + + if (registerResult['success'] != true) { + final regReason = + registerResult['reason'] as String? ?? 'registration_failed'; + final regMessage = registerResult['message'] as String? ?? + 'Registration rejected by server'; + debugError('[ZONE] Stage 2 failed: $regReason - $regMessage'); + logError('Zone transfer failed: $regMessage', + severity: ErrorSeverity.error); + await disconnect(); + return; + } + + debugLog( + '[ZONE] Stage 2 succeeded: registered for zone $newZoneCode'); + result = registerResult; + } else { + logError('Zone transfer failed: $message', + severity: ErrorSeverity.error); + await disconnect(); + return; + } } // 11. Auth succeeded — update session zone code diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 0044442..ba4889e 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -395,6 +395,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // runs whose remove/add calls interleave and produce "Source already exists" // errors in the native log. bool _coverageRefreshScheduled = false; + + // Double-buffered coverage overlay: each refresh allocates fresh suffixed + // IDs so the new raster source/layer can be added on top of the previous + // one and rendered before the old layer is removed. This prevents the + // brief blank frame the user previously saw every cache-bust cycle. + String? _activeCoverageSourceId; + String? _activeCoverageLayerId; + int _coverageBufferCounter = 0; + // One-shot completer released by _onMapIdle (or the timeout fallback) to + // signal the swap that new tiles have rendered and the old layer is safe + // to remove. Null when no swap is in flight. + Completer? _coverageSwapIdleCompleter; + Timer? _coverageSwapTimeoutTimer; bool _styleLoaded = false; bool _hasStyleLoadedOnce = false; // True after first onStyleLoadedCallback (prevents re-centering on style switch) @@ -412,6 +425,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // advanced during the run and triggers a rebuild if so. bool _syncInFlight = false; + // GPS marker sync runs on its own gate, separate from _syncAllAnnotations. + // GPS position is camera/sensor state — it changes every tick during + // auto-follow but the marker data (repeaters, pings, focus) does not. + // Routing GPS through _syncAllAnnotations made setGeoJsonSource fire on + // every tick, which triggered MapLibre's global symbol-collision recalc + // and made base-style POI labels flicker. Splitting the gates keeps + // _syncGpsSymbol cheap and lets _syncAllAnnotations idle when nothing + // marker-related has changed. + int _lastGpsSyncVersion = -1; + bool _gpsSyncInFlight = false; + // Tile load failure detection — shows a banner if map tiles haven't loaded // within a timeout after style load. Cleared when onMapIdle fires. bool _tileLoadFailed = false; @@ -436,6 +460,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // "Failed to update repeater source: sourceNotFound" error at startup). bool _clusterLayersReady = false; + // True while the focus-lines source + 1-2 line layers are installed in the + // current style. Lets _updateFocusLines short-circuit when there's nothing + // to remove and nothing to add — touching MapLibre's layer stack (even with + // no-op removes that hit try/catch) crosses the platform channel and can + // nudge the symbol-collision pass. + bool _focusLinesInstalled = false; + // Native annotation tracking — populated by sync methods. // Maps from app-state IDs to MapLibre Symbol/Line objects so we can diff // (add new, update existing, remove deleted) on each data version change. @@ -526,6 +557,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { void dispose() { WidgetsBinding.instance.removeObserver(this); _tileLoadTimeoutTimer?.cancel(); + _coverageSwapTimeoutTimer?.cancel(); + final swapWaiter = _coverageSwapIdleCompleter; + if (swapWaiter != null && !swapWaiter.isCompleted) { + swapWaiter.complete(); + } final controller = _mapController; if (controller != null) { controller.removeListener(_onCameraChanged); @@ -910,7 +946,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { - _animateToPositionWithZoom(lastKnownCenter, 15.0); + _animateToPositionWithZoom(lastKnownCenter, 15.0 - _zoomEpsilon); debugLog('[MAP] Initial zoom to last known position'); } }); @@ -938,12 +974,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { initialPosition, widget.bottomPaddingPixels, widget.rightPaddingPixels, - 16.0); - _animateToPositionWithZoom(adjustedPosition, 16.0); + 16.0 - _zoomEpsilon); + _animateToPositionWithZoom( + adjustedPosition, 16.0 - _zoomEpsilon); debugLog( '[MAP] Initial zoom to GPS position (with panel offset)'); } else { - _animateToPositionWithZoom(initialPosition, 16.0); + _animateToPositionWithZoom(initialPosition, 16.0 - _zoomEpsilon); debugLog('[MAP] Initial zoom to GPS position'); } } @@ -1055,7 +1092,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } // Animate to the exact target position (no offset) - _animateToPositionWithZoom(targetPosition, 18.0); + _animateToPositionWithZoom(targetPosition, 18.0 - _zoomEpsilon); } }); } @@ -1099,6 +1136,38 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + // GPS marker has its own lightweight gate. Position/heading change every + // GPS tick during auto-follow, but updating the GPS symbol is one cheap + // updateSymbol call — it does not need the heavy _syncAllAnnotations + // pipeline, and routing it through there caused setGeoJsonSource on the + // repeater cluster source to fire every tick, which made MapLibre + // re-run its global symbol collision pass and flickered the base-style + // POI labels at high zoom. The gpsMarkerStyle pref is included so style + // changes (arrow → walk, etc.) re-render the marker's bitmap. + if (_isMapReady && _styleLoaded && _imagesRegistered) { + final gpsVersion = Object.hash( + appState.currentPosition?.latitude, + appState.currentPosition?.longitude, + _computedHeading, + appState.preferences.gpsMarkerStyle, + ); + if (gpsVersion != _lastGpsSyncVersion) { + _lastGpsSyncVersion = gpsVersion; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + if (_gpsSyncInFlight) return; + _gpsSyncInFlight = true; + try { + await _syncGpsSymbol(appState); + } catch (e) { + debugError('[MAP] _syncGpsSymbol failed: $e'); + } finally { + _gpsSyncInFlight = false; + } + }); + } + } + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; // Get safe area padding for dynamic island/notch in landscape @@ -1353,7 +1422,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { target: center, zoom: _defaultZoom, ), - minMaxZoomPreference: const MinMaxZoomPreference(3, 17), + minMaxZoomPreference: const MinMaxZoomPreference(3, _maxUserZoom), rotateGesturesEnabled: !_rotationLocked, scrollGesturesEnabled: true, zoomGesturesEnabled: true, @@ -1809,6 +1878,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // syncs from racing ahead of source creation. _clusterLayersReady = false; + // Style reload wipes the native layer stack, so any tracked coverage + // overlay IDs now reference layers that no longer exist. Reset them so + // the next _swapCoverageOverlay treats this as a fresh add (no old + // buffer to retire) instead of attempting a doomed removal. + _activeCoverageSourceId = null; + _activeCoverageLayerId = null; + // Same reasoning for the focus-lines source/layers — gone with the + // style. Clear the flag so _updateFocusLines won't try to remove + // already-gone layers next time it's called. + _focusLinesInstalled = false; + // Disable symbol decluttering on the annotation manager. By default, // MapLibre symbol layers hide overlapping icons/labels at lower zoom to // reduce visual clutter — but for wardriving we want every coverage @@ -1898,6 +1978,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // — that double-sync was racing the first sync's symbol refs and // throwing "you can only set existing annotations" errors twice. _lastMarkerDataVersion = _computeMarkerDataVersion(appState); + // Same idea for the GPS-only sync gate: _syncAllAnnotations already + // ran _syncGpsSymbol, so capture the current GPS version to keep the + // next build from scheduling a redundant updateSymbol call. + _lastGpsSyncVersion = Object.hash( + appState.currentPosition?.latitude, + appState.currentPosition?.longitude, + _computedHeading, + appState.preferences.gpsMarkerStyle, + ); if (mounted) setState(() {}); } } finally { @@ -1907,30 +1996,37 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Fires when the map finishes loading visible tiles and the camera is idle. /// We use this as the "tiles loaded successfully" signal — clears the failure - /// timer and hides any tile-load warning banner. + /// timer and hides any tile-load warning banner. Also releases any pending + /// coverage-overlay swap waiter so the previous buffer can be retired now + /// that the new tiles have rendered. void _onMapIdle() { _tileLoadTimeoutTimer?.cancel(); if (_tileLoadFailed && mounted) { debugLog('[MAP] Tiles recovered after earlier load failure'); setState(() => _tileLoadFailed = false); } + final waiter = _coverageSwapIdleCompleter; + if (waiter != null && !waiter.isCompleted) { + waiter.complete(); + } } /// Fires when the camera stops moving — after both gestures and - /// programmatic animations. While auto-follow is on, we use this as the - /// point to sync our tracked target zoom with whatever zoom the camera - /// actually settled at (e.g. after the user pinch-zoomed). That keeps the - /// next auto-follow GPS tick from snapping the camera back to a stale - /// target zoom. + /// programmatic animations. Auto-follow uses [_autoFollowDesiredZoom] as + /// a *one-shot* zoom target: tap-to-follow sets it to [_maxUserZoom], + /// the resulting animateCamera lands at that zoom, and this idle handler + /// then clears it. Subsequent GPS ticks fall through to the camera's + /// current zoom (line ~1005), so the user can pinch-zoom freely while + /// auto-follow is on without each tick snapping the zoom back. void _onCameraIdle() { if (!_autoFollow || _mapController == null) return; - final currentZoom = _mapController!.cameraPosition?.zoom; - if (currentZoom != null) { - _autoFollowDesiredZoom = currentZoom; - } + _autoFollowDesiredZoom = null; } - /// Add MeshMapper coverage raster overlay as a MapLibre source+layer + /// Add MeshMapper coverage raster overlay as a MapLibre source+layer. + /// Allocates fresh suffixed IDs each call so a previous layer can remain + /// in place (and continue rendering its tiles) while the new one's tiles + /// load on top — see [_swapCoverageOverlay] for the double-buffer flow. Future _addCoverageOverlay(AppStateProvider appState) async { if (_mapController == null || !_showMeshMapperOverlay) return; if (!appState.preferences.mapTilesEnabled) return; @@ -1942,9 +2038,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final url = 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}$cvdParam'; + final sourceId = _nextCoverageSourceId(); + final layerId = _coverageLayerIdFor(sourceId); + try { await _mapController!.addSource( - 'meshmapper-overlay', + sourceId, RasterSourceProperties(tiles: [url], tileSize: 256, maxzoom: 17), ); // Target the bottom of the repeater cluster stack when it exists, so the @@ -1956,6 +2055,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Fallback to the symbol annotation layer only if cluster layers haven't // been created yet (shouldn't happen in practice, but keeps the raster // underneath markers either way). + // + // Using the same belowLayerId for the new layer as the previous overlay + // intentionally places this insertion directly under the marker stack + // and ABOVE the previous raster layer — so as the new tiles render + // they paint over the old ones rather than the old being torn down + // first. _swapCoverageOverlay removes the old layer once the new tiles + // have settled. final belowLayer = _clusterLayersReady ? _repeaterIndividualLayerId : _symbolAnnotationLayerId(); @@ -1969,26 +2075,35 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ? 0.0 : appState.preferences.coverageOverlayOpacity; await _mapController!.addRasterLayer( - 'meshmapper-overlay', - 'meshmapper-overlay-layer', + sourceId, + layerId, RasterLayerProperties(rasterOpacity: opacity), belowLayerId: belowLayer, ); + _activeCoverageSourceId = sourceId; + _activeCoverageLayerId = layerId; _lastAppliedCoverageOpacity = opacity; debugLog( - '[MAP] Coverage overlay added (below ${belowLayer ?? "top"}, opacity ${opacity.toStringAsFixed(2)})'); + '[MAP] Coverage overlay added as $layerId (below ${belowLayer ?? "top"}, opacity ${opacity.toStringAsFixed(2)})'); } catch (e) { debugLog('[MAP] Failed to add coverage overlay: $e'); } } + String _nextCoverageSourceId() => + 'meshmapper-overlay-${++_coverageBufferCounter}'; + + String _coverageLayerIdFor(String sourceId) => '$sourceId-layer'; + /// Apply a new coverage overlay opacity to the live raster layer without /// removing/re-adding it. No-op if the layer doesn't exist yet. Future _applyCoverageOverlayOpacity(double opacity) async { if (_mapController == null) return; + final layerId = _activeCoverageLayerId; + if (layerId == null) return; try { await _mapController!.setLayerProperties( - 'meshmapper-overlay-layer', + layerId, RasterLayerProperties(rasterOpacity: opacity), ); _lastAppliedCoverageOpacity = opacity; @@ -2029,19 +2144,105 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } - /// Remove coverage overlay source and layer + /// Remove the active coverage overlay source and layer (if any) and clear + /// the tracked IDs. Called by the mapTilesEnabled-toggle teardown and on + /// style reload — it does NOT participate in the double-buffer swap path. Future _removeCoverageOverlay() async { + final layerId = _activeCoverageLayerId; + final sourceId = _activeCoverageSourceId; + _activeCoverageLayerId = null; + _activeCoverageSourceId = null; + if (layerId != null && sourceId != null) { + await _removeCoverageLayerById(layerId, sourceId); + } + } + + /// Remove a specific coverage source+layer pair without touching the + /// active-ID tracking. Used by [_swapCoverageOverlay] to retire the + /// previous buffer once new tiles have rendered. + Future _removeCoverageLayerById( + String layerId, String sourceId) async { if (_mapController == null) return; try { - await _mapController!.removeLayer('meshmapper-overlay-layer'); - await _mapController!.removeSource('meshmapper-overlay'); + await _mapController!.removeLayer(layerId); + } catch (_) {} + try { + await _mapController!.removeSource(sourceId); } catch (_) {} } - /// Refresh coverage overlay (remove and re-add with new URL) - Future _refreshCoverageOverlay(AppStateProvider appState) async { - await _removeCoverageOverlay(); + /// Refresh coverage overlay using a double-buffered swap so the current + /// tiles stay visible until the new ones have rendered on top. + Future _refreshCoverageOverlay(AppStateProvider appState) => + _swapCoverageOverlay(appState); + + /// Double-buffered overlay refresh: + /// 1. Capture the currently-active source/layer IDs (the "old" buffer). + /// 2. Add the new source+layer — [_addCoverageOverlay] uses the same + /// belowLayerId so the new layer lands directly above the old one, + /// and updates the active-ID fields to point at the new buffer. + /// 3. Wait for [_onMapIdle] (or a short timeout) so the new tiles have + /// a chance to paint over the old. + /// 4. Remove the old source+layer. + /// + /// If the add was skipped (overlay disabled, no zone, etc.) the old + /// buffer is dropped immediately — there's nothing to buffer against. + Future _swapCoverageOverlay(AppStateProvider appState) async { + final oldSourceId = _activeCoverageSourceId; + final oldLayerId = _activeCoverageLayerId; + await _addCoverageOverlay(appState); + + final addedNewBuffer = _activeCoverageSourceId != oldSourceId && + _activeCoverageSourceId != null; + + if (!addedNewBuffer) { + // Add was a no-op (preconditions failed). Drop the previous buffer if + // the overlay should no longer be visible. _addCoverageOverlay's + // preconditions match the conditions under which we want the overlay + // gone, so this is the correct place to retire it. + if (oldSourceId != null && oldLayerId != null) { + _activeCoverageSourceId = null; + _activeCoverageLayerId = null; + await _removeCoverageLayerById(oldLayerId, oldSourceId); + } + return; + } + + if (oldSourceId == null || oldLayerId == null) { + // No previous buffer to retire (first add since style load or after a + // teardown). Nothing more to do. + return; + } + + await _waitForCoverageSwapIdle(timeout: const Duration(seconds: 3)); + if (!mounted) return; + await _removeCoverageLayerById(oldLayerId, oldSourceId); + } + + /// Block until [_onMapIdle] completes the swap completer, or [timeout] + /// elapses (whichever happens first). Replaces any prior in-flight waiter + /// so a new swap starting mid-flight doesn't strand the old waiter. + Future _waitForCoverageSwapIdle({required Duration timeout}) async { + final prior = _coverageSwapIdleCompleter; + if (prior != null && !prior.isCompleted) { + prior.complete(); + } + final completer = Completer(); + _coverageSwapIdleCompleter = completer; + _coverageSwapTimeoutTimer?.cancel(); + _coverageSwapTimeoutTimer = Timer(timeout, () { + if (!completer.isCompleted) completer.complete(); + }); + try { + await completer.future; + } finally { + if (identical(_coverageSwapIdleCompleter, completer)) { + _coverageSwapIdleCompleter = null; + } + _coverageSwapTimeoutTimer?.cancel(); + _coverageSwapTimeoutTimer = null; + } } /// Returns the fill color for a repeater status keyword. @@ -2547,8 +2748,23 @@ class _MapWidgetState extends State with WidgetsBindingObserver { math.asin(math.min(1.0, math.sqrt(h))); } + /// MapLibre Native (Android SDK 12.3.1 / iOS 6.19.1, both bound by + /// maplibre_gl 0.25.0) blinks symbol-layer labels for one frame when an + /// `animateCamera` call ends on an exact integer zoom level (15.0, 16.0, + /// 17.0, …). Tracking issue: + /// https://github.com/maplibre/maplibre-native/issues/2477 + /// + /// Workaround until upstream fix: nudge every programmatic zoom value + /// in this file by this epsilon so the camera never settles on an + /// integer. Visually identical (~0.001 zoom is sub-pixel at any scale) + /// but avoids the bug. When it's resolved upstream, set this to 0.0 + /// (or remove the subtractions). + static const double _zoomEpsilon = 0.001; + /// Maximum zoom the camera can reach (matches `minMaxZoomPreference`). - static const double _maxUserZoom = 17.0; + /// Subtracted by [_zoomEpsilon] to dodge the integer-zoom label-blink + /// bug described above. + static const double _maxUserZoom = 17.0 - _zoomEpsilon; /// True when the camera is at (or floating-point close to) the user's /// hard zoom cap. Spider expansion is gated on this — at any lower zoom @@ -3104,19 +3320,41 @@ class _MapWidgetState extends State with WidgetsBindingObserver { Future _updateFocusLines() async { if (_mapController == null || !_styleLoaded) return; - // Always remove existing layers/source first (silently ignore if absent). - // Order matters: remove the layers BEFORE the source they reference. - try { - await _mapController!.removeLayer(_focusLinesLayerId); - } catch (_) {} - try { - await _mapController!.removeLayer(_focusLinesAmbiguousLayerId); - } catch (_) {} - try { - await _mapController!.removeSource(_focusLinesSourceId); - } catch (_) {} + final hasFocus = + _focusedPingLocation != null && _focusedRepeaters.isNotEmpty; + + // No focus → no install. Skip the platform calls entirely when there's + // nothing on the map to begin with; only run the remove block when a + // previous activation actually installed the layers. + if (!hasFocus) { + if (!_focusLinesInstalled) return; + try { + await _mapController!.removeLayer(_focusLinesLayerId); + } catch (_) {} + try { + await _mapController!.removeLayer(_focusLinesAmbiguousLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_focusLinesSourceId); + } catch (_) {} + _focusLinesInstalled = false; + return; + } - if (_focusedPingLocation == null || _focusedRepeaters.isEmpty) return; + // Focus is active — remove any prior install before re-adding with the + // current focus state. Order matters: layers BEFORE their source. + if (_focusLinesInstalled) { + try { + await _mapController!.removeLayer(_focusLinesLayerId); + } catch (_) {} + try { + await _mapController!.removeLayer(_focusLinesAmbiguousLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_focusLinesSourceId); + } catch (_) {} + _focusLinesInstalled = false; + } // Build a FeatureCollection with one LineString per connected repeater. // Per-feature properties carry the line color (data-driven styling) and @@ -3195,6 +3433,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), belowLayerId: belowLayer, ); + _focusLinesInstalled = true; } catch (e) { debugError('[MAP] Failed to add focus lines: $e'); } @@ -3599,9 +3838,6 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _focusedRepeaters.length, appState.preferences.gpsMarkerStyle, appState.preferences.markerStyle, - appState.currentPosition?.latitude, - appState.currentPosition?.longitude, - _computedHeading, txEchoTotal, discNodeTotal, ); @@ -3923,7 +4159,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { appState.currentPosition!.latitude, appState.currentPosition!.longitude, ); - const targetZoom = 17.0; // Street level zoom when enabling follow + const targetZoom = + _maxUserZoom; // Street-level zoom when enabling follow (already nudged off integer) setState(() { _autoFollow = true; _lastGpsPosition = targetPosition; @@ -4802,8 +5039,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (entry.success) ...[ const SizedBox(height: 12), - // Table with headers - Container( + // Table with headers — reserve a sliver of node-column + // width for the inline `location_off` indicator when the + // target repeater has no GPS on file. + Builder(builder: (context) { + final chipWidth = _nodeColumnWidth(); + final lacksLocation = + _hexIdLacksLocation(entry.targetRepeaterId); + final iconReserve = lacksLocation ? 18.0 : 0.0; + final nodeColWidth = chipWidth + iconReserve; + return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), @@ -4822,7 +5067,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: Row( children: [ SizedBox( - width: _nodeColumnWidth(), + width: nodeColWidth, child: Text( 'Node', style: TextStyle( @@ -4900,10 +5145,26 @@ class _MapWidgetState extends State with WidgetsBindingObserver { horizontal: 12, vertical: 8), child: Row( children: [ - RepeaterIdChip( - repeaterId: entry.targetRepeaterId, - fontSize: 13, - width: _nodeColumnWidth()), + // Repeater ID + optional no-location icon, + // pinned to the node column width so the + // SNR/RSSI/TX columns stay aligned. + SizedBox( + width: nodeColWidth, + child: Row( + children: [ + RepeaterIdChip( + repeaterId: entry.targetRepeaterId, + fontSize: 13, + width: chipWidth), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only( + left: 4), + child: _noLocationIndicator(), + ), + ], + ), + ), // RX SNR Expanded( child: Center( @@ -4938,7 +5199,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { }), ], ), - ), + ); + }), ], ], ), @@ -4990,9 +5252,53 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return resolved; } + /// Look up the first matching [Repeater] by hex-ID prefix (case-insensitive). + /// Used by focus bottom-sheet rows to decide whether to surface the + /// `location_off` indicator. Returns null when no match is found — callers + /// treat that as "no location" too, since we have no coordinates. + Repeater? _lookupRepeaterByHexId(String hexId) { + if (hexId.isEmpty) return null; + final all = context.read().repeaters; + final key = hexId.toLowerCase(); + for (final r in all) { + if (r.hexId.toLowerCase().startsWith(key)) return r; + } + return null; + } + + /// True when we should show a "no location" hint for the given hex ID, + /// either because the matched repeater is at `(0, 0)` or because there is + /// no match at all. + bool _hexIdLacksLocation(String hexId) { + final r = _lookupRepeaterByHexId(hexId); + return r == null || !r.hasLocation; + } + + /// Small grey [Icons.location_off] used inline next to repeater chips in + /// the focus bottom sheets to signal "we heard this repeater but don't + /// know where it is". Tooltip explains on long-press. + Widget _noLocationIndicator() { + return Tooltip( + message: 'No location on file', + child: Icon( + Icons.location_off, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } + /// Activate ping focus mode — draw lines, fade markers, zoom to fit. void _activatePingFocus(LatLng pingLocation, DateTime timestamp, List<_ResolvedRepeater> repeaters) { + // Drop repeaters lacking GPS — they would draw lines off to (0, 0). + // The bottom-sheet row builder still surfaces them with a no-location + // icon. If nothing is left to focus on, skip activation entirely so + // the user's current map view (zoom, autofollow, rotation) is kept. + final located = + repeaters.where((r) => r.repeater.hasLocation).toList(growable: false); + if (located.isEmpty) return; + final pos = _mapController?.cameraPosition; _preFocusCenter = pos?.target; _preFocusZoom = pos?.zoom; @@ -5018,7 +5324,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { setState(() { _focusedPingLocation = pingLocation; _focusedPingTimestamp = timestamp; - _focusedRepeaters = repeaters; + _focusedRepeaters = located; }); // Hide the MeshMapper coverage raster overlay for a clean focus view. @@ -5028,7 +5334,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _focusedPingLocation != null) { - _zoomToFocusBounds(pingLocation, repeaters); + _zoomToFocusBounds(pingLocation, located); } }); @@ -5275,8 +5581,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (heardRepeaters.isNotEmpty) ...[ const SizedBox(height: 12), - // Repeaters table - Container( + // If any heard repeater is missing GPS, reserve a sliver of + // node-column width for the inline `location_off` indicator + // so SNR/RSSI columns stay aligned row-to-row. + Builder(builder: (context) { + final chipWidth = _nodeColumnWidth(); + final anyLacksLocation = heardRepeaters + .any((hr) => _hexIdLacksLocation(hr.repeaterId)); + final iconReserve = anyLacksLocation ? 18.0 : 0.0; + final nodeColWidth = chipWidth + iconReserve; + return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), @@ -5295,7 +5609,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: Row( children: [ SizedBox( - width: _nodeColumnWidth(), + width: nodeColWidth, child: Text( 'Node', style: TextStyle( @@ -5346,6 +5660,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final rssiColor = repeater.rssi != null ? PingColors.rssiColor(repeater.rssi!) : Colors.grey; + final lacksLocation = + _hexIdLacksLocation(repeater.repeaterId); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup( @@ -5358,11 +5674,25 @@ class _MapWidgetState extends State with WidgetsBindingObserver { horizontal: 12, vertical: 8), child: Row( children: [ - // Repeater ID - RepeaterIdChip( - repeaterId: repeater.repeaterId, - fontSize: 13, - width: _nodeColumnWidth()), + // Repeater ID + optional no-location icon, + // pinned to the node column width. + SizedBox( + width: nodeColWidth, + child: Row( + children: [ + RepeaterIdChip( + repeaterId: repeater.repeaterId, + fontSize: 13, + width: chipWidth), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only( + left: 4), + child: _noLocationIndicator(), + ), + ], + ), + ), // SNR Expanded( child: Center( @@ -5392,7 +5722,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { }), ], ), - ), + ); + }), ], ], ), @@ -5525,8 +5856,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), const SizedBox(height: 12), - // Repeater table (single row) - Container( + // Repeater table (single row). When the repeater has no GPS on + // file, surface a small grey location_off icon next to the chip + // so the user knows the focus map deliberately skipped it. + Builder(builder: (context) { + final chipWidth = _nodeColumnWidth(); + final lacksLocation = _hexIdLacksLocation(ping.repeaterId); + final iconReserve = lacksLocation ? 18.0 : 0.0; + final nodeColWidth = chipWidth + iconReserve; + return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), @@ -5545,7 +5883,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: Row( children: [ SizedBox( - width: _nodeColumnWidth(), + width: nodeColWidth, child: Text( 'Node', style: TextStyle( @@ -5597,11 +5935,24 @@ class _MapWidgetState extends State with WidgetsBindingObserver { horizontal: 12, vertical: 8), child: Row( children: [ - // Repeater ID - RepeaterIdChip( - repeaterId: ping.repeaterId, - fontSize: 13, - width: _nodeColumnWidth()), + // Repeater ID + optional no-location icon, pinned + // to the node column width so SNR/RSSI stay aligned. + SizedBox( + width: nodeColWidth, + child: Row( + children: [ + RepeaterIdChip( + repeaterId: ping.repeaterId, + fontSize: 13, + width: chipWidth), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only(left: 4), + child: _noLocationIndicator(), + ), + ], + ), + ), // SNR Expanded( child: Center( @@ -5626,7 +5977,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ], ), - ), + ); + }), // Path section (origin → ... → us). Skipped when the path is // unavailable, e.g. RxPings reloaded from Hive (transient field). @@ -5807,8 +6159,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (entry.discoveredNodes.isNotEmpty) ...[ const SizedBox(height: 12), - // Table with headers - Container( + // Table with headers — reserve a sliver of node-column + // width for the inline `location_off` indicator when any + // discovered node has no GPS on file, so the RX/RSSI/TX + // columns stay aligned row-to-row. + Builder(builder: (context) { + final anyLacksLocation = entry.discoveredNodes + .any((n) => _hexIdLacksLocation(n.repeaterId)); + final nodeExtra = 20.0 + (anyLacksLocation ? 18.0 : 0.0); + return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), @@ -5827,7 +6186,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: Row( children: [ SizedBox( - width: _nodeColumnWidth(extraPadding: 20), + width: + _nodeColumnWidth(extraPadding: nodeExtra), child: Text( 'Node', style: TextStyle( @@ -5890,6 +6250,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { PingColors.rssiColor(node.localRssi); final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); + final lacksLocation = + _hexIdLacksLocation(node.repeaterId); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup( @@ -5904,9 +6266,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { horizontal: 12, vertical: 8), child: Row( children: [ - // Node ID with type + // Node ID with type (+ optional no-loc icon) SizedBox( - width: _nodeColumnWidth(extraPadding: 20), + width: + _nodeColumnWidth(extraPadding: nodeExtra), child: Row( children: [ RepeaterIdChip( @@ -5920,6 +6283,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { color: _discMarkerColor, ), ), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only( + left: 4), + child: _noLocationIndicator(), + ), ], ), ), @@ -5958,7 +6327,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { }), ], ), - ), + ); + }), ], ], ), From c96dd4225eba0e3c2d0b0fbe43d526e98f78b29a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 30 Apr 2026 21:38:33 -0400 Subject: [PATCH 025/100] - **Per-Region Stale Repeater Threshold:** The stale repeater cutoff is no longer hardcoded. The app now reads `stale_repeater_hours` from the zone status API response so that it matches the web client. --- lib/models/repeater.dart | 10 +++++++--- lib/providers/app_state_provider.dart | 7 +++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/models/repeater.dart b/lib/models/repeater.dart index fdfef51..8fafe93 100644 --- a/lib/models/repeater.dart +++ b/lib/models/repeater.dart @@ -3,6 +3,11 @@ import 'package:intl/intl.dart'; /// Represents a repeater from the MeshMapper API. /// Used to display repeater markers on the map. class Repeater { + /// Zone-level fallback for stale threshold, updated from the status API's + /// `stale_repeater_hours` field. Used only when a repeater lacks a + /// per-repeater [staleTime]. + static int staleHoursFallback = 24; + /// Unique ID (e.g., "01", "92") final String id; @@ -128,16 +133,15 @@ class Repeater { /// Check if the repeater is active. /// Uses server-provided [staleTime] when available, otherwise falls back - /// to a 24-hour threshold from [lastHeard]. + /// to [staleHoursFallback] (set from the zone's `stale_repeater_hours`). bool get isActive { if (staleTime != null) { final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; return nowSeconds < staleTime!; } - // Fallback: 24-hour threshold from lastHeard if (lastHeard == 0) return false; final heard = DateTime.fromMillisecondsSinceEpoch(lastHeard * 1000); - return DateTime.now().difference(heard).inHours < 24; + return DateTime.now().difference(heard).inHours < staleHoursFallback; } /// Check if the repeater has not been heard in the past 24 hours diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index fc64618..7bd96c0 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -4839,6 +4839,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _currentZone = newZone; _nearestZone = null; + + final staleHours = result['stale_repeater_hours']; + if (staleHours is int && staleHours > 0) { + Repeater.staleHoursFallback = staleHours; + debugLog('[GEOFENCE] Zone stale repeater threshold: ${staleHours}h'); + } + debugLog('[GEOFENCE] In zone: $newZoneName ($newZoneCode)'); if (newZoneCode.isNotEmpty) { From 12ae67baaa80c309c8aa636bd7930ca3dab1c351 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 30 Apr 2026 22:45:16 -0400 Subject: [PATCH 026/100] - **Battery & Performance:** Countdown timers (cooldown, auto-ping, RX window, discovery window) no longer trigger a full widget-tree rebuild on every 500ms tick. They now self-notify via `ChangeNotifier` so only the ping control buttons redraw. During auto-ping this eliminates 4-6 unnecessary rebuilds per second across 11+ widgets. Noise floor and battery streams also skip redundant notifications when values haven't changed, removing ~840 unnecessary full-tree rebuilds per hour during stable conditions. --- lib/providers/app_state_provider.dart | 40 +++++++++----- lib/services/countdown_timer_service.dart | 64 +++++++++++------------ lib/widgets/ping_controls.dart | 22 +++++++- 3 files changed, 78 insertions(+), 48 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7bd96c0..30a8b0c 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -109,6 +109,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { late final RxWindowTimer _rxWindowTimer; late final DiscoveryWindowTimer _discoveryWindowTimer; // Discovery listening window (Passive Mode) + late final Listenable _timerListenable; MeshCoreConnection? _meshCoreConnection; PingService? _pingService; UnifiedRxHandler? _unifiedRxHandler; @@ -660,6 +661,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { RxWindowTimer get rxWindowTimer => _rxWindowTimer; DiscoveryWindowTimer get discoveryWindowTimer => _discoveryWindowTimer; // Discovery listening window (Passive Mode) + Listenable get timerListenable => _timerListenable; // ============================================ // Initialization @@ -705,13 +707,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _offlineSessionService = OfflineSessionService(); _deviceModelService = DeviceModelService(); - // Initialize countdown timers with notifyListeners callback for smooth UI updates - _cooldownTimer = CooldownTimer(onUpdate: notifyListeners); - _manualPingCooldownTimer = - ManualPingCooldownTimer(onUpdate: notifyListeners); - _autoPingTimer = AutoPingTimer(onUpdate: notifyListeners); - _rxWindowTimer = RxWindowTimer(onUpdate: notifyListeners); - _discoveryWindowTimer = DiscoveryWindowTimer(onUpdate: notifyListeners); + // Initialize countdown timers. They self-notify via ChangeNotifier so only + // widgets listening to the timers directly rebuild on each 500ms tick. + _cooldownTimer = CooldownTimer(); + _manualPingCooldownTimer = ManualPingCooldownTimer(); + _autoPingTimer = AutoPingTimer(); + _rxWindowTimer = RxWindowTimer(); + _discoveryWindowTimer = DiscoveryWindowTimer(); + _timerListenable = Listenable.merge([ + _cooldownTimer, + _manualPingCooldownTimer, + _autoPingTimer, + _rxWindowTimer, + _discoveryWindowTimer, + ]); // Initialize debug logging (enabled by default, respects user preference) await _initDebugLogs(); @@ -1383,20 +1392,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); }); - // Listen for noise floor updates + // Listen for noise floor updates — only rebuild UI when value changes _noiseFloorSubscription = _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { - _currentNoiseFloor = noiseFloor; - // Record sample to current noise floor session (if active) _recordNoiseFloorSample(noiseFloor); - notifyListeners(); + if (noiseFloor != _currentNoiseFloor) { + _currentNoiseFloor = noiseFloor; + notifyListeners(); + } }); - // Listen for battery updates + // Listen for battery updates — only rebuild UI when value changes _batterySubscription = _meshCoreConnection!.batteryStream.listen((batteryPercent) { - _currentBatteryPercent = batteryPercent; - notifyListeners(); + if (batteryPercent != _currentBatteryPercent) { + _currentBatteryPercent = batteryPercent; + notifyListeners(); + } }); // Execute connection workflow diff --git a/lib/services/countdown_timer_service.dart b/lib/services/countdown_timer_service.dart index 412bd3f..c7e61de 100644 --- a/lib/services/countdown_timer_service.dart +++ b/lib/services/countdown_timer_service.dart @@ -1,22 +1,20 @@ import 'dart:async'; -import 'package:flutter/foundation.dart' show VoidCallback; +import 'package:flutter/foundation.dart'; import '../utils/debug_logger_io.dart'; -/// Countdown timer for cooldown, auto-ping, and RX window +/// Countdown timer for cooldown, auto-ping, and RX window. /// Reference: createCountdownTimer() in wardrive.js /// -/// Features: -/// - 500ms update interval for responsive countdown display -/// - Auto-stops when countdown reaches zero -class CountdownTimerService { +/// Extends ChangeNotifier so that only widgets listening to this specific timer +/// rebuild on each tick. Previously, every tick called +/// AppStateProvider.notifyListeners(), which rebuilt the entire widget tree +/// (including the expensive MapWidget) 2× per second per active timer. +class CountdownTimerService extends ChangeNotifier { Timer? _timer; DateTime? _endTime; - int? _durationMs; // Original duration for progress calculation - final VoidCallback? onUpdate; // Callback for UI refresh on each timer tick - - CountdownTimerService({this.onUpdate}); + int? _durationMs; /// Check if timer is running bool get isRunning => _timer != null; @@ -41,8 +39,8 @@ class CountdownTimerService { /// Start countdown timer /// @param durationMs - Duration in milliseconds void start(int durationMs) { - stop(); - _durationMs = durationMs; // Track original duration for progress + _cancelTimer(); + _durationMs = durationMs; _endTime = DateTime.now().add(Duration(milliseconds: durationMs)); // Start 500ms update timer for responsive countdown @@ -50,7 +48,7 @@ class CountdownTimerService { Timer.periodic(const Duration(milliseconds: 500), (_) => _update()); // Trigger immediate update - _update(); + notifyListeners(); } /// Update countdown display @@ -61,35 +59,43 @@ class CountdownTimerService { // Stop when countdown reaches zero if (remainingMs == 0) { - stop(); - // Trigger UI refresh when timer ends so UI updates immediately - onUpdate?.call(); + _cancelTimer(); + _endTime = null; + _durationMs = null; + notifyListeners(); return; } - // Trigger UI refresh callback after each update - onUpdate?.call(); + notifyListeners(); } /// Stop countdown timer void stop() { - _timer?.cancel(); - _timer = null; + if (_timer == null) return; + _cancelTimer(); _endTime = null; _durationMs = null; + notifyListeners(); + } + + void _cancelTimer() { + _timer?.cancel(); + _timer = null; } /// Dispose resources + @override void dispose() { - stop(); + _cancelTimer(); + _endTime = null; + _durationMs = null; + super.dispose(); } } /// Specialized countdown timer for ping cooldown /// Reference: wardrive.js state.cooldownEndTime and cooldownUpdateTimer class CooldownTimer extends CountdownTimerService { - CooldownTimer({super.onUpdate}); - @override void stop() { final wasRunning = isRunning; @@ -106,8 +112,6 @@ class AutoPingTimer extends CountdownTimerService { /// Skip reason (e.g., "too close", "gps too old") String? skipReason; - AutoPingTimer({super.onUpdate}); - /// Start countdown with optional skip reason /// Reference: startAutoCountdown() in wardrive.js with state.skipReason void startWithSkipReason(int durationMs, String? reason) { @@ -118,19 +122,13 @@ class AutoPingTimer extends CountdownTimerService { /// Specialized countdown timer for RX listening window /// Reference: wardrive.js state.rxListeningEndTime -class RxWindowTimer extends CountdownTimerService { - RxWindowTimer({super.onUpdate}); -} +class RxWindowTimer extends CountdownTimerService {} /// Specialized countdown timer for discovery listening window (Passive Mode) -class DiscoveryWindowTimer extends CountdownTimerService { - DiscoveryWindowTimer({super.onUpdate}); -} +class DiscoveryWindowTimer extends CountdownTimerService {} /// Specialized countdown timer for manual ping cooldown (15 seconds) class ManualPingCooldownTimer extends CountdownTimerService { - ManualPingCooldownTimer({super.onUpdate}); - @override void stop() { final wasRunning = isRunning; diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index fcfac93..b79c981 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -14,6 +14,9 @@ class PingControls extends StatelessWidget { @override Widget build(BuildContext context) { final appState = context.watch(); + return ListenableBuilder( + listenable: appState.timerListenable, + builder: (_, __) { final validation = appState.pingValidation; final manualValidation = appState .manualPingValidation; // Manual ping validation (no distance check) @@ -315,6 +318,8 @@ class PingControls extends StatelessWidget { ), ], ); + }, + ); } Future _sendPing( @@ -594,9 +599,12 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { @override Widget build(BuildContext context) { final appState = context.watch(); + final colorScheme = Theme.of(context).colorScheme; + return ListenableBuilder( + listenable: appState.timerListenable, + builder: (_, __) { final isTargetedRunning = appState.isTargetedModeRunning; final maxLen = appState.traceHopBytes * 2; - final colorScheme = Theme.of(context).colorScheme; // Sync controller when provider clears target (e.g. trace bytes changed) if (appState.targetRepeaterId == null && _controller.text.isNotEmpty) { @@ -769,6 +777,8 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { ], ), ); + }, + ); } } @@ -806,6 +816,9 @@ class _CompactPingControlsState extends State { @override Widget build(BuildContext context) { final appState = context.watch(); + return ListenableBuilder( + listenable: appState.timerListenable, + builder: (_, __) { final manualValidation = appState .manualPingValidation; // Manual ping validation (no distance check) final autoValidation = appState.autoModeValidation; @@ -1150,6 +1163,8 @@ class _CompactPingControlsState extends State { ], ], ); + }, + ); } /// Get label for Send Ping button @@ -1367,6 +1382,9 @@ class LandscapePingControls extends StatelessWidget { @override Widget build(BuildContext context) { final appState = context.watch(); + return ListenableBuilder( + listenable: appState.timerListenable, + builder: (_, __) { final manualValidation = appState .manualPingValidation; // Manual ping validation (no distance check) final autoValidation = appState.autoModeValidation; @@ -1553,6 +1571,8 @@ class LandscapePingControls extends StatelessWidget { ), ], ); + }, + ); } Future _sendPing( From e0a9f5f79366f2ca144a275bb29597eac57c5244 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 3 May 2026 08:14:34 -0400 Subject: [PATCH 027/100] - **(iOS) Launch & Resume Crash:** Fixed a crash on app launch and resume from background. MapLibre's internal projection math can produce NaN coordinates when the GL surface hasn't fully initialized (cold start before the first frame, or iOS GPU context restore after suspension), triggering a C++ `std::domain_error` and SIGABRT. Camera animations are now suppressed for one frame after style load and after resume, letting the surface reach a valid state before any camera move fires. --- lib/widgets/map_widget.dart | 49 ++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index ba4889e..1876c8f 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -313,15 +313,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { MapLibreMapController? _mapController; // Tracks the app lifecycle so we can suppress every animateCamera() call - // while the app is not in the foreground. MapLibre's native flyTo throws - // an uncaught C++ exception (mbgl::LatLng domain_error → SIGABRT) when - // the underlying MLNMapView's GL context is degraded, which is what - // happens on iOS while the app is backgrounded. The Flutter frame - // pipeline keeps running in our background-mode app (bluetooth-central + - // location), so GPS-driven rebuilds would otherwise still fire animateCamera. + // while the app is not in the foreground OR while the GL surface is + // settling after a state transition. MapLibre Native's + // constrainCameraAndZoomToBounds (PR #2475) calls Projection::unproject + // internally — when the GL surface is degenerate (zero-sized on first + // frame, or not yet restored after iOS background suspension), unproject + // produces NaN and the LatLng constructor throws std::domain_error → + // SIGABRT. Suppressing animations for one frame after style-load and + // after resume lets the surface reach a valid state first. AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; + bool _cameraAnimationReady = false; bool get _canAnimateCamera => - _appLifecycleState == AppLifecycleState.resumed; + _appLifecycleState == AppLifecycleState.resumed && + _cameraAnimationReady; // Auto-follow GPS like a navigation app bool _autoFollow = false; // Disabled by default - users often zoom out first @@ -550,7 +554,22 @@ class _MapWidgetState extends State with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { + final wasBackground = _appLifecycleState != AppLifecycleState.resumed; _appLifecycleState = state; + + if (state != AppLifecycleState.resumed) { + // Going to background — block camera animations immediately. + _cameraAnimationReady = false; + } else if (wasBackground && _isMapReady) { + // Resuming from background — GL surface needs a frame to restore + // before constrainCameraAndZoomToBounds can project without NaN. + _cameraAnimationReady = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _cameraAnimationReady = true; + } + }); + } } @override @@ -938,7 +957,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (appState.currentPosition == null && appState.lastKnownPosition != null && !_hasZoomedToLastKnown && - _isMapReady) { + _isMapReady && + _canAnimateCamera) { _hasZoomedToLastKnown = true; final lastKnownCenter = LatLng( appState.lastKnownPosition!.lat, @@ -961,7 +981,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // One-time initial zoom to GPS when we first get a position // This happens even with auto-follow disabled so user sees their location // Don't apply panel offset - center directly on GPS so pin is in middle of screen - if (!_hasInitialZoomed && _isMapReady) { + if (!_hasInitialZoomed && _isMapReady && _canAnimateCamera) { _hasInitialZoomed = true; final initialPosition = center; _lastGpsPosition = initialPosition; @@ -1855,6 +1875,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _styleLoaded = true; _isMapReady = true; + // Let the GL surface render one frame before allowing camera animations. + // Without this, constrainCameraAndZoomToBounds can produce NaN on the + // very first flyTo/easeTo after style load (the viewport may be + // zero-sized or the projection matrix degenerate). + _cameraAnimationReady = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _cameraAnimationReady = true; + } + }); + // CRITICAL: clear stale Symbol references from any previous style load. // Style reloads cause maplibre_gl to construct a brand-new SymbolManager // with empty internal _idToAnnotation maps. Our _gpsSymbol / From bc35531c3c10bdb433481f637538bc100bf79e5e Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 9 May 2026 12:59:42 -0400 Subject: [PATCH 028/100] Fixed Anonymous mode getting permanently stuck after an unexpected Bluetooth disconnect --- lib/providers/app_state_provider.dart | 132 ++++++++++++++++--- lib/services/api_service.dart | 6 - lib/services/meshcore/tx_tracker.dart | 27 ---- lib/widgets/map_widget.dart | 178 +++++++++++++++++++++++--- lib/widgets/repeater_id_chip.dart | 8 +- 5 files changed, 280 insertions(+), 71 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 30a8b0c..913f5ae 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -213,6 +213,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _originalDeviceName; // Real name stored before rename bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" + /// Per-device real name persistence: maps device public key → real device name. + /// Survives unexpected BLE disconnects where setAdvertName restore can't run. + Map _deviceRealNames = {}; + /// Per-device antenna preferences: maps companion name → external antenna bool Map _deviceAntennaPreferences = {}; @@ -800,6 +804,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _loadPreferences(); await _loadDeviceAntennaPreferences(); await _loadDevicePowerOverrides(); + await _loadDeviceRealNames(); // Load last known GPS position for map centering await _loadLastPosition(); @@ -1161,13 +1166,30 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_preferences.anonymousMode && !_isAnonymousRenamed) { final realName = _meshCoreConnection!.selfInfo?.name; if (realName != null && realName.isNotEmpty) { - _originalDeviceName = realName; + // Cascade guard: if firmware is stuck as "Anonymous" from a previous + // unclean disconnect, recover the real name from Hive + if (realName == 'Anonymous') { + final persisted = _deviceRealNames[publicKey]; + _originalDeviceName = + persisted ?? realName; // fall back if nothing saved + if (persisted != null) { + debugLog( + '[CONN] Anonymous mode: recovered real name "$persisted" from Hive (firmware was stuck)'); + } + } else { + _originalDeviceName = realName; + } try { await _meshCoreConnection!.setAdvertName('Anonymous'); _isAnonymousRenamed = true; _displayDeviceName = 'Anonymous'; + // Persist real name keyed by public key (only if not "Anonymous") + if (_originalDeviceName != 'Anonymous') { + _deviceRealNames[publicKey] = _originalDeviceName!; + _saveDeviceRealNames(); + } debugLog( - '[CONN] Anonymous mode: renamed from "$realName" to "Anonymous"'); + '[CONN] Anonymous mode: renamed from "$_originalDeviceName" to "Anonymous"'); // Short delay for firmware to process await Future.delayed(const Duration(milliseconds: 300)); } catch (e) { @@ -1178,10 +1200,35 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Resolve device name: use "Anonymous" if renamed, otherwise SelfInfo name - final deviceName = _isAnonymousRenamed - ? 'Anonymous' - : (_meshCoreConnection!.selfInfo?.name ?? - connectedDeviceName?.replaceFirst('MeshCore-', '')); + String? deviceName; + if (_isAnonymousRenamed) { + deviceName = 'Anonymous'; + } else { + final selfInfoName = _meshCoreConnection!.selfInfo?.name; + // Detect stuck anonymous name: firmware still has "Anonymous" but mode is OFF + if (selfInfoName == 'Anonymous') { + final persistedName = _deviceRealNames[publicKey]; + if (persistedName != null) { + debugLog( + '[CONN] Detected stuck anonymous name, recovering to "$persistedName"'); + try { + await _meshCoreConnection!.setAdvertName(persistedName); + debugLog('[CONN] Restored firmware name to "$persistedName"'); + _clearPersistedRealName(publicKey); + } catch (e) { + debugError('[CONN] Failed to restore firmware name: $e'); + } + deviceName = persistedName; + } else { + debugWarn( + '[CONN] Firmware name is "Anonymous" but no persisted real name found'); + deviceName = selfInfoName; + } + } else { + deviceName = selfInfoName ?? + connectedDeviceName?.replaceFirst('MeshCore-', ''); + } + } if (deviceName == null || deviceName.isEmpty) { debugError( '[APP] Cannot request auth: could not retrieve device name'); @@ -1366,17 +1413,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Persist device info for bug reports when disconnected // Use original name (not "Anonymous") for bug report identification - var deviceName = _isAnonymousRenamed + var lastDeviceName = _isAnonymousRenamed ? _originalDeviceName : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); - if (deviceName != null) { - // Always strip MeshCore- prefix if present - deviceName = deviceName.replaceFirst('MeshCore-', ''); + if (lastDeviceName != null) { + lastDeviceName = lastDeviceName.replaceFirst('MeshCore-', ''); } - if (deviceName != null && - deviceName.isNotEmpty && + // Cascade guard: never persist "Anonymous" as the last connected device + if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { + lastDeviceName = + _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; + } + if (lastDeviceName != null && + lastDeviceName.isNotEmpty && _devicePublicKey != null) { - _saveLastConnectedDevice(deviceName, _devicePublicKey!); + _saveLastConnectedDevice(lastDeviceName, _devicePublicKey!); } // In offline mode, fetch signed contact URI for later registration during upload @@ -1945,9 +1996,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update remembered device with real name (not "Anonymous") // BLE advertisement name may be stale after device rename - final realName = _isAnonymousRenamed - ? (_originalDeviceName ?? selfInfoName) - : selfInfoName; + String? realName; + if (_isAnonymousRenamed) { + realName = _originalDeviceName ?? selfInfoName; + } else if (selfInfoName == 'Anonymous' && _devicePublicKey != null) { + realName = _deviceRealNames[_devicePublicKey!] ?? selfInfoName; + } else { + realName = selfInfoName; + } if (_rememberedDevice != null && _rememberedDevice!.id == device.id) { final updatedName = 'MeshCore-$realName'; if (_rememberedDevice!.name != updatedName) { @@ -2944,6 +3000,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _meshCoreConnection?.setAdvertName(_originalDeviceName!); debugLog( '[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); + if (_devicePublicKey != null) { + _clearPersistedRealName(_devicePublicKey!); + } } catch (e) { debugError('[CONN] Anonymous mode: failed to restore name: $e'); logError( @@ -6104,6 +6163,46 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // ============================================ + // Device Real Name Persistence (Anonymous Mode Recovery) + // ============================================ + + Future _loadDeviceRealNames() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + final raw = box.get('device_real_names'); + if (raw != null) { + _deviceRealNames = Map.from(raw as Map); + debugLog( + '[APP] Loaded real names for ${_deviceRealNames.length} device(s)'); + } + } catch (e) { + debugLog('[APP] Failed to load device real names: $e'); + } + } + + Future _saveDeviceRealNames() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + await box.put('device_real_names', _deviceRealNames); + await box.flush(); + } catch (e) { + debugLog('[APP] Failed to save device real names: $e'); + } + } + + Future _clearPersistedRealName(String publicKey) async { + if (_deviceRealNames.remove(publicKey) != null) { + await _saveDeviceRealNames(); + debugLog( + '[APP] Cleared persisted real name for device ${publicKey.substring(0, 16)}...'); + } + } + // ============================================ // Last Connected Device Persistence // ============================================ @@ -6427,6 +6526,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _bluetoothService.dispose(); _audioService.dispose(); _cooldownTimer.dispose(); + _manualPingCooldownTimer.dispose(); _autoPingTimer.dispose(); _rxWindowTimer.dispose(); _discoveryWindowTimer.dispose(); diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 5175392..ae11188 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -452,12 +452,6 @@ class ApiService { debugLog('[API] Regional admin has disabled flood traffic'); } - // Parse flood_disabled flag from auth response - _floodDisabled = data['flood_disabled'] == true; - if (_floodDisabled) { - debugLog('[API] Regional admin has disabled flood traffic'); - } - // Parse min_mode_interval from auth response final minInterval = data['min_mode_interval']; if (minInterval is int && minInterval > 0) { diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index 9092814..b4090e2 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -7,36 +7,9 @@ import 'crypto_service.dart'; import 'packet_metadata.dart'; import 'packet_validator.dart'; -/// Classification result from [TxTracker.handlePacket]. -/// -/// [ownPingNotTracked] is the key signal the unified RX handler needs to -/// suppress passive RX logging of our own ping's echo when it can't be -/// credited as a TX echo (multi-hop, RSSI failsafe, user-filtered, or -/// arrived after the 5s echo window closed). -enum TxTrackerResult { - /// Valid repeater echo — recorded as a TX success for this ping. - tracked, - - /// Confirmed echo of our own recently-sent ping, but not trackable as a - /// TX success. Caller MUST NOT route this to the passive RX logger — - /// doing so produces misleading blue RX markers and polluted API payloads - /// where we post our own ping back to MeshMapper as a passive observation. - ownPingNotTracked, - - /// Packet is not an echo of our ping (different channel, different - /// content, or no recent ping context). Caller may route to the passive - /// RX logger as usual. - notOurPing, -} - /// TX echo tracker for repeater detection during 5-second window /// Reference: handleTxLogging() in wardrive.js (lines 3561-3710) class TxTracker { - /// Window during which we still identify our own ping's echoes, even after - /// [stopTracking] has fired. Covers echoes that arrive between the 5s echo - /// window closing and the RX listening window closing (BLE ack latency + - /// multi-hop propagation can easily push echoes past the 5s mark). - static const Duration _ownEchoAbsorbWindow = Duration(seconds: 10); bool isListening = false; DateTime? sentTimestamp; String? sentPayload; diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 1876c8f..0ba29f6 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -155,7 +155,6 @@ Future<({Uint8List bytes, Size size})> _renderDistanceLabelPng( ); } -/// most modern phones (typical DPR is 2.0–3.5). Future _renderPainterToPng( CustomPainter painter, Size size, { @@ -3136,6 +3135,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { required DateTime ts, required bool success, required int idForMetadata, + String? iconImageOverride, }) async { final key = _coverageKey(type, ts, lat, lon); final isFocused = _isFocusedPing(lat, lon, ts); @@ -3147,7 +3147,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final options = SymbolOptions( geometry: LatLng(lat, lon), - iconImage: _MapImages.coverage(type, success), + iconImage: iconImageOverride ?? _MapImages.coverage(type, success), iconSize: isFocused ? 1.2 : 1.0, ); @@ -3198,16 +3198,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // DISC entries (success = received node responses; drop = treat as TX fail) for (final entry in appState.discLogEntries) { final received = entry.nodeCount > 0; - // When discDropEnabled, "no response" should look like a TX fail color. - // We model that by using the 'tx' image variant for failed DISCs: - final type = (!received && appState.discDropEnabled) ? 'tx' : 'disc'; + final renderAsTxFail = !received && appState.discDropEnabled; await syncOne( - type: type, + type: 'disc', lat: entry.latitude, lon: entry.longitude, ts: entry.timestamp, success: received, idForMetadata: entry.timestamp.millisecondsSinceEpoch, + iconImageOverride: + renderAsTxFail ? _MapImages.coverage('tx', false) : null, ); } @@ -3337,6 +3337,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { static const _focusLinesSourceId = 'focus-lines-source'; static const _focusLinesLayerId = 'focus-lines-layer'; static const _focusLinesAmbiguousLayerId = 'focus-lines-ambiguous-border'; + static const _focusLinesAmbiguousLabelId = 'focus-lines-ambiguous-label'; /// Builds and applies the focus-mode dotted polylines that visually connect /// a focused ping to each repeater that heard it. Color-coded by SNR; @@ -3362,6 +3363,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { try { await _mapController!.removeLayer(_focusLinesLayerId); } catch (_) {} + try { + await _mapController!.removeLayer(_focusLinesAmbiguousLabelId); + } catch (_) {} try { await _mapController!.removeLayer(_focusLinesAmbiguousLayerId); } catch (_) {} @@ -3378,6 +3382,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { try { await _mapController!.removeLayer(_focusLinesLayerId); } catch (_) {} + try { + await _mapController!.removeLayer(_focusLinesAmbiguousLabelId); + } catch (_) {} try { await _mapController!.removeLayer(_focusLinesAmbiguousLayerId); } catch (_) {} @@ -3431,14 +3438,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // the repeater box. const belowLayer = _repeaterIndividualLayerId; - // Border line (white, wider, only for ambiguous matches) — added FIRST + // Border line (amber, wider, only for ambiguous matches) — added FIRST // so it renders BENEATH the colored line on top. await _mapController!.addLineLayer( _focusLinesSourceId, _focusLinesAmbiguousLayerId, const LineLayerProperties( - lineColor: '#FFFFFF', - lineOpacity: 0.6, + lineColor: '#F59E0B', + lineOpacity: 0.8, lineWidth: 6.5, lineDasharray: [2, 4], lineCap: 'round', @@ -3451,6 +3458,32 @@ class _MapWidgetState extends State with WidgetsBindingObserver { belowLayerId: belowLayer, ); + // "DUPLICATE ID" text along ambiguous lines — only renders when the + // line is long enough for the label to fit without collision. + await _mapController!.addSymbolLayer( + _focusLinesSourceId, + _focusLinesAmbiguousLabelId, + const SymbolLayerProperties( + symbolPlacement: 'line', + textField: 'DUPLICATE ID', + textSize: 11, + textColor: '#F59E0B', + textHaloColor: '#FFFFFF', + textHaloWidth: 1.5, + textKeepUpright: true, + textFont: _defaultFontStack, + symbolSpacing: 200, + textAllowOverlap: false, + textOptional: true, + ), + filter: [ + '==', + ['get', 'ambiguous'], + true + ], + belowLayerId: belowLayer, + ); + // Main colored line (color from feature property via data-driven expression) await _mapController!.addLineLayer( _focusLinesSourceId, @@ -5471,21 +5504,97 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + /// Show all ambiguous (duplicate-ID) repeaters in a dialog. + void _showDuplicateRepeaterPopup( + List<_ResolvedRepeater> resolved, { + ({double lat, double lon})? fromLatLng, + }) { + final ambiguous = resolved.where((r) => r.ambiguous).toList(); + if (ambiguous.isEmpty) return; + + final appState = context.read(); + final regionOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; + + showDialog( + context: context, + builder: (dialogContext) => Dialog( + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.warning_amber_rounded, + size: 18, color: Color(0xFFF59E0B)), + const SizedBox(width: 8), + Text( + 'Duplicate Repeater IDs', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 12), + Divider(height: 1, color: Theme.of(context).dividerColor), + const SizedBox(height: 8), + Text( + 'Multiple repeaters share the same short ID. ' + 'We can\'t determine which one heard your ping.', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + ...ambiguous.map((r) => RepeaterIdChip.buildRepeaterRow( + context, + r.repeater, + refLat: fromLatLng?.lat, + refLon: fromLatLng?.lon, + regionHopBytesOverride: regionOverride, + )), + ], + ), + ), + ), + ), + ); + } + /// Show TX ping details popup void _showTxPingDetails(TxPing ping) { // Use the heardRepeaters directly from the TxPing final heardRepeaters = ping.heardRepeaters; + // Resolve repeater matches (hoisted so bottom sheet can check ambiguity) + final resolved = heardRepeaters.isNotEmpty + ? _resolveRepeatersByHexIds( + heardRepeaters.map((r) => r.repeaterId).toList(), + snrValues: heardRepeaters.map((r) => r.snr).toList(), + ) + : <_ResolvedRepeater>[]; + final hasAmbiguous = resolved.any((r) => r.ambiguous); + // Activate focus mode if the ping was heard by known repeaters - if (heardRepeaters.isNotEmpty) { - final resolved = _resolveRepeatersByHexIds( - heardRepeaters.map((r) => r.repeaterId).toList(), - snrValues: heardRepeaters.map((r) => r.snr).toList(), - ); - if (resolved.isNotEmpty) { - _activatePingFocus( - LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); - } + if (resolved.isNotEmpty) { + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); } showModalBottomSheet( @@ -5610,6 +5719,39 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ), + if (hasAmbiguous) + GestureDetector( + onTap: () => _showDuplicateRepeaterPopup( + resolved, + fromLatLng: ( + lat: ping.latitude, + lon: ping.longitude, + ), + ), + child: const Padding( + padding: EdgeInsets.only(top: 6), + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, + size: 14, color: Color(0xFFF59E0B)), + SizedBox(width: 4), + Flexible( + child: Text( + 'Duplicate repeater ID — lines shown to all possible matches', + style: TextStyle( + fontSize: 11, + color: Color(0xFFF59E0B), + ), + ), + ), + SizedBox(width: 4), + Icon(Icons.info_outline, + size: 14, color: Color(0xFFF59E0B)), + ], + ), + ), + ), + if (heardRepeaters.isNotEmpty) ...[ const SizedBox(height: 12), // If any heard repeater is missing GPS, reserve a sliver of diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index e93db01..533990a 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -153,7 +153,7 @@ class RepeaterIdChip extends StatelessWidget { content = Column( mainAxisSize: MainAxisSize.min, children: matches - .map((r) => _buildRepeaterRow(context, r, + .map((r) => buildRepeaterRow(context, r, refLat: refLat, refLon: refLon, regionHopBytesOverride: regionOverride)) @@ -213,7 +213,7 @@ class RepeaterIdChip extends StatelessWidget { ); } - static Widget _buildRepeaterRow( + static Widget buildRepeaterRow( BuildContext context, Repeater repeater, { double? refLat, @@ -254,7 +254,7 @@ class RepeaterIdChip extends StatelessWidget { child: Row( children: [ // Colored badge — circle for short IDs, pill for longer - _buildHexBadge( + buildHexBadge( repeater.displayHexId(overrideHopBytes: regionHopBytesOverride), badgeColor), const SizedBox(width: 12), @@ -330,7 +330,7 @@ class RepeaterIdChip extends StatelessWidget { } /// Build a hex ID badge — circle for 2-char, pill for longer IDs - static Widget _buildHexBadge(String displayId, Color color) { + static Widget buildHexBadge(String displayId, Color color) { final isLong = displayId.length > 2; return Container( From 474436049f16d34f10f4bc63f0c1e335e84c4dc6 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 9 May 2026 13:09:17 -0400 Subject: [PATCH 029/100] Minor fix to the last commit --- lib/providers/app_state_provider.dart | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 913f5ae..2ac75fa 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1204,7 +1204,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_isAnonymousRenamed) { deviceName = 'Anonymous'; } else { - final selfInfoName = _meshCoreConnection!.selfInfo?.name; + var selfInfoName = _meshCoreConnection!.selfInfo?.name; // Detect stuck anonymous name: firmware still has "Anonymous" but mode is OFF if (selfInfoName == 'Anonymous') { final persistedName = _deviceRealNames[publicKey]; @@ -1213,21 +1213,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { '[CONN] Detected stuck anonymous name, recovering to "$persistedName"'); try { await _meshCoreConnection!.setAdvertName(persistedName); - debugLog('[CONN] Restored firmware name to "$persistedName"'); + await Future.delayed(const Duration(milliseconds: 300)); + final refreshed = await _meshCoreConnection!.getSelfInfo(); + selfInfoName = refreshed.name; + debugLog( + '[CONN] Confirmed firmware name restored to "$selfInfoName"'); _clearPersistedRealName(publicKey); } catch (e) { debugError('[CONN] Failed to restore firmware name: $e'); + selfInfoName = persistedName; } - deviceName = persistedName; } else { debugWarn( '[CONN] Firmware name is "Anonymous" but no persisted real name found'); - deviceName = selfInfoName; } - } else { - deviceName = selfInfoName ?? - connectedDeviceName?.replaceFirst('MeshCore-', ''); } + deviceName = selfInfoName ?? + connectedDeviceName?.replaceFirst('MeshCore-', ''); } if (deviceName == null || deviceName.isEmpty) { debugError( From d818c0f9eb69dc03e8985e87167b805a5ed2629e Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 9 May 2026 13:24:00 -0400 Subject: [PATCH 030/100] =?UTF-8?q?-=20**(Android)=20Fixed=20a=20crash=20w?= =?UTF-8?q?hen=20returning=20to=20the=20app=20after=20extended=20backgroun?= =?UTF-8?q?d=20time.**=20Same=20root=20cause=20as=20the=20iOS=20fix=20?= =?UTF-8?q?=E2=80=94=20MapLibre's=20GL=20surface=20can=20be=20temporarily?= =?UTF-8?q?=20invalid=20on=20resume.=20Map=20interactions=20now=20wait=20o?= =?UTF-8?q?ne=20frame=20after=20resume.=20Also=20fixed=20`flutter=5Flocal?= =?UTF-8?q?=5Fnotifications`=20TypeToken=20errors=20on=20every=20startup.?= =?UTF-8?q?=20-=20Changed=20way=20that=20anonymous=20mode=20is=20enabled,?= =?UTF-8?q?=20it=20now=20shows=20a=20popup=20with=20progress=20instead=20o?= =?UTF-8?q?f=20switching=20to=20connect=20tab=20to=20show=20the=20reconnec?= =?UTF-8?q?tion=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 1 + android/app/proguard-rules.pro | 7 ++ lib/providers/app_state_provider.dart | 29 ++++--- lib/screens/main_scaffold.dart | 106 ++++++++++++++++++++++---- lib/widgets/map_widget.dart | 7 +- 5 files changed, 121 insertions(+), 29 deletions(-) create mode 100644 android/app/proguard-rules.pro diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4f06511..38066d3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -60,6 +60,7 @@ android { buildTypes { release { signingConfig = signingConfigs.getByName("release") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..e89a275 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,7 @@ +# flutter_local_notifications uses Gson to serialize/deserialize scheduled +# notification data. R8 strips the generic signature from TypeToken +# subclasses, causing "TypeToken must be created with a type argument" at +# runtime when cancel() tries to load the notification cache. +-keep class com.google.gson.reflect.TypeToken { *; } +-keep class * extends com.google.gson.reflect.TypeToken +-keepattributes Signature diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 2ac75fa..14d7eb1 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -323,7 +323,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int _mapNavigationTrigger = 0; // Increment to trigger navigation bool _requestMapTabSwitch = false; // Request switch to map tab bool _requestErrorLogSwitch = false; // Request switch to error log tab - bool _requestConnectionTabSwitch = false; // Request switch to connection tab + bool _isAnonymousReconnectInProgress = false; + bool _anonymousReconnectEnabling = true; // Repeater markers state List _repeaters = []; @@ -508,7 +509,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int get mapNavigationTrigger => _mapNavigationTrigger; bool get requestMapTabSwitch => _requestMapTabSwitch; bool get requestErrorLogSwitch => _requestErrorLogSwitch; - bool get requestConnectionTabSwitch => _requestConnectionTabSwitch; + bool get isAnonymousReconnectInProgress => _isAnonymousReconnectInProgress; + bool get anonymousReconnectEnabling => _anonymousReconnectEnabling; UserPreferences get preferences => _preferences; RememberedDevice? get rememberedDevice => _rememberedDevice; @@ -572,6 +574,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? get zoneTransferFrom => _zoneTransferFrom; String? get zoneTransferTo => _zoneTransferTo; + // Repeater markers getters List get repeaters => List.unmodifiable(_repeaters); @@ -4261,12 +4264,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _meshCoreConnection != null) { final deviceToReconnect = _bluetoothService.connectedDevice; if (deviceToReconnect != null) { - _requestConnectionTabSwitch = true; + _isAnonymousReconnectInProgress = true; + _anonymousReconnectEnabling = enabled; + _connectionStep = ConnectionStep.disconnected; notifyListeners(); - await disconnect(); // Full cleanup (restores name if previously anonymous) - // Short delay for BLE cleanup - await Future.delayed(const Duration(milliseconds: 500)); - await connectToDevice(deviceToReconnect); + try { + await disconnect(); + await Future.delayed(const Duration(milliseconds: 500)); + await connectToDevice(deviceToReconnect); + } catch (e) { + debugError('[APP] Anonymous mode reconnect error: $e'); + } finally { + _isAnonymousReconnectInProgress = false; + notifyListeners(); + } } } } @@ -4452,10 +4463,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _requestErrorLogSwitch = false; } - /// Clear the connection tab switch request (called by main scaffold after switching) - void clearConnectionTabSwitchRequest() { - _requestConnectionTabSwitch = false; - } // ============================================ // API Error Handling diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index 375a6a9..b080be7 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -6,6 +6,7 @@ import 'package:geolocator/geolocator.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; +import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../services/permission_disclosure_service.dart'; import '../utils/debug_logger_io.dart'; @@ -202,18 +203,6 @@ class _MainScaffoldState extends State { }); } - // Listen for connection tab requests - switch to Connect tab (e.g. anonymous mode reconnect) - if (appState.requestConnectionTabSwitch && _selectedIndex != 3) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - _selectedIndex = 3; // Switch to Connect tab - }); - appState.clearConnectionTabSwitchRequest(); - } - }); - } - // Listen for flood-traffic-disabled-by-region alert (user had it on, // region forced it off on auth/zone-change) if (appState.floodDisabledAlertPending && !_floodDisabledDialogOpen) { @@ -228,9 +217,22 @@ class _MainScaffoldState extends State { MediaQuery.of(context).orientation == Orientation.landscape; return Scaffold( - body: IndexedStack( - index: _selectedIndex, - children: _screens, + body: Stack( + children: [ + IndexedStack( + index: _selectedIndex, + children: _screens, + ), + if (appState.isAnonymousReconnectInProgress) + Positioned.fill( + child: Container( + color: Colors.black54, + child: Center( + child: _buildAnonymousReconnectOverlay(appState), + ), + ), + ), + ], ), bottomNavigationBar: isLandscape ? _buildCompactNavBar(appState) @@ -238,6 +240,80 @@ class _MainScaffoldState extends State { ); } + Widget _buildAnonymousReconnectOverlay(AppStateProvider appState) { + final enabling = appState.anonymousReconnectEnabling; + final step = appState.connectionStep; + final totalSteps = ConnectionStepExtension.totalSteps; + final progress = step.stepNumber > 0 ? step.stepNumber / totalSteps : 0.0; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + decoration: BoxDecoration( + color: const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: Colors.orange.shade400, + ), + const SizedBox(height: 20), + Text( + enabling + ? 'Enabling Anonymous Mode...' + : 'Disabling Anonymous Mode...', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.grey.shade100, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress, + backgroundColor: Colors.grey.shade800, + color: Colors.orange.shade400, + minHeight: 4, + ), + ), + const SizedBox(height: 10), + Text( + step.description, + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 13, + ), + ), + if (step.stepNumber > 0) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Step ${step.stepNumber} of $totalSteps', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ); + } + /// Compact navigation bar for landscape mode (icon-only, shorter height) Widget _buildCompactNavBar(AppStateProvider appState) { return Container( diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 0ba29f6..b8ff36c 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1012,7 +1012,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // zoom the camera is animating toward — using it instead of the // (potentially interpolated) current zoom prevents drift during the // initial zoom animation after tapping center-on-position. - if (_autoFollow && _isMapReady) { + if (_autoFollow && _isMapReady && _cameraAnimationReady) { final newPosition = center; if (_lastGpsPosition == null || _lastGpsPosition!.latitude != newPosition.latitude || @@ -1131,7 +1131,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (_isMapReady && _styleLoaded && _imagesRegistered && - _clusterLayersReady) { + _clusterLayersReady && + _cameraAnimationReady) { final dataVersion = _computeMarkerDataVersion(appState); if (dataVersion != _lastMarkerDataVersion) { _lastMarkerDataVersion = dataVersion; @@ -1163,7 +1164,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // re-run its global symbol collision pass and flickered the base-style // POI labels at high zoom. The gpsMarkerStyle pref is included so style // changes (arrow → walk, etc.) re-render the marker's bitmap. - if (_isMapReady && _styleLoaded && _imagesRegistered) { + if (_isMapReady && _styleLoaded && _imagesRegistered && _cameraAnimationReady) { final gpsVersion = Object.hash( appState.currentPosition?.latitude, appState.currentPosition?.longitude, From 5ec79e87a607fd7db0a51300fe71796d44341d12 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 9 May 2026 13:27:23 -0400 Subject: [PATCH 031/100] - Fixed being unable to switch from offline back to online mode while connected --- lib/providers/app_state_provider.dart | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 14d7eb1..79a5fb5 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3606,6 +3606,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Hot-switching to online mode while connected'); _isSwitchingMode = true; _modeSwitchError = null; + var switchSucceeded = false; notifyListeners(); try { @@ -3642,7 +3643,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } - // Re-check zone status BEFORE auth (zone data was cleared when entering offline mode) + // Clear offline mode before zone check so checkZoneStatus() doesn't skip the API call + _preferences = _preferences.copyWith(offlineMode: false); + _apiQueueService.offlineMode = false; + debugLog('[APP] Re-checking zone status before auth...'); await checkZoneStatus(); @@ -3777,8 +3781,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // 5. Auth successful - update state - _preferences = _preferences.copyWith(offlineMode: false); - _apiQueueService.offlineMode = false; + switchSucceeded = true; // 6. Update regional channels from auth response final channels = result['channels']; @@ -3800,6 +3803,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _modeSwitchError = 'Failed to switch to online mode: $e'; return (success: false, error: _modeSwitchError); } finally { + if (!switchSucceeded) { + _preferences = _preferences.copyWith(offlineMode: true); + _apiQueueService.offlineMode = true; + } _isSwitchingMode = false; notifyListeners(); } From 4b1214c3efb295675b5bce7a596c4a63147157f2 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 9 May 2026 13:40:40 -0400 Subject: [PATCH 032/100] - Fixed misleading "No empty channel slots" error when BLE disconnects during connection --- lib/services/meshcore/channel_service.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/services/meshcore/channel_service.dart b/lib/services/meshcore/channel_service.dart index d92573c..56f04c0 100644 --- a/lib/services/meshcore/channel_service.dart +++ b/lib/services/meshcore/channel_service.dart @@ -177,7 +177,6 @@ class ChannelService { // Full scan prevents duplicates from orphaned channels after unexpected disconnects int? firstEmptySlot; var channelIdx = 0; - while (true) { try { // Retry mechanism for first channel (sometimes gets spurious OK responses) @@ -222,6 +221,13 @@ class ChannelService { // #wardriving not found - create it at first empty slot if (firstEmptySlot == null) { + if (channelIdx == 0) { + // Couldn't read any channels — BLE dropped, not a channel issue + debugError('[CHANNEL] BLE connection lost during channel scan'); + throw Exception( + 'BLE connection lost during channel setup. Please try connecting again.', + ); + } debugError( '[CHANNEL] No empty channel slots found in first $channelIdx channels'); throw Exception( From 225064daa90e0f9f3b67f0577b4c7b1a0736c6aa Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 9 May 2026 13:44:08 -0400 Subject: [PATCH 033/100] - Fixed offline sessions getting stuck at "partial upload" and failing to upload --- lib/providers/app_state_provider.dart | 79 ++++++++++++++++++----- lib/screens/settings_screen.dart | 2 +- lib/services/api_service.dart | 21 ++++-- lib/services/offline_session_service.dart | 49 ++++++++++++-- 4 files changed, 125 insertions(+), 26 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 79a5fb5..98ab3f1 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -4134,28 +4134,67 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog( '[OFFLINE] Authenticated with isolated session: $offlineSessionId'); - // Delay after auth before posting - await Future.delayed(const Duration(seconds: 1)); + // Server needs ~3-5s to propagate session after auth + await Future.delayed(const Duration(seconds: 4)); - // 4. Upload pings in batches of 50 using isolated session + // 4. Upload pings in batches of 50 with retry on session timing errors const batchSize = 50; var uploadedCount = 0; - var failedBatches = 0; + var discardedCount = 0; + var sessionFailed = false; final totalBatches = (pings.length + batchSize - 1) ~/ batchSize; for (var i = 0; i < pings.length; i += batchSize) { + if (sessionFailed) break; + final batchNum = (i ~/ batchSize) + 1; onProgress?.call('Batch $batchNum/$totalBatches'); final batch = pings.skip(i).take(batchSize).toList(); - final result = + var result = await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); - if (result == UploadResult.success) { - uploadedCount += batch.length; - debugLog('[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); - } else { - failedBatches++; - debugError('[OFFLINE] Failed to upload batch $batchNum'); + + // Retry logic for session timing errors (server propagation delay) + if (result == UploadResult.sessionError) { + const maxRetries = 3; + for (var retry = 1; retry <= maxRetries; retry++) { + final delay = 2 * retry; + debugLog( + '[OFFLINE] Batch $batchNum session error, retry $retry/$maxRetries after ${delay}s'); + onProgress?.call('Batch $batchNum/$totalBatches (retry $retry)'); + await Future.delayed(Duration(seconds: delay)); + result = await _apiService.uploadBatchWithSessionId( + batch, offlineSessionId); + if (result != UploadResult.sessionError) break; + } + } else if (result == UploadResult.retryable) { + debugLog('[OFFLINE] Batch $batchNum retryable error, retrying after 2s'); + await Future.delayed(const Duration(seconds: 2)); + result = await _apiService.uploadBatchWithSessionId( + batch, offlineSessionId); + } + + // Process final result + switch (result) { + case UploadResult.success: + uploadedCount += batch.length; + debugLog( + '[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); + break; + case UploadResult.nonRetryable: + discardedCount += batch.length; + debugWarn('[OFFLINE] Batch $batchNum discarded (data error)'); + break; + case UploadResult.sessionError: + debugError( + '[OFFLINE] Batch $batchNum session error persists after retries, aborting'); + sessionFailed = true; + break; + case UploadResult.retryable: + debugError( + '[OFFLINE] Batch $batchNum still failing after retry, aborting'); + sessionFailed = true; + break; } } @@ -4171,15 +4210,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); debugLog('[OFFLINE] Isolated upload session released'); - // 6. Mark session as uploaded (don't delete) if all batches succeeded - if (failedBatches == 0) { + // 6. Clean up session based on results + final totalProcessed = uploadedCount + discardedCount; + final remainingPings = pings.length - totalProcessed; + + if (remainingPings <= 0) { await _offlineSessionService.markAsUploaded(filename); - debugLog('[OFFLINE] Uploaded ${pings.length} pings from $filename'); + debugLog( + '[OFFLINE] Session complete: $uploadedCount uploaded, $discardedCount discarded from $filename'); notifyListeners(); return OfflineUploadResult.success; } else { + if (totalProcessed > 0) { + await _offlineSessionService.removeProcessedPings( + filename, totalProcessed); + debugLog( + '[OFFLINE] Removed $totalProcessed processed pings, $remainingPings remain in $filename'); + } debugWarn( - '[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); + '[OFFLINE] Partial upload: $uploadedCount uploaded, $discardedCount discarded, $remainingPings remaining from $filename'); notifyListeners(); return OfflineUploadResult.partialFailure; } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 12a3343..b0f1627 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2208,7 +2208,7 @@ class _SettingsScreenState extends State { backgroundColor = Colors.red; break; case OfflineUploadResult.partialFailure: - message = 'Partial upload - some pings failed'; + message = 'Partial upload - tap again to retry remaining pings'; backgroundColor = Colors.orange; break; case OfflineUploadResult.uploadInProgress: diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index ae11188..d6ba1e2 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -8,7 +8,7 @@ import '../models/repeater.dart'; import '../utils/debug_logger_io.dart'; /// Result of a batch upload attempt -enum UploadResult { success, retryable, nonRetryable } +enum UploadResult { success, retryable, sessionError, nonRetryable } /// MeshMapper API service /// Handles communication with the MeshMapper backend @@ -1085,12 +1085,21 @@ class ApiService { final reason = result['reason'] as String?; - // For offline uploads, session/auth errors are non-retryable but do NOT cascade - const criticalErrors = { + // Session timing errors: session not yet propagated or expired. + // The pings are fine — retrying with a delay may succeed. + const sessionTimingErrors = { + 'bad_session', 'session_expired', 'session_invalid', 'session_revoked', - 'bad_session', + }; + if (sessionTimingErrors.contains(reason)) { + debugError('[API] Offline upload batch session timing error: $reason'); + return UploadResult.sessionError; + } + + // Permanent auth/zone errors: session will never work, abort upload. + const permanentSessionErrors = { 'invalid_key', 'unauthorized', 'bad_key', @@ -1098,8 +1107,8 @@ class ApiService { 'zone_full', 'zone_disabled', }; - if (criticalErrors.contains(reason)) { - debugError('[API] Offline upload batch session error: $reason'); + if (permanentSessionErrors.contains(reason)) { + debugError('[API] Offline upload batch permanent error: $reason'); return UploadResult.nonRetryable; } diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index 761a315..f295859 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -54,13 +54,17 @@ class OfflineSession { }; } - /// Create a copy with uploaded status changed - OfflineSession copyWith({bool? uploaded}) { + /// Create a copy with updated fields + OfflineSession copyWith({ + bool? uploaded, + Map? data, + int? pingCount, + }) { return OfflineSession( filename: filename, createdAt: createdAt, - pingCount: pingCount, - data: data, + pingCount: pingCount ?? this.pingCount, + data: data ?? this.data, devicePublicKey: devicePublicKey, deviceName: deviceName, contactUri: contactUri, @@ -274,6 +278,43 @@ class OfflineSessionService { debugLog('[OFFLINE] Marked session as uploaded: $filename'); } + /// Remove the first [processedCount] pings from a session. + /// Called after partial upload so retries don't re-send already-processed data. + /// Returns the number of remaining pings, or null if session not found. + Future removeProcessedPings( + String filename, int processedCount) async { + final index = _sessions.indexWhere((s) => s.filename == filename); + if (index == -1) { + debugWarn('[OFFLINE] Session not found for ping removal: $filename'); + return null; + } + + final session = _sessions[index]; + final pings = (session.data['pings'] as List?) ?? []; + + if (processedCount >= pings.length) { + _sessions[index] = session.copyWith(uploaded: true); + await _saveSessions(); + debugLog( + '[OFFLINE] All pings processed, marking session complete: $filename'); + return 0; + } + + final remaining = pings.sublist(processedCount); + final updatedData = Map.from(session.data); + updatedData['pings'] = remaining; + updatedData['ping_count'] = remaining.length; + + _sessions[index] = session.copyWith( + data: updatedData, + pingCount: remaining.length, + ); + await _saveSessions(); + debugLog( + '[OFFLINE] Removed $processedCount processed pings from $filename, ${remaining.length} remain'); + return remaining.length; + } + /// Get a session by filename OfflineSession? getSession(String filename) { try { From a66debd91f8f52b214be44ec71d4068d0c7c2815 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 9 May 2026 13:44:16 -0400 Subject: [PATCH 034/100] - Fixed offline sessions getting stuck at "partial upload" and failing to upload --- assets/device-models.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/assets/device-models.json b/assets/device-models.json index 49cb9e6..22f5a6b 100644 --- a/assets/device-models.json +++ b/assets/device-models.json @@ -299,6 +299,14 @@ "txPower": 22, "notes": "Nano G2 Ultra" }, + { + "manufacturer": "pyMC-Repeater-Companion", + "shortName": "pyMC Repeater Companion", + "power": 1.0, + "platform": "PyMC", + "txPower": 30, + "notes": "pyMC Repeater Companion" + }, { "manufacturer": "Station G2", "shortName": "Station G2", From e4034daefde117f5d93998cdc7ba16f7e8254676 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 10 May 2026 07:49:01 -0400 Subject: [PATCH 035/100] - Fixed auto-ping mode buttons disappearing after reconnect timeout and zone transitions --- lib/providers/app_state_provider.dart | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 98ab3f1..ab3f28b 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2763,6 +2763,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { '[CONN] Auto-reconnect: calling reconnectToRememberedDevice()'); await reconnectToRememberedDevice(); + // Timeout or cancel fired while connection was in-flight. + // Disconnect the orphaned connection — abandon already cleaned up state. + if (!_isAutoReconnecting) { + if (_connectionStep == ConnectionStep.connected) { + debugLog( + '[CONN] Auto-reconnect completed after timeout — disconnecting orphaned connection'); + disconnect(); + } + return; + } + // If we get here and connection step is 'connected', success! if (_connectionStep == ConnectionStep.connected) { debugLog( @@ -5324,6 +5335,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_autoPingEnabled) { _autoPingEnabled = false; _idleAutoStopReference = null; + await _pingService?.forceDisableAutoPing(); debugLog('[ZONE] Auto-ping paused for zone transfer'); } @@ -5608,6 +5620,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } if (!_autoPingEnabled) { + _cooldownTimer.stop(); toggleAutoPing(previousMode); debugLog('[ZONE] Auto-ping restored (mode=$previousMode)'); } From d24cd30514546e8d0869343d9b07c62e7fbcf852 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 13 May 2026 17:37:14 -0400 Subject: [PATCH 036/100] TCP/USB transport support --- DEVELOPMENT.md | 20 +- README.md | 1 + THIRD_PARTY_LICENSES | 29 + android/app/src/main/AndroidManifest.xml | 3 + .../kotlin/net/meshmapper/app/MainActivity.kt | 10 + .../meshmapper/app/MeshMapperUsbService.kt | 557 +++++ lib/models/connection_state.dart | 18 +- lib/models/remembered_device.dart | 32 +- lib/providers/app_state_provider.dart | 2086 ++++++++++------- lib/screens/connection_screen.dart | 505 +++- lib/services/bluetooth/bluetooth_service.dart | 10 +- lib/services/meshcore/connection.dart | 36 +- lib/services/meshcore/protocol_constants.dart | 2 +- .../transport/android_serial_service.dart | 131 ++ .../transport/companion_transport.dart | 26 + .../transport/stream_frame_codec.dart | 128 + .../transport/stream_transport_base.dart | 121 + lib/services/transport/tcp_service.dart | 167 ++ .../transport/web_serial_factory.dart | 2 + .../transport/web_serial_factory_stub.dart | 4 + .../transport/web_serial_factory_web.dart | 8 + .../transport/web_serial_service.dart | 128 + pubspec.yaml | 2 +- .../transport/stream_frame_codec_test.dart | 282 +++ 24 files changed, 3382 insertions(+), 926 deletions(-) create mode 100644 THIRD_PARTY_LICENSES create mode 100644 android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt create mode 100644 lib/services/transport/android_serial_service.dart create mode 100644 lib/services/transport/companion_transport.dart create mode 100644 lib/services/transport/stream_frame_codec.dart create mode 100644 lib/services/transport/stream_transport_base.dart create mode 100644 lib/services/transport/tcp_service.dart create mode 100644 lib/services/transport/web_serial_factory.dart create mode 100644 lib/services/transport/web_serial_factory_stub.dart create mode 100644 lib/services/transport/web_serial_factory_web.dart create mode 100644 lib/services/transport/web_serial_service.dart create mode 100644 test/services/transport/stream_frame_codec_test.dart diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 50aab68..d7b96bb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -55,11 +55,20 @@ MESHMAPPER_API_KEY= ./Build.sh The app uses a layered service architecture with clear separation of concerns: **Bluetooth Abstraction Layer** (`lib/services/bluetooth/`): -- `BluetoothService`: Abstract interface for BLE operations +- `BluetoothService`: Abstract interface for BLE operations, implements `CompanionTransport` - `MobileBluetoothService`: Android/iOS implementation using `flutter_blue_plus` - `WebBluetoothService`: Web implementation using `flutter_web_bluetooth` - Platform selection happens at runtime in `main.dart` using `kIsWeb` +**Transport Layer** (`lib/services/transport/`): +- `CompanionTransport`: Transport-agnostic interface for MeshCore companion connections (BLE, TCP, USB Serial) +- `StreamFrameCodec`: Framing codec for TCP/USB Serial (`[0x3C][len_lo][len_hi][payload]` out, `[0x3E][len_lo][len_hi][payload]` in) +- `StreamTransportBase`: Abstract base for TCP and USB Serial transports, owns codec and connection lifecycle +- `TcpService`: TCP socket transport with saved connections persistence (Android/iOS) +- `AndroidSerialService`: USB Serial via USB OTG on Android using `usb_serial` package +- `WebSerialService`: USB Serial via Web Serial API (Chrome/Edge) using `dart:js_interop` +- Platform matrix: BLE (all platforms), TCP (Android/iOS), USB Serial (Android/Web) + **MeshCore Protocol Layer** (`lib/services/meshcore/`): - `MeshCoreConnection`: Implements the 9-step connection workflow and MeshCore companion protocol - `PacketParser`: Binary packet parsing with BufferReader/Writer utilities @@ -85,7 +94,7 @@ The app uses a layered service architecture with clear separation of concerns: Critical safety: The connection sequence MUST complete in order. -1. **BLE GATT Connect**: Platform-specific BLE connection +1. **Transport Connect**: Platform-specific transport connection (BLE GATT, TCP socket, or USB Serial port) 2. **Protocol Handshake**: `deviceQuery()` with protocol version 3. **Device Info**: `deviceQuery()` returns manufacturer string, then `getSelfInfo()` acquires device public key (required for geo-auth API authentication). If `getSelfInfo()` fails, the entire connection fails. 4. **Device Identification**: Parse manufacturer string, match against `device-models.json` (does NOT modify radio settings) @@ -342,6 +351,7 @@ Key packages used in this project: - `provider`: State management - `http`: API requests - `pointycastle`: Encryption (AES-ECB, SHA-256) +- `usb_serial`: USB Serial communication on Android (USB OTG) ## Development Workflow Requirements @@ -506,6 +516,12 @@ All API endpoints may return maintenance mode: - `lib/services/meshcore/tx_tracker.dart` - Repeater echo detection (7s window) - `lib/services/meshcore/disc_tracker.dart` - Discovery response tracking (7s window) - `lib/services/meshcore/rx_logger.dart` - Passive observation logging +- `lib/services/transport/companion_transport.dart` - Transport-agnostic interface for companion connections +- `lib/services/transport/stream_frame_codec.dart` - TCP/USB Serial framing codec +- `lib/services/transport/stream_transport_base.dart` - Shared base for TCP/USB Serial transports +- `lib/services/transport/tcp_service.dart` - TCP socket transport with saved connections +- `lib/services/transport/android_serial_service.dart` - USB Serial transport for Android (USB OTG) +- `lib/services/transport/web_serial_service.dart` - USB Serial transport for Web (Web Serial API) - `lib/services/ping_service.dart` - TX/RX/Discovery ping orchestration - `lib/services/gps_service.dart` - GPS tracking and geofencing - `lib/services/api_queue_service.dart` - Persistent upload queue diff --git a/README.md b/README.md index dc09548..5f46dd0 100644 --- a/README.md +++ b/README.md @@ -97,4 +97,5 @@ This project is licensed under the MIT License — see [LICENSE](LICENSE) for de - Original [MeshMapper WebClient](https://github.com/MeshMapper/MeshMapper_WebClient) - [MeshCore](https://github.com/meshcore-dev/MeshCore) firmware project +- [meshcore-open](https://github.com/zjs81/meshcore-open) by zjs81 — Android USB Serial support is based heavily on its native USB host implementation (MIT License, see [THIRD_PARTY_LICENSES](THIRD_PARTY_LICENSES)) - [The Greater Ottawa Mesh Radio Enthusiasts community](https://ottawamesh.ca/) diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES new file mode 100644 index 0000000..a542052 --- /dev/null +++ b/THIRD_PARTY_LICENSES @@ -0,0 +1,29 @@ +================================================================================ +meshcore-open +https://github.com/zjs81/meshcore-open +================================================================================ + +The Android USB Serial implementation (MeshMapperUsbService.kt) is based on +the USB host code from meshcore-open. + +MIT License + +Copyright (c) 2025 zjs81 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 688df15..bd38937 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -14,6 +14,9 @@ + + + diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt index d529fc6..c96604d 100644 --- a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt +++ b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt @@ -9,9 +9,14 @@ import org.maplibre.android.offline.OfflineRegionStatus import java.io.File class MainActivity : FlutterActivity() { + private var usbService: MeshMapperUsbService? = null + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + usbService = MeshMapperUsbService(this) + usbService!!.configureFlutterEngine(flutterEngine) + // MapLibre tile cache management. Mirrors AppDelegate.swift's iOS // implementation. Called from Dart's TileCacheService by the Offline // Maps screen's Tile Cache card. @@ -90,4 +95,9 @@ class MainActivity : FlutterActivity() { } ) } + + override fun onDestroy() { + usbService?.dispose() + super.onDestroy() + } } diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt new file mode 100644 index 0000000..c2279c4 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt @@ -0,0 +1,557 @@ +// Android USB Serial implementation based on meshcore-open +// https://github.com/zjs81/meshcore-open +// Copyright (c) zjs81, MIT License — see THIRD_PARTY_LICENSES + +package net.meshmapper.app + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicLong + +class MeshMapperUsbService( + private val activity: FlutterActivity, +) { + private companion object { + const val USB_RECIPIENT_INTERFACE = 0x01 + const val METHOD_CHANNEL = "net.meshmapper.app/usb_serial" + const val EVENT_CHANNEL = "net.meshmapper.app/usb_serial/data" + const val USB_PERMISSION_ACTION = "net.meshmapper.app.USB_PERMISSION" + } + + private val usbManager by lazy { + activity.getSystemService(Context.USB_SERVICE) as UsbManager + } + private val mainHandler = Handler(Looper.getMainLooper()) + private val usbIoExecutor: ExecutorService = Executors.newSingleThreadExecutor() + + @Volatile private var eventSink: EventChannel.EventSink? = null + @Volatile private var usbConnection: UsbDeviceConnection? = null + @Volatile private var usbInEndpoint: UsbEndpoint? = null + @Volatile private var usbOutEndpoint: UsbEndpoint? = null + @Volatile private var controlInterface: UsbInterface? = null + @Volatile private var dataInterface: UsbInterface? = null + private var readThread: Thread? = null + @Volatile private var isReading = false + @Volatile private var connectedDeviceName: String? = null + + private val readAttempts = AtomicLong(0) + private val bytesReceived = AtomicLong(0) + private val eventsPosted = AtomicLong(0) + private val sinkNullCount = AtomicLong(0) + + private var pendingConnectResult: MethodChannel.Result? = null + private var pendingConnectPortName: String? = null + private var pendingConnectBaudRate: Int = 115200 + + private data class PortConfig( + val controlInterface: UsbInterface?, + val dataInterface: UsbInterface, + val inEndpoint: UsbEndpoint, + val outEndpoint: UsbEndpoint, + ) + + private val permissionReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + UsbManager.ACTION_USB_DEVICE_DETACHED -> { + handleUsbDetached(intent) + return + } + USB_PERMISSION_ACTION -> Unit + else -> return + } + + val result = pendingConnectResult + val portName = pendingConnectPortName + pendingConnectResult = null + pendingConnectPortName = null + + if (result == null || portName == null) return + + val device = findUsbDevice(portName) + if (device == null) { + result.error("usb_device_missing", "USB device not found after permission grant", null) + return + } + + val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + if (!granted || !usbManager.hasPermission(device)) { + result.error("usb_permission_denied", "USB permission was denied", null) + return + } + + openUsbDevice(device, pendingConnectBaudRate, result) + } + } + + fun configureFlutterEngine(flutterEngine: FlutterEngine) { + registerUsbPermissionReceiver() + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "listDevices" -> result.success(listUsbDevices()) + "connect" -> handleConnect(call, result) + "write" -> handleWrite(call, result) + "readDiagnostics" -> result.success(mapOf( + "readAttempts" to readAttempts.get(), + "bytesReceived" to bytesReceived.get(), + "eventsPosted" to eventsPosted.get(), + "sinkNullCount" to sinkNullCount.get(), + "isReading" to isReading, + "threadAlive" to (readThread?.isAlive == true), + "sinkSet" to (eventSink != null), + )) + "disconnect" -> { + scheduleCloseUsbConnection { + result.success(null) + } + } + else -> result.notImplemented() + } + } + + EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL) + .setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + eventSink = events + } + override fun onCancel(arguments: Any?) { + eventSink = null + } + }, + ) + } + + fun dispose() { + closeUsbConnection() + usbIoExecutor.shutdownNow() + try { + activity.unregisterReceiver(permissionReceiver) + } catch (_: IllegalArgumentException) {} + } + + private fun registerUsbPermissionReceiver() { + val filter = IntentFilter().apply { + addAction(USB_PERMISSION_ACTION) + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity.registerReceiver(permissionReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + @Suppress("DEPRECATION") + activity.registerReceiver(permissionReceiver, filter) + } + } + + private fun listUsbDevices(): List> { + return usbManager.deviceList.values.map { device -> + val serial = try { device.serialNumber } catch (_: SecurityException) { null } + mapOf( + "deviceName" to device.deviceName, + "productName" to (device.productName ?: "USB Serial Device"), + "vid" to device.vendorId, + "pid" to device.productId, + "serial" to serial, + ) + } + } + + private fun handleConnect(call: MethodCall, result: MethodChannel.Result) { + val deviceName = call.argument("deviceName") + val baudRate = call.argument("baudRate") ?: 115200 + if (deviceName.isNullOrBlank()) { + result.error("usb_invalid_port", "Device name is required", null) + return + } + + val device = findUsbDevice(deviceName) + if (device == null) { + result.error("usb_device_missing", "USB device '$deviceName' not found", null) + return + } + + if (usbManager.hasPermission(device)) { + openUsbDevice(device, baudRate, result) + return + } + + if (pendingConnectResult != null) { + result.error("usb_busy", "Another USB permission request is pending", null) + return + } + + pendingConnectResult = result + pendingConnectPortName = deviceName + pendingConnectBaudRate = baudRate + + val permissionIntent = PendingIntent.getBroadcast( + activity, + 0, + Intent(USB_PERMISSION_ACTION).setPackage(activity.packageName), + pendingIntentFlags(), + ) + usbManager.requestPermission(device, permissionIntent) + } + + private fun handleWrite(call: MethodCall, result: MethodChannel.Result) { + val data = call.argument("data") + val connection = usbConnection + val endpoint = usbOutEndpoint + if (data == null) { + result.error("usb_invalid_data", "Write data is required", null) + return + } + if (connection == null || endpoint == null) { + result.error("usb_not_connected", "No USB device connected", null) + return + } + + usbIoExecutor.execute { + try { + writeToDevice(data) + mainHandler.post { result.success(null) } + } catch (error: Exception) { + mainHandler.post { + result.error("usb_write_failed", error.message, null) + } + } + } + } + + private fun findUsbDevice(deviceName: String): UsbDevice? { + return usbManager.deviceList.values.firstOrNull { it.deviceName == deviceName } + } + + private fun openUsbDevice( + device: UsbDevice, + baudRate: Int, + result: MethodChannel.Result, + ) { + usbIoExecutor.execute { + try { + closeUsbConnection() + + val config = resolvePortConfig(device) + if (config == null) { + mainHandler.post { + result.error("usb_no_endpoints", "No compatible USB endpoints found on device", null) + } + return@execute + } + + val connection = usbManager.openDevice(device) + if (connection == null) { + mainHandler.post { + result.error("usb_open_failed", "Failed to open USB device connection", null) + } + return@execute + } + + if (!connection.claimInterface(config.dataInterface, true)) { + connection.close() + mainHandler.post { + result.error("usb_open_failed", "Failed to claim USB data interface", null) + } + return@execute + } + + if (config.controlInterface != null && + config.controlInterface.id != config.dataInterface.id && + !connection.claimInterface(config.controlInterface, true) + ) { + connection.releaseInterface(config.dataInterface) + connection.close() + mainHandler.post { + result.error("usb_open_failed", "Failed to claim USB control interface", null) + } + return@execute + } + + usbConnection = connection + usbInEndpoint = config.inEndpoint + usbOutEndpoint = config.outEndpoint + controlInterface = config.controlInterface + dataInterface = config.dataInterface + + configureDevice(connection, config, baudRate) + + connectedDeviceName = device.deviceName + startReadLoop() + + mainHandler.post { + result.success(mapOf( + "interfaceCount" to device.interfaceCount, + "controlFound" to (config.controlInterface != null), + "controlId" to config.controlInterface?.id, + "controlClass" to config.controlInterface?.interfaceClass, + "dataId" to config.dataInterface.id, + "dataClass" to config.dataInterface.interfaceClass, + "inMaxPacket" to config.inEndpoint.maxPacketSize, + "outMaxPacket" to config.outEndpoint.maxPacketSize, + )) + } + } catch (error: Exception) { + closeUsbConnection() + mainHandler.post { + result.error("usb_connect_failed", error.message, null) + } + } + } + } + + private fun resolvePortConfig(device: UsbDevice): PortConfig? { + var preferredDataInterface: UsbInterface? = null + var preferredInEndpoint: UsbEndpoint? = null + var preferredOutEndpoint: UsbEndpoint? = null + var fallbackDataInterface: UsbInterface? = null + var fallbackInEndpoint: UsbEndpoint? = null + var fallbackOutEndpoint: UsbEndpoint? = null + var preferredControlInterface: UsbInterface? = null + + for (i in 0 until device.interfaceCount) { + val usbInterface = device.getInterface(i) + var inEp: UsbEndpoint? = null + var outEp: UsbEndpoint? = null + + for (j in 0 until usbInterface.endpointCount) { + val endpoint = usbInterface.getEndpoint(j) + if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) continue + when (endpoint.direction) { + UsbConstants.USB_DIR_IN -> if (inEp == null) inEp = endpoint + UsbConstants.USB_DIR_OUT -> if (outEp == null) outEp = endpoint + } + } + + val hasDataPair = inEp != null && outEp != null + when { + usbInterface.interfaceClass == UsbConstants.USB_CLASS_COMM && + preferredControlInterface == null -> { + preferredControlInterface = usbInterface + } + hasDataPair && + usbInterface.interfaceClass == UsbConstants.USB_CLASS_CDC_DATA -> { + preferredDataInterface = usbInterface + preferredInEndpoint = inEp + preferredOutEndpoint = outEp + } + hasDataPair && fallbackDataInterface == null -> { + fallbackDataInterface = usbInterface + fallbackInEndpoint = inEp + fallbackOutEndpoint = outEp + } + } + } + + val dataIface = preferredDataInterface ?: fallbackDataInterface ?: return null + val inEndpoint = preferredInEndpoint ?: fallbackInEndpoint ?: return null + val outEndpoint = preferredOutEndpoint ?: fallbackOutEndpoint ?: return null + return PortConfig(preferredControlInterface, dataIface, inEndpoint, outEndpoint) + } + + private fun configureDevice( + connection: UsbDeviceConnection, + config: PortConfig, + baudRate: Int, + ) { + val control = config.controlInterface ?: return + + val lineCoding = byteArrayOf( + (baudRate and 0xFF).toByte(), + ((baudRate shr 8) and 0xFF).toByte(), + ((baudRate shr 16) and 0xFF).toByte(), + ((baudRate shr 24) and 0xFF).toByte(), + 0, // stop bits: 1 + 0, // parity: none + 8, // data bits + ) + + val lineCodingResult = connection.controlTransfer( + UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_CLASS or USB_RECIPIENT_INTERFACE, + 0x20, // SET_LINE_CODING + 0, + control.id, + lineCoding, + lineCoding.size, + 1000, + ) + if (lineCodingResult < 0) { + throw IllegalStateException("Failed to configure USB line coding") + } + + val controlLineResult = connection.controlTransfer( + UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_CLASS or USB_RECIPIENT_INTERFACE, + 0x22, // SET_CONTROL_LINE_STATE + 0x0001, // DTR on, RTS off + control.id, + null, + 0, + 1000, + ) + if (controlLineResult < 0) { + throw IllegalStateException("Failed to configure USB control line state") + } + } + + private fun startReadLoop() { + val connection = usbConnection ?: return + val endpoint = usbInEndpoint ?: return + + isReading = true + readAttempts.set(0) + bytesReceived.set(0) + eventsPosted.set(0) + sinkNullCount.set(0) + + readThread = Thread({ + val packetSize = endpoint.maxPacketSize.coerceAtLeast(64) + val buffer = ByteArray(packetSize * 4) + try { + while (isReading) { + readAttempts.incrementAndGet() + val bytesRead = connection.bulkTransfer(endpoint, buffer, buffer.size, 250) + if (!isReading) break + if (bytesRead <= 0) continue + bytesReceived.addAndGet(bytesRead.toLong()) + val packet = buffer.copyOf(bytesRead) + mainHandler.post { + val sink = eventSink + if (sink != null) { + sink.success(packet) + eventsPosted.incrementAndGet() + } else { + sinkNullCount.incrementAndGet() + } + } + } + } catch (error: Exception) { + if (isReading) { + mainHandler.post { + eventSink?.error("usb_io_error", error.message ?: "USB serial I/O error", null) + } + scheduleCloseUsbConnection() + } + } + }, "MeshMapperUsbRead").also { thread -> + thread.isDaemon = true + thread.start() + } + } + + private fun writeToDevice(data: ByteArray) { + val connection = usbConnection ?: throw IllegalStateException("USB connection missing") + val endpoint = usbOutEndpoint ?: throw IllegalStateException("USB output endpoint missing") + var offset = 0 + val maxPacketSize = endpoint.maxPacketSize.coerceAtLeast(64) + while (offset < data.size) { + val chunkSize = minOf(maxPacketSize, data.size - offset) + val chunk = data.copyOfRange(offset, offset + chunkSize) + val bytesWritten = connection.bulkTransfer(endpoint, chunk, chunkSize, 1000) + if (bytesWritten < 0) { + throw IllegalStateException("USB write error: bulkTransfer returned $bytesWritten") + } + offset += bytesWritten + } + } + + private fun scheduleCloseUsbConnection(onComplete: (() -> Unit)? = null) { + usbIoExecutor.execute { + closeUsbConnection() + if (onComplete != null) { + mainHandler.post(onComplete) + } + } + } + + @Synchronized + private fun closeUsbConnection() { + isReading = false + readThread?.interrupt() + if (readThread != null && readThread !== Thread.currentThread()) { + try { + readThread?.join(300) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + readThread = null + + val connection = usbConnection + val claimedControl = controlInterface + val claimedData = dataInterface + + usbInEndpoint = null + usbOutEndpoint = null + controlInterface = null + dataInterface = null + usbConnection = null + + if (connection != null) { + if (claimedControl != null) { + try { connection.releaseInterface(claimedControl) } catch (_: Exception) {} + } + if (claimedData != null && claimedData.id != claimedControl?.id) { + try { connection.releaseInterface(claimedData) } catch (_: Exception) {} + } + try { connection.close() } catch (_: Exception) {} + } + connectedDeviceName = null + } + + private fun handleUsbDetached(intent: Intent) { + val detachedDevice = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + } + + val detachedName = detachedDevice?.deviceName ?: return + + if (pendingConnectPortName == detachedName) { + pendingConnectResult?.error( + "usb_device_detached", + "USB device was removed before the connection completed", + null, + ) + pendingConnectResult = null + pendingConnectPortName = null + } + + if (connectedDeviceName == detachedName) { + scheduleCloseUsbConnection { + eventSink?.error( + "usb_device_detached", + "USB device was disconnected", + null, + ) + } + } + } + + private fun pendingIntentFlags(): Int { + return PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } +} diff --git a/lib/models/connection_state.dart b/lib/models/connection_state.dart index e807295..4b3a965 100644 --- a/lib/models/connection_state.dart +++ b/lib/models/connection_state.dart @@ -1,3 +1,6 @@ +/// Transport type for MeshCore companion connections. +enum TransportType { ble, tcp, usbSerial } + /// Connection status for Bluetooth devices enum ConnectionStatus { /// Not connected to any device @@ -25,8 +28,8 @@ enum ConnectionStep { /// Auto-reconnecting after unexpected BLE disconnect reconnecting, - /// Step 1: BLE GATT connect - bleConnecting, + /// Step 1: Transport-level connect (BLE/TCP/USB) + transportConnecting, /// Step 2: Protocol handshake protocolHandshake, @@ -52,6 +55,9 @@ enum ConnectionStep { /// Step 9: Fully connected and ready connected, + /// Disconnecting (cleanup in progress) + disconnecting, + /// Error state error, } @@ -85,7 +91,7 @@ extension ConnectionStepExtension on ConnectionStep { return 'Disconnected'; case ConnectionStep.reconnecting: return 'Reconnecting...'; - case ConnectionStep.bleConnecting: + case ConnectionStep.transportConnecting: return 'Connecting to device...'; case ConnectionStep.protocolHandshake: return 'Protocol handshake...'; @@ -103,6 +109,8 @@ extension ConnectionStepExtension on ConnectionStep { return 'Initializing GPS...'; case ConnectionStep.connected: return 'Connected'; + case ConnectionStep.disconnecting: + return 'Disconnecting...'; case ConnectionStep.error: return 'Connection error'; } @@ -115,7 +123,7 @@ extension ConnectionStepExtension on ConnectionStep { return 0; case ConnectionStep.reconnecting: return 0; - case ConnectionStep.bleConnecting: + case ConnectionStep.transportConnecting: return 1; case ConnectionStep.protocolHandshake: return 2; @@ -133,6 +141,8 @@ extension ConnectionStepExtension on ConnectionStep { return 8; case ConnectionStep.connected: return 9; + case ConnectionStep.disconnecting: + return 0; case ConnectionStep.error: return -1; } diff --git a/lib/models/remembered_device.dart b/lib/models/remembered_device.dart index 2bf9236..426502c 100644 --- a/lib/models/remembered_device.dart +++ b/lib/models/remembered_device.dart @@ -1,33 +1,57 @@ -/// Remembered BLE device for quick reconnection +import 'connection_state.dart'; + +/// Remembered device for quick reconnection. +/// Supports BLE, TCP, and USB Serial transports. class RememberedDevice { final String id; final String name; final DateTime lastConnected; + final TransportType transportType; + final String? tcpHost; + final int? tcpPort; + final String? serialPortPath; const RememberedDevice({ required this.id, required this.name, required this.lastConnected, + this.transportType = TransportType.ble, + this.tcpHost, + this.tcpPort, + this.serialPortPath, }); - /// Create from JSON (for persistence) factory RememberedDevice.fromJson(Map json) { return RememberedDevice( id: json['id'] as String, name: json['name'] as String, lastConnected: DateTime.parse(json['lastConnected'] as String), + transportType: _parseTransportType(json['transportType'] as String?), + tcpHost: json['tcpHost'] as String?, + tcpPort: json['tcpPort'] as int?, + serialPortPath: json['serialPortPath'] as String?, ); } - /// Convert to JSON (for persistence) Map toJson() { return { 'id': id, 'name': name, 'lastConnected': lastConnected.toIso8601String(), + 'transportType': transportType.name, + if (tcpHost != null) 'tcpHost': tcpHost, + if (tcpPort != null) 'tcpPort': tcpPort, + if (serialPortPath != null) 'serialPortPath': serialPortPath, }; } - /// Get display name (stripped of MeshCore- prefix) String get displayName => name.replaceFirst('MeshCore-', ''); + + static TransportType _parseTransportType(String? value) { + if (value == null) return TransportType.ble; + return TransportType.values.firstWhere( + (e) => e.name == value, + orElse: () => TransportType.ble, + ); + } } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index ab3f28b..20fba85 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -25,6 +25,9 @@ import '../services/background_service.dart'; import '../services/debug_file_logger.dart'; import '../services/offline_session_service.dart'; import '../services/bluetooth/bluetooth_service.dart'; +import '../services/transport/android_serial_service.dart'; +import '../services/transport/companion_transport.dart'; +import '../services/transport/tcp_service.dart'; import '../services/device_model_service.dart'; import '../services/gps_service.dart'; import '../services/gps_simulator_service.dart'; @@ -119,6 +122,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { StreamSubscription? _noiseFloorSubscription; StreamSubscription? _batterySubscription; + // Transport selection + TransportType _selectedTransport = TransportType.ble; + CompanionTransport? _activeTransport; + StreamSubscription? _transportConnectionSubscription; + // Device identity String _deviceId = ''; @@ -152,8 +160,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _devicePublicKey; String? _offlineContactUri; - /// BLE device name (e.g., "MeshCore-MrAlders0n_Elecrow") - String? get connectedDeviceName => _bluetoothService.connectedDevice?.name; + /// Connected device name (e.g., "MeshCore-MrAlders0n_Elecrow" for BLE, "TCP 10.0.0.1:5000" for TCP) + String? get connectedDeviceName => + (_activeTransport ?? _bluetoothService).connectedDevice?.name; /// Display name from SelfInfo (reflects user's chosen name in MeshCore) /// BLE advertisement name may be cached/stale after device rename @@ -277,6 +286,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _isSwitchingMode = false; String? _modeSwitchError; // Error message if mode switch fails + // Connection guard — prevents concurrent connect attempts and provides instant UI feedback + bool _isConnecting = false; + // Auto-reconnect state bool _userRequestedDisconnect = false; bool _isAutoReconnecting = false; @@ -388,6 +400,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String get deviceId => _deviceId; bool get preferencesLoaded => _preferencesLoaded; + TransportType get selectedTransport => _selectedTransport; ConnectionStatus get connectionStatus => _connectionStatus; ConnectionStep get connectionStep => _connectionStep; String? get connectionError => _connectionError; @@ -558,6 +571,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Anonymous mode getter bool get isAnonymousRenamed => _isAnonymousRenamed; + // Connection guard getter + bool get isConnecting => _isConnecting; + // Auto-reconnect getters bool get isAutoReconnecting => _isAutoReconnecting; int get reconnectAttempt => _reconnectAttempt; @@ -1129,13 +1145,313 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Connection // ============================================ - /// Connect to a discovered device - Future connectToDevice(DiscoveredDevice device) async { + /// Creates the two-stage auth callback for MeshCoreConnection Step 6. + /// Shared by all transport types (BLE, TCP, USB Serial). + Future?> Function() _createAuthCallback() { + return () async { + final publicKey = _meshCoreConnection!.devicePublicKey; + if (publicKey == null) { + debugError('[APP] Cannot request auth: no public key'); + return { + 'success': false, + 'reason': 'no_public_key', + 'message': 'Device public key not available' + }; + } + + // Anonymous mode: rename device before auth so mesh pings broadcast as "Anonymous" + if (_preferences.anonymousMode && !_isAnonymousRenamed) { + final realName = _meshCoreConnection!.selfInfo?.name; + if (realName != null && realName.isNotEmpty) { + if (realName == 'Anonymous') { + final persisted = _deviceRealNames[publicKey]; + _originalDeviceName = persisted ?? realName; + if (persisted != null) { + debugLog( + '[CONN] Anonymous mode: recovered real name "$persisted" from Hive (firmware was stuck)'); + } + } else { + _originalDeviceName = realName; + } + try { + await _meshCoreConnection!.setAdvertName('Anonymous'); + _isAnonymousRenamed = true; + _displayDeviceName = 'Anonymous'; + if (_originalDeviceName != 'Anonymous') { + _deviceRealNames[publicKey] = _originalDeviceName!; + _saveDeviceRealNames(); + } + debugLog( + '[CONN] Anonymous mode: renamed from "$_originalDeviceName" to "Anonymous"'); + await Future.delayed(const Duration(milliseconds: 300)); + } catch (e) { + debugError('[CONN] Anonymous mode: rename failed: $e'); + } + } + } + + // Resolve device name: use "Anonymous" if renamed, otherwise SelfInfo name + String? deviceName; + if (_isAnonymousRenamed) { + deviceName = 'Anonymous'; + } else { + var selfInfoName = _meshCoreConnection!.selfInfo?.name; + if (selfInfoName == 'Anonymous') { + final persistedName = _deviceRealNames[publicKey]; + if (persistedName != null) { + debugLog( + '[CONN] Detected stuck anonymous name, recovering to "$persistedName"'); + try { + await _meshCoreConnection!.setAdvertName(persistedName); + await Future.delayed(const Duration(milliseconds: 300)); + final refreshed = await _meshCoreConnection!.getSelfInfo(); + selfInfoName = refreshed.name; + debugLog( + '[CONN] Confirmed firmware name restored to "$selfInfoName"'); + _clearPersistedRealName(publicKey); + } catch (e) { + debugError('[CONN] Failed to restore firmware name: $e'); + selfInfoName = persistedName; + } + } else { + debugWarn( + '[CONN] Firmware name is "Anonymous" but no persisted real name found'); + } + } + deviceName = selfInfoName ?? + connectedDeviceName?.replaceFirst('MeshCore-', ''); + } + if (deviceName == null || deviceName.isEmpty) { + debugError( + '[APP] Cannot request auth: could not retrieve device name'); + return { + 'success': false, + 'reason': 'no_device_name', + 'message': 'Could not retrieve device name' + }; + } + + // Stage 1: Try existing public_key authentication + debugLog( + '[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); + + final result = await _apiService.requestAuth( + reason: 'connect', + publicKey: publicKey, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: zoneCode ?? _preferences.iataCode, + model: _meshCoreConnection!.deviceModel?.manufacturer ?? + _meshCoreConnection!.deviceInfo?.manufacturer ?? + 'Unknown', + lat: _currentPosition?.latitude, + lon: _currentPosition?.longitude, + accuracyMeters: _currentPosition?.accuracy, + ); + + if (result != null && result['maintenance'] == true) { + _maintenanceMode = true; + _maintenanceMessage = result['maintenance_message'] as String?; + _maintenanceUrl = result['maintenance_url'] as String?; + debugLog( + '[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + _startMaintenancePolling(); + notifyListeners(); + return { + 'success': false, + 'reason': 'maintenance', + 'message': _maintenanceMessage ?? 'Service is under maintenance', + }; + } + + if (result != null && result['success'] == true) { + debugLog('[APP] Stage 1 succeeded: authenticated via public_key'); + if (result['type'] != null) { + _authType = result['type'] as String; + debugLog('[APP] Auth type: $_authType'); + notifyListeners(); + } + _syncZoneCapacityFromAuth(result); + return result; + } + + if (result == null) { + debugError('[APP] API unreachable - network error'); + return { + 'success': false, + 'reason': 'network_error', + 'message': 'Unable to reach the MeshMapper server', + }; + } + + debugLog( + '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + + final stage1Reason = result['reason'] as String?; + if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { + debugError( + '[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + return { + 'success': false, + 'reason': stage1Reason, + 'message': result['message'] as String?, + }; + } + + // Stage 2: Auth failed, attempt registration via signed contact_uri + debugLog('[APP] Stage 2: Attempting registration via contact_uri...'); + + String? contactUri; + try { + debugLog('[APP] Requesting signed contact URI from device...'); + contactUri = await _meshCoreConnection!.exportContact(); + debugLog( + '[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + } catch (e) { + debugError('[APP] Failed to get contact URI from device: $e'); + return { + 'success': false, + 'reason': 'registration_failed', + 'message': + 'Companion not found in backend and failed to register via API' + }; + } + + final registerResult = await _apiService.requestAuth( + reason: 'register', + contactUri: contactUri, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: zoneCode ?? _preferences.iataCode, + model: _meshCoreConnection!.deviceModel?.manufacturer ?? + _meshCoreConnection!.deviceInfo?.manufacturer ?? + 'Unknown', + lat: _currentPosition?.latitude, + lon: _currentPosition?.longitude, + accuracyMeters: _currentPosition?.accuracy, + ); + + if (registerResult == null) { + debugError('[APP] Stage 2 failed: network error (API unreachable)'); + return { + 'success': false, + 'reason': 'network_error', + 'message': 'Unable to reach the MeshMapper server', + }; + } + + if (registerResult['success'] != true) { + final serverReason = + registerResult['reason'] as String? ?? 'registration_failed'; + final serverMessage = registerResult['message'] as String?; + debugError( + '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + return { + 'success': false, + 'reason': serverReason, + 'message': serverMessage ?? 'Registration rejected by server', + }; + } + + debugLog('[APP] Stage 2 succeeded: registered and authenticated'); + if (registerResult['type'] != null) { + _authType = registerResult['type'] as String; + debugLog('[APP] Auth type: $_authType'); + notifyListeners(); + } + _syncZoneCapacityFromAuth(registerResult); + return registerResult; + }; + } + + /// Handle connection errors — shared by all transport connection methods. + Future _handleConnectionError(Object e) async { + debugError('[APP] Connection failed: $e'); + try { - _connectionError = null; + await _meshCoreConnection?.deleteWardrivingChannelEarly(); + } catch (channelError) { + debugError('[APP] Cleanup channel delete failed: $channelError'); + } + + try { + if (_meshCoreConnection != null) { + await _meshCoreConnection!.disconnect(); + } + } catch (disconnectError) { + debugError('[APP] Cleanup disconnect failed: $disconnectError'); + } + + final errorStr = e.toString(); + if (errorStr.contains('AUTH_FAILED:')) { + _isAuthError = true; + final parts = errorStr.split('AUTH_FAILED:'); + if (parts.length > 1) { + final errorParts = parts[1].split(':'); + final reason = errorParts.isNotEmpty ? errorParts[0] : 'unknown'; + final serverMessage = + errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; + _isNetworkError = reason == 'network_error'; + _connectionError = _getErrorMessage(reason, serverMessage); + } else { + _connectionError = 'Authentication failed'; + } + } else { _isAuthError = false; _isNetworkError = false; + if (errorStr.contains('timeout') || + errorStr.contains('Timeout') || + errorStr.contains('timed out')) { + _connectionError = 'Connection timed out'; + } else { + _connectionError = errorStr.replaceFirst('Exception: ', ''); + } + } + _isConnecting = false; + _connectionStep = ConnectionStep.error; + notifyListeners(); + } + /// Set up disconnect listener for non-BLE transports (TCP, USB Serial). + void _setupTransportDisconnectListener(CompanionTransport transport) { + _transportConnectionSubscription?.cancel(); + _transportConnectionSubscription = + transport.connectionStream.listen((status) async { + if (status == ConnectionStatus.disconnected) { + final wasConnected = _connectionStep == ConnectionStep.connected; + final hasRemembered = _rememberedDevice != null; + final isUnexpected = + !_userRequestedDisconnect && !_isAutoReconnecting; + final canAutoReconnect = hasRemembered && + !kIsWeb && + _rememberedDevice!.transportType != TransportType.usbSerial; + if (wasConnected && isUnexpected && canAutoReconnect) { + debugLog( + '[CONN] Unexpected transport disconnect - starting auto-reconnect'); + await _startAutoReconnect(); + } else if (!_isAutoReconnecting) { + await _fullDisconnectCleanup(); + } + notifyListeners(); + } + }); + } + + /// Connect to a discovered device + Future connectToDevice(DiscoveredDevice device) async { + if (_isConnecting) { + debugLog('[APP] Connection already in progress, ignoring duplicate tap'); + return; + } + _isConnecting = true; + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + _isAuthError = false; + _isNetworkError = false; + notifyListeners(); + try { // Clean up any previous connection first if (_meshCoreConnection != null) { debugLog('[APP] Disposing previous MeshCoreConnection'); @@ -1146,269 +1462,273 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // ALWAYS START FRESH - clear any stale pings before connecting await _apiQueueService.clearBeforeConnect(); - // Create MeshCore connection + debugLog('[APP] Connecting BLE transport to ${device.id}'); + await _bluetoothService.connect(device.id); + _activeTransport = _bluetoothService; debugLog('[APP] Creating new MeshCoreConnection'); - _meshCoreConnection = MeshCoreConnection(bluetooth: _bluetoothService); + _meshCoreConnection = MeshCoreConnection(transport: _bluetoothService); - // Set auth callback for Step 6 (called during connect, after public key is acquired) - // Implements two-stage auth flow with registration fallback - // Skip auth when offline mode is enabled if (!_preferences.offlineMode) { - _meshCoreConnection!.onRequestAuth = () async { - final publicKey = _meshCoreConnection!.devicePublicKey; - if (publicKey == null) { - debugError('[APP] Cannot request auth: no public key'); - return { - 'success': false, - 'reason': 'no_public_key', - 'message': 'Device public key not available' - }; - } + _meshCoreConnection!.onRequestAuth = _createAuthCallback(); + } else { + _meshCoreConnection!.onRequestAuth = null; + debugLog('[APP] Offline mode: skipping API auth'); + } - // Anonymous mode: rename device before auth so mesh pings broadcast as "Anonymous" - if (_preferences.anonymousMode && !_isAnonymousRenamed) { - final realName = _meshCoreConnection!.selfInfo?.name; - if (realName != null && realName.isNotEmpty) { - // Cascade guard: if firmware is stuck as "Anonymous" from a previous - // unclean disconnect, recover the real name from Hive - if (realName == 'Anonymous') { - final persisted = _deviceRealNames[publicKey]; - _originalDeviceName = - persisted ?? realName; // fall back if nothing saved - if (persisted != null) { - debugLog( - '[CONN] Anonymous mode: recovered real name "$persisted" from Hive (firmware was stuck)'); - } - } else { - _originalDeviceName = realName; - } - try { - await _meshCoreConnection!.setAdvertName('Anonymous'); - _isAnonymousRenamed = true; - _displayDeviceName = 'Anonymous'; - // Persist real name keyed by public key (only if not "Anonymous") - if (_originalDeviceName != 'Anonymous') { - _deviceRealNames[publicKey] = _originalDeviceName!; - _saveDeviceRealNames(); - } - debugLog( - '[CONN] Anonymous mode: renamed from "$_originalDeviceName" to "Anonymous"'); - // Short delay for firmware to process - await Future.delayed(const Duration(milliseconds: 300)); - } catch (e) { - debugError('[CONN] Anonymous mode: rename failed: $e'); - // Continue with real name if rename fails - } - } - } + // Listen for step changes + _meshCoreConnection!.stepStream.listen((step) { + _connectionStep = step; + if (step == ConnectionStep.connected) { + // Update device info + _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; + _firmwareVersionString = + _meshCoreConnection!.deviceInfo?.firmwareVersionString; + _deviceModel = _meshCoreConnection!.deviceModel; + _devicePublicKey = _meshCoreConnection!.devicePublicKey; + debugLog( + '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); - // Resolve device name: use "Anonymous" if renamed, otherwise SelfInfo name - String? deviceName; - if (_isAnonymousRenamed) { - deviceName = 'Anonymous'; - } else { - var selfInfoName = _meshCoreConnection!.selfInfo?.name; - // Detect stuck anonymous name: firmware still has "Anonymous" but mode is OFF - if (selfInfoName == 'Anonymous') { - final persistedName = _deviceRealNames[publicKey]; - if (persistedName != null) { - debugLog( - '[CONN] Detected stuck anonymous name, recovering to "$persistedName"'); - try { - await _meshCoreConnection!.setAdvertName(persistedName); - await Future.delayed(const Duration(milliseconds: 300)); - final refreshed = await _meshCoreConnection!.getSelfInfo(); - selfInfoName = refreshed.name; - debugLog( - '[CONN] Confirmed firmware name restored to "$selfInfoName"'); - _clearPersistedRealName(publicKey); - } catch (e) { - debugError('[CONN] Failed to restore firmware name: $e'); - selfInfoName = persistedName; - } - } else { - debugWarn( - '[CONN] Firmware name is "Anonymous" but no persisted real name found'); - } - } - deviceName = selfInfoName ?? - connectedDeviceName?.replaceFirst('MeshCore-', ''); + // Persist device info for bug reports when disconnected + // Use original name (not "Anonymous") for bug report identification + var lastDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); + if (lastDeviceName != null) { + lastDeviceName = lastDeviceName.replaceFirst('MeshCore-', ''); + } + // Cascade guard: never persist "Anonymous" as the last connected device + if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { + lastDeviceName = + _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; } - if (deviceName == null || deviceName.isEmpty) { - debugError( - '[APP] Cannot request auth: could not retrieve device name'); - return { - 'success': false, - 'reason': 'no_device_name', - 'message': 'Could not retrieve device name' - }; + if (lastDeviceName != null && + lastDeviceName.isNotEmpty && + _devicePublicKey != null) { + _saveLastConnectedDevice(lastDeviceName, _devicePublicKey!); } - // ============================================================ - // STAGE 1: Try existing public_key authentication - // ============================================================ - debugLog( - '[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); + // In offline mode, fetch signed contact URI for later registration during upload + if (_preferences.offlineMode && _meshCoreConnection != null) { + _meshCoreConnection!.exportContact().then((uri) { + _offlineContactUri = uri; + debugLog('[OFFLINE] Stored contact URI for offline session'); + }).catchError((e) { + debugWarn('[OFFLINE] Failed to get contact URI: $e'); + }); + } + } + notifyListeners(); + }); - final result = await _apiService.requestAuth( - reason: 'connect', - publicKey: publicKey, - who: deviceName, - appVersion: _appVersion, - power: _preferences.powerLevel, - iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection!.deviceModel?.manufacturer ?? - _meshCoreConnection!.deviceInfo?.manufacturer ?? - 'Unknown', - lat: _currentPosition?.latitude, - lon: _currentPosition?.longitude, - accuracyMeters: _currentPosition?.accuracy, - ); + // Listen for noise floor updates — only rebuild UI when value changes + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _recordNoiseFloorSample(noiseFloor); + if (noiseFloor != _currentNoiseFloor) { + _currentNoiseFloor = noiseFloor; + notifyListeners(); + } + }); - // Check for maintenance mode - if (result != null && result['maintenance'] == true) { - _maintenanceMode = true; - _maintenanceMessage = result['maintenance_message'] as String?; - _maintenanceUrl = result['maintenance_url'] as String?; - debugLog( - '[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); - _startMaintenancePolling(); - notifyListeners(); - return { - 'success': false, - 'reason': 'maintenance', - 'message': _maintenanceMessage ?? 'Service is under maintenance', - }; - } + // Listen for battery updates — only rebuild UI when value changes + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { + if (batteryPercent != _currentBatteryPercent) { + _currentBatteryPercent = batteryPercent; + notifyListeners(); + } + }); - // Check if Stage 1 succeeded - if (result != null && result['success'] == true) { - debugLog('[APP] Stage 1 succeeded: authenticated via public_key'); + // Execute connection workflow (transport already connected above) + final connectionResult = await _meshCoreConnection!.connect( + _deviceModelService.models, + ); - // Store the auth type from response - if (result['type'] != null) { - _authType = result['type'] as String; - debugLog('[APP] Auth type: $_authType'); - notifyListeners(); - } + await _postConnectionSetup(connectionResult, device); + _isConnecting = false; + } catch (e) { + await _handleConnectionError(e); + } + } - // Sync zone capacity display with auth result - _syncZoneCapacityFromAuth(result); + /// Set the selected transport type for the connection screen. + void setSelectedTransport(TransportType type) { + _selectedTransport = type; + notifyListeners(); + } - return result; - } + /// Connect to a MeshCore device via TCP. + Future connectViaTcp(String host, int port) async { + if (_isConnecting) { + debugLog('[APP] Connection already in progress, ignoring duplicate tap'); + return; + } + _isConnecting = true; + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + _isAuthError = false; + _isNetworkError = false; + notifyListeners(); + try { + if (_meshCoreConnection != null) { + debugLog('[APP] Disposing previous MeshCoreConnection'); + _meshCoreConnection!.dispose(); + _meshCoreConnection = null; + } - // API unreachable (null = network/timeout error, not an auth rejection) - if (result == null) { - debugError('[APP] API unreachable - network error'); - return { - 'success': false, - 'reason': 'network_error', - 'message': 'Unable to reach the MeshMapper server', - }; - } + await _apiQueueService.clearBeforeConnect(); - debugLog( - '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); - - // If Stage 1 failed due to GPS issues, Stage 2 will also fail with same bad data - final stage1Reason = result['reason'] as String?; - if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { - debugError( - '[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); - return { - 'success': false, - 'reason': stage1Reason, - 'message': result['message'] as String?, - }; - } + final tcpService = TcpService(host: host, port: port); + debugLog('[APP] Connecting TCP transport to $host:$port'); + await tcpService.openConnection(); + _activeTransport = tcpService; + _setupTransportDisconnectListener(tcpService); - // ============================================================ - // STAGE 2: Auth failed, attempt registration via signed contact_uri - // ============================================================ - debugLog('[APP] Stage 2: Attempting registration via contact_uri...'); + debugLog('[APP] Creating new MeshCoreConnection (TCP)'); + _meshCoreConnection = MeshCoreConnection(transport: tcpService); - String? contactUri; - try { - debugLog('[APP] Requesting signed contact URI from device...'); - contactUri = await _meshCoreConnection!.exportContact(); - debugLog( - '[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); - } catch (e) { - debugError('[APP] Failed to get contact URI from device: $e'); - return { - 'success': false, - 'reason': 'registration_failed', - 'message': - 'Companion not found in backend and failed to register via API' - }; - } + if (!_preferences.offlineMode) { + _meshCoreConnection!.onRequestAuth = _createAuthCallback(); + } else { + _meshCoreConnection!.onRequestAuth = null; + debugLog('[APP] Offline mode: skipping API auth'); + } - // Call API with contact_uri for registration - final registerResult = await _apiService.requestAuth( - reason: 'register', - contactUri: contactUri, - who: deviceName, - appVersion: _appVersion, - power: _preferences.powerLevel, - iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection!.deviceModel?.manufacturer ?? - _meshCoreConnection!.deviceInfo?.manufacturer ?? - 'Unknown', - lat: _currentPosition?.latitude, - lon: _currentPosition?.longitude, - accuracyMeters: _currentPosition?.accuracy, - ); + _meshCoreConnection!.stepStream.listen((step) { + _connectionStep = step; + if (step == ConnectionStep.connected) { + _manufacturerString = + _meshCoreConnection!.deviceInfo?.manufacturer; + _firmwareVersionString = + _meshCoreConnection!.deviceInfo?.firmwareVersionString; + _deviceModel = _meshCoreConnection!.deviceModel; + _devicePublicKey = _meshCoreConnection!.devicePublicKey; + debugLog( + '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); - if (registerResult == null) { - debugError('[APP] Stage 2 failed: network error (API unreachable)'); - return { - 'success': false, - 'reason': 'network_error', - 'message': 'Unable to reach the MeshMapper server', - }; + var lastDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); + if (lastDeviceName != null) { + lastDeviceName = lastDeviceName.replaceFirst('MeshCore-', ''); + } + if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { + lastDeviceName = + _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; + } + if (lastDeviceName != null && + lastDeviceName.isNotEmpty && + _devicePublicKey != null) { + _saveLastConnectedDevice(lastDeviceName, _devicePublicKey!); } - if (registerResult['success'] != true) { - final serverReason = - registerResult['reason'] as String? ?? 'registration_failed'; - final serverMessage = registerResult['message'] as String?; - debugError( - '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); - return { - 'success': false, - 'reason': serverReason, - 'message': serverMessage ?? 'Registration rejected by server', - }; + if (_preferences.offlineMode && _meshCoreConnection != null) { + _meshCoreConnection!.exportContact().then((uri) { + _offlineContactUri = uri; + debugLog('[OFFLINE] Stored contact URI for offline session'); + }).catchError((e) { + debugWarn('[OFFLINE] Failed to get contact URI: $e'); + }); } + } + notifyListeners(); + }); - // Registration successful - response contains full auth data directly - debugLog('[APP] Stage 2 succeeded: registered and authenticated'); + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _recordNoiseFloorSample(noiseFloor); + if (noiseFloor != _currentNoiseFloor) { + _currentNoiseFloor = noiseFloor; + notifyListeners(); + } + }); - // Store the auth type from response - if (registerResult['type'] != null) { - _authType = registerResult['type'] as String; - debugLog('[APP] Auth type: $_authType'); - notifyListeners(); - } + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { + if (batteryPercent != _currentBatteryPercent) { + _currentBatteryPercent = batteryPercent; + notifyListeners(); + } + }); - // Sync zone capacity display with auth result - _syncZoneCapacityFromAuth(registerResult); + final connectionResult = await _meshCoreConnection!.connect( + _deviceModelService.models, + ); - return registerResult; - }; + final device = DiscoveredDevice( + id: '$host:$port', + name: 'TCP $host:$port', + ); + await _postConnectionSetup(connectionResult, device, + tcpHost: host, tcpPort: port); + + await TcpService.saveConnection(host, port, displayDeviceName ?? ''); + _isConnecting = false; + } catch (e) { + await _handleConnectionError(e); + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); + } + _activeTransport = null; + _transportConnectionSubscription?.cancel(); + } + } + + /// Connect to a MeshCore device via Android USB Serial (OTG). + Future connectViaUsb(Map usbDevice) async { + if (_isConnecting) { + debugLog('[APP] Connection already in progress, ignoring duplicate tap'); + return; + } + _isConnecting = true; + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + _isAuthError = false; + _isNetworkError = false; + notifyListeners(); + try { + if (_meshCoreConnection != null) { + debugLog('[APP] Disposing previous MeshCoreConnection'); + _meshCoreConnection!.dispose(); + _meshCoreConnection = null; + } + + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); + } + _activeTransport = null; + _transportConnectionSubscription?.cancel(); + + await _apiQueueService.clearBeforeConnect(); + + final usbProductName = + usbDevice['productName'] as String? ?? 'USB Serial'; + final usbDeviceName = + usbDevice['deviceName'] as String? ?? 'USB Serial'; + final serialService = AndroidSerialService( + deviceName: usbDeviceName, + productName: usbProductName, + ); + debugLog('[APP] Connecting USB Serial transport to $usbProductName'); + await serialService.openConnection(); + _activeTransport = serialService; + _setupTransportDisconnectListener(serialService); + + debugLog('[APP] Creating new MeshCoreConnection (USB Serial)'); + _meshCoreConnection = MeshCoreConnection(transport: serialService); + + if (!_preferences.offlineMode) { + _meshCoreConnection!.onRequestAuth = _createAuthCallback(); } else { - // Offline mode: skip API auth _meshCoreConnection!.onRequestAuth = null; debugLog('[APP] Offline mode: skipping API auth'); } - // Listen for step changes _meshCoreConnection!.stepStream.listen((step) { _connectionStep = step; if (step == ConnectionStep.connected) { - // Update device info - _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; + _manufacturerString = + _meshCoreConnection!.deviceInfo?.manufacturer; _firmwareVersionString = _meshCoreConnection!.deviceInfo?.firmwareVersionString; _deviceModel = _meshCoreConnection!.deviceModel; @@ -1416,15 +1736,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog( '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); - // Persist device info for bug reports when disconnected - // Use original name (not "Anonymous") for bug report identification var lastDeviceName = _isAnonymousRenamed ? _originalDeviceName : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); if (lastDeviceName != null) { lastDeviceName = lastDeviceName.replaceFirst('MeshCore-', ''); } - // Cascade guard: never persist "Anonymous" as the last connected device if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { lastDeviceName = _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; @@ -1435,7 +1752,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _saveLastConnectedDevice(lastDeviceName, _devicePublicKey!); } - // In offline mode, fetch signed contact URI for later registration during upload if (_preferences.offlineMode && _meshCoreConnection != null) { _meshCoreConnection!.exportContact().then((uri) { _offlineContactUri = uri; @@ -1448,7 +1764,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); }); - // Listen for noise floor updates — only rebuild UI when value changes _noiseFloorSubscription = _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { _recordNoiseFloorSample(noiseFloor); @@ -1458,7 +1773,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } }); - // Listen for battery updates — only rebuild UI when value changes _batterySubscription = _meshCoreConnection!.batteryStream.listen((batteryPercent) { if (batteryPercent != _currentBatteryPercent) { @@ -1467,689 +1781,693 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } }); - // Execute connection workflow final connectionResult = await _meshCoreConnection!.connect( - device.id, _deviceModelService.models, ); - // Update preferences if device model was recognized (for display/API reporting) - // Note: This does NOT change the radio's TX power - it only sets what power level to REPORT - if (connectionResult.deviceModelMatched && - connectionResult.deviceModel != null) { - final device = connectionResult.deviceModel!; - _preferences = _preferences.copyWith( - powerLevel: device.power, - txPower: device.txPower, - autoPowerSet: - true, // Indicates power was auto-detected from device model - powerLevelSet: false, // Clear stale manual flag from previous session - ); - notifyListeners(); - debugLog( - '[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); + final vid = usbDevice['vid'] as int? ?? 0; + final pid = usbDevice['pid'] as int? ?? 0; + final serial = usbDevice['serial'] as String? ?? ''; + final deviceId = '$vid:$pid:$serial'; + final device = DiscoveredDevice( + id: deviceId, + name: usbProductName, + ); + await _postConnectionSetup(connectionResult, device, + serialPortPath: deviceId); + _isConnecting = false; + } catch (e) { + await _handleConnectionError(e); + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); } + _activeTransport = null; + _transportConnectionSubscription?.cancel(); + } + } - // Note: API session acquisition is now handled by the auth callback - // during connection workflow Step 6 (onRequestAuth) + /// Connect using a pre-opened transport (for platform-specific transports + /// like Web Serial that can't be imported cross-platform). + Future connectWithTransport( + CompanionTransport transport, { + required String deviceId, + required String deviceName, + String? serialPortPath, + }) async { + if (_isConnecting) { + debugLog('[APP] Connection already in progress, ignoring duplicate tap'); + return; + } + _isConnecting = true; + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + _isAuthError = false; + _isNetworkError = false; + notifyListeners(); + try { + if (_meshCoreConnection != null) { + debugLog('[APP] Disposing previous MeshCoreConnection'); + _meshCoreConnection!.dispose(); + _meshCoreConnection = null; + } - // Create unified RX handler - await _createUnifiedRxHandler(); + await _apiQueueService.clearBeforeConnect(); - // Set regional channels from API response and update validator - final apiChannels = _apiService.channels; - await ChannelService.setRegionalChannels(apiChannels); - _regionalChannels = ChannelService.getRegionalChannelNames(); - debugLog('[APP] Regional channels configured: $_regionalChannels'); + _activeTransport = transport; + _setupTransportDisconnectListener(transport); - // Update unified RX handler's validator with new channel configuration - if (_unifiedRxHandler != null) { - final allowedChannelsData = - ChannelService.getAllowedChannelsForValidator(); - final allowedChannels = {}; - for (final entry in allowedChannelsData.entries) { - allowedChannels[entry.key] = ChannelInfo( - channelName: entry.value.channelName, - key: entry.value.key, - hash: entry.value.hash, - ); - } - final newValidator = PacketValidator( - allowedChannels: allowedChannels, - disableRssiFilter: _preferences.disableRssiFilter, - ); - _unifiedRxHandler!.updateValidator(newValidator); - debugLog( - '[APP] PacketValidator updated with ${allowedChannels.length} channels: ' - '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); - } + debugLog('[APP] Creating new MeshCoreConnection (generic transport)'); + _meshCoreConnection = MeshCoreConnection(transport: transport); - // Set flood scope from API response (regional TX filtering) - // "*" or "#*" = wildcard/global → no scope (unscoped flood, same as before) - // Any other value (e.g., "ottawa") → derive TransportKey and set scope - final apiScopes = _apiService.scopes; - final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; - final isWildcard = - firstScope == null || firstScope == '*' || firstScope == '#*'; - if (!isWildcard) { - final scopeName = firstScope; - _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; - final scopeKey = CryptoService.deriveScopeKey(scopeName); - debugLog('[CONN] Setting flood scope: $scopeName'); - await _meshCoreConnection!.setFloodScope(scopeKey); - debugLog('[CONN] Flood scope set successfully'); + if (!_preferences.offlineMode) { + _meshCoreConnection!.onRequestAuth = _createAuthCallback(); } else { - _scope = null; - debugLog('[CONN] No regional scope — using unscoped flood'); + _meshCoreConnection!.onRequestAuth = null; + debugLog('[APP] Offline mode: skipping API auth'); } - // Enforce hybrid mode if required by regional admin - if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { - _preferences = _preferences.copyWith(hybridModeEnabled: true); - debugLog('[CONN] Hybrid mode force-enabled by regional admin'); - } + _meshCoreConnection!.stepStream.listen((step) { + _connectionStep = step; + if (step == ConnectionStep.connected) { + _manufacturerString = + _meshCoreConnection!.deviceInfo?.manufacturer; + _firmwareVersionString = + _meshCoreConnection!.deviceInfo?.firmwareVersionString; + _deviceModel = _meshCoreConnection!.deviceModel; + _devicePublicKey = _meshCoreConnection!.devicePublicKey; + debugLog( + '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); - // Enforce discovery drop if required by regional admin - if (_apiService.enforceDiscDrop && !_preferences.discDropEnabled) { - _preferences = _preferences.copyWith(discDropEnabled: true); - debugLog('[CONN] Discovery drop force-enabled by regional admin'); - } + var lastDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); + if (lastDeviceName != null) { + lastDeviceName = lastDeviceName.replaceFirst('MeshCore-', ''); + } + if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { + lastDeviceName = + _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; + } + if (lastDeviceName != null && + lastDeviceName.isNotEmpty && + _devicePublicKey != null) { + _saveLastConnectedDevice(lastDeviceName, _devicePublicKey!); + } - // Sync Flood Traffic preference with regional policy: - // - flood_disabled=true → force OFF (region forbids) - // - flood_disabled=false → force ON (region permits, user lands ready) - // Fire a one-shot alert only on user-on → region-off transition. - final wasFloodEnabledByUser = _preferences.floodTrafficEnabled; - final shouldEnableFlood = !_apiService.floodDisabled; - if (_preferences.floodTrafficEnabled != shouldEnableFlood) { - _preferences = - _preferences.copyWith(floodTrafficEnabled: shouldEnableFlood); - debugLog(shouldEnableFlood - ? '[CONN] Flood traffic auto-enabled (region permits)' - : '[CONN] Flood traffic disabled by regional admin'); - } - if (wasFloodEnabledByUser && _apiService.floodDisabled) { - _floodDisabledAlertPending = true; + if (_preferences.offlineMode && _meshCoreConnection != null) { + _meshCoreConnection!.exportContact().then((uri) { + _offlineContactUri = uri; + debugLog('[OFFLINE] Stored contact URI for offline session'); + }).catchError((e) { + debugWarn('[OFFLINE] Failed to get contact URI: $e'); + }); + } + } + notifyListeners(); + }); + + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _recordNoiseFloorSample(noiseFloor); + if (noiseFloor != _currentNoiseFloor) { + _currentNoiseFloor = noiseFloor; + notifyListeners(); + } + }); + + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { + if (batteryPercent != _currentBatteryPercent) { + _currentBatteryPercent = batteryPercent; + notifyListeners(); + } + }); + + final connectionResult = await _meshCoreConnection!.connect( + _deviceModelService.models, + ); + + final device = DiscoveredDevice(id: deviceId, name: deviceName); + await _postConnectionSetup(connectionResult, device, + serialPortPath: serialPortPath); + _isConnecting = false; + } catch (e) { + await _handleConnectionError(e); + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); } + _activeTransport = null; + _transportConnectionSubscription?.cancel(); + } + } + + /// Post-connection setup shared by all transport types. + /// Called after MeshCoreConnection.connect() completes successfully. + Future _postConnectionSetup( + ({DeviceModel? deviceModel, bool deviceModelMatched}) connectionResult, + DiscoveredDevice device, { + String? tcpHost, + int? tcpPort, + String? serialPortPath, + }) async { + if (connectionResult.deviceModelMatched && + connectionResult.deviceModel != null) { + final matchedDevice = connectionResult.deviceModel!; + _preferences = _preferences.copyWith( + powerLevel: matchedDevice.power, + txPower: matchedDevice.txPower, + autoPowerSet: true, + powerLevelSet: false, + ); + notifyListeners(); + debugLog( + '[MODEL] Device recognized: ${matchedDevice.shortName} - reporting ${matchedDevice.power}W in API calls'); + } - // Enforce minimum auto-ping interval if required by regional admin - if (_preferences.autoPingInterval < _apiService.minModeInterval) { - _preferences = _preferences.copyWith( - autoPingInterval: _apiService.minModeInterval); - debugLog( - '[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); - } + await _createUnifiedRxHandler(); - // Configure multi-byte path hash mode on radio - await _configurePathHashMode(); + final apiChannels = _apiService.channels; + await ChannelService.setRegionalChannels(apiChannels); + _regionalChannels = ChannelService.getRegionalChannelNames(); + debugLog('[APP] Regional channels configured: $_regionalChannels'); - // Create ping service with wakelock (create new instance per connection) - _pingService = PingService( - gpsService: _gpsService, - connection: _meshCoreConnection!, - apiQueue: _apiQueueService, - wakelockService: WakelockService(), - cooldownTimer: _cooldownTimer, - manualPingCooldownTimer: _manualPingCooldownTimer, - rxWindowTimer: _rxWindowTimer, - discoveryWindowTimer: _discoveryWindowTimer, - deviceId: _deviceId, - txTracker: _txTracker, - audioService: _audioService, + if (_unifiedRxHandler != null) { + final allowedChannelsData = + ChannelService.getAllowedChannelsForValidator(); + final allowedChannels = {}; + for (final entry in allowedChannelsData.entries) { + allowedChannels[entry.key] = ChannelInfo( + channelName: entry.value.channelName, + key: entry.value.key, + hash: entry.value.hash, + ); + } + final newValidator = PacketValidator( + allowedChannels: allowedChannels, disableRssiFilter: _preferences.disableRssiFilter, - hopBytes: effectiveHopBytes, - traceHopBytes: _traceHopBytes, - shouldIgnoreRepeater: (String repeaterId) { - final prefs = _preferences; - if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - return PacketValidator.isCarpeaterIdMatch( - repeaterId, prefs.ignoreRepeaterId!); - } - return false; - }, ); + _unifiedRxHandler!.updateValidator(newValidator); + debugLog( + '[APP] PacketValidator updated with ${allowedChannels.length} channels: ' + '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); + } + + final apiScopes = _apiService.scopes; + final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; + final isWildcard = + firstScope == null || firstScope == '*' || firstScope == '#*'; + if (!isWildcard) { + final scopeName = firstScope; + _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; + final scopeKey = CryptoService.deriveScopeKey(scopeName); + debugLog('[CONN] Setting flood scope: $scopeName'); + await _meshCoreConnection!.setFloodScope(scopeKey); + debugLog('[CONN] Flood scope set successfully'); + } else { + _scope = null; + debugLog('[CONN] No regional scope — using unscoped flood'); + } - // Wire UnifiedRxHandler so trace payloads route to TraceTracker - _pingService!.unifiedRxHandler = _unifiedRxHandler; + if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { + _preferences = _preferences.copyWith(hybridModeEnabled: true); + debugLog('[CONN] Hybrid mode force-enabled by regional admin'); + } - // Set validation callbacks - _pingService!.checkExternalAntennaConfigured = () { - // External antenna must be explicitly set (yes or no) before pinging - return _preferences.externalAntennaSet; - }; + if (_apiService.enforceDiscDrop && !_preferences.discDropEnabled) { + _preferences = _preferences.copyWith(discDropEnabled: true); + debugLog('[CONN] Discovery drop force-enabled by regional admin'); + } - _pingService!.checkPowerLevelConfigured = () { - // Power is configured if: - // - Auto-detected from device model, OR - // - Manually selected by user, OR - // - Device model is known (has default power) - return _preferences.autoPowerSet || - _preferences.powerLevelSet || - _deviceModel != null; - }; + final wasFloodEnabledByUser = _preferences.floodTrafficEnabled; + final shouldEnableFlood = !_apiService.floodDisabled; + if (_preferences.floodTrafficEnabled != shouldEnableFlood) { + _preferences = + _preferences.copyWith(floodTrafficEnabled: shouldEnableFlood); + debugLog(shouldEnableFlood + ? '[CONN] Flood traffic auto-enabled (region permits)' + : '[CONN] Flood traffic disabled by regional admin'); + } + if (wasFloodEnabledByUser && _apiService.floodDisabled) { + _floodDisabledAlertPending = true; + } + + if (_preferences.autoPingInterval < _apiService.minModeInterval) { + _preferences = _preferences.copyWith( + autoPingInterval: _apiService.minModeInterval); + debugLog( + '[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); + } + + await _configurePathHashMode(); + + _pingService = PingService( + gpsService: _gpsService, + connection: _meshCoreConnection!, + apiQueue: _apiQueueService, + wakelockService: WakelockService(), + cooldownTimer: _cooldownTimer, + manualPingCooldownTimer: _manualPingCooldownTimer, + rxWindowTimer: _rxWindowTimer, + discoveryWindowTimer: _discoveryWindowTimer, + deviceId: _deviceId, + txTracker: _txTracker, + audioService: _audioService, + disableRssiFilter: _preferences.disableRssiFilter, + hopBytes: effectiveHopBytes, + traceHopBytes: _traceHopBytes, + shouldIgnoreRepeater: (String repeaterId) { + final prefs = _preferences; + if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { + return PacketValidator.isCarpeaterIdMatch( + repeaterId, prefs.ignoreRepeaterId!); + } + return false; + }, + ); - // Get external antenna value for API payloads - _pingService!.getExternalAntenna = () => _preferences.externalAntenna; + _pingService!.unifiedRxHandler = _unifiedRxHandler; - // Get power level from preferences (includes per-device overrides and manual selection) - _pingService!.getPowerLevel = () => _preferences.powerLevel; + _pingService!.checkExternalAntennaConfigured = () { + return _preferences.externalAntennaSet; + }; - // Check if TX is allowed by API (zone capacity) - _pingService!.checkTxAllowed = () => txAllowed; + _pingService!.checkPowerLevelConfigured = () { + return _preferences.autoPowerSet || + _preferences.powerLevelSet || + _deviceModel != null; + }; - // Check if discovery drop is enabled - _pingService!.getDiscDropEnabled = () => discDropEnabled; + _pingService!.getExternalAntenna = () => _preferences.externalAntenna; + _pingService!.getPowerLevel = () => _preferences.powerLevel; + _pingService!.checkTxAllowed = () => txAllowed; + _pingService!.getDiscDropEnabled = () => discDropEnabled; - _pingService!.onTxPing = (ping) { - _txPings.add(ping); - if (_txPings.length > _maxMapPins) _txPings.removeAt(0); + _pingService!.onTxPing = (ping) { + _txPings.add(ping); + if (_txPings.length > _maxMapPins) _txPings.removeAt(0); - // Add TX log entry (power in watts from preferences) - _txLogEntries.add(TxLogEntry( - timestamp: ping.timestamp, - latitude: ping.latitude, - longitude: ping.longitude, - power: _preferences.powerLevel, // Watts (0.3, 0.6, 1.0, 2.0) - events: [], // Will be updated when RX responses come in - )); - if (_txLogEntries.length > _maxLogEntries) _txLogEntries.removeAt(0); + _txLogEntries.add(TxLogEntry( + timestamp: ping.timestamp, + latitude: ping.latitude, + longitude: ping.longitude, + power: _preferences.powerLevel, + events: [], + )); + if (_txLogEntries.length > _maxLogEntries) _txLogEntries.removeAt(0); - notifyListeners(); - }; + notifyListeners(); + }; - _pingService!.onRxPing = (ping) { - _rxPings.add(ping); - if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); - - // Add RX log entry - _rxLogEntries.add(RxLogEntry( - timestamp: ping.timestamp, - repeaterId: ping.repeaterId, - snr: ping.snr, - rssi: ping.rssi, - pathLength: 0, // TODO: Extract from packet metadata - header: 0, // TODO: Extract from packet metadata - latitude: ping.latitude, - longitude: ping.longitude, - )); - if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); - - // Update RX overlay slot with this RX observation - _updateRxOverlaySlot(ping.repeaterId, ping.snr); + _pingService!.onRxPing = (ping) { + _rxPings.add(ping); + if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); + + _rxLogEntries.add(RxLogEntry( + timestamp: ping.timestamp, + repeaterId: ping.repeaterId, + snr: ping.snr, + rssi: ping.rssi, + pathLength: 0, + header: 0, + latitude: ping.latitude, + longitude: ping.longitude, + )); + if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); - notifyListeners(); - }; + _updateRxOverlaySlot(ping.repeaterId, ping.snr); + notifyListeners(); + }; + + _pingService!.onStatsUpdated = (stats) { + _pingStats = stats.copyWith( + rxCount: _pingStats.rxCount, + successfulUploads: _pingStats.successfulUploads, + ); + notifyListeners(); - _pingService!.onStatsUpdated = (stats) { - // Preserve rxCount and successfulUploads while updating TX-related stats from PingService - // PingService sends stats with rxCount=0 and successfulUploads=0 (it doesn't track these), - // so we must preserve the values that other handlers increment - _pingStats = stats.copyWith( + if (_autoPingEnabled) { + final modeName = _autoMode == AutoMode.passive + ? 'Passive Mode' + : _autoMode == AutoMode.hybrid + ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted + ? 'Trace Mode' + : 'Active Mode'; + BackgroundServiceManager.updateNotification( + mode: modeName, + txCount: _pingStats.txCount, rxCount: _pingStats.rxCount, - successfulUploads: _pingStats.successfulUploads, + queueSize: _queueSize, ); - notifyListeners(); + } + }; - // Update background service notification with current stats - if (_autoPingEnabled) { - final modeName = _autoMode == AutoMode.passive - ? 'Passive Mode' - : _autoMode == AutoMode.hybrid - ? 'Hybrid Mode' - : _autoMode == AutoMode.targeted - ? 'Trace Mode' - : 'Active Mode'; - BackgroundServiceManager.updateNotification( - mode: modeName, - txCount: _pingStats.txCount, - rxCount: _pingStats.rxCount, - queueSize: _queueSize, + _pingService!.onEchoReceived = (txPing, repeater, isNew) { + debugLog('[APP] ========== ECHO CALLBACK RECEIVED =========='); + debugLog( + '[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); + debugLog('[APP] TxLogEntries count: ${_txLogEntries.length}'); + + if (_txLogEntries.isNotEmpty) { + final lastEntry = _txLogEntries.last; + final timeDiff = + lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); + if (timeDiff <= 10) { + final existingEvents = List.from(lastEntry.events); + final newEvent = RxEvent( + repeaterId: repeater.repeaterId, + snr: repeater.snr, + rssi: repeater.rssi, ); - } - }; - - // Handle real-time echo updates - update TxLogEntry as echoes are received - _pingService!.onEchoReceived = (txPing, repeater, isNew) { - debugLog('[APP] ========== ECHO CALLBACK RECEIVED =========='); - debugLog( - '[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); - debugLog('[APP] TxLogEntries count: ${_txLogEntries.length}'); - - // Find the matching TxLogEntry and update its events - if (_txLogEntries.isNotEmpty) { - final lastEntry = _txLogEntries.last; - // Verify it's the right entry by timestamp (should be within a few seconds) - final timeDiff = - lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); - if (timeDiff <= 10) { - // Build updated events list - final existingEvents = List.from(lastEntry.events); - final newEvent = RxEvent( - repeaterId: repeater.repeaterId, - snr: repeater.snr, - rssi: repeater.rssi, - ); - - if (isNew) { - // Add new event - existingEvents.add(newEvent); - // Play receive sound for new repeater echo - _audioService.playReceiveSound(); - } else { - // Update existing event's SNR - final idx = existingEvents - .indexWhere((e) => e.repeaterId == repeater.repeaterId); - if (idx >= 0) { - existingEvents[idx] = newEvent; - } - } - // Replace the entry with updated events - final updatedEntry = TxLogEntry( - timestamp: lastEntry.timestamp, - latitude: lastEntry.latitude, - longitude: lastEntry.longitude, - power: lastEntry.power, - events: existingEvents, - ); - _txLogEntries[_txLogEntries.length - 1] = updatedEntry; - debugLog( - '[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); - - // Update top repeaters overlay with current TX echoes - _updateTopRepeaters( - existingEvents - .where((e) => e.snr != null) - .map((e) => - (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) - .toList(), - OverlayPingType.tx); - - debugLog('[APP] Calling notifyListeners() to update UI'); - notifyListeners(); - debugLog('[APP] notifyListeners() completed'); + if (isNew) { + existingEvents.add(newEvent); + _audioService.playReceiveSound(); } else { - debugLog( - '[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); - } - } else { - debugLog('[APP] WARNING: _txLogEntries is empty, cannot update'); - } - }; - - // Wire up ping progress callback for immediate UI refresh (e.g. "Sending..." on disc) - _pingService!.onPingProgressChanged = notifyListeners; - - // Wire up auto ping scheduled callback for countdown display - _pingService!.onAutoPingScheduled = (intervalMs, skipReason) { - _autoPingTimer.startWithSkipReason(intervalMs, skipReason); - - // Track idle time for auto-stop - if (skipReason != null) { - // Ping was skipped — check if idle too long - if (_preferences.autoStopAfterIdle && - _idleAutoStopReference != null) { - final elapsed = DateTime.now().difference(_idleAutoStopReference!); - if (elapsed >= _autoStopIdleTimeout) { - _triggerIdleAutoStop(); + final idx = existingEvents + .indexWhere((e) => e.repeaterId == repeater.repeaterId); + if (idx >= 0) { + existingEvents[idx] = newEvent; } } - } else { - // Successful ping — reset idle reference - _idleAutoStopReference = DateTime.now(); - } - }; - // Wire up discovery ping callback - fires immediately (like onTxPing) - _pingService!.onDiscPing = (entry) { - _addDiscLogEntry(entry); - }; + final updatedEntry = TxLogEntry( + timestamp: lastEntry.timestamp, + latitude: lastEntry.latitude, + longitude: lastEntry.longitude, + power: lastEntry.power, + events: existingEvents, + ); + _txLogEntries[_txLogEntries.length - 1] = updatedEntry; + debugLog( + '[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); - // Wire up real-time disc node discovery callback (like onEchoReceived) - _pingService!.onDiscNodeDiscovered = (discPing, nodeEntry, isNew) { - debugLog( - '[APP] Real-time disc node: ${nodeEntry.repeaterId}, isNew=$isNew'); - if (isNew) { - _audioService.playReceiveSound(); + _updateTopRepeaters( + existingEvents + .where((e) => e.snr != null) + .map((e) => + (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) + .toList(), + OverlayPingType.tx); + + debugLog('[APP] Calling notifyListeners() to update UI'); + notifyListeners(); + debugLog('[APP] notifyListeners() completed'); + } else { + debugLog( + '[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); } + } else { + debugLog('[APP] WARNING: _txLogEntries is empty, cannot update'); + } + }; - // Update top repeaters overlay with all discovered nodes from this ping - _updateTopRepeaters( - discPing.discoveredNodes - .map((n) => - (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) - .toList(), - OverlayPingType.disc); + _pingService!.onPingProgressChanged = notifyListeners; - notifyListeners(); - }; + _pingService!.onAutoPingScheduled = (intervalMs, skipReason) { + _autoPingTimer.startWithSkipReason(intervalMs, skipReason); - // Wire up TX window complete callback for noise floor graph - _pingService!.onTxWindowComplete = (success) { - // Get location and repeater info from the last TX log entry - double? lat; - double? lon; - List? repeaters; - - if (_txLogEntries.isNotEmpty) { - final lastTx = _txLogEntries.last; - lat = lastTx.latitude; - lon = lastTx.longitude; - if (lastTx.events.isNotEmpty) { - repeaters = lastTx.events - .map((e) => MarkerRepeaterInfo( - repeaterId: e.repeaterId, - snr: e.snr ?? 0.0, - rssi: e.rssi ?? 0, - )) - .toList(); + if (skipReason != null) { + if (_preferences.autoStopAfterIdle && + _idleAutoStopReference != null) { + final elapsed = + DateTime.now().difference(_idleAutoStopReference!); + if (elapsed >= _autoStopIdleTimeout) { + _triggerIdleAutoStop(); } } + } else { + _idleAutoStopReference = DateTime.now(); + } + }; - recordPingEvent( - success ? PingEventType.txSuccess : PingEventType.txFail, - latitude: lat, - longitude: lon, - repeaters: repeaters, - ); - }; - - // Wire up discovery window complete callback for noise floor graph - _pingService!.onDiscoveryWindowComplete = (success) { - // Get location and node info from the most recent discovery log entry - // Note: _discLogEntries uses insert(0,...) so .first is newest - double? lat; - double? lon; - List? repeaters; - - if (_discLogEntries.isNotEmpty) { - final lastDisc = _discLogEntries.first; - lat = lastDisc.latitude; - lon = lastDisc.longitude; - if (lastDisc.discoveredNodes.isNotEmpty) { - repeaters = lastDisc.discoveredNodes - .map((n) => MarkerRepeaterInfo( - repeaterId: n.repeaterId, - snr: n.localSnr, - rssi: n.localRssi, - pubkeyHex: n.pubkeyHex, - )) - .toList(); - } - } + _pingService!.onDiscPing = (entry) { + _addDiscLogEntry(entry); + }; - PingEventType eventType; - if (success) { - eventType = PingEventType.discSuccess; - } else if (discDropEnabled) { - eventType = PingEventType.txFail; - } else { - eventType = PingEventType.discFail; - } + _pingService!.onDiscNodeDiscovered = (discPing, nodeEntry, isNew) { + debugLog( + '[APP] Real-time disc node: ${nodeEntry.repeaterId}, isNew=$isNew'); + if (isNew) { + _audioService.playReceiveSound(); + } - recordPingEvent( - eventType, - latitude: lat, - longitude: lon, - repeaters: repeaters, - ); - }; + _updateTopRepeaters( + discPing.discoveredNodes + .map((n) => + (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) + .toList(), + OverlayPingType.disc); - // Wire up trace ping callback (for log entry creation) - _pingService!.onTracePing = (entry) { - _addTraceLogEntry(entry); - }; + notifyListeners(); + }; - // Wire up trace window complete callback for noise floor graph - _pingService!.onTraceWindowComplete = (result) { - double? lat; - double? lon; - List? repeaters; - - if (_traceLogEntries.isNotEmpty) { - final lastTrace = _traceLogEntries.first; - lat = lastTrace.latitude; - lon = lastTrace.longitude; - if (result != null && result.success) { - repeaters = [ - MarkerRepeaterInfo( - repeaterId: result.targetRepeaterId, - snr: result.localSnr, - rssi: result.localRssi, - ) - ]; - // Update the log entry with success data - _traceLogEntries[0] = TraceLogEntry( - timestamp: lastTrace.timestamp, - latitude: lastTrace.latitude, - longitude: lastTrace.longitude, - targetRepeaterId: lastTrace.targetRepeaterId, - noiseFloor: lastTrace.noiseFloor, - localSnr: result.localSnr, - remoteSnr: result.remoteSnr, - localRssi: result.localRssi, - success: true, - ); - notifyListeners(); - } + _pingService!.onTxWindowComplete = (success) { + double? lat; + double? lon; + List? repeaters; + + if (_txLogEntries.isNotEmpty) { + final lastTx = _txLogEntries.last; + lat = lastTx.latitude; + lon = lastTx.longitude; + if (lastTx.events.isNotEmpty) { + repeaters = lastTx.events + .map((e) => MarkerRepeaterInfo( + repeaterId: e.repeaterId, + snr: e.snr ?? 0.0, + rssi: e.rssi ?? 0, + )) + .toList(); } + } - recordPingEvent( - result != null && result.success - ? PingEventType.traceSuccess - : PingEventType.traceFail, - latitude: lat, - longitude: lon, - repeaters: repeaters, - ); - }; - - // Wire up discovery carpeater drop callback (for DiscTracker RSSI failsafe) - _pingService!.onDiscCarpeaterDrop = (String repeaterId, String reason) { - debugLog( - '[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); - logError('Discovery Dropped\nPossible carpeater: $repeaterId\n$reason', - severity: ErrorSeverity.warning, autoSwitch: false); - }; + recordPingEvent( + success ? PingEventType.txSuccess : PingEventType.txFail, + latitude: lat, + longitude: lon, + repeaters: repeaters, + ); + }; - // Wire up pending disable complete callback - // Called when user disables Active Mode during sending/listening and the RX window ends - _pingService!.onPendingDisableComplete = () async { - debugLog('[APP] Pending disable completed, cleaning up'); + _pingService!.onDiscoveryWindowComplete = (success) { + double? lat; + double? lon; + List? repeaters; + + if (_discLogEntries.isNotEmpty) { + final lastDisc = _discLogEntries.first; + lat = lastDisc.latitude; + lon = lastDisc.longitude; + if (lastDisc.discoveredNodes.isNotEmpty) { + repeaters = lastDisc.discoveredNodes + .map((n) => MarkerRepeaterInfo( + repeaterId: n.repeaterId, + snr: n.localSnr, + rssi: n.localRssi, + pubkeyHex: n.pubkeyHex, + )) + .toList(); + } + } - // Stop TX echo tracking - _pingService!.stopEchoTracking(); - // Stop RX wardriving (flushes batches) - _rxLogger?.stopWardriving(trigger: 'pending_disable'); + PingEventType eventType; + if (success) { + eventType = PingEventType.discSuccess; + } else if (discDropEnabled) { + eventType = PingEventType.txFail; + } else { + eventType = PingEventType.discFail; + } - // Stop background service - await BackgroundServiceManager.stopService(); + recordPingEvent( + eventType, + latitude: lat, + longitude: lon, + repeaters: repeaters, + ); + }; - // Stop countdown timers - _autoPingTimer.stop(); - _rxWindowTimer.stop(); + _pingService!.onTracePing = (entry) { + _addTraceLogEntry(entry); + }; - // Save offline session if offline mode is enabled - if (_preferences.offlineMode) { - await _saveOfflineSession(); + _pingService!.onTraceWindowComplete = (result) { + double? lat; + double? lon; + List? repeaters; + + if (_traceLogEntries.isNotEmpty) { + final lastTrace = _traceLogEntries.first; + lat = lastTrace.latitude; + lon = lastTrace.longitude; + if (result != null && result.success) { + repeaters = [ + MarkerRepeaterInfo( + repeaterId: result.targetRepeaterId, + snr: result.localSnr, + rssi: result.localRssi, + ) + ]; + _traceLogEntries[0] = TraceLogEntry( + timestamp: lastTrace.timestamp, + latitude: lastTrace.latitude, + longitude: lastTrace.longitude, + targetRepeaterId: lastTrace.targetRepeaterId, + noiseFloor: lastTrace.noiseFloor, + localSnr: result.localSnr, + remoteSnr: result.remoteSnr, + localRssi: result.localRssi, + success: true, + ); + notifyListeners(); } + } - // End noise floor session - await _endNoiseFloorSession(); + recordPingEvent( + result != null && result.success + ? PingEventType.traceSuccess + : PingEventType.traceFail, + latitude: lat, + longitude: lon, + repeaters: repeaters, + ); + }; - // Disable heartbeat - _apiService.disableHeartbeat(); + _pingService!.onDiscCarpeaterDrop = (String repeaterId, String reason) { + debugLog( + '[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); + logError( + 'Discovery Dropped\nPossible carpeater: $repeaterId\n$reason', + severity: ErrorSeverity.warning, autoSwitch: false); + }; - // Update local state - _autoPingEnabled = false; - _idleAutoStopReference = null; + _pingService!.onPendingDisableComplete = () async { + debugLog('[APP] Pending disable completed, cleaning up'); - debugLog('[APP] Pending disable cleanup complete, cooldown running'); - notifyListeners(); - }; + _pingService!.stopEchoTracking(); + _rxLogger?.stopWardriving(trigger: 'pending_disable'); - // Save this device for quick reconnection (mobile only) - await _saveRememberedDevice(device); - - // Update display name from SelfInfo (reflects user's chosen name) - // BLE advertisement name may be cached/stale after device rename - final selfInfoName = _meshCoreConnection?.selfInfo?.name; - if (selfInfoName != null && selfInfoName.isNotEmpty) { - // Keep "Anonymous" display name if anonymous mode is active - _displayDeviceName = _isAnonymousRenamed ? 'Anonymous' : selfInfoName; - debugLog('[APP] Display name set: "$_displayDeviceName"'); - - // Update remembered device with real name (not "Anonymous") - // BLE advertisement name may be stale after device rename - String? realName; - if (_isAnonymousRenamed) { - realName = _originalDeviceName ?? selfInfoName; - } else if (selfInfoName == 'Anonymous' && _devicePublicKey != null) { - realName = _deviceRealNames[_devicePublicKey!] ?? selfInfoName; - } else { - realName = selfInfoName; - } - if (_rememberedDevice != null && _rememberedDevice!.id == device.id) { - final updatedName = 'MeshCore-$realName'; - if (_rememberedDevice!.name != updatedName) { - await _saveRememberedDevice( - DiscoveredDevice(id: device.id, name: updatedName)); - debugLog( - '[APP] Updated remembered device name from SelfInfo: $updatedName'); - } - } - } + await BackgroundServiceManager.stopService(); - // Restore per-device antenna preference if previously saved - // Use original name for keying, not "Anonymous" - final resolvedName = - _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; - if (resolvedName != null && - _deviceAntennaPreferences.containsKey(resolvedName)) { - final savedAntenna = _deviceAntennaPreferences[resolvedName]!; - _preferences = _preferences.copyWith( - externalAntenna: savedAntenna, - externalAntennaSet: true, - ); - _antennaRestoredFromDevice = true; - _savePreferences(); - debugLog( - '[APP] Restored antenna preference for "$resolvedName": ${savedAntenna ? "external" : "device"}'); - notifyListeners(); - } + _autoPingTimer.stop(); + _rxWindowTimer.stop(); - // Restore per-device power override if previously saved - if (resolvedName != null && - _devicePowerOverrides.containsKey(resolvedName)) { - final saved = _devicePowerOverrides[resolvedName]!; - _preferences = _preferences.copyWith( - powerLevel: (saved['powerLevel'] as num).toDouble(), - txPower: (saved['txPower'] as num).toInt(), - autoPowerSet: false, - powerLevelSet: true, - ); - _powerRestoredFromDevice = true; - _savePreferences(); - debugLog( - '[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); - notifyListeners(); + if (_preferences.offlineMode) { + await _saveOfflineSession(); } - // Log connection status based on TX/RX permissions - if (hasApiSession) { - if (txAllowed && rxAllowed) { - debugLog('[CONN] Connected with full access (TX + RX allowed)'); - } else if (rxAllowed) { - debugLog( - '[CONN] Connected with RX-only access (TX not allowed, zone at TX capacity)'); - } else { - debugLog('[CONN] Connected with limited access'); - } + await _endNoiseFloorSession(); + _apiService.disableHeartbeat(); - // Track session zone for zone-to-zone transfer detection - _sessionZoneCode = zoneCode; + _autoPingEnabled = false; + _idleAutoStopReference = null; - // Start periodic zone refresh to keep slot counts current - if (!_preferences.offlineMode) { - _startZoneRefreshTimer(); - } + debugLog('[APP] Pending disable cleanup complete, cooldown running'); + notifyListeners(); + }; - // Enable heartbeat immediately on connection to keep server session alive - // Previously only enabled on auto-ping start, causing silent session expiry - if (!_preferences.offlineMode && _apiService.hasSession) { - _apiService.enableHeartbeat( - gpsProvider: () { - final pos = _gpsService.lastPosition; - if (pos == null) return null; - return (lat: pos.latitude, lon: pos.longitude); - }, - ); - debugLog('[HEARTBEAT] Enabled on connection'); + await _saveRememberedDevice(device, + transportType: _selectedTransport, + tcpHost: tcpHost, + tcpPort: tcpPort, + serialPortPath: serialPortPath); + + final selfInfoName = _meshCoreConnection?.selfInfo?.name; + if (selfInfoName != null && selfInfoName.isNotEmpty) { + _displayDeviceName = _isAnonymousRenamed ? 'Anonymous' : selfInfoName; + debugLog('[APP] Display name set: "$_displayDeviceName"'); + + String? realName; + if (_isAnonymousRenamed) { + realName = _originalDeviceName ?? selfInfoName; + } else if (selfInfoName == 'Anonymous' && _devicePublicKey != null) { + realName = _deviceRealNames[_devicePublicKey!] ?? selfInfoName; + } else { + realName = selfInfoName; + } + if (_rememberedDevice != null && _rememberedDevice!.id == device.id) { + final updatedName = 'MeshCore-$realName'; + if (_rememberedDevice!.name != updatedName) { + await _saveRememberedDevice( + DiscoveredDevice(id: device.id, name: updatedName), + transportType: _selectedTransport, + tcpHost: tcpHost, + tcpPort: tcpPort, + serialPortPath: serialPortPath); + debugLog( + '[APP] Updated remembered device name from SelfInfo: $updatedName'); } + } + } - // Start 15-minute idle disconnect timer (cancelled by manual ping or auto-ping start) - _startIdleDisconnectTimer(); + final resolvedName = + _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; + if (resolvedName != null && + _deviceAntennaPreferences.containsKey(resolvedName)) { + final savedAntenna = _deviceAntennaPreferences[resolvedName]!; + _preferences = _preferences.copyWith( + externalAntenna: savedAntenna, + externalAntennaSet: true, + ); + _antennaRestoredFromDevice = true; + _savePreferences(); + debugLog( + '[APP] Restored antenna preference for "$resolvedName": ${savedAntenna ? "external" : "device"}'); + notifyListeners(); + } + + if (resolvedName != null && + _devicePowerOverrides.containsKey(resolvedName)) { + final saved = _devicePowerOverrides[resolvedName]!; + _preferences = _preferences.copyWith( + powerLevel: (saved['powerLevel'] as num).toDouble(), + txPower: (saved['txPower'] as num).toInt(), + autoPowerSet: false, + powerLevelSet: true, + ); + _powerRestoredFromDevice = true; + _savePreferences(); + debugLog( + '[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); + notifyListeners(); + } + + if (hasApiSession) { + if (txAllowed && rxAllowed) { + debugLog('[CONN] Connected with full access (TX + RX allowed)'); + } else if (rxAllowed) { + debugLog( + '[CONN] Connected with RX-only access (TX not allowed, zone at TX capacity)'); } else { - // No API session - offline mode or auth skipped - debugLog('[CONN] Connected without API session (offline mode)'); + debugLog('[CONN] Connected with limited access'); } - // Log ping validation status after connection - final validation = pingValidation; - if (validation != PingValidation.valid) { - debugLog('[CONN] Ping validation after connect: $validation'); - } - } catch (e) { - debugError('[APP] Connection failed: $e'); + _sessionZoneCode = zoneCode; - // Ensure channel is cleaned up if it was created during connection - // Must happen BEFORE BLE disconnect while connection is still alive - try { - await _meshCoreConnection?.deleteWardrivingChannelEarly(); - } catch (channelError) { - debugError('[APP] Cleanup channel delete failed: $channelError'); + if (!_preferences.offlineMode) { + _startZoneRefreshTimer(); } - // Ensure BLE is disconnected on any connection failure - // (connection.dart should have done this, but be defensive) - try { - if (_meshCoreConnection != null) { - await _meshCoreConnection!.disconnect(); - } - } catch (disconnectError) { - debugError('[APP] Cleanup disconnect failed: $disconnectError'); - } - - // Parse auth failure errors for clean display - final errorStr = e.toString(); - if (errorStr.contains('AUTH_FAILED:')) { - // Format: "Exception: AUTH_FAILED:reason:message" - _isAuthError = true; - final parts = errorStr.split('AUTH_FAILED:'); - if (parts.length > 1) { - final errorParts = parts[1].split(':'); - final reason = errorParts.isNotEmpty ? errorParts[0] : 'unknown'; - final serverMessage = - errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; - _isNetworkError = reason == 'network_error'; - _connectionError = _getErrorMessage(reason, serverMessage); - } else { - _connectionError = 'Authentication failed'; - } - } else { - _isAuthError = false; - _isNetworkError = false; - // Provide clean user-facing messages for common BLE errors - if (errorStr.contains('timeout') || - errorStr.contains('Timeout') || - errorStr.contains('timed out')) { - _connectionError = 'Bluetooth connection scan timed out'; - } else { - _connectionError = errorStr.replaceFirst('Exception: ', ''); - } + if (!_preferences.offlineMode && _apiService.hasSession) { + _apiService.enableHeartbeat( + gpsProvider: () { + final pos = _gpsService.lastPosition; + if (pos == null) return null; + return (lat: pos.latitude, lon: pos.longitude); + }, + ); + debugLog('[HEARTBEAT] Enabled on connection'); } - _connectionStep = ConnectionStep.error; - notifyListeners(); + + _startIdleDisconnectTimer(); + } else { + debugLog('[CONN] Connected without API session (offline mode)'); + } + + final validation = pingValidation; + if (validation != PingValidation.valid) { + debugLog('[CONN] Ping validation after connect: $validation'); } } @@ -2589,6 +2907,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } _cancelPendingAutoPingRestore(); + _isConnecting = false; _connectionStep = ConnectionStep.disconnected; // Cancel any active zone grace period @@ -2597,9 +2916,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _zoneGraceSecondsRemaining = 0; _autoPingWasEnabledBeforeGrace = false; - // Stop heartbeat immediately on BLE disconnect _apiService.disableHeartbeat(); - debugLog('[CONN] Heartbeat disabled due to BLE disconnect'); + debugLog('[CONN] Heartbeat disabled due to disconnect'); // Stop zone refresh timer _stopZoneRefreshTimer(); @@ -2614,7 +2932,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } _autoPingEnabled = false; _idleAutoStopReference = null; - debugLog('[AUTO] Auto-ping disabled due to BLE disconnect'); + debugLog('[AUTO] Auto-ping disabled due to disconnect'); } // End noise floor session on BLE disconnect @@ -2638,7 +2956,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Release API session (best effort - don't block on failure) if (_devicePublicKey != null && _apiService.hasSession) { - debugLog('[CONN] Releasing API session due to BLE disconnect'); + debugLog('[CONN] Releasing API session due to disconnect'); try { await _apiService.requestAuth( reason: 'disconnect', @@ -2650,21 +2968,26 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } - // Reset anonymous mode state (BLE already gone, can't restore name) _isAnonymousRenamed = false; _originalDeviceName = null; - // Clear top-heard overlay _clearOverlayState(); - // Existing cleanup _meshCoreConnection?.dispose(); _meshCoreConnection = null; _pingService?.dispose(); _pingService = null; + + // Clean up non-BLE transport + _transportConnectionSubscription?.cancel(); + _transportConnectionSubscription = null; + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); + } + _activeTransport = null; } - /// Start auto-reconnect after unexpected BLE disconnect + /// Start auto-reconnect after unexpected transport disconnect Future _startAutoReconnect() async { // Defensive: cancel zone grace period if active if (_isInZoneGracePeriod) { @@ -2830,8 +3153,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } - /// Detect iOS apple-code 14/15 bond errors and clear the stale bond before retry + /// Detect iOS apple-code 14/15 bond errors and clear the stale bond before retry. + /// Only applies to BLE transports. Future _handleBondErrorIfNeeded(Object error) async { + if (_selectedTransport != TransportType.ble) return; final errorStr = error.toString(); if (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || @@ -2942,6 +3267,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Mark as user-requested so BLE disconnect listener doesn't trigger auto-reconnect _userRequestedDisconnect = true; + // Immediate UI feedback + _connectionStep = ConnectionStep.disconnecting; + notifyListeners(); + // Cancel idle disconnect timer _cancelIdleDisconnectTimer(); @@ -3056,7 +3385,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _txTracker = null; // TxTracker is disposed by UnifiedRxHandler _rxLogger = null; // RxLogger is disposed by UnifiedRxHandler - // Disconnect BLE (don't call disconnect() twice - meshCoreConnection.disconnect() already does it) await _meshCoreConnection?.disconnect(); // Cancel stream subscriptions @@ -3065,6 +3393,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _batterySubscription?.cancel(); _batterySubscription = null; + // Clean up non-BLE transport (TCP/USB instances are owned by us, not shared) + _transportConnectionSubscription?.cancel(); + _transportConnectionSubscription = null; + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); + } + _activeTransport = null; + _meshCoreConnection?.dispose(); _meshCoreConnection = null; _pingService?.dispose(); @@ -6054,8 +6390,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Save device for quick reconnection - Future _saveRememberedDevice(DiscoveredDevice device) async { - // Skip on web - Web Bluetooth requires user interaction for each connection + Future _saveRememberedDevice( + DiscoveredDevice device, { + TransportType transportType = TransportType.ble, + String? tcpHost, + int? tcpPort, + String? serialPortPath, + }) async { if (kIsWeb) return; final box = await _openBoxSafely(_rememberedDeviceBoxName); @@ -6066,34 +6407,51 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { id: device.id, name: device.name, lastConnected: DateTime.now(), + transportType: transportType, + tcpHost: tcpHost, + tcpPort: tcpPort, + serialPortPath: serialPortPath, ); await box.put('device', remembered.toJson()); _rememberedDevice = remembered; - debugLog('[APP] Saved remembered device: ${device.name}'); + debugLog( + '[APP] Saved remembered device: ${device.name} (${transportType.name})'); notifyListeners(); } catch (e) { debugLog('[APP] Failed to save remembered device: $e'); } } - /// Reconnect to remembered device without scanning + /// Reconnect to remembered device without scanning. + /// Routes to the correct transport based on the remembered device's type. Future reconnectToRememberedDevice() async { if (_rememberedDevice == null) return; - if (kIsWeb) return; // Not supported on web - - final device = DiscoveredDevice( - id: _rememberedDevice!.id, - name: _rememberedDevice!.name, - ); - - // Pre-populate the BLE scan cache with remembered device info - // This ensures the device name is available during connect() - // (normally populated by scanning, but we're skipping the scan) - _bluetoothService.cacheDeviceInfo(device); + if (kIsWeb) return; - await connectToDevice(device); + switch (_rememberedDevice!.transportType) { + case TransportType.ble: + final device = DiscoveredDevice( + id: _rememberedDevice!.id, + name: _rememberedDevice!.name, + ); + _bluetoothService.cacheDeviceInfo(device); + await connectToDevice(device); + break; + case TransportType.tcp: + final host = _rememberedDevice!.tcpHost; + final port = _rememberedDevice!.tcpPort; + if (host != null && port != null) { + await connectViaTcp(host, port); + } else { + debugError('[APP] Cannot reconnect via TCP: missing host/port'); + } + break; + case TransportType.usbSerial: + debugLog('[APP] USB Serial reconnect requires user to select device'); + break; + } } /// Clear remembered device diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 5e6b9a7..1472132 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -13,6 +13,9 @@ import '../models/user_preferences.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; import '../services/bluetooth/bluetooth_service.dart'; +import '../services/transport/android_serial_service.dart'; +import '../services/transport/tcp_service.dart'; +import '../services/transport/web_serial_factory.dart'; import '../widgets/offline_mode_toggle.dart'; import '../widgets/regional_config_card.dart'; @@ -26,14 +29,23 @@ class ConnectionScreen extends StatefulWidget { class _ConnectionScreenState extends State with WidgetsBindingObserver { + final _tcpHostController = TextEditingController(); + final _tcpPortController = TextEditingController(text: '5000'); + bool _tcpConnecting = false; + Future>>? _usbDevicesFuture; + Future>? _savedTcpFuture; + @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + _savedTcpFuture = TcpService.getSavedConnections(); } @override void dispose() { + _tcpHostController.dispose(); + _tcpPortController.dispose(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -77,13 +89,17 @@ class _ConnectionScreenState extends State Widget build(BuildContext context) { final appState = context.watch(); - return Scaffold( - appBar: AppBar( - toolbarHeight: 40, - title: const Text('Connection', style: TextStyle(fontSize: 18)), - automaticallyImplyLeading: false, + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( + appBar: AppBar( + toolbarHeight: 40, + title: const Text('Connection', style: TextStyle(fontSize: 18)), + automaticallyImplyLeading: false, + ), + body: _buildBody(context, appState), ), - body: _buildBody(context, appState), ); } @@ -94,6 +110,7 @@ class _ConnectionScreenState extends State } if (appState.connectionStep != ConnectionStep.disconnected && appState.connectionStep != ConnectionStep.connected && + appState.connectionStep != ConnectionStep.disconnecting && appState.connectionStep != ConnectionStep.error) { return _buildConnectionProgress(context, appState); } @@ -120,6 +137,20 @@ class _ConnectionScreenState extends State return _buildZoneGraceView(context, appState); } + // Show disconnecting state + if (appState.connectionStep == ConnectionStep.disconnecting) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Disconnecting...'), + ], + ), + ); + } + // Show connected state if (appState.isConnected) { final pathWarning = appState.pendingPathHashWarning; @@ -143,26 +174,43 @@ class _ConnectionScreenState extends State return _buildDeviceList(context, appState); } - /// Persistent bottom action bar: offline toggle + scan/cancel/disconnect + /// Persistent bottom action bar: transport picker + offline toggle + action button Widget _buildBottomBar(BuildContext context, AppStateProvider appState) { return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Row( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - const Expanded(child: OfflineModeToggle()), - const SizedBox(width: 12), - Expanded(child: _buildActionButton(context, appState)), + Row( + children: [ + const Expanded(child: OfflineModeToggle()), + const SizedBox(width: 12), + Expanded(child: _buildActionButton(context, appState)), + ], + ), + if (!appState.isConnected) ...[ + const SizedBox(height: 8), + _buildTransportPicker(context, appState), + ], ], ), ); } - /// The right-side action button: Scan, Cancel, or Disconnect + /// The right-side action button: Scan/Connect, Cancel, or Disconnect Widget _buildActionButton(BuildContext context, AppStateProvider appState) { + if (appState.connectionStep == ConnectionStep.disconnecting) { + return _buildBottomButton( + icon: Icons.hourglass_top, + label: 'Disconnecting', + color: Colors.grey, + onPressed: null, + ); + } + if (appState.isConnected) { - // Disconnect return _buildBottomButton( - icon: Icons.bluetooth_disabled, + icon: Icons.link_off, label: 'Disconnect', color: Colors.red, onPressed: () async => await appState.disconnect(), @@ -170,7 +218,6 @@ class _ConnectionScreenState extends State } if (appState.isScanning) { - // Cancel scan return _buildBottomButton( icon: Icons.close, label: 'Cancel', @@ -179,19 +226,54 @@ class _ConnectionScreenState extends State ); } - // Scan — disabled when can't scan - final canScan = appState.connectionStep == ConnectionStep.disconnected && - !appState.isAutoReconnecting && - (!appState.maintenanceMode || appState.offlineMode) && - !appState.isBluetoothOff && - (appState.offlineMode || appState.inZone == true); - - return _buildBottomButton( - icon: Icons.bluetooth_searching, - label: 'Scan', - color: Theme.of(context).colorScheme.primary, - onPressed: canScan ? () => appState.startScan() : null, - ); + final canConnect = + appState.connectionStep == ConnectionStep.disconnected && + !appState.isAutoReconnecting && + (!appState.maintenanceMode || appState.offlineMode) && + (appState.offlineMode || appState.inZone == true); + + switch (appState.selectedTransport) { + case TransportType.ble: + return _buildBottomButton( + icon: Icons.bluetooth_searching, + label: 'Scan', + color: Theme.of(context).colorScheme.primary, + onPressed: canConnect && !appState.isBluetoothOff + ? () => appState.startScan() + : null, + ); + case TransportType.tcp: + final hostFilled = _tcpHostController.text.trim().isNotEmpty; + return _buildBottomButton( + icon: Icons.lan, + label: _tcpConnecting ? 'Connecting' : 'Connect', + color: Theme.of(context).colorScheme.primary, + onPressed: canConnect && hostFilled && !_tcpConnecting + ? () => _connectTcp(appState) + : null, + ); + case TransportType.usbSerial: + if (kIsWeb) { + return _buildBottomButton( + icon: Icons.usb, + label: 'Connect', + color: Theme.of(context).colorScheme.primary, + onPressed: canConnect + ? () => _connectWebSerial(appState) + : null, + ); + } + return _buildBottomButton( + icon: Icons.refresh, + label: 'Refresh', + color: Theme.of(context).colorScheme.primary, + onPressed: canConnect + ? () => setState(() { + _usbDevicesFuture = AndroidSerialService.getAvailablePorts(); + }) + : null, + ); + } } /// Styled button matching OfflineModeToggle shape @@ -1569,8 +1651,9 @@ class _ConnectionScreenState extends State ); } - // Show Bluetooth off message (takes priority over zone checks) - if (appState.isBluetoothOff) { + // Show Bluetooth off message (BLE transport only) + if (appState.selectedTransport == TransportType.ble && + appState.isBluetoothOff) { return _buildMessageContent( context: context, icon: Icons.bluetooth_disabled, @@ -1743,6 +1826,106 @@ class _ConnectionScreenState extends State ); } + return _buildTransportContent(context, appState, canConnect); + } + + /// Transport selector buttons styled like the bottom bar. + Widget _buildTransportPicker( + BuildContext context, AppStateProvider appState) { + final available = _availableTransports(); + if (available.length <= 1) return const SizedBox.shrink(); + + final primary = Theme.of(context).colorScheme.primary; + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: primary.withValues(alpha: 0.3)), + ), + clipBehavior: Clip.antiAlias, + child: Row( + children: [ + for (final transport in available) + Expanded( + child: _buildTransportSegment( + context, appState, transport, + isSelected: appState.selectedTransport == transport, + ), + ), + ], + ), + ); + } + + Widget _buildTransportSegment( + BuildContext context, AppStateProvider appState, TransportType type, + {required bool isSelected}) { + const meta = { + TransportType.ble: (Icons.bluetooth, 'BLE'), + TransportType.tcp: (Icons.lan, 'TCP'), + TransportType.usbSerial: (Icons.usb, 'USB'), + }; + final (icon, label) = meta[type]!; + final primary = Theme.of(context).colorScheme.primary; + final color = isSelected ? primary : Colors.grey; + + return Material( + color: isSelected ? primary.withValues(alpha: 0.2) : Colors.transparent, + child: InkWell( + onTap: () => appState.setSelectedTransport(type), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ), + ), + ); + } + + /// Available transports based on platform. + /// BLE: all platforms. TCP: mobile only. USB: Android and Web. + List _availableTransports() { + final transports = [TransportType.ble]; + if (!kIsWeb) { + transports.add(TransportType.tcp); + if (Platform.isAndroid) { + transports.add(TransportType.usbSerial); + } + } else { + transports.add(TransportType.usbSerial); + } + return transports; + } + + /// Routes to the correct transport-specific content. + Widget _buildTransportContent( + BuildContext context, AppStateProvider appState, bool canConnect) { + switch (appState.selectedTransport) { + case TransportType.ble: + return _buildBleContent(context, appState, canConnect); + case TransportType.tcp: + return _buildTcpContent(context, appState, canConnect); + case TransportType.usbSerial: + return _buildUsbContent(context, appState, canConnect); + } + } + + /// BLE transport content — scanning, device list, remembered device. + Widget _buildBleContent( + BuildContext context, AppStateProvider appState, bool canConnect) { if (appState.isScanning) { return Column( children: [ @@ -1755,18 +1938,16 @@ class _ConnectionScreenState extends State } if (appState.discoveredDevices.isEmpty) { - // Show remembered device option if available (mobile only) final remembered = appState.rememberedDevice; - if (!kIsWeb && remembered != null) { + if (!kIsWeb && + remembered != null && + remembered.transportType == TransportType.ble) { return _buildRememberedDeviceView(context, appState, remembered, canConnect: canConnect); } - // Show GPS disabled message when location services are off if (appState.gpsStatus == GpsStatus.disabled) { - // iOS doesn't allow opening Location Services directly, so no button on iOS final isIOS = !kIsWeb && Platform.isIOS; - return _buildMessageContent( context: context, icon: Icons.gps_off, @@ -1784,7 +1965,6 @@ class _ConnectionScreenState extends State ); } - // Show GPS permission required message when permissions are denied if (appState.gpsStatus == GpsStatus.permissionDenied) { return _buildMessageContent( context: context, @@ -1813,6 +1993,259 @@ class _ConnectionScreenState extends State return _buildDeviceListView(context, appState, canConnect: canConnect); } + /// TCP transport content — host/port entry + saved connections. + Widget _buildTcpContent( + BuildContext context, AppStateProvider appState, bool canConnect) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Host/port input + TextField( + controller: _tcpHostController, + decoration: const InputDecoration( + labelText: 'Host', + hintText: 'IP address or hostname', + prefixIcon: Icon(Icons.dns_outlined), + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.url, + textInputAction: TextInputAction.next, + enabled: !_tcpConnecting, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + TextField( + controller: _tcpPortController, + decoration: const InputDecoration( + labelText: 'Port', + hintText: '5000', + prefixIcon: Icon(Icons.numbers), + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + enabled: !_tcpConnecting, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + // Saved connections + FutureBuilder>( + future: _savedTcpFuture ??= TcpService.getSavedConnections(), + builder: (context, snapshot) { + final saved = snapshot.data; + if (saved == null || saved.isEmpty) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Saved Connections', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + ...saved.map((conn) => _buildSavedTcpTile( + context, appState, conn, canConnect)), + ], + ); + }, + ), + ], + ), + ); + } + + void _connectTcp(AppStateProvider appState) async { + final host = _tcpHostController.text.trim(); + final portStr = _tcpPortController.text.trim(); + if (host.isEmpty) return; + final port = int.tryParse(portStr) ?? 5000; + + setState(() => _tcpConnecting = true); + try { + await appState.connectViaTcp(host, port); + } finally { + if (mounted) { + setState(() { + _tcpConnecting = false; + _savedTcpFuture = TcpService.getSavedConnections(); + }); + } + } + } + + Widget _buildSavedTcpTile(BuildContext context, AppStateProvider appState, + SavedTcpConnection conn, bool canConnect) { + final showName = !conn.name.startsWith('TCP '); + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + showName ? conn.name : conn.host, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + '${conn.host}:${conn.port}', + style: TextStyle(fontSize: 13, color: Colors.grey.shade600), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + constraints: const BoxConstraints(), + padding: const EdgeInsets.all(8), + onPressed: () async { + await TcpService.deleteConnection(conn.id); + if (mounted) { + final future = TcpService.getSavedConnections(); + setState(() { _savedTcpFuture = future; }); + } + }, + ), + const SizedBox(width: 4), + FilledButton( + onPressed: canConnect && !_tcpConnecting + ? () { + _tcpHostController.text = conn.host; + _tcpPortController.text = conn.port.toString(); + _connectTcp(appState); + } + : null, + child: const Text('Reconnect'), + ), + ], + ), + ), + ); + } + + /// USB Serial transport content. + Widget _buildUsbContent( + BuildContext context, AppStateProvider appState, bool canConnect) { + if (kIsWeb) { + return _buildWebSerialContent(context, appState, canConnect); + } + return _buildAndroidSerialContent(context, appState, canConnect); + } + + Widget _buildAndroidSerialContent( + BuildContext context, AppStateProvider appState, bool canConnect) { + return FutureBuilder>>( + future: _usbDevicesFuture ??= AndroidSerialService.getAvailablePorts(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + final devices = snapshot.data ?? []; + if (devices.isEmpty) { + return _buildMessageContent( + context: context, + icon: Icons.usb_off, + iconColor: Colors.grey.withValues(alpha: 0.5), + title: 'No USB Devices', + message: + 'Connect a MeshCore device via USB OTG and tap Refresh.', + action: FilledButton.icon( + onPressed: () => setState(() { + _usbDevicesFuture = AndroidSerialService.getAvailablePorts(); + }), + icon: const Icon(Icons.refresh), + label: const Text('Refresh'), + ), + ); + } + + return ListView.builder( + itemCount: devices.length, + itemBuilder: (context, index) { + final device = devices[index]; + final vid = device['vid'] as int? ?? 0; + final pid = device['pid'] as int? ?? 0; + return ListTile( + leading: const Icon(Icons.usb), + title: Text( + device['productName'] as String? ?? 'USB Device'), + subtitle: Text( + 'VID: ${vid.toRadixString(16)} PID: ${pid.toRadixString(16)}'), + enabled: canConnect, + onTap: canConnect + ? () => appState.connectViaUsb(device) + : null, + ); + }, + ); + }, + ); + } + + Widget _buildWebSerialContent( + BuildContext context, AppStateProvider appState, bool canConnect) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.usb, + size: 64, + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + 'USB Serial', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Connect a MeshCore device via USB and tap Connect below. ' + 'Your browser will show a port picker.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade600), + ), + ], + ), + ), + ); + } + + void _connectWebSerial(AppStateProvider appState) async { + try { + final transport = await openWebSerialTransport(); + await appState.connectWithTransport( + transport, + deviceId: 'webserial', + deviceName: 'USB Serial (Web)', + serialPortPath: 'webserial', + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('USB Serial error: $e')), + ); + } + } + } + void _launchOnboardingUrl() async { final uri = Uri.parse('https://meshmapper.net/?onboarding'); try { diff --git a/lib/services/bluetooth/bluetooth_service.dart b/lib/services/bluetooth/bluetooth_service.dart index 4702178..47bc82f 100644 --- a/lib/services/bluetooth/bluetooth_service.dart +++ b/lib/services/bluetooth/bluetooth_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:typed_data'; import '../../models/connection_state.dart'; +import '../transport/companion_transport.dart'; /// Exception thrown when BLE permissions are permanently denied /// User must enable permissions in device Settings @@ -41,20 +42,24 @@ enum BluetoothAdapterState { /// Abstract Bluetooth service interface /// Platform implementations provided by MobileBluetoothService and WebBluetoothService -abstract class BluetoothService { +abstract class BluetoothService implements CompanionTransport { /// Stream of connection status changes + @override Stream get connectionStream; /// Stream of received data from device + @override Stream get dataStream; /// Stream of Bluetooth adapter state changes (on/off) Stream get adapterStateStream; /// Current connection status + @override ConnectionStatus get connectionStatus; /// Currently connected device (null if not connected) + @override DiscoveredDevice? get connectedDevice; /// Check if Bluetooth is available on this platform @@ -77,9 +82,11 @@ abstract class BluetoothService { Future connect(String deviceId); /// Disconnect from current device + @override Future disconnect(); /// Write data to device + @override Future write(Uint8List data); /// Pre-populate device cache with known device info @@ -93,5 +100,6 @@ abstract class BluetoothService { Future removeBond(String deviceId); /// Dispose of resources + @override void dispose(); } diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 1ed7977..4821981 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -5,7 +5,7 @@ import 'dart:typed_data'; import '../../models/connection_state.dart'; import '../../models/device_model.dart'; import '../../utils/debug_logger_io.dart'; -import '../bluetooth/bluetooth_service.dart'; +import '../transport/companion_transport.dart'; import 'buffer_utils.dart'; import 'channel_service.dart'; import 'crypto_service.dart'; @@ -69,7 +69,7 @@ class SelfInfo { /// 8. GPS Init /// 9. Connected State class MeshCoreConnection { - final BluetoothService _bluetooth; + final CompanionTransport _transport; bool _disposed = false; final _stepController = StreamController.broadcast(); final _channelMessageController = @@ -116,9 +116,9 @@ class MeshCoreConnection { int? _lastBatteryMilliVolts; // millivolts or null if not supported Timer? _batteryTimer; - MeshCoreConnection({required BluetoothService bluetooth}) - : _bluetooth = bluetooth { - _dataSubscription = _bluetooth.dataStream.listen(_onFrameReceived); + MeshCoreConnection({required CompanionTransport transport}) + : _transport = transport { + _dataSubscription = _transport.dataStream.listen(_onFrameReceived); } /// Stream of connection step changes @@ -205,16 +205,15 @@ class MeshCoreConnection { /// Returns (deviceModel, deviceModelMatched) for display/reporting purposes /// Note: This method does NOT modify radio TX power settings - it only reads device info Future<({DeviceModel? deviceModel, bool deviceModelMatched})> connect( - String deviceId, List deviceModels) async { + List deviceModels) async { if (_disposed) { throw Exception('Connection instance has been disposed'); } bool deviceModelMatched = false; try { - // Step 1: BLE Connect - _updateStep(ConnectionStep.bleConnecting); - await _bluetooth.connect(deviceId); + // Step 1: Transport connect (already connected by caller) + _updateStep(ConnectionStep.transportConnecting); // Step 2: Protocol Handshake (handled automatically by device) _updateStep(ConnectionStep.protocolHandshake); @@ -281,6 +280,13 @@ class MeshCoreConnection { '[CONN] No auth callback set, skipping API session acquisition'); } + // Guard: transport may have disconnected during the async auth API call + if (_disposed || + _transport.connectionStatus != ConnectionStatus.connected) { + throw Exception( + 'Transport disconnected during authentication. Please try connecting again.'); + } + // Step 7: Channel Setup _updateStep(ConnectionStep.channelSetup); debugLog('[CONN] Creating #wardriving channel'); @@ -315,7 +321,7 @@ class MeshCoreConnection { _updateStep(ConnectionStep.error); // Clean up BLE connection on failure try { - await _bluetooth.disconnect(); + await _transport.disconnect(); debugLog('[CONN] Disconnected BLE after connection failure'); } catch (disconnectError) { debugError('[CONN] Failed to disconnect after error: $disconnectError'); @@ -349,7 +355,7 @@ class MeshCoreConnection { // See deleteWardrivingChannelEarly() called from app_state_provider // Disconnect BLE - await _bluetooth.disconnect(); + await _transport.disconnect(); _deviceInfo = null; _deviceModel = null; _selfInfo = null; @@ -775,7 +781,7 @@ class MeshCoreConnection { /// Write frame to device Future _sendToRadio(BufferWriter data) async { - await _bluetooth.write(data.toBytes()); + await _transport.write(data.toBytes()); } // ============================================ @@ -824,6 +830,10 @@ class MeshCoreConnection { data.writeByte(appTargetVer); await _sendToRadio(data); + // Send APP_START so device enters companion mode. + // Without this, some devices won't respond to the device query. + await sendCommandAppStart(); + return future.timeout( const Duration(seconds: 5), onTimeout: () => throw TimeoutException('Device query timed out'), @@ -891,7 +901,7 @@ class MeshCoreConnection { final bytes = data.toBytes(); debugLog( '[CONN] getChannel bytes: ${bytes.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join(' ')}'); - await _bluetooth.write(bytes); + await _transport.write(bytes); return future.timeout( const Duration(seconds: 5), diff --git a/lib/services/meshcore/protocol_constants.dart b/lib/services/meshcore/protocol_constants.dart index 1e2de5e..ba4bb0f 100644 --- a/lib/services/meshcore/protocol_constants.dart +++ b/lib/services/meshcore/protocol_constants.dart @@ -5,7 +5,7 @@ class ProtocolConstants { ProtocolConstants._(); /// Supported companion protocol version - static const int supportedCompanionProtocolVersion = 1; + static const int supportedCompanionProtocolVersion = 4; /// Serial frame types static const int serialFrameTypeIncoming = 0x3e; // ">" diff --git a/lib/services/transport/android_serial_service.dart b/lib/services/transport/android_serial_service.dart new file mode 100644 index 0000000..f3a37a0 --- /dev/null +++ b/lib/services/transport/android_serial_service.dart @@ -0,0 +1,131 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import '../../utils/debug_logger_io.dart'; +import '../bluetooth/bluetooth_service.dart'; +import 'stream_transport_base.dart'; + +/// USB Serial transport for Android via USB OTG. +/// +/// Uses a native Kotlin implementation (MeshMapperUsbService) that directly +/// accesses Android's USB API with proper CDC control transfers. +class AndroidSerialService extends StreamTransportBase { + final String deviceName; + final String productName; + + static const _methodChannel = + MethodChannel('net.meshmapper.app/usb_serial'); + static const _eventChannel = + EventChannel('net.meshmapper.app/usb_serial/data'); + + StreamSubscription? _dataSubscription; + + AndroidSerialService({ + required this.deviceName, + required this.productName, + }); + + @override + Future openConnection() async { + debugLog('[CONN] USB Serial connecting to $productName'); + setConnecting(); + + try { + _dataSubscription = _eventChannel.receiveBroadcastStream().listen( + (data) { + if (data is Uint8List) { + onRawBytesReceived(data); + } else if (data is List) { + onRawBytesReceived(Uint8List.fromList(data.cast())); + } + }, + onError: (error) { + debugError('[CONN] USB Serial stream error: $error'); + setDisconnected(); + }, + onDone: () { + debugLog('[CONN] USB Serial stream closed'); + setDisconnected(); + }, + ); + + final connectResult = await _methodChannel.invokeMethod('connect', { + 'deviceName': deviceName, + 'baudRate': 115200, + }); + if (connectResult != null) { + debugLog('[CONN] USB device info: controlFound=${connectResult['controlFound']}, ' + 'interfaces=${connectResult['interfaceCount']}, ' + 'dataClass=0x${(connectResult['dataClass'] as int?)?.toRadixString(16) ?? '?'}, ' + 'inPacket=${connectResult['inMaxPacket']}, outPacket=${connectResult['outMaxPacket']}'); + } + + setConnected(DiscoveredDevice( + id: deviceName, + name: productName, + )); + + debugLog('[CONN] USB Serial connected: $productName'); + } catch (e) { + _dataSubscription?.cancel(); + _dataSubscription = null; + debugError('[CONN] USB Serial connection failed: $e'); + setError(); + rethrow; + } + } + + @override + Future writeRawBytes(Uint8List data) async { + await _methodChannel.invokeMethod('write', {'data': data}); + } + + @override + Future closeConnection() async { + try { + final diag = + await _methodChannel.invokeMethod('readDiagnostics'); + if (diag != null) { + debugLog('[CONN] USB read loop: ' + 'reads=${diag['readAttempts']}, ' + 'bytesRx=${diag['bytesReceived']}, ' + 'events=${diag['eventsPosted']}, ' + 'sinkNull=${diag['sinkNullCount']}, ' + 'reading=${diag['isReading']}, ' + 'threadAlive=${diag['threadAlive']}, ' + 'sinkSet=${diag['sinkSet']}'); + } + } catch (e) { + debugWarn('[CONN] Read diagnostics unavailable: $e'); + } + _dataSubscription?.cancel(); + _dataSubscription = null; + try { + await _methodChannel.invokeMethod('disconnect'); + } catch (e) { + debugError('[CONN] USB Serial disconnect error: $e'); + } + debugLog('[CONN] USB Serial connection closed'); + } + + @override + void dispose() { + _dataSubscription?.cancel(); + _dataSubscription = null; + try { + _methodChannel.invokeMethod('disconnect'); + } catch (_) {} + super.dispose(); + } + + /// List available USB serial devices via native Android USB API. + static Future>> getAvailablePorts() async { + final result = + await _methodChannel.invokeMethod('listDevices'); + if (result == null) return []; + return result + .map((e) => Map.from(e as Map)) + .toList(); + } +} diff --git a/lib/services/transport/companion_transport.dart b/lib/services/transport/companion_transport.dart new file mode 100644 index 0000000..e3647ba --- /dev/null +++ b/lib/services/transport/companion_transport.dart @@ -0,0 +1,26 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../../models/connection_state.dart'; +import '../bluetooth/bluetooth_service.dart'; + +/// Transport-agnostic interface for MeshCore companion connections. +/// +/// Implemented by BLE (BluetoothService), TCP (TcpService), +/// and USB Serial (AndroidSerialService, WebSerialService). +/// MeshCoreConnection depends only on this interface. +abstract class CompanionTransport { + Stream get dataStream; + + Stream get connectionStream; + + ConnectionStatus get connectionStatus; + + DiscoveredDevice? get connectedDevice; + + Future write(Uint8List data); + + Future disconnect(); + + void dispose(); +} diff --git a/lib/services/transport/stream_frame_codec.dart b/lib/services/transport/stream_frame_codec.dart new file mode 100644 index 0000000..8532d51 --- /dev/null +++ b/lib/services/transport/stream_frame_codec.dart @@ -0,0 +1,128 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../../utils/debug_logger_io.dart'; + +/// Framing codec for TCP and USB Serial MeshCore companion connections. +/// +/// TCP and Serial use identical byte-stream framing: +/// - Outgoing (app→device): [0x3C][len_lo][len_hi][payload] +/// - Incoming (device→app): [0x3E][len_lo][len_hi][payload] +/// +/// BLE does NOT use this codec — GATT provides message boundaries natively. +class StreamFrameCodec { + static const int outgoingMarker = 0x3C; // '<' + static const int incomingMarker = 0x3E; // '>' + static const int headerSize = 3; + static const int maxTxPayload = 172; // firmware MAX_FRAME_SIZE + static const int maxRxPayload = 300; // defensive limit (matches meshcore_py) + + final _frameController = StreamController.broadcast(); + final _buffer = BytesBuilder(copy: true); + + Stream get frames => _frameController.stream; + + /// Encode an outgoing payload with the frame header. + static Uint8List encode(Uint8List payload) { + assert(payload.length <= maxTxPayload, + 'Payload exceeds max TX size: ${payload.length} > $maxTxPayload'); + final frame = Uint8List(payload.length + headerSize); + frame[0] = outgoingMarker; + frame[1] = payload.length & 0xFF; + frame[2] = (payload.length >> 8) & 0xFF; + frame.setRange(headerSize, frame.length, payload); + return frame; + } + + /// Feed incoming raw bytes for frame reassembly. + /// + /// Handles: junk byte discard, partial headers across chunks, + /// multiple frames in one chunk, zero-length and oversized frame rejection. + void addBytes(Uint8List data) { + if (data.isEmpty) return; + _buffer.add(data); + _processBuffer(); + } + + void _processBuffer() { + while (true) { + final bytes = _buffer.toBytes(); + if (bytes.length < headerSize) return; + + // Scan for incoming marker, discarding junk bytes before it + int markerIndex = -1; + for (int i = 0; i < bytes.length; i++) { + if (bytes[i] == incomingMarker) { + markerIndex = i; + break; + } + } + + if (markerIndex == -1) { + // No marker found — discard all bytes + _buffer.clear(); + return; + } + + if (markerIndex > 0) { + // Discard junk bytes before marker + _replaceBuffer(bytes.sublist(markerIndex)); + continue; + } + + // Marker is at position 0 — check if we have the full header + if (bytes.length < headerSize) return; + + final payloadLength = bytes[1] | (bytes[2] << 8); + + // Zero-length frame — skip the marker byte and rescan + if (payloadLength == 0) { + _replaceBuffer(bytes.sublist(1)); + continue; + } + + // Oversized frame — treat marker as junk, skip it and rescan + if (payloadLength > maxRxPayload) { + debugWarn( + '[CODEC] Oversized frame ($payloadLength bytes), skipping marker'); + _replaceBuffer(bytes.sublist(1)); + continue; + } + + final totalFrameSize = headerSize + payloadLength; + + // Not enough data yet for the full frame — wait for more + if (bytes.length < totalFrameSize) return; + + // Extract the payload (strip header) + final payload = Uint8List.fromList( + bytes.sublist(headerSize, totalFrameSize)); + + // Remove consumed bytes, keep remainder + if (bytes.length > totalFrameSize) { + _replaceBuffer(bytes.sublist(totalFrameSize)); + } else { + _buffer.clear(); + } + + // Emit the complete frame + if (!_frameController.isClosed) { + _frameController.add(payload); + } + } + } + + void _replaceBuffer(List remaining) { + _buffer.clear(); + _buffer.add(remaining); + } + + void reset() { + _buffer.clear(); + } + + void dispose() { + _buffer.clear(); + _frameController.close(); + } +} diff --git a/lib/services/transport/stream_transport_base.dart b/lib/services/transport/stream_transport_base.dart new file mode 100644 index 0000000..88cbaac --- /dev/null +++ b/lib/services/transport/stream_transport_base.dart @@ -0,0 +1,121 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../../models/connection_state.dart'; +import '../../utils/debug_logger_io.dart'; +import '../bluetooth/bluetooth_service.dart'; +import 'companion_transport.dart'; +import 'stream_frame_codec.dart'; + +/// Shared base class for TCP and USB Serial transports. +/// +/// Handles frame encoding/decoding via [StreamFrameCodec] and manages +/// connection state, data streams, and lifecycle. Subclasses implement +/// the raw byte I/O for their specific transport. +abstract class StreamTransportBase implements CompanionTransport { + final StreamFrameCodec _codec = StreamFrameCodec(); + final _connectionController = StreamController.broadcast(); + final _dataController = StreamController.broadcast(); + ConnectionStatus _status = ConnectionStatus.disconnected; + DiscoveredDevice? _device; + StreamSubscription? _codecSubscription; + + StreamTransportBase() { + _codecSubscription = _codec.frames.listen((payload) { + if (!_dataController.isClosed) { + _dataController.add(payload); + } + }); + } + + @override + Stream get dataStream => _dataController.stream; + + @override + Stream get connectionStream => + _connectionController.stream; + + @override + ConnectionStatus get connectionStatus => _status; + + @override + DiscoveredDevice? get connectedDevice => _device; + + @override + Future write(Uint8List data) async { + if (_status != ConnectionStatus.connected) { + throw StateError('Cannot write: transport not connected'); + } + final framed = StreamFrameCodec.encode(data); + await writeRawBytes(framed); + } + + @override + Future disconnect() async { + if (_status == ConnectionStatus.disconnected) return; + try { + await closeConnection(); + } catch (e) { + debugError('[CONN] Error closing connection: $e'); + } + setDisconnected(); + } + + @override + void dispose() { + _codecSubscription?.cancel(); + _codec.dispose(); + _connectionController.close(); + _dataController.close(); + } + + /// Called when raw bytes arrive from the underlying transport. + /// Feeds them into the frame codec for reassembly. + void onRawBytesReceived(Uint8List data) { + _codec.addBytes(data); + } + + /// Update state to connecting and emit. + void setConnecting() { + _status = ConnectionStatus.connecting; + if (!_connectionController.isClosed) { + _connectionController.add(ConnectionStatus.connecting); + } + } + + /// Update state to error and emit. + void setError() { + _status = ConnectionStatus.error; + if (!_connectionController.isClosed) { + _connectionController.add(ConnectionStatus.error); + } + } + + /// Update state to connected and emit. + void setConnected(DiscoveredDevice device) { + _device = device; + _status = ConnectionStatus.connected; + if (!_connectionController.isClosed) { + _connectionController.add(ConnectionStatus.connected); + } + } + + /// Update state to disconnected and emit. + void setDisconnected() { + _device = null; + _codec.reset(); + _status = ConnectionStatus.disconnected; + if (!_connectionController.isClosed) { + _connectionController.add(ConnectionStatus.disconnected); + } + } + + /// Open the underlying byte stream (socket, serial port, etc). + Future openConnection(); + + /// Write raw (already framed) bytes to the underlying stream. + Future writeRawBytes(Uint8List data); + + /// Close the underlying byte stream. + Future closeConnection(); +} diff --git a/lib/services/transport/tcp_service.dart b/lib/services/transport/tcp_service.dart new file mode 100644 index 0000000..d1a5428 --- /dev/null +++ b/lib/services/transport/tcp_service.dart @@ -0,0 +1,167 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../utils/debug_logger_io.dart'; +import '../bluetooth/bluetooth_service.dart'; +import 'stream_transport_base.dart'; + +/// Saved TCP connection for quick reconnect. +class SavedTcpConnection { + final String id; + final String host; + final int port; + final String name; + final DateTime lastConnected; + + const SavedTcpConnection({ + required this.id, + required this.host, + required this.port, + required this.name, + required this.lastConnected, + }); + + Map toJson() => { + 'id': id, + 'host': host, + 'port': port, + 'name': name, + 'lastConnected': lastConnected.toIso8601String(), + }; + + factory SavedTcpConnection.fromJson(Map json) { + return SavedTcpConnection( + id: json['id'] as String, + host: json['host'] as String, + port: json['port'] as int, + name: json['name'] as String, + lastConnected: DateTime.parse(json['lastConnected'] as String), + ); + } +} + +/// TCP transport for MeshCore companion connections. +/// +/// Connects to a MeshCore device via raw TCP socket (default port 5000). +/// Available on Android, iOS, and Desktop (NOT web — browsers cannot +/// open raw TCP sockets). +class TcpService extends StreamTransportBase { + final String host; + final int port; + + Socket? _socket; + StreamSubscription? _socketSubscription; + + TcpService({required this.host, this.port = 5000}); + + @override + Future openConnection() async { + debugLog('[CONN] TCP connecting to $host:$port'); + setConnecting(); + + try { + _socket = await Socket.connect(host, port, + timeout: const Duration(seconds: 10)); + debugLog('[CONN] TCP connected to $host:$port'); + + setConnected(DiscoveredDevice( + id: '$host:$port', + name: 'TCP $host:$port', + )); + + _socketSubscription = _socket!.listen( + (data) => onRawBytesReceived(Uint8List.fromList(data)), + onError: (error) { + debugError('[CONN] TCP socket error: $error'); + setDisconnected(); + }, + onDone: () { + debugLog('[CONN] TCP socket closed by remote'); + setDisconnected(); + }, + ); + } catch (e) { + debugError('[CONN] TCP connection failed: $e'); + setError(); + rethrow; + } + } + + @override + Future writeRawBytes(Uint8List data) async { + _socket?.add(data); + } + + @override + Future closeConnection() async { + _socketSubscription?.cancel(); + _socketSubscription = null; + _socket?.destroy(); + _socket = null; + debugLog('[CONN] TCP connection closed'); + } + + @override + void dispose() { + _socketSubscription?.cancel(); + _socket?.destroy(); + super.dispose(); + } + + // --- Saved connections persistence --- + + static const _prefsKey = 'saved_tcp_connections'; + + static Future> getSavedConnections() async { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_prefsKey); + if (jsonString == null) return []; + + try { + final list = jsonDecode(jsonString) as List; + return list + .map((e) => SavedTcpConnection.fromJson(e as Map)) + .toList() + ..sort((a, b) => b.lastConnected.compareTo(a.lastConnected)); + } catch (e) { + debugError('[CONN] Failed to load saved TCP connections: $e'); + return []; + } + } + + static Future saveConnection( + String host, int port, String name) async { + final prefs = await SharedPreferences.getInstance(); + final connections = await getSavedConnections(); + + final id = '$host:$port'; + connections.removeWhere((c) => c.id == id); + connections.insert( + 0, + SavedTcpConnection( + id: id, + host: host, + port: port, + name: name.isEmpty ? 'TCP $host:$port' : name, + lastConnected: DateTime.now(), + )); + + final jsonString = + jsonEncode(connections.map((c) => c.toJson()).toList()); + await prefs.setString(_prefsKey, jsonString); + } + + static Future deleteConnection(String id) async { + final prefs = await SharedPreferences.getInstance(); + final connections = await getSavedConnections(); + connections.removeWhere((c) => c.id == id); + + final jsonString = + jsonEncode(connections.map((c) => c.toJson()).toList()); + await prefs.setString(_prefsKey, jsonString); + } +} diff --git a/lib/services/transport/web_serial_factory.dart b/lib/services/transport/web_serial_factory.dart new file mode 100644 index 0000000..2f9d138 --- /dev/null +++ b/lib/services/transport/web_serial_factory.dart @@ -0,0 +1,2 @@ +export 'web_serial_factory_stub.dart' + if (dart.library.js_interop) 'web_serial_factory_web.dart'; diff --git a/lib/services/transport/web_serial_factory_stub.dart b/lib/services/transport/web_serial_factory_stub.dart new file mode 100644 index 0000000..514fcf9 --- /dev/null +++ b/lib/services/transport/web_serial_factory_stub.dart @@ -0,0 +1,4 @@ +import 'companion_transport.dart'; + +Future openWebSerialTransport() => + throw UnsupportedError('Web Serial API is only available on web'); diff --git a/lib/services/transport/web_serial_factory_web.dart b/lib/services/transport/web_serial_factory_web.dart new file mode 100644 index 0000000..0ccc7db --- /dev/null +++ b/lib/services/transport/web_serial_factory_web.dart @@ -0,0 +1,8 @@ +import 'companion_transport.dart'; +import 'web_serial_service.dart'; + +Future openWebSerialTransport() async { + final service = WebSerialService(); + await service.openConnection(); + return service; +} diff --git a/lib/services/transport/web_serial_service.dart b/lib/services/transport/web_serial_service.dart new file mode 100644 index 0000000..161b046 --- /dev/null +++ b/lib/services/transport/web_serial_service.dart @@ -0,0 +1,128 @@ +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +import '../../utils/debug_logger_io.dart'; +import '../bluetooth/bluetooth_service.dart'; +import 'stream_transport_base.dart'; + +// Web Serial API JS interop bindings + +@JS('navigator.serial') +external JSObject? get _jsNavigatorSerial; + +extension type SerialApi._(JSObject _) implements JSObject { + external JSPromise requestPort([JSObject? options]); +} + +extension type SerialPort._(JSObject _) implements JSObject { + external JSPromise open(JSObject options); + external JSPromise close(); + external web.ReadableStream get readable; + external web.WritableStream get writable; +} + +extension type SerialOptions._(JSObject _) implements JSObject { + external factory SerialOptions({int baudRate}); +} + +/// USB Serial transport for web browsers via the Web Serial API. +/// +/// Uses `navigator.serial` (Chrome/Edge only). The browser shows a native +/// port picker dialog when [openConnection] is called. +class WebSerialService extends StreamTransportBase { + SerialPort? _port; + web.ReadableStreamDefaultReader? _reader; + bool _reading = false; + + @override + Future openConnection() async { + debugLog('[CONN] Web Serial: requesting port'); + setConnecting(); + + try { + final serial = _jsNavigatorSerial; + if (serial == null) { + throw UnsupportedError('Web Serial API not available in this browser'); + } + + final serialApi = SerialApi._(serial); + final portObj = await serialApi.requestPort().toDart; + _port = SerialPort._(portObj); + await _port!.open(SerialOptions(baudRate: 115200)).toDart; + + setConnected(const DiscoveredDevice( + id: 'webserial', + name: 'USB Serial (Web)', + )); + + _startReadLoop(); + debugLog('[CONN] Web Serial connected'); + } catch (e) { + debugError('[CONN] Web Serial connection failed: $e'); + setError(); + rethrow; + } + } + + void _startReadLoop() async { + _reader = _port!.readable.getReader() as web.ReadableStreamDefaultReader; + _reading = true; + + try { + while (_reading) { + final result = await _reader!.read().toDart; + if (result.done) break; + + final value = result.value; + if (value != null) { + final jsArray = value as JSUint8Array; + final bytes = jsArray.toDart; + onRawBytesReceived(bytes); + } + } + } catch (e) { + if (_reading) { + debugError('[CONN] Web Serial read error: $e'); + setDisconnected(); + } + } + } + + @override + Future writeRawBytes(Uint8List data) async { + final writer = _port!.writable.getWriter(); + try { + final jsData = data.toJS; + await writer.write(jsData).toDart; + } finally { + writer.releaseLock(); + } + } + + @override + Future closeConnection() async { + _reading = false; + try { + await _reader?.cancel().toDart; + } catch (_) {} + _reader = null; + try { + await _port?.close().toDart; + } catch (_) {} + _port = null; + debugLog('[CONN] Web Serial connection closed'); + } + + @override + void dispose() { + _reading = false; + _reader?.cancel(); + super.dispose(); + } + + /// Check if Web Serial API is available in this browser. + static bool get isAvailable => _jsNavigatorSerial != null; +} diff --git a/pubspec.yaml b/pubspec.yaml index fed05ed..80b57d4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: 'none' version: 1.0.0+1 environment: - sdk: '>=3.2.0 <4.0.0' + sdk: '>=3.3.0 <4.0.0' dependencies: flutter: diff --git a/test/services/transport/stream_frame_codec_test.dart b/test/services/transport/stream_frame_codec_test.dart new file mode 100644 index 0000000..ccabac5 --- /dev/null +++ b/test/services/transport/stream_frame_codec_test.dart @@ -0,0 +1,282 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/transport/stream_frame_codec.dart'; + +void main() { + late StreamFrameCodec codec; + late List receivedFrames; + + setUp(() { + codec = StreamFrameCodec(); + receivedFrames = []; + codec.frames.listen((frame) => receivedFrames.add(frame)); + }); + + tearDown(() { + codec.dispose(); + }); + + Uint8List makeIncomingFrame(List payload) { + final frame = Uint8List(3 + payload.length); + frame[0] = StreamFrameCodec.incomingMarker; // 0x3E + frame[1] = payload.length & 0xFF; + frame[2] = (payload.length >> 8) & 0xFF; + frame.setRange(3, frame.length, payload); + return frame; + } + + group('encode', () { + test('prepends outgoing marker and length', () { + final payload = Uint8List.fromList([0x01, 0x02, 0x03]); + final encoded = StreamFrameCodec.encode(payload); + + expect(encoded.length, 6); + expect(encoded[0], StreamFrameCodec.outgoingMarker); // 0x3C + expect(encoded[1], 3); // length low byte + expect(encoded[2], 0); // length high byte + expect(encoded.sublist(3), payload); + }); + + test('handles max TX payload (172 bytes)', () { + final payload = Uint8List(172); + final encoded = StreamFrameCodec.encode(payload); + + expect(encoded.length, 175); + expect(encoded[0], StreamFrameCodec.outgoingMarker); + expect(encoded[1], 172); // 0xAC + expect(encoded[2], 0); + }); + + test('encodes length as little-endian uint16', () { + final payload = Uint8List(100); + final encoded = StreamFrameCodec.encode(payload); + + expect(encoded[1], 100); + expect(encoded[2], 0); + }); + + test('handles single-byte payload', () { + final payload = Uint8List.fromList([0xFF]); + final encoded = StreamFrameCodec.encode(payload); + + expect(encoded.length, 4); + expect(encoded[3], 0xFF); + }); + }); + + group('addBytes - single frame', () { + test('decodes a complete frame', () async { + final frame = makeIncomingFrame([0x01, 0x02, 0x03]); + codec.addBytes(frame); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0x01, 0x02, 0x03])); + }); + + test('decodes frame with max RX payload (300 bytes)', () async { + final payload = List.generate(300, (i) => i & 0xFF); + final frame = makeIncomingFrame(payload); + codec.addBytes(frame); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0].length, 300); + }); + }); + + group('addBytes - multiple frames in one chunk', () { + test('extracts two frames from one chunk', () async { + final frame1 = makeIncomingFrame([0xAA]); + final frame2 = makeIncomingFrame([0xBB, 0xCC]); + final combined = Uint8List.fromList([...frame1, ...frame2]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 2); + expect(receivedFrames[0], Uint8List.fromList([0xAA])); + expect(receivedFrames[1], Uint8List.fromList([0xBB, 0xCC])); + }); + + test('extracts three frames from one chunk', () async { + final frame1 = makeIncomingFrame([0x01]); + final frame2 = makeIncomingFrame([0x02]); + final frame3 = makeIncomingFrame([0x03]); + final combined = + Uint8List.fromList([...frame1, ...frame2, ...frame3]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 3); + }); + }); + + group('addBytes - partial frames across chunks', () { + test('header split across two chunks', () async { + final frame = makeIncomingFrame([0x01, 0x02, 0x03]); + + // Send marker + first length byte + codec.addBytes(Uint8List.fromList(frame.sublist(0, 2))); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 0); + + // Send rest + codec.addBytes(Uint8List.fromList(frame.sublist(2))); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0x01, 0x02, 0x03])); + }); + + test('one byte at a time', () async { + final frame = makeIncomingFrame([0xAA, 0xBB]); + + for (int i = 0; i < frame.length; i++) { + codec.addBytes(Uint8List.fromList([frame[i]])); + await Future.delayed(Duration.zero); + } + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0xAA, 0xBB])); + }); + + test('payload split across two chunks', () async { + final frame = makeIncomingFrame([0x01, 0x02, 0x03, 0x04, 0x05]); + + // Send header + partial payload + codec.addBytes(Uint8List.fromList(frame.sublist(0, 5))); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 0); + + // Send remaining payload + codec.addBytes(Uint8List.fromList(frame.sublist(5))); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 1); + expect(receivedFrames[0], + Uint8List.fromList([0x01, 0x02, 0x03, 0x04, 0x05])); + }); + }); + + group('addBytes - junk byte handling', () { + test('discards junk bytes before marker', () async { + final junk = [0x41, 0x42, 0x43]; // "ABC" debug text + final frame = makeIncomingFrame([0x01]); + final combined = Uint8List.fromList([...junk, ...frame]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0x01])); + }); + + test('discards all bytes when no marker present', () async { + codec.addBytes(Uint8List.fromList([0x41, 0x42, 0x43, 0x44])); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 0); + }); + + test('handles junk between two frames', () async { + final frame1 = makeIncomingFrame([0xAA]); + final junk = [0x44, 0x45, 0x42, 0x55, 0x47]; // "DEBUG" + final frame2 = makeIncomingFrame([0xBB]); + final combined = + Uint8List.fromList([...frame1, ...junk, ...frame2]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 2); + expect(receivedFrames[0], Uint8List.fromList([0xAA])); + expect(receivedFrames[1], Uint8List.fromList([0xBB])); + }); + }); + + group('addBytes - invalid frames', () { + test('skips zero-length frame', () async { + // Zero-length frame: marker + [0x00, 0x00] + final zeroFrame = Uint8List.fromList([0x3E, 0x00, 0x00]); + final validFrame = makeIncomingFrame([0x01]); + final combined = Uint8List.fromList([...zeroFrame, ...validFrame]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0x01])); + }); + + test('skips oversized frame (>300 bytes)', () async { + // Frame claiming 301 bytes: marker + [0x2D, 0x01] = 301 + final oversizedHeader = Uint8List.fromList([0x3E, 0x2D, 0x01]); + final validFrame = makeIncomingFrame([0x42]); + final combined = + Uint8List.fromList([...oversizedHeader, ...validFrame]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0x42])); + }); + }); + + group('addBytes - edge cases', () { + test('empty addBytes is a no-op', () async { + codec.addBytes(Uint8List(0)); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 0); + }); + + test('only marker byte, then rest later', () async { + codec.addBytes(Uint8List.fromList([0x3E])); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 0); + + codec.addBytes(Uint8List.fromList([0x02, 0x00, 0xAA, 0xBB])); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0xAA, 0xBB])); + }); + + test('reset clears buffer state', () async { + // Send partial frame + final frame = makeIncomingFrame([0x01, 0x02, 0x03]); + codec.addBytes(Uint8List.fromList(frame.sublist(0, 2))); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 0); + + // Reset + codec.reset(); + + // Send a complete new frame + final newFrame = makeIncomingFrame([0xFF]); + codec.addBytes(newFrame); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0xFF])); + }); + }); + + group('encode/decode round-trip', () { + test('encode then decode produces original payload', () async { + final original = Uint8List.fromList([0x16, 0x01, 0x4D, 0x65, 0x73]); + final encoded = StreamFrameCodec.encode(original); + + // Simulate what the device would do: change the marker from 0x3C to 0x3E + // (device echoes back with incoming marker) + final incoming = Uint8List.fromList(encoded); + incoming[0] = StreamFrameCodec.incomingMarker; + + codec.addBytes(incoming); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], original); + }); + }); +} From 21395acb7dc8c7a924364bdaa0580dd8105045f2 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 13 May 2026 22:29:28 -0400 Subject: [PATCH 037/100] - **Auto-Ping Grace Period:** Fixed auto-ping timers not actually stopping during zone grace period, causing a desync where re-enabling auto-ping silently failed. - **Cross-Zone Auto-Ping Restore:** Fixed auto-ping mode not being restored after transferring to a different zone during grace period. The saved mode was being cleared before the transfer could read it. - **Path Hash Mode on Zone Change:** Fixed path hash mode not being reconfigured correctly when moving from an enforced zone to a non-enforced zone whose firmware default matched the previous setting. --- lib/providers/app_state_provider.dart | 41 ++++++++++++++++----------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 20fba85..b9a145c 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2744,14 +2744,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final deviceInfo = _meshCoreConnection?.deviceInfo; if (deviceInfo == null) return; - // Store the device's current mode (from DeviceInfo response) + // Capture what the radio is CURRENTLY doing before resetting to firmware + // default — during zone transfer this reflects the previous zone's mode + final currentRuntimeHopBytes = _hopBytes; + + // Store the device's original firmware mode (from DeviceInfo response) _originalPathHashMode = deviceInfo.pathHashMode; - // Sync runtime hopBytes from device's current mode + // Sync runtime hopBytes from device's firmware mode + final deviceMode = + _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) + final deviceHopBytes = deviceMode + 1; if (_originalPathHashMode != null) { - final deviceHopBytes = _originalPathHashMode! + 1; _hopBytes = deviceHopBytes; - // Map TX bytes to trace bytes (3-byte traces not possible, use 4) _traceHopBytes = deviceHopBytes == 3 ? 4 : deviceHopBytes; _pingService?.traceHopBytes = _traceHopBytes; debugLog( @@ -2762,19 +2767,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } final effective = effectiveHopBytes; - final deviceMode = - _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) - final deviceHopBytes = deviceMode + 1; - if (effective != deviceHopBytes && _originalPathHashMode != null) { + if (effective != currentRuntimeHopBytes && _originalPathHashMode != null) { // Need to change the radio's path hash mode try { await _meshCoreConnection!.setPathHashMode(effective - 1); - _hopBytes = effective; // Update runtime state to reflect new mode + _hopBytes = effective; _traceHopBytes = effective == 3 ? 4 : effective; _pingService?.traceHopBytes = _traceHopBytes; debugLog( - '[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); + '[PATH] Set path hash mode: radio was $currentRuntimeHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); // Show warning popup if changing from 1-byte to multi-byte if (deviceMode == 0 && effective > 1) { @@ -2782,7 +2784,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ? 'set by your regional admin' : 'set in your app preferences'; _pendingPathHashWarning = (hopBytes: effective, reason: reason); - notifyListeners(); // Trigger UI to show warning + notifyListeners(); } } catch (e) { debugError('[PATH] Failed to set path hash mode: $e'); @@ -2798,7 +2800,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } else { debugLog( - '[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); + '[PATH] Path hash mode OK: radio=$currentRuntimeHopBytes-byte, effective=$effective-byte'); } } @@ -5464,6 +5466,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_autoPingEnabled) { _autoPingEnabled = false; _idleAutoStopReference = null; + await _pingService?.forceDisableAutoPing(); debugLog('[ZONE GRACE] Auto-ping paused'); } @@ -5545,9 +5548,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _cancelZoneGraceTimers(); _isInZoneGracePeriod = false; _zoneGraceSecondsRemaining = 0; + final savedAutoPing = _autoPingWasEnabledBeforeGrace; + final savedMode = _autoModeBeforeGrace; _autoPingWasEnabledBeforeGrace = false; await _handleZoneTransfer( - reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown'); + reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown', + wasAutoPingOverride: savedAutoPing, + previousModeOverride: savedMode); return; } @@ -5646,7 +5653,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Releases old zone session and acquires new session for target zone. /// Preserves BLE connection and radio configuration. Future _handleZoneTransfer( - String newZoneCode, String newZoneName) async { + String newZoneCode, String newZoneName, + {bool? wasAutoPingOverride, AutoMode? previousModeOverride}) async { if (_isZoneTransferInProgress) { debugLog('[ZONE] Transfer already in progress, skipping'); return; @@ -5661,8 +5669,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { // 1. Save auto-ping state for restoration - final wasAutoPing = _autoPingEnabled; - final previousMode = _autoMode; + // Prefer overrides from grace period (where provider state was already cleared) + final wasAutoPing = wasAutoPingOverride ?? _autoPingEnabled; + final previousMode = previousModeOverride ?? _autoMode; // 2. Pause auto-ping and wardriving activity _autoPingTimer.stop(); From f6ae59a44c82b14f81d7c0492f569114dfd8c88d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 13:00:17 -0400 Subject: [PATCH 038/100] Add zoneDisabled enum value and new offline upload diagnostic script --- bin/test_offline_upload.dart | 975 ++++++++++++++++++++++++++ lib/providers/app_state_provider.dart | 3 + 2 files changed, 978 insertions(+) create mode 100644 bin/test_offline_upload.dart diff --git a/bin/test_offline_upload.dart b/bin/test_offline_upload.dart new file mode 100644 index 0000000..89ba5d4 --- /dev/null +++ b/bin/test_offline_upload.dart @@ -0,0 +1,975 @@ +/// MeshMapper Offline Upload Diagnostic Script +/// +/// Reproduces server-side session invalidation during offline batch uploads. +/// Runs four scenarios to determine if the server has an item count limit, +/// rate limit, zone mismatch issue, or other constraint. +/// +/// Usage: +/// dart run bin/test_offline_upload.dart --key= --pubkey= [options] +/// +/// Required: +/// --key= MeshMapper API key +/// --pubkey= Registered device public key (hex) +/// +/// Optional: +/// --lat= Latitude for auth (default: 45.3215) +/// --lon= Longitude for auth (default: -75.6693) +/// --data-lat= Latitude for ping data (default: same as --lat) +/// --data-lon= Longitude for ping data (default: same as --lon) +/// --who= Device name (default: DIAG-TEST) +/// --scenario=<1|2|3|4|all> Run specific scenario (default: all) +/// --contact-uri= Signed contact URI (for unregistered devices) +/// +/// Examples: +/// dart run bin/test_offline_upload.dart --key=abc123 --pubkey=deadbeef... +/// dart run bin/test_offline_upload.dart --key=abc123 --pubkey=deadbeef... --scenario=1 +library; + +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const String baseUrl = 'https://meshmapper.net'; +const String authEndpoint = '$baseUrl/wardrive-api.php/auth'; +const String wardriveEndpoint = '$baseUrl/wardrive-api.php/wardrive'; +final String testVersion = + 'APP-${DateTime.now().millisecondsSinceEpoch ~/ 1000}'; +const String testModel = 'Offline Upload'; + +// ============================================================================ +// GLOBAL STATE (for Ctrl+C cleanup) +// ============================================================================ + +String? _activeSessionId; +String? _activeApiKey; +String? _activePubkey; +http.Client? _activeClient; + +// ============================================================================ +// DATA GENERATION +// ============================================================================ + +List> generateTestItems( + int count, { + double baseLat = 45.3215, + double baseLon = -75.6693, +}) { + final baseTimestamp = + (DateTime.now().subtract(const Duration(hours: 24)).millisecondsSinceEpoch ~/ 1000); + final items = >[]; + + for (var i = 0; i < count; i++) { + final timestamp = baseTimestamp + (i * 10); + final lat = baseLat + (i * 0.00001); + final lon = baseLon + (i * 0.00001); + + if (i.isEven) { + items.add({ + 'type': 'RX', + 'lat': lat, + 'lon': lon, + 'noisefloor': -100, + 'heard_repeats': 'ff(0.0)', + 'timestamp': timestamp, + 'external_antenna': true, + 'power': '0.3w', + }); + } else { + items.add({ + 'type': 'DISC', + 'lat': lat, + 'lon': lon, + 'noisefloor': -100, + 'repeater_id': 'ff', + 'node_type': 'REPEATER', + 'local_snr': 0.0, + 'local_rssi': -100, + 'remote_snr': 0.0, + 'public_key': + '0000000000000000000000000000000000000000000000000000000000000000', + 'timestamp': timestamp, + 'external_antenna': true, + 'power': '0.3w', + }); + } + } + return items; +} + +// ============================================================================ +// API HELPERS +// ============================================================================ + +Future?> authenticate({ + required http.Client client, + required String apiKey, + required String pubkey, + required double lat, + required double lon, + required String who, + String? contactUri, +}) async { + final payload = { + 'key': apiKey, + 'reason': 'connect', + 'offline_mode': true, + 'who': who, + 'ver': testVersion, + 'power': '0.3w', + 'model': testModel, + 'coords': { + 'lat': lat, + 'lng': lon, + 'accuracy_m': 5.0, + 'timestamp': DateTime.now().millisecondsSinceEpoch ~/ 1000, + }, + }; + + if (contactUri != null) { + payload['contact_uri'] = contactUri; + } else { + payload['public_key'] = pubkey; + } + + try { + final stopwatch = Stopwatch()..start(); + final response = await client + .post( + Uri.parse(authEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); + stopwatch.stop(); + + final data = json.decode(response.body) as Map; + + if (response.statusCode != 200) { + print(' Auth HTTP ${response.statusCode}: ${response.body}'); + } + + return { + ...data, + '_http_status': response.statusCode, + '_elapsed_ms': stopwatch.elapsedMilliseconds, + }; + } catch (e) { + print(' Auth exception: $e'); + return null; + } +} + +Future?> registerDevice({ + required http.Client client, + required String apiKey, + required String contactUri, + required double lat, + required double lon, + required String who, +}) async { + return authenticate( + client: client, + apiKey: apiKey, + pubkey: '', + lat: lat, + lon: lon, + who: who, + contactUri: contactUri, + ); +} + +Future uploadBatch({ + required http.Client client, + required String apiKey, + required String sessionId, + required List> items, + required int batchNum, + required int cumulativeItems, +}) async { + final stopwatch = Stopwatch()..start(); + + try { + final payload = { + 'key': apiKey, + 'session_id': sessionId, + 'data': items, + }; + + final response = await client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); + + stopwatch.stop(); + + final data = json.decode(response.body) as Map; + + return BatchResult( + batchNumber: batchNum, + itemCount: items.length, + cumulativeItems: cumulativeItems, + success: data['success'] == true, + httpStatus: response.statusCode, + reason: data['reason'] as String?, + message: data['message'] as String?, + expiresAt: data['expires_at'] as int?, + responseTime: stopwatch.elapsed, + ); + } catch (e) { + stopwatch.stop(); + return BatchResult( + batchNumber: batchNum, + itemCount: items.length, + cumulativeItems: cumulativeItems, + success: false, + httpStatus: 0, + reason: 'exception', + message: '$e', + expiresAt: null, + responseTime: stopwatch.elapsed, + ); + } +} + +Future disconnectSession({ + required http.Client client, + required String apiKey, + required String pubkey, + required String sessionId, +}) async { + try { + final payload = { + 'key': apiKey, + 'reason': 'disconnect', + 'public_key': pubkey, + 'session_id': sessionId, + }; + + await client + .post( + Uri.parse(authEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); + } catch (_) { + // Best effort + } +} + +// ============================================================================ +// RESULT TYPES +// ============================================================================ + +class BatchResult { + final int batchNumber; + final int itemCount; + final int cumulativeItems; + final bool success; + final int httpStatus; + final String? reason; + final String? message; + final int? expiresAt; + final Duration responseTime; + + BatchResult({ + required this.batchNumber, + required this.itemCount, + required this.cumulativeItems, + required this.success, + required this.httpStatus, + this.reason, + this.message, + this.expiresAt, + required this.responseTime, + }); +} + +class ScenarioResult { + final String name; + final String description; + final int batchSize; + final Duration interBatchDelay; + final List batches; + final int? failedAtBatch; + final int? failedAtCumulativeItems; + final String? failureReason; + final bool authFailed; + + ScenarioResult({ + required this.name, + required this.description, + required this.batchSize, + required this.interBatchDelay, + required this.batches, + this.failedAtBatch, + this.failedAtCumulativeItems, + this.failureReason, + this.authFailed = false, + }); + + bool get passed => failedAtBatch == null && !authFailed; + int get totalUploaded => + batches.where((b) => b.success).fold(0, (sum, b) => sum + b.itemCount); +} + +// ============================================================================ +// SCENARIO RUNNER +// ============================================================================ + +Future runScenario({ + required http.Client client, + required String apiKey, + required String pubkey, + required double lat, + required double lon, + required String who, + required String name, + required String description, + required int batchSize, + required int maxBatches, + required Duration interBatchDelay, + String? contactUri, + double? dataLat, + double? dataLon, +}) async { + // Authenticate + stdout.write(' Authenticating...'); + final authResult = await authenticate( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + who: who, + contactUri: contactUri, + ); + + if (authResult == null || authResult['success'] != true) { + final reason = authResult?['reason'] as String? ?? 'network error'; + + // Try registration if unknown_device and contactUri available + if (reason == 'unknown_device' && contactUri != null) { + stdout.write(' unknown device, trying registration...'); + final regResult = await registerDevice( + client: client, + apiKey: apiKey, + contactUri: contactUri, + lat: lat, + lon: lon, + who: who, + ); + if (regResult == null || regResult['success'] != true) { + print(' FAILED (registration: ${regResult?['reason'] ?? 'error'})'); + return ScenarioResult( + name: name, + description: description, + batchSize: batchSize, + interBatchDelay: interBatchDelay, + batches: [], + failureReason: 'Registration failed: ${regResult?['reason']}', + authFailed: true, + ); + } + // Use registration session + final sessionId = regResult['session_id'] as String?; + if (sessionId == null) { + print(' FAILED (no session_id after registration)'); + return ScenarioResult( + name: name, + description: description, + batchSize: batchSize, + interBatchDelay: interBatchDelay, + batches: [], + failureReason: 'No session_id after registration', + authFailed: true, + ); + } + print(' OK via registration (session: $sessionId)'); + _activeSessionId = sessionId; + _activeApiKey = apiKey; + _activePubkey = pubkey; + _activeClient = client; + // Fall through to upload with sessionId + return _runBatches( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat, + dataLon: dataLon, + sessionId: sessionId, + name: name, + description: description, + batchSize: batchSize, + maxBatches: maxBatches, + interBatchDelay: interBatchDelay, + ); + } + + print(' FAILED ($reason)'); + if (reason == 'unknown_device') { + print( + ' Hint: device not registered. Pass --contact-uri= or use a registered device key.'); + } + return ScenarioResult( + name: name, + description: description, + batchSize: batchSize, + interBatchDelay: interBatchDelay, + batches: [], + failureReason: 'Auth failed: $reason', + authFailed: true, + ); + } + + final sessionId = authResult['session_id'] as String?; + final expiresAt = authResult['expires_at'] as int?; + final elapsed = authResult['_elapsed_ms'] as int?; + if (sessionId == null) { + print(' FAILED (no session_id)'); + return ScenarioResult( + name: name, + description: description, + batchSize: batchSize, + interBatchDelay: interBatchDelay, + batches: [], + failureReason: 'Auth succeeded but no session_id', + authFailed: true, + ); + } + + final ttl = expiresAt != null + ? expiresAt - (DateTime.now().millisecondsSinceEpoch ~/ 1000) + : 0; + print(' OK (session: $sessionId, TTL: ${ttl}s, ${elapsed}ms)'); + + _activeSessionId = sessionId; + _activeApiKey = apiKey; + _activePubkey = pubkey; + _activeClient = client; + + return _runBatches( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat, + dataLon: dataLon, + sessionId: sessionId, + name: name, + description: description, + batchSize: batchSize, + maxBatches: maxBatches, + interBatchDelay: interBatchDelay, + ); +} + +Future _runBatches({ + required http.Client client, + required String apiKey, + required String pubkey, + required double lat, + required double lon, + double? dataLat, + double? dataLon, + required String sessionId, + required String name, + required String description, + required int batchSize, + required int maxBatches, + required Duration interBatchDelay, +}) async { + final batches = []; + int? failedAtBatch; + int? failedAtCumulative; + String? failureReason; + + // Wait for session propagation + print(' Waiting 4s for session propagation...'); + await Future.delayed(const Duration(seconds: 4)); + + // Generate all test items up front (use data coords if provided, else auth coords) + final totalItems = batchSize * maxBatches; + final allItems = generateTestItems(totalItems, + baseLat: dataLat ?? lat, baseLon: dataLon ?? lon); + + // Upload batches + var cumulativeItems = 0; + for (var i = 0; i < maxBatches; i++) { + final batchNum = i + 1; + final start = i * batchSize; + final end = (start + batchSize).clamp(0, allItems.length); + final batch = allItems.sublist(start, end); + cumulativeItems += batch.length; + + stdout.write( + ' Batch $batchNum/$maxBatches (${batch.length} items, total: $cumulativeItems)...'); + + final result = await uploadBatch( + client: client, + apiKey: apiKey, + sessionId: sessionId, + items: batch, + batchNum: batchNum, + cumulativeItems: cumulativeItems, + ); + batches.add(result); + + if (result.success) { + final elapsed = + '${(result.responseTime.inMilliseconds / 1000).toStringAsFixed(2)}s'; + print(' OK ($elapsed)'); + } else { + final elapsed = + '${(result.responseTime.inMilliseconds / 1000).toStringAsFixed(2)}s'; + print( + ' FAIL ${result.httpStatus} ${result.reason ?? 'unknown'} ($elapsed)'); + if (result.message != null) { + print(' "${result.message}"'); + } + failedAtBatch = batchNum; + failedAtCumulative = cumulativeItems; + failureReason = result.reason; + break; + } + + // Inter-batch delay + if (interBatchDelay.inMilliseconds > 0 && i < maxBatches - 1) { + stdout.write( + ' (waiting ${interBatchDelay.inSeconds}s before next batch...)\n'); + await Future.delayed(interBatchDelay); + } + } + + // Disconnect + stdout.write(' Disconnecting...'); + await disconnectSession( + client: client, + apiKey: apiKey, + pubkey: pubkey, + sessionId: sessionId, + ); + print(' OK'); + + _activeSessionId = null; + _activeApiKey = null; + _activePubkey = null; + _activeClient = null; + + return ScenarioResult( + name: name, + description: description, + batchSize: batchSize, + interBatchDelay: interBatchDelay, + batches: batches, + failedAtBatch: failedAtBatch, + failedAtCumulativeItems: failedAtCumulative, + failureReason: failureReason, + ); +} + +// ============================================================================ +// OUTPUT +// ============================================================================ + +void printHeader(String apiKey, String pubkey, double lat, double lon, + double? dataLat, double? dataLon, String who, String scenarios) { + print(''); + print('=' * 60); + print(' MESHMAPPER OFFLINE UPLOAD DIAGNOSTIC'); + print('=' * 60); + print(''); + print(' Config:'); + print( + ' API Key: ${apiKey.length > 8 ? '${apiKey.substring(0, 4)}...${apiKey.substring(apiKey.length - 4)}' : '****'}'); + print( + ' Public Key: ${pubkey.length > 16 ? '${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}' : pubkey}'); + print(' Device: $who'); + print(' Auth Coords: $lat, $lon'); + if (dataLat != null || dataLon != null) { + print(' Data Coords: ${dataLat ?? lat}, ${dataLon ?? lon}'); + } + print(' Scenarios: $scenarios'); + print(''); +} + +void printScenarioHeader(String name, String description) { + print('=' * 60); + print(' SCENARIO: $name'); + print(' $description'); + print('=' * 60); + print(''); +} + +void printSummary(List results) { + print(''); + print('=' * 60); + print(' DIAGNOSTIC SUMMARY'); + print('=' * 60); + print(''); + + for (final result in results) { + final icon = result.passed ? '[+]' : '[X]'; + final delayStr = result.interBatchDelay.inSeconds > 0 + ? '${result.interBatchDelay.inSeconds}s delay' + : 'no delay'; + + if (result.authFailed) { + print(' $icon ${result.name} (batch=${result.batchSize}, $delayStr)'); + print(' Auth failed: ${result.failureReason}'); + } else if (result.passed) { + print(' $icon ${result.name} (batch=${result.batchSize}, $delayStr)'); + print(' PASSED ${result.totalUploaded} items across ${result.batches.length} batches'); + } else { + print(' $icon ${result.name} (batch=${result.batchSize}, $delayStr)'); + print( + ' FAILED at batch ${result.failedAtBatch} (${result.failedAtCumulativeItems} cumulative items)'); + print(' Reason: ${result.failureReason}'); + print( + ' Successfully uploaded: ${result.totalUploaded} items before failure'); + } + print(''); + } + + // Analysis + final validResults = results.where((r) => !r.authFailed).toList(); + if (validResults.isEmpty) { + print(' FINDINGS:'); + print(' - All scenarios failed to authenticate.'); + print( + ' - Check your API key and device public key, or provide --contact-uri'); + } else { + print(' FINDINGS:'); + + final rapidFire = validResults + .where( + (r) => r.interBatchDelay.inSeconds == 0 && r.batchSize == 50) + .toList(); + final throttled = validResults + .where((r) => r.interBatchDelay.inSeconds > 0 && r.batchSize == 50) + .toList(); + final batchSweep = validResults + .where( + (r) => r.interBatchDelay.inSeconds == 0 && r.batchSize != 50) + .toList(); + + // Check if rapid fire failed but throttled passed + if (rapidFire.isNotEmpty && + throttled.isNotEmpty && + !rapidFire.first.passed && + throttled.first.passed) { + print( + ' - Rapid-fire batches fail at ~${rapidFire.first.totalUploaded} items, but throttled batches pass.'); + print(' - CONCLUSION: Server-side RATE LIMIT (not item count limit).'); + print( + ' - The server rejects rapid consecutive POSTs to the same session.'); + print( + ' - Workaround: add a delay between batch uploads (${throttled.first.interBatchDelay.inSeconds}s worked).'); + } else if (rapidFire.isNotEmpty && + throttled.isNotEmpty && + !rapidFire.first.passed && + !throttled.first.passed) { + final rapidItems = rapidFire.first.totalUploaded; + final throttledItems = throttled.first.totalUploaded; + if ((rapidItems - throttledItems).abs() < 60) { + print( + ' - Both rapid and throttled fail at ~$rapidItems items.'); + print( + ' - CONCLUSION: Server-side ITEM COUNT LIMIT (~$rapidItems per session).'); + print( + ' - Workaround: re-authenticate after every ~${(rapidItems * 0.8).round()} items.'); + } else { + print( + ' - Rapid: failed at $rapidItems items. Throttled: failed at $throttledItems items.'); + print( + ' - CONCLUSION: Mixed behavior — may be a combination of rate + count limits.'); + } + } else if (validResults.every((r) => r.passed)) { + print(' - All scenarios passed!'); + print( + ' - Could not reproduce the session invalidation in this run.'); + print( + ' - The issue may depend on server load, time of day, or specific session state.'); + } + + // Batch size analysis + if (batchSweep.length >= 2) { + print(''); + print(' Batch size analysis:'); + for (final r in batchSweep) { + final status = r.passed ? 'PASSED' : 'FAILED at ${r.totalUploaded}'; + print(' batch=${r.batchSize}: $status'); + } + } + + // Out-of-zone analysis + final oozResults = + validResults.where((r) => r.name == 'Out-of-Zone Data').toList(); + if (oozResults.isNotEmpty) { + print(''); + print(' Out-of-zone data analysis:'); + final ooz = oozResults.first; + if (ooz.passed) { + print( + ' PASSED — server accepted ${ooz.totalUploaded} items with out-of-zone coordinates.'); + print( + ' Zone mismatch between auth and ping data does NOT cause session invalidation.'); + } else { + print( + ' FAILED at batch ${ooz.failedAtBatch} (${ooz.failedAtCumulativeItems} cumulative items).'); + print(' Reason: ${ooz.failureReason}'); + if (ooz.failureReason == 'bad_session' || + ooz.failureReason == 'session_expired') { + print( + ' CONCLUSION: Server MAY invalidate sessions when ping coords are outside auth zone.'); + } + } + } + } + + print(''); + print('=' * 60); + print(' NOTE: Test data uses who="DIAG-TEST" and past timestamps.'); + print(' Ask the backend team to purge DIAG-TEST data after review.'); + print('=' * 60); + print(''); +} + +// ============================================================================ +// MAIN +// ============================================================================ + +Future main(List arguments) async { + if (arguments.isEmpty || arguments.contains('--help')) { + printUsage(); + return; + } + + // Parse arguments + String? apiKey; + String? pubkey; + String? contactUri; + double lat = 45.3215; + double lon = -75.6693; + double? dataLat; + double? dataLon; + String who = 'DIAG-TEST'; + String scenario = 'all'; + + for (final arg in arguments) { + if (arg.startsWith('--key=')) { + apiKey = arg.substring(6); + } else if (arg.startsWith('--pubkey=')) { + pubkey = arg.substring(9); + } else if (arg.startsWith('--contact-uri=')) { + contactUri = arg.substring(14); + } else if (arg.startsWith('--lat=')) { + lat = double.parse(arg.substring(6)); + } else if (arg.startsWith('--lon=')) { + lon = double.parse(arg.substring(6)); + } else if (arg.startsWith('--data-lat=')) { + dataLat = double.parse(arg.substring(11)); + } else if (arg.startsWith('--data-lon=')) { + dataLon = double.parse(arg.substring(11)); + } else if (arg.startsWith('--who=')) { + who = arg.substring(6); + } else if (arg.startsWith('--scenario=')) { + scenario = arg.substring(11); + } + } + + if (apiKey == null) { + print('Error: --key= is required'); + printUsage(); + exit(1); + } + if (pubkey == null && contactUri == null) { + print('Error: --pubkey= or --contact-uri= is required'); + printUsage(); + exit(1); + } + + pubkey ??= ''; + + // Setup signal handler for Ctrl+C + ProcessSignal.sigint.watch().listen((_) async { + print('\n\n [CTRL+C] Cleaning up...'); + if (_activeSessionId != null && _activeClient != null) { + await disconnectSession( + client: _activeClient!, + apiKey: _activeApiKey!, + pubkey: _activePubkey!, + sessionId: _activeSessionId!, + ); + print(' Session disconnected.'); + } + _activeClient?.close(); + exit(1); + }); + + final client = http.Client(); + final results = []; + + printHeader(apiKey, pubkey, lat, lon, dataLat, dataLon, who, scenario); + + // Scenario 1: Rapid fire (reproduce the bug) + if (scenario == 'all' || scenario == '1') { + printScenarioHeader( + '1 - Rapid Fire', + 'Batch=50, no delay. Should reproduce failure at ~batch 4.', + ); + results.add(await runScenario( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat, + dataLon: dataLon, + who: who, + contactUri: contactUri, + name: 'Rapid Fire', + description: 'batch=50, no delay', + batchSize: 50, + maxBatches: 10, + interBatchDelay: Duration.zero, + )); + print(''); + } + + // Scenario 2: Throttled (test rate limit hypothesis) + if (scenario == 'all' || scenario == '2') { + printScenarioHeader( + '2 - Throttled', + 'Batch=50, 3s delay between batches. Tests rate limit hypothesis.', + ); + results.add(await runScenario( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat, + dataLon: dataLon, + who: who, + contactUri: contactUri, + name: 'Throttled', + description: 'batch=50, 3s delay', + batchSize: 50, + maxBatches: 10, + interBatchDelay: const Duration(seconds: 3), + )); + print(''); + } + + // Scenario 3: Batch size sweep + if (scenario == 'all' || scenario == '3') { + for (final size in [10, 25, 100]) { + final maxBatches = (200 / size).ceil(); + printScenarioHeader( + '3 - Batch Size $size', + 'Batch=$size, no delay, up to $maxBatches batches (${size * maxBatches} items).', + ); + results.add(await runScenario( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat, + dataLon: dataLon, + who: who, + contactUri: contactUri, + name: 'Batch Size $size', + description: 'batch=$size, no delay', + batchSize: size, + maxBatches: maxBatches, + interBatchDelay: Duration.zero, + )); + print(''); + } + } + + // Scenario 4: Out-of-zone ping data + if (scenario == 'all' || scenario == '4') { + // Auth from in-zone coords, but ping data from far outside any zone + const oozLat = 50.0; + const oozLon = -90.0; + printScenarioHeader( + '4 - Out-of-Zone Data', + 'Auth at ($lat, $lon) but ping coords at ($oozLat, $oozLon). Tests zone mismatch.', + ); + results.add(await runScenario( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat ?? oozLat, + dataLon: dataLon ?? oozLon, + who: who, + contactUri: contactUri, + name: 'Out-of-Zone Data', + description: 'auth in-zone, data out-of-zone', + batchSize: 50, + maxBatches: 3, + interBatchDelay: Duration.zero, + )); + print(''); + } + + printSummary(results); + client.close(); +} + +void printUsage() { + print(''' +MeshMapper Offline Upload Diagnostic + +Reproduces server-side session invalidation during offline batch uploads. +Runs four scenarios to determine if the server has an item count limit, +rate limit, zone mismatch issue, or other constraint. + +Usage: + dart run bin/test_offline_upload.dart --key= --pubkey= [options] + +Required: + --key= MeshMapper API key + --pubkey= Registered device public key (hex) + +Optional: + --contact-uri= Signed contact URI (if device not yet registered) + --lat= Auth latitude (default: 45.3215) + --lon= Auth longitude (default: -75.6693) + --data-lat= Ping data latitude (default: same as --lat) + --data-lon= Ping data longitude (default: same as --lon) + --who= Device name (default: DIAG-TEST) + --scenario=<1|2|3|4|all> Run specific scenario (default: all) + +Scenarios: + 1 Rapid fire Batch=50, no delay, 10 batches. Reproduces the bug. + 2 Throttled Batch=50, 3s delay, 10 batches. Tests rate limit. + 3 Batch sweep Batch=10/25/100, no delay. Tests per-batch vs cumulative limit. + 4 Out-of-zone data Auth in-zone, ping coords out-of-zone. Tests zone mismatch. + +Examples: + dart run bin/test_offline_upload.dart --key=abc123 --pubkey=deadbeef01234567 + dart run bin/test_offline_upload.dart --key=abc123 --pubkey=deadbeef01234567 --scenario=1 + dart run bin/test_offline_upload.dart --key=abc123 --contact-uri="meshcore://..." --scenario=2 + dart run bin/test_offline_upload.dart --key=abc123 --pubkey=deadbeef01234567 --scenario=4 --data-lat=50.0 --data-lon=-90.0 +'''); +} diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index b9a145c..4703d07 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -87,6 +87,9 @@ enum OfflineUploadResult { /// GPS position required but not available gpsRequired, + + /// Zone is disabled server-side + zoneDisabled, } /// Main application state provider From dae5bf4c91541df69c471cff6cb96eab80ff25f5 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 13:23:58 -0400 Subject: [PATCH 039/100] - Fixed auto-ping mode not validated against new zone permissions on transfer - Fixed zone admin overrides persisting after moving to a less restrictive zone - Fixed TCP dropping in background on Android - Fixed Connect screen always defaulting to BLE instead of last used transport --- android/app/src/main/AndroidManifest.xml | 3 +- lib/providers/app_state_provider.dart | 91 ++++++++++++++++++++++-- lib/services/background_service.dart | 1 + lib/services/transport/tcp_service.dart | 1 + 4 files changed, 89 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index bd38937..ab81f27 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,6 +27,7 @@ + diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 4703d07..c2ac446 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -246,6 +246,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Remembered device for quick reconnection (mobile only) RememberedDevice? _rememberedDevice; + // User's original preferences before zone admin overrides (single baseline). + // Saved on initial connect; restored before applying each new zone's policies. + int? _userOriginalAutoPingInterval; + bool? _userOriginalHybridMode; + bool? _userOriginalDiscDrop; + bool? _userOriginalFloodTraffic; + // Debug logs state (non-persistent, always starts false) bool _debugLogsEnabled = false; List _debugLogFiles = []; @@ -1991,6 +1998,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[CONN] No regional scope — using unscoped flood'); } + // Snapshot user's preferences before zone admin overrides (single baseline) + _userOriginalAutoPingInterval = _preferences.autoPingInterval; + _userOriginalHybridMode = _preferences.hybridModeEnabled; + _userOriginalDiscDrop = _preferences.discDropEnabled; + _userOriginalFloodTraffic = _preferences.floodTrafficEnabled; + if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { _preferences = _preferences.copyWith(hybridModeEnabled: true); debugLog('[CONN] Hybrid mode force-enabled by regional admin'); @@ -3406,6 +3419,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } _activeTransport = null; + // Restore transport tab selection so connection screen shows the right tab + if (_rememberedDevice != null) { + _selectedTransport = _rememberedDevice!.transportType; + } + _meshCoreConnection?.dispose(); _meshCoreConnection = null; _pingService?.dispose(); @@ -3436,6 +3454,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _regionalChannels = []; _scope = null; + // Clear user-original preference tracking + _userOriginalAutoPingInterval = null; + _userOriginalHybridMode = null; + _userOriginalDiscDrop = null; + _userOriginalFloodTraffic = null; + // Clear zone transfer state _sessionZoneCode = null; _isZoneTransferInProgress = false; @@ -4610,6 +4634,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _preferences = preferences; + // Update user-original baseline when user changes zone-overridable settings + if (_userOriginalAutoPingInterval != null) { + _userOriginalAutoPingInterval = preferences.autoPingInterval; + _userOriginalHybridMode = preferences.hybridModeEnabled; + _userOriginalDiscDrop = preferences.discDropEnabled; + _userOriginalFloodTraffic = preferences.floodTrafficEnabled; + } + // Clear restored flags — user is making a manual choice now _antennaRestoredFromDevice = false; _powerRestoredFromDevice = false; @@ -5612,8 +5644,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } if (!_autoPingEnabled) { - toggleAutoPing(previousMode); - debugLog('[ZONE GRACE] Auto-ping restored (mode=$previousMode)'); + final resolvedMode = _resolveAutoModeForZone(previousMode); + debugLog( + '[ZONE GRACE] Mode resolved: $previousMode → $resolvedMode'); + toggleAutoPing(resolvedMode); } }); } else { @@ -5648,6 +5682,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _abandonZoneGracePeriod(); } + /// Resolve the desired auto-ping mode against the current zone's permissions. + /// Maps invalid modes to the best valid alternative for the new zone. + AutoMode _resolveAutoModeForZone(AutoMode desired) { + final txOk = _apiService.txAllowed; + final rxOk = _apiService.rxAllowed; + final hybrid = _apiService.enforceHybrid; + + // No TX allowed → passive only + if (!txOk && rxOk) return AutoMode.passive; + + // TX allowed but zone enforces hybrid → map active to hybrid + if (txOk && hybrid && desired == AutoMode.active) return AutoMode.hybrid; + + // TX allowed, zone doesn't enforce hybrid → map hybrid back to active + // (unless user explicitly chose hybrid via _userOriginalHybridMode) + if (txOk && !hybrid && desired == AutoMode.hybrid) { + if (_userOriginalHybridMode != true) return AutoMode.active; + } + + // Targeted/trace mode is transport-level, not channel TX — keep as-is + return desired; + } + // ============================================ // Zone-to-Zone Transfer // ============================================ @@ -5894,7 +5951,26 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[ZONE] No regional scope — using unscoped flood'); } - // 15. Enforce regional admin policies from new zone + // 15. Restore user's original preferences, then apply new zone's policies + if (_userOriginalAutoPingInterval != null) { + _preferences = _preferences.copyWith( + autoPingInterval: _userOriginalAutoPingInterval!); + } + if (_userOriginalHybridMode != null) { + _preferences = + _preferences.copyWith(hybridModeEnabled: _userOriginalHybridMode!); + } + if (_userOriginalDiscDrop != null) { + _preferences = + _preferences.copyWith(discDropEnabled: _userOriginalDiscDrop!); + } + if (_userOriginalFloodTraffic != null) { + _preferences = + _preferences.copyWith(floodTrafficEnabled: _userOriginalFloodTraffic!); + } + debugLog( + '[ZONE] Preferences restored to user baseline before applying new zone policies'); + if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { _preferences = _preferences.copyWith(hybridModeEnabled: true); debugLog('[ZONE] Hybrid mode force-enabled by new zone admin'); @@ -5969,8 +6045,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (!_autoPingEnabled) { _cooldownTimer.stop(); - toggleAutoPing(previousMode); - debugLog('[ZONE] Auto-ping restored (mode=$previousMode)'); + final resolvedMode = _resolveAutoModeForZone(previousMode); + debugLog( + '[ZONE] Mode resolved for new zone: $previousMode → $resolvedMode'); + toggleAutoPing(resolvedMode); } }); } else { @@ -6393,7 +6471,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (json != null) { _rememberedDevice = RememberedDevice.fromJson(Map.from(json)); - debugLog('[APP] Loaded remembered device: ${_rememberedDevice!.name}'); + _selectedTransport = _rememberedDevice!.transportType; + debugLog('[APP] Loaded remembered device: ${_rememberedDevice!.name} (${_rememberedDevice!.transportType.name})'); notifyListeners(); } } catch (e) { diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 2d7bed5..d5dd44e 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -82,6 +82,7 @@ class BackgroundServiceManager { foregroundServiceTypes: [ AndroidForegroundType.location, AndroidForegroundType.connectedDevice, + AndroidForegroundType.dataSync, ], ), iosConfiguration: IosConfiguration( diff --git a/lib/services/transport/tcp_service.dart b/lib/services/transport/tcp_service.dart index d1a5428..16f7105 100644 --- a/lib/services/transport/tcp_service.dart +++ b/lib/services/transport/tcp_service.dart @@ -66,6 +66,7 @@ class TcpService extends StreamTransportBase { try { _socket = await Socket.connect(host, port, timeout: const Duration(seconds: 10)); + _socket!.setOption(SocketOption.tcpNoDelay, true); debugLog('[CONN] TCP connected to $host:$port'); setConnected(DiscoveredDevice( From a6edfca9ead5122b893360ff3e44ad044d57cf87 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 13:43:53 -0400 Subject: [PATCH 040/100] Add design spec for focus mode minimize feature Allows minimizing the ping details popup to a compact pill, giving full map zoom/pan access while keeping focus lines visible. --- .../2026-05-17-focus-mode-minimize-design.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-17-focus-mode-minimize-design.md diff --git a/docs/superpowers/specs/2026-05-17-focus-mode-minimize-design.md b/docs/superpowers/specs/2026-05-17-focus-mode-minimize-design.md new file mode 100644 index 0000000..a0399e4 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-focus-mode-minimize-design.md @@ -0,0 +1,134 @@ +# Focus Mode Minimize + +Allow users to minimize the ping details popup during focus mode to get a full map view with zoom/pan access, while keeping focus lines and context visible. + +## Current Behavior + +When a ping marker is tapped, focus mode activates: +1. `_activatePingFocus()` saves pre-focus camera state, hides unrelated markers/coverage, draws focus lines to repeaters, zooms to fit bounds +2. `showModalBottomSheet()` displays ping details (TX/RX/Disc/Trace variants) +3. The sheet uses `barrierColor: Colors.transparent` — map is visible but the invisible barrier blocks all touch events (no zoom/pan) +4. Closing the sheet (X or swipe-down) calls `_dismissPingFocus()` which restores the pre-focus map state + +**Problem:** Users cannot zoom or pan the map while viewing focus lines because the modal barrier intercepts all gestures. + +## Design + +### Minimize Button in Sheet Header + +Each of the 4 detail sheet variants (TX, RX, Disc, Trace) gains a minimize button (down-chevron icon) next to the existing close button: + +``` + [↑] TX Ping 12:34:05 ▽ ✕ +``` + +- `▽` (minimize): closes the sheet but keeps focus mode active → shows minimized pill +- `✕` (close): exits focus mode entirely (unchanged behavior) + +### Minimized Pill + +A compact, non-modal widget rendered in the map widget's `Stack`: + +``` +┌──────────────────────────────────────────────┐ +│ ↑ TX Ping 12:34:05 3 repeaters [△] [✕] │ +└──────────────────────────────────────────────┘ +``` + +- **Position:** Bottom of map, horizontally centered, above safe area inset +- **Style:** `surfaceContainerHighest` background, rounded corners (12px), subtle border — matches existing sheet theme +- **Content:** Ping type icon (colored), type label, formatted timestamp, repeater count, expand button, close button +- **Behavior:** + - `△` (expand): re-opens the full details sheet without re-zooming the map + - `✕` (close): calls `_dismissPingFocus()` to exit focus mode entirely + - Tapping the pill body also expands (same as △) + +### Map Interaction When Minimized + +Since the pill is a regular widget in the Stack (not a modal), the map underneath is fully interactable: +- Pinch-to-zoom works +- Pan/drag works +- Rotation works (if rotation lock is off) +- Map control buttons (top-right) remain accessible +- Focus lines and distance labels remain visible + +### What Stays the Same + +- Full details sheet content (repeater tables, location chip, path chain, etc.) +- Focus activation logic (zoom-to-fit, save pre-focus state, focus lines, coverage hide) +- `_dismissPingFocus()` restore behavior (auto-follow, rotation, zoom-back animation) +- Transparent barrier color on the full sheet when expanded +- Swipe-down on sheet still closes and exits focus (unchanged) + +## Implementation Details + +### New State in `_MapWidgetState` + +```dart +bool _focusPanelMinimized = false; +dynamic _focusedPingSource; // TxPing | RxPing | DiscLogEntry | TraceLogEntry +``` + +### Modified Methods + +**`_activatePingFocus()`:** +- If `_focusedPingLocation != null` (already in focus): skip saving pre-focus state, skip auto-follow/rotation changes — just update the focused ping/repeaters and zoom to new bounds +- Clear `_focusPanelMinimized = false` on activation + +**`_dismissPingFocus()`:** +- Additionally clears `_focusPanelMinimized = false` and `_focusedPingSource = null` + +**`_show{Tx,Rx,Disc,Trace}Details()`:** +- Store the ping/entry in `_focusedPingSource` before showing the sheet +- Accept optional `{bool fromMinimized = false}` parameter — when true, skip `_activatePingFocus` call (focus is already active) +- Add minimize `IconButton` to header row +- Change `.whenComplete(() => _dismissPingFocus())` to `.then((result) { ... })`: + - If `result == 'minimized'`: `setState(() => _focusPanelMinimized = true)` + - Otherwise: `_dismissPingFocus()` + +**Minimize button action:** `Navigator.pop(context, 'minimized')` + +### New Methods + +**`_buildMinimizedFocusPanel()`:** +- Returns the pill widget +- Derives title/icon/color from `_focusedPingSource` runtime type +- Repeater count from `_focusedRepeaters.length` +- Timestamp from `_focusedPingTimestamp` + +**`_reshowFocusPanel()`:** +- Checks `_focusedPingSource` type, calls the matching `_show*Details(source, fromMinimized: true)` +- Sets `_focusPanelMinimized = false` via setState + +### Build Method Change + +In the outer `Stack` (line ~1198), add after map controls: + +```dart +if (_focusPanelMinimized && _focusedPingLocation != null) + Positioned( + bottom: 16 + MediaQuery.of(context).padding.bottom, + left: 16, + right: 16, + child: _buildMinimizedFocusPanel(), + ), +``` + +### Edge Cases + +- **User taps a different ping while minimized:** `_handleSymbolTap` calls `_show*Details` for the new ping. `_activatePingFocus` detects existing focus, updates focus state without re-saving pre-focus. The new sheet opens, minimized state is cleared. +- **Auto-reconnect during minimized state:** Disconnect cleanup calls `_dismissPingFocus()` which clears everything including minimized state. +- **Orientation change while minimized:** Pill repositions naturally via `Positioned` + safe area insets. + +## Files Modified + +- `lib/widgets/map_widget.dart` — all changes are in this single file + +## Testing + +- Tap TX/RX/Disc/Trace ping → verify minimize button visible in header +- Tap minimize → verify pill appears, map is pannable/zoomable, focus lines stay +- Tap expand on pill → verify full sheet re-opens without re-zooming +- Tap close on pill → verify focus mode fully dismisses, map restores +- While minimized, tap a different ping → verify new focus replaces old +- Swipe-down on full sheet → verify still exits focus mode (unchanged) From e13e5f858fdb0698cc7fe7f90a6a122aa71bdb5a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 13:50:29 -0400 Subject: [PATCH 041/100] Add implementation plan for focus mode minimize feature 9-task plan covering state fields, _activatePingFocus re-activation, minimize buttons in all 4 detail sheets, minimized pill widget, and Stack integration. --- .../plans/2026-05-17-focus-mode-minimize.md | 822 ++++++++++++++++++ 1 file changed, 822 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-17-focus-mode-minimize.md diff --git a/docs/superpowers/plans/2026-05-17-focus-mode-minimize.md b/docs/superpowers/plans/2026-05-17-focus-mode-minimize.md new file mode 100644 index 0000000..1ac975a --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-focus-mode-minimize.md @@ -0,0 +1,822 @@ +# Focus Mode Minimize Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow users to minimize the focus mode ping details popup to a compact pill, giving full map zoom/pan access while keeping focus lines visible. + +**Architecture:** All changes are in `lib/widgets/map_widget.dart`. Two new state fields (`_focusPanelMinimized`, `_focusedPingSource`) track minimized state. The 4 existing `_show*Details` methods gain a minimize button and `fromMinimized` parameter. A new `_buildMinimizedFocusPanel()` method renders the pill in the map's outer `Stack`. `_activatePingFocus` and `_dismissPingFocus` are updated to manage the new state. + +**Tech Stack:** Flutter, MapLibre GL, Provider + +--- + +### Task 1: Add state fields and update `_activatePingFocus` + +**Files:** +- Modify: `lib/widgets/map_widget.dart:382` (state fields) +- Modify: `lib/widgets/map_widget.dart:5357-5413` (`_activatePingFocus`) + +- [ ] **Step 1: Add new state fields** + +After line 382 (`bool _wasRotatingBeforeFocus = false;`), add: + +```dart + bool _focusPanelMinimized = false; + dynamic _focusedPingSource; // TxPing | RxPing | DiscLogEntry | TraceLogEntry +``` + +- [ ] **Step 2: Update `_activatePingFocus` to handle re-activation** + +Replace the entire `_activatePingFocus` method (lines 5357-5413) with: + +```dart + void _activatePingFocus(LatLng pingLocation, DateTime timestamp, + List<_ResolvedRepeater> repeaters) { + // Drop repeaters lacking GPS — they would draw lines off to (0, 0). + // The bottom-sheet row builder still surfaces them with a no-location + // icon. If nothing is left to focus on, skip activation entirely so + // the user's current map view (zoom, autofollow, rotation) is kept. + final located = + repeaters.where((r) => r.repeater.hasLocation).toList(growable: false); + if (located.isEmpty) return; + + // Only save pre-focus state on first activation. When re-activating + // (e.g. user taps a different ping while already in focus, or expanding + // from minimized), we keep the original pre-focus snapshot so dismiss + // restores the correct camera position. + final alreadyInFocus = _focusedPingLocation != null; + if (!alreadyInFocus) { + final pos = _mapController?.cameraPosition; + _preFocusCenter = pos?.target; + _preFocusZoom = pos?.zoom; + _wasAutoFollowBeforeFocus = _autoFollow; + _wasRotatingBeforeFocus = !_alwaysNorth; + + if (_autoFollow) { + _autoFollow = false; + } + + // Lock to north-up during focus so the zoom-to-fit view is stable + if (!_alwaysNorth) { + _alwaysNorth = true; + // Snap rotation to north (instant — avoids wobble before zoom-to-fit animation) + if (_isMapReady && _mapController != null && _canAnimateCamera) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 1), + ); + } + } + } + + _focusPanelMinimized = false; + + setState(() { + _focusedPingLocation = pingLocation; + _focusedPingTimestamp = timestamp; + _focusedRepeaters = located; + }); + + // Hide the MeshMapper coverage raster overlay for a clean focus view. + // Uses opacity=0 rather than removing the layer to avoid a tile refetch + // on dismiss. No-ops gracefully if the layer isn't present. + _applyCoverageOverlayOpacity(0.0); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _focusedPingLocation != null) { + _zoomToFocusBounds(pingLocation, located); + } + }); + + // Once the 500ms zoom-to-fit animation settles, re-flow the distance + // labels so any that collide on screen slide along their lines to a + // non-overlapping slot. 600ms gives the camera a bit of buffer beyond + // the animation duration. + Future.delayed(const Duration(milliseconds: 600), () { + if (!mounted || _focusedPingLocation == null) return; + _reflowDistanceLabelsForCollisions(); + }); + } +``` + +- [ ] **Step 3: Verify no syntax errors** + +Run: `flutter analyze lib/widgets/map_widget.dart` +Expected: No new errors (existing warnings are OK) + +- [ ] **Step 4: Commit** + +```bash +git add lib/widgets/map_widget.dart +git commit -m "feat: add focus panel state fields and update _activatePingFocus for re-activation" +``` + +--- + +### Task 2: Update `_dismissPingFocus` to clear new state + +**Files:** +- Modify: `lib/widgets/map_widget.dart:5417-5461` (`_dismissPingFocus`) + +- [ ] **Step 1: Update `_dismissPingFocus`** + +Replace lines 5417-5461 (the full `_dismissPingFocus` method) with: + +```dart + void _dismissPingFocus() { + if (_focusedPingLocation == null || !mounted) return; + + final center = _preFocusCenter; + final zoom = _preFocusZoom; + final shouldRestoreAutoFollow = _wasAutoFollowBeforeFocus && !_autoFollow; + final shouldRestoreRotation = _wasRotatingBeforeFocus && _alwaysNorth; + + // Clear focus state but do NOT restore auto-follow or rotation yet — + // they would immediately trigger animations in the build method that + // override our zoom-back animation (both share _animationController). + setState(() { + _focusedPingLocation = null; + _focusedPingTimestamp = null; + _focusedRepeaters = []; + _focusPanelMinimized = false; + _focusedPingSource = null; + }); + + // Restore the MeshMapper coverage raster overlay opacity. Safe if the + // layer was hidden via the toggle during focus — setLayerProperties is + // wrapped in try/catch inside the helper. + final appState = context.read(); + _applyCoverageOverlayOpacity(appState.preferences.coverageOverlayOpacity); + + if (center != null && zoom != null) { + _animateToPositionWithZoom(center, zoom); + + // Restore auto-follow and heading rotation after the zoom-back + // animation completes (500ms) so they don't clobber it mid-flight. + if (shouldRestoreAutoFollow || shouldRestoreRotation) { + Future.delayed(const Duration(milliseconds: 550), () { + if (mounted) { + setState(() { + if (shouldRestoreAutoFollow) _autoFollow = true; + if (shouldRestoreRotation) _alwaysNorth = false; + }); + } + }); + } + } else { + setState(() { + if (shouldRestoreAutoFollow) _autoFollow = true; + if (shouldRestoreRotation) _alwaysNorth = false; + }); + } + } +``` + +- [ ] **Step 2: Verify no syntax errors** + +Run: `flutter analyze lib/widgets/map_widget.dart` +Expected: No new errors + +- [ ] **Step 3: Commit** + +```bash +git add lib/widgets/map_widget.dart +git commit -m "feat: update _dismissPingFocus to clear minimized state" +``` + +--- + +### Task 3: Add minimize button to `_showTxPingDetails` and change completion handler + +**Files:** +- Modify: `lib/widgets/map_widget.dart:5582-5908` (`_showTxPingDetails`) + +- [ ] **Step 1: Add `fromMinimized` parameter and store ping source** + +Replace the method signature and pre-sheet logic (lines 5582-5599): + +```dart + void _showTxPingDetails(TxPing ping, {bool fromMinimized = false}) { + // Use the heardRepeaters directly from the TxPing + final heardRepeaters = ping.heardRepeaters; + + // Resolve repeater matches (hoisted so bottom sheet can check ambiguity) + final resolved = heardRepeaters.isNotEmpty + ? _resolveRepeatersByHexIds( + heardRepeaters.map((r) => r.repeaterId).toList(), + snrValues: heardRepeaters.map((r) => r.snr).toList(), + ) + : <_ResolvedRepeater>[]; + final hasAmbiguous = resolved.any((r) => r.ambiguous); + + _focusedPingSource = ping; + + // Activate focus mode if the ping was heard by known repeaters + if (!fromMinimized && resolved.isNotEmpty) { + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + } +``` + +- [ ] **Step 2: Add minimize button next to close button in header** + +Find the close IconButton in the TX sheet (around line 5665): + +```dart + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), +``` + +Replace with: + +```dart + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), +``` + +- [ ] **Step 3: Change `.whenComplete` to `.then`** + +Find (around line 5907): + +```dart + ).whenComplete(() => _dismissPingFocus()); + } +``` + +(This is the one immediately after the TX sheet builder's closing brackets, before `_showRxPingDetails`.) + +Replace with: + +```dart + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); + } +``` + +- [ ] **Step 4: Verify no syntax errors** + +Run: `flutter analyze lib/widgets/map_widget.dart` +Expected: No new errors + +- [ ] **Step 5: Commit** + +```bash +git add lib/widgets/map_widget.dart +git commit -m "feat: add minimize button to TX ping details sheet" +``` + +--- + +### Task 4: Add minimize button to `_showRxPingDetails` + +**Files:** +- Modify: `lib/widgets/map_widget.dart:5911-6193` (`_showRxPingDetails`) + +- [ ] **Step 1: Add `fromMinimized` parameter and store ping source** + +Replace the method signature and pre-sheet logic (lines 5911-5923): + +```dart + void _showRxPingDetails(RxPing ping, {bool fromMinimized = false}) { + final snrColor = PingColors.snrColor(ping.snr); + final rssiColor = PingColors.rssiColor(ping.rssi); + + // Activate focus mode for the RX ping's repeater + final resolved = _resolveRepeatersByHexIds( + [ping.repeaterId], + snrValues: [ping.snr], + ); + + _focusedPingSource = ping; + + if (!fromMinimized && resolved.isNotEmpty) { + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + } +``` + +- [ ] **Step 2: Add minimize button next to close button in header** + +Find the close IconButton in the RX sheet (around line 5978): + +```dart + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), +``` + +Replace with: + +```dart + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), +``` + +- [ ] **Step 3: Change `.whenComplete` to `.then`** + +Find (around line 6193): + +```dart + ).whenComplete(() => _dismissPingFocus()); + } +``` + +(This is the one immediately before `_showDiscPingDetails`.) + +Replace with: + +```dart + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); + } +``` + +- [ ] **Step 4: Verify no syntax errors** + +Run: `flutter analyze lib/widgets/map_widget.dart` +Expected: No new errors + +- [ ] **Step 5: Commit** + +```bash +git add lib/widgets/map_widget.dart +git commit -m "feat: add minimize button to RX ping details sheet" +``` + +--- + +### Task 5: Add minimize button to `_showDiscPingDetails` + +**Files:** +- Modify: `lib/widgets/map_widget.dart:6197-6512` (`_showDiscPingDetails`) + +- [ ] **Step 1: Add `fromMinimized` parameter and store entry source** + +Replace the method signature and pre-sheet logic (lines 6197-6210): + +```dart + void _showDiscPingDetails(DiscLogEntry entry, {bool fromMinimized = false}) { + // Activate focus mode for discovered nodes with known repeater positions + _focusedPingSource = entry; + + if (!fromMinimized && entry.discoveredNodes.isNotEmpty) { + final resolved = _resolveRepeatersByHexIds( + entry.discoveredNodes.map((n) => n.repeaterId).toList(), + fullHexIds: entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), + snrValues: + entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), + ); + if (resolved.isNotEmpty) { + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + } + } +``` + +- [ ] **Step 2: Add minimize button next to close button in header** + +Find the close IconButton in the Disc sheet (around line 6276): + +```dart + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), +``` + +Replace with: + +```dart + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), +``` + +- [ ] **Step 3: Change `.whenComplete` to `.then`** + +Find (around line 6512): + +```dart + ).whenComplete(() => _dismissPingFocus()); + } +``` + +(This is the one immediately before `_buildRepeaterStatusChip`.) + +Replace with: + +```dart + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); + } +``` + +- [ ] **Step 4: Verify no syntax errors** + +Run: `flutter analyze lib/widgets/map_widget.dart` +Expected: No new errors + +- [ ] **Step 5: Commit** + +```bash +git add lib/widgets/map_widget.dart +git commit -m "feat: add minimize button to Disc ping details sheet" +``` + +--- + +### Task 6: Add minimize button to `_showTraceDetails` + +**Files:** +- Modify: `lib/widgets/map_widget.dart:4971-5275` (`_showTraceDetails`) + +- [ ] **Step 1: Add `fromMinimized` parameter and store entry source** + +Replace the method signature and pre-sheet logic (lines 4971-4982): + +```dart + void _showTraceDetails(TraceLogEntry entry, {bool fromMinimized = false}) { + // Activate focus mode for successful traces with a known repeater + _focusedPingSource = entry; + + if (!fromMinimized && entry.success) { + final resolved = _resolveRepeatersByHexIds( + [entry.targetRepeaterId], + snrValues: [entry.localSnr], + ); + if (resolved.isNotEmpty) { + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + } + } +``` + +- [ ] **Step 2: Add minimize button next to close button in header** + +Find the close IconButton in the Trace sheet (around line 5047): + +```dart + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), +``` + +Replace with: + +```dart + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), +``` + +- [ ] **Step 3: Change `.whenComplete` to `.then`** + +Find (around line 5275): + +```dart + ).whenComplete(() => _dismissPingFocus()); + } +``` + +(This is the one immediately before `/// DISC marker color`.) + +Replace with: + +```dart + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); + } +``` + +- [ ] **Step 4: Verify no syntax errors** + +Run: `flutter analyze lib/widgets/map_widget.dart` +Expected: No new errors + +- [ ] **Step 5: Commit** + +```bash +git add lib/widgets/map_widget.dart +git commit -m "feat: add minimize button to Trace details sheet" +``` + +--- + +### Task 7: Add `_buildMinimizedFocusPanel` and `_reshowFocusPanel` methods + +**Files:** +- Modify: `lib/widgets/map_widget.dart` (add new methods near `_dismissPingFocus`, around line 5462) + +- [ ] **Step 1: Add `_reshowFocusPanel` method** + +Insert after the closing brace of `_dismissPingFocus()` (which ends with `}`): + +```dart + void _reshowFocusPanel() { + setState(() => _focusPanelMinimized = false); + final source = _focusedPingSource; + if (source is TxPing) { + _showTxPingDetails(source, fromMinimized: true); + } else if (source is RxPing) { + _showRxPingDetails(source, fromMinimized: true); + } else if (source is DiscLogEntry) { + _showDiscPingDetails(source, fromMinimized: true); + } else if (source is TraceLogEntry) { + _showTraceDetails(source, fromMinimized: true); + } + } +``` + +- [ ] **Step 2: Add `_buildMinimizedFocusPanel` method** + +Insert immediately after `_reshowFocusPanel`: + +```dart + Widget _buildMinimizedFocusPanel() { + final source = _focusedPingSource; + String title; + IconData icon; + Color color; + if (source is TxPing) { + title = 'TX Ping'; + icon = Icons.arrow_upward; + color = PingColors.txSuccess; + } else if (source is RxPing) { + title = 'RX Ping'; + icon = Icons.arrow_downward; + color = PingColors.rx; + } else if (source is DiscLogEntry) { + title = 'Disc Request'; + icon = Icons.radar; + color = PingColors.discSuccess; + } else if (source is TraceLogEntry) { + title = 'Trace'; + icon = Icons.gps_fixed; + color = Colors.cyan; + } else { + return const SizedBox.shrink(); + } + + final repeaterCount = _focusedRepeaters.length; + final timeStr = + _focusedPingTimestamp != null ? _formatTime(_focusedPingTimestamp!) : ''; + + return GestureDetector( + onTap: _reshowFocusPanel, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(width: 8), + Text( + timeStr, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (repeaterCount > 0) ...[ + const SizedBox(width: 8), + Text( + '$repeaterCount repeater${repeaterCount != 1 ? 's' : ''}', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(width: 8), + GestureDetector( + onTap: _reshowFocusPanel, + child: Icon( + Icons.keyboard_arrow_up, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: _dismissPingFocus, + child: Icon( + Icons.close, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +``` + +- [ ] **Step 3: Verify no syntax errors** + +Run: `flutter analyze lib/widgets/map_widget.dart` +Expected: No new errors + +- [ ] **Step 4: Commit** + +```bash +git add lib/widgets/map_widget.dart +git commit -m "feat: add minimized focus panel pill and reshow logic" +``` + +--- + +### Task 8: Add minimized pill to the outer Stack in `build()` + +**Files:** +- Modify: `lib/widgets/map_widget.dart:1198-1246` (outer Stack in `build()`) + +- [ ] **Step 1: Add minimized pill to Stack** + +Find the tile load failure banner block (around line 1234-1244): + +```dart + // Tile load failure banner — appears if base tiles haven't finished + // loading within ${_tileLoadTimeoutSeconds}s after style load. + if (_tileLoadFailed) + Positioned( + top: topPadding, + left: 0, + right: 0, + child: Center( + child: _buildTileLoadFailedBanner(), + ), + ), + ], + ); +``` + +Replace with: + +```dart + // Tile load failure banner — appears if base tiles haven't finished + // loading within ${_tileLoadTimeoutSeconds}s after style load. + if (_tileLoadFailed) + Positioned( + top: topPadding, + left: 0, + right: 0, + child: Center( + child: _buildTileLoadFailedBanner(), + ), + ), + + // Minimized focus panel pill — shown when user minimizes a ping + // details sheet. Not a modal, so the map underneath stays fully + // interactable (zoom, pan, rotation). + if (_focusPanelMinimized && _focusedPingLocation != null) + Positioned( + bottom: 16 + MediaQuery.of(context).padding.bottom, + left: 16, + right: 16, + child: Center( + child: _buildMinimizedFocusPanel(), + ), + ), + ], + ); +``` + +- [ ] **Step 2: Verify no syntax errors** + +Run: `flutter analyze lib/widgets/map_widget.dart` +Expected: No new errors + +- [ ] **Step 3: Commit** + +```bash +git add lib/widgets/map_widget.dart +git commit -m "feat: render minimized focus pill in map Stack" +``` + +--- + +### Task 9: Final verification + +**Files:** +- Verify: `lib/widgets/map_widget.dart` + +- [ ] **Step 1: Run full static analysis** + +Run: `flutter analyze` +Expected: No new errors introduced. Pre-existing warnings are acceptable. + +- [ ] **Step 2: Run tests** + +Run: `flutter test` +Expected: All existing tests pass. + +- [ ] **Step 3: Final commit (squash-friendly message)** + +Only if there were any fixups needed from analysis/tests: + +```bash +git add lib/widgets/map_widget.dart +git commit -m "fix: address analysis warnings from focus minimize feature" +``` From 918b78ba6c521119026189f6b2185a7b7c4244e9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 13:54:22 -0400 Subject: [PATCH 042/100] feat: add focus panel state fields, update _activatePingFocus and _dismissPingFocus - Added _focusPanelMinimized and _focusedPingSource state fields - _activatePingFocus now skips pre-focus state snapshot when re-activating (already in focus), preserving the original camera position for dismiss; always resets _focusPanelMinimized to false on activation - _dismissPingFocus clears _focusPanelMinimized and _focusedPingSource in its setState block --- lib/widgets/map_widget.dart | 49 +++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index b8ff36c..e2f8dde 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -380,6 +380,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { double? _preFocusZoom; bool _wasAutoFollowBeforeFocus = false; bool _wasRotatingBeforeFocus = false; // true if heading mode was active + bool _focusPanelMinimized = false; + dynamic _focusedPingSource; // TxPing | RxPing | DiscLogEntry | TraceLogEntry // MapLibre style and overlay tracking int _lastCacheBust = 0; @@ -5364,28 +5366,37 @@ class _MapWidgetState extends State with WidgetsBindingObserver { repeaters.where((r) => r.repeater.hasLocation).toList(growable: false); if (located.isEmpty) return; - final pos = _mapController?.cameraPosition; - _preFocusCenter = pos?.target; - _preFocusZoom = pos?.zoom; - _wasAutoFollowBeforeFocus = _autoFollow; - _wasRotatingBeforeFocus = !_alwaysNorth; - - if (_autoFollow) { - _autoFollow = false; - } + // Only save pre-focus state on first activation. When re-activating + // (e.g. user taps a different ping while already in focus, or expanding + // from minimized), we keep the original pre-focus snapshot so dismiss + // restores the correct camera position. + final alreadyInFocus = _focusedPingLocation != null; + if (!alreadyInFocus) { + final pos = _mapController?.cameraPosition; + _preFocusCenter = pos?.target; + _preFocusZoom = pos?.zoom; + _wasAutoFollowBeforeFocus = _autoFollow; + _wasRotatingBeforeFocus = !_alwaysNorth; + + if (_autoFollow) { + _autoFollow = false; + } - // Lock to north-up during focus so the zoom-to-fit view is stable - if (!_alwaysNorth) { - _alwaysNorth = true; - // Snap rotation to north (instant — avoids wobble before zoom-to-fit animation) - if (_isMapReady && _mapController != null && _canAnimateCamera) { - _mapController!.animateCamera( - CameraUpdate.bearingTo(0), - duration: const Duration(milliseconds: 1), - ); + // Lock to north-up during focus so the zoom-to-fit view is stable + if (!_alwaysNorth) { + _alwaysNorth = true; + // Snap rotation to north (instant — avoids wobble before zoom-to-fit animation) + if (_isMapReady && _mapController != null && _canAnimateCamera) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 1), + ); + } } } + _focusPanelMinimized = false; + setState(() { _focusedPingLocation = pingLocation; _focusedPingTimestamp = timestamp; @@ -5429,6 +5440,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _focusedPingLocation = null; _focusedPingTimestamp = null; _focusedRepeaters = []; + _focusPanelMinimized = false; + _focusedPingSource = null; }); // Restore the MeshMapper coverage raster overlay opacity. Safe if the From 417d2ae0db2b4a29003d88b3ae9741aeb21ce9e1 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 13:56:15 -0400 Subject: [PATCH 043/100] feat: add minimize button to TX ping details sheet Adds fromMinimized parameter to _showTxPingDetails, stores _focusedPingSource, guards _activatePingFocus on fromMinimized, and adds minimize/close buttons with .then() handler to set _focusPanelMinimized state. --- lib/widgets/map_widget.dart | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index e2f8dde..c67cb96 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -5592,7 +5592,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } /// Show TX ping details popup - void _showTxPingDetails(TxPing ping) { + void _showTxPingDetails(TxPing ping, {bool fromMinimized = false}) { // Use the heardRepeaters directly from the TxPing final heardRepeaters = ping.heardRepeaters; @@ -5605,8 +5605,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { : <_ResolvedRepeater>[]; final hasAmbiguous = resolved.any((r) => r.ambiguous); + _focusedPingSource = ping; + // Activate focus mode if the ping was heard by known repeaters - if (resolved.isNotEmpty) { + if (!fromMinimized && resolved.isNotEmpty) { _activatePingFocus( LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); } @@ -5675,6 +5677,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ], ), ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), @@ -5917,7 +5927,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ), ), - ).whenComplete(() => _dismissPingFocus()); + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); } /// Show RX ping details popup From d835cf384f71db778876cf4c7e2c84cd19a423d7 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 14:07:49 -0400 Subject: [PATCH 044/100] Add minimized focus panel pill and wire into map Stack Adds _reshowFocusPanel() dispatch and _buildMinimizedFocusPanel() pill widget. Pill shows in the map Stack when user taps the minimize button, giving full map zoom/pan access while keeping focus lines. --- lib/widgets/map_widget.dart | 196 ++++++++++++++++++++++++++++++++++-- 1 file changed, 187 insertions(+), 9 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index c67cb96..32882ca 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1244,6 +1244,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: _buildTileLoadFailedBanner(), ), ), + + // Minimized focus panel pill — shown when user minimizes a ping + // details sheet. Not a modal, so the map underneath stays fully + // interactable (zoom, pan, rotation). + if (_focusPanelMinimized && _focusedPingLocation != null) + Positioned( + bottom: 16 + MediaQuery.of(context).padding.bottom, + left: 16, + right: 16, + child: Center( + child: _buildMinimizedFocusPanel(), + ), + ), ], ); } @@ -4970,9 +4983,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _focusedPingLocation!.longitude == lon; } - void _showTraceDetails(TraceLogEntry entry) { + void _showTraceDetails(TraceLogEntry entry, {bool fromMinimized = false}) { // Activate focus mode for successful traces with a known repeater - if (entry.success) { + _focusedPingSource = entry; + + if (!fromMinimized && entry.success) { final resolved = _resolveRepeatersByHexIds( [entry.targetRepeaterId], snrValues: [entry.localSnr], @@ -5046,6 +5061,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ], ), ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), @@ -5274,7 +5297,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ), ), - ).whenComplete(() => _dismissPingFocus()); + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); } /// DISC marker color (delegates to active palette) @@ -5473,6 +5502,122 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + void _reshowFocusPanel() { + setState(() => _focusPanelMinimized = false); + final source = _focusedPingSource; + if (source is TxPing) { + _showTxPingDetails(source, fromMinimized: true); + } else if (source is RxPing) { + _showRxPingDetails(source, fromMinimized: true); + } else if (source is DiscLogEntry) { + _showDiscPingDetails(source, fromMinimized: true); + } else if (source is TraceLogEntry) { + _showTraceDetails(source, fromMinimized: true); + } + } + + Widget _buildMinimizedFocusPanel() { + final source = _focusedPingSource; + String title; + IconData icon; + Color color; + if (source is TxPing) { + title = 'TX Ping'; + icon = Icons.arrow_upward; + color = PingColors.txSuccess; + } else if (source is RxPing) { + title = 'RX Ping'; + icon = Icons.arrow_downward; + color = PingColors.rx; + } else if (source is DiscLogEntry) { + title = 'Disc Request'; + icon = Icons.radar; + color = PingColors.discSuccess; + } else if (source is TraceLogEntry) { + title = 'Trace'; + icon = Icons.gps_fixed; + color = Colors.cyan; + } else { + return const SizedBox.shrink(); + } + + final repeaterCount = _focusedRepeaters.length; + final timeStr = + _focusedPingTimestamp != null ? _formatTime(_focusedPingTimestamp!) : ''; + + return GestureDetector( + onTap: _reshowFocusPanel, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(width: 8), + Text( + timeStr, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (repeaterCount > 0) ...[ + const SizedBox(width: 8), + Text( + '$repeaterCount repeater${repeaterCount != 1 ? 's' : ''}', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(width: 8), + GestureDetector( + onTap: _reshowFocusPanel, + child: Icon( + Icons.keyboard_arrow_up, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: _dismissPingFocus, + child: Icon( + Icons.close, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + Set _getDuplicateRepeaterIds(List repeaters) { final idCounts = {}; for (final repeater in repeaters) { @@ -5937,7 +6082,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } /// Show RX ping details popup - void _showRxPingDetails(RxPing ping) { + void _showRxPingDetails(RxPing ping, {bool fromMinimized = false}) { final snrColor = PingColors.snrColor(ping.snr); final rssiColor = PingColors.rssiColor(ping.rssi); @@ -5946,7 +6091,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { [ping.repeaterId], snrValues: [ping.snr], ); - if (resolved.isNotEmpty) { + + _focusedPingSource = ping; + + if (!fromMinimized && resolved.isNotEmpty) { _activatePingFocus( LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); } @@ -6004,6 +6152,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ], ), ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), @@ -6219,13 +6375,21 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ], ), ), - ).whenComplete(() => _dismissPingFocus()); + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); } /// Show DISC ping details popup - void _showDiscPingDetails(DiscLogEntry entry) { + void _showDiscPingDetails(DiscLogEntry entry, {bool fromMinimized = false}) { // Activate focus mode for discovered nodes with known repeater positions - if (entry.discoveredNodes.isNotEmpty) { + _focusedPingSource = entry; + + if (!fromMinimized && entry.discoveredNodes.isNotEmpty) { final resolved = _resolveRepeatersByHexIds( entry.discoveredNodes.map((n) => n.repeaterId).toList(), fullHexIds: entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), @@ -6302,6 +6466,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ], ), ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), @@ -6538,7 +6710,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ), ), - ).whenComplete(() => _dismissPingFocus()); + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); } /// Build a status chip for the repeater popup From af2e771fd630a3daf7066629969cff6411ad73e7 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 14:10:34 -0400 Subject: [PATCH 045/100] Hide control panel during focus mode so minimized pill is visible Adds isFocusModeActive to AppStateProvider, set by _activatePingFocus and cleared by _dismissPingFocus. Home screen hides the control panel (portrait and landscape) when focus mode is active, giving the minimized pill clear space and maximizing map visibility. --- lib/providers/app_state_provider.dart | 9 +++++++++ lib/screens/home_screen.dart | 12 ++++++------ lib/widgets/map_widget.dart | 3 +++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index c2ac446..b0e92fb 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -600,6 +600,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? get zoneTransferFrom => _zoneTransferFrom; String? get zoneTransferTo => _zoneTransferTo; + // Focus mode (map ping detail sheet active) + bool _isFocusModeActive = false; + bool get isFocusModeActive => _isFocusModeActive; + set isFocusModeActive(bool value) { + if (_isFocusModeActive != value) { + _isFocusModeActive = value; + notifyListeners(); + } + } // Repeater markers getters List get repeaters => List.unmodifiable(_repeaters); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index bd65adc..2ffd426 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -424,7 +424,7 @@ class _HomeScreenState extends State { if (!isLandscape) const StatusBar() else const SizedBox.shrink(), Expanded( child: MapWidget( - bottomPaddingPixels: isLandscape ? 0 : _getControlPanelHeight(), + bottomPaddingPixels: isLandscape || appState.isFocusModeActive ? 0 : _getControlPanelHeight(), mapControlsExpanded: isLandscape ? _mapControlsExpanded : null, onMapControlsToggle: isLandscape ? _toggleMapControls : null, ), @@ -476,8 +476,8 @@ class _HomeScreenState extends State { ), ), - // Portrait: bottom control panel - if (!isLandscape) + // Portrait: bottom control panel (hidden during focus mode) + if (!isLandscape && !appState.isFocusModeActive) Positioned( bottom: 0, left: 0, @@ -487,14 +487,14 @@ class _HomeScreenState extends State { : _buildControlPanel(), ), - // Landscape: side control panel or FAB - if (isLandscape && _showControlPanel) + // Landscape: side control panel or FAB (hidden during focus mode) + if (isLandscape && _showControlPanel && !appState.isFocusModeActive) Positioned( bottom: 16, left: leftInset, child: _buildLandscapeControlPanel(appState), ), - if (isLandscape && !_showControlPanel) + if (isLandscape && !_showControlPanel && !appState.isFocusModeActive) Positioned( bottom: 16, left: leftInset, diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 32882ca..1bbdc27 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -5401,6 +5401,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // restores the correct camera position. final alreadyInFocus = _focusedPingLocation != null; if (!alreadyInFocus) { + context.read().isFocusModeActive = true; final pos = _mapController?.cameraPosition; _preFocusCenter = pos?.target; _preFocusZoom = pos?.zoom; @@ -5457,6 +5458,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { void _dismissPingFocus() { if (_focusedPingLocation == null || !mounted) return; + context.read().isFocusModeActive = false; + final center = _preFocusCenter; final zoom = _preFocusZoom; final shouldRestoreAutoFollow = _wasAutoFollowBeforeFocus && !_autoFollow; From ef1d2c8652f21bbae618c4a1ca2f31146aef9c8a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 20:29:08 -0400 Subject: [PATCH 046/100] feat: enhance repeater ID display with ambiguity indicator and update UI elements --- lib/screens/log_screen.dart | 49 ++++++++++++++++++------------- lib/screens/main_scaffold.dart | 10 +++---- lib/screens/settings_screen.dart | 4 +++ lib/widgets/repeater_id_chip.dart | 14 ++++++--- 4 files changed, 47 insertions(+), 30 deletions(-) diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 16a88b3..d84db19 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -340,10 +340,6 @@ class _AllPingsTabState extends State<_AllPingsTab> { } /// True if the query looks like a hex string (only 0-9, a-f). - static bool _isHexQuery(String query) { - return RegExp(r'^[0-9a-fA-F]+$').hasMatch(query); - } - /// Whether an entry matches the current search query. bool _matchesSearch(UnifiedPingLogEntry entry, List repeaters) { if (_searchQuery.isEmpty) return true; @@ -392,8 +388,6 @@ class _AllPingsTabState extends State<_AllPingsTab> { /// Only shown when searching by name (non-hex query) and a repeater ID is ambiguous. bool _shouldShowAmbiguity( UnifiedPingLogEntry entry, List repeaters) { - if (_searchQuery.isEmpty || _isHexQuery(_searchQuery)) return false; - switch (entry.type) { case PingLogType.tx: return entry.asTx.events @@ -706,7 +700,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { // Repeaters table if (entry.events.isNotEmpty) ...[ const SizedBox(height: 10), - _buildRepeaterTable(context, entry.events), + _buildRepeaterTable(context, entry.events, widget.repeaters), ] else ...[ const SizedBox(height: 8), Text( @@ -725,7 +719,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - Widget _buildRepeaterTable(BuildContext context, List events) { + Widget _buildRepeaterTable( + BuildContext context, List events, List repeaters) { var maxIdLen = 0; for (final e in events) { if (e.repeaterId.length > maxIdLen) maxIdLen = e.repeaterId.length; @@ -753,17 +748,18 @@ class _AllPingsTabState extends State<_AllPingsTab> { ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - ...events.map( - (event) => _buildTxRepeaterRow(context, event, nodeWidth)), + ...events.map((event) => + _buildTxRepeaterRow(context, event, nodeWidth, repeaters)), ], ), ); } - Widget _buildTxRepeaterRow( - BuildContext context, RxEvent event, double nodeWidth) { + Widget _buildTxRepeaterRow(BuildContext context, RxEvent event, + double nodeWidth, List repeaters) { final snrColor = _snrColor(event.severity); final rssiColor = _rssiColor(event.rssi); + final isAmbiguous = _isAmbiguousId(event.repeaterId, repeaters); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, event.repeaterId), child: Padding( @@ -773,7 +769,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { RepeaterIdChip( repeaterId: event.repeaterId, fontSize: 14, - width: nodeWidth), + width: nodeWidth, + isAmbiguous: isAmbiguous), Expanded( child: Center( child: _buildChip( @@ -799,6 +796,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { final snrColor = _snrColor(entry.severity); final rssiColor = _rssiColor(entry.rssi); final nodeWidth = _nodeColumnWidthForLength(entry.repeaterId.length); + final isAmbiguous = _isAmbiguousId(entry.repeaterId, widget.repeaters); return Card( margin: const EdgeInsets.only(bottom: 8), @@ -857,7 +855,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { RepeaterIdChip( repeaterId: entry.repeaterId, fontSize: 14, - width: nodeWidth), + width: nodeWidth, + isAmbiguous: isAmbiguous), Expanded( child: Center( child: _buildChip( @@ -968,8 +967,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - ...entry.discoveredNodes - .map((node) => _buildDiscNodeRow(context, node)), + ...entry.discoveredNodes.map((node) => + _buildDiscNodeRow(context, node, widget.repeaters)), ], ), ), @@ -991,10 +990,12 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - Widget _buildDiscNodeRow(BuildContext context, DiscoveredNodeEntry node) { + Widget _buildDiscNodeRow( + BuildContext context, DiscoveredNodeEntry node, List repeaters) { final rxSnrColor = _snrColorFromValue(node.localSnr); final rssiColor = _rssiColor(node.localRssi); final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); + final isAmbiguous = _isAmbiguousId(node.repeaterId, repeaters); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, @@ -1009,7 +1010,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { children: [ Flexible( child: RepeaterIdChip( - repeaterId: node.repeaterId, fontSize: 14)), + repeaterId: node.repeaterId, + fontSize: 14, + isAmbiguous: isAmbiguous)), Text( node.nodeTypeLabel, style: TextStyle( @@ -1094,7 +1097,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - _buildTraceNodeRow(context, entry), + _buildTraceNodeRow(context, entry, widget.repeaters), ], ), ), @@ -1116,10 +1119,12 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - Widget _buildTraceNodeRow(BuildContext context, TraceLogEntry entry) { + Widget _buildTraceNodeRow( + BuildContext context, TraceLogEntry entry, List repeaters) { final rxSnrColor = _snrColorFromNullableValue(entry.localSnr); final rssiColor = _rssiColor(entry.localRssi); final txSnrColor = _snrColorFromNullableValue(entry.remoteSnr); + final isAmbiguous = _isAmbiguousId(entry.targetRepeaterId, repeaters); return Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), @@ -1128,7 +1133,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { SizedBox( width: 70, child: RepeaterIdChip( - repeaterId: entry.targetRepeaterId, fontSize: 14)), + repeaterId: entry.targetRepeaterId, + fontSize: 14, + isAmbiguous: isAmbiguous)), Expanded( child: Center( child: _buildChip( diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index b080be7..27815d7 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -342,8 +342,8 @@ class _MainScaffoldState extends State { showBadge: appState.errorLogEntries.isNotEmpty, ), _buildCompactNavItem( - icon: Icons.show_chart_outlined, - activeIcon: Icons.show_chart, + icon: Icons.history_outlined, + activeIcon: Icons.history, index: 2, ), _buildCompactNavItem( @@ -433,9 +433,9 @@ class _MainScaffoldState extends State { label: 'Log', ), const BottomNavigationBarItem( - icon: Icon(Icons.show_chart_outlined), - activeIcon: Icon(Icons.show_chart), - label: 'Graph', + icon: Icon(Icons.history_outlined), + activeIcon: Icon(Icons.history), + label: 'History', ), BottomNavigationBarItem( icon: Icon( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index b0f1627..11e19cd 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2215,6 +2215,10 @@ class _SettingsScreenState extends State { message = 'Another upload is already in progress'; backgroundColor = Colors.orange; break; + case OfflineUploadResult.zoneDisabled: + message = 'Upload failed - wardriving is disabled in this zone'; + backgroundColor = Colors.red; + break; } ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 533990a..32371b8 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -23,11 +23,15 @@ class RepeaterIdChip extends StatelessWidget { /// Optional SizedBox width constraint (e.g., 50 or 60) final double? width; + /// When true, the info icon is rendered in amber to flag an ambiguous ID. + final bool isAmbiguous; + const RepeaterIdChip({ super.key, required this.repeaterId, this.fontSize = 11, this.width, + this.isAmbiguous = false, }); @override @@ -57,10 +61,12 @@ class RepeaterIdChip extends StatelessWidget { Icon( Icons.info_outline, size: fontSize - 1, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant - .withValues(alpha: 0.5), + color: isAmbiguous + ? const Color(0xFFF59E0B) + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.5), ), ], ); From 415f11dc1112fdce383bd2114043f85afd1ffa93 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 21:25:02 -0400 Subject: [PATCH 047/100] - Session History: The "Graph" tab is now "History." View past sessions on the map with ping markers and camera fit-to-bounds, or open the Noise Floor graph. Tapping history markers opens the same detail sheets as live sessions. --- lib/providers/app_state_provider.dart | 31 +++ lib/screens/graph_screen.dart | 253 +++++++++++------ lib/screens/home_screen.dart | 16 +- lib/widgets/map_widget.dart | 376 +++++++++++++++++++++----- 4 files changed, 525 insertions(+), 151 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index b0e92fb..c0d7a59 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -379,6 +379,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { List _storedNoiseFloorSessions = []; Box? _noiseFloorSessionBox; + // History session map view + List? _historySessionMarkers; + bool _viewingHistorySession = false; + // Flag to track if preferences have been loaded from storage bool _preferencesLoaded = false; @@ -630,6 +634,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { List get storedNoiseFloorSessions => List.unmodifiable(_storedNoiseFloorSessions); + // History session map view getters + List? get historySessionMarkers => _historySessionMarkers; + bool get viewingHistorySession => _viewingHistorySession; + // Audio service getters bool get isSoundEnabled => _audioService.isEnabled; bool get isTxSoundEnabled => _audioService.isTxEnabled; @@ -7013,6 +7021,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// Show a historical session's ping markers on the map + void viewHistorySessionOnMap(NoiseFloorSession session) { + final markers = session.markers + .where((m) => m.latitude != null && m.longitude != null) + .toList(); + if (markers.isEmpty) return; + + _historySessionMarkers = markers; + _viewingHistorySession = true; + _requestMapTabSwitch = true; + debugLog('[GRAPH] Viewing session on map: ${markers.length} markers'); + notifyListeners(); + } + + /// Dismiss the history session map view + void clearHistorySession() { + if (!_viewingHistorySession) return; + _historySessionMarkers = null; + _viewingHistorySession = false; + debugLog('[GRAPH] Cleared history session map view'); + notifyListeners(); + } + // ============================================ // Cleanup // ============================================ diff --git a/lib/screens/graph_screen.dart b/lib/screens/graph_screen.dart index 623520d..6fe8e08 100644 --- a/lib/screens/graph_screen.dart +++ b/lib/screens/graph_screen.dart @@ -7,7 +7,7 @@ import '../models/noise_floor_session.dart'; import '../providers/app_state_provider.dart'; import '../widgets/noise_floor_chart.dart'; -/// Screen showing noise floor session history and graph popup +/// Screen showing session history with options to view on map or noise floor graph class GraphScreen extends StatelessWidget { const GraphScreen({super.key}); @@ -20,8 +20,7 @@ class GraphScreen extends StatelessWidget { return Scaffold( appBar: AppBar( toolbarHeight: 40, - title: - const Text('Noise Floor History', style: TextStyle(fontSize: 18)), + title: const Text('Session History', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, actions: [ if (sessions.isNotEmpty) @@ -32,16 +31,16 @@ class GraphScreen extends StatelessWidget { ), ], ), - body: _buildBody(context, currentSession, sessions), + body: _buildBody(context, appState, currentSession, sessions), ); } Widget _buildBody( BuildContext context, + AppStateProvider appState, NoiseFloorSession? currentSession, List sessions, ) { - // Show empty state if no sessions if (currentSession == null && sessions.isEmpty) { return Center( child: Padding( @@ -50,7 +49,7 @@ class GraphScreen extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.show_chart, + Icons.history, size: 64, color: Theme.of(context).colorScheme.onSurfaceVariant, ), @@ -61,7 +60,7 @@ class GraphScreen extends StatelessWidget { ), const SizedBox(height: 8), Text( - 'Enable Active or Passive Mode to start recording noise floor data.', + 'Enable Active or Passive Mode to start recording session data.', textAlign: TextAlign.center, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, @@ -75,27 +74,33 @@ class GraphScreen extends StatelessWidget { return ListView( children: [ - // Current active session (if recording) if (currentSession != null) ...[ - _SessionListTile( + _SessionCard( session: currentSession, isActive: true, - onTap: () => + onViewGraph: () => _openFullScreenGraph(context, currentSession, isLive: true), + onViewMap: null, ), - if (sessions.isNotEmpty) const Divider(), + if (sessions.isNotEmpty) const SizedBox(height: 4), ], - - // Stored sessions (last 10) - ...sessions.map((session) => _SessionListTile( + ...sessions.map((session) => _SessionCard( session: session, isActive: false, - onTap: () => _openFullScreenGraph(context, session), + onViewGraph: () => _openFullScreenGraph(context, session), + onViewMap: _sessionHasGpsMarkers(session) + ? () => appState.viewHistorySessionOnMap(session) + : null, )), ], ); } + bool _sessionHasGpsMarkers(NoiseFloorSession session) { + return session.markers + .any((m) => m.latitude != null && m.longitude != null); + } + void _openFullScreenGraph(BuildContext context, NoiseFloorSession session, {bool isLive = false}) { Navigator.of(context).push( @@ -112,7 +117,7 @@ class GraphScreen extends StatelessWidget { builder: (context) => AlertDialog( title: const Text('Clear All Sessions?'), content: const Text( - 'This will delete all saved noise floor session graphs. The current active session will not be affected.'), + 'This will delete all saved session history. The current active session will not be affected.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -131,6 +136,162 @@ class GraphScreen extends StatelessWidget { } } +/// Card showing a session with action buttons +class _SessionCard extends StatelessWidget { + final NoiseFloorSession session; + final bool isActive; + final VoidCallback onViewGraph; + final VoidCallback? onViewMap; + + const _SessionCard({ + required this.session, + required this.isActive, + required this.onViewGraph, + required this.onViewMap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row: mode icon, mode name, live badge, time + Row( + children: [ + Icon( + _modeIcon, + size: 20, + color: isActive ? Colors.green : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + session.modeDisplay, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + if (isActive) ...[ + const SizedBox(width: 8), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green), + ), + child: const Text( + 'LIVE', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.green, + ), + ), + ), + ], + const Spacer(), + Text( + _formatDateTime(session.startTime), + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 6), + // Stats row + Text( + '${session.durationDisplay} duration | ' + '${session.samples.length} samples | ' + '${session.markers.length} events', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + // Action buttons row + Row( + children: [ + Expanded( + child: _ActionButton( + icon: Icons.map_outlined, + label: 'View on Map', + onPressed: onViewMap, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _ActionButton( + icon: Icons.show_chart, + label: 'Noise Floor', + onPressed: onViewGraph, + ), + ), + ], + ), + ], + ), + ), + ); + } + + IconData get _modeIcon => switch (session.mode) { + 'active' => Icons.send, + 'hybrid' => Icons.swap_horiz, + 'targeted' => Icons.track_changes, + _ => Icons.hearing, + }; + + String _formatDateTime(DateTime dt) { + return '${dt.month}/${dt.day} ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}'; + } +} + +/// Styled action button for session cards +class _ActionButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback? onPressed; + + const _ActionButton({ + required this.icon, + required this.label, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return OutlinedButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 16), + label: Text(label, style: const TextStyle(fontSize: 12)), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + minimumSize: const Size(0, 34), + foregroundColor: + onPressed != null ? colorScheme.primary : colorScheme.outline, + side: BorderSide( + color: onPressed != null + ? colorScheme.outline + : colorScheme.outline.withValues(alpha: 0.4), + ), + ), + ); + } +} + /// Full-screen graph page with pinch-to-zoom and pan class _FullScreenGraphPage extends StatefulWidget { final NoiseFloorSession session; @@ -161,7 +322,6 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { _session = current; }); } else { - // Session ended while viewing _liveTimer?.cancel(); _liveTimer = null; setState(() { @@ -214,7 +374,6 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { onPressed: () => Navigator.pop(context), ), actions: [ - // Reset zoom button IconButton( icon: const Icon(Icons.zoom_out_map), tooltip: 'Reset zoom', @@ -227,7 +386,6 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { body: SafeArea( child: Column( children: [ - // Session info header Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( @@ -254,7 +412,6 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { ), ), const Divider(height: 1), - // Hint text Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Text( @@ -266,7 +423,6 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { ), ), ), - // Chart Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(8, 0, 16, 8), @@ -287,58 +443,3 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { return '${dt.month}/${dt.day} ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}'; } } - -/// List tile showing a session summary -class _SessionListTile extends StatelessWidget { - final NoiseFloorSession session; - final bool isActive; - final VoidCallback onTap; - - const _SessionListTile({ - required this.session, - required this.isActive, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return ListTile( - leading: Icon( - session.mode == 'active' ? Icons.send : Icons.hearing, - color: isActive ? Colors.green : null, - ), - title: Text(session.modeDisplay), - subtitle: Text( - '${_formatDateTime(session.startTime)} | ${session.durationDisplay} | ' - '${session.samples.length} samples, ${session.markers.length} events', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - trailing: isActive - ? Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.green), - ), - child: const Text( - 'LIVE', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - color: Colors.green, - ), - ), - ) - : const Icon(Icons.chevron_right), - onTap: onTap, - ); - } - - String _formatDateTime(DateTime dt) { - return '${dt.month}/${dt.day} ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}'; - } -} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 2ffd426..7960b01 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -424,7 +424,7 @@ class _HomeScreenState extends State { if (!isLandscape) const StatusBar() else const SizedBox.shrink(), Expanded( child: MapWidget( - bottomPaddingPixels: isLandscape || appState.isFocusModeActive ? 0 : _getControlPanelHeight(), + bottomPaddingPixels: isLandscape || appState.isFocusModeActive || appState.viewingHistorySession ? 0 : _getControlPanelHeight(), mapControlsExpanded: isLandscape ? _mapControlsExpanded : null, onMapControlsToggle: isLandscape ? _toggleMapControls : null, ), @@ -432,8 +432,8 @@ class _HomeScreenState extends State { ], ), - // Landscape: floating status bar overlay - if (isLandscape) + // Landscape: floating status bar overlay (hidden during history view) + if (isLandscape && !appState.viewingHistorySession) Positioned( top: 16, left: leftInset + 72, @@ -476,8 +476,8 @@ class _HomeScreenState extends State { ), ), - // Portrait: bottom control panel (hidden during focus mode) - if (!isLandscape && !appState.isFocusModeActive) + // Portrait: bottom control panel (hidden during focus mode and history view) + if (!isLandscape && !appState.isFocusModeActive && !appState.viewingHistorySession) Positioned( bottom: 0, left: 0, @@ -487,14 +487,14 @@ class _HomeScreenState extends State { : _buildControlPanel(), ), - // Landscape: side control panel or FAB (hidden during focus mode) - if (isLandscape && _showControlPanel && !appState.isFocusModeActive) + // Landscape: side control panel or FAB (hidden during focus mode and history view) + if (isLandscape && _showControlPanel && !appState.isFocusModeActive && !appState.viewingHistorySession) Positioned( bottom: 16, left: leftInset, child: _buildLandscapeControlPanel(appState), ), - if (isLandscape && !_showControlPanel && !appState.isFocusModeActive) + if (isLandscape && !_showControlPanel && !appState.isFocusModeActive && !appState.viewingHistorySession) Positioned( bottom: 16, left: leftInset, diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 1bbdc27..7a31be4 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -12,6 +12,7 @@ import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:provider/provider.dart'; import '../models/log_entry.dart'; +import '../models/noise_floor_session.dart'; import '../models/ping_data.dart'; import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; @@ -372,6 +373,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Map navigation trigger tracking (from log screen) int _lastNavigationTrigger = 0; + // History session map view tracking + bool _wasViewingHistory = false; + // Ping focus mode — highlight connected repeaters when a marker is tapped LatLng? _focusedPingLocation; DateTime? _focusedPingTimestamp; @@ -1119,6 +1123,22 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + // Handle history session view — fit camera to session bounds on transition + if (_isMapReady && appState.viewingHistorySession && !_wasViewingHistory) { + _wasViewingHistory = true; + final markers = appState.historySessionMarkers; + if (markers != null && markers.isNotEmpty) { + _autoFollow = false; + _alwaysNorth = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _mapController == null) return; + _fitCameraToHistoryMarkers(markers); + }); + } + } else if (!appState.viewingHistorySession && _wasViewingHistory) { + _wasViewingHistory = false; + } + // Sync native annotations whenever marker data changes (provider triggers // a rebuild). The version hash detects changes to ping/repeater counts, // GPS position, focus state, prefs, etc. Native annotations stay in sync @@ -1210,28 +1230,30 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: SizedBox.expand(), ), - // GPS Info + Top Repeaters overlay (top-left, respects dynamic island in landscape) - Positioned( - top: topPadding, - left: leftPadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildGpsInfoOverlay(appState), - if (appState.preferences.showTopRepeaters) ...[ - const SizedBox(height: 6), - _buildTopRepeatersOverlay(appState), + // GPS Info + Top Repeaters overlay (hidden during history view) + if (!appState.viewingHistorySession) + Positioned( + top: topPadding, + left: leftPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildGpsInfoOverlay(appState), + if (appState.preferences.showTopRepeaters) ...[ + const SizedBox(height: 6), + _buildTopRepeatersOverlay(appState), + ], ], - ], + ), ), - ), - // Map controls - top-right in both orientations, collapsible - Positioned( - top: topPadding, - right: 8, - child: _buildCollapsibleMapControls(appState), - ), + // Map controls (hidden during history view) + if (!appState.viewingHistorySession) + Positioned( + top: topPadding, + right: 8, + child: _buildCollapsibleMapControls(appState), + ), // Tile load failure banner — appears if base tiles haven't finished // loading within ${_tileLoadTimeoutSeconds}s after style load. @@ -1257,6 +1279,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: _buildMinimizedFocusPanel(), ), ), + + // History session pill (bottom, styled like minimized focus panel) + if (appState.viewingHistorySession) + Positioned( + bottom: 16 + MediaQuery.of(context).padding.bottom, + left: 16, + right: 16, + child: Center( + child: _buildHistoryBanner(appState), + ), + ), ], ); } @@ -1291,6 +1324,67 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ); } + /// Pill shown at the bottom of the map when viewing a history session + Widget _buildHistoryBanner(AppStateProvider appState) { + final count = appState.historySessionMarkers?.length ?? 0; + return GestureDetector( + onTap: () => appState.clearHistorySession(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.history, + color: Theme.of(context).colorScheme.primary, + size: 18, + ), + const SizedBox(width: 8), + Text( + 'Session History', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(width: 8), + Text( + '$count events', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => appState.clearHistorySession(), + child: Icon( + Icons.close, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + /// Collapsible map controls (toggle at top, expands downward) Widget _buildCollapsibleMapControls(AppStateProvider appState) { // Use external state if provided, otherwise use internal state @@ -1546,7 +1640,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { .firstOrNull; if (entry != null) _showTraceDetails(entry); break; - // gps, distance-label: not tappable in original — no action + case 'history_tx': + case 'history_rx': + case 'history_disc': + case 'history_trace': + final marker = appState.historySessionMarkers + ?.where((m) => m.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (marker != null) _showHistoryMarkerAsLive(marker); + break; } } @@ -3187,56 +3289,74 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } - // TX pings - for (final ping in appState.txPings) { - await syncOne( - type: 'tx', - lat: ping.latitude, - lon: ping.longitude, - ts: ping.timestamp, - success: ping.heardRepeaters.isNotEmpty, - idForMetadata: ping.timestamp.millisecondsSinceEpoch, - ); - } + // When viewing a history session, show only those markers + if (appState.viewingHistorySession && + appState.historySessionMarkers != null) { + for (final marker in appState.historySessionMarkers!) { + if (marker.latitude == null || marker.longitude == null) continue; + final mapping = _historyMarkerType(marker.type); + await syncOne( + type: 'history_${mapping.type}', + lat: marker.latitude!, + lon: marker.longitude!, + ts: marker.timestamp, + success: mapping.success, + idForMetadata: marker.timestamp.millisecondsSinceEpoch, + iconImageOverride: _MapImages.coverage(mapping.type, mapping.success), + ); + } + } else { + // TX pings + for (final ping in appState.txPings) { + await syncOne( + type: 'tx', + lat: ping.latitude, + lon: ping.longitude, + ts: ping.timestamp, + success: ping.heardRepeaters.isNotEmpty, + idForMetadata: ping.timestamp.millisecondsSinceEpoch, + ); + } - // RX pings - for (final ping in appState.rxPings) { - await syncOne( - type: 'rx', - lat: ping.latitude, - lon: ping.longitude, - ts: ping.timestamp, - success: true, // RX has no fail state — always uses the rx color - idForMetadata: ping.timestamp.millisecondsSinceEpoch, - ); - } + // RX pings + for (final ping in appState.rxPings) { + await syncOne( + type: 'rx', + lat: ping.latitude, + lon: ping.longitude, + ts: ping.timestamp, + success: true, + idForMetadata: ping.timestamp.millisecondsSinceEpoch, + ); + } - // DISC entries (success = received node responses; drop = treat as TX fail) - for (final entry in appState.discLogEntries) { - final received = entry.nodeCount > 0; - final renderAsTxFail = !received && appState.discDropEnabled; - await syncOne( - type: 'disc', - lat: entry.latitude, - lon: entry.longitude, - ts: entry.timestamp, - success: received, - idForMetadata: entry.timestamp.millisecondsSinceEpoch, - iconImageOverride: - renderAsTxFail ? _MapImages.coverage('tx', false) : null, - ); - } + // DISC entries (success = received node responses; drop = treat as TX fail) + for (final entry in appState.discLogEntries) { + final received = entry.nodeCount > 0; + final renderAsTxFail = !received && appState.discDropEnabled; + await syncOne( + type: 'disc', + lat: entry.latitude, + lon: entry.longitude, + ts: entry.timestamp, + success: received, + idForMetadata: entry.timestamp.millisecondsSinceEpoch, + iconImageOverride: + renderAsTxFail ? _MapImages.coverage('tx', false) : null, + ); + } - // Trace entries - for (final entry in appState.traceLogEntries) { - await syncOne( - type: 'trace', - lat: entry.latitude, - lon: entry.longitude, - ts: entry.timestamp, - success: entry.success, - idForMetadata: entry.timestamp.millisecondsSinceEpoch, - ); + // Trace entries + for (final entry in appState.traceLogEntries) { + await syncOne( + type: 'trace', + lat: entry.latitude, + lon: entry.longitude, + ts: entry.timestamp, + success: entry.success, + idForMetadata: entry.timestamp.millisecondsSinceEpoch, + ); + } } // Remove symbols for pings that no longer exist (e.g., user cleared markers) @@ -3886,6 +4006,59 @@ class _MapWidgetState extends State with WidgetsBindingObserver { await _syncDistanceLabels(appState); } + /// Fit camera to show all history session markers + void _fitCameraToHistoryMarkers(List markers) { + final pts = markers + .where((m) => m.latitude != null && m.longitude != null) + .toList(); + if (pts.isEmpty) return; + + if (pts.length == 1) { + _animateToPositionWithZoom( + LatLng(pts.first.latitude!, pts.first.longitude!), + 16.0 - _zoomEpsilon, + ); + return; + } + + double minLat = pts.first.latitude!; + double maxLat = pts.first.latitude!; + double minLon = pts.first.longitude!; + double maxLon = pts.first.longitude!; + for (final p in pts) { + if (p.latitude! < minLat) minLat = p.latitude!; + if (p.latitude! > maxLat) maxLat = p.latitude!; + if (p.longitude! < minLon) minLon = p.longitude!; + if (p.longitude! > maxLon) maxLon = p.longitude!; + } + + _mapController!.animateCamera( + CameraUpdate.newLatLngBounds( + LatLngBounds( + southwest: LatLng(minLat, minLon), + northeast: LatLng(maxLat, maxLon), + ), + left: 60, + top: 60, + right: 60, + bottom: 60, + ), + duration: const Duration(milliseconds: 500), + ); + } + + /// Map PingEventType to coverage marker type and success state + static ({String type, bool success}) _historyMarkerType(PingEventType t) => + switch (t) { + PingEventType.txSuccess => (type: 'tx', success: true), + PingEventType.txFail => (type: 'tx', success: false), + PingEventType.rx => (type: 'rx', success: true), + PingEventType.discSuccess => (type: 'disc', success: true), + PingEventType.discFail => (type: 'disc', success: false), + PingEventType.traceSuccess => (type: 'trace', success: true), + PingEventType.traceFail => (type: 'trace', success: false), + }; + /// Compute a version hash of all data that affects the marker list. /// When this changes, the cached marker list is rebuilt; otherwise it's reused /// across camera-change rebuilds (which happen at ~60Hz during pan/zoom). @@ -3920,6 +4093,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { appState.preferences.markerStyle, txEchoTotal, discNodeTotal, + appState.viewingHistorySession, + appState.historySessionMarkers?.length ?? 0, ); } @@ -5621,6 +5796,73 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ); } + void _showHistoryMarkerAsLive(PingEventMarker marker) { + if (marker.latitude == null || marker.longitude == null) return; + final lat = marker.latitude!; + final lon = marker.longitude!; + final repeaters = marker.repeaters ?? []; + + switch (marker.type) { + case PingEventType.txSuccess: + case PingEventType.txFail: + _showTxPingDetails(TxPing( + latitude: lat, + longitude: lon, + timestamp: marker.timestamp, + power: 0, + deviceId: '', + heardRepeaters: repeaters + .map((r) => HeardRepeater( + repeaterId: r.repeaterId, + snr: r.snr, + rssi: r.rssi, + )) + .toList(), + )); + case PingEventType.rx: + final r = repeaters.isNotEmpty ? repeaters.first : null; + _showRxPingDetails(RxPing( + latitude: lat, + longitude: lon, + timestamp: marker.timestamp, + repeaterId: r?.repeaterId ?? '', + snr: r?.snr ?? 0, + rssi: r?.rssi ?? 0, + )); + case PingEventType.discSuccess: + case PingEventType.discFail: + _showDiscPingDetails(DiscLogEntry( + timestamp: marker.timestamp, + latitude: lat, + longitude: lon, + noiseFloor: marker.noiseFloor, + discoveredNodes: repeaters + .map((r) => DiscoveredNodeEntry( + repeaterId: r.repeaterId, + nodeType: 'REPEATER', + localSnr: r.snr, + localRssi: r.rssi, + remoteSnr: 0, + pubkeyHex: r.pubkeyHex, + )) + .toList(), + )); + case PingEventType.traceSuccess: + case PingEventType.traceFail: + final r = repeaters.isNotEmpty ? repeaters.first : null; + _showTraceDetails(TraceLogEntry( + timestamp: marker.timestamp, + latitude: lat, + longitude: lon, + targetRepeaterId: r?.repeaterId ?? '', + noiseFloor: marker.noiseFloor, + localSnr: r?.snr, + localRssi: r?.rssi, + success: marker.type == PingEventType.traceSuccess, + )); + } + } + Set _getDuplicateRepeaterIds(List repeaters) { final idCounts = {}; for (final repeater in repeaters) { From 73cad99d0ade0073829196f039889c7f0ee932dc Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 21:40:50 -0400 Subject: [PATCH 048/100] - Coverage overlay tile refresh debounce increased from 5s to 30s to reduce visual churn during active wardriving - Fixed iOS reconnect taking 4+ minutes after BLE disconnect with stale bond keys, now under 10 seconds --- lib/providers/app_state_provider.dart | 4 +++- lib/services/bluetooth/mobile_bluetooth.dart | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index c0d7a59..33d3f14 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -822,8 +822,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Schedule overlay tile refresh after server has time to regenerate tiles. // The MapWidget watches _overlayCacheBust and calls _refreshCoverageOverlay() // (remove + re-add raster source with new URL) when it changes. + // Use 30s debounce to avoid excessive tile refreshes during rapid auto-ping + // (especially on flaky networks where tiles may fail to load). _tileRefreshTimer?.cancel(); - _tileRefreshTimer = Timer(const Duration(seconds: 5), () { + _tileRefreshTimer = Timer(const Duration(seconds: 30), () { _overlayCacheBust = DateTime.now().millisecondsSinceEpoch; debugLog('[MAP] Refreshing overlay tiles'); notifyListeners(); diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index 47a5f23..7051389 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -439,9 +439,23 @@ class MobileBluetoothService implements BluetoothService { if ((isError133 || isBondError) && attempt < _maxRetries) { if (isBondError) { debugLog( - '[BLE] Bond error (apple-code 14/15) on attempt $attempt, removing bond and retrying...'); + '[BLE] Bond error (apple-code 14/15) on attempt $attempt'); await removeBond(deviceId); - await Future.delayed(const Duration(seconds: 2)); + // On iOS, removeBond() is a no-op (not supported by Core Bluetooth). + // Don't burn internal retries against stale bond keys — they will all + // fail the same way and each hangs for the full connect timeout. + // Rethrow immediately so the auto-reconnect system can apply a longer + // delay (giving iOS time to resolve) or inform the user. + debugWarn( + '[BLE] iOS bond error — skipping internal retries (stale keys cannot be cleared programmatically)'); + try { + await _bleDevice?.disconnect(); + } catch (_) {} + _bleDevice = null; + _rxCharacteristic = null; + _txCharacteristic = null; + _updateStatus(ConnectionStatus.error); + rethrow; } else { debugLog( '[BLE] Error 133 on attempt $attempt, retrying after delay...'); From 6e4ec3d3d1f681b73176560b751a612714909a06 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 22:41:09 -0400 Subject: [PATCH 049/100] - Fixed generic "Timestamp is too old" registration failure now diagnosing a device clock stuck in the future with actionable instructions to power-cycleYou said: Word smith this --- .../plans/2026-05-17-focus-mode-minimize.md | 822 ------------------ .../2026-05-17-focus-mode-minimize-design.md | 134 --- lib/providers/app_state_provider.dart | 60 +- lib/services/meshcore/connection.dart | 27 + lib/widgets/map_widget.dart | 153 +--- 5 files changed, 117 insertions(+), 1079 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-17-focus-mode-minimize.md delete mode 100644 docs/superpowers/specs/2026-05-17-focus-mode-minimize-design.md diff --git a/docs/superpowers/plans/2026-05-17-focus-mode-minimize.md b/docs/superpowers/plans/2026-05-17-focus-mode-minimize.md deleted file mode 100644 index 1ac975a..0000000 --- a/docs/superpowers/plans/2026-05-17-focus-mode-minimize.md +++ /dev/null @@ -1,822 +0,0 @@ -# Focus Mode Minimize Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Allow users to minimize the focus mode ping details popup to a compact pill, giving full map zoom/pan access while keeping focus lines visible. - -**Architecture:** All changes are in `lib/widgets/map_widget.dart`. Two new state fields (`_focusPanelMinimized`, `_focusedPingSource`) track minimized state. The 4 existing `_show*Details` methods gain a minimize button and `fromMinimized` parameter. A new `_buildMinimizedFocusPanel()` method renders the pill in the map's outer `Stack`. `_activatePingFocus` and `_dismissPingFocus` are updated to manage the new state. - -**Tech Stack:** Flutter, MapLibre GL, Provider - ---- - -### Task 1: Add state fields and update `_activatePingFocus` - -**Files:** -- Modify: `lib/widgets/map_widget.dart:382` (state fields) -- Modify: `lib/widgets/map_widget.dart:5357-5413` (`_activatePingFocus`) - -- [ ] **Step 1: Add new state fields** - -After line 382 (`bool _wasRotatingBeforeFocus = false;`), add: - -```dart - bool _focusPanelMinimized = false; - dynamic _focusedPingSource; // TxPing | RxPing | DiscLogEntry | TraceLogEntry -``` - -- [ ] **Step 2: Update `_activatePingFocus` to handle re-activation** - -Replace the entire `_activatePingFocus` method (lines 5357-5413) with: - -```dart - void _activatePingFocus(LatLng pingLocation, DateTime timestamp, - List<_ResolvedRepeater> repeaters) { - // Drop repeaters lacking GPS — they would draw lines off to (0, 0). - // The bottom-sheet row builder still surfaces them with a no-location - // icon. If nothing is left to focus on, skip activation entirely so - // the user's current map view (zoom, autofollow, rotation) is kept. - final located = - repeaters.where((r) => r.repeater.hasLocation).toList(growable: false); - if (located.isEmpty) return; - - // Only save pre-focus state on first activation. When re-activating - // (e.g. user taps a different ping while already in focus, or expanding - // from minimized), we keep the original pre-focus snapshot so dismiss - // restores the correct camera position. - final alreadyInFocus = _focusedPingLocation != null; - if (!alreadyInFocus) { - final pos = _mapController?.cameraPosition; - _preFocusCenter = pos?.target; - _preFocusZoom = pos?.zoom; - _wasAutoFollowBeforeFocus = _autoFollow; - _wasRotatingBeforeFocus = !_alwaysNorth; - - if (_autoFollow) { - _autoFollow = false; - } - - // Lock to north-up during focus so the zoom-to-fit view is stable - if (!_alwaysNorth) { - _alwaysNorth = true; - // Snap rotation to north (instant — avoids wobble before zoom-to-fit animation) - if (_isMapReady && _mapController != null && _canAnimateCamera) { - _mapController!.animateCamera( - CameraUpdate.bearingTo(0), - duration: const Duration(milliseconds: 1), - ); - } - } - } - - _focusPanelMinimized = false; - - setState(() { - _focusedPingLocation = pingLocation; - _focusedPingTimestamp = timestamp; - _focusedRepeaters = located; - }); - - // Hide the MeshMapper coverage raster overlay for a clean focus view. - // Uses opacity=0 rather than removing the layer to avoid a tile refetch - // on dismiss. No-ops gracefully if the layer isn't present. - _applyCoverageOverlayOpacity(0.0); - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _focusedPingLocation != null) { - _zoomToFocusBounds(pingLocation, located); - } - }); - - // Once the 500ms zoom-to-fit animation settles, re-flow the distance - // labels so any that collide on screen slide along their lines to a - // non-overlapping slot. 600ms gives the camera a bit of buffer beyond - // the animation duration. - Future.delayed(const Duration(milliseconds: 600), () { - if (!mounted || _focusedPingLocation == null) return; - _reflowDistanceLabelsForCollisions(); - }); - } -``` - -- [ ] **Step 3: Verify no syntax errors** - -Run: `flutter analyze lib/widgets/map_widget.dart` -Expected: No new errors (existing warnings are OK) - -- [ ] **Step 4: Commit** - -```bash -git add lib/widgets/map_widget.dart -git commit -m "feat: add focus panel state fields and update _activatePingFocus for re-activation" -``` - ---- - -### Task 2: Update `_dismissPingFocus` to clear new state - -**Files:** -- Modify: `lib/widgets/map_widget.dart:5417-5461` (`_dismissPingFocus`) - -- [ ] **Step 1: Update `_dismissPingFocus`** - -Replace lines 5417-5461 (the full `_dismissPingFocus` method) with: - -```dart - void _dismissPingFocus() { - if (_focusedPingLocation == null || !mounted) return; - - final center = _preFocusCenter; - final zoom = _preFocusZoom; - final shouldRestoreAutoFollow = _wasAutoFollowBeforeFocus && !_autoFollow; - final shouldRestoreRotation = _wasRotatingBeforeFocus && _alwaysNorth; - - // Clear focus state but do NOT restore auto-follow or rotation yet — - // they would immediately trigger animations in the build method that - // override our zoom-back animation (both share _animationController). - setState(() { - _focusedPingLocation = null; - _focusedPingTimestamp = null; - _focusedRepeaters = []; - _focusPanelMinimized = false; - _focusedPingSource = null; - }); - - // Restore the MeshMapper coverage raster overlay opacity. Safe if the - // layer was hidden via the toggle during focus — setLayerProperties is - // wrapped in try/catch inside the helper. - final appState = context.read(); - _applyCoverageOverlayOpacity(appState.preferences.coverageOverlayOpacity); - - if (center != null && zoom != null) { - _animateToPositionWithZoom(center, zoom); - - // Restore auto-follow and heading rotation after the zoom-back - // animation completes (500ms) so they don't clobber it mid-flight. - if (shouldRestoreAutoFollow || shouldRestoreRotation) { - Future.delayed(const Duration(milliseconds: 550), () { - if (mounted) { - setState(() { - if (shouldRestoreAutoFollow) _autoFollow = true; - if (shouldRestoreRotation) _alwaysNorth = false; - }); - } - }); - } - } else { - setState(() { - if (shouldRestoreAutoFollow) _autoFollow = true; - if (shouldRestoreRotation) _alwaysNorth = false; - }); - } - } -``` - -- [ ] **Step 2: Verify no syntax errors** - -Run: `flutter analyze lib/widgets/map_widget.dart` -Expected: No new errors - -- [ ] **Step 3: Commit** - -```bash -git add lib/widgets/map_widget.dart -git commit -m "feat: update _dismissPingFocus to clear minimized state" -``` - ---- - -### Task 3: Add minimize button to `_showTxPingDetails` and change completion handler - -**Files:** -- Modify: `lib/widgets/map_widget.dart:5582-5908` (`_showTxPingDetails`) - -- [ ] **Step 1: Add `fromMinimized` parameter and store ping source** - -Replace the method signature and pre-sheet logic (lines 5582-5599): - -```dart - void _showTxPingDetails(TxPing ping, {bool fromMinimized = false}) { - // Use the heardRepeaters directly from the TxPing - final heardRepeaters = ping.heardRepeaters; - - // Resolve repeater matches (hoisted so bottom sheet can check ambiguity) - final resolved = heardRepeaters.isNotEmpty - ? _resolveRepeatersByHexIds( - heardRepeaters.map((r) => r.repeaterId).toList(), - snrValues: heardRepeaters.map((r) => r.snr).toList(), - ) - : <_ResolvedRepeater>[]; - final hasAmbiguous = resolved.any((r) => r.ambiguous); - - _focusedPingSource = ping; - - // Activate focus mode if the ping was heard by known repeaters - if (!fromMinimized && resolved.isNotEmpty) { - _activatePingFocus( - LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); - } -``` - -- [ ] **Step 2: Add minimize button next to close button in header** - -Find the close IconButton in the TX sheet (around line 5665): - -```dart - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), -``` - -Replace with: - -```dart - IconButton( - icon: const Icon(Icons.keyboard_arrow_down, size: 20), - onPressed: () => Navigator.pop(context, 'minimized'), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - tooltip: 'Minimize', - ), - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), -``` - -- [ ] **Step 3: Change `.whenComplete` to `.then`** - -Find (around line 5907): - -```dart - ).whenComplete(() => _dismissPingFocus()); - } -``` - -(This is the one immediately after the TX sheet builder's closing brackets, before `_showRxPingDetails`.) - -Replace with: - -```dart - ).then((result) { - if (result == 'minimized') { - setState(() => _focusPanelMinimized = true); - } else { - _dismissPingFocus(); - } - }); - } -``` - -- [ ] **Step 4: Verify no syntax errors** - -Run: `flutter analyze lib/widgets/map_widget.dart` -Expected: No new errors - -- [ ] **Step 5: Commit** - -```bash -git add lib/widgets/map_widget.dart -git commit -m "feat: add minimize button to TX ping details sheet" -``` - ---- - -### Task 4: Add minimize button to `_showRxPingDetails` - -**Files:** -- Modify: `lib/widgets/map_widget.dart:5911-6193` (`_showRxPingDetails`) - -- [ ] **Step 1: Add `fromMinimized` parameter and store ping source** - -Replace the method signature and pre-sheet logic (lines 5911-5923): - -```dart - void _showRxPingDetails(RxPing ping, {bool fromMinimized = false}) { - final snrColor = PingColors.snrColor(ping.snr); - final rssiColor = PingColors.rssiColor(ping.rssi); - - // Activate focus mode for the RX ping's repeater - final resolved = _resolveRepeatersByHexIds( - [ping.repeaterId], - snrValues: [ping.snr], - ); - - _focusedPingSource = ping; - - if (!fromMinimized && resolved.isNotEmpty) { - _activatePingFocus( - LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); - } -``` - -- [ ] **Step 2: Add minimize button next to close button in header** - -Find the close IconButton in the RX sheet (around line 5978): - -```dart - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), -``` - -Replace with: - -```dart - IconButton( - icon: const Icon(Icons.keyboard_arrow_down, size: 20), - onPressed: () => Navigator.pop(context, 'minimized'), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - tooltip: 'Minimize', - ), - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), -``` - -- [ ] **Step 3: Change `.whenComplete` to `.then`** - -Find (around line 6193): - -```dart - ).whenComplete(() => _dismissPingFocus()); - } -``` - -(This is the one immediately before `_showDiscPingDetails`.) - -Replace with: - -```dart - ).then((result) { - if (result == 'minimized') { - setState(() => _focusPanelMinimized = true); - } else { - _dismissPingFocus(); - } - }); - } -``` - -- [ ] **Step 4: Verify no syntax errors** - -Run: `flutter analyze lib/widgets/map_widget.dart` -Expected: No new errors - -- [ ] **Step 5: Commit** - -```bash -git add lib/widgets/map_widget.dart -git commit -m "feat: add minimize button to RX ping details sheet" -``` - ---- - -### Task 5: Add minimize button to `_showDiscPingDetails` - -**Files:** -- Modify: `lib/widgets/map_widget.dart:6197-6512` (`_showDiscPingDetails`) - -- [ ] **Step 1: Add `fromMinimized` parameter and store entry source** - -Replace the method signature and pre-sheet logic (lines 6197-6210): - -```dart - void _showDiscPingDetails(DiscLogEntry entry, {bool fromMinimized = false}) { - // Activate focus mode for discovered nodes with known repeater positions - _focusedPingSource = entry; - - if (!fromMinimized && entry.discoveredNodes.isNotEmpty) { - final resolved = _resolveRepeatersByHexIds( - entry.discoveredNodes.map((n) => n.repeaterId).toList(), - fullHexIds: entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), - snrValues: - entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), - ); - if (resolved.isNotEmpty) { - _activatePingFocus( - LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); - } - } -``` - -- [ ] **Step 2: Add minimize button next to close button in header** - -Find the close IconButton in the Disc sheet (around line 6276): - -```dart - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), -``` - -Replace with: - -```dart - IconButton( - icon: const Icon(Icons.keyboard_arrow_down, size: 20), - onPressed: () => Navigator.pop(context, 'minimized'), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - tooltip: 'Minimize', - ), - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), -``` - -- [ ] **Step 3: Change `.whenComplete` to `.then`** - -Find (around line 6512): - -```dart - ).whenComplete(() => _dismissPingFocus()); - } -``` - -(This is the one immediately before `_buildRepeaterStatusChip`.) - -Replace with: - -```dart - ).then((result) { - if (result == 'minimized') { - setState(() => _focusPanelMinimized = true); - } else { - _dismissPingFocus(); - } - }); - } -``` - -- [ ] **Step 4: Verify no syntax errors** - -Run: `flutter analyze lib/widgets/map_widget.dart` -Expected: No new errors - -- [ ] **Step 5: Commit** - -```bash -git add lib/widgets/map_widget.dart -git commit -m "feat: add minimize button to Disc ping details sheet" -``` - ---- - -### Task 6: Add minimize button to `_showTraceDetails` - -**Files:** -- Modify: `lib/widgets/map_widget.dart:4971-5275` (`_showTraceDetails`) - -- [ ] **Step 1: Add `fromMinimized` parameter and store entry source** - -Replace the method signature and pre-sheet logic (lines 4971-4982): - -```dart - void _showTraceDetails(TraceLogEntry entry, {bool fromMinimized = false}) { - // Activate focus mode for successful traces with a known repeater - _focusedPingSource = entry; - - if (!fromMinimized && entry.success) { - final resolved = _resolveRepeatersByHexIds( - [entry.targetRepeaterId], - snrValues: [entry.localSnr], - ); - if (resolved.isNotEmpty) { - _activatePingFocus( - LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); - } - } -``` - -- [ ] **Step 2: Add minimize button next to close button in header** - -Find the close IconButton in the Trace sheet (around line 5047): - -```dart - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), -``` - -Replace with: - -```dart - IconButton( - icon: const Icon(Icons.keyboard_arrow_down, size: 20), - onPressed: () => Navigator.pop(context, 'minimized'), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - tooltip: 'Minimize', - ), - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), -``` - -- [ ] **Step 3: Change `.whenComplete` to `.then`** - -Find (around line 5275): - -```dart - ).whenComplete(() => _dismissPingFocus()); - } -``` - -(This is the one immediately before `/// DISC marker color`.) - -Replace with: - -```dart - ).then((result) { - if (result == 'minimized') { - setState(() => _focusPanelMinimized = true); - } else { - _dismissPingFocus(); - } - }); - } -``` - -- [ ] **Step 4: Verify no syntax errors** - -Run: `flutter analyze lib/widgets/map_widget.dart` -Expected: No new errors - -- [ ] **Step 5: Commit** - -```bash -git add lib/widgets/map_widget.dart -git commit -m "feat: add minimize button to Trace details sheet" -``` - ---- - -### Task 7: Add `_buildMinimizedFocusPanel` and `_reshowFocusPanel` methods - -**Files:** -- Modify: `lib/widgets/map_widget.dart` (add new methods near `_dismissPingFocus`, around line 5462) - -- [ ] **Step 1: Add `_reshowFocusPanel` method** - -Insert after the closing brace of `_dismissPingFocus()` (which ends with `}`): - -```dart - void _reshowFocusPanel() { - setState(() => _focusPanelMinimized = false); - final source = _focusedPingSource; - if (source is TxPing) { - _showTxPingDetails(source, fromMinimized: true); - } else if (source is RxPing) { - _showRxPingDetails(source, fromMinimized: true); - } else if (source is DiscLogEntry) { - _showDiscPingDetails(source, fromMinimized: true); - } else if (source is TraceLogEntry) { - _showTraceDetails(source, fromMinimized: true); - } - } -``` - -- [ ] **Step 2: Add `_buildMinimizedFocusPanel` method** - -Insert immediately after `_reshowFocusPanel`: - -```dart - Widget _buildMinimizedFocusPanel() { - final source = _focusedPingSource; - String title; - IconData icon; - Color color; - if (source is TxPing) { - title = 'TX Ping'; - icon = Icons.arrow_upward; - color = PingColors.txSuccess; - } else if (source is RxPing) { - title = 'RX Ping'; - icon = Icons.arrow_downward; - color = PingColors.rx; - } else if (source is DiscLogEntry) { - title = 'Disc Request'; - icon = Icons.radar; - color = PingColors.discSuccess; - } else if (source is TraceLogEntry) { - title = 'Trace'; - icon = Icons.gps_fixed; - color = Colors.cyan; - } else { - return const SizedBox.shrink(); - } - - final repeaterCount = _focusedRepeaters.length; - final timeStr = - _focusedPingTimestamp != null ? _formatTime(_focusedPingTimestamp!) : ''; - - return GestureDetector( - onTap: _reshowFocusPanel, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.2), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: color, size: 18), - const SizedBox(width: 8), - Text( - title, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(width: 8), - Text( - timeStr, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - if (repeaterCount > 0) ...[ - const SizedBox(width: 8), - Text( - '$repeaterCount repeater${repeaterCount != 1 ? 's' : ''}', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - const SizedBox(width: 8), - GestureDetector( - onTap: _reshowFocusPanel, - child: Icon( - Icons.keyboard_arrow_up, - size: 20, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: _dismissPingFocus, - child: Icon( - Icons.close, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - } -``` - -- [ ] **Step 3: Verify no syntax errors** - -Run: `flutter analyze lib/widgets/map_widget.dart` -Expected: No new errors - -- [ ] **Step 4: Commit** - -```bash -git add lib/widgets/map_widget.dart -git commit -m "feat: add minimized focus panel pill and reshow logic" -``` - ---- - -### Task 8: Add minimized pill to the outer Stack in `build()` - -**Files:** -- Modify: `lib/widgets/map_widget.dart:1198-1246` (outer Stack in `build()`) - -- [ ] **Step 1: Add minimized pill to Stack** - -Find the tile load failure banner block (around line 1234-1244): - -```dart - // Tile load failure banner — appears if base tiles haven't finished - // loading within ${_tileLoadTimeoutSeconds}s after style load. - if (_tileLoadFailed) - Positioned( - top: topPadding, - left: 0, - right: 0, - child: Center( - child: _buildTileLoadFailedBanner(), - ), - ), - ], - ); -``` - -Replace with: - -```dart - // Tile load failure banner — appears if base tiles haven't finished - // loading within ${_tileLoadTimeoutSeconds}s after style load. - if (_tileLoadFailed) - Positioned( - top: topPadding, - left: 0, - right: 0, - child: Center( - child: _buildTileLoadFailedBanner(), - ), - ), - - // Minimized focus panel pill — shown when user minimizes a ping - // details sheet. Not a modal, so the map underneath stays fully - // interactable (zoom, pan, rotation). - if (_focusPanelMinimized && _focusedPingLocation != null) - Positioned( - bottom: 16 + MediaQuery.of(context).padding.bottom, - left: 16, - right: 16, - child: Center( - child: _buildMinimizedFocusPanel(), - ), - ), - ], - ); -``` - -- [ ] **Step 2: Verify no syntax errors** - -Run: `flutter analyze lib/widgets/map_widget.dart` -Expected: No new errors - -- [ ] **Step 3: Commit** - -```bash -git add lib/widgets/map_widget.dart -git commit -m "feat: render minimized focus pill in map Stack" -``` - ---- - -### Task 9: Final verification - -**Files:** -- Verify: `lib/widgets/map_widget.dart` - -- [ ] **Step 1: Run full static analysis** - -Run: `flutter analyze` -Expected: No new errors introduced. Pre-existing warnings are acceptable. - -- [ ] **Step 2: Run tests** - -Run: `flutter test` -Expected: All existing tests pass. - -- [ ] **Step 3: Final commit (squash-friendly message)** - -Only if there were any fixups needed from analysis/tests: - -```bash -git add lib/widgets/map_widget.dart -git commit -m "fix: address analysis warnings from focus minimize feature" -``` diff --git a/docs/superpowers/specs/2026-05-17-focus-mode-minimize-design.md b/docs/superpowers/specs/2026-05-17-focus-mode-minimize-design.md deleted file mode 100644 index a0399e4..0000000 --- a/docs/superpowers/specs/2026-05-17-focus-mode-minimize-design.md +++ /dev/null @@ -1,134 +0,0 @@ -# Focus Mode Minimize - -Allow users to minimize the ping details popup during focus mode to get a full map view with zoom/pan access, while keeping focus lines and context visible. - -## Current Behavior - -When a ping marker is tapped, focus mode activates: -1. `_activatePingFocus()` saves pre-focus camera state, hides unrelated markers/coverage, draws focus lines to repeaters, zooms to fit bounds -2. `showModalBottomSheet()` displays ping details (TX/RX/Disc/Trace variants) -3. The sheet uses `barrierColor: Colors.transparent` — map is visible but the invisible barrier blocks all touch events (no zoom/pan) -4. Closing the sheet (X or swipe-down) calls `_dismissPingFocus()` which restores the pre-focus map state - -**Problem:** Users cannot zoom or pan the map while viewing focus lines because the modal barrier intercepts all gestures. - -## Design - -### Minimize Button in Sheet Header - -Each of the 4 detail sheet variants (TX, RX, Disc, Trace) gains a minimize button (down-chevron icon) next to the existing close button: - -``` - [↑] TX Ping 12:34:05 ▽ ✕ -``` - -- `▽` (minimize): closes the sheet but keeps focus mode active → shows minimized pill -- `✕` (close): exits focus mode entirely (unchanged behavior) - -### Minimized Pill - -A compact, non-modal widget rendered in the map widget's `Stack`: - -``` -┌──────────────────────────────────────────────┐ -│ ↑ TX Ping 12:34:05 3 repeaters [△] [✕] │ -└──────────────────────────────────────────────┘ -``` - -- **Position:** Bottom of map, horizontally centered, above safe area inset -- **Style:** `surfaceContainerHighest` background, rounded corners (12px), subtle border — matches existing sheet theme -- **Content:** Ping type icon (colored), type label, formatted timestamp, repeater count, expand button, close button -- **Behavior:** - - `△` (expand): re-opens the full details sheet without re-zooming the map - - `✕` (close): calls `_dismissPingFocus()` to exit focus mode entirely - - Tapping the pill body also expands (same as △) - -### Map Interaction When Minimized - -Since the pill is a regular widget in the Stack (not a modal), the map underneath is fully interactable: -- Pinch-to-zoom works -- Pan/drag works -- Rotation works (if rotation lock is off) -- Map control buttons (top-right) remain accessible -- Focus lines and distance labels remain visible - -### What Stays the Same - -- Full details sheet content (repeater tables, location chip, path chain, etc.) -- Focus activation logic (zoom-to-fit, save pre-focus state, focus lines, coverage hide) -- `_dismissPingFocus()` restore behavior (auto-follow, rotation, zoom-back animation) -- Transparent barrier color on the full sheet when expanded -- Swipe-down on sheet still closes and exits focus (unchanged) - -## Implementation Details - -### New State in `_MapWidgetState` - -```dart -bool _focusPanelMinimized = false; -dynamic _focusedPingSource; // TxPing | RxPing | DiscLogEntry | TraceLogEntry -``` - -### Modified Methods - -**`_activatePingFocus()`:** -- If `_focusedPingLocation != null` (already in focus): skip saving pre-focus state, skip auto-follow/rotation changes — just update the focused ping/repeaters and zoom to new bounds -- Clear `_focusPanelMinimized = false` on activation - -**`_dismissPingFocus()`:** -- Additionally clears `_focusPanelMinimized = false` and `_focusedPingSource = null` - -**`_show{Tx,Rx,Disc,Trace}Details()`:** -- Store the ping/entry in `_focusedPingSource` before showing the sheet -- Accept optional `{bool fromMinimized = false}` parameter — when true, skip `_activatePingFocus` call (focus is already active) -- Add minimize `IconButton` to header row -- Change `.whenComplete(() => _dismissPingFocus())` to `.then((result) { ... })`: - - If `result == 'minimized'`: `setState(() => _focusPanelMinimized = true)` - - Otherwise: `_dismissPingFocus()` - -**Minimize button action:** `Navigator.pop(context, 'minimized')` - -### New Methods - -**`_buildMinimizedFocusPanel()`:** -- Returns the pill widget -- Derives title/icon/color from `_focusedPingSource` runtime type -- Repeater count from `_focusedRepeaters.length` -- Timestamp from `_focusedPingTimestamp` - -**`_reshowFocusPanel()`:** -- Checks `_focusedPingSource` type, calls the matching `_show*Details(source, fromMinimized: true)` -- Sets `_focusPanelMinimized = false` via setState - -### Build Method Change - -In the outer `Stack` (line ~1198), add after map controls: - -```dart -if (_focusPanelMinimized && _focusedPingLocation != null) - Positioned( - bottom: 16 + MediaQuery.of(context).padding.bottom, - left: 16, - right: 16, - child: _buildMinimizedFocusPanel(), - ), -``` - -### Edge Cases - -- **User taps a different ping while minimized:** `_handleSymbolTap` calls `_show*Details` for the new ping. `_activatePingFocus` detects existing focus, updates focus state without re-saving pre-focus. The new sheet opens, minimized state is cleared. -- **Auto-reconnect during minimized state:** Disconnect cleanup calls `_dismissPingFocus()` which clears everything including minimized state. -- **Orientation change while minimized:** Pill repositions naturally via `Positioned` + safe area insets. - -## Files Modified - -- `lib/widgets/map_widget.dart` — all changes are in this single file - -## Testing - -- Tap TX/RX/Disc/Trace ping → verify minimize button visible in header -- Tap minimize → verify pill appears, map is pannable/zoomable, focus lines stay -- Tap expand on pill → verify full sheet re-opens without re-zooming -- Tap close on pill → verify focus mode fully dismisses, map restores -- While minimized, tap a different ping → verify new focus replaces old -- Swipe-down on full sheet → verify still exits focus mode (unchanged) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 33d3f14..84055ac 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1377,6 +1377,38 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final serverMessage = registerResult['message'] as String?; debugError( '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + + // Diagnose stale ADVERT timestamp: the firmware refuses to set the + // clock backwards (ERR code 6), so if the device's RTC is stuck in + // the future the signed ADVERT will always be rejected by the server. + // Query the device clock and surface an actionable error. + if (serverMessage != null && + serverMessage.contains('Timestamp') && + _meshCoreConnection != null) { + try { + final deviceTime = await _meshCoreConnection!.getDeviceTime(); + final appTime = + DateTime.now().millisecondsSinceEpoch ~/ 1000; + final drift = deviceTime - appTime; + debugError( + '[APP] Device clock: $deviceTime, app clock: $appTime, drift: ${drift}s'); + if (drift > 3600) { + final deviceDate = DateTime.fromMillisecondsSinceEpoch( + deviceTime * 1000, + isUtc: true); + return { + 'success': false, + 'reason': 'clock_error', + 'message': + 'Device clock is set to ${deviceDate.toIso8601String().substring(0, 10)}. ' + 'Power-cycle your device to reset it.', + }; + } + } catch (e) { + debugWarn('[APP] Could not query device time: $e'); + } + } + return { 'success': false, 'reason': serverReason, @@ -4433,17 +4465,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return OfflineUploadResult.invalidSession; } - // 2. Get device credentials from session - final publicKey = session.devicePublicKey; - if (publicKey == null) { - debugLog('[OFFLINE] Session missing device public key: $filename'); - return OfflineUploadResult.invalidSession; - } + // 2. Get device credentials from session, falling back to connected device + var publicKey = session.devicePublicKey; + var deviceName = session.deviceName; - final deviceName = session.deviceName; - if (deviceName == null || deviceName.isEmpty) { - debugLog('[OFFLINE] Session missing device name: $filename'); - return OfflineUploadResult.invalidSession; + if (publicKey == null || deviceName == null || deviceName.isEmpty) { + if (_devicePublicKey != null && displayDeviceName != null) { + publicKey ??= _devicePublicKey; + if (deviceName == null || deviceName.isEmpty) { + deviceName = displayDeviceName; + } + debugLog( + '[OFFLINE] Legacy session $filename: using connected device credentials'); + } else { + debugLog( + '[OFFLINE] Session missing credentials and no device connected: $filename'); + return OfflineUploadResult.invalidSession; + } } onProgress?.call('Authenticating...'); @@ -4967,6 +5005,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return 'Service is under maintenance. Try again later.'; case 'network_error': return 'Unable to connect to the MeshMapper server. Please check your internet connection and try again.'; + case 'clock_error': + return serverMessage ?? 'Device clock error. Power-cycle your device to reset it.'; default: return serverMessage ?? 'Unknown error occurred.'; } diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 4821981..7bdecd5 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -97,6 +97,7 @@ class MeshCoreConnection { Completer? _channelInfoCompleter; Completer? _statsCompleter; Completer? _exportContactCompleter; + Completer? _getTimeCompleter; // Device self info (contains public key) SelfInfo? _selfInfo; @@ -440,6 +441,8 @@ class MeshCoreConnection { _deviceQueryCompleter = null; _exportContactCompleter?.completeError(errException); _exportContactCompleter = null; + _getTimeCompleter?.completeError(errException); + _getTimeCompleter = null; break; case ResponseCodes.deviceInfo: _onDeviceInfoResponse(reader); @@ -474,6 +477,12 @@ class MeshCoreConnection { case ResponseCodes.batteryVoltage: _onBatteryVoltageResponse(reader); break; + case ResponseCodes.currTime: + if (_getTimeCompleter != null && reader.remainingBytesCount >= 4) { + _getTimeCompleter?.complete(reader.readUInt32LE()); + _getTimeCompleter = null; + } + break; case ResponseCodes.exportContact: _onExportContactResponse(reader); break; @@ -859,6 +868,24 @@ class MeshCoreConnection { ); } + /// Query the device's current RTC clock (epoch seconds) + Future getDeviceTime() async { + _getTimeCompleter = Completer(); + final future = _getTimeCompleter!.future; + + final data = BufferWriter(); + data.writeByte(CommandCodes.getDeviceTime); + await _sendToRadio(data); + + return future.timeout( + const Duration(seconds: 5), + onTimeout: () { + _getTimeCompleter = null; + throw TimeoutException('getDeviceTime timed out'); + }, + ); + } + /// Set TX power Future setTxPower(int txPower) async { final data = BufferWriter(); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 7a31be4..41ef675 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -405,18 +405,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // errors in the native log. bool _coverageRefreshScheduled = false; - // Double-buffered coverage overlay: each refresh allocates fresh suffixed - // IDs so the new raster source/layer can be added on top of the previous - // one and rendered before the old layer is removed. This prevents the - // brief blank frame the user previously saw every cache-bust cycle. + // Coverage overlay IDs: each refresh allocates fresh suffixed IDs so + // a remove+add sequence never collides with a stale native source. String? _activeCoverageSourceId; String? _activeCoverageLayerId; int _coverageBufferCounter = 0; - // One-shot completer released by _onMapIdle (or the timeout fallback) to - // signal the swap that new tiles have rendered and the old layer is safe - // to remove. Null when no swap is in flight. - Completer? _coverageSwapIdleCompleter; - Timer? _coverageSwapTimeoutTimer; bool _styleLoaded = false; bool _hasStyleLoadedOnce = false; // True after first onStyleLoadedCallback (prevents re-centering on style switch) @@ -572,20 +565,28 @@ class _MapWidgetState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _cameraAnimationReady = true; + _recoverCoverageOverlayIfNeeded(); } }); } } + /// Re-add the coverage overlay if it should be visible but was lost during + /// a background/foreground transition. + void _recoverCoverageOverlayIfNeeded() { + if (_activeCoverageSourceId != null) return; + if (!_showMeshMapperOverlay) return; + final appState = context.read(); + if (!appState.preferences.mapTilesEnabled) return; + if (appState.zoneCode == null || appState.zoneCode!.isEmpty) return; + debugLog('[MAP] Recovering coverage overlay after app resume'); + _addCoverageOverlay(appState); + } + @override void dispose() { WidgetsBinding.instance.removeObserver(this); _tileLoadTimeoutTimer?.cancel(); - _coverageSwapTimeoutTimer?.cancel(); - final swapWaiter = _coverageSwapIdleCompleter; - if (swapWaiter != null && !swapWaiter.isCompleted) { - swapWaiter.complete(); - } final controller = _mapController; if (controller != null) { controller.removeListener(_onCameraChanged); @@ -2028,8 +2029,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Style reload wipes the native layer stack, so any tracked coverage // overlay IDs now reference layers that no longer exist. Reset them so - // the next _swapCoverageOverlay treats this as a fresh add (no old - // buffer to retire) instead of attempting a doomed removal. + // the next refresh treats this as a fresh add. _activeCoverageSourceId = null; _activeCoverageLayerId = null; // Same reasoning for the focus-lines source/layers — gone with the @@ -2153,10 +2153,6 @@ class _MapWidgetState extends State with WidgetsBindingObserver { debugLog('[MAP] Tiles recovered after earlier load failure'); setState(() => _tileLoadFailed = false); } - final waiter = _coverageSwapIdleCompleter; - if (waiter != null && !waiter.isCompleted) { - waiter.complete(); - } } /// Fires when the camera stops moving — after both gestures and @@ -2172,13 +2168,22 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } /// Add MeshMapper coverage raster overlay as a MapLibre source+layer. - /// Allocates fresh suffixed IDs each call so a previous layer can remain - /// in place (and continue rendering its tiles) while the new one's tiles - /// load on top — see [_swapCoverageOverlay] for the double-buffer flow. + /// Allocates fresh suffixed IDs each call to avoid native collisions. Future _addCoverageOverlay(AppStateProvider appState) async { - if (_mapController == null || !_showMeshMapperOverlay) return; - if (!appState.preferences.mapTilesEnabled) return; - if (appState.zoneCode == null || appState.zoneCode!.isEmpty) return; + if (_mapController == null || !_showMeshMapperOverlay) { + debugLog( + '[MAP] Coverage overlay add skipped: controller=${_mapController != null}, showOverlay=$_showMeshMapperOverlay'); + return; + } + if (!appState.preferences.mapTilesEnabled) { + debugLog('[MAP] Coverage overlay add skipped: mapTilesEnabled=false'); + return; + } + if (appState.zoneCode == null || appState.zoneCode!.isEmpty) { + debugLog( + '[MAP] Coverage overlay add skipped: zoneCode=${appState.zoneCode}'); + return; + } final cvdParam = appState.preferences.colorVisionType != 'none' ? '&cvd=${appState.preferences.colorVisionType}' @@ -2196,20 +2201,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ); // Target the bottom of the repeater cluster stack when it exists, so the // raster lands beneath ALL marker layers (repeater clusters + symbol - // annotations). During the initial style load, _setupRepeaterClusterLayers - // runs before this — so _clusterLayersReady is true and we use the - // individual repeater layer as the reference. The zoneCode watcher also - // fires after cluster setup, so both paths converge to the same stack. - // Fallback to the symbol annotation layer only if cluster layers haven't - // been created yet (shouldn't happen in practice, but keeps the raster - // underneath markers either way). - // - // Using the same belowLayerId for the new layer as the previous overlay - // intentionally places this insertion directly under the marker stack - // and ABOVE the previous raster layer — so as the new tiles render - // they paint over the old ones rather than the old being torn down - // first. _swapCoverageOverlay removes the old layer once the new tiles - // have settled. + // annotations). Fallback to the symbol annotation layer if cluster layers + // haven't been created yet. final belowLayer = _clusterLayersReady ? _repeaterIndividualLayerId : _symbolAnnotationLayerId(); @@ -2306,8 +2299,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } /// Remove a specific coverage source+layer pair without touching the - /// active-ID tracking. Used by [_swapCoverageOverlay] to retire the - /// previous buffer once new tiles have rendered. + /// active-ID tracking. Future _removeCoverageLayerById( String layerId, String sourceId) async { if (_mapController == null) return; @@ -2319,78 +2311,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } catch (_) {} } - /// Refresh coverage overlay using a double-buffered swap so the current - /// tiles stay visible until the new ones have rendered on top. - Future _refreshCoverageOverlay(AppStateProvider appState) => - _swapCoverageOverlay(appState); - - /// Double-buffered overlay refresh: - /// 1. Capture the currently-active source/layer IDs (the "old" buffer). - /// 2. Add the new source+layer — [_addCoverageOverlay] uses the same - /// belowLayerId so the new layer lands directly above the old one, - /// and updates the active-ID fields to point at the new buffer. - /// 3. Wait for [_onMapIdle] (or a short timeout) so the new tiles have - /// a chance to paint over the old. - /// 4. Remove the old source+layer. - /// - /// If the add was skipped (overlay disabled, no zone, etc.) the old - /// buffer is dropped immediately — there's nothing to buffer against. - Future _swapCoverageOverlay(AppStateProvider appState) async { - final oldSourceId = _activeCoverageSourceId; - final oldLayerId = _activeCoverageLayerId; - + /// Refresh coverage overlay by removing the current layer and adding a fresh + /// one with updated cache-bust URL. Accepts a brief visual gap between + /// remove and add — imperceptible during driving and quick when stationary + /// (tiles load from cache for same viewport). + Future _refreshCoverageOverlay(AppStateProvider appState) async { + await _removeCoverageOverlay(); await _addCoverageOverlay(appState); - - final addedNewBuffer = _activeCoverageSourceId != oldSourceId && - _activeCoverageSourceId != null; - - if (!addedNewBuffer) { - // Add was a no-op (preconditions failed). Drop the previous buffer if - // the overlay should no longer be visible. _addCoverageOverlay's - // preconditions match the conditions under which we want the overlay - // gone, so this is the correct place to retire it. - if (oldSourceId != null && oldLayerId != null) { - _activeCoverageSourceId = null; - _activeCoverageLayerId = null; - await _removeCoverageLayerById(oldLayerId, oldSourceId); - } - return; - } - - if (oldSourceId == null || oldLayerId == null) { - // No previous buffer to retire (first add since style load or after a - // teardown). Nothing more to do. - return; - } - - await _waitForCoverageSwapIdle(timeout: const Duration(seconds: 3)); - if (!mounted) return; - await _removeCoverageLayerById(oldLayerId, oldSourceId); - } - - /// Block until [_onMapIdle] completes the swap completer, or [timeout] - /// elapses (whichever happens first). Replaces any prior in-flight waiter - /// so a new swap starting mid-flight doesn't strand the old waiter. - Future _waitForCoverageSwapIdle({required Duration timeout}) async { - final prior = _coverageSwapIdleCompleter; - if (prior != null && !prior.isCompleted) { - prior.complete(); - } - final completer = Completer(); - _coverageSwapIdleCompleter = completer; - _coverageSwapTimeoutTimer?.cancel(); - _coverageSwapTimeoutTimer = Timer(timeout, () { - if (!completer.isCompleted) completer.complete(); - }); - try { - await completer.future; - } finally { - if (identical(_coverageSwapIdleCompleter, completer)) { - _coverageSwapIdleCompleter = null; - } - _coverageSwapTimeoutTimer?.cancel(); - _coverageSwapTimeoutTimer = null; - } } /// Returns the fill color for a repeater status keyword. From 22da229f466f38b4fd2e919388cd33f5ca964926 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 17 May 2026 23:20:28 -0400 Subject: [PATCH 050/100] feat: add TCP health check after app resume to ensure connection stability --- lib/providers/app_state_provider.dart | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 84055ac..ffccc05 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -399,6 +399,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { debugLog('[APP] App resumed from background'); + _checkTcpHealthAfterResume(); } else if (state == AppLifecycleState.paused) { debugLog('[APP] App paused (backgrounded)'); // Save offline pings immediately on pause to prevent data loss if OS kills app @@ -408,6 +409,34 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// Probe TCP connection after iOS resume — socket may have died while suspended. + Future _checkTcpHealthAfterResume() async { + if (_selectedTransport != TransportType.tcp) return; + if (_connectionStep != ConnectionStep.connected) return; + if (_isAutoReconnecting || _userRequestedDisconnect) return; + + // Let pending socket error/done events propagate first + await Future.delayed(const Duration(milliseconds: 1500)); + + // If socket events already triggered auto-reconnect, nothing to do + if (_connectionStep != ConnectionStep.connected) return; + if (_isAutoReconnecting) return; + + debugLog('[CONN] Probing TCP connection after resume'); + try { + await _meshCoreConnection!.getNoiseFloor(); + debugLog('[CONN] TCP connection healthy after resume'); + } catch (e) { + debugLog('[CONN] TCP probe failed after resume: $e'); + if (_connectionStep == ConnectionStep.connected && + !_isAutoReconnecting && + _rememberedDevice != null && + !_userRequestedDisconnect) { + await _startAutoReconnect(); + } + } + } + // ============================================ // Getters // ============================================ From 51cc2aac876760e131ac9225d7a36cdaafaa47f2 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 20 May 2026 23:22:58 -0400 Subject: [PATCH 051/100] - Fixed auto-ping permanently stopping when crossing a regional zone boundary --- lib/providers/app_state_provider.dart | 5 +++++ lib/services/ping_service.dart | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index ffccc05..1545dc5 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3306,6 +3306,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (!_autoPingEnabled) { + _cooldownTimer.stop(); + _pingService!.clearCooldown(); toggleAutoPing(previousMode); debugLog( '[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); @@ -5732,6 +5734,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } if (!_autoPingEnabled) { + _cooldownTimer.stop(); + _pingService!.clearCooldown(); final resolvedMode = _resolveAutoModeForZone(previousMode); debugLog( '[ZONE GRACE] Mode resolved: $previousMode → $resolvedMode'); @@ -6133,6 +6137,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (!_autoPingEnabled) { _cooldownTimer.stop(); + _pingService!.clearCooldown(); final resolvedMode = _resolveAutoModeForZone(previousMode); debugLog( '[ZONE] Mode resolved for new zone: $previousMode → $resolvedMode'); diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 0482f81..dd55976 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -465,6 +465,11 @@ class PingService { return remaining.inSeconds.clamp(0, _autoPingCooldown.inSeconds); } + /// Clear the auto-ping cooldown (used during zone transfer to avoid blocking restart). + void clearCooldown() { + _lastTxTime = null; + } + /// Check if currently in manual ping cooldown period bool isInManualCooldown() { return _manualPingCooldownTimer.remainingMs > 0; From 5d56fe25b1270050df5c256f21fdc2a0e52a025b Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 20 May 2026 23:25:28 -0400 Subject: [PATCH 052/100] - "Clear Map Markers" now also removes discovery and trace markers --- lib/providers/app_state_provider.dart | 2 ++ lib/screens/settings_screen.dart | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 1545dc5..f0f3115 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3843,6 +3843,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void clearPings() { _txPings.clear(); _rxPings.clear(); + _discLogEntries.clear(); + _traceLogEntries.clear(); _clearOverlayState(); _pingService?.resetStats(); notifyListeners(); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 11e19cd..e5ba729 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1454,7 +1454,7 @@ class _SettingsScreenState extends State { builder: (context) => AlertDialog( title: const Text('Clear Map Markers?'), content: const Text( - 'This will remove all TX/RX markers from the map. This won\'t affect uploaded data.', + 'This will remove all markers from the map. This won\'t affect uploaded data.', ), actions: [ TextButton( From 3b5c115c008d840d8ce91c924f74505e1988152a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 20 May 2026 23:30:49 -0400 Subject: [PATCH 053/100] =?UTF-8?q?=E2=8F=BA=20-=20Added=20a=20toggle=20bu?= =?UTF-8?q?tton=20to=20the=20map=20control=20panel=20to=20show/hide=20the?= =?UTF-8?q?=20regional=20boundary=20line=20and=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/widgets/map_widget.dart | 47 ++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 41ef675..ee8e205 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -364,6 +364,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // MeshMapper overlay toggle (on by default) bool _showMeshMapperOverlay = true; + // Region boundary overlay toggle (on by default) + bool _showRegionBorders = true; + // Collapsible map controls in landscape bool _mapControlsExpanded = true; @@ -1522,9 +1525,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { )); if (bordersSig != _lastBordersSignature && _isMapReady && _styleLoaded) { _lastBordersSignature = bordersSig; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _refreshRegionBorders(appState); - }); + if (_showRegionBorders) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _refreshRegionBorders(appState); + }); + } } // Detect coverage overlay opacity change (user dragged the slider in @@ -4239,6 +4244,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { isActive: _showMeshMapperOverlay, ), ], + // Region boundary toggle (only show when borders available) + if (appState.regionBorders.isNotEmpty) ...[ + _buildControlDivider(), + _buildControlButton( + icon: Icons.fence, + tooltip: _showRegionBorders + ? 'Hide Region Boundary' + : 'Show Region Boundary', + onPressed: () => _toggleRegionBorders(appState), + isActive: _showRegionBorders, + ), + ], _buildControlDivider(), // Center on position / toggle auto-follow _buildControlButton( @@ -4380,6 +4397,30 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + void _toggleRegionBorders(AppStateProvider appState) { + setState(() { + _showRegionBorders = !_showRegionBorders; + }); + if (_showRegionBorders) { + _refreshRegionBorders(appState); + } else { + _removeRegionBorders(); + } + } + + void _removeRegionBorders() { + if (_mapController == null) return; + try { + _mapController!.removeLayer(_regionBorderLabelLayerId); + } catch (_) {} + try { + _mapController!.removeLayer(_regionBorderLineLayerId); + } catch (_) {} + try { + _mapController!.removeSource(_regionBorderSourceId); + } catch (_) {} + } + void _toggleNorthMode() { final appState = context.read(); setState(() { From 95f02d8ed95e272e4b0bdc8f78ced009af8bbe1c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 21 May 2026 09:15:00 -0400 Subject: [PATCH 054/100] Shortened the "DUPLICATE ID" label on focus mode lines to "DUP" so it actually renders on shorter lines instead of being hidden or unreadable --- lib/widgets/map_widget.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index ee8e205..2f6dfa7 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -3533,7 +3533,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _focusLinesAmbiguousLabelId, const SymbolLayerProperties( symbolPlacement: 'line', - textField: 'DUPLICATE ID', + textField: 'DUP', textSize: 11, textColor: '#F59E0B', textHaloColor: '#FFFFFF', From 043220adda91df5baab87afda40ea281c61f4f87 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 21 May 2026 13:13:59 -0400 Subject: [PATCH 055/100] - Multi-hop TX echoes are now grouped under their parent TX ping instead of showing as anonymous RX observations. Log tab, noise floor graph, and map popup show "Direct Repeats" and "Multi-hop Repeats" sections. TX markers are green (direct echo), RX-colored (multi-hop only), or red (no response). --- lib/models/log_entry.dart | 31 +- lib/models/noise_floor_session.dart | 10 + lib/models/ping_data.dart | 2 + lib/providers/app_state_provider.dart | 95 ++- lib/screens/log_screen.dart | 157 ++++- lib/services/meshcore/tx_tracker.dart | 154 +++-- lib/services/meshcore/unified_rx_handler.dart | 10 +- lib/services/ping_service.dart | 83 ++- lib/widgets/map_widget.dart | 546 ++++++++++++------ lib/widgets/noise_floor_chart.dart | 223 ++++--- 10 files changed, 974 insertions(+), 337 deletions(-) diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart index 9afe565..0f5a632 100644 --- a/lib/models/log_entry.dart +++ b/lib/models/log_entry.dart @@ -5,7 +5,8 @@ class TxLogEntry { final double latitude; final double longitude; final double power; // Power in watts (0.3, 0.6, 1.0, 2.0) - final List events; // Repeaters that heard this ping + final List events; // Direct 1-hop repeaters that heard this ping + final List multiHopEvents; // Multi-hop echoes (2+ hops) TxLogEntry({ required this.timestamp, @@ -13,6 +14,7 @@ class TxLogEntry { required this.longitude, required this.power, required this.events, + this.multiHopEvents = const [], }); /// Get formatted timestamp (HH:MM:SS) @@ -36,7 +38,32 @@ class TxLogEntry { ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' : '${e.repeaterId}(null)') .join(','); - return '${timestamp.toIso8601String()},$latitude,$longitude,$power,$eventsStr'; + final multiHopStr = multiHopEvents.isEmpty + ? '' + : ',MH:${multiHopEvents.map((e) => e.snr != null ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' : '${e.repeaterId}(null)').join(',')}'; + return '${timestamp.toIso8601String()},$latitude,$longitude,$power,$eventsStr$multiHopStr'; + } +} + +/// Multi-hop echo event (repeater that relayed a TX ping via 2+ hops) +class MultiHopEchoEvent { + final String repeaterId; + final double? snr; + final int? rssi; + final List pathHops; + + MultiHopEchoEvent({ + required this.repeaterId, + this.snr, + this.rssi, + this.pathHops = const [], + }); + + SnrSeverity? get severity { + if (snr == null) return null; + if (snr! <= -1) return SnrSeverity.poor; + if (snr! <= 5) return SnrSeverity.fair; + return SnrSeverity.good; } } diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index c2af353..8bdeb73 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -42,6 +42,9 @@ enum PingEventType { @HiveField(6) traceFail, // Grey: Trace no response + + @HiveField(7) + txMultiHopOnly, // RX color: TX got multi-hop echoes but no direct } /// Repeater info for graph markers @@ -61,11 +64,16 @@ class MarkerRepeaterInfo extends HiveObject { @HiveField(3) final String? pubkeyHex; + /// Hop path for multi-hop echoes. Null = direct 1-hop echo, non-null = multi-hop. + @HiveField(4) + final List? pathHops; + MarkerRepeaterInfo({ required this.repeaterId, required this.snr, required this.rssi, this.pubkeyHex, + this.pathHops, }); } @@ -108,6 +116,7 @@ class PingEventMarker extends HiveObject { PingEventType.discFail => PingColors.discFail, PingEventType.traceSuccess => PingColors.traceSuccess, PingEventType.traceFail => PingColors.noResponse, + PingEventType.txMultiHopOnly => PingColors.rx, }; /// Get a display label for this event type @@ -119,6 +128,7 @@ class PingEventMarker extends HiveObject { PingEventType.discFail => 'DISC Fail', PingEventType.traceSuccess => 'Trace Success', PingEventType.traceFail => 'Trace Fail', + PingEventType.txMultiHopOnly => 'TX Multi-hop', }; } diff --git a/lib/models/ping_data.dart b/lib/models/ping_data.dart index 9cd326e..09defe6 100644 --- a/lib/models/ping_data.dart +++ b/lib/models/ping_data.dart @@ -120,12 +120,14 @@ class HeardRepeater { final double? snr; // Best SNR observed (null for CARpeater pass-through) final int? rssi; // RSSI in dBm (null for CARpeater pass-through) final int seenCount; // How many times this repeater was heard + final List? pathHops; // null = direct echo, non-null = multi-hop const HeardRepeater({ required this.repeaterId, this.snr, this.rssi, this.seenCount = 1, + this.pathHops, }); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index f0f3115..0ca6c38 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2253,10 +2253,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { longitude: lastEntry.longitude, power: lastEntry.power, events: existingEvents, + multiHopEvents: lastEntry.multiHopEvents, ); _txLogEntries[_txLogEntries.length - 1] = updatedEntry; debugLog( - '[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); + '[APP] Updated TxLogEntry with ${existingEvents.length} direct, ' + '${lastEntry.multiHopEvents.length} multi-hop events (real-time)'); _updateTopRepeaters( existingEvents @@ -2278,6 +2280,52 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } }; + _pingService!.onMultiHopEchoReceived = + (txPing, repeaterId, snr, rssi, pathHops, isNew) { + debugLog( + '[APP] Multi-hop echo: $repeaterId, hops=${pathHops.length}, isNew=$isNew'); + + if (_txLogEntries.isNotEmpty) { + final lastEntry = _txLogEntries.last; + final timeDiff = + lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); + if (timeDiff <= 10) { + final multiHopEvents = + List.from(lastEntry.multiHopEvents); + final newEvent = MultiHopEchoEvent( + repeaterId: repeaterId, + snr: snr, + rssi: rssi, + pathHops: pathHops, + ); + + if (isNew) { + multiHopEvents.add(newEvent); + _audioService.playReceiveSound(); + _pingStats = + _pingStats.copyWith(rxCount: _pingStats.rxCount + 1); + } else { + final idx = multiHopEvents + .indexWhere((e) => e.repeaterId == repeaterId); + if (idx >= 0) { + multiHopEvents[idx] = newEvent; + } + } + + _txLogEntries[_txLogEntries.length - 1] = TxLogEntry( + timestamp: lastEntry.timestamp, + latitude: lastEntry.latitude, + longitude: lastEntry.longitude, + power: lastEntry.power, + events: lastEntry.events, + multiHopEvents: multiHopEvents, + ); + + notifyListeners(); + } + } + }; + _pingService!.onPingProgressChanged = notifyListeners; _pingService!.onAutoPingScheduled = (intervalMs, skipReason) { @@ -2318,31 +2366,52 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); }; - _pingService!.onTxWindowComplete = (success) { + _pingService!.onTxWindowComplete = (directSuccess, multiHopEchoes) { double? lat; double? lon; - List? repeaters; + List? allRepeaters; if (_txLogEntries.isNotEmpty) { final lastTx = _txLogEntries.last; lat = lastTx.latitude; lon = lastTx.longitude; - if (lastTx.events.isNotEmpty) { - repeaters = lastTx.events - .map((e) => MarkerRepeaterInfo( - repeaterId: e.repeaterId, - snr: e.snr ?? 0.0, - rssi: e.rssi ?? 0, - )) - .toList(); + + final directRepeaters = lastTx.events + .map((e) => MarkerRepeaterInfo( + repeaterId: e.repeaterId, + snr: e.snr ?? 0.0, + rssi: e.rssi ?? 0, + )) + .toList(); + + final multiHopRepeaters = multiHopEchoes + .map((e) => MarkerRepeaterInfo( + repeaterId: e.repeaterId, + snr: e.snr ?? 0.0, + rssi: e.rssi ?? 0, + pathHops: e.pathHops, + )) + .toList(); + + if (directRepeaters.isNotEmpty || multiHopRepeaters.isNotEmpty) { + allRepeaters = [...directRepeaters, ...multiHopRepeaters]; } } + final PingEventType eventType; + if (directSuccess) { + eventType = PingEventType.txSuccess; + } else if (multiHopEchoes.isNotEmpty) { + eventType = PingEventType.txMultiHopOnly; + } else { + eventType = PingEventType.txFail; + } + recordPingEvent( - success ? PingEventType.txSuccess : PingEventType.txFail, + eventType, latitude: lat, longitude: lon, - repeaters: repeaters, + repeaters: allRepeaters, ); }; diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index d84db19..951eba4 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -355,6 +355,13 @@ class _AllPingsTabState extends State<_AllPingsTab> { return true; } } + for (final event in tx.multiHopEvents) { + if (event.repeaterId.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(event.repeaterId, repeaters); + if (resolved.names.any((n) => n.toLowerCase().contains(query))) { + return true; + } + } return false; case PingLogType.rx: final rx = entry.asRx; @@ -391,7 +398,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { switch (entry.type) { case PingLogType.tx: return entry.asTx.events - .any((e) => _isAmbiguousId(e.repeaterId, repeaters)); + .any((e) => _isAmbiguousId(e.repeaterId, repeaters)) || + entry.asTx.multiHopEvents + .any((e) => _isAmbiguousId(e.repeaterId, repeaters)); case PingLogType.rx: return _isAmbiguousId(entry.asRx.repeaterId, repeaters); case PingLogType.disc: @@ -697,11 +706,29 @@ class _AllPingsTabState extends State<_AllPingsTab> { _buildCardHeader(context, PingLogType.tx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), - // Repeaters table + // Direct echoes table if (entry.events.isNotEmpty) ...[ const SizedBox(height: 10), _buildRepeaterTable(context, entry.events, widget.repeaters), - ] else ...[ + ] else if (entry.multiHopEvents.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'No direct repeats heard', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + // Multi-hop echoes section + if (entry.multiHopEvents.isNotEmpty) ...[ + const SizedBox(height: 10), + _buildMultiHopSection( + context, entry.multiHopEvents, widget.repeaters), + ], + // No echoes at all + if (entry.events.isEmpty && entry.multiHopEvents.isEmpty) ...[ const SizedBox(height: 8), Text( 'No repeaters heard', @@ -786,6 +813,130 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } + // --------------------------------------------------------------------------- + // Multi-hop Echo Section (inside TX Card) + // --------------------------------------------------------------------------- + + Widget _buildMultiHopSection(BuildContext context, + List events, List repeaters) { + var maxIdLen = 0; + for (final e in events) { + if (e.repeaterId.length > maxIdLen) maxIdLen = e.repeaterId.length; + } + final nodeWidth = _nodeColumnWidthForLength(maxIdLen); + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + Icon(Icons.route, size: 14, color: PingColors.rx), + const SizedBox(width: 6), + Text( + 'Multi-hop Repeats', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + // Table header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox(width: nodeWidth, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'SNR', center: true)), + Expanded(child: _tableHeader(context, 'RSSI', center: true)), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...events.map((event) { + final snrColor = _snrColor(event.severity); + final rssiColor = _rssiColor(event.rssi); + final isAmbiguous = _isAmbiguousId(event.repeaterId, repeaters); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, event.repeaterId), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + RepeaterIdChip( + repeaterId: event.repeaterId, + fontSize: 14, + width: nodeWidth, + isAmbiguous: isAmbiguous), + Expanded( + child: Center( + child: _buildChip( + event.snr?.toStringAsFixed(1) ?? '-', + snrColor))), + Expanded( + child: Center( + child: _buildChip( + event.rssi != null + ? '${event.rssi}' + : '-', + rssiColor))), + ], + ), + ), + ), + if (event.pathHops.isNotEmpty) + Padding( + padding: + const EdgeInsets.only(left: 10, right: 10, bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.route, + size: 12, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + const SizedBox(width: 4), + Expanded( + child: RxPathChain( + hops: event.pathHops, + fontSize: 11, + ), + ), + ], + ), + ), + ], + ); + }), + ], + ), + ); + } + // --------------------------------------------------------------------------- // RX Card // --------------------------------------------------------------------------- diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index b4090e2..92226f2 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -17,21 +17,29 @@ class TxTracker { int? expectedChannelHash; Uint8List? channelKey; - /// Map of repeaterId (hex) -> RepeaterEcho + /// Map of repeaterId (hex) -> RepeaterEcho (direct 1-hop echoes) final Map repeaters = {}; + /// Map of repeaterId (hex) -> RepeaterEcho (multi-hop 2+ hop echoes) + final Map multiHopRepeaters = {}; + Timer? _windowTimer; /// CARpeater prefix — when set, multi-hop packets with this firstHop are stripped /// to report the underlying repeater with null SNR/RSSI String? carpeaterPrefix; - /// Callback fired when a new echo is received (for real-time UI updates) + /// Callback fired when a direct echo is received (for real-time UI updates) /// Parameters: (repeaterId, snr, rssi, isNew) - isNew is true for first time seeing this repeater /// snr/rssi are nullable for CARpeater pass-through (signal data is meaningless) void Function(String repeaterId, double? snr, int? rssi, bool isNew)? onEchoReceived; + /// Callback fired when a multi-hop echo is received (for real-time UI updates) + void Function( + String repeaterId, double? snr, int? rssi, List pathHops, bool isNew)? + onMultiHopEchoReceived; + /// Callback for carpeater drops (for quiet error logging) /// Called with repeater ID and reason when an echo is dropped due to carpeater detection void Function(String repeaterId, String reason)? onCarpeaterDrop; @@ -69,6 +77,7 @@ class TxTracker { expectedChannelHash = channelHash; this.channelKey = channelKey; repeaters.clear(); + multiHopRepeaters.clear(); // Start window timer _windowTimer?.cancel(); @@ -81,7 +90,8 @@ class TxTracker { /// Stop tracking echoes void stopTracking() { debugLog( - '[TX LOG] Stopping echo tracking (heard ${repeaters.length} repeaters)'); + '[TX LOG] Stopping echo tracking (heard ${repeaters.length} direct, ' + '${multiHopRepeaters.length} multi-hop repeaters)'); isListening = false; _windowTimer?.cancel(); @@ -91,15 +101,22 @@ class TxTracker { if (repeaters.isNotEmpty) { for (final entry in repeaters.entries) { debugLog( - '[TX LOG] Final: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, seen=${entry.value.seenCount}x'); + '[TX LOG] Final direct: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, seen=${entry.value.seenCount}x'); + } + } + if (multiHopRepeaters.isNotEmpty) { + for (final entry in multiHopRepeaters.entries) { + debugLog( + '[TX LOG] Final multi-hop: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, ' + 'hops=${entry.value.pathHops.length}, seen=${entry.value.seenCount}x'); } } } /// Handle incoming packet, check if it's an echo - /// Returns true if packet was an echo and tracked - Future handlePacket(PacketMetadata metadata) async { - if (!isListening) return false; + /// Returns TxEchoResult indicating whether packet was consumed and how + Future handlePacket(PacketMetadata metadata) async { + if (!isListening) return TxEchoResult.notEcho; final originalPayload = sentPayload; final expectedHash = expectedChannelHash; @@ -112,17 +129,16 @@ class TxTracker { if (!metadata.isGroupText) { debugLog('[TX LOG] Ignoring: header validation failed ' '(header=0x${metadata.header.toRadixString(16).padLeft(2, '0')})'); - return false; + return TxEchoResult.notEcho; } debugLog( '[TX LOG] Header validation passed: 0x${metadata.header.toRadixString(16).padLeft(2, '0')}'); // VALIDATION STEP 1.5: Path length check (must have hops to identify repeater) - // Moved before RSSI check so we can log the repeater ID on carpeater drops if (metadata.pathHashCount == 0) { debugLog( '[TX LOG] Ignoring: no path (direct transmission, not a repeater echo)'); - return false; + return TxEchoResult.notEcho; } // Extract first hop (first repeater) for use in validation and logging @@ -137,7 +153,7 @@ class TxTracker { PacketValidator.isCarpeaterIdMatch(pathHex, carpeaterPrefix!)) { if (metadata.pathHashCount < 2) { debugLog('[TX LOG] CARpeater pass-through: single-hop, dropping'); - return false; + return TxEchoResult.notEcho; } // Multi-hop: strip CARpeater, report underlying repeater (second hop) final underlyingHex = metadata.getHopHex(1)!; @@ -149,28 +165,31 @@ class TxTracker { reportedRssi = null; } - // Only direct (1-hop) echoes yield a meaningful SNR/RSSI for the repeater - // that heard our TX: the radio reports last-hop link quality, so for any - // multi-hop relay the metrics describe a different link entirely. - if (!carpeaterStripped && metadata.pathHashCount > 1) { + // Determine if this is a multi-hop echo (2+ hops, not CARpeater-stripped) + final bool isMultiHop = !carpeaterStripped && metadata.pathHashCount > 1; + + // For multi-hop: use lastHop as reporting repeater, build display path + List displayHops = const []; + if (isMultiHop) { + pathHex = metadata.lastHopHex!; + displayHops = [ + for (var i = 0; i < metadata.pathHashCount; i++) metadata.getHopHex(i)!, + ]; debugLog( - '[TX LOG] Ignoring: multi-hop echo (pathHashCount=${metadata.pathHashCount}) ' - 'from $pathHex — SNR/RSSI would measure last-hop link, not repeater downlink'); - return false; + '[TX LOG] Multi-hop echo (pathHashCount=${metadata.pathHashCount}): ' + 'reporting repeater=$pathHex, path=${displayHops.join(' → ')}'); } - // VALIDATION STEP 2: Check user carpeater filter (before RSSI check so user - // never sees confusing "RSSI too strong" errors for a device they told the app to ignore) + // VALIDATION STEP 2: Check user carpeater filter if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(pathHex.toUpperCase())) { debugLog( '[TX LOG] ❌ DROPPED: Repeater $pathHex ignored by user carpeater filter'); - return false; + return TxEchoResult.notEcho; } // VALIDATION STEP 2.5: Check RSSI (carpeater failsafe) - // Skip for CARpeater pass-through (user explicitly identified their CARpeater) if (carpeaterStripped) { debugLog('[TX LOG] RSSI check skipped (CARpeater pass-through)'); } else if (disableRssiFilter) { @@ -184,7 +203,7 @@ class TxTracker { '[TX LOG] onCarpeaterDrop callback is ${onCarpeaterDrop != null ? "SET" : "NULL"}'); onCarpeaterDrop?.call( pathHex, 'RSSI too strong (${metadata.rssi} dBm)'); - return false; // Mark as handled (dropped) + return TxEchoResult.notEcho; } else { debugLog( '[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); @@ -194,7 +213,7 @@ class TxTracker { if (metadata.encryptedPayload.length < 3) { debugLog( '[TX LOG] Ignoring: payload too short to contain channel hash'); - return false; + return TxEchoResult.notEcho; } final packetChannelHash = metadata.channelHash!; @@ -204,35 +223,30 @@ class TxTracker { if (packetChannelHash != expectedHash) { debugLog('[TX LOG] Ignoring: channel hash mismatch'); - return false; + return TxEchoResult.notEcho; } debugLog( '[TX LOG] Channel hash match confirmed - this is a message on our channel'); - // VALIDATION STEP 3: Message content verification + // VALIDATION STEP 4: Message content verification if (channelKey != null && originalPayload != null) { debugLog( '[MESSAGE_CORRELATION] Channel key available, attempting decryption...'); try { - // Payload structure: [1 byte channel_hash][2 bytes MAC][encrypted message] - // Skip first 3 bytes to get the encrypted message final encryptedMessage = metadata.encryptedPayload.sublist(3); final decryptedBytes = CryptoService.decryptChannelMessage( encryptedMessage, channelKey!, ); - // Decrypted structure: [4 bytes timestamp][1 byte flags][message text] - // Skip first 5 bytes to get the actual message if (decryptedBytes.length < 5) { debugLog( '[MESSAGE_CORRELATION] ❌ REJECT: Decrypted data too short'); - return false; + return TxEchoResult.notEcho; } final messageBytes = decryptedBytes.sublist(5); - // Convert bytes to string and strip null terminators var decryptedMessage = utf8.decode(messageBytes, allowMalformed: true); decryptedMessage = @@ -245,8 +259,6 @@ class TxTracker { debugLog( '[MESSAGE_CORRELATION] Expected: "$originalPayload" (${originalPayload.length} chars)'); - // Check if our expected message is contained in the decrypted text - // This handles both exact matches and messages with sender prefixes final messageMatches = decryptedMessage == originalPayload || decryptedMessage.contains(originalPayload); @@ -255,7 +267,7 @@ class TxTracker { '[MESSAGE_CORRELATION] ❌ REJECT: Message content mismatch (not an echo of our ping)'); debugLog( '[MESSAGE_CORRELATION] This is a different message on the same channel'); - return false; + return TxEchoResult.notEcho; } if (decryptedMessage == originalPayload) { @@ -269,7 +281,7 @@ class TxTracker { } catch (e) { debugLog( '[MESSAGE_CORRELATION] ❌ REJECT: Failed to decrypt message: $e'); - return false; + return TxEchoResult.notEcho; } } else { debugWarn( @@ -278,20 +290,22 @@ class TxTracker { '[MESSAGE_CORRELATION] Proceeding without message content verification (less reliable)'); } - // Path length and first hop already validated/extracted earlier (before RSSI check) + // --- Validation passed, store the echo --- + + final targetMap = isMultiHop ? multiHopRepeaters : repeaters; + final echoType = isMultiHop ? 'multi-hop' : 'direct'; debugLog( - '[PING] Repeater echo accepted: first_hop=$pathHex, SNR=$reportedSnr, ' + '[PING] Repeater echo accepted ($echoType): repeater=$pathHex, SNR=$reportedSnr, ' 'full_path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); // Deduplication: check if we already have this repeater bool isNewRepeater = false; - if (repeaters.containsKey(pathHex)) { - final existing = repeaters[pathHex]!; - debugLog('[PING] Deduplication: path $pathHex already seen ' + if (targetMap.containsKey(pathHex)) { + final existing = targetMap[pathHex]!; + debugLog('[PING] Deduplication ($echoType): path $pathHex already seen ' '(existing SNR=${existing.snr}, new SNR=$reportedSnr)'); - // Keep the best (highest) SNR — null SNR never replaces non-null final shouldUpdate = reportedSnr != null && existing.snr != null ? reportedSnr > existing.snr! : reportedSnr != null && existing.snr == null; @@ -299,48 +313,56 @@ class TxTracker { debugLog( '[PING] Deduplication decision: updating path $pathHex with better SNR: ' '${existing.snr} -> $reportedSnr'); - repeaters[pathHex] = RepeaterEcho( + targetMap[pathHex] = RepeaterEcho( repeaterId: pathHex, snr: reportedSnr, rssi: reportedRssi, seenCount: existing.seenCount + 1, + isMultiHop: isMultiHop, + pathHops: displayHops, ); } else { debugLog( '[PING] Deduplication decision: keeping existing SNR for path $pathHex ' '(existing ${existing.snr} >= new $reportedSnr)'); - // Still increment seen count existing.seenCount++; } } else { - // New repeater isNewRepeater = true; debugLog( - '[PING] Adding new repeater echo: path=$pathHex, SNR=$reportedSnr, RSSI=$reportedRssi'); - repeaters[pathHex] = RepeaterEcho( + '[PING] Adding new $echoType echo: path=$pathHex, SNR=$reportedSnr, RSSI=$reportedRssi'); + targetMap[pathHex] = RepeaterEcho( repeaterId: pathHex, snr: reportedSnr, rssi: reportedRssi, seenCount: 1, + isMultiHop: isMultiHop, + pathHops: displayHops, ); } - // Notify callback for real-time UI updates - final bestSnr = repeaters[pathHex]!.snr; - final bestRssi = repeaters[pathHex]!.rssi; - debugLog( - '[TX LOG] Invoking onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); - if (onEchoReceived != null) { - onEchoReceived!(pathHex, bestSnr, bestRssi, isNewRepeater); - debugLog('[TX LOG] onEchoReceived callback invoked successfully'); + // Notify appropriate callback + final best = targetMap[pathHex]!; + if (isMultiHop) { + debugLog( + '[TX LOG] Invoking onMultiHopEchoReceived callback'); + onMultiHopEchoReceived?.call( + pathHex, best.snr, best.rssi, displayHops, isNewRepeater); + } else { + debugLog( + '[TX LOG] Invoking onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); + if (onEchoReceived != null) { + onEchoReceived!(pathHex, best.snr, best.rssi, isNewRepeater); + debugLog('[TX LOG] onEchoReceived callback invoked successfully'); + } } - debugLog('[TX LOG] ✅ Echo tracked successfully'); - return true; + debugLog('[TX LOG] ✅ Echo tracked successfully ($echoType)'); + return isMultiHop ? TxEchoResult.multiHopEcho : TxEchoResult.directEcho; } catch (error, stackTrace) { debugError('[TX LOG] Error processing rx_log entry: $error'); debugError('[TX LOG] Stack trace: $stackTrace'); - return false; + return TxEchoResult.notEcho; } } @@ -350,17 +372,33 @@ class TxTracker { } } +/// Result of TxTracker.handlePacket() +enum TxEchoResultType { notEcho, directEcho, multiHopEcho } + +class TxEchoResult { + final TxEchoResultType type; + + const TxEchoResult._(this.type); + static const notEcho = TxEchoResult._(TxEchoResultType.notEcho); + static const directEcho = TxEchoResult._(TxEchoResultType.directEcho); + static const multiHopEcho = TxEchoResult._(TxEchoResultType.multiHopEcho); +} + /// Repeater echo data class RepeaterEcho { final String repeaterId; // Hex string double? snr; // Best SNR seen (null for CARpeater pass-through) int? rssi; // RSSI value (dBm) (null for CARpeater pass-through) int seenCount; // Times observed + final bool isMultiHop; + final List pathHops; RepeaterEcho({ required this.repeaterId, this.snr, this.rssi, this.seenCount = 1, + this.isMultiHop = false, + this.pathHops = const [], }); } diff --git a/lib/services/meshcore/unified_rx_handler.dart b/lib/services/meshcore/unified_rx_handler.dart index 2f5e71d..84949b0 100644 --- a/lib/services/meshcore/unified_rx_handler.dart +++ b/lib/services/meshcore/unified_rx_handler.dart @@ -95,9 +95,13 @@ class UnifiedRxHandler { // Route to TX tracking if active (during 5s echo window) if (txTracker.isListening) { debugLog('[UNIFIED RX] TX tracking active - checking for echo'); - final wasEcho = await txTracker.handlePacket(metadata); - if (wasEcho) { - debugLog('[UNIFIED RX] Packet was TX echo, done'); + final result = await txTracker.handlePacket(metadata); + if (result.type == TxEchoResultType.directEcho) { + debugLog('[UNIFIED RX] Packet was direct TX echo, done'); + return; + } + if (result.type == TxEchoResultType.multiHopEcho) { + debugLog('[UNIFIED RX] Packet was multi-hop TX echo, grouped with TX'); return; } } diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index dd55976..13fc495 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -156,10 +156,14 @@ class PingService { void Function(RxPing)? onRxPing; void Function(PingStats)? onStatsUpdated; - /// Called in real-time when each echo is received during tracking window + /// Called in real-time when each direct echo is received during tracking window /// Parameters: (TxPing txPing, HeardRepeater repeater, bool isNew) void Function(TxPing, HeardRepeater, bool isNew)? onEchoReceived; + /// Called in real-time when each multi-hop echo is received during tracking window + void Function(TxPing txPing, String repeaterId, double? snr, int? rssi, + List pathHops, bool isNew)? onMultiHopEchoReceived; + /// Callback for discovery events (Passive Mode) /// Fires immediately when disc ping is created (like onTxPing) void Function(DiscLogEntry)? onDiscPing; @@ -170,8 +174,11 @@ class PingService { onDiscNodeDiscovered; /// Callback when TX window ends (for noise floor graph) - /// Parameters: (bool success) - true if any repeaters heard, false if none - void Function(bool success)? onTxWindowComplete; + /// Parameters: (bool directSuccess, List multiHopEchoes) + void Function( + bool directSuccess, + List<({String repeaterId, double? snr, int? rssi, List pathHops})> + multiHopEchoes)? onTxWindowComplete; /// Callback when discovery window ends (for noise floor graph) /// Parameters: (bool success) - true if any nodes discovered, false if none @@ -650,6 +657,37 @@ class PingService { } }; + txTracker.onMultiHopEchoReceived = + (repeaterId, snr, rssi, pathHops, isNew) { + debugLog( + '[PING] Multi-hop echo: $repeaterId, SNR=$snr, hops=${pathHops.length}, isNew=$isNew'); + final txPing = _lastTxPing; + if (txPing != null) { + final repeater = HeardRepeater( + repeaterId: repeaterId, + snr: snr, + rssi: rssi, + seenCount: + txTracker.multiHopRepeaters[repeaterId]?.seenCount ?? 1, + pathHops: pathHops, + ); + + if (isNew) { + txPing.heardRepeaters.add(repeater); + } else { + final idx = txPing.heardRepeaters + .indexWhere((r) => r.repeaterId == repeaterId && + r.pathHops != null); + if (idx >= 0) { + txPing.heardRepeaters[idx] = repeater; + } + } + + onMultiHopEchoReceived?.call( + txPing, repeaterId, snr, rssi, pathHops, isNew); + } + }; + txTracker.startTracking( payload: pingMessage, channelIdx: channelIndex, @@ -758,11 +796,25 @@ class PingService { debugLog('[PING] No repeater echoes detected during listening window'); } + // Collect multi-hop echo data for the onTxWindowComplete callback + final multiHopEchoes = + <({String repeaterId, double? snr, int? rssi, List pathHops})>[]; + if (txTracker != null && txTracker.multiHopRepeaters.isNotEmpty) { + for (final entry in txTracker.multiHopRepeaters.entries) { + final echo = entry.value; + multiHopEchoes.add(( + repeaterId: echo.repeaterId, + snr: echo.snr, + rssi: echo.rssi, + pathHops: echo.pathHops, + )); + } + } + // Notify about TX window completion for noise floor graph - onTxWindowComplete?.call(txSuccess); + onTxWindowComplete?.call(txSuccess, multiHopEchoes); // Queue TX entry with heard_repeats AFTER RX window ends - // Reference: enqueueTX() called after RX window in wardrive.js final txTimestamp = _pendingTxTimestamp; if (txTimestamp != null) { _apiQueue.enqueueTx( @@ -776,6 +828,27 @@ class PingService { ); debugLog('[PING] Queued TX entry with heard_repeats: $heardRepeats'); + // Queue multi-hop echoes as individual RX API entries + if (multiHopEchoes.isNotEmpty) { + for (final echo in multiHopEchoes) { + final rxHeardRepeats = echo.snr != null + ? '${echo.repeaterId}(${echo.snr!.toStringAsFixed(2)})' + : '${echo.repeaterId}(null)'; + _apiQueue.enqueueRx( + latitude: txPosition.latitude, + longitude: txPosition.longitude, + heardRepeats: rxHeardRepeats, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + repeaterId: echo.repeaterId, + externalAntenna: getExternalAntenna?.call() ?? false, + noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), + ); + } + debugLog( + '[PING] Queued ${multiHopEchoes.length} multi-hop echoes as RX'); + } + // Clear pending TX context _pendingTxTimestamp = null; _pendingTxNoiseFloor = null; diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 2f6dfa7..77fd3df 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -3989,6 +3989,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { PingEventType.discFail => (type: 'disc', success: false), PingEventType.traceSuccess => (type: 'trace', success: true), PingEventType.traceFail => (type: 'trace', success: false), + PingEventType.txMultiHopOnly => (type: 'tx', success: true), }; /// Compute a version hash of all data that affects the marker list. @@ -5773,6 +5774,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { switch (marker.type) { case PingEventType.txSuccess: case PingEventType.txFail: + case PingEventType.txMultiHopOnly: _showTxPingDetails(TxPing( latitude: lat, longitude: lon, @@ -5954,7 +5956,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Use the heardRepeaters directly from the TxPing final heardRepeaters = ping.heardRepeaters; - // Resolve repeater matches (hoisted so bottom sheet can check ambiguity) + // Resolve all repeaters (direct + multi-hop) for focus-mode lines final resolved = heardRepeaters.isNotEmpty ? _resolveRepeatersByHexIds( heardRepeaters.map((r) => r.repeaterId).toList(), @@ -6088,210 +6090,390 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), const SizedBox(height: 16), - // Repeaters section header - Text( - heardRepeaters.isEmpty - ? 'No repeaters heard' - : 'Heard Repeaters (${heardRepeaters.length})', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - letterSpacing: 0.5, - ), + // Split repeaters into direct and multi-hop + ..._buildTxRepeaterSections(context, ping, heardRepeaters, + resolved, hasAmbiguous), + ], + ), + ), + ), + ), + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); + } + + List _buildTxRepeaterSections( + BuildContext context, + TxPing ping, + List allRepeaters, + List<_ResolvedRepeater> resolved, + bool hasAmbiguous, + ) { + final directRepeaters = + allRepeaters.where((r) => r.pathHops == null).toList(); + final multiHopRepeaters = + allRepeaters.where((r) => r.pathHops != null).toList(); + + if (directRepeaters.isEmpty && multiHopRepeaters.isEmpty) { + return [ + Text( + 'No repeaters heard', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + ]; + } + + final widgets = []; + + // --- Direct echoes section --- + widgets.add(Text( + directRepeaters.isEmpty + ? 'No direct repeats heard' + : 'Direct Repeats (${directRepeaters.length})', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + fontStyle: + directRepeaters.isEmpty ? FontStyle.italic : FontStyle.normal, + ), + )); + + if (hasAmbiguous) { + widgets.add(GestureDetector( + onTap: () => _showDuplicateRepeaterPopup( + resolved, + fromLatLng: (lat: ping.latitude, lon: ping.longitude), + ), + child: const Padding( + padding: EdgeInsets.only(top: 6), + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, + size: 14, color: Color(0xFFF59E0B)), + SizedBox(width: 4), + Flexible( + child: Text( + 'Duplicate repeater ID — lines shown to all possible matches', + style: TextStyle(fontSize: 11, color: Color(0xFFF59E0B)), ), + ), + SizedBox(width: 4), + Icon(Icons.info_outline, size: 14, color: Color(0xFFF59E0B)), + ], + ), + ), + )); + } - if (hasAmbiguous) - GestureDetector( - onTap: () => _showDuplicateRepeaterPopup( - resolved, - fromLatLng: ( - lat: ping.latitude, - lon: ping.longitude, - ), - ), - child: const Padding( - padding: EdgeInsets.only(top: 6), + if (directRepeaters.isNotEmpty) { + widgets.add(const SizedBox(height: 12)); + widgets.add(_buildRepeaterTable(context, ping, directRepeaters)); + } + + // --- Multi-hop echoes section --- + if (multiHopRepeaters.isNotEmpty) { + widgets.add(const SizedBox(height: 16)); + widgets.add(Row( + children: [ + Icon(Icons.route, size: 14, color: PingColors.rx), + const SizedBox(width: 6), + Text( + 'Multi-hop Repeats (${multiHopRepeaters.length})', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + ], + )); + widgets.add(const SizedBox(height: 12)); + widgets.add(_buildMultiHopRepeaterTable( + context, ping, multiHopRepeaters)); + } + + return widgets; + } + + Widget _buildRepeaterTable( + BuildContext context, TxPing ping, List repeaters) { + final chipWidth = _nodeColumnWidth(); + final anyLacksLocation = + repeaters.any((hr) => _hexIdLacksLocation(hr.repeaterId)); + final iconReserve = anyLacksLocation ? 18.0 : 0.0; + final nodeColWidth = chipWidth + iconReserve; + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + SizedBox( + width: nodeColWidth, + child: Text('Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant)), + ), + Expanded( + child: Text('SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant))), + Expanded( + child: Text('RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant))), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...repeaters.map((repeater) { + final snrColor = repeater.snr != null + ? PingColors.snrColor(repeater.snr!) + : Colors.grey; + final rssiColor = repeater.rssi != null + ? PingColors.rssiColor(repeater.rssi!) + : Colors.grey; + final lacksLocation = _hexIdLacksLocation(repeater.repeaterId); + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, repeater.repeaterId, + fromLatLng: (lat: ping.latitude, lon: ping.longitude)), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + child: Row( + children: [ + SizedBox( + width: nodeColWidth, child: Row( children: [ - Icon(Icons.warning_amber_rounded, - size: 14, color: Color(0xFFF59E0B)), - SizedBox(width: 4), - Flexible( - child: Text( - 'Duplicate repeater ID — lines shown to all possible matches', - style: TextStyle( - fontSize: 11, - color: Color(0xFFF59E0B), - ), + RepeaterIdChip( + repeaterId: repeater.repeaterId, + fontSize: 13, + width: chipWidth), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only(left: 4), + child: _noLocationIndicator(), ), - ), - SizedBox(width: 4), - Icon(Icons.info_outline, - size: 14, color: Color(0xFFF59E0B)), ], ), ), - ), + Expanded( + child: Center( + child: _buildStatChip( + value: repeater.snr?.toStringAsFixed(1) ?? '-', + color: snrColor, + ), + ), + ), + Expanded( + child: Center( + child: _buildStatChip( + value: repeater.rssi != null + ? '${repeater.rssi}' + : '-', + color: rssiColor, + ), + ), + ), + ], + ), + ), + ); + }), + ], + ), + ); + } - if (heardRepeaters.isNotEmpty) ...[ - const SizedBox(height: 12), - // If any heard repeater is missing GPS, reserve a sliver of - // node-column width for the inline `location_off` indicator - // so SNR/RSSI columns stay aligned row-to-row. - Builder(builder: (context) { - final chipWidth = _nodeColumnWidth(); - final anyLacksLocation = heardRepeaters - .any((hr) => _hexIdLacksLocation(hr.repeaterId)); - final iconReserve = anyLacksLocation ? 18.0 : 0.0; - final nodeColWidth = chipWidth + iconReserve; - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all( + Widget _buildMultiHopRepeaterTable( + BuildContext context, TxPing ping, List repeaters) { + final chipWidth = _nodeColumnWidth(); + final anyLacksLocation = + repeaters.any((hr) => _hexIdLacksLocation(hr.repeaterId)); + final iconReserve = anyLacksLocation ? 18.0 : 0.0; + final nodeColWidth = chipWidth + iconReserve; + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + SizedBox( + width: nodeColWidth, + child: Text('Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, color: Theme.of(context) .colorScheme - .outline - .withValues(alpha: 0.5)), - ), - child: Column( + .onSurfaceVariant)), + ), + Expanded( + child: Text('SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant))), + Expanded( + child: Text('RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant))), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...repeaters.map((repeater) { + final snrColor = repeater.snr != null + ? PingColors.snrColor(repeater.snr!) + : Colors.grey; + final rssiColor = repeater.rssi != null + ? PingColors.rssiColor(repeater.rssi!) + : Colors.grey; + final lacksLocation = _hexIdLacksLocation(repeater.repeaterId); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, repeater.repeaterId, + fromLatLng: (lat: ping.latitude, lon: ping.longitude)), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + child: Row( children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), + SizedBox( + width: nodeColWidth, child: Row( children: [ - SizedBox( - width: nodeColWidth, - child: Text( - 'Node', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), + RepeaterIdChip( + repeaterId: repeater.repeaterId, + fontSize: 13, + width: chipWidth), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only(left: 4), + child: _noLocationIndicator(), ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ), ], ), ), - Divider( - height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...heardRepeaters.map((repeater) { - final snrColor = repeater.snr != null - ? PingColors.snrColor(repeater.snr!) - : Colors.grey; - final rssiColor = repeater.rssi != null - ? PingColors.rssiColor(repeater.rssi!) - : Colors.grey; - final lacksLocation = - _hexIdLacksLocation(repeater.repeaterId); - - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup( - context, repeater.repeaterId, fromLatLng: ( - lat: ping.latitude, - lon: ping.longitude - )), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - child: Row( - children: [ - // Repeater ID + optional no-location icon, - // pinned to the node column width. - SizedBox( - width: nodeColWidth, - child: Row( - children: [ - RepeaterIdChip( - repeaterId: repeater.repeaterId, - fontSize: 13, - width: chipWidth), - if (lacksLocation) - Padding( - padding: const EdgeInsets.only( - left: 4), - child: _noLocationIndicator(), - ), - ], - ), - ), - // SNR - Expanded( - child: Center( - child: _buildStatChip( - value: - repeater.snr?.toStringAsFixed(1) ?? - '-', - color: snrColor, - ), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildStatChip( - value: repeater.rssi != null - ? '${repeater.rssi}' - : '-', - color: rssiColor, - ), - ), - ), - ], - ), + Expanded( + child: Center( + child: _buildStatChip( + value: + repeater.snr?.toStringAsFixed(1) ?? '-', + color: snrColor, ), - ); - }), + ), + ), + Expanded( + child: Center( + child: _buildStatChip( + value: repeater.rssi != null + ? '${repeater.rssi}' + : '-', + color: rssiColor, + ), + ), + ), ], ), - ); - }), - ], + ), + ), + if (repeater.pathHops != null && repeater.pathHops!.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: 12, right: 12, bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(Icons.route, + size: 12, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant), + ), + const SizedBox(width: 6), + Expanded( + child: RxPathChain( + hops: repeater.pathHops!, fontSize: 11), + ), + ], + ), + ), ], - ), - ), - ), + ); + }), + ], ), - ).then((result) { - if (result == 'minimized') { - setState(() => _focusPanelMinimized = true); - } else { - _dismissPingFocus(); - } - }); + ); } /// Show RX ping details popup diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index 77510bc..312d6a1 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -6,6 +6,7 @@ import '../models/noise_floor_session.dart'; import '../providers/app_state_provider.dart'; import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; +import 'rx_path_chain.dart'; /// Interactive noise floor chart with pinch-to-zoom and pan class InteractiveNoiseFloorChart extends StatefulWidget { @@ -239,6 +240,7 @@ class InteractiveNoiseFloorChartState final eventTypeLabel = switch (marker.type) { PingEventType.txSuccess => 'TX Success', PingEventType.txFail => 'TX Failed', + PingEventType.txMultiHopOnly => 'TX Multi-hop', PingEventType.rx => 'RX Received', PingEventType.discSuccess => 'Discovery Success', PingEventType.discFail => 'Discovery Failed', @@ -249,6 +251,8 @@ class InteractiveNoiseFloorChartState final eventDescription = switch (marker.type) { PingEventType.txSuccess => 'Ping was heard by repeater(s)', PingEventType.txFail => 'Ping was not heard by any repeater', + PingEventType.txMultiHopOnly => + 'Ping was heard via multi-hop only (no direct)', PingEventType.rx => 'Received passive observation', PingEventType.discSuccess => 'Discovery got response', PingEventType.discFail => 'Discovery got no response', @@ -357,79 +361,10 @@ class InteractiveNoiseFloorChartState ], ), - // Repeaters section (table format like TX log) + // Repeaters section — split into direct and multi-hop if (marker.repeaters != null && marker.repeaters!.isNotEmpty) ...[ - const SizedBox(height: 16), - Container( - decoration: BoxDecoration( - color: - Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context) - .colorScheme - .outline - .withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 8), - child: Row( - children: [ - SizedBox( - width: 50, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ), - ], - ), - ), - Divider( - height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...marker.repeaters! - .map((r) => _buildRepeaterRow(context, r)), - ], - ), - ), + ..._buildMarkerRepeaterSections(context, marker.repeaters!), ], // View on Map button @@ -525,6 +460,152 @@ class InteractiveNoiseFloorChartState ); } + /// Build direct and multi-hop repeater sections for the marker detail sheet + List _buildMarkerRepeaterSections( + BuildContext context, List repeaters) { + final directRepeaters = + repeaters.where((r) => r.pathHops == null).toList(); + final multiHopRepeaters = + repeaters.where((r) => r.pathHops != null).toList(); + + final widgets = []; + + Widget buildTableHeader() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox( + width: 50, + child: Text('Node', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant)), + ), + Expanded( + child: Text('SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant)), + ), + Expanded( + child: Text('RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant)), + ), + ], + ), + ); + } + + // Direct echoes table + if (directRepeaters.isNotEmpty) { + widgets.addAll([ + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + children: [ + buildTableHeader(), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...directRepeaters.map((r) => _buildRepeaterRow(context, r)), + ], + ), + ), + ]); + } + + // Multi-hop echoes section + if (multiHopRepeaters.isNotEmpty) { + widgets.addAll([ + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + Icon(Icons.route, size: 12, color: PingColors.rx), + const SizedBox(width: 6), + Text('Multi-hop Repeats', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant)), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + buildTableHeader(), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...multiHopRepeaters.map((r) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRepeaterRow(context, r), + if (r.pathHops != null && r.pathHops!.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: 10, right: 10, bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(Icons.route, + size: 10, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant), + ), + const SizedBox(width: 4), + Expanded( + child: RxPathChain( + hops: r.pathHops!, + fontSize: 9, + ), + ), + ], + ), + ), + ], + )), + ], + ), + ), + ]); + } + + return widgets; + } + /// Build a table row for a repeater (matching TX log style) Widget _buildRepeaterRow(BuildContext context, MarkerRepeaterInfo repeater) { final snrColor = PingColors.snrColor(repeater.snr); From fd619b70a786b75f790feeb45446a8498d0ebc6b Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 26 May 2026 10:42:41 -0400 Subject: [PATCH 056/100] Enhance map widget: add multi-hop only icon and regional boundary toggle --- lib/widgets/map_widget.dart | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 77fd3df..d51825e 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -3240,6 +3240,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } else { // TX pings for (final ping in appState.txPings) { + final hasDirectEcho = + ping.heardRepeaters.any((r) => r.pathHops == null); + final hasMultiHopOnly = + !hasDirectEcho && ping.heardRepeaters.isNotEmpty; await syncOne( type: 'tx', lat: ping.latitude, @@ -3247,6 +3251,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ts: ping.timestamp, success: ping.heardRepeaters.isNotEmpty, idForMetadata: ping.timestamp.millisecondsSinceEpoch, + iconImageOverride: + hasMultiHopOnly ? _MapImages.coverage('rx', true) : null, ); } @@ -3989,7 +3995,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { PingEventType.discFail => (type: 'disc', success: false), PingEventType.traceSuccess => (type: 'trace', success: true), PingEventType.traceFail => (type: 'trace', success: false), - PingEventType.txMultiHopOnly => (type: 'tx', success: true), + PingEventType.txMultiHopOnly => (type: 'rx', success: true), }; /// Compute a version hash of all data that affects the marker list. @@ -4885,6 +4891,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { description: 'Prevent accidental rotation of the map', ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.fence, + label: 'Region Boundary', + description: + 'Toggle the regional boundary outline and labels on the map', + ), Divider( height: 1, color: Theme.of(context) From 804fa9f4d8bb434c4b82176032909dc5a3f45208 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 26 May 2026 12:57:12 -0400 Subject: [PATCH 057/100] Refactor map widget to improve tile load failure handling and sync focus lines --- lib/services/meshcore/trace_tracker.dart | 8 ++- lib/widgets/map_widget.dart | 91 ++++++++++++++++-------- 2 files changed, 66 insertions(+), 33 deletions(-) diff --git a/lib/services/meshcore/trace_tracker.dart b/lib/services/meshcore/trace_tracker.dart index 265c6e4..6d2ddcd 100644 --- a/lib/services/meshcore/trace_tracker.dart +++ b/lib/services/meshcore/trace_tracker.dart @@ -214,8 +214,12 @@ class TraceTracker { onWindowComplete?.call(result); } - /// Dispose of resources + /// Dispose of resources — fires onWindowComplete if a window was active void dispose() { - stopTracking(); + if (isListening) { + _endWindow(); + } else { + stopTracking(); + } } } diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index d51825e..5d04a09 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -443,7 +443,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Tile load failure detection — shows a banner if map tiles haven't loaded // within a timeout after style load. Cleared when onMapIdle fires. - bool _tileLoadFailed = false; + // Only surfaces the banner after 3 consecutive timeouts to avoid confusing + // users with transient failures. + int _consecutiveTileLoadFailures = 0; + static const _tileLoadFailureThreshold = 3; /// Tracks the last-applied mapTilesEnabled value so we can detect changes /// in _buildMap and call setOffline() without a full style reload. @@ -490,8 +493,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // style-reload path can drop stale names from the map's image cache if ever // needed. Right now we just re-addImage on each sync (idempotent). final Set _registeredDistanceLabelImages = {}; + final Map _registeredDistanceLabelImageSizes = {}; Symbol? _gpsSymbol; // single GPS marker + // When true, _syncAllAnnotations skips _updateFocusLines and + // _syncDistanceLabels so the 500ms zoom-to-fit animation runs without + // contention from heavy native platform calls. The deferred work runs + // after the animation settles via _activatePingFocus's delayed callback. + bool _focusSyncDeferred = false; + // Repeater cluster source/layer IDs (custom GeoJSON layer with cluster: true) static const _repeaterSourceId = 'repeaters-source'; static const _repeaterIndividualLayerId = 'repeaters-individual'; @@ -1177,6 +1187,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { debugError('[MAP] _syncAllAnnotations failed: $e'); } finally { _syncInFlight = false; + if (mounted) { + final freshVersion = _computeMarkerDataVersion(appState); + if (freshVersion != _lastMarkerDataVersion) { + setState(() {}); + } + } } }); } @@ -1259,9 +1275,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: _buildCollapsibleMapControls(appState), ), - // Tile load failure banner — appears if base tiles haven't finished - // loading within ${_tileLoadTimeoutSeconds}s after style load. - if (_tileLoadFailed) + if (_consecutiveTileLoadFailures >= _tileLoadFailureThreshold) Positioned( top: topPadding, left: 0, @@ -2027,6 +2041,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _distanceLabelImageSize.clear(); _distanceLabelRepeaterPos.clear(); _registeredDistanceLabelImages.clear(); + _registeredDistanceLabelImageSizes.clear(); // Mark cluster layers as not-ready until _setupRepeaterClusterLayers // creates them on the new style. This gates build()-driven post-frame // syncs from racing ahead of source creation. @@ -2085,18 +2100,23 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Ensure MapLibre offline mode matches the user's preference. _setOfflineIfSupported(!tilesEnabled); if (tilesEnabled) { - _tileLoadFailed = false; _tileLoadTimeoutTimer = Timer(const Duration(seconds: _tileLoadTimeoutSeconds), () { - if (mounted && !_tileLoadFailed) { - debugWarn( - '[MAP] Tile load timeout — tiles did not finish loading within ${_tileLoadTimeoutSeconds}s'); - setState(() => _tileLoadFailed = true); + if (!mounted) return; + _consecutiveTileLoadFailures++; + debugWarn( + '[MAP] Tile load timeout — tiles did not finish loading within ${_tileLoadTimeoutSeconds}s ($_consecutiveTileLoadFailures/$_tileLoadFailureThreshold)'); + if (_consecutiveTileLoadFailures >= _tileLoadFailureThreshold) { + appState.logError( + 'Map tiles unavailable — check connection', + severity: ErrorSeverity.warning, + autoSwitch: false, + ); + setState(() {}); } }); } else { - // Cache-only mode — never show the tile-load warning - _tileLoadFailed = false; + _consecutiveTileLoadFailures = 0; } // First-load-only setup: center on GPS and register camera listener. @@ -2154,9 +2174,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// that the new tiles have rendered. void _onMapIdle() { _tileLoadTimeoutTimer?.cancel(); - if (_tileLoadFailed && mounted) { - debugLog('[MAP] Tiles recovered after earlier load failure'); - setState(() => _tileLoadFailed = false); + if (_consecutiveTileLoadFailures > 0 && mounted) { + if (_consecutiveTileLoadFailures >= _tileLoadFailureThreshold) { + debugLog('[MAP] Tiles recovered after $_consecutiveTileLoadFailures consecutive load failures'); + } + _consecutiveTileLoadFailures = 0; + setState(() {}); } } @@ -3771,21 +3794,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final rendered = await _renderDistanceLabelPng(labelText); await _mapController!.addImage(imageName, rendered.bytes); _registeredDistanceLabelImages.add(imageName); + _registeredDistanceLabelImageSizes[imageName] = rendered.size; imageSize = rendered.size; } catch (e) { debugError('[MAP] render/addImage(distance label) failed: $e'); } } - // If we didn't just render (reuse case) we still need the size for - // collision tests. Re-render for measurement; this is cheap and rare. - if (imageSize == null) { - try { - final rendered = await _renderDistanceLabelPng(labelText); - imageSize = rendered.size; - } catch (_) { - imageSize = const Size(60, 18); - } - } + imageSize ??= _registeredDistanceLabelImageSizes[imageName] ?? + const Size(60, 18); _distanceLabelImageSize[key] = imageSize; _distanceLabelRepeaterPos[key] = LatLng(r.repeater.lat, r.repeater.lon); @@ -3940,8 +3956,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { await _syncRepeaterSymbols(appState); await _syncCoverageSymbols(appState); await _syncGpsSymbol(appState); - await _updateFocusLines(); - await _syncDistanceLabels(appState); + if (!_focusSyncDeferred) { + await _updateFocusLines(); + await _syncDistanceLabels(appState); + } } /// Fit camera to show all history session markers @@ -4015,6 +4033,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { for (final e in appState.discLogEntries) { discNodeTotal += e.discoveredNodes.length; } + int traceSuccessTotal = 0; + for (final t in appState.traceLogEntries) { + if (t.success) traceSuccessTotal++; + } return Object.hash( appState.txPings.length, @@ -4032,6 +4054,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { appState.preferences.markerStyle, txEchoTotal, discNodeTotal, + traceSuccessTotal, appState.viewingHistorySession, appState.historySessionMarkers?.length ?? 0, ); @@ -5590,6 +5613,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _focusPanelMinimized = false; + // Defer focus lines and distance labels so the 500ms zoom-to-fit + // animation runs without contention from heavy native platform calls. + _focusSyncDeferred = true; + setState(() { _focusedPingLocation = pingLocation; _focusedPingTimestamp = timestamp; @@ -5607,12 +5634,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } }); - // Once the 500ms zoom-to-fit animation settles, re-flow the distance - // labels so any that collide on screen slide along their lines to a - // non-overlapping slot. 600ms gives the camera a bit of buffer beyond - // the animation duration. - Future.delayed(const Duration(milliseconds: 600), () { + // Once the 500ms zoom-to-fit animation settles, run the deferred focus + // line and distance label sync, then reflow labels for collisions. + final appState = context.read(); + Future.delayed(const Duration(milliseconds: 600), () async { if (!mounted || _focusedPingLocation == null) return; + _focusSyncDeferred = false; + await _updateFocusLines(); + await _syncDistanceLabels(appState); _reflowDistanceLabelsForCollisions(); }); } From 0bbb6fae5c89b6b18a5c250e6aaba6d4fdf755ca Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 11 Jun 2026 11:41:50 -0400 Subject: [PATCH 058/100] feat: The app now sends your radio preset to the server on connect, and every ping is tagged with it. You can now filter region coverage by radio preset. --- ios/Flutter/ephemeral/flutter_lldb_helper.py | 32 ---------- ios/Flutter/ephemeral/flutter_lldbinit | 5 -- ios/Podfile.lock | 40 ------------ lib/providers/app_state_provider.dart | 16 +++++ lib/screens/connection_screen.dart | 43 +++++++++++++ lib/services/api_service.dart | 2 + lib/services/meshcore/connection.dart | 66 +++++++++++++++++--- lib/services/offline_session_service.dart | 15 +++++ 8 files changed, 135 insertions(+), 84 deletions(-) delete mode 100644 ios/Flutter/ephemeral/flutter_lldb_helper.py delete mode 100644 ios/Flutter/ephemeral/flutter_lldbinit delete mode 100644 ios/Podfile.lock diff --git a/ios/Flutter/ephemeral/flutter_lldb_helper.py b/ios/Flutter/ephemeral/flutter_lldb_helper.py deleted file mode 100644 index a88caf9..0000000 --- a/ios/Flutter/ephemeral/flutter_lldb_helper.py +++ /dev/null @@ -1,32 +0,0 @@ -# -# Generated file, do not edit. -# - -import lldb - -def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict): - """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages.""" - base = frame.register["x0"].GetValueAsAddress() - page_len = frame.register["x1"].GetValueAsUnsigned() - - # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the - # first page to see if handled it correctly. This makes diagnosing - # misconfiguration (e.g. missing breakpoint) easier. - data = bytearray(page_len) - data[0:8] = b'IHELPED!' - - error = lldb.SBError() - frame.GetThread().GetProcess().WriteMemory(base, data, error) - if not error.Success(): - print(f'Failed to write into {base}[+{page_len}]', error) - return - -def __lldb_init_module(debugger: lldb.SBDebugger, _): - target = debugger.GetDummyTarget() - # Caveat: must use BreakpointCreateByRegEx here and not - # BreakpointCreateByName. For some reasons callback function does not - # get carried over from dummy target for the later. - bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$") - bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__)) - bp.SetAutoContinue(True) - print("-- LLDB integration loaded --") diff --git a/ios/Flutter/ephemeral/flutter_lldbinit b/ios/Flutter/ephemeral/flutter_lldbinit deleted file mode 100644 index e3ba6fb..0000000 --- a/ios/Flutter/ephemeral/flutter_lldbinit +++ /dev/null @@ -1,5 +0,0 @@ -# -# Generated file, do not edit. -# - -command script import --relative-to-command-file flutter_lldb_helper.py diff --git a/ios/Podfile.lock b/ios/Podfile.lock deleted file mode 100644 index 3eb0bde..0000000 --- a/ios/Podfile.lock +++ /dev/null @@ -1,40 +0,0 @@ -PODS: - - disk_space_plus (0.0.1): - - Flutter - - Flutter (1.0.0) - - flutter_background_service_ios (0.0.3): - - Flutter - - flutter_local_notifications (0.0.1): - - Flutter - - permission_handler_apple (9.3.0): - - Flutter - -DEPENDENCIES: - - disk_space_plus (from `.symlinks/plugins/disk_space_plus/ios`) - - Flutter (from `Flutter`) - - flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`) - - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - -EXTERNAL SOURCES: - disk_space_plus: - :path: ".symlinks/plugins/disk_space_plus/ios" - Flutter: - :path: Flutter - flutter_background_service_ios: - :path: ".symlinks/plugins/flutter_background_service_ios/ios" - flutter_local_notifications: - :path: ".symlinks/plugins/flutter_local_notifications/ios" - permission_handler_apple: - :path: ".symlinks/plugins/permission_handler_apple/ios" - -SPEC CHECKSUMS: - disk_space_plus: a36391fb5f732dbdd29628b0bac5a1acbd43aaef - Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e - flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - -PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e - -COCOAPODS: 1.16.2 diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 0ca6c38..5fa5198 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -459,6 +459,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { DeviceModel? get deviceModel => _deviceModel; String? get manufacturerString => _manufacturerString; String? get firmwareVersionString => _firmwareVersionString; + + /// Human-readable radio config from the connected device's SelfInfo + /// (e.g. "910.525 MHz · 62.5 kHz · SF7 · CR5"); null on older firmware/no device. + String? get radioConfigDisplay => + _meshCoreConnection?.selfInfo?.radioConfigDisplay; String? get devicePublicKey => _devicePublicKey; PingStats get pingStats => _pingStats; bool get autoPingEnabled => _autoPingEnabled; @@ -1303,6 +1308,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { model: _meshCoreConnection!.deviceModel?.manufacturer ?? _meshCoreConnection!.deviceInfo?.manufacturer ?? 'Unknown', + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -1386,6 +1392,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { model: _meshCoreConnection!.deviceModel?.manufacturer ?? _meshCoreConnection!.deviceInfo?.manufacturer ?? 'Unknown', + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -4204,6 +4211,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, model: modelString, + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, @@ -4276,6 +4284,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, model: modelString, + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, @@ -4431,6 +4440,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { devicePublicKey: _devicePublicKey, deviceName: offlineDeviceName, contactUri: _offlineContactUri, + radioConfig: _meshCoreConnection?.selfInfo?.radioConfigApi, ); _offlineSessionService.finalizeCurrentSession(); debugLog('[APP] Saved offline session with ${pings.length} pings'); @@ -4458,6 +4468,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { devicePublicKey: _devicePublicKey, deviceName: offlineDeviceName, contactUri: _offlineContactUri, + radioConfig: _meshCoreConnection?.selfInfo?.radioConfigApi, ); } @@ -4607,6 +4618,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, model: 'Offline Upload', + radioFreq: session.radioConfig, lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -4637,6 +4649,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, model: 'Offline Upload', + radioFreq: session.radioConfig, lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -5184,6 +5197,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { devicePublicKey: _devicePublicKey, deviceName: offlineDeviceName, contactUri: _offlineContactUri, + radioConfig: _meshCoreConnection?.selfInfo?.radioConfigApi, ); debugLog( '[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); @@ -5962,6 +5976,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: newZoneCode, model: modelString, + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, @@ -6018,6 +6033,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: newZoneCode, model: modelString, + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 1472132..798ae70 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -660,6 +660,12 @@ class _ConnectionScreenState extends State const SizedBox(height: 4), _buildPowerRow(context, appState, isPowerSet, isAutoMode, prefs), + // Radio config row (freq/bandwidth/SF/CR), under Power Level + if (appState.radioConfigDisplay != null) ...[ + const SizedBox(height: 4), + _buildRadioRow(context, appState.radioConfigDisplay!), + ], + // Public key row if (appState.devicePublicKey != null) ...[ const SizedBox(height: 8), @@ -805,6 +811,43 @@ class _ConnectionScreenState extends State ); } + /// Radio configuration row (frequency/bandwidth/SF/CR), shown under Power Level. + /// Read-only — the app only reports the device's radio settings, never changes them. + Widget _buildRadioRow(BuildContext context, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + const SizedBox( + width: 120, + child: Text( + 'Radio', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.radio, size: 16, color: Colors.blue.shade400), + const SizedBox(width: 4), + Flexible( + child: Text( + value, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + /// Small detail chip with icon + text Widget _buildDetailChip(BuildContext context, IconData icon, String text) { final theme = Theme.of(context); diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index d6ba1e2..f451813 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -318,6 +318,7 @@ class ApiService { double? power, String? iataCode, String? model, + String? radioFreq, double? lat, double? lon, double? accuracyMeters, @@ -359,6 +360,7 @@ class ApiService { } if (iataCode != null) payload['iata'] = iataCode; if (model != null) payload['model'] = model; + if (radioFreq != null) payload['radio_freq'] = radioFreq; payload['coords'] = { 'lat': lat, 'lng': lon, // Convert lon → lng for API diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 7bdecd5..9d3f014 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -40,12 +40,24 @@ class SelfInfo { final Uint8List publicKey; final String name; + /// Radio configuration reported in the SelfInfo response (newer firmware only; + /// null on older firmware that omits the radio block). Units are as sent by the + /// MeshCore companion protocol: frequency and bandwidth in Hz, SF/CR raw. + final int? radioFreqHz; + final int? radioBwHz; + final int? radioSf; + final int? radioCr; + const SelfInfo({ required this.type, required this.txPower, required this.maxTxPower, required this.publicKey, required this.name, + this.radioFreqHz, + this.radioBwHz, + this.radioSf, + this.radioCr, }); /// Get public key as hex string @@ -53,6 +65,37 @@ class SelfInfo { .map((b) => b.toRadixString(16).padLeft(2, '0')) .join('') .toUpperCase(); + + /// Whether the device reported a usable radio configuration. + bool get hasRadioConfig => radioFreqHz != null && radioFreqHz! > 0; + + /// Compact radio config for the API: "freqMHz,bwKHz,SF,CR" (e.g. "910.525,62.5,7,5"). + /// Frequency Hz→MHz, bandwidth Hz→kHz. Null when the device didn't report radio params. + String? get radioConfigApi { + if (!hasRadioConfig) return null; + final freq = _trimNum(radioFreqHz! / 1e6); + final bw = _trimNum((radioBwHz ?? 0) / 1e3); + return '$freq,$bw,${radioSf ?? 0},${radioCr ?? 0}'; + } + + /// Human-readable radio config for the UI: "910.525 MHz · 62.5 kHz · SF7 · CR5". + /// Null when unavailable. + String? get radioConfigDisplay { + if (!hasRadioConfig) return null; + final freq = _trimNum(radioFreqHz! / 1e6); + final bw = _trimNum((radioBwHz ?? 0) / 1e3); + return '$freq MHz · $bw kHz · SF${radioSf ?? 0} · CR${radioCr ?? 0}'; + } + + /// Format a number with up to 3 decimals, trimming trailing zeros and a trailing dot + /// (910.525 → "910.525", 62.5 → "62.5", 915.0 → "915"). + static String _trimNum(double v) { + var s = v.toStringAsFixed(3); + if (s.contains('.')) { + s = s.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), ''); + } + return s; + } } /// MeshCore connection manager @@ -586,17 +629,22 @@ class MeshCoreConnection { final maxTxPower = reader.readByte(); final publicKey = reader.readBytes(32); - // Skip additional fields added in newer firmware versions - // These fields exist between publicKey and name + // Additional fields added in newer firmware versions, between publicKey and name. + // radioFreq/radioBw are uint32 Hz; radioSf/radioCr are single bytes (MeshCore + // companion protocol RESP_CODE_SELF_INFO). Older firmware omits this block. + int? radioFreqHz; + int? radioBwHz; + int? radioSf; + int? radioCr; if (reader.remainingBytesCount >= 22) { reader.readInt32LE(); // advLat reader.readInt32LE(); // advLon reader.readBytes(3); // reserved reader.readByte(); // manualAddContacts - reader.readUInt32LE(); // radioFreq - reader.readUInt32LE(); // radioBw - reader.readByte(); // radioSf - reader.readByte(); // radioCr + radioFreqHz = reader.readUInt32LE(); // radioFreq (Hz) + radioBwHz = reader.readUInt32LE(); // radioBw (Hz) + radioSf = reader.readByte(); // radioSf + radioCr = reader.readByte(); // radioCr } // Read name from remaining bytes @@ -608,11 +656,15 @@ class MeshCoreConnection { maxTxPower: maxTxPower, publicKey: publicKey, name: name, + radioFreqHz: radioFreqHz, + radioBwHz: radioBwHz, + radioSf: radioSf, + radioCr: radioCr, ); _selfInfo = selfInfo; debugLog( - '[CONN] SelfInfo received: name="${selfInfo.name}", publicKey=${selfInfo.publicKeyHex.substring(0, 16)}...'); + '[CONN] SelfInfo received: name="${selfInfo.name}", publicKey=${selfInfo.publicKeyHex.substring(0, 16)}..., radio=${selfInfo.radioConfigApi ?? "n/a"}'); _selfInfoCompleter?.complete(selfInfo); _selfInfoCompleter = null; diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index f295859..b7ba554 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -13,6 +13,7 @@ class OfflineSession { final String? devicePublicKey; // Device public key for auth during upload final String? deviceName; // Device name for display final String? contactUri; // Signed contact URI for registration during upload + final String? radioConfig; // Radio config captured at record time ("910.525,62.5,7,5") final bool uploaded; // Track upload status OfflineSession({ @@ -23,6 +24,7 @@ class OfflineSession { this.devicePublicKey, this.deviceName, this.contactUri, + this.radioConfig, this.uploaded = false, }); @@ -36,6 +38,7 @@ class OfflineSession { devicePublicKey: json['devicePublicKey'] as String?, deviceName: json['deviceName'] as String?, contactUri: json['contactUri'] as String?, + radioConfig: json['radioConfig'] as String?, uploaded: json['uploaded'] as bool? ?? false, ); } @@ -50,6 +53,7 @@ class OfflineSession { 'devicePublicKey': devicePublicKey, 'deviceName': deviceName, 'contactUri': contactUri, + 'radioConfig': radioConfig, 'uploaded': uploaded, }; } @@ -68,6 +72,7 @@ class OfflineSession { devicePublicKey: devicePublicKey, deviceName: deviceName, contactUri: contactUri, + radioConfig: radioConfig, uploaded: uploaded ?? this.uploaded, ); } @@ -161,6 +166,7 @@ class OfflineSessionService { String? devicePublicKey, String? deviceName, String? contactUri, + String? radioConfig, }) async { if (pings.isEmpty) { debugLog('[OFFLINE] No pings to save, skipping session creation'); @@ -178,6 +184,7 @@ class OfflineSessionService { 'pings': pings, if (devicePublicKey != null) 'device_public_key': devicePublicKey, if (deviceName != null) 'device_name': deviceName, + if (radioConfig != null) 'radio_config': radioConfig, }; final session = OfflineSession( @@ -188,6 +195,7 @@ class OfflineSessionService { devicePublicKey: devicePublicKey, deviceName: deviceName, contactUri: contactUri, + radioConfig: radioConfig, ); _sessions.insert(0, session); // Add at beginning (newest first) @@ -205,6 +213,7 @@ class OfflineSessionService { String? devicePublicKey, String? deviceName, String? contactUri, + String? radioConfig, }) async { if (pings.isEmpty) { debugLog('[OFFLINE] No pings to auto-save, skipping'); @@ -220,6 +229,10 @@ class OfflineSessionService { final updatedData = Map.from(existing.data); updatedData['pings'] = pings; updatedData['ping_count'] = pings.length; + final effectiveRadioConfig = radioConfig ?? existing.radioConfig; + if (effectiveRadioConfig != null) { + updatedData['radio_config'] = effectiveRadioConfig; + } _sessions[index] = OfflineSession( filename: existing.filename, @@ -229,6 +242,7 @@ class OfflineSessionService { devicePublicKey: devicePublicKey ?? existing.devicePublicKey, deviceName: deviceName ?? existing.deviceName, contactUri: contactUri ?? existing.contactUri, + radioConfig: effectiveRadioConfig, ); await _saveSessions(); debugLog( @@ -247,6 +261,7 @@ class OfflineSessionService { devicePublicKey: devicePublicKey, deviceName: deviceName, contactUri: contactUri, + radioConfig: radioConfig, ); // saveSession inserts at index 0 (newest first) if (_sessions.isNotEmpty) { From 3cb8b174c0b96c91c390f32db66841e10d9205a9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 11 Jun 2026 12:46:12 -0400 Subject: [PATCH 059/100] Fixed formatting of radio preset in app --- ios/Podfile.lock | 40 +++++++++++++++++++++++++++ lib/screens/connection_screen.dart | 23 ++++++++------- lib/services/meshcore/connection.dart | 34 +++++++++++++---------- 3 files changed, 73 insertions(+), 24 deletions(-) create mode 100644 ios/Podfile.lock diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..3eb0bde --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,40 @@ +PODS: + - disk_space_plus (0.0.1): + - Flutter + - Flutter (1.0.0) + - flutter_background_service_ios (0.0.3): + - Flutter + - flutter_local_notifications (0.0.1): + - Flutter + - permission_handler_apple (9.3.0): + - Flutter + +DEPENDENCIES: + - disk_space_plus (from `.symlinks/plugins/disk_space_plus/ios`) + - Flutter (from `Flutter`) + - flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + +EXTERNAL SOURCES: + disk_space_plus: + :path: ".symlinks/plugins/disk_space_plus/ios" + Flutter: + :path: Flutter + flutter_background_service_ios: + :path: ".symlinks/plugins/flutter_background_service_ios/ios" + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + +SPEC CHECKSUMS: + disk_space_plus: a36391fb5f732dbdd29628b0bac5a1acbd43aaef + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e + flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 798ae70..4f8898b 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -813,12 +813,19 @@ class _ConnectionScreenState extends State /// Radio configuration row (frequency/bandwidth/SF/CR), shown under Power Level. /// Read-only — the app only reports the device's radio settings, never changes them. + /// The value ("910.525 MHz · 62.5 kHz · SF7 · CR5") is split onto two tidy lines — + /// freq + bandwidth, then SF + CR — so it never wraps mid-value in the narrow card. Widget _buildRadioRow(BuildContext context, String value) { + final parts = value.split(' · '); + final line1 = parts.length >= 2 ? parts.take(2).join(' · ') : value; + final line2 = parts.length > 2 ? parts.skip(2).join(' · ') : ''; + const valueStyle = TextStyle(fontWeight: FontWeight.w500); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox( width: 120, @@ -827,18 +834,14 @@ class _ConnectionScreenState extends State style: TextStyle(fontWeight: FontWeight.w500), ), ), + Icon(Icons.radio, size: 16, color: Colors.blue.shade400), + const SizedBox(width: 4), Expanded( - child: Row( - mainAxisSize: MainAxisSize.min, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.radio, size: 16, color: Colors.blue.shade400), - const SizedBox(width: 4), - Flexible( - child: Text( - value, - style: const TextStyle(fontWeight: FontWeight.w500), - ), - ), + Text(line1, style: valueStyle), + if (line2.isNotEmpty) Text(line2, style: valueStyle), ], ), ), diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 9d3f014..355c328 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -41,9 +41,10 @@ class SelfInfo { final String name; /// Radio configuration reported in the SelfInfo response (newer firmware only; - /// null on older firmware that omits the radio block). Units are as sent by the - /// MeshCore companion protocol: frequency and bandwidth in Hz, SF/CR raw. - final int? radioFreqHz; + /// null on older firmware that omits the radio block). Encoding as sent by the device: + /// frequency in kHz, bandwidth in Hz, SF/CR raw. (The companion-protocol wiki documents + /// freq as Hz, but real hardware reports kHz — a 910.525 MHz radio sends 910525.) + final int? radioFreqKHz; final int? radioBwHz; final int? radioSf; final int? radioCr; @@ -54,7 +55,7 @@ class SelfInfo { required this.maxTxPower, required this.publicKey, required this.name, - this.radioFreqHz, + this.radioFreqKHz, this.radioBwHz, this.radioSf, this.radioCr, @@ -67,13 +68,13 @@ class SelfInfo { .toUpperCase(); /// Whether the device reported a usable radio configuration. - bool get hasRadioConfig => radioFreqHz != null && radioFreqHz! > 0; + bool get hasRadioConfig => radioFreqKHz != null && radioFreqKHz! > 0; /// Compact radio config for the API: "freqMHz,bwKHz,SF,CR" (e.g. "910.525,62.5,7,5"). - /// Frequency Hz→MHz, bandwidth Hz→kHz. Null when the device didn't report radio params. + /// Frequency kHz→MHz (÷1000), bandwidth Hz→kHz (÷1000). Null when no radio params. String? get radioConfigApi { if (!hasRadioConfig) return null; - final freq = _trimNum(radioFreqHz! / 1e6); + final freq = _trimNum(radioFreqKHz! / 1e3); final bw = _trimNum((radioBwHz ?? 0) / 1e3); return '$freq,$bw,${radioSf ?? 0},${radioCr ?? 0}'; } @@ -82,7 +83,7 @@ class SelfInfo { /// Null when unavailable. String? get radioConfigDisplay { if (!hasRadioConfig) return null; - final freq = _trimNum(radioFreqHz! / 1e6); + final freq = _trimNum(radioFreqKHz! / 1e3); final bw = _trimNum((radioBwHz ?? 0) / 1e3); return '$freq MHz · $bw kHz · SF${radioSf ?? 0} · CR${radioCr ?? 0}'; } @@ -629,10 +630,11 @@ class MeshCoreConnection { final maxTxPower = reader.readByte(); final publicKey = reader.readBytes(32); - // Additional fields added in newer firmware versions, between publicKey and name. - // radioFreq/radioBw are uint32 Hz; radioSf/radioCr are single bytes (MeshCore - // companion protocol RESP_CODE_SELF_INFO). Older firmware omits this block. - int? radioFreqHz; + // Additional fields added in newer firmware versions, between publicKey and name + // (MeshCore companion protocol RESP_CODE_SELF_INFO). Older firmware omits this block. + // Encoding note: the wiki documents radioFreq as uint32 Hz, but real hardware reports + // it in kHz (a 910.525 MHz radio sends 910525); radioBw is uint32 Hz; SF/CR are bytes. + int? radioFreqKHz; int? radioBwHz; int? radioSf; int? radioCr; @@ -641,7 +643,7 @@ class MeshCoreConnection { reader.readInt32LE(); // advLon reader.readBytes(3); // reserved reader.readByte(); // manualAddContacts - radioFreqHz = reader.readUInt32LE(); // radioFreq (Hz) + radioFreqKHz = reader.readUInt32LE(); // radioFreq (kHz on real hardware) radioBwHz = reader.readUInt32LE(); // radioBw (Hz) radioSf = reader.readByte(); // radioSf radioCr = reader.readByte(); // radioCr @@ -656,7 +658,7 @@ class MeshCoreConnection { maxTxPower: maxTxPower, publicKey: publicKey, name: name, - radioFreqHz: radioFreqHz, + radioFreqKHz: radioFreqKHz, radioBwHz: radioBwHz, radioSf: radioSf, radioCr: radioCr, @@ -665,6 +667,10 @@ class MeshCoreConnection { _selfInfo = selfInfo; debugLog( '[CONN] SelfInfo received: name="${selfInfo.name}", publicKey=${selfInfo.publicKeyHex.substring(0, 16)}..., radio=${selfInfo.radioConfigApi ?? "n/a"}'); + // Raw radio values straight off the device — surfaces the actual encoding in the + // downloadable debug log (diagnoses any future unit questions). + debugLog( + '[CONN] Radio raw: freqKHz=$radioFreqKHz bwHz=$radioBwHz sf=$radioSf cr=$radioCr → ${selfInfo.radioConfigApi ?? "n/a"}'); _selfInfoCompleter?.complete(selfInfo); _selfInfoCompleter = null; From a8805dd5050515785c2bfdf0878b89043568cfa1 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 11 Jun 2026 14:11:46 -0400 Subject: [PATCH 060/100] Privacy: your location is no longer broadcast over the air - TX pings now send a short opaque tag (e.g. "MM:zpCFQwc") on the #wardriving channel instead of your GPS coordinates. Your location still reaches the server over the API, it's just no longer readable off the air by anyone with the channel key. - New "Broadcast My Coordinates" toggle (Settings -> Ping Settings, off by default) lets you opt back into putting your real coordinates on the air, now sent as a compact "MM:lat,lon". - Very long sessions now end cleanly: when the per-session ping limit is reached the app uploads any pending pings, then disconnects with "Reached session limit, please reconnect." --- lib/models/api_queue_item.dart | 18 +++ lib/models/user_preferences.dart | 11 ++ lib/providers/app_state_provider.dart | 37 ++++++ lib/screens/settings_screen.dart | 9 ++ lib/services/api_queue_service.dart | 4 + lib/services/api_service.dart | 26 +++++ lib/services/meshcore/connection.dart | 16 ++- lib/services/meshcore/wire_tag_codec.dart | 132 ++++++++++++++++++++++ lib/services/ping_service.dart | 55 ++++++++- test/services/wire_tag_codec_test.dart | 82 ++++++++++++++ 10 files changed, 375 insertions(+), 15 deletions(-) create mode 100644 lib/services/meshcore/wire_tag_codec.dart create mode 100644 test/services/wire_tag_codec_test.dart diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index 3a19735..472c4c8 100644 --- a/lib/models/api_queue_item.dart +++ b/lib/models/api_queue_item.dart @@ -56,6 +56,14 @@ class ApiQueueItem extends HiveObject { @HiveField(15) final double? power; + /// TX wire-tag ping counter (token mode only; null otherwise). + @HiveField(16) + final int? pingCounter; + + /// TX wire-tag body sent on the air, e.g. "MM:FlmLG4I" (token mode only; null otherwise). + @HiveField(17) + final String? wireTag; + ApiQueueItem({ required this.type, required this.latitude, @@ -68,6 +76,8 @@ class ApiQueueItem extends HiveObject { this.lastRetryAt, this.noiseFloor, this.power, + this.pingCounter, + this.wireTag, }); /// Create from TX ping @@ -80,6 +90,8 @@ class ApiQueueItem extends HiveObject { required bool externalAntenna, int? noiseFloor, double? power, + int? pingCounter, + String? wireTag, }) { return ApiQueueItem( type: 'TX', @@ -92,6 +104,8 @@ class ApiQueueItem extends HiveObject { externalAntenna: externalAntenna, noiseFloor: noiseFloor, power: power, + pingCounter: pingCounter, + wireTag: wireTag, ); } @@ -269,6 +283,10 @@ class ApiQueueItem extends HiveObject { timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, + // Token-mode TX only. Their presence selects the server's validated path; + // absence (coords mode / RX) is the unchanged-from-today coords path. + if (pingCounter != null) 'ping_counter': pingCounter, + if (wireTag != null) 'wire_tag': wireTag, }; } diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index d26d4c2..eb8c2e2 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -70,6 +70,10 @@ class UserPreferences { /// Anonymous mode: rename companion to "Anonymous" during wardriving final bool anonymousMode; + /// Broadcast coordinates: when true, TX pings put real GPS on the air (legacy + /// behavior). Default false = privacy-preserving wire tag; coords go via API only. + final bool broadcastCoords; + /// Discovery drop: count failed discoveries as failed pings and report to API final bool discDropEnabled; @@ -149,6 +153,7 @@ class UserPreferences { this.mapRotationLocked = false, this.disableRssiFilter = false, this.anonymousMode = false, + this.broadcastCoords = false, this.discDropEnabled = false, this.floodTrafficEnabled = false, this.deleteChannelOnDisconnect = true, @@ -195,6 +200,7 @@ class UserPreferences { mapRotationLocked: (json['mapRotationLocked'] as bool?) ?? false, disableRssiFilter: (json['disableRssiFilter'] as bool?) ?? false, anonymousMode: (json['anonymousMode'] as bool?) ?? false, + broadcastCoords: (json['broadcastCoords'] as bool?) ?? false, discDropEnabled: (json['discDropEnabled'] as bool?) ?? false, floodTrafficEnabled: (json['floodTrafficEnabled'] as bool?) ?? false, deleteChannelOnDisconnect: @@ -253,6 +259,7 @@ class UserPreferences { 'mapRotationLocked': mapRotationLocked, 'disableRssiFilter': disableRssiFilter, 'anonymousMode': anonymousMode, + 'broadcastCoords': broadcastCoords, 'discDropEnabled': discDropEnabled, 'floodTrafficEnabled': floodTrafficEnabled, 'deleteChannelOnDisconnect': deleteChannelOnDisconnect, @@ -298,6 +305,7 @@ class UserPreferences { bool? mapRotationLocked, bool? disableRssiFilter, bool? anonymousMode, + bool? broadcastCoords, bool? discDropEnabled, bool? floodTrafficEnabled, bool? deleteChannelOnDisconnect, @@ -342,6 +350,7 @@ class UserPreferences { mapRotationLocked: mapRotationLocked ?? this.mapRotationLocked, disableRssiFilter: disableRssiFilter ?? this.disableRssiFilter, anonymousMode: anonymousMode ?? this.anonymousMode, + broadcastCoords: broadcastCoords ?? this.broadcastCoords, discDropEnabled: discDropEnabled ?? this.discDropEnabled, floodTrafficEnabled: floodTrafficEnabled ?? this.floodTrafficEnabled, deleteChannelOnDisconnect: @@ -419,6 +428,7 @@ class UserPreferences { other.mapRotationLocked == mapRotationLocked && other.disableRssiFilter == disableRssiFilter && other.anonymousMode == anonymousMode && + other.broadcastCoords == broadcastCoords && other.discDropEnabled == discDropEnabled && other.floodTrafficEnabled == floodTrafficEnabled && other.deleteChannelOnDisconnect == deleteChannelOnDisconnect && @@ -463,6 +473,7 @@ class UserPreferences { mapRotationLocked, disableRssiFilter, anonymousMode, + broadcastCoords, discDropEnabled, floodTrafficEnabled, deleteChannelOnDisconnect, diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 5fa5198..6b3f0b7 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2165,6 +2165,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService!.checkTxAllowed = () => txAllowed; _pingService!.getDiscDropEnabled = () => discDropEnabled; + // Wire-tag composition (privacy-preserving TX body by default). + _pingService!.getSessionId = () => _apiService.sessionId; + _pingService!.getWireKey = () => _apiService.wireKey; + _pingService!.getNextPingCounter = () => _apiService.nextPingCounter(); + _pingService!.getBroadcastCoords = () => _preferences.broadcastCoords; + _pingService!.getPingCounter = () => _apiService.pingCounter; + _pingService!.onSessionLimitReached = + () => handleSessionError('session_limit', null); + _pingService!.onTxPing = (ping) { _txPings.add(ping); if (_txPings.length > _maxMapPins) _txPings.removeAt(0); @@ -5048,6 +5057,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + /// Broadcast my coordinates: when true, TX pings put real GPS on the air + /// (legacy). When false (default), pings broadcast the privacy-preserving wire + /// tag and coords travel only via the API. + Future setBroadcastCoords(bool enabled) async { + _preferences = _preferences.copyWith(broadcastCoords: enabled); + await _savePreferences(); + debugLog('[PING] Broadcast coordinates ${enabled ? 'enabled' : 'disabled'}'); + notifyListeners(); + } + /// Play disconnect alert if enabled (triple beep for unexpected ping stop) void _playDisconnectAlert() { if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) { @@ -5102,6 +5121,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return serverMessage ?? 'Invalid request to API.'; case 'session_expired': return 'Session has expired. Please reconnect.'; + case 'session_limit': + return 'Reached session limit. Please reconnect to continue.'; case 'bad_session': return 'Invalid session. Please reconnect.'; case 'outofdate': @@ -5132,6 +5153,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Future handleSessionError(String? reason, String? message) async { final userMessage = _getErrorMessage(reason, message); + // Session ping-counter exhausted (wire tag's 11-bit cap). The session is still + // valid here, so flush the queue under it BEFORE disconnecting: clearOnDisconnect() + // drops pending pings, and token-ping wire tags would fail validation if re-uploaded + // later under a new session. + if (reason == 'session_limit') { + debugError('[SESSION] $userMessage'); + logError(userMessage, severity: ErrorSeverity.warning); + try { + await _apiQueueService.flushQueue(); + } catch (e) { + debugError('[SESSION] Queue flush before session-limit disconnect failed: $e'); + } + await disconnect(); + return; + } + // Rate limiting should warn but not disconnect (per PORTED_APP behavior) if (reason == 'rate_limited') { debugWarn( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e5ba729..8f1dab7 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -307,6 +307,15 @@ class _SettingsScreenState extends State { } }, ), + SwitchListTile( + secondary: const Icon(Icons.my_location), + title: const Text('Broadcast My Coordinates'), + subtitle: Text(prefs.broadcastCoords + ? 'Real GPS is sent on the air' + : 'Coordinates stay private (sent only to the server)'), + value: prefs.broadcastCoords, + onChanged: (value) => appState.setBroadcastCoords(value), + ), ListTile( leading: const Icon(Icons.timer), title: const Text('Auto-Ping Interval'), diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 4ed600a..e9ff635 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -237,6 +237,8 @@ class ApiQueueService { required bool externalAntenna, int? noiseFloor, double? power, + int? pingCounter, + String? wireTag, }) async { final item = ApiQueueItem.fromTx( latitude: latitude, @@ -246,6 +248,8 @@ class ApiQueueService { externalAntenna: externalAntenna, noiseFloor: noiseFloor, power: power, + pingCounter: pingCounter, + wireTag: wireTag, ); // In offline mode, accumulate to offline pings list instead of queue diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index f451813..47c8c34 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -39,6 +39,8 @@ class ApiService { bool _txAllowed = false; bool _rxAllowed = false; int? _sessionExpiresAt; + String? _wireKey; // TX wire-tag secret from /auth (null = un-keyed fallback) + int _pingCounter = 0; // per-session TX counter; resets on fresh /auth, not on heartbeat Timer? _heartbeatTimer; Timer? _heartbeatRetryTimer; @@ -153,6 +155,18 @@ class ApiService { /// Get session ID String? get sessionId => _sessionId; + /// TX wire-tag secret delivered by /auth (null when the server didn't send one). + String? get wireKey => _wireKey; + + /// Current per-session TX ping counter (peek without incrementing). + int get pingCounter => _pingCounter; + + /// Increment and return the next per-session TX ping counter (1-based). + /// Hard-capped at 2047 (the wire tag's 11-bit counter field) so the packed + /// token can never overflow into the session#/region bits. Reaching the cap + /// needs an ~8.5h continuous session at 15s; a fresh /auth resets it. + int nextPingCounter() => _pingCounter >= 2047 ? 2047 : ++_pingCounter; + /// Get session expiry timestamp int? get sessionExpiresAt => _sessionExpiresAt; @@ -418,6 +432,16 @@ class ApiService { _rxAllowed = data['rx_allowed'] == true; _sessionExpiresAt = data['expires_at'] as int?; + // TX wire-tag key + per-session ping counter (counter resets on a fresh /auth). + // Log only receipt + length — NEVER the raw key (debug logs are uploadable). + _wireKey = data['wire_key'] as String?; + _pingCounter = 0; + if (_wireKey != null) { + debugLog('[AUTH] wire-tag key received (len=${_wireKey!.length})'); + } else { + debugLog('[AUTH] no wire-tag key in auth response (un-keyed tag)'); + } + // Parse channels array from auth response final channelsData = data['channels']; if (channelsData is List) { @@ -837,6 +861,8 @@ class ApiService { /// Clear session data and cancel all timers void _clearSession() { _sessionId = null; + _wireKey = null; + _pingCounter = 0; _txAllowed = false; _rxAllowed = false; _sessionExpiresAt = null; diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 355c328..9a59331 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -1105,20 +1105,18 @@ class MeshCoreConnection { ); } - /// Send ping to #wardriving channel - /// Format: @[MapperBot] LAT, LON - /// Power is no longer included in the mesh message — it is sent per-ping in the API payload instead - /// Reference: buildPayload() in wardrive.js - Future sendPing(double lat, double lon) async { + /// Send a pre-composed TX body to the #wardriving channel. + /// The caller composes the body (privacy wire tag "MM:..." by default, or the + /// legacy "@[MapperBot] LAT, LON" when the user opts into broadcasting coords) + /// so the exact same string is used for both TxTracker echo matching and the + /// actual transmission. + /// Power is not included in the mesh message — it is sent per-ping in the API payload. + Future sendPing(String message) async { final channel = _wardrivingChannel; if (channel == null) { throw Exception('Wardriving channel not initialized'); } - // Format coordinates to 5 decimal places with comma separator - final coordsStr = '${lat.toStringAsFixed(5)}, ${lon.toStringAsFixed(5)}'; - final message = '@[MapperBot] $coordsStr'; - debugLog('[CONN] Sending ping: $message'); final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; await sendChannelTextMessage( diff --git a/lib/services/meshcore/wire_tag_codec.dart b/lib/services/meshcore/wire_tag_codec.dart new file mode 100644 index 0000000..c11f818 --- /dev/null +++ b/lib/services/meshcore/wire_tag_codec.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// Encodes/decodes the on-air TX "wire tag" that replaces plaintext GPS +/// coordinates on the `#wardriving` channel. +/// +/// A tag is `"MM:" + 7 base64url chars` (10 chars total → a single 16-byte AES +/// block once the channel layer encrypts it). It packs the origin region, the +/// session number, and the per-session ping counter into 40 bits, then runs a +/// keyed 4-round Feistel so it can only be decoded with the shared secret. +/// +/// The session *date* is intentionally NOT encoded — a decoder always holds a +/// receive timestamp (or, server-side, the full session_id), so the date is +/// supplied from context. The full session_id is reconstructed as +/// `(decoded region)-(date from context)-(decoded session#)`. +/// +/// This MUST stay byte-identical to the PHP `wireTagEncode`/`wireTagDecode` +/// twins — see `test/services/wire_tag_codec_test.dart` for the canonical +/// cross-language vectors. +/// +/// Web safety: this app runs on web (dart2js), where the bitwise operators +/// `<<`, `>>`, `&`, `|` truncate to 32 bits. Our packed value is up to 40 bits, +/// so anything that can exceed 31 bits uses plain arithmetic (`*`, `~/`, `%`), +/// which is exact for integers below 2^53. Bitwise ops are used only on the +/// two ≤20-bit Feistel halves, which are always < 2^31. +class WireTagCodec { + WireTagCodec._(); + + /// Marker prefix that distinguishes a MeshMapper tag from ordinary chatter. + static const String prefix = 'MM:'; + + static const int _pow11 = 2048; // 2^11 (counter field) + static const int _pow20 = 1048576; // 2^20 (Feistel half) + static const int _pow25 = 33554432; // 2^25 (region field shift) + static const int _mask20 = 0xFFFFF; // 20 bits — safe (< 2^31) + + static int _regionPack(String iata) { + final u = iata.toUpperCase(); + return (u.codeUnitAt(0) - 65) * 676 + + (u.codeUnitAt(1) - 65) * 26 + + (u.codeUnitAt(2) - 65); + } + + static String _regionUnpack(int n) => + String.fromCharCodes([n ~/ 676 + 65, (n % 676) ~/ 26 + 65, n % 26 + 65]); + + /// Feistel round function: first 3 bytes of SHA-256(secret ‖ round ‖ half), + /// masked to 20 bits. `half` is serialized big-endian as 3 bytes. + static int _f(List secret, int half, int round) { + final input = [ + ...secret, + round, + (half ~/ 65536) % 256, + (half ~/ 256) % 256, + half % 256, + ]; + final d = sha256.convert(input).bytes; + return (d[0] * 65536 + d[1] * 256 + d[2]) % _pow20; + } + + static int _feistel(List secret, int v, {required bool decrypt}) { + var l = v ~/ _pow20; // top 20 bits + var r = v % _pow20; // bottom 20 bits + for (final round in decrypt ? const [3, 2, 1, 0] : const [0, 1, 2, 3]) { + if (!decrypt) { + final newL = r; + r = (l ^ _f(secret, r, round)) & _mask20; + l = newL; + } else { + final newR = l; + l = (r ^ _f(secret, l, round)) & _mask20; + r = newR; + } + } + return l * _pow20 + r; + } + + static Uint8List _toBytes5(int v) { + final b = Uint8List(5); + var x = v; + for (var i = 4; i >= 0; i--) { + b[i] = x % 256; + x = x ~/ 256; + } + return b; + } + + static int _fromBytes5(List b) { + var v = 0; + for (final byte in b) { + v = v * 256 + byte; + } + return v; + } + + static String _b64url(List bytes) => + base64Url.encode(bytes).replaceAll('=', ''); + + static Uint8List _unb64url(String s) => + base64Url.decode(s + ('=' * ((4 - s.length % 4) % 4))); + + /// Encode `sessionId` (`IATA-YYYYMMDD-NNNN`) + `counter` into the wire body. + /// `key` is the shared secret from `/auth`; null/empty uses the un-keyed + /// fallback (still coord-free, just not key-protected). + static String encode(String sessionId, int counter, String? key) { + final parts = sessionId.split('-'); + final v = _regionPack(parts[0]) * _pow25 + + int.parse(parts[2]) * _pow11 + + counter; + final cipher = _feistel(utf8.encode(key ?? ''), v, decrypt: false); + return prefix + _b64url(_toBytes5(cipher)); + } + + /// Decode a wire body back to region / session# / counter using the key alone + /// (no database needed). The date is not recoverable from the tag. + static ({String region, int sessionNum, int counter}) decode( + String body, String? key) { + final token = + body.startsWith(prefix) ? body.substring(prefix.length) : body; + final v = + _feistel(utf8.encode(key ?? ''), _fromBytes5(_unb64url(token)), decrypt: true); + final region = v ~/ _pow25; + final rem = v % _pow25; + return ( + region: _regionUnpack(region), + sessionNum: rem ~/ _pow11, + counter: rem % _pow11, + ); + } +} diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 13fc495..3a29815 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -15,6 +15,7 @@ import 'meshcore/connection.dart'; import 'meshcore/disc_tracker.dart'; import 'meshcore/trace_tracker.dart'; import 'meshcore/tx_tracker.dart'; +import 'meshcore/wire_tag_codec.dart'; import 'meshcore/unified_rx_handler.dart'; import 'wakelock_service.dart'; @@ -96,6 +97,8 @@ class PingService { // TX ping context for queueing after RX window ends int? _pendingTxTimestamp; int? _pendingTxNoiseFloor; + int? _pendingTxPingCounter; // wire-tag ping counter (null in coords mode) + String? _pendingTxWireTag; // wire-tag body sent on air (null in coords mode) // Ping in progress guard (prevents concurrent BLE GATT errors) // Reference: state.pingInProgress in wardrive.js @@ -151,6 +154,19 @@ class PingService { /// Callback to check if TX is allowed by API (zone capacity check) bool Function()? checkTxAllowed; + /// Wire-tag composition (default privacy mode). Return the active session_id, + /// the wire-tag key from /auth, the next per-session ping counter, and whether + /// the user opted into broadcasting real coords on the air. + String? Function()? getSessionId; + String? Function()? getWireKey; + int Function()? getNextPingCounter; + bool Function()? getBroadcastCoords; + + /// Peek the current per-session ping counter, and react when it is exhausted + /// (wire tag's 11-bit cap) — AppStateProvider disconnects with a session-limit message. + int Function()? getPingCounter; + Future Function()? onSessionLimitReached; + /// Callback for ping events void Function(TxPing)? onTxPing; void Function(RxPing)? onRxPing; @@ -578,11 +594,34 @@ class PingService { } final txPowerDbm = _connection.deviceModel?.txPower ?? 22; - // Build ping message (same format used for TxTracker correlation) - // Power is no longer included in the mesh message — sent per-ping in API payload + // Build the on-air body ONCE (same string is used for TxTracker echo + // correlation AND the actual transmission). Power is sent per-ping in the API. + // + // Default (privacy): a keyed wire tag "MM:..." — coords go only via the API. + // Opt-in (Broadcast My Coordinates) OR no session yet: plaintext "MM:lat,lon". final coordsStr = - '${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'; - final pingMessage = '@[MapperBot] $coordsStr'; + '${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}'; + final broadcastCoords = getBroadcastCoords?.call() ?? false; + final sessionId = getSessionId?.call(); + String pingMessage; + int? txPingCounter; + String? txWireTag; + if (!broadcastCoords && sessionId != null && sessionId.isNotEmpty) { + // Session-limit guard: the wire tag's counter is 11 bits (max 2047). When it + // is exhausted, end the session cleanly rather than repeating/corrupting tags. + if ((getPingCounter?.call() ?? 0) >= 2047) { + debugError('[SESSION] Reached session ping limit (2047) — disconnecting'); + _pingInProgress = false; + onSessionLimitReached?.call(); + return false; + } + txPingCounter = getNextPingCounter?.call() ?? 1; + txWireTag = + WireTagCodec.encode(sessionId, txPingCounter, getWireKey?.call()); + pingMessage = txWireTag; + } else { + pingMessage = 'MM:$coordsStr'; + } // Capture noise floor at ping time final noiseFloor = _connection.lastNoiseFloor; @@ -703,8 +742,8 @@ class PingService { // Play transmit sound immediately before sending _audioService?.playTransmitSound(); - // Send ping via BLE (coordinates only — power is in API payload) - await _connection.sendPing(position.latitude, position.longitude); + // Send ping via BLE (pre-composed body — wire tag or legacy coords) + await _connection.sendPing(pingMessage); // Mark ping time and position _lastTxTime = DateTime.now(); @@ -723,6 +762,8 @@ class PingService { // TX entry is queued AFTER RX window so heard_repeats can be populated _pendingTxTimestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; _pendingTxNoiseFloor = noiseFloor; + _pendingTxPingCounter = txPingCounter; // null in coords mode + _pendingTxWireTag = txWireTag; // null in coords mode // Start RX listening window (TX will be queued when window ends) _startRxListeningWindow(position); @@ -825,6 +866,8 @@ class PingService { externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, power: getPowerLevel?.call(), + pingCounter: _pendingTxPingCounter, // null in coords mode → server coords path + wireTag: _pendingTxWireTag, // null in coords mode → server coords path ); debugLog('[PING] Queued TX entry with heard_repeats: $heardRepeats'); diff --git a/test/services/wire_tag_codec_test.dart b/test/services/wire_tag_codec_test.dart new file mode 100644 index 0000000..bcf047c --- /dev/null +++ b/test/services/wire_tag_codec_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/meshcore/wire_tag_codec.dart'; + +/// Canonical cross-language vectors. These MUST stay byte-identical to the PHP +/// `wireTagEncode` and the Python reference oracle — they are the wire contract. +/// See docs / the TX Wire-Tag plan for how they were generated. +void main() { + group('WireTagCodec.encode (canonical vectors, key=TESTKEY)', () { + const key = 'TESTKEY'; + final vectors = <(String, int), String>{ + ('PAR-20260611-0013', 1): 'MM:zpCFQwc', + ('JKG-20260611-0009', 1): 'MM:zJHa-B8', + ('AAR-20260611-0014', 1): 'MM:GD59I2Q', + ('AAR-20260611-0123', 1000): 'MM:x6laqiY', + ('YOW-20260504-0005', 1): 'MM:2Oj9Xyg', + ('ZZZ-20260101-9999', 2047): 'MM:ETfo5FI', + }; + + vectors.forEach((input, expected) { + test('${input.$1} ping ${input.$2} -> $expected', () { + expect(WireTagCodec.encode(input.$1, input.$2, key), expected); + }); + }); + + test('body is always "MM:" + 7 base64url chars (10 chars, one AES block)', () { + final body = WireTagCodec.encode('PAR-20260611-0013', 1, key); + expect(body.length, 10); + expect(RegExp(r'^MM:[A-Za-z0-9_-]{7}$').hasMatch(body), isTrue); + }); + }); + + group('WireTagCodec.encode (empty-key fallback)', () { + final vectors = <(String, int), String>{ + ('PAR-20260611-0013', 1): 'MM:jHHz-gQ', + ('JKG-20260611-0009', 1): 'MM:ozT0SI8', + ('AAR-20260611-0014', 1): 'MM:4y-cINQ', + ('AAR-20260611-0123', 1000): 'MM:ATsK8_8', + ('YOW-20260504-0005', 1): 'MM:EiC-3p4', + ('ZZZ-20260101-9999', 2047): 'MM:_CI9Xfs', + }; + + vectors.forEach((input, expected) { + test('null key == "" key, ${input.$1} ping ${input.$2} -> $expected', () { + expect(WireTagCodec.encode(input.$1, input.$2, null), expected); + expect(WireTagCodec.encode(input.$1, input.$2, ''), expected); + }); + }); + }); + + group('WireTagCodec.decode (key only, no DB)', () { + test('recovers region/session#/counter from a known body', () { + final r = WireTagCodec.decode('MM:zpCFQwc', 'TESTKEY'); + expect(r.region, 'PAR'); + expect(r.sessionNum, 13); + expect(r.counter, 1); + }); + + test('decode with the wrong key yields a different region', () { + final right = WireTagCodec.decode('MM:zpCFQwc', 'TESTKEY'); + final wrong = WireTagCodec.decode('MM:zpCFQwc', 'nope'); + expect(right.region, 'PAR'); + expect(wrong.region, isNot('PAR')); + }); + }); + + group('round-trip exactness across ping 1..1000', () { + test('encode then decode recovers the exact triple, all unique', () { + const key = 'TESTKEY'; + const sid = 'AAR-20260611-0123'; + final bodies = {}; + for (var c = 1; c <= 1000; c++) { + final body = WireTagCodec.encode(sid, c, key); + bodies.add(body); + final d = WireTagCodec.decode(body, key); + expect(d.region, 'AAR'); + expect(d.sessionNum, 123); + expect(d.counter, c); + } + expect(bodies.length, 1000, reason: 'every ping must be unique on the wire'); + }); + }); +} From d74f7ac106393f540c30d2f77bd16af997713ca9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 12 Jun 2026 20:05:00 -0400 Subject: [PATCH 061/100] Added Vector Tile Support and Refreshing single tiles by injecting GeoJson tile(hack cause we cant invalidate a single tile with LibreMaps) --- DEVELOPMENT.md | 36 ++- lib/models/user_preferences.dart | 14 ++ lib/providers/app_state_provider.dart | 242 +++++++++++++++++-- lib/screens/settings_screen.dart | 62 +++++ lib/services/api_queue_service.dart | 9 +- lib/services/api_service.dart | 46 ++++ lib/utils/coverage_tile_palette.dart | 89 +++++++ lib/utils/mvt_cells.dart | 159 +++++++++++++ lib/widgets/map_widget.dart | 327 ++++++++++++++++++++++---- test/utils/mvt_cells_test.dart | 46 ++++ 10 files changed, 963 insertions(+), 67 deletions(-) create mode 100644 lib/utils/coverage_tile_palette.dart create mode 100644 lib/utils/mvt_cells.dart create mode 100644 test/utils/mvt_cells_test.dart diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d7b96bb..2e636b2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -290,7 +290,41 @@ Keeps the screen on during auto-ping to prevent device sleep during wardriving s - **Platform**: Android and iOS only (Web N/A — always requires active tab) - **File**: `lib/services/wakelock_service.dart` -## Critical Protocol Details +### Coverage Overlay (vector tiles) + +The MeshMapper coverage layer is rendered from the region server's vector tiles +(`vector_tile.php`, z7–14, overzoom beyond) as a MapLibre source+layer pair. The app is +vector-only — every region server must serve `vector_tile.php` (the legacy raster +`tiles.php` overlay was removed from the app 2026-06). Contract reference: +`MeshMapper_Server/docs/VECTOR_TILES.md`. + +- **Styling is client-side**: each cell carries an integer status category `st`; colours + come from `match` expressions built by `lib/utils/coverage_tile_palette.dart` (kept in + sync with the server's `dev/cvd_palettes.php`, including all colour-vision palettes). +- **Coverage Grid preference (`prefs.coverageGridSize`)**: Simplified (300 m, default) or + Detailed (100 m + blob), mirroring the web's Grid Mode; baked into the tile URL. The + grid is locked to the chosen preset at every zoom — cells never resize. +- **Post-wardrive live refresh**: on upload success the queue hands the uploaded items to + `AppStateProvider`; +7 s later the server re-renders the affected tiles at z11–14 + (`fresh=1`, incl. neighbouring tiles within ~0.005° — blob/border spill lands in the + next tile over), and the user's own cells are decoded from the fresh z14 bodies + (`lib/utils/mvt_cells.dart`) into a session **patch layer**: a GeoJSON source updated + in place above the base layer, with the base layer's copies hidden via `setFilter`. + The base source is never swapped — nothing visibly changes except the changed cells. + A second check runs at +10 s only when the first found no changes. Logged under + `[COVERAGE]`. +- **GOTCHA — never partial-update a fill layer**: `setLayerProperties` serializes with + `skipNulls: false`; any `FillLayerProperties` field left null is RESET to its + style-spec default on iOS/web (`fill-color` → black). Always resend the full colour + expressions with an opacity change (see `_applyCoverageOverlayOpacity`). +- **GOTCHA — feature ids don't survive Android's filter bridge**: the platform converter + parses filter JSON numbers as float32, which rounds the 42-bit cell ids. Filter on the + small-int `i`/`j` properties (as an `"i_j"` string) instead — see + `_applyBasePatchFilter`. +- **Files**: `lib/widgets/map_widget.dart` (`_addCoverageOverlay`, `_applyCoveragePatch`), + `lib/providers/app_state_provider.dart` (`_freshenAffectedVectorTiles`), + `lib/services/api_service.dart` (`freshenVectorTile`), + `lib/utils/coverage_tile_palette.dart`, `lib/utils/mvt_cells.dart`. ### BLE Service UUIDs (MeshCore Companion Protocol) - Service: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index eb8c2e2..2090262 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -111,6 +111,12 @@ class UserPreferences { /// layer so users can see the base map underneath the coverage squares. final double coverageOverlayOpacity; + + /// Coverage grid preset, matching the web UI's Grid Mode: 300 = Simplified + /// (300 m cells, the default), 100 = Detailed (100 m cells + 3×3 blob, + /// applied server-side). Only these two values are valid. + final int coverageGridSize; + /// Disconnect alert: play audible alert when pinging stops unexpectedly (BLE disconnect, idle timeout, maintenance) final bool disconnectAlertEnabled; @@ -165,6 +171,7 @@ class UserPreferences { this.colorVisionType = 'none', this.mapTilesEnabled = true, this.coverageOverlayOpacity = 0.7, + this.coverageGridSize = 300, this.disconnectAlertEnabled = false, this.customApiEnabled = false, this.customApiUrl, @@ -214,6 +221,10 @@ class UserPreferences { mapTilesEnabled: (json['mapTilesEnabled'] as bool?) ?? true, coverageOverlayOpacity: (json['coverageOverlayOpacity'] as num?)?.toDouble() ?? 0.7, + coverageGridSize: switch ((json['coverageGridSize'] as num?)?.toInt()) { + 100 => 100, + _ => 300, + }, disconnectAlertEnabled: (json['disconnectAlertEnabled'] as bool?) ?? false, customApiEnabled: (json['customApiEnabled'] as bool?) ?? false, @@ -271,6 +282,7 @@ class UserPreferences { 'colorVisionType': colorVisionType, 'mapTilesEnabled': mapTilesEnabled, 'coverageOverlayOpacity': coverageOverlayOpacity, + 'coverageGridSize': coverageGridSize, 'disconnectAlertEnabled': disconnectAlertEnabled, 'customApiEnabled': customApiEnabled, 'customApiUrl': customApiUrl, @@ -317,6 +329,7 @@ class UserPreferences { String? colorVisionType, bool? mapTilesEnabled, double? coverageOverlayOpacity, + int? coverageGridSize, bool? disconnectAlertEnabled, bool? customApiEnabled, String? customApiUrl, @@ -365,6 +378,7 @@ class UserPreferences { mapTilesEnabled: mapTilesEnabled ?? this.mapTilesEnabled, coverageOverlayOpacity: coverageOverlayOpacity ?? this.coverageOverlayOpacity, + coverageGridSize: coverageGridSize ?? this.coverageGridSize, disconnectAlertEnabled: disconnectAlertEnabled ?? this.disconnectAlertEnabled, customApiEnabled: customApiEnabled ?? this.customApiEnabled, diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 6b3f0b7..7c7f56c 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart' show SystemNavigator; @@ -19,6 +20,7 @@ import '../models/remembered_device.dart'; import '../models/repeater.dart'; import '../models/user_preferences.dart'; import '../services/api_queue_service.dart'; +import '../utils/mvt_cells.dart'; import '../services/api_service.dart'; import '../services/audio_service.dart'; import '../services/background_service.dart'; @@ -285,9 +287,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _maintenanceUrl; Timer? _maintenanceCheckTimer; - // Tile refresh after upload - int _overlayCacheBust = 0; - Timer? _tileRefreshTimer; + // Post-wardrive tile refresh: coords of recently uploaded pings (with the + // zone they belong to) and the +7s fresh-fetch timer (one retry at +10s). + // See VECTOR_TILES.md "Flutter post-wardrive live refresh". + Timer? _vectorFreshTimer; + final List> _pendingFreshCoords = []; // [lat, lon] + String? _pendingFreshZone; + bool _vectorOverlayActive = false; + + // Session coverage patch: the user's own freshly-pinged cells, drawn by the + // MapWidget as a small GeoJSON layer ON TOP of the base overlay (whose + // copies of these ids are filtered out, so translucent fills never stack). + // The base source is never swapped/cache-busted during a session — nothing + // may visibly change except the cells that actually changed. Keyed by + // feature id, insertion-ordered, capped. + final Map _coveragePatchCells = {}; + int _coveragePatchVersion = 0; // Auth type from API response (API, Mesh, Manual) String? _authType; @@ -591,7 +606,197 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isCheckingZone => _isCheckingZone; String? get zoneName => _currentZone?['name'] as String?; String? get zoneCode => _currentZone?['code'] as String?; - int get overlayCacheBust => _overlayCacheBust; + + /// MapWidget handshake: whether the coverage overlay is currently on the + /// map. Gates the post-wardrive fresh-tile flow — no overlay, no fetches. + void reportVectorOverlayActive(bool active) { + _vectorOverlayActive = active; + } + + /// Bumped whenever the session coverage patch changes; the MapWidget + /// watches it and re-applies the patch GeoJSON + base-layer filter. + int get coveragePatchVersion => _coveragePatchVersion; + + /// The user's own freshly-pinged cells (feature id -> cell), authoritative + /// server state decoded from the fresh z14 tiles. + Map get coveragePatchCells => _coveragePatchCells; + + /// Drop the session patch — the cells belong to one region + grid preset + /// (called on zone change and when the Coverage Grid preference changes). + void clearCoveragePatch() { + _coveragePatchCells.clear(); + _coveragePatchVersion++; + } + + /// Post-wardrive live refresh: have the server re-render (`fresh=1`) the + /// tiles around the uploaded ping coords at z11-14, then patch the user's + /// own cells onto the map from the fresh z14 bodies (the base overlay is + /// never swapped — see _coveragePatchCells). z8-10 are skipped: a single + /// ping is sub-pixel there and those whole-region renders are the expensive + /// ones; they ride the server's longer TTL. Zooms above 14 overzoom from + /// the z14 tile. attempt 1 fires +7s after upload; attempt 2 (+10s) runs + /// only when attempt 1 saw no changed tiles (ingestion can lag the post). + Future _freshenAffectedVectorTiles({required int attempt}) async { + final zone = zoneCode; + if (zone == null || + zone != _pendingFreshZone || + _pendingFreshCoords.isEmpty) { + // Zone changed since the coords were queued: they belong to the OLD + // region's grid — freshening them against the new region's server + // would be pure wasted renders. + _pendingFreshCoords.clear(); + return; + } + // Snapshot: a new upload can append (and re-schedule the timer) while the + // fetches below are in flight; only this snapshot is processed and only + // it gets removed afterwards, so late arrivals keep their refresh. + final coords = List>.from(_pendingFreshCoords); + + // A ping's influence is wider than its own cell: blob dilation and cells + // straddling a tile border are emitted in the NEIGHBOURING tile too (the + // server pads its tile queries by ~0.005°). Freshen every tile within + // that margin of the ping, or the spilled part of a cell stays stale in + // the next tile over. + const pad = 0.005; + final tiles = >{}; // 'z/x/y' -> [z, x, y] + var capped = false; + for (final c in coords) { + for (var z = 11; z <= 14 && !capped; z++) { + final n = 1 << z; + int lonToX(double lon) { + final x = (((lon + 180.0) / 360.0) * n).floor(); + return x < 0 ? 0 : (x >= n ? n - 1 : x); + } + int latToY(double lat) { + final latRad = lat * math.pi / 180.0; + final sinhArg = math.tan(latRad); + final asinh = math.log(sinhArg + math.sqrt(sinhArg * sinhArg + 1)); + final y = ((1.0 - asinh / math.pi) / 2.0 * n).floor(); + return y < 0 ? 0 : (y >= n ? n - 1 : y); + } + + // Containing tile plus any neighbour the pad reaches into (≤4 per z). + for (final xt in {lonToX(c[1] - pad), lonToX(c[1] + pad)}) { + for (final yt in {latToY(c[0] + pad), latToY(c[0] - pad)}) { + if (tiles.length >= 56) { + capped = true; + break; + } + tiles['$z/$xt/$yt'] = [z, xt, yt]; + } + } + } + if (capped) break; + } + if (capped) { + debugLog('[COVERAGE] Fresh-tile fan-out capped at 56 tiles this batch'); + } + + debugLog( + '[COVERAGE] Fresh-tile check (attempt $attempt): ${tiles.length} tiles for ${coords.length} ping(s), z11-14 incl. spill neighbours'); + // Throttled to 4 concurrent renders: a full-burst Future.wait can pile up + // PHP workers and SQLite lock contention on the shared region host (each + // fresh=1 is a live render racing the wardrive INSERT). The whole batch + // still completes in a second or two. + final entries = tiles.entries.toList(); + final z14Bodies = []; + var anyChanged = false; + for (var i = 0; i < entries.length; i += 4) { + final chunk = entries.sublist(i, math.min(i + 4, entries.length)); + await Future.wait(chunk.map((e) async { + final result = await _apiService.freshenVectorTile( + zone: zone, + z: e.value[0], + x: e.value[1], + y: e.value[2], + gsize: _preferences.coverageGridSize); + if (result.changed == true) { + anyChanged = true; + debugLog('[COVERAGE] Retrieved new tile ${e.key}'); + } else if (result.changed == false) { + debugLog('[COVERAGE] No new tile ${e.key} (unchanged)'); + } else { + debugLog('[COVERAGE] Tile ${e.key} fresh check failed'); + } + if (e.value[0] == 14 && result.body != null) { + z14Bodies.add(result.body!); + } + })); + if (_isDisposed) return; + } + + // Patch ONLY the user's own cells onto the map. The base overlay is never + // swapped — the fresh renders above keep the SERVER cache hot (other + // viewers + MapLibre's own tile revalidation pick them up); the cells the + // user is watching update instantly through the patch layer. + final patched = _extractOwnCells(coords, z14Bodies); + if (patched.isNotEmpty) { + for (final cell in patched) { + _coveragePatchCells.remove(cell.id); // re-insert: newest-last ordering + _coveragePatchCells[cell.id] = cell; + } + while (_coveragePatchCells.length > 5000) { + _coveragePatchCells.remove(_coveragePatchCells.keys.first); + } + _coveragePatchVersion++; + debugLog( + '[COVERAGE] Patched ${patched.length} cell(s) at your position onto the overlay (attempt $attempt)'); + notifyListeners(); + } else { + debugLog( + '[COVERAGE] No cells for your position in the fresh tiles yet (attempt $attempt)'); + } + + if (attempt < 2 && !anyChanged) { + // Re-check at +10s only when the first sweep came back unchanged — + // ingestion can lag a few seconds behind the post. An ACTIVE timer here + // belongs to a newer upload (it re-armed the +7s timer while this run's + // fetches were in flight); let it own the next sweep — overwriting it + // would sweep the new batch seconds too early. + if (!(_vectorFreshTimer?.isActive ?? false)) { + debugLog('[COVERAGE] No changes yet — second fresh-tile check at +10s'); + _vectorFreshTimer = Timer(const Duration(seconds: 3), () { + _freshenAffectedVectorTiles(attempt: 2); + }); + } + } else { + _pendingFreshCoords.removeRange( + 0, math.min(coords.length, _pendingFreshCoords.length)); + } + } + + /// The cells this batch of pings actually touches — the ping's own cell + /// plus its blob reach (Detailed dilates 3×3) — looked up by grid index in + /// the freshly rendered z14 tiles so the patch carries the SERVER-resolved + /// status (priority merge with whatever was already in the cell). + List _extractOwnCells( + List> coords, List bodies) { + if (bodies.isEmpty) return const []; + final steps = kCoverageGridSteps[_preferences.coverageGridSize]; + if (steps == null) return const []; + final reach = _preferences.coverageGridSize == 100 ? 1 : 0; + final wanted = {}; + for (final c in coords) { + final ci = (c[0] / steps[0]).floor(); + final cj = (c[1] / steps[1]).floor(); + for (var di = -reach; di <= reach; di++) { + for (var dj = -reach; dj <= reach; dj++) { + wanted.add('${ci + di}_${cj + dj}'); + } + } + } + final out = []; + final seen = {}; + for (final body in bodies) { + for (final cell in decodeCoverageCells(body)) { + if (wanted.contains('${cell.i}_${cell.j}') && seen.add(cell.id)) { + out.add(cell); + } + } + } + return out; + } + int? get zoneSlotsAvailable => _currentZone?['slots_available'] as int?; int? get zoneSlotsMax => _currentZone?['slots_max'] as int?; String? get nearestZoneName => _nearestZone?['name'] as String?; @@ -845,7 +1050,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } }; - _apiQueueService.onUploadSuccess = (uploadedCount) { + _apiQueueService.onUploadSuccess = (uploadedCount, uploadedItems) { _pingStats = _pingStats.copyWith( successfulUploads: _pingStats.successfulUploads + uploadedCount, ); @@ -853,17 +1058,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { '[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); notifyListeners(); - // Schedule overlay tile refresh after server has time to regenerate tiles. - // The MapWidget watches _overlayCacheBust and calls _refreshCoverageOverlay() - // (remove + re-add raster source with new URL) when it changes. - // Use 30s debounce to avoid excessive tile refreshes during rapid auto-ping - // (especially on flaky networks where tiles may fail to load). - _tileRefreshTimer?.cancel(); - _tileRefreshTimer = Timer(const Duration(seconds: 30), () { - _overlayCacheBust = DateTime.now().millisecondsSinceEpoch; - debugLog('[MAP] Refreshing overlay tiles'); - notifyListeners(); - }); + if (_vectorOverlayActive) { + // Queue the batch's coords for the +7s fresh-tile check; the user's + // own cells land on the map via the session patch (see + // _freshenAffectedVectorTiles). + _pendingFreshZone = zoneCode; + for (final item in uploadedItems) { + if (_pendingFreshCoords.length >= 16) break; + _pendingFreshCoords.add([item.latitude, item.longitude]); + } + _vectorFreshTimer?.cancel(); + _vectorFreshTimer = Timer(const Duration(seconds: 7), () { + _freshenAffectedVectorTiles(attempt: 1); + }); + } }; // Initialize offline session service @@ -7281,7 +7489,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _offlineAutoSaveTimer?.cancel(); _zoneRefreshTimer?.cancel(); _cancelZoneGraceTimers(); - _tileRefreshTimer?.cancel(); + _vectorFreshTimer?.cancel(); _unifiedRxHandler?.dispose(); _meshCoreConnection?.dispose(); _pingService?.dispose(); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 8f1dab7..2ac78c9 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -249,6 +249,16 @@ class _SettingsScreenState extends State { trailing: Text('${(prefs.coverageOverlayOpacity * 100).round()}%'), ), + if (prefs.mapTilesEnabled) + ListTile( + leading: const Icon(Icons.grid_on), + title: const Text('Coverage Grid'), + subtitle: Text(prefs.coverageGridSize == 100 + ? 'Detailed (100 m cells)' + : 'Simplified (300 m cells)'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showCoverageGridSelector(context, appState), + ), ListTile( leading: const Icon(Icons.visibility), title: const Text('Color Vision'), @@ -1295,6 +1305,58 @@ class _SettingsScreenState extends State { }; } + /// Coverage grid preset selector — mirrors the web UI's Grid Mode + /// (Simplified = 300 m, Detailed = 100 m + blob, applied server-side). + void _showCoverageGridSelector( + BuildContext context, AppStateProvider appState) { + final options = [ + (300, 'Simplified', '300 m cells — the default, matches the web map'), + (100, 'Detailed', '100 m cells with blob smoothing — finer detail'), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Coverage Grid', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.coverageGridSize, + onChanged: (v) { + if (v != null) { + appState.updatePreferences( + appState.preferences.copyWith(coverageGridSize: v)); + } + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (value, label, subtitle) in options) + RadioListTile( + secondary: const Icon(Icons.grid_on), + title: Text(label), + subtitle: + Text(subtitle, style: const TextStyle(fontSize: 12)), + value: value, + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + void _showColorVisionSelector( BuildContext context, AppStateProvider appState) { final options = [ diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index e9ff635..50ea4d1 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -43,8 +43,11 @@ class ApiQueueService { /// Callback for queue updates void Function(int queueSize)? onQueueUpdated; - /// Callback for successful uploads (passes count of items uploaded) - void Function(int uploadedCount)? onUploadSuccess; + /// Callback for successful uploads. Passes the count AND the uploaded items + /// so the listener can compute which coverage tiles the batch touched (the + /// post-wardrive vector tile refresh needs the ping coordinates). + void Function(int uploadedCount, List uploadedItems)? + onUploadSuccess; /// Callback when persistence fails (for user-visible error logging) void Function(String errorMessage)? onPersistenceError; @@ -607,7 +610,7 @@ class ApiQueueService { _memoryQueue.remove(item); } debugLog('[API QUEUE] Upload SUCCESS: deleted $uploadedCount items'); - onUploadSuccess?.call(uploadedCount); + onUploadSuccess?.call(uploadedCount, items); // Fire-and-forget: forward to custom API endpoint customApiService?.forwardPings(pings); } else if (result == UploadResult.nonRetryable) { diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 47c8c34..12893da 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; +import 'dart:typed_data'; import 'package:http/http.dart' as http; @@ -891,6 +892,51 @@ class ApiService { /// Callback for maintenance mode detection (while connected) void Function(String message, String? url)? onMaintenanceMode; + /// Force-rebuild one vector coverage tile on the region server + /// (`vector_tile.php?...&fresh=1`, see VECTOR_TILES.md). Used by the + /// post-wardrive live refresh: it keeps the server cache hot AND hands the + /// fresh tile bytes back so the caller can patch the user's own cells onto + /// the map without touching the rest of the overlay. + /// + /// `changed`: true/false from the X-Tile-Changed header; null on network + /// failure, non-2xx, or a server that doesn't implement fresh=1 yet. + /// `body`: the uncompressed MVT bytes on a 200, null otherwise (204 = tile + /// is empty; package:http has already gunzipped the response). + Future<({bool? changed, Uint8List? body})> freshenVectorTile({ + required String zone, + required int z, + required int x, + required int y, + int gsize = 300, + }) async { + final url = Uri.parse( + 'https://${zone.toLowerCase()}.meshmapper.net/vector_tile.php' + '?z=$z&x=$x&y=$y&gsize=$gsize&fresh=1'); + final sw = Stopwatch()..start(); + debugLog( + '[API] GET /vector_tile.php?z=$z&x=$x&y=$y&gsize=$gsize&fresh=1 (zone ${zone.toLowerCase()})'); + try { + final response = + await _client.get(url).timeout(const Duration(seconds: 8)); + final changed = response.headers['x-tile-changed']; + debugLog( + '[API] Tile $z/$x/$y response (${response.statusCode}) in ${(sw.elapsedMilliseconds / 1000).toStringAsFixed(2)}s: ' + '${response.bodyBytes.length}B, X-Tile-Changed=${changed ?? 'absent'}'); + if (response.statusCode != 200 && response.statusCode != 204) { + return (changed: null, body: null); + } + final body = response.statusCode == 200 ? response.bodyBytes : null; + return ( + changed: changed == null ? null : changed == '1', + body: body, + ); + } catch (e) { + debugWarn( + '[API] Tile $z/$x/$y fresh fetch failed in ${(sw.elapsedMilliseconds / 1000).toStringAsFixed(2)}s: $e'); + return (changed: null, body: null); + } + } + /// Upload batch of wardrive data /// Returns UploadResult indicating success, retryable failure, or non-retryable failure /// Triggers onSessionError callback for session-related errors diff --git a/lib/utils/coverage_tile_palette.dart b/lib/utils/coverage_tile_palette.dart new file mode 100644 index 0000000..7d90909 --- /dev/null +++ b/lib/utils/coverage_tile_palette.dart @@ -0,0 +1,89 @@ +/// Colour tables + MapLibre style expressions for the VECTOR coverage tiles +/// (`vector_tile.php`). The server emits only an integer status category per +/// cell (`st`); colour is applied client-side via a `match` expression, so the +/// rendered fills are exactly the colours the raster tiles used to bake. +/// +/// KEEP IN SYNC with `getCvdPaletteHex()` in MeshMapper_Server +/// `dev/cvd_palettes.php` — these are the server raster fill/border values, +/// NOT the lighter legend swatches in [PingColors] (those are tuned for how +/// the raster looks after layer opacity and stay legend-only). +/// +/// `st` enum (ascending priority, lower wins; see VECTOR_TILES.md): +/// 1=green (BIDIR), 2=cyan (DISC/TRACE), 3=orange (TX), 4=purple (RX), +/// 5=grey (dead), 6=red (fail). +class CoverageTilePalette { + CoverageTilePalette._(); + + /// cvd mode -> [st 1..6] -> [fill, border] hex. + static const Map>> _palettes = { + 'none': [ + ['#1e7e34', '#14522d'], // 1 green + ['#17a2b8', '#117a8b'], // 2 cyan + ['#fd7e14', '#d96b0c'], // 3 orange + ['#6f42c1', '#59359a'], // 4 purple + ['#6c757d', '#545b62'], // 5 grey + ['#bd2130', '#8b101b'], // 6 red + ], + 'protanopia': [ + ['#0072B2', '#00507D'], + ['#56B4E9', '#3C7EA3'], + ['#E69F00', '#A16F00'], + ['#CC79A7', '#8F5575'], + ['#9E9E9E', '#6F6F6F'], + ['#D55E00', '#954200'], + ], + // Server maps deuteranopia to the protanopia palette. + 'deuteranopia': [ + ['#0072B2', '#00507D'], + ['#56B4E9', '#3C7EA3'], + ['#E69F00', '#A16F00'], + ['#CC79A7', '#8F5575'], + ['#9E9E9E', '#6F6F6F'], + ['#D55E00', '#954200'], + ], + 'tritanopia': [ + ['#009E73', '#006F51'], + ['#E69F00', '#A16F00'], + ['#CC79A7', '#8F5575'], + ['#CC79A7', '#8F5575'], + ['#9E9E9E', '#6F6F6F'], + ['#D55E00', '#954200'], + ], + 'achromatopsia': [ + ['#E0E0E0', '#9D9D9D'], + ['#BDBDBD', '#848484'], + ['#9E9E9E', '#6F6F6F'], + ['#757575', '#525252'], + ['#616161', '#444444'], + ['#424242', '#2E2E2E'], + ], + }; + + static List> _paletteFor(String cvdMode) => + _palettes[cvdMode] ?? _palettes['none']!; + + /// Builds `['match', ['get','st'], 1, c1, ..., 5, c5, c6]` — st 6 doubles + /// as the match default so unknown future codes render as red, the same + /// fallthrough the server-side mapping uses. + static List _matchExpression(String cvdMode, int hexIndex) { + final pal = _paletteFor(cvdMode); + final expr = [ + 'match', + ['get', 'st'], + ]; + for (var st = 1; st <= 5; st++) { + expr.add(st); + expr.add(pal[st - 1][hexIndex]); + } + expr.add(pal[5][hexIndex]); // default (st 6 / unknown) + return expr; + } + + /// MapLibre `fill-color` expression for the user's colour-vision mode. + static List fillColorExpression(String cvdMode) => + _matchExpression(cvdMode, 0); + + /// MapLibre `fill-outline-color` expression (the raster's 1px cell border). + static List borderColorExpression(String cvdMode) => + _matchExpression(cvdMode, 1); +} diff --git a/lib/utils/mvt_cells.dart b/lib/utils/mvt_cells.dart new file mode 100644 index 0000000..1ded785 --- /dev/null +++ b/lib/utils/mvt_cells.dart @@ -0,0 +1,159 @@ +import 'dart:typed_data'; + +/// Minimal decoder for MeshMapper's coverage vector tiles (vector_tile.php) — +/// just enough of the MVT/protobuf wire format to extract each cell's +/// `{id, i, j, st}`. Geometry is skipped entirely: a cell's rectangle is +/// reconstructed from its grid indices (i, j) and the grid step table, which +/// is byte-identical to what the server encoded (both compute corners from +/// indices). Contract reference: MeshMapper_Server/docs/VECTOR_TILES.md. +/// +/// WEB-SAFE BY DESIGN: feature ids run up to ~2^42 and dart2js truncates +/// bitwise ops to 32 bits, so varint/zigzag decoding here uses arithmetic +/// (`*`, `~/`, `%`) only — same rule as WireTagCodec. + +/// gsize (metres) -> [latStep, lonStep] degrees. KEEP IN SYNC with +/// `coverageGridPresets()` in MeshMapper_Server dev/coverage_cells.php +/// (the app only ever uses the 300/100 presets). +const Map> kCoverageGridSteps = { + 300: [0.0027, 0.00384], + 100: [0.0009, 0.00128], +}; + +/// One coverage cell from a decoded tile. `st` is the server's status +/// category (1=green 2=cyan 3=orange 4=purple 5=grey 6=red). +class CoverageCell { + final int id; + final int i; + final int j; + final int st; + const CoverageCell(this.id, this.i, this.j, this.st); +} + +class _Reader { + final Uint8List buf; + int pos = 0; + _Reader(this.buf); + + bool get done => pos >= buf.length; + + /// Varint as a Dart int via arithmetic (exact up to 2^53 — ids are < 2^53). + int varint() { + var value = 0; + var multiplier = 1; + while (true) { + final b = buf[pos++]; + value += (b % 128) * multiplier; + if (b < 128) return value; + multiplier *= 128; + } + } + + Uint8List bytes(int len) { + final out = Uint8List.sublistView(buf, pos, pos + len); + pos += len; + return out; + } +} + +int _unzigzag(int n) => n.isOdd ? -((n + 1) ~/ 2) : n ~/ 2; + +/// Decodes the cells of a single-layer coverage MVT (uncompressed bytes — +/// package:http has already gunzipped the response). Returns const [] for +/// anything that doesn't parse as expected: a tile we can't read must never +/// break the wardriving flow, the patch just skips it. +List decodeCoverageCells(Uint8List mvt) { + try { + final tile = _Reader(mvt); + while (!tile.done) { + final tag = tile.varint(); + final field = tag ~/ 8; + final wire = tag % 8; + if (field == 3 && wire == 2) { + return _decodeLayer(_Reader(tile.bytes(tile.varint()))); + } + _skip(tile, wire); + } + } catch (_) { + // Malformed/foreign tile — treated as empty below. + } + return const []; +} + +List _decodeLayer(_Reader layer) { + final keys = []; + final values = []; + final features = <_Reader>[]; + while (!layer.done) { + final tag = layer.varint(); + final field = tag ~/ 8; + final wire = tag % 8; + if (field == 2 && wire == 2) { + features.add(_Reader(layer.bytes(layer.varint()))); + } else if (field == 3 && wire == 2) { + keys.add(String.fromCharCodes(layer.bytes(layer.varint()))); + } else if (field == 4 && wire == 2) { + values.add(_decodeValue(_Reader(layer.bytes(layer.varint())))); + } else { + _skip(layer, wire); + } + } + + final cells = []; + for (final f in features) { + var id = 0; + final tags = []; + while (!f.done) { + final tag = f.varint(); + final field = tag ~/ 8; + final wire = tag % 8; + if (field == 1 && wire == 0) { + id = f.varint(); + } else if (field == 2 && wire == 2) { + final packed = _Reader(f.bytes(f.varint())); + while (!packed.done) { + tags.add(packed.varint()); + } + } else { + _skip(f, wire); + } + } + int? i, j, st; + for (var t = 0; t + 1 < tags.length; t += 2) { + final key = tags[t] < keys.length ? keys[tags[t]] : ''; + final value = tags[t + 1] < values.length ? values[tags[t + 1]] : 0; + if (key == 'i') i = value; + if (key == 'j') j = value; + if (key == 'st') st = value; + } + if (i != null && j != null && st != null) { + cells.add(CoverageCell(id, i, j, st)); + } + } + return cells; +} + +/// Value message: the server encodes all properties as sint64 (field 6). +int _decodeValue(_Reader value) { + while (!value.done) { + final tag = value.varint(); + final field = tag ~/ 8; + final wire = tag % 8; + if (field == 6 && wire == 0) return _unzigzag(value.varint()); + _skip(value, wire); + } + return 0; +} + +void _skip(_Reader r, int wire) { + if (wire == 0) { + r.varint(); + } else if (wire == 2) { + r.bytes(r.varint()); + } else if (wire == 5) { + r.pos += 4; + } else if (wire == 1) { + r.pos += 8; + } else { + throw const FormatException('unsupported wire type'); + } +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 5d04a09..25f3875 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -17,7 +17,9 @@ import '../models/ping_data.dart'; import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../services/gps_service.dart'; +import '../utils/coverage_tile_palette.dart'; import '../utils/debug_logger_io.dart'; +import '../utils/mvt_cells.dart'; import '../utils/distance_formatter.dart'; import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; @@ -390,19 +392,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { bool _focusPanelMinimized = false; dynamic _focusedPingSource; // TxPing | RxPing | DiscLogEntry | TraceLogEntry - // MapLibre style and overlay tracking - int _lastCacheBust = 0; + // MapLibre style and overlay tracking. // Tracks the zone code we last rendered the coverage overlay for. When the // zone check succeeds after the style has already loaded (e.g. first check // failed with gps_inaccurate and a later retry succeeded), _addCoverageOverlay - // would otherwise never re-run and the raster layer would stay missing. + // would otherwise never re-run and the coverage layer would stay missing. String? _lastOverlayZoneCode; // Last coverage overlay opacity we pushed into MapLibre. Compared against // the current preference in _buildMap to detect slider changes and apply // them live via _applyCoverageOverlayOpacity (no layer rebuild needed). double? _lastAppliedCoverageOpacity; - // Guard flag that coalesces multiple overlay-refresh triggers (cache bust - // and zone change) in the same frame into a single post-frame callback. + // Guard flag that coalesces multiple overlay-refresh triggers (zone and + // pref changes) in the same frame into a single post-frame callback. // Without this, two watchers can schedule concurrent _refreshCoverageOverlay // runs whose remove/add calls interleave and produce "Source already exists" // errors in the native log. @@ -413,6 +414,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { String? _activeCoverageSourceId; String? _activeCoverageLayerId; int _coverageBufferCounter = 0; + // Grid size is baked into the tile URL and the CVD palette into the layer + // paint; a change to either rebuilds the overlay via the build watcher. + int? _lastAppliedGridSize; + String? _lastAppliedCvd; + // Session coverage patch: a GeoJSON layer carrying the user's own + // freshly-pinged cells ON TOP of the base overlay; the base layer's copies + // of those cells are hidden via setFilter so translucent fills never stack. + // The base source is never swapped during a session — only the patch + // updates, so nothing visibly changes except the changed cells. + static const String _patchSourceId = 'meshmapper-coverage-patch'; + static const String _patchLayerId = 'meshmapper-coverage-patch-layer'; + bool _patchLayerReady = false; + int _lastAppliedPatchVersion = -1; bool _styleLoaded = false; bool _hasStyleLoadedOnce = false; // True after first onStyleLoadedCallback (prevents re-centering on style switch) @@ -561,6 +575,23 @@ class _MapWidgetState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); _appLifecycleState = WidgetsBinding.instance.lifecycleState ?? AppLifecycleState.resumed; + // Coverage-patch updates are driven by a DIRECT provider listener, not the + // build() watchers: when the device sits still nothing else rebuilds this + // widget, and a patch that waits for the next rebuild only appears after + // the user happens to touch the map. + _patchProviderRef = context.read(); + _patchProviderRef!.addListener(_onCoveragePatchNotify); + } + + AppStateProvider? _patchProviderRef; + + void _onCoveragePatchNotify() { + final appState = _patchProviderRef; + if (appState == null || !mounted) return; + if (appState.coveragePatchVersion == _lastAppliedPatchVersion) return; + if (!_isMapReady || !_styleLoaded) return; + _lastAppliedPatchVersion = appState.coveragePatchVersion; + _applyCoveragePatch(appState); } @override @@ -599,6 +630,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { @override void dispose() { WidgetsBinding.instance.removeObserver(this); + _patchProviderRef?.removeListener(_onCoveragePatchNotify); _tileLoadTimeoutTimer?.cancel(); final controller = _mapController; if (controller != null) { @@ -1489,6 +1521,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { await _setOfflineIfSupported(!tilesEnabled); if (wasEnabled == true && !tilesEnabled) { await _removeCoverageOverlay(); + // No overlay on the map -> the post-wardrive fresh-tile flow must + // not keep rendering server tiles for it. + appState.reportVectorOverlayActive(false); } else if (wasEnabled == false && tilesEnabled && _styleLoaded) { await _addCoverageOverlay(appState); } @@ -1500,26 +1535,43 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // onStyleLoadedCallback → _onStyleLoaded re-registers images, rebuilds // cluster layers, re-adds the coverage overlay, and re-syncs annotations. - // Detect cache bust or zoneCode change → schedule a SINGLE coalesced - // refresh. Previously each watcher scheduled its own post-frame callback, - // which could race when both changed in the same frame (e.g. a zone - // transition that also rotates cache bust). The _coverageRefreshScheduled - // flag ensures at most one refresh is queued per frame. + // Detect zoneCode or overlay-pref changes → schedule a SINGLE coalesced + // refresh; the _coverageRefreshScheduled flag ensures at most one refresh + // is queued per frame even when both change together. // // The zoneCode watcher is needed because _addCoverageOverlay only runs // during _onStyleLoaded — if the first zone check failed with // gps_inaccurate, the style loads with zoneCode=null and the overlay is // skipped. When a later retry sets the zone, nothing else would trigger - // the raster layer. - final cacheBustChanged = appState.overlayCacheBust != _lastCacheBust && - _isMapReady && - _styleLoaded; + // the coverage layer. final zoneChanged = appState.zoneCode != _lastOverlayZoneCode && _isMapReady && _styleLoaded; - if (cacheBustChanged || zoneChanged) { - if (cacheBustChanged) _lastCacheBust = appState.overlayCacheBust; - if (zoneChanged) _lastOverlayZoneCode = appState.zoneCode; + // Grid size is baked into the tile URL and the CVD palette into the layer + // paint, so either change rebuilds the overlay. + final prefsForOverlay = appState.preferences; + final overlayPrefChanged = _isMapReady && + _styleLoaded && + ((_lastAppliedGridSize != null && + _lastAppliedGridSize != prefsForOverlay.coverageGridSize) || + (_lastAppliedCvd != null && + _lastAppliedCvd != prefsForOverlay.colorVisionType)); + + if (zoneChanged || overlayPrefChanged) { + if (zoneChanged) { + _lastOverlayZoneCode = appState.zoneCode; + // The session patch belongs to the old region's grid. + appState.clearCoveragePatch(); + } + if (overlayPrefChanged) { + // Patched cell ids/geometry are per grid preset; a palette-only + // change keeps them (the rebuild restyles the patch layer too). + if (_lastAppliedGridSize != prefsForOverlay.coverageGridSize) { + appState.clearCoveragePatch(); + } + _lastAppliedGridSize = prefsForOverlay.coverageGridSize; + _lastAppliedCvd = prefsForOverlay.colorVisionType; + } if (!_coverageRefreshScheduled) { _coverageRefreshScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -1546,6 +1598,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + // (Session-patch application: direct provider listener + // _onCoveragePatchNotify — see initState for why it's not a build watcher.) + // Detect coverage overlay opacity change (user dragged the slider in // Settings → General) and push it to the live raster layer without // rebuilding the whole overlay. Skipped while ping focus mode is active — @@ -2213,48 +2268,77 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return; } - final cvdParam = appState.preferences.colorVisionType != 'none' - ? '&cvd=${appState.preferences.colorVisionType}' - : ''; - final url = - 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}$cvdParam'; + final prefs = appState.preferences; + final zone = appState.zoneCode!.toLowerCase(); + final gridSize = prefs.coverageGridSize; final sourceId = _nextCoverageSourceId(); final layerId = _coverageLayerIdFor(sourceId); try { - await _mapController!.addSource( - sourceId, - RasterSourceProperties(tiles: [url], tileSize: 256, maxzoom: 17), - ); // Target the bottom of the repeater cluster stack when it exists, so the - // raster lands beneath ALL marker layers (repeater clusters + symbol + // overlay lands beneath ALL marker layers (repeater clusters + symbol // annotations). Fallback to the symbol annotation layer if cluster layers // haven't been created yet. final belowLayer = _clusterLayersReady ? _repeaterIndividualLayerId : _symbolAnnotationLayerId(); - // While ping focus mode is active, force the newly added raster layer - // to opacity 0 so a cache-bust tile refresh (fires 5s after every API - // upload success — see AppStateProvider._tileRefreshTimer) doesn't + // While ping focus mode is active, force the newly added layer to + // opacity 0 so an overlay rebuild (zone/grid/palette change) doesn't // make the overlay pop back into view in the middle of focus mode. // Dismissing focus restores the preference value via // _applyCoverageOverlayOpacity in _dismissPingFocus. - final opacity = _focusedPingLocation != null - ? 0.0 - : appState.preferences.coverageOverlayOpacity; - await _mapController!.addRasterLayer( + final opacity = + _focusedPingLocation != null ? 0.0 : prefs.coverageOverlayOpacity; + + // The server emits an integer status category per cell (st); colour + // happens HERE via match expressions, so colour-vision palettes apply + // without any server param and a tile carries data, not pixels. + final url = + 'https://$zone.meshmapper.net/vector_tile.php?z={z}&x={x}&y={y}&gsize=$gridSize'; + // minzoom 7 = the raster's old on-screen range (512px-convention vector + // tiles sit one display-zoom lower than 256px raster tiles). + await _mapController!.addSource( + sourceId, + VectorSourceProperties(tiles: [url], minzoom: 7, maxzoom: 14), + ); + await _mapController!.addFillLayer( sourceId, layerId, - RasterLayerProperties(rasterOpacity: opacity), + FillLayerProperties( + fillColor: + CoverageTilePalette.fillColorExpression(prefs.colorVisionType), + fillOutlineColor: + CoverageTilePalette.borderColorExpression(prefs.colorVisionType), + fillOpacity: opacity, + ), + sourceLayer: 'coverage', belowLayerId: belowLayer, ); _activeCoverageSourceId = sourceId; _activeCoverageLayerId = layerId; + + // The session patch rides directly above the base layer (same anchor, + // added later = higher) with identical styling. Creation is + // self-healing: if it fails here, the next patch update retries it. + // The base-layer filter is only applied once the patch layer exists — + // hiding cells the patch can't draw would punch holes in the overlay. + _patchLayerReady = false; + if (await _ensureCoveragePatchLayer(appState)) { + _lastAppliedPatchVersion = appState.coveragePatchVersion; + await _applyBasePatchFilter(appState, layerId); + } + _lastAppliedCoverageOpacity = opacity; + _lastAppliedGridSize = gridSize; + _lastAppliedCvd = prefs.colorVisionType; + appState.reportVectorOverlayActive(true); debugLog( - '[MAP] Coverage overlay added as $layerId (below ${belowLayer ?? "top"}, opacity ${opacity.toStringAsFixed(2)})'); + '[MAP] Coverage overlay added as $layerId (grid $gridSize, below ${belowLayer ?? "top"}, opacity ${opacity.toStringAsFixed(2)})'); } catch (e) { + // No overlay on the map — the post-wardrive fresh-tile flow must not + // keep fetching for it. + appState.reportVectorOverlayActive(false); debugLog('[MAP] Failed to add coverage overlay: $e'); } } @@ -2264,17 +2348,28 @@ class _MapWidgetState extends State with WidgetsBindingObserver { String _coverageLayerIdFor(String sourceId) => '$sourceId-layer'; - /// Apply a new coverage overlay opacity to the live raster layer without - /// removing/re-adding it. No-op if the layer doesn't exist yet. + /// Apply a new coverage overlay opacity to the live fill layers without + /// removing/re-adding them. No-op if the layer doesn't exist yet. + /// + /// setLayerProperties serializes with skipNulls:false — every property left + /// null is RESET to its style-spec default on iOS/web (fill-color → black), + /// so the full colour expressions must be resent alongside the new opacity, + /// never a partial FillLayerProperties. Future _applyCoverageOverlayOpacity(double opacity) async { - if (_mapController == null) return; + if (_mapController == null || !mounted) return; final layerId = _activeCoverageLayerId; if (layerId == null) return; try { - await _mapController!.setLayerProperties( - layerId, - RasterLayerProperties(rasterOpacity: opacity), + final cvd = context.read().preferences.colorVisionType; + final props = FillLayerProperties( + fillColor: CoverageTilePalette.fillColorExpression(cvd), + fillOutlineColor: CoverageTilePalette.borderColorExpression(cvd), + fillOpacity: opacity, ); + if (_patchLayerReady) { + await _mapController!.setLayerProperties(_patchLayerId, props); + } + await _mapController!.setLayerProperties(layerId, props); _lastAppliedCoverageOpacity = opacity; debugLog( '[MAP] Coverage overlay opacity updated to ${opacity.toStringAsFixed(2)}'); @@ -2324,6 +2419,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (layerId != null && sourceId != null) { await _removeCoverageLayerById(layerId, sourceId); } + await _removeCoveragePatchLayer(); } /// Remove a specific coverage source+layer pair without touching the @@ -2339,10 +2435,148 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } catch (_) {} } - /// Refresh coverage overlay by removing the current layer and adding a fresh - /// one with updated cache-bust URL. Accepts a brief visual gap between - /// remove and add — imperceptible during driving and quick when stationary - /// (tiles load from cache for same viewport). + /// Remove the session-patch source+layer (vector overlay companion). + Future _removeCoveragePatchLayer() async { + _patchLayerReady = false; + if (_mapController == null) return; + try { + await _mapController!.removeLayer(_patchLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_patchSourceId); + } catch (_) {} + } + + /// GeoJSON FeatureCollection of the session patch: one rectangle per cell, + /// corners computed from grid indices exactly like the server's tiles. + Map _buildPatchGeoJson(AppStateProvider appState) { + final steps = + kCoverageGridSteps[appState.preferences.coverageGridSize] ?? + kCoverageGridSteps[300]!; + final features = >[]; + for (final cell in appState.coveragePatchCells.values) { + final lat0 = cell.i * steps[0]; + final lon0 = cell.j * steps[1]; + // No feature id: nothing reads it on the patch layer, and 42-bit ids + // don't survive Android's float32 JSON bridge anyway (see + // _applyBasePatchFilter). + features.add({ + 'type': 'Feature', + 'properties': {'st': cell.st}, + 'geometry': { + 'type': 'Polygon', + 'coordinates': [ + [ + [lon0, lat0], + [lon0 + steps[1], lat0], + [lon0 + steps[1], lat0 + steps[0]], + [lon0, lat0 + steps[0]], + [lon0, lat0], + ] + ], + }, + }); + } + return {'type': 'FeatureCollection', 'features': features}; + } + + /// Hide the BASE layer's copies of patched cells — the patch layer owns + /// them now; without the filter the translucent fills would stack and the + /// patched cells would render darker than their neighbours. + /// + /// Matched on an "i_j" string built from the small-int grid properties, NOT + /// on the feature id: Android's filter converter parses every JSON number + /// as float32, which silently rounds the 42-bit ids so an id-based `in` + /// never matches. Strings cross the bridge untouched on every platform. + Future _applyBasePatchFilter( + AppStateProvider appState, String baseLayerId) async { + if (_mapController == null || appState.coveragePatchCells.isEmpty) return; + final keys = [ + for (final cell in appState.coveragePatchCells.values) + '${cell.i}_${cell.j}' + ]; + try { + await _mapController!.setFilter(baseLayerId, [ + '!', + [ + 'in', + [ + 'concat', + ['to-string', ['get', 'i']], + '_', + ['to-string', ['get', 'j']], + ], + ['literal', keys], + ], + ]); + } catch (e) { + debugLog('[MAP] Coverage base filter failed: $e'); + } + } + + /// Idempotently (re)create the patch source+layer. Self-healing: if the + /// creation during overlay add failed, or a style change replaced the + /// native layers, the next patch application rebuilds it here. + Future _ensureCoveragePatchLayer(AppStateProvider appState) async { + if (_patchLayerReady) return true; + if (_mapController == null || _activeCoverageLayerId == null) return false; + try { + final prefs = appState.preferences; + final belowLayer = _clusterLayersReady + ? _repeaterIndividualLayerId + : _symbolAnnotationLayerId(); + final opacity = + _focusedPingLocation != null ? 0.0 : prefs.coverageOverlayOpacity; + await _removeCoveragePatchLayer(); + await _mapController! + .addGeoJsonSource(_patchSourceId, _buildPatchGeoJson(appState)); + await _mapController!.addFillLayer( + _patchSourceId, + _patchLayerId, + FillLayerProperties( + fillColor: + CoverageTilePalette.fillColorExpression(prefs.colorVisionType), + fillOutlineColor: + CoverageTilePalette.borderColorExpression(prefs.colorVisionType), + fillOpacity: opacity, + ), + belowLayerId: belowLayer, + ); + _patchLayerReady = true; + debugLog('[MAP] Coverage patch layer created'); + return true; + } catch (e) { + debugLog('[MAP] Coverage patch layer create failed: $e'); + return false; + } + } + + /// Push the latest session patch into the map: update the GeoJSON source + /// (flash-free, in-place) and extend the base-layer filter. Nothing else on + /// the map changes — this is the live-update path for the user's own pings. + Future _applyCoveragePatch(AppStateProvider appState) async { + if (_mapController == null) return; + final baseLayerId = _activeCoverageLayerId; + if (baseLayerId == null) { + debugLog('[MAP] Coverage patch skipped: no active coverage layer'); + return; + } + if (!await _ensureCoveragePatchLayer(appState)) return; + try { + await _mapController! + .setGeoJsonSource(_patchSourceId, _buildPatchGeoJson(appState)); + await _applyBasePatchFilter(appState, baseLayerId); + debugLog( + '[MAP] Coverage patch applied: ${appState.coveragePatchCells.length} cell(s)'); + } catch (e) { + debugLog('[MAP] Coverage patch apply failed: $e'); + } + } + + /// Rebuild the coverage overlay (remove + re-add). Only runs on real + /// transitions — zone change, grid/palette pref change, style reload — + /// never as a live-refresh path (that's the session patch's job), so the + /// brief remove/add gap is acceptable. Future _refreshCoverageOverlay(AppStateProvider appState) async { await _removeCoverageOverlay(); await _addCoverageOverlay(appState); @@ -4424,6 +4658,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _addCoverageOverlay(context.read()); } else { _removeCoverageOverlay(); + context.read().reportVectorOverlayActive(false); } } diff --git a/test/utils/mvt_cells_test.dart b/test/utils/mvt_cells_test.dart new file mode 100644 index 0000000..cf293d1 --- /dev/null +++ b/test/utils/mvt_cells_test.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/mvt_cells.dart'; + +/// Fixtures generated by the SERVER's dev/mvt_encoder.php — the decoder must +/// stay in lockstep with it (see MeshMapper_Server/docs/VECTOR_TILES.md). +/// +/// `_knownTile`: layer "coverage", extent 4096, two features: +/// id=42 {i:-5, j:7, st:1} and id=43 {i:3, j:7, st:6}. +/// `_realTile`: an actual rendered z12 coverage tile (29 cells). +const _knownTile = + 'GmUKCGNvdmVyYWdlEhkIKhIGAAABAQICGAMiCwkAABoUAAAUEwAPEhsIKxIGAAMBAQIEGAMiDQnIAZADGhQAABQTAA8aAWkaAWoaAnN0IgIwCSICMA4iAjACIgIwBiICMAwogCB4Ag=='; +const _realTile = + 'GuoJCghjb3ZlcmFnZRIjCM2lwuWVPxIGAAABAQICGAMiEAmMPJAgGuYCAAC6AuUCAA8SIwjOpcLllT8SBgAAAQMCAhgDIhAJ8j6QIBrmAgAAugLlAgAPEiMIzKXC5JU/EgYABAEFAgIYAyIQCaY5yiIa5gIAALoC5QIADxIjCM6lwuSVPxIGAAQBAwICGAMiEAnyPsoiGuYCAAC6AuUCAA8SIwjNpcLjlT8SBgAGAQECBxgDIhAJjDyEJRrmAgAAugLlAgAPEiMIzKXC4pU/EgYACAEFAgIYAyIQCaY5vica5gIAALoC5QIADxIjCM2lwuKVPxIGAAgBAQICGAMiEAmMPL4nGuYCAAC6AuUCAA8SIwjDpcLhlT8SBgAJAQoCCxgDIhAJkCD4KRrmAgAAugLlAgAPEiMIy6XC4ZU/EgYACQEMAgsYAyIQCcA2+Cka5gIAALoC5QIADxIjCMOlwuCVPxIGAA0BCgIHGAMiEAmQILIsGuYCAAC6AuUCAA8SIwjEpcLglT8SBgANAQ4CCxgDIhAJ9iKyLBrmAgAAugLlAgAPEiMIy6XC4JU/EgYADQEMAg8YAyIQCcA2siwa5gIAALoC5QIADxIjCM2lwuCVPxIGAA0BAQILGAMiEAmMPLIsGuYCAAC6AuUCAA8SIwjNpcLflT8SBgAQAQECCxgDIhAJjDzsLhrmAgAAugLlAgAPEiMIyqXC3pU/EgYAEQESAg8YAyIQCdozpjEa5gIAALoC5QIADxIjCMylwt6VPxIGABEBBQILGAMiEAmmOaYxGuYCAAC6AuUCAA8SIwjNpcLelT8SBgARAQECBxgDIhAJjDymMRrmAgAAugLlAgAPEiMIyqXC3ZU/EgYAEwESAgIYAyIQCdoz4DMa5gIAALoC5QIADxIjCMylwt2VPxIGABMBBQILGAMiEAmmOeAzGuYCAAC6AuUCAA8SIwjJpcLclT8SBgAUARUCCxgDIhAJ9DCaNhrmAgAAugLlAgAPEiMIyqXC3JU/EgYAFAESAgsYAyIQCdozmjYa5gIAALoC5QIADxIjCMulwtyVPxIGABQBDAILGAMiEAnANpo2GuYCAAC6AuUCAA8SIwjGpcLblT8SBgAWARcCCxgDIhAJwijUOBrmAgAAugLlAgAPEiMIx6XC25U/EgYAFgEYAgcYAyIQCagr1Dga5gIAALoC5QIADxIjCMilwtuVPxIGABYBGQILGAMiEAmOLtQ4GuYCAAC6AuUCAA8SIwjKpcLblT8SBgAWARICDxgDIhAJ2jPUOBrmAgAAugLlAgAPEiMIy6XC25U/EgYAFgEMAgsYAyIQCcA21Dga5gIAALoC5QIADxIjCMqlwtmVPxIGABoBEgICGAMiEAnaM8g9GuYCAAC6AuUCAA8SIwjJpcLYlT8SBgAbARUCAhgDIhAJ9DCCQBrmAgAAugLlAgAPGgFpGgFqGgJzdCIEMLXUASIEMJrLBCICMAwiBDCcywQiBDC31AEiBDCYywQiBDC51AEiAjAIIgQwu9QBIgQwvdQBIgQwhssEIgIwBCIEMJbLBCIEML/UASIEMIjLBCICMAIiBDDB1AEiBDDD1AEiBDCUywQiBDDF1AEiBDDH1AEiBDCSywQiBDDJ1AEiBDCMywQiBDCOywQiBDCQywQiBDDN1AEiBDDP1AEogCB4Ag=='; + +void main() { + test('decodes the known two-feature tile exactly', () { + final cells = decodeCoverageCells(base64Decode(_knownTile)); + expect(cells.length, 2); + expect(cells[0].id, 42); + expect(cells[0].i, -5); + expect(cells[0].j, 7); + expect(cells[0].st, 1); + expect(cells[1].id, 43); + expect(cells[1].i, 3); + expect(cells[1].j, 7); + expect(cells[1].st, 6); + }); + + test('decodes a real rendered tile with valid ids and categories', () { + final cells = decodeCoverageCells(base64Decode(_realTile)); + expect(cells.length, 29); + for (final cell in cells) { + expect(cell.st, inInclusiveRange(1, 6)); + // id = ((i + 2^20) << 21) | (j + 2^20), computed arithmetically here + // because dart2js truncates >32-bit bitwise ops (web build). + final expectedId = (cell.i + 1048576) * 2097152 + (cell.j + 1048576); + expect(cell.id, expectedId); + } + }); + + test('garbage input decodes to empty, never throws', () { + expect(decodeCoverageCells(base64Decode('aGVsbG8gd29ybGQ=')), isEmpty); + }); +} From af44cf9992ddca08c39af92e2c7d618332c23d48 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 13 Jun 2026 23:44:16 -0400 Subject: [PATCH 062/100] perf: stop the map rebuilding on every notifyListeners (wardrive overheat fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of phones overheating during wardriving: the MapLibre MapWidget (the most expensive subtree) did context.watch() and so rebuilt on EVERY one of the provider's ~166 notifyListeners() calls — including high-frequency UI-only notifies (noise floor every 5s, battery, live stats) and the dense-mesh passive-RX pin storm (10-20x/sec). This kept the CPU/GPU pinned. Present in the weeks-old build too, which matched the reports. Fix (Tier B): - AppStateProvider gains `mapRevision` plus `_notifyMapNow()` (immediate) and `_notifyMapThrottled()` (250ms leading+trailing coalescing). Only map-relevant mutations (TX/RX/disc/trace markers, echoes, GPS position, repeater load, history view, marker/log clears, marker-style prefs) bump mapRevision; the RX-pin/echo storm goes through the throttle (~4/sec cap). UI-only notifies (noise floor, battery, stats) leave mapRevision untouched. - MapWidget is wrapped in a Selector keyed on (mapRevision, focus, history, padding, controls) in home_screen.dart, so it is cached across all UI-only notifies, and its top-level subscription switches from context.watch to read. Result: the map rebuilds only when map data/layout actually changes, at a bounded rate, instead of continuously. Investigated and found NOT to be causes (no change): - 500ms countdown timer: already isolated into its own ChangeNotifier. - graph_screen 2s live timer: it's a pushed MaterialPageRoute (disposed on pop), not an offscreen IndexedStack child — no continuous offscreen cost. - _saveLastPosition: already throttled to 30s; checkDistanceTriggers gates at 25m. - _computeMarkerDataVersion heard/discovered sums: they drive marker color transitions, so they must stay; Tier B makes the O(N) loop run rarely. flutter analyze: clean. flutter test: 39/39 pass. --- lib/providers/app_state_provider.dart | 88 +++++++++++++++++++++------ lib/screens/home_screen.dart | 30 +++++++-- lib/widgets/map_widget.dart | 7 ++- 3 files changed, 103 insertions(+), 22 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7c7f56c..d8a4444 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1191,9 +1191,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _gpsPositionSubscription = _gpsService.positionStream.listen((position) async { _currentPosition = position; - notifyListeners(); + // Position change is map-relevant (auto-follow camera) — bump the map. + _notifyMapNow(); - // Save last position for next app launch + // Save last position for next app launch (already throttled to 30s) _saveLastPosition(position.latitude, position.longitude); // Check zone on first GPS lock (when _inZone is null) @@ -2395,7 +2396,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { )); if (_txLogEntries.length > _maxLogEntries) _txLogEntries.removeAt(0); - notifyListeners(); + _notifyMapNow(); }; _pingService!.onRxPing = (ping) { @@ -2415,7 +2416,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); _updateRxOverlaySlot(ping.repeaterId, ping.snr); - notifyListeners(); + _notifyMapThrottled(); }; _pingService!.onStatsUpdated = (stats) { @@ -2493,7 +2494,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { OverlayPingType.tx); debugLog('[APP] Calling notifyListeners() to update UI'); - notifyListeners(); + _notifyMapThrottled(); debugLog('[APP] notifyListeners() completed'); } else { debugLog( @@ -2545,7 +2546,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { multiHopEvents: multiHopEvents, ); - notifyListeners(); + _notifyMapThrottled(); } } }; @@ -2587,7 +2588,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { .toList(), OverlayPingType.disc); - notifyListeners(); + _notifyMapThrottled(); }; _pingService!.onTxWindowComplete = (directSuccess, multiHopEchoes) { @@ -2709,7 +2710,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { localRssi: result.localRssi, success: true, ); - notifyListeners(); + _notifyMapNow(); } } @@ -2943,7 +2944,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ), ], ); - notifyListeners(); + _notifyMapThrottled(); } else { debugLog( '[APP] Repeater ${observation.repeaterId} already has pin in current batch, SNR will update on flush if better'); @@ -3066,8 +3067,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, ); - // Update UI - notifyListeners(); + // Update UI (throttled — dense mesh RX must not churn the map) + _notifyMapThrottled(); } catch (e, stackTrace) { debugError('[APP] Error in finalized RX entry callback: $e'); debugError('[APP] Stack trace: $stackTrace'); @@ -4140,7 +4141,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _traceLogEntries.clear(); _clearOverlayState(); _pingService?.resetStats(); - notifyListeners(); + _notifyMapNow(); } /// Clear log entries @@ -4151,7 +4152,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _traceLogEntries.clear(); _errorLogEntries.clear(); _clearOverlayState(); - notifyListeners(); + _notifyMapNow(); } /// Add a discovery log entry (from Passive Mode) @@ -4162,7 +4163,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } debugLog( '[APP] Discovery log entry added: ${entry.nodeCount} nodes discovered'); - notifyListeners(); + _notifyMapNow(); } /// Add a trace log entry (from Trace Mode) @@ -4183,7 +4184,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { OverlayPingType.trace); } - notifyListeners(); + _notifyMapNow(); } /// Log a user-facing error message @@ -5076,7 +5077,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { .setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); PingService.currentMinDistance = preferences.minPingDistanceMeters; - notifyListeners(); + // Marker-style / GPS-marker prefs can change here — bump the map. + _notifyMapNow(); _savePreferences(); } @@ -6519,7 +6521,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _repeatersLoaded = true; _repeatersLoadedForIata = iata; debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); - notifyListeners(); + _notifyMapNow(); } else { debugWarn( '[MAP] No repeaters returned for zone $iata — will retry on next zone check'); @@ -7462,6 +7464,57 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectRestoreGeneration++; } + // ============================================ + // Map rebuild isolation (overheating fix) + // ============================================ + // + // The MapWidget (MapLibre GL) is by far the most expensive subtree. It used + // to rebuild on EVERY notifyListeners() — including high-frequency UI-only + // notifies (noise floor every 5s, battery, live stats) and the dense-mesh + // RX-pin storm (10-20x/sec) — which kept the GPU/CPU pinned and overheated + // the phone. Now the map is wrapped in a Selector keyed on [mapRevision] + // (see home_screen.dart `_buildLayout`), so it only rebuilds when a + // map-relevant change bumps the revision. UI-only notifies leave + // [mapRevision] untouched, so the map stays cached. + int _mapRevision = 0; + + /// Monotonic counter bumped whenever map-rendered data changes (markers, + /// position, history view). The map's Selector watches this; UI-only + /// notifies (noise floor, battery, stats) intentionally do not bump it. + int get mapRevision => _mapRevision; + + Timer? _mapThrottleTimer; + bool _mapThrottlePending = false; + static const Duration _mapThrottleWindow = Duration(milliseconds: 250); + + /// Bump the map revision and notify immediately. Use for low-frequency + /// map-relevant changes (TX ping added, ping window finalized, GPS position, + /// history view, marker/log clears, marker-style preference changes). + void _notifyMapNow() { + _mapRevision++; + notifyListeners(); + } + + /// Bump the map revision but coalesce notifications to ~4/sec. Use for + /// high-frequency map-relevant changes (passive RX pins, echo bursts) so a + /// dense mesh cannot force the map to rebuild 10-20x/sec. The pin data is + /// updated immediately; only the rebuild signal is throttled. + void _notifyMapThrottled() { + _mapRevision++; + if (_mapThrottleTimer != null) { + _mapThrottlePending = true; + return; + } + notifyListeners(); + _mapThrottleTimer = Timer(_mapThrottleWindow, () { + _mapThrottleTimer = null; + if (_mapThrottlePending) { + _mapThrottlePending = false; + notifyListeners(); + } + }); + } + @override @override void notifyListeners() { @@ -7490,6 +7543,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _zoneRefreshTimer?.cancel(); _cancelZoneGraceTimers(); _vectorFreshTimer?.cancel(); + _mapThrottleTimer?.cancel(); _unifiedRxHandler?.dispose(); _meshCoreConnection?.dispose(); _pingService?.dispose(); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 7960b01..8be9aa3 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -423,10 +423,32 @@ class _HomeScreenState extends State { children: [ if (!isLandscape) const StatusBar() else const SizedBox.shrink(), Expanded( - child: MapWidget( - bottomPaddingPixels: isLandscape || appState.isFocusModeActive || appState.viewingHistorySession ? 0 : _getControlPanelHeight(), - mapControlsExpanded: isLandscape ? _mapControlsExpanded : null, - onMapControlsToggle: isLandscape ? _toggleMapControls : null, + // The MapLibre map is the most expensive subtree in the app. + // Isolate it from the provider's high-frequency UI-only notifies + // (noise floor, battery, live stats) and the dense-mesh RX-pin + // storm by rebuilding it ONLY when a map-relevant value changes: + // AppStateProvider.mapRevision (markers/position/history) or the + // map's own layout inputs (focus/history/padding/controls). This + // Selector caches the map subtree across all other notifies and is + // the core of the overheating fix. + child: Selector( + selector: (_, p) => ( + rev: p.mapRevision, + focus: p.isFocusModeActive, + history: p.viewingHistorySession, + padH: isLandscape || + p.isFocusModeActive || + p.viewingHistorySession + ? 0.0 + : _getControlPanelHeight(), + ctrl: isLandscape ? _mapControlsExpanded : null, + ), + builder: (_, s, __) => MapWidget( + bottomPaddingPixels: s.padH, + mapControlsExpanded: s.ctrl, + onMapControlsToggle: isLandscape ? _toggleMapControls : null, + ), ), ), ], diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 25f3875..24c37b6 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -979,7 +979,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { - final appState = context.watch(); + // NOTE: read (not watch). This widget is rebuilt by a Selector in + // home_screen.dart keyed on AppStateProvider.mapRevision (+ layout inputs), + // so it must NOT subscribe to the whole provider — that was the overheating + // root cause (the map rebuilt on every notifyListeners). build() runs + // whenever the Selector rebuilds it, at which point read gives fresh state. + final appState = context.read(); // Load saved map toggle preferences once, after Hive has finished loading if (!_prefsApplied && appState.preferencesLoaded) { From b80bc1a536adf781fee5288ab2cd906809990eab Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 13 Jun 2026 23:51:28 -0400 Subject: [PATCH 063/100] docs: document map rebuild isolation (mapRevision) architecture Add the "Map Rebuild Isolation" section to DEVELOPMENT.md and CLAUDE.md, and a new Critical Rule 9 in CLAUDE.md: map-rendered state must bump mapRevision via _notifyMapNow()/_notifyMapThrottled() (not plain notifyListeners()) so the map, now isolated behind a Selector + context.read, still updates. --- DEVELOPMENT.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 2e636b2..a0569c2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -90,6 +90,30 @@ The app uses a layered service architecture with clear separation of concerns: - `AppStateProvider`: Single ChangeNotifier for all app state using Provider pattern - All UI updates happen via `notifyListeners()` after state mutations +### Map Rebuild Isolation + +The MapLibre `MapWidget` is by far the most expensive subtree. It is therefore +**not** subscribed to the whole provider — that previously made it rebuild on +every `notifyListeners()` (including noise-floor/battery/stats every few seconds +and the dense-mesh passive-RX pin storm at 10–20×/sec), which pinned the CPU/GPU +and overheated the device during wardriving. + +Instead the map is isolated: +- `AppStateProvider` exposes `mapRevision`, an integer bumped only when + **map-rendered** state changes (TX/RX/disc/trace markers, echoes, GPS + position, zone repeater load, history view, marker/log clears, marker-style + prefs). +- Two helpers drive it: `_notifyMapNow()` (bump + immediate notify, for + low-frequency changes) and `_notifyMapThrottled()` (bump + ~250 ms + leading+trailing coalescing, for the high-frequency RX/echo storm — caps map + rebuilds at ~4/sec while pin data updates immediately). +- `MapWidget` is wrapped in a `Selector` (`home_screen.dart` `_buildLayout`) + keyed on `(mapRevision, focus, history, padding, controls)` and uses + `context.read` internally, so it is cached across all UI-only notifies. +- UI-only state (noise floor, battery, live stats) calls plain + `notifyListeners()` and leaves `mapRevision` untouched, so the status bar + updates without rebuilding the map. + ### 9-Step Connection Workflow Critical safety: The connection sequence MUST complete in order. From 489064d1e86b028f93d7831c5eb2cb2e662c5811 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 14 Jun 2026 20:55:46 -0400 Subject: [PATCH 064/100] perf: make the mode-button glow static (stop the all-session GPU frame pump) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Active/Hybrid/Passive mode buttons ran a repeating 1200ms pulse AnimationController (_pulseController.repeat) the entire time a mode was running. A repeating AnimationController keeps Flutter's vsync ticker alive, so the GPU/compositor never idled — on a 120Hz device the app rendered ~120fps for the whole wardriving session for a purely cosmetic breathing glow, even parked. Profile-mode measurement (SchedulerBinding.addTimingsCallback FPS logger): during active Hybrid mode the app sat at ~113-129 fps continuously; with the pulse removed it drops to ~25-28 fps (renders only when something actually changes). The button still reads as active via color, the indicator dot, the "Active" text, and the live countdown — only the animated breathing is gone. Removes the AnimationController / AnimatedBuilder from _ActionButton, _LandscapeIconButton, and _CompactActionButton; the active-state background opacity is now a static value. flutter analyze: clean. flutter test: 39/39 pass. --- lib/widgets/ping_controls.dart | 157 ++++----------------------------- 1 file changed, 17 insertions(+), 140 deletions(-) diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index b79c981..a43174a 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -380,45 +380,7 @@ class _ActionButton extends StatefulWidget { State<_ActionButton> createState() => _ActionButtonState(); } -class _ActionButtonState extends State<_ActionButton> - with SingleTickerProviderStateMixin { - late AnimationController _pulseController; - late Animation _pulseAnimation; - - @override - void initState() { - super.initState(); - _pulseController = AnimationController( - duration: const Duration(milliseconds: 1200), - vsync: this, - ); - // Pulse opacity from 0.3 to 0.6 for a subtle glow effect - _pulseAnimation = Tween(begin: 0.15, end: 0.35).animate( - CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), - ); - - if (widget.isActive) { - _pulseController.repeat(reverse: true); - } - } - - @override - void didUpdateWidget(_ActionButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.isActive && !oldWidget.isActive) { - _pulseController.repeat(reverse: true); - } else if (!widget.isActive && oldWidget.isActive) { - _pulseController.stop(); - _pulseController.reset(); - } - } - - @override - void dispose() { - _pulseController.dispose(); - super.dispose(); - } - +class _ActionButtonState extends State<_ActionButton> { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -428,14 +390,13 @@ class _ActionButtonState extends State<_ActionButton> final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; final borderOpacity = widget.isActive ? 0.6 : 0.3; + // Static active-state background opacity. This was a repeating pulse + // animation, removed because a .repeat() AnimationController kept the GPU + // rendering at the display refresh rate for the entire wardriving session. + // The button still reads as "active" via color, the dot, and the text. + final bgOpacity = widget.isActive ? 0.25 : 0.12; - return AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - // Use animated opacity for active state background - final bgOpacity = widget.isActive ? _pulseAnimation.value : 0.12; - - return Material( + return Material( color: Colors.transparent, child: InkWell( onTap: widget.enabled ? widget.onPressed : null, @@ -534,8 +495,6 @@ class _ActionButtonState extends State<_ActionButton> ), ), ); - }, - ); } } @@ -1769,57 +1728,18 @@ class _LandscapeIconButton extends StatefulWidget { State<_LandscapeIconButton> createState() => _LandscapeIconButtonState(); } -class _LandscapeIconButtonState extends State<_LandscapeIconButton> - with SingleTickerProviderStateMixin { - late AnimationController _pulseController; - late Animation _pulseAnimation; - - @override - void initState() { - super.initState(); - _pulseController = AnimationController( - duration: const Duration(milliseconds: 1200), - vsync: this, - ); - _pulseAnimation = Tween(begin: 0.15, end: 0.35).animate( - CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), - ); - - if (widget.isActive) { - _pulseController.repeat(reverse: true); - } - } - - @override - void didUpdateWidget(_LandscapeIconButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.isActive && !oldWidget.isActive) { - _pulseController.repeat(reverse: true); - } else if (!widget.isActive && oldWidget.isActive) { - _pulseController.stop(); - _pulseController.reset(); - } - } - - @override - void dispose() { - _pulseController.dispose(); - super.dispose(); - } - +class _LandscapeIconButtonState extends State<_LandscapeIconButton> { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final showColor = widget.enabled || widget.isActive; final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; + // Static active-state opacity (continuous pulse animation removed — see + // _ActionButton; it kept the GPU rendering all session). + final bgOpacity = widget.isActive ? 0.25 : 0.10; - return AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - final bgOpacity = widget.isActive ? _pulseAnimation.value : 0.10; - - return Tooltip( + return Tooltip( message: widget.tooltip, child: Material( color: Colors.transparent, @@ -1892,8 +1812,6 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> ), ), ); - }, - ); } } @@ -1926,44 +1844,7 @@ class _CompactActionButton extends StatefulWidget { State<_CompactActionButton> createState() => _CompactActionButtonState(); } -class _CompactActionButtonState extends State<_CompactActionButton> - with SingleTickerProviderStateMixin { - late AnimationController _pulseController; - late Animation _pulseAnimation; - - @override - void initState() { - super.initState(); - _pulseController = AnimationController( - duration: const Duration(milliseconds: 1200), - vsync: this, - ); - _pulseAnimation = Tween(begin: 0.15, end: 0.35).animate( - CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), - ); - - if (widget.isActive) { - _pulseController.repeat(reverse: true); - } - } - - @override - void didUpdateWidget(_CompactActionButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.isActive && !oldWidget.isActive) { - _pulseController.repeat(reverse: true); - } else if (!widget.isActive && oldWidget.isActive) { - _pulseController.stop(); - _pulseController.reset(); - } - } - - @override - void dispose() { - _pulseController.dispose(); - super.dispose(); - } - +class _CompactActionButtonState extends State<_CompactActionButton> { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -1972,13 +1853,11 @@ class _CompactActionButtonState extends State<_CompactActionButton> showColor ? widget.color : colorScheme.onSurfaceVariant; // Show label if colored OR if expanded (shows countdown on grey button during cooldown) final hasLabel = widget.label != null && (showColor || widget.isExpanded); + // Static active-state opacity (continuous pulse animation removed — see + // _ActionButton; it kept the GPU rendering all session). + final bgOpacity = widget.isActive ? 0.25 : 0.12; - return AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - final bgOpacity = widget.isActive ? _pulseAnimation.value : 0.12; - - return Material( + return Material( color: Colors.transparent, child: InkWell( onTap: widget.enabled ? widget.onPressed : null, @@ -2069,7 +1948,5 @@ class _CompactActionButtonState extends State<_CompactActionButton> ), ), ); - }, - ); } } From 3c2782063c8952e6ef40b4aea37a3d4735316fed Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 14 Jun 2026 22:08:30 -0400 Subject: [PATCH 065/100] perf: stop the map relayouting on every GPS tick (wardrive overheat fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MapWidget's Selector was being silently defeated on every HomeScreen rebuild. HomeScreen.build() uses context.watch, so it rebuilds on every notifyListeners() — including the ~2Hz GPS notify. Each rebuild constructed a fresh inline Selector instance, and provider's Selector invalidates its cache whenever `oldWidget != widget` (selector.dart:77), so the cached MapWidget was rebuilt BEFORE the value comparison ran — relayouting the iOS platform view (~24ms) on every GPS position update. Measured on-device (iPhone, profile mode): GPS-driven map builds dropped from 2-3x/sec @ ~24ms to 0/sec; per-frame build time fell ~24ms -> ~1ms while the camera still follows in real time. Two changes: - Decouple GPS from mapRevision: the GPS listener now calls plain notifyListeners(); camera-follow, derived heading and the GPS puck are driven from a direct provider listener (_onPositionNotify -> _handleGpsPosition) via the native controller (animateCamera / updateSymbol), preserving the real-time nav feel with no widget rebuild. - Memoize the map Selector (home_screen _buildMapSelector) so its widget identity is stable across HomeScreen's per-notify rebuilds, letting the Selector's value comparison actually gate the map. --- DEVELOPMENT.md | 27 +++- lib/providers/app_state_provider.dart | 14 +- lib/screens/home_screen.dart | 74 +++++++---- lib/widgets/map_widget.dart | 179 +++++++++++++++----------- 4 files changed, 188 insertions(+), 106 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a0569c2..6005f94 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -100,20 +100,39 @@ and overheated the device during wardriving. Instead the map is isolated: - `AppStateProvider` exposes `mapRevision`, an integer bumped only when - **map-rendered** state changes (TX/RX/disc/trace markers, echoes, GPS - position, zone repeater load, history view, marker/log clears, marker-style - prefs). + **map-rendered** state changes (TX/RX/disc/trace markers, echoes, zone + repeater load, history view, marker/log clears, marker-style prefs). - Two helpers drive it: `_notifyMapNow()` (bump + immediate notify, for low-frequency changes) and `_notifyMapThrottled()` (bump + ~250 ms leading+trailing coalescing, for the high-frequency RX/echo storm — caps map rebuilds at ~4/sec while pin data updates immediately). -- `MapWidget` is wrapped in a `Selector` (`home_screen.dart` `_buildLayout`) +- `MapWidget` is wrapped in a `Selector` (`home_screen.dart` `_buildMapSelector`) keyed on `(mapRevision, focus, history, padding, controls)` and uses `context.read` internally, so it is cached across all UI-only notifies. - UI-only state (noise floor, battery, live stats) calls plain `notifyListeners()` and leaves `mapRevision` untouched, so the status bar updates without rebuilding the map. +**GPS position does NOT bump `mapRevision`.** Position updates ~1–2×/sec while +driving; rebuilding the map that often relayouts the iOS platform view (~24 ms +each) — a dominant heat source. Instead, the GPS listener calls plain +`notifyListeners()`, and `MapWidget` drives camera-follow, derived heading, and +the GPS puck from a **direct provider listener** (`_onPositionNotify` → +`_handleGpsPosition`) that calls the native controller (`animateCamera` / +`updateSymbol`) every tick — real-time nav, no widget rebuild. The GPS-info +overlay rebuilds only when the map itself does. + +**The Selector MUST be memoized (identity-stable).** `HomeScreen.build()` uses +`context.watch`, so it rebuilds on every notify (incl. the 2 Hz GPS one). +provider's `Selector` invalidates its cache whenever `oldWidget != widget` +(`selector.dart:77`), so a fresh inline `Selector(...)` instance each build +forces `MapWidget` to rebuild **before** the value comparison ever runs — +silently defeating the isolation. `_buildMapSelector` therefore caches the +`Selector` instance, keyed only on the State fields its closures capture +(`isLandscape` / `_isControlsMinimized` / `_mapControlsExpanded`), so its +identity survives parent rebuilds and the value comparison actually gates the +map. + ### 9-Step Connection Workflow Critical safety: The connection sequence MUST complete in order. diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index d8a4444..91e6420 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1191,8 +1191,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _gpsPositionSubscription = _gpsService.positionStream.listen((position) async { _currentPosition = position; - // Position change is map-relevant (auto-follow camera) — bump the map. - _notifyMapNow(); + // Do NOT bump mapRevision here. Position drives the camera/puck/coords + // directly (MapWidget._onPositionNotify listener + a Selector on the + // GPS-info overlay) — all real-time — WITHOUT rebuilding the map, which + // would relayout the iOS platform view (~28ms) every GPS tick. A plain + // notifyListeners() reaches those position watchers; the map's Selector + // (keyed on mapRevision) stays cached. + notifyListeners(); // Save last position for next app launch (already throttled to 30s) _saveLastPosition(position.latitude, position.longitude); @@ -5291,7 +5296,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _mapNavigationTarget = (lat: latitude, lon: longitude); _mapNavigationTrigger++; // Increment to trigger listeners _requestMapTabSwitch = true; // Request tab switch - notifyListeners(); + // Map-relevant: the build()-side nav block reads mapNavigationTrigger, so + // the map must rebuild for the camera jump to fire (GPS no longer forces + // frequent rebuilds after the position/map-rebuild decouple). + _notifyMapNow(); } /// Clear the map tab switch request (called by main scaffold after switching) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 8be9aa3..b974b8c 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -29,6 +29,51 @@ class _HomeScreenState extends State { /// Landscape side panel width (220px gives more room for controls) static const double _landscapePanelWidth = 220.0; + // ── Map Selector memoization (overheating fix) ──────────────────────────── + // HomeScreen.build() runs context.watch, so it rebuilds on EVERY provider + // notify (incl. the 2Hz GPS notify). An inline `Selector(...)` is a NEW widget + // instance each build, and provider's Selector invalidates its cache whenever + // `oldWidget != widget` (selector.dart:77) — so a fresh instance forces the + // cached MapWidget to rebuild BEFORE the value comparison runs, relayouting the + // iOS platform view (~24ms) every GPS tick. Returning the SAME Selector instance + // (identity stable) makes Flutter short-circuit the child and lets the value + // comparison actually gate the map. The instance is keyed only on the State + // fields its closures capture (isLandscape / _isControlsMinimized / + // _mapControlsExpanded); provider values (mapRevision/focus/history) are read + // live inside the closures and handled by the Selector's value comparison. + Widget? _cachedMapSelector; + ({bool landscape, bool minimized, bool ctrlExpanded})? _cachedMapSelectorKey; + + Widget _buildMapSelector(bool isLandscape) { + final key = ( + landscape: isLandscape, + minimized: _isControlsMinimized, + ctrlExpanded: _mapControlsExpanded, + ); + if (_cachedMapSelector != null && _cachedMapSelectorKey == key) { + return _cachedMapSelector!; + } + _cachedMapSelectorKey = key; + _cachedMapSelector = Selector( + selector: (_, p) => ( + rev: p.mapRevision, + focus: p.isFocusModeActive, + history: p.viewingHistorySession, + padH: isLandscape || p.isFocusModeActive || p.viewingHistorySession + ? 0.0 + : _getControlPanelHeight(), + ctrl: isLandscape ? _mapControlsExpanded : null, + ), + builder: (_, s, __) => MapWidget( + bottomPaddingPixels: s.padH, + mapControlsExpanded: s.ctrl, + onMapControlsToggle: isLandscape ? _toggleMapControls : null, + ), + ); + return _cachedMapSelector!; + } + /// Toggle control panel in landscape mode (closes map controls if open) void _toggleControlPanel() { setState(() { @@ -428,28 +473,13 @@ class _HomeScreenState extends State { // (noise floor, battery, live stats) and the dense-mesh RX-pin // storm by rebuilding it ONLY when a map-relevant value changes: // AppStateProvider.mapRevision (markers/position/history) or the - // map's own layout inputs (focus/history/padding/controls). This - // Selector caches the map subtree across all other notifies and is - // the core of the overheating fix. - child: Selector( - selector: (_, p) => ( - rev: p.mapRevision, - focus: p.isFocusModeActive, - history: p.viewingHistorySession, - padH: isLandscape || - p.isFocusModeActive || - p.viewingHistorySession - ? 0.0 - : _getControlPanelHeight(), - ctrl: isLandscape ? _mapControlsExpanded : null, - ), - builder: (_, s, __) => MapWidget( - bottomPaddingPixels: s.padH, - mapControlsExpanded: s.ctrl, - onMapControlsToggle: isLandscape ? _toggleMapControls : null, - ), - ), + // map's own layout inputs (focus/history/padding/controls). The + // Selector is MEMOIZED (_buildMapSelector) so its widget identity + // is stable across HomeScreen's per-notify rebuilds — otherwise a + // fresh Selector instance defeats its own cache (selector.dart:77, + // `oldWidget != widget`) and the map rebuilds every GPS tick. This + // is the core of the overheating fix. + child: _buildMapSelector(isLandscape), ), ], ), diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 24c37b6..eaad09a 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -581,6 +581,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // the user happens to touch the map. _patchProviderRef = context.read(); _patchProviderRef!.addListener(_onCoveragePatchNotify); + // GPS position drives camera-follow / heading / puck via a DIRECT listener + // (not build()): the camera follows every tick WITHOUT rebuilding the map, + // which would relayout the iOS platform view (~28 ms/tick). See + // _handleGpsPosition. GPS notifies no longer bump mapRevision, so the map's + // Selector doesn't rebuild on position. + _patchProviderRef!.addListener(_onPositionNotify); } AppStateProvider? _patchProviderRef; @@ -594,6 +600,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _applyCoveragePatch(appState); } + /// Fires on every provider notify; drives real-time camera-follow + GPS puck + /// from the current position without rebuilding the map. Heavy work inside is + /// version-gated, so non-position notifies are a cheap no-op. + void _onPositionNotify() { + final appState = _patchProviderRef; + if (appState == null || !mounted) return; + _handleGpsPosition(appState); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { final wasBackground = _appLifecycleState != AppLifecycleState.resumed; @@ -631,6 +646,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { void dispose() { WidgetsBinding.instance.removeObserver(this); _patchProviderRef?.removeListener(_onCoveragePatchNotify); + _patchProviderRef?.removeListener(_onPositionNotify); _tileLoadTimeoutTimer?.cancel(); final controller = _mapController; if (controller != null) { @@ -977,24 +993,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { position.latitude + latOffset, position.longitude + lonOffset); } - @override - Widget build(BuildContext context) { - // NOTE: read (not watch). This widget is rebuilt by a Selector in - // home_screen.dart keyed on AppStateProvider.mapRevision (+ layout inputs), - // so it must NOT subscribe to the whole provider — that was the overheating - // root cause (the map rebuilt on every notifyListeners). build() runs - // whenever the Selector rebuilds it, at which point read gives fresh state. - final appState = context.read(); - - // Load saved map toggle preferences once, after Hive has finished loading - if (!_prefsApplied && appState.preferencesLoaded) { - _prefsApplied = true; - _autoFollow = appState.preferences.mapAutoFollow; - _alwaysNorth = appState.preferences.mapAlwaysNorth; - _rotationLocked = appState.preferences.mapRotationLocked; - } - - // Determine map center - prefer current GPS, fallback to last known, then Ottawa + /// Drives camera-follow, derived heading, one-time zooms, and the GPS-marker + /// puck from the current GPS position. Invoked on EVERY GPS tick by a direct + /// provider listener (`_onPositionNotify`) so the camera follows in real time + /// WITHOUT rebuilding `MapWidget` — a rebuild relayouts the iOS platform view + /// (~28 ms) every tick, which was the dominant wardriving CPU/heat cost. The + /// camera move itself is the same native controller call (`animateCamera`) as + /// before; only its trigger moved out of `build()`. Also called from `build()` + /// as an idempotent safety net (all heavy work is version-gated). + void _handleGpsPosition(AppStateProvider appState) { + // Map center — prefer current GPS, fallback to last known. LatLng center = _defaultCenter; if (appState.currentPosition != null) { center = LatLng( @@ -1008,8 +1016,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ); } - // One-time zoom to last known position when GPS is not yet available - // This runs before GPS locks, so user sees their previous location instead of Ottawa + // One-time zoom to last known position when GPS is not yet available. if (appState.currentPosition == null && appState.lastKnownPosition != null && !_hasZoomedToLastKnown && @@ -1029,14 +1036,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } if (appState.currentPosition != null) { - // Recompute our derived heading for this frame. _computedHeading is - // updated as a side effect; use it below instead of reading - // currentPosition.heading directly (which is unreliable at low speeds). + // Recompute derived heading (more reliable than position.heading at low + // speeds); _computedHeading is updated as a side effect. _computeHeading(appState.currentPosition!); - // One-time initial zoom to GPS when we first get a position - // This happens even with auto-follow disabled so user sees their location - // Don't apply panel offset - center directly on GPS so pin is in middle of screen + // One-time initial zoom to GPS (even with auto-follow off, centered on GPS). if (!_hasInitialZoomed && _isMapReady && _canAnimateCamera) { _hasInitialZoomed = true; final initialPosition = center; @@ -1044,8 +1048,6 @@ class _MapWidgetState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { if (_autoFollow) { - // Auto-follow is on and panel may be open — apply panel offset so - // the marker appears centered in the visible map area. final adjustedPosition = _offsetPositionForPadding( initialPosition, widget.bottomPaddingPixels, @@ -1063,12 +1065,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { }); } - // Auto-follow GPS position when enabled. When auto-follow is on we - // bundle pan, zoom, and bearing into a single animateCamera call so - // the three don't race each other. _autoFollowDesiredZoom is the - // zoom the camera is animating toward — using it instead of the - // (potentially interpolated) current zoom prevents drift during the - // initial zoom animation after tapping center-on-position. + // Auto-follow: bundle pan, zoom, and bearing into one animateCamera call. if (_autoFollow && _isMapReady && _cameraAnimationReady) { final newPosition = center; if (_lastGpsPosition == null || @@ -1082,9 +1079,6 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final double targetZoom = _autoFollowDesiredZoom ?? _mapController?.cameraPosition?.zoom ?? _defaultZoom; - // Track _lastHeading here too so the separate rotation block - // below (which runs when auto-follow is off) doesn't fire a - // redundant rotation animation on the next frame. if (!_alwaysNorth && _computedHeading != null) { _lastHeading = _computedHeading; } @@ -1107,19 +1101,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } - // Handle map rotation based on heading when NOT auto-following. - // When auto-follow is on, rotation is bundled into the combined - // camera update above so we don't race two animateCamera calls. + // Heading-based rotation when NOT auto-following. if (!_autoFollow && !_alwaysNorth && _isMapReady && _computedHeading != null) { final heading = _computedHeading!; if (_lastHeading == null) { - // First heading after startup — store without rotating so the - // initial zoom animation can settle at rotation 0 (where the - // panel offset was computed). Heading mode will begin rotating - // on the next GPS update when heading changes. _lastHeading = heading; debugLog( '[MAP] First heading after startup (${heading.toStringAsFixed(1)}°) — stored without rotating'); @@ -1133,12 +1121,79 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } } else { - // GPS lock lost — clear bearing state so reacquisition starts fresh - // instead of snapping the marker/map to a stale direction. + // GPS lock lost — clear bearing state so reacquisition starts fresh. _bearingAnchor = null; _computedHeading = null; } + // GPS marker puck — cheap updateSymbol, gated by gpsVersion (position + + // heading + style). Real-time every tick; does NOT go through the heavy + // _syncAllAnnotations pipeline. + if (_isMapReady && + _styleLoaded && + _imagesRegistered && + _cameraAnimationReady) { + final gpsVersion = Object.hash( + appState.currentPosition?.latitude, + appState.currentPosition?.longitude, + _computedHeading, + appState.preferences.gpsMarkerStyle, + ); + if (gpsVersion != _lastGpsSyncVersion) { + _lastGpsSyncVersion = gpsVersion; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + if (_gpsSyncInFlight) return; + _gpsSyncInFlight = true; + try { + await _syncGpsSymbol(appState); + } catch (e) { + debugError('[MAP] _syncGpsSymbol failed: $e'); + } finally { + _gpsSyncInFlight = false; + } + }); + } + } + } + + @override + Widget build(BuildContext context) { + // NOTE: read (not watch). This widget is rebuilt by a Selector in + // home_screen.dart keyed on AppStateProvider.mapRevision (+ layout inputs), + // so it must NOT subscribe to the whole provider — that was the overheating + // root cause (the map rebuilt on every notifyListeners). build() runs + // whenever the Selector rebuilds it, at which point read gives fresh state. + final appState = context.read(); + + // Load saved map toggle preferences once, after Hive has finished loading + if (!_prefsApplied && appState.preferencesLoaded) { + _prefsApplied = true; + _autoFollow = appState.preferences.mapAutoFollow; + _alwaysNorth = appState.preferences.mapAlwaysNorth; + _rotationLocked = appState.preferences.mapRotationLocked; + } + + // Determine map center - prefer current GPS, fallback to last known, then Ottawa + LatLng center = _defaultCenter; + if (appState.currentPosition != null) { + center = LatLng( + appState.currentPosition!.latitude, + appState.currentPosition!.longitude, + ); + } else if (appState.lastKnownPosition != null) { + center = LatLng( + appState.lastKnownPosition!.lat, + appState.lastKnownPosition!.lon, + ); + } + + // Camera-follow / derived heading / one-time zooms / GPS-marker puck now + // run in _handleGpsPosition, invoked every GPS tick by _onPositionNotify + // (real-time follow WITHOUT rebuilding the map / relayouting the platform + // view). Called here too as an idempotent safety net — version-gated. + _handleGpsPosition(appState); + // Handle navigation trigger from log screen or graph // Reset map state and navigate to the target location if (_isMapReady && @@ -1235,37 +1290,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } - // GPS marker has its own lightweight gate. Position/heading change every - // GPS tick during auto-follow, but updating the GPS symbol is one cheap - // updateSymbol call — it does not need the heavy _syncAllAnnotations - // pipeline, and routing it through there caused setGeoJsonSource on the - // repeater cluster source to fire every tick, which made MapLibre - // re-run its global symbol collision pass and flickered the base-style - // POI labels at high zoom. The gpsMarkerStyle pref is included so style - // changes (arrow → walk, etc.) re-render the marker's bitmap. - if (_isMapReady && _styleLoaded && _imagesRegistered && _cameraAnimationReady) { - final gpsVersion = Object.hash( - appState.currentPosition?.latitude, - appState.currentPosition?.longitude, - _computedHeading, - appState.preferences.gpsMarkerStyle, - ); - if (gpsVersion != _lastGpsSyncVersion) { - _lastGpsSyncVersion = gpsVersion; - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (!mounted) return; - if (_gpsSyncInFlight) return; - _gpsSyncInFlight = true; - try { - await _syncGpsSymbol(appState); - } catch (e) { - debugError('[MAP] _syncGpsSymbol failed: $e'); - } finally { - _gpsSyncInFlight = false; - } - }); - } - } + // (GPS marker puck sync moved into _handleGpsPosition — runs every tick.) final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; From d5b556d4a9c6fc8cb5ffb7eb0cb3e889978f015f Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 16 Jun 2026 15:38:56 -0400 Subject: [PATCH 066/100] feat: bake repeater hex into Detailed-mode chip icons; rename "Coverage Grid" -> "Grid Mode" --- lib/providers/app_state_provider.dart | 2 +- lib/screens/settings_screen.dart | 6 +- lib/widgets/map_widget.dart | 312 ++++++++++++++++++++++---- 3 files changed, 269 insertions(+), 51 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 91e6420..c8d526c 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -622,7 +622,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Map get coveragePatchCells => _coveragePatchCells; /// Drop the session patch — the cells belong to one region + grid preset - /// (called on zone change and when the Coverage Grid preference changes). + /// (called on zone change and when the Grid Mode preference changes). void clearCoveragePatch() { _coveragePatchCells.clear(); _coveragePatchVersion++; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 2ac78c9..83a8b08 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -252,7 +252,7 @@ class _SettingsScreenState extends State { if (prefs.mapTilesEnabled) ListTile( leading: const Icon(Icons.grid_on), - title: const Text('Coverage Grid'), + title: const Text('Grid Mode'), subtitle: Text(prefs.coverageGridSize == 100 ? 'Detailed (100 m cells)' : 'Simplified (300 m cells)'), @@ -1305,7 +1305,7 @@ class _SettingsScreenState extends State { }; } - /// Coverage grid preset selector — mirrors the web UI's Grid Mode + /// Grid Mode preset selector — mirrors the web UI's Grid Mode /// (Simplified = 300 m, Detailed = 100 m + blob, applied server-side). void _showCoverageGridSelector( BuildContext context, AppStateProvider appState) { @@ -1324,7 +1324,7 @@ class _SettingsScreenState extends State { children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Coverage Grid', + child: Text('Grid Mode', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), RadioGroup( diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index eaad09a..3714543 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -53,6 +53,15 @@ class _MapImages { static String repeater(String status, int hopBytes) => 'rep_${status}_$hopBytes'; + // Detailed-mode baked repeater CHIP bitmaps: status × hop_bytes × hex label. + // The hex is baked into the icon (no text-field) so overlapping un-clustered + // chips can't have a label detach onto a neighbour's box. One image per + // distinct (status, hop, hex); registered lazily + deduped. See + // _renderRepeaterChipPng / _ensureRepeaterChipImages. + // Names: repchip_active_2_A1B2, etc. + static String repeaterChip(String status, int hopBytes, String hex) => + 'repchip_${status}_${hopBytes}_$hex'; + static const repeaterStatuses = ['active', 'dead', 'new', 'dup']; static const repeaterHopBytes = [1, 2, 3]; @@ -158,6 +167,102 @@ Future<({Uint8List bytes, Size size})> _renderDistanceLabelPng( ); } +/// Bakes a complete repeater "chip" — the status-colored rounded box plus its +/// centered hex label — into a single PNG, so the label is part of the icon and +/// can never detach onto a neighbouring chip's box (the MapLibre symbol two-pass +/// "all icons, then all glyphs" overlap bug). Used ONLY in Detailed grid mode, +/// where repeaters are un-clustered and can overlap. Simplified mode keeps the +/// cheap shared-glyph text-field path (clustering guarantees ≥50px spacing). +/// +/// Variable width — sized to the measured hex like [_renderDistanceLabelPng]. +/// Baked at devicePixelRatio 3.0 to stay crisp on hi-DPI; rendered with +/// iconSize 1.0 + center anchor. Box visuals mirror [_RepeaterShapePainter] +/// (drop shadow, filled box, 2px white border) so chips match the Simplified +/// shape markers. +Future _renderRepeaterChipPng( + String hex, + Color fill, + double borderRadius, { + double devicePixelRatio = 3.0, +}) async { + const fontSize = 13.0; + const horizontalPad = 8.0; // inside the box, each side + const boxHeight = 26.0; + const shadowBlur = 4.0; + const margin = 5.0; // room around the box for the (blurred, +2px) shadow + + final textPainter = TextPainter( + text: TextSpan( + text: hex, + style: const TextStyle( + fontSize: fontSize, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + + final boxWidth = textPainter.width + horizontalPad * 2; + final logicalWidth = boxWidth + margin * 2; + const logicalHeight = boxHeight + margin * 2; + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.scale(devicePixelRatio); + + final boxRect = Rect.fromLTWH(margin, margin, boxWidth, boxHeight); + final radius = Radius.circular(borderRadius); + + // Drop shadow (positioned 2px below the box). + canvas.drawRRect( + RRect.fromRectAndRadius(boxRect.shift(const Offset(0, 2)), radius), + Paint() + ..color = Colors.black26 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, shadowBlur), + ); + + // Filled colored box. + canvas.drawRRect( + RRect.fromRectAndRadius(boxRect, radius), + Paint()..color = fill, + ); + + // White border (2px, drawn just inside the box edge). + canvas.drawRRect( + RRect.fromRectAndRadius( + boxRect.deflate(1), + Radius.circular(borderRadius - 1), + ), + Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0, + ); + + // Centered hex label. + textPainter.paint( + canvas, + Offset( + margin + (boxWidth - textPainter.width) / 2, + margin + (boxHeight - textPainter.height) / 2, + ), + ); + + final picture = recorder.endRecording(); + final image = await picture.toImage( + (logicalWidth * devicePixelRatio).round(), + (logicalHeight * devicePixelRatio).round(), + ); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + picture.dispose(); + image.dispose(); + if (byteData == null) { + throw StateError('Failed to encode repeater chip to PNG bytes'); + } + return byteData.buffer.asUint8List(); +} + Future _renderPainterToPng( CustomPainter painter, Size size, { @@ -508,6 +613,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // needed. Right now we just re-addImage on each sync (idempotent). final Set _registeredDistanceLabelImages = {}; final Map _registeredDistanceLabelImageSizes = {}; + // Detailed-mode baked repeater chip image names already registered via + // addImage (deduped by "repchip_{status}_{hop}_{hex}"). Lazily grown by + // _ensureRepeaterChipImages; cleared on style reload (native drops images). + final Set _registeredChipImages = {}; Symbol? _gpsSymbol; // single GPS marker // When true, _syncAllAnnotations skips _updateFocusLines and @@ -1586,6 +1695,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _lastAppliedGridSize != prefsForOverlay.coverageGridSize) || (_lastAppliedCvd != null && _lastAppliedCvd != prefsForOverlay.colorVisionType)); + // A Grid Mode change ALSO toggles repeater clustering (Detailed = 100 is + // un-clustered). `cluster` is fixed at source creation, so the repeater + // source/layers must be rebuilt — not just the coverage overlay. + final gridChanged = _isMapReady && + _styleLoaded && + _lastAppliedGridSize != null && + _lastAppliedGridSize != prefsForOverlay.coverageGridSize; if (zoneChanged || overlayPrefChanged) { if (zoneChanged) { @@ -1596,7 +1712,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (overlayPrefChanged) { // Patched cell ids/geometry are per grid preset; a palette-only // change keeps them (the rebuild restyles the patch layer too). - if (_lastAppliedGridSize != prefsForOverlay.coverageGridSize) { + if (gridChanged) { appState.clearCoveragePatch(); } _lastAppliedGridSize = prefsForOverlay.coverageGridSize; @@ -1607,7 +1723,24 @@ class _MapWidgetState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addPostFrameCallback((_) async { _coverageRefreshScheduled = false; if (!mounted) return; + // Rebuild the repeater source/layers with the new cluster flag BEFORE + // the coverage overlay refresh — coverage targets the bottom repeater + // layer as its belowLayerId, so those layers must exist first. Collapse + // any open spider so its (cluster-only) state doesn't leak across the + // rebuild. + if (gridChanged && _clusterLayersReady) { + _collapseSpider(); + _clusterLayersReady = false; + await _setupRepeaterClusterLayers( + clustered: appState.preferences.coverageGridSize != 100); + await _syncRepeaterSymbols(appState); + } await _refreshCoverageOverlay(appState); + // Region borders sit below the repeater stack; re-add them so they + // stay beneath the freshly-rebuilt repeater layers. + if (gridChanged && _showRegionBorders) { + await _refreshRegionBorders(appState); + } }); } } @@ -2127,6 +2260,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _distanceLabelRepeaterPos.clear(); _registeredDistanceLabelImages.clear(); _registeredDistanceLabelImageSizes.clear(); + // Detailed-mode baked repeater chips are dropped by the native side on + // style reload too — clear the cache so the next sync re-registers them. + _registeredChipImages.clear(); // Mark cluster layers as not-ready until _setupRepeaterClusterLayers // creates them on the new style. This gates build()-driven post-frame // syncs from racing ahead of source creation. @@ -2156,10 +2292,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _imagesRegistered = false; await _registerMapImages(appState); - // Set up the repeater cluster source + 3 layers. Must run AFTER images - // are registered, since the individual symbol layer's iconImage expression - // looks up names registered by _registerMapImages. - await _setupRepeaterClusterLayers(); + // Set up the repeater source + layers. Must run AFTER images are + // registered, since the individual symbol layer's iconImage expression + // looks up names registered by _registerMapImages. Clustering follows the + // Grid Mode pref: Detailed (gsize 100) renders every repeater individually. + await _setupRepeaterClusterLayers( + clustered: appState.preferences.coverageGridSize != 100); // Re-add coverage overlay AFTER cluster layers exist so _addCoverageOverlay // can target the bottom repeater layer as its belowLayerId reference. This @@ -2752,6 +2890,52 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return 'active'; } + /// Ensures every baked repeater-chip image referenced by [featureCollection] + /// (Detailed grid mode) is registered via addImage. Deduped by image name — + /// each distinct `repchip_{status}_{hop}_{hex}` chip is baked at most once and + /// cached in [_registeredChipImages] (cleared on style reload). Driven off the + /// FeatureCollection itself so the registered set can't drift from what the + /// layer actually references. The status/hop/hex are parsed back out of the + /// (controlled, underscore-free) image name to re-derive colour + radius. + Future _ensureRepeaterChipImages( + Map featureCollection) async { + if (_mapController == null) return; + final features = + (featureCollection['features'] as List?) ?? const []; + var baked = 0; + for (final f in features) { + final props = (f as Map)['properties'] as Map?; + final name = props?['iconImage'] as String?; + if (name == null || + !name.startsWith('repchip_') || + _registeredChipImages.contains(name)) { + continue; + } + // repchip_{status}_{hop}_{hex} — status/hex are underscore-free. + final parts = name.split('_'); + if (parts.length != 4) continue; + final status = parts[1]; + final hop = int.tryParse(parts[2]) ?? 1; + final hex = parts[3]; + try { + final bytes = await _renderRepeaterChipPng( + hex, + _repeaterStatusColor(status), + _repeaterBorderRadius(hop), + ); + await _mapController!.addImage(name, bytes); + _registeredChipImages.add(name); + baked++; + } catch (e) { + debugError('[MAP] render/addImage(repeater chip $name) failed: $e'); + } + } + if (baked > 0) { + debugLog('[MAP] Baked $baked new repeater chip image(s); ' + '${_registeredChipImages.length} total registered'); + } + } + /// Converts a Flutter [Color] to a `#RRGGBB` (or `#RRGGBBAA`) hex string /// for MapLibre symbol/line properties (which take CSS-style color strings). String _colorToHex(Color color, {bool includeAlpha = false}) { @@ -2776,6 +2960,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final duplicates = _getDuplicateRepeaterIds(visible); final hopOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; + // Detailed (gsize 100) is un-clustered, so each feature references a baked + // chip image (hex baked in). Simplified reuses the 12 shared shape images + // + a text-field hex label. See _setupRepeaterClusterLayers. + final detailed = appState.preferences.coverageGridSize == 100; final focusActive = _focusedPingLocation != null; // While a spider is open, repeaters in the spread set are tagged with // `inSpider:true`. The individual symbol layer's filter excludes those @@ -2801,8 +2989,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { : effectiveBytes == 2 ? 2 : 1; - final iconImage = _MapImages.repeater(statusKey, shapeBytes); final hex = repeater.displayHexId(overrideHopBytes: hopOverride); + // Detailed: per-(status,hop,hex) baked chip (hex baked in); Simplified: + // shared shape image + a text-field hex label. _ensureRepeaterChipImages + // registers the chip lazily before the source is pushed. + final iconImage = detailed + ? _MapImages.repeaterChip(statusKey, shapeBytes, hex) + : _MapImages.repeater(statusKey, shapeBytes); final colorHex = _colorToHex(_repeaterStatusColor(statusKey)); features.add({ @@ -2828,11 +3021,24 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return {'type': 'FeatureCollection', 'features': features}; } - /// Creates the cluster-enabled GeoJSON source and three rendering layers - /// (individual symbols, cluster bubble circles, cluster count text). Called - /// once per style load AFTER images are registered (the individual symbol - /// layer references the registered icon names via a data-driven expression). - Future _setupRepeaterClusterLayers() async { + /// Creates the repeater GeoJSON source and its rendering layers (individual + /// symbols, cluster bubble circles, cluster count text, spider line/symbols). + /// Called once per style load AFTER images are registered, and again whenever + /// Grid Mode changes (the `cluster` flag is fixed at source creation, so the + /// source must be rebuilt to toggle it). + /// + /// [clustered] follows Grid Mode: Simplified (gsize 300) clusters; Detailed + /// (gsize 100) does NOT — every repeater renders individually. The bubble / + /// count / spider layers are still created when un-clustered, but stay inert + /// (no `point_count` features ever exist, the spider source stays empty) so + /// the layer ids that other code paths reference — `queryRenderedFeatures`, + /// the region-border `belowLayerId` — remain valid in both modes. + /// + /// Styling differs by mode: clustered uses the cheap shared-glyph `textField` + /// hex label (clustering guarantees ≥50px spacing, so labels never overlap); + /// un-clustered uses the baked-in-icon chip (no `textField`) so an overlapping + /// chip's label can't detach onto a neighbour's box. + Future _setupRepeaterClusterLayers({required bool clustered}) async { if (_mapController == null) return; // Idempotent: tear down any existing source/layers from a previous style load. @@ -2855,8 +3061,39 @@ class _MapWidgetState extends State with WidgetsBindingObserver { await _mapController!.removeSource(_spiderSourceId); } catch (_) {} - // Empty source with cluster enabled. We'll push real data via setGeoJsonSource - // from _syncRepeaterSymbols whenever the marker data version changes. + // Shared symbol styling for the individual layer AND the spider symbol + // layer (the spider comment requires they look identical). Selected once by + // mode: Simplified keeps the shared-glyph text-field hex; Detailed bakes the + // hex into the chip image (no text-field, no iconColor — colour is baked in) + // so overlapping un-clustered chips can't have a label detach. iconSize 1.0 + // matches the distance-label baked-icon convention (DPR-3 PNG, centre + // anchor); the Simplified 48×28 shape keeps its existing 1.4 scale. + final SymbolLayerProperties repeaterSymbolProps = clustered + ? const SymbolLayerProperties( + iconImage: ['get', 'iconImage'], + iconColor: ['get', 'color'], + iconSize: 1.4, + iconAllowOverlap: true, + iconIgnorePlacement: true, + textField: ['get', 'hex'], + textColor: '#FFFFFF', + textHaloColor: '#000000', + textHaloWidth: 1.5, + textSize: 13, + textAllowOverlap: true, + textIgnorePlacement: true, + textFont: _defaultFontStack, + ) + : const SymbolLayerProperties( + iconImage: ['get', 'iconImage'], + iconSize: 1.0, + iconAllowOverlap: true, + iconIgnorePlacement: true, + ); + + // Empty source. We'll push real data via setGeoJsonSource from + // _syncRepeaterSymbols whenever the marker data version changes. `cluster` + // is fixed at creation, so a Grid Mode change rebuilds this whole source. // // IMPORTANT: pass `data` as a Dart Map (NOT jsonEncode-d string). The iOS // plugin's `buildShapeSource` assumes that if `data` is a String, it must be @@ -2865,12 +3102,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { try { await _mapController!.addSource( _repeaterSourceId, - const GeojsonSourceProperties( - data: { + GeojsonSourceProperties( + data: const { 'type': 'FeatureCollection', 'features': [] }, - cluster: true, + // Simplified clusters; Detailed shows every repeater individually. + cluster: clustered, clusterRadius: 50, // Cluster at every reachable zoom (max user zoom is 17). Stacked // markers — those within `clusterRadius` pixels at the current @@ -2878,7 +3116,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // into a pile of overlapping individual symbols when the user is // zoomed all the way in. Tap a cluster → spiderfy. Markers far // enough apart that they exceed `clusterRadius` pixels at higher - // zooms still separate into individuals naturally on zoom. + // zooms still separate into individuals naturally on zoom. Inert + // when cluster is false. clusterMaxZoom: 17, ), ); @@ -2895,21 +3134,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { await _mapController!.addSymbolLayer( _repeaterSourceId, _repeaterIndividualLayerId, - const SymbolLayerProperties( - iconImage: ['get', 'iconImage'], - iconColor: ['get', 'color'], - iconSize: 1.4, - iconAllowOverlap: true, - iconIgnorePlacement: true, - textField: ['get', 'hex'], - textColor: '#FFFFFF', - textHaloColor: '#000000', - textHaloWidth: 1.5, - textSize: 13, - textAllowOverlap: true, - textIgnorePlacement: true, - textFont: _defaultFontStack, - ), + repeaterSymbolProps, filter: [ 'all', [ @@ -3022,21 +3247,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { await _mapController!.addSymbolLayer( _spiderSourceId, _spiderSymbolLayerId, - const SymbolLayerProperties( - iconImage: ['get', 'iconImage'], - iconColor: ['get', 'color'], - iconSize: 1.4, - iconAllowOverlap: true, - iconIgnorePlacement: true, - textField: ['get', 'hex'], - textColor: '#FFFFFF', - textHaloColor: '#000000', - textHaloWidth: 1.5, - textSize: 13, - textAllowOverlap: true, - textIgnorePlacement: true, - textFont: _defaultFontStack, - ), + repeaterSymbolProps, filter: [ '==', ['geometry-type'], @@ -3071,6 +3282,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } try { final geojson = _buildRepeaterFeatureCollection(appState); + // Detailed mode references baked per-chip images — make sure every one is + // registered before pushing, or the symbol layer would reference a + // missing icon. No-op in Simplified (features use the pre-registered + // shape images). Driven off the FeatureCollection, so it can't drift. + if (appState.preferences.coverageGridSize == 100) { + await _ensureRepeaterChipImages(geojson); + } await _mapController!.setGeoJsonSource(_repeaterSourceId, geojson); } catch (e) { debugError('[MAP] Failed to update repeater source: $e'); From 8eadf84b6156f97b2d099b76ce1505f410b63768 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 16 Jun 2026 22:31:41 -0400 Subject: [PATCH 067/100] fix: reword coverage mode, fix map draw from backgrounded --- lib/screens/settings_screen.dart | 8 ++++---- lib/widgets/map_widget.dart | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 83a8b08..7314dcf 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -254,8 +254,8 @@ class _SettingsScreenState extends State { leading: const Icon(Icons.grid_on), title: const Text('Grid Mode'), subtitle: Text(prefs.coverageGridSize == 100 - ? 'Detailed (100 m cells)' - : 'Simplified (300 m cells)'), + ? 'Detailed (More detailed cells, non grouped repeaters)' + : 'Simplified (Merged cells, grouped repeaters)'), trailing: const Icon(Icons.chevron_right), onTap: () => _showCoverageGridSelector(context, appState), ), @@ -1310,8 +1310,8 @@ class _SettingsScreenState extends State { void _showCoverageGridSelector( BuildContext context, AppStateProvider appState) { final options = [ - (300, 'Simplified', '300 m cells — the default, matches the web map'), - (100, 'Detailed', '100 m cells with blob smoothing — finer detail'), + (300, 'Simplified', 'Merged cells, grouped repeaters'), + (100, 'Detailed', 'More detailed cells, non grouped repeaters'), ]; showModalBottomSheet( context: context, diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 3714543..5f12afb 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -734,6 +734,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (mounted) { _cameraAnimationReady = true; _recoverCoverageOverlayIfNeeded(); + // Markers that arrived while backgrounded were skipped because the + // build() marker-sync is gated on _cameraAnimationReady (and left + // _lastMarkerDataVersion stale). Now that the gate is open, force a + // rebuild so build() detects the version diff and syncs the + // accumulated pins — otherwise they don't render until the next + // mapRevision bump (the next ping). + setState(() {}); } }); } From 5d7d3d3dae00dbdcc9e108c7b38150761f0ae968 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 17 Jun 2026 22:30:33 -0400 Subject: [PATCH 068/100] feat: in-app cell GRID SUMMARY + repeater popup; full-hex token resolution for cell MAX-DIST Brings the web's coverage "Cell Click" (GRID SUMMARY: per-status counts, AVG SNR/NOISE, MAX DIST, proportional bar graph) and "Repeater Click" (online status, fingerprint, hop bytes, schedule + clock-skew warning, first heard, max range, BIDIR/TX/RX/DISC/DEAD totals) into the app, with client-side aggregation mirroring the web. Coverage is fetched lazily via the key-gated app_coverage.php proxy. Cell MAX-DIST (GridSummary) resolves tokens via _repForToken (full-hex / narrowCandidates) instead of a bare id lookup, so it stays correct as the server normalizes coverage path tokens to a wider-than-id canonical hex. - lib/utils/coverage_summary.dart: GridSummary / RepeaterStats / RepeaterLookup / GridCell; _repForToken full-hex resolver. - lib/utils/repeater_format.dart, distance_formatter.dart: date / clock-skew / coverage-distance formatting. - lib/widgets/cell_summary_sheet.dart, map_widget.dart: cell-tap + repeater detail sheets. - lib/services/api_service.dart, providers/app_state_provider.dart: coverage fetchers. lib/models/repeater.dart: time_offset. - tests in test/utils/. --- lib/models/repeater.dart | 19 + lib/providers/app_state_provider.dart | 35 ++ lib/services/api_service.dart | 90 +++++ lib/utils/coverage_summary.dart | 523 ++++++++++++++++++++++++++ lib/utils/distance_formatter.dart | 14 + lib/utils/repeater_format.dart | 66 ++++ lib/widgets/cell_summary_sheet.dart | 300 +++++++++++++++ lib/widgets/map_widget.dart | 319 ++++++++++++++-- test/utils/coverage_summary_test.dart | 254 +++++++++++++ test/utils/repeater_format_test.dart | 68 ++++ 10 files changed, 1650 insertions(+), 38 deletions(-) create mode 100644 lib/utils/coverage_summary.dart create mode 100644 lib/utils/repeater_format.dart create mode 100644 lib/widgets/cell_summary_sheet.dart create mode 100644 test/utils/coverage_summary_test.dart create mode 100644 test/utils/repeater_format_test.dart diff --git a/lib/models/repeater.dart b/lib/models/repeater.dart index 8fafe93..8d5a620 100644 --- a/lib/models/repeater.dart +++ b/lib/models/repeater.dart @@ -42,6 +42,11 @@ class Repeater { /// Number of bytes per hop hash for this repeater's path (1, 2, or 3) final int hopBytes; + /// Repeater clock skew in seconds reported by the server (+ve = repeater + /// clock behind real time, -ve = ahead), or null when unknown. Drives the + /// "time is not set correctly" warning in the detail sheet. + final int? timeOffset; + const Repeater({ required this.id, required this.hexId, @@ -54,6 +59,7 @@ class Repeater { this.createdAt, this.staleTime, this.hopBytes = 1, + this.timeOffset, }); /// Parse from JSON object in repeaters.json @@ -76,6 +82,17 @@ class Repeater { staleTime = int.tryParse(rawStaleTime); } + // Parse time_offset (repeater clock skew, seconds) which may be int or String + int? timeOffset; + final rawTimeOffset = json['time_offset']; + if (rawTimeOffset is int) { + timeOffset = rawTimeOffset; + } else if (rawTimeOffset is num) { + timeOffset = rawTimeOffset.toInt(); + } else if (rawTimeOffset is String) { + timeOffset = int.tryParse(rawTimeOffset); + } + return Repeater( id: json['id'] as String, hexId: json['hex_id'] as String? ?? '', @@ -88,6 +105,7 @@ class Repeater { createdAt: createdAt, staleTime: staleTime, hopBytes: (json['hop_bytes'] as int?) ?? 1, + timeOffset: timeOffset, ); } @@ -104,6 +122,7 @@ class Repeater { 'created_at': createdAt, 'stale_time': staleTime, 'hop_bytes': hopBytes, + 'time_offset': timeOffset, }; } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index c8d526c..62ba715 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -856,6 +856,41 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Repeater markers getters List get repeaters => List.unmodifiable(_repeaters); + /// Lazy tap-to-inspect: fetch raw coverage points for a clicked map cell from + /// the current zone's app endpoint. Returns `[]` when there is no zone or on + /// failure. The caller aggregates these into a GRID SUMMARY (read-only — no + /// state mutation, so no `notifyListeners`). + Future>> fetchCellCoverage({ + required double lat, + required double lon, + required double radiusMeters, + }) { + final zone = zoneCode; + if (zone == null || zone.isEmpty) { + return Future.value(const >[]); + } + return _apiService.fetchMapData( + zone: zone, + lat: lat, + lon: lon, + radiusMeters: radiusMeters, + ); + } + + /// Lazy tap-to-inspect: fetch the coverage points referencing a repeater + /// (hex-prefix superset) from the current zone's app endpoint. Returns `[]` + /// when there is no zone or on failure. The caller aggregates these into the + /// repeater's BIDIR/TX/RX/DISC/DEAD totals + max range. + Future>> fetchRepeaterCoveragePoints({ + required String prefix, + }) { + final zone = zoneCode; + if (zone == null || zone.isEmpty) { + return Future.value(const >[]); + } + return _apiService.fetchRepeaterCoverage(zone: zone, prefix: prefix); + } + /// Regional boundary polygons loaded from the /border API. /// Each entry is a `{code: String, polygon: List>}` map where /// `polygon` holds `[lat, lon]` pairs in the server's original order. diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 12893da..58c1c78 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1074,6 +1074,96 @@ class ApiService { } } + /// Fetch raw coverage points for a clicked map cell, for the tap-to-inspect + /// GRID SUMMARY. Posts to the region's app-facing endpoint + /// (`app_coverage.php` → `api.php` `map_data`); the app aggregates the points + /// client-side (see `coverage_summary.dart`). Returns `[]` on any failure. + Future>> fetchMapData({ + required String zone, + required double lat, + required double lon, + required double radiusMeters, + }) { + return _fetchCoveragePoints( + zone: zone, + label: 'map_data', + body: { + 'request': 'map_data', + 'lat': lat, + 'lon': lon, + 'radius': radiusMeters, + }, + ); + } + + /// Fetch the coverage points referencing a repeater (a hex-prefix superset), + /// for the repeater detail sheet's BIDIR/TX/RX/DISC/DEAD totals + max range. + /// Posts to `app_coverage.php` → `api.php` `repeater_coverage`. Returns `[]` + /// on any failure. + Future>> fetchRepeaterCoverage({ + required String zone, + required String prefix, + }) { + return _fetchCoveragePoints( + zone: zone, + label: 'repeater_coverage', + body: { + 'request': 'repeater_coverage', + 'prefix': prefix, + }, + ); + } + + /// Shared POST to `https://.meshmapper.net/app_coverage.php` with the app + /// key in the JSON body. Returns a list of point maps, or `[]` on any failure. + Future>> _fetchCoveragePoints({ + required String zone, + required String label, + required Map body, + }) async { + final z = zone.toLowerCase(); + final url = Uri.parse('https://$z.meshmapper.net/app_coverage.php'); + final sw = Stopwatch()..start(); + debugLog('[COVERAGE] POST /app_coverage.php ($label, zone $z)'); + try { + final response = await _client + .post( + url, + headers: {'Content-Type': 'application/json'}, + body: json.encode({'key': apiKey, ...body}), + ) + .timeout(const Duration(seconds: 15)); + final secs = (sw.elapsedMilliseconds / 1000).toStringAsFixed(2); + + if (response.statusCode != 200) { + final snippet = response.body.isEmpty + ? '(empty)' + : (response.body.length > 200 + ? response.body.substring(0, 200) + : response.body); + debugWarn( + '[COVERAGE] $label HTTP ${response.statusCode} in ${secs}s: $snippet'); + return []; + } + + final decoded = json.decode(response.body); + if (decoded is! List) { + debugWarn('[COVERAGE] $label: unexpected response (not a JSON list)'); + return []; + } + final points = decoded + .whereType() + .map((e) => Map.from(e)) + .toList(); + debugLog('[COVERAGE] $label OK in ${secs}s: ${points.length} points'); + return points; + } catch (e) { + debugWarn( + '[COVERAGE] $label POST failed in ${(sw.elapsedMilliseconds / 1000).toStringAsFixed(2)}s: $e'); + return []; + } + } + /// Submit wardrive data using an explicit session ID (for offline uploads) /// Does NOT read/write shared _sessionId, _sessionExpiresAt, or heartbeat state Future?> submitWardriveDataWithSessionId( diff --git a/lib/utils/coverage_summary.dart b/lib/utils/coverage_summary.dart new file mode 100644 index 0000000..f30d065 --- /dev/null +++ b/lib/utils/coverage_summary.dart @@ -0,0 +1,523 @@ +import 'dart:math' as math; + +import '../models/repeater.dart'; + +// Client-side coverage aggregation, ported from the web client (dev/index.php) +// so the app's cell GRID SUMMARY and repeater totals stay in parity with the web. +// +// Both views fetch raw coverage points (map_data / repeater_coverage, via +// app_coverage.php) and aggregate here — exactly as generateSummaryContent +// (:13345) and renderRepeaterChart (:14063) do in the browser. +// +// Coverage status codes: 1=BIDIR, 2=TX, 5=RX, 3=DEAD, 0=DROP, 6|7=DISC. + +// --- token + coord helpers (ports of the web equivalents) ------------------- + +/// Matches `hex(?)(snr)[lat,lon]` repeater path tokens. Groups: 1=hex, 2=`?`, +/// 3=snr, 4=`lat,lon`. Mirrors the web regex used in both functions. +final RegExp _tokenRe = + RegExp(r'([a-f0-9]+)(\?)?(?:\((.*?)\))?(?:\[(.*?)\])?', caseSensitive: false); +final RegExp _hexOnlyRe = RegExp(r'[^a-fA-F0-9]'); +final RegExp _snrParenRe = RegExp(r'\(([\d.-]+)\)'); +final RegExp _coordBracketRe = RegExp(r'\[([\d.-]+),([\d.-]+)\]'); + +double _haversineMeters(double lat1, double lon1, double lat2, double lon2) { + const r = 6371000.0; // same earth radius as Leaflet map.distance + coordsWithin100m + final dLat = (lat2 - lat1) * math.pi / 180; + final dLon = (lon2 - lon1) * math.pi / 180; + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(lat1 * math.pi / 180) * + math.cos(lat2 * math.pi / 180) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); +} + +/// Port of `coordsWithin100m` (`dev/index.php:6198`). +bool _within100m(double lat1, double lon1, double lat2, double lon2) => + _haversineMeters(lat1, lon1, lat2, lon2) <= 100; + +int _toInt(dynamic v) { + if (v is int) return v; + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v) ?? -999; + return -999; +} + +double? _toDouble(dynamic v) { + if (v is num) return v.toDouble(); + if (v is String) return double.tryParse(v); + return null; +} + +/// The coverage grid cell containing a coordinate, plus helpers to snap the +/// cell-summary fetch to the cell centre and filter pings to the cell — so the +/// summary is identical no matter where inside the cell the user taps (parity +/// with the web's `lazyShowPingsAt`). Steps come from `kCoverageGridSteps` +/// (mvt_cells.dart), byte-identical to the server grid (dev/coverage_cells.php). +class GridCell { + final int i; + final int j; + final double latStep; + final double lonStep; + const GridCell(this.i, this.j, this.latStep, this.lonStep); + + factory GridCell.containing( + double lat, double lon, double latStep, double lonStep) => + GridCell( + (lat / latStep).floor(), (lon / lonStep).floor(), latStep, lonStep); + + double get centerLat => (i + 0.5) * latStep; + double get centerLon => (j + 0.5) * lonStep; + + bool contains(double lat, double lon) => + (lat / latStep).floor() == i && (lon / lonStep).floor() == j; + + /// Keep only points whose own cell is this one (parses lat/lon from the raw map). + List> filter(List> points) { + return points.where((p) { + final la = _toDouble(p['lat']); + final lo = _toDouble(p['lon']); + return la != null && lo != null && contains(la, lo); + }).toList(); + } +} + +// --- repeater lookup (ports repObj / repeaterByHex / repeaterByFullHex) ------ + +class _RepInfo { + final String id; // short hex id, lowercased + final String hexId; // full hex, cleaned + lowercased + final double lat; + final double lon; + final int status; // enabled: 1 active, 2 ambiguous/excluded + final bool hidden; + const _RepInfo(this.id, this.hexId, this.lat, this.lon, this.status, this.hidden); +} + +/// Indexes the loaded repeaters the same three ways the web does: by id +/// (`repeaterLocs`), by full hex (`repeaterByFullHex`), and by short-hex bucket +/// (`repeaterByHex`), plus `narrowCandidates`/`prefixLen` for token resolution. +class RepeaterLookup { + final int prefixLen; + final Map _byId = {}; + final Map _byFullHex = {}; + final Map> _byShortHex = {}; + + RepeaterLookup._(this.prefixLen); + + /// [hopBytes] is the region path width (web `hopBytes`); `prefixLen = hopBytes*2`. + factory RepeaterLookup.fromRepeaters(Iterable repeaters, + {required int hopBytes}) { + final prefixLen = math.max(2, hopBytes * 2); + final lk = RepeaterLookup._(prefixLen); + for (final r in repeaters) { + // web adds enabled IN (1,2) to the lookup + if (r.enabled != 1 && r.enabled != 2) continue; + if (r.lat.isNaN || r.lon.isNaN) continue; + // Web parity (index.php:10231 `if (rep.lat && rep.lon && rep.id)`): a 0 + // lat/lon is the "location not published" sentinel — exclude it, or MAX + // DIST / max range would be computed to (0,0) (~8900 km from Ottawa). + if (r.lat == 0 || r.lon == 0 || r.id.isEmpty) continue; + final cleanHex = r.hexId.replaceAll(_hexOnlyRe, '').toLowerCase(); + final id = r.id.toLowerCase(); + // Web parity (index.php:10239): hidden only when the 🚫 marker is at the + // start or end of the name — NOT anywhere in the middle. + final hidden = r.name.startsWith('🚫') || r.name.endsWith('🚫'); + final info = _RepInfo(id, cleanHex, r.lat, r.lon, r.enabled, hidden); + lk._byId[id] = info; + final shortHex = + cleanHex.length >= prefixLen ? cleanHex.substring(0, prefixLen) : cleanHex; + if (shortHex.length >= 2) { + (lk._byShortHex[shortHex] ??= <_RepInfo>[]).add(info); + final firstByte = cleanHex.length >= 2 ? cleanHex.substring(0, 2) : ''; + if (firstByte.length == 2 && firstByte != shortHex) { + (lk._byShortHex[firstByte] ??= <_RepInfo>[]).add(info); + } + } + if (cleanHex.isNotEmpty) lk._byFullHex[cleanHex] = info; + } + return lk; + } + + /// Port of `narrowCandidates` (`dev/index.php:9709`): greedy narrowing on a + /// longer id, backward-compat widening on a shorter id. + List<_RepInfo> _narrowCandidates(List<_RepInfo> candidates, String id) { + final idLower = id.toLowerCase(); + if (candidates.length > 1 && id.length > prefixLen) { + final narrowed = candidates + .where((rep) => + rep.hexId.length >= id.length && + rep.hexId.substring(0, id.length) == idLower) + .toList(); + if (narrowed.isNotEmpty) return narrowed; + } + if (candidates.isEmpty && id.isNotEmpty && id.length < prefixLen) { + final results = <_RepInfo>[]; + _byShortHex.forEach((key, reps) { + if (key.length >= idLower.length && + key.substring(0, idLower.length) == idLower) { + for (final rep in reps) { + if (!results.contains(rep)) results.add(rep); + } + } + }); + if (results.isNotEmpty) return results; + } + return candidates; + } + + /// Resolve a token hex to candidate repeaters, mirroring the web order: + /// full-hex, else narrowed short-hex bucket, else `repeaterLocs[id]`. + List<_RepInfo> _candidatesFor(String rid) { + final full = _byFullHex[rid]; + if (full != null) return <_RepInfo>[full]; + final short = rid.length >= prefixLen ? rid.substring(0, prefixLen) : rid; + final cands = _narrowCandidates(_byShortHex[short] ?? const <_RepInfo>[], rid); + if (cands.isEmpty) { + final loc = _byId[rid]; + if (loc != null) return <_RepInfo>[loc]; + } + return cands; + } + + /// Resolve a token hex (any width — old narrow OR new 4-byte-capped) to its single repeater, + /// or null. Mirrors the web `repObjForToken`: full-hex, else a UNIQUE short-hex/narrowed match, + /// else the legacy id key. Tokens normalize WIDER than `repeater.id`, so a bare `_byId[token]` + /// lookup misses — the cell MAX-DIST must resolve by full-hex. + _RepInfo? _repForToken(String rid) { + final full = _byFullHex[rid]; + if (full != null) return full; + final short = rid.length >= prefixLen ? rid.substring(0, prefixLen) : rid; + final cands = _narrowCandidates(_byShortHex[short] ?? const <_RepInfo>[], rid); + if (cands.length == 1) return cands.first; + return _byId[rid]; + } +} + +// --- cell GRID SUMMARY ------------------------------------------------------- + +/// Aggregated stats for a clicked map cell. Port of `generateSummaryContent` +/// (`dev/index.php:13345-13521`). +class GridSummary { + final int total; + final int bidir; + final int tx; + final int rx; + final int disc; + final int dead; + final int drop; + final double? avgSnr; // null = N/A + final int? avgNoise; // rounded dBm; null = N/A + final double? maxDistMeters; // null = N/A + + const GridSummary({ + required this.total, + required this.bidir, + required this.tx, + required this.rx, + required this.disc, + required this.dead, + required this.drop, + required this.avgSnr, + required this.avgNoise, + required this.maxDistMeters, + }); + + /// Signal bucket driving the AVG SNR icon/colour: `good` (>5), `medium` (>=-1), + /// `bad` (<-1), or null when AVG SNR is N/A (web thresholds at `:13456`). + String? get snrBucket { + if (avgSnr == null) return null; + final v = double.parse(avgSnr!.toStringAsFixed(1)); + if (v > 5) return 'good'; + if (v >= -1) return 'medium'; + return 'bad'; + } + + factory GridSummary.fromPoints( + List> points, RepeaterLookup lookup) { + int bidir = 0, tx = 0, rx = 0, dead = 0, drop = 0, disc = 0; + double totalSnr = 0; + int countSnr = 0; + double totalNoise = 0; + int countNoise = 0; + double maxDist = 0; + + for (final p in points) { + final s = _toInt(p['status']); + if (s == 1) { + bidir++; + } else if (s == 2) { + tx++; + } else if (s == 5) { + rx++; + } else if (s == 3) { + dead++; + } else if (s == 0) { + drop++; + } else if (s == 6 || s == 7) { + disc++; + } + + // SNR: local_snr, else max SNR parsed from heard_repeats "(x)" tokens. + double? pingSnr = _toDouble(p['local_snr']); + if (pingSnr == null) { + final hr = p['heard_repeats']; + if (hr is String && hr.isNotEmpty && hr != 'None') { + double maxS = -999; + bool found = false; + for (final part in hr.split(',')) { + final m = _snrParenRe.firstMatch(part); + if (m != null) { + final v = double.tryParse(m.group(1)!); + if (v != null) { + if (v > maxS) maxS = v; + found = true; + } + } + } + if (found) pingSnr = maxS; + } + } + if (pingSnr != null && !pingSnr.isNaN) { + totalSnr += pingSnr; + countSnr++; + } + + // Noise. + final nf = _toDouble(p['noisefloor']); + if (nf != null && !nf.isNaN) { + totalNoise += nf; + countNoise++; + } + + // Max range: token coords matched to a repeater (by id) within 100 m. + final ids = []; + final pLat = _toDouble(p['lat']); + final pLon = _toDouble(p['lon']); + + void collectFrom(String text) { + for (final m in _tokenRe.allMatches(text)) { + if (m.group(2) == '?' || m.group(4) == null) continue; + final c = m.group(4)!.split(','); + if (c.length < 2) continue; + final tLat = double.tryParse(c[0]); + final tLon = double.tryParse(c[1]); + final rID = m.group(1)!.toLowerCase(); + final loc = lookup._repForToken(rID); + if (loc != null && + tLat != null && + tLon != null && + _within100m(loc.lat, loc.lon, tLat, tLon)) { + ids.add(m.group(1)!); + } + } + } + + final hr = p['heard_repeats']; + if (hr is String && hr.isNotEmpty && hr != 'None') collectFrom(hr); + + final via = p['via']; + if (via is String && + via.isNotEmpty && + via != 'Direct' && + !via.contains('Wardriving')) { + final cleanVia = via + .replaceAll(RegExp(r'\bDirect\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bNone\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bN/A\b', caseSensitive: false), ''); + collectFrom(cleanVia); + } + + // DISC full-id check via public_key. + if (s == 6 && p['public_key'] is String) { + final pkClean = + (p['public_key'] as String).replaceAll(_hexOnlyRe, '').toLowerCase(); + final targetRep = lookup._byFullHex[pkClean]; + if (targetRep != null) { + final hrStr = hr is String ? hr : ''; + final cm = _coordBracketRe.firstMatch(hrStr); + if (cm != null) { + final tLat = double.tryParse(cm.group(1)!); + final tLon = double.tryParse(cm.group(2)!); + if (tLat != null && + tLon != null && + _within100m(targetRep.lat, targetRep.lon, tLat, tLon)) { + ids.add(targetRep.id); + } + } + } + } + + for (final rID in ids) { + final loc = lookup._repForToken(rID.toLowerCase()); + if (loc == null) continue; + if (loc.status == 2 || loc.hidden) continue; + if (pLat != null && pLon != null) { + final d = _haversineMeters(pLat, pLon, loc.lat, loc.lon); + if (d > maxDist) maxDist = d; + } + } + } + + return GridSummary( + total: points.length, + bidir: bidir, + tx: tx, + rx: rx, + disc: disc, + dead: dead, + drop: drop, + avgSnr: countSnr > 0 ? totalSnr / countSnr : null, + avgNoise: countNoise > 0 ? (totalNoise / countNoise).round() : null, + maxDistMeters: maxDist > 0 ? maxDist : null, + ); + } +} + +// --- per-repeater totals ----------------------------------------------------- + +/// Per-repeater BIDIR/TX/RX/DISC/DEAD counts + max range. Port of the attribution +/// in `renderRepeaterChart` (`dev/index.php:14063-14322`). +class RepeaterStats { + final int bidir; + final int tx; + final int rx; + final int disc; + final int dead; + final double? maxRangeMeters; + + const RepeaterStats({ + required this.bidir, + required this.tx, + required this.rx, + required this.disc, + required this.dead, + required this.maxRangeMeters, + }); + + int get totalMatched => bidir + tx + rx + disc + dead; + + factory RepeaterStats.fromCoverage( + List> points, Repeater target, RepeaterLookup lookup, + {bool disableDupLogic = false}) { + final repId = target.id.toLowerCase(); + final repLat = target.lat; + final repLon = target.lon; + int bidir = 0, tx = 0, rx = 0, disc = 0, dead = 0; + double maxRange = 0; + + bool tokenMatchesTarget(RegExpMatch m) { + if (!disableDupLogic && m.group(2) == '?') return false; + final rID = m.group(1)!.toLowerCase(); + final candidates = lookup._candidatesFor(rID); + if (!candidates.any((c) => c.id == repId)) return false; + final coords = m.group(4); + if (coords != null) { + final c = coords.split(','); + if (c.length < 2) return false; + final cLat = double.tryParse(c[0]); + final cLon = double.tryParse(c[1]); + return cLat != null && + cLon != null && + _within100m(repLat, repLon, cLat, cLon); + } + return disableDupLogic; // no coords: only valid when dup-logic disabled + } + + for (final p in points) { + final pLat = _toDouble(p['lat']); + final pLon = _toDouble(p['lon']); + if (pLat == null || pLon == null) continue; + + final status = _toInt(p['status']); + final String type; + if (status == 1) { + type = 'BIDIR'; + } else if (status == 2) { + type = 'TX'; + } else if (status == 5) { + type = 'RX'; + } else if (status == 6 || status == 7) { + type = 'DISC'; + } else if (status == 3) { + type = 'DEAD'; + } else { + continue; // DROP / unknown -> skip (matches web) + } + + bool matched = false; + + final hr = p['heard_repeats']; + if (hr is String && hr.isNotEmpty && hr != 'None') { + for (final m in _tokenRe.allMatches(hr)) { + if (tokenMatchesTarget(m)) matched = true; + } + } + + final via = p['via']; + if (via is String && + via.isNotEmpty && + via != 'Direct' && + !via.contains('Wardriving')) { + final cleanVia = via + .replaceAll(RegExp(r'\bDirect\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bNone\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bN/A\b', caseSensitive: false), ''); + for (final m in _tokenRe.allMatches(cleanVia)) { + if (tokenMatchesTarget(m)) matched = true; + } + } + + if (status == 6 && p['public_key'] is String) { + final pk = + (p['public_key'] as String).replaceAll(_hexOnlyRe, '').toLowerCase(); + final tr = lookup._byFullHex[pk]; + if (tr != null && tr.id == repId) { + final hrStr = hr is String ? hr : ''; + final cm = _coordBracketRe.firstMatch(hrStr); + if (cm != null) { + final cLat = double.tryParse(cm.group(1)!); + final cLon = double.tryParse(cm.group(2)!); + if (cLat != null && + cLon != null && + _within100m(repLat, repLon, cLat, cLon)) { + matched = true; + } + } + } + } + + if (matched) { + switch (type) { + case 'BIDIR': + bidir++; + break; + case 'TX': + tx++; + break; + case 'RX': + rx++; + break; + case 'DISC': + disc++; + break; + case 'DEAD': + dead++; + break; + } + final d = _haversineMeters(pLat, pLon, repLat, repLon); + if (d > maxRange) maxRange = d; + } + } + + return RepeaterStats( + bidir: bidir, + tx: tx, + rx: rx, + disc: disc, + dead: dead, + maxRangeMeters: maxRange > 0 ? maxRange : null, + ); + } +} diff --git a/lib/utils/distance_formatter.dart b/lib/utils/distance_formatter.dart index 63cfc7d..d8fab1a 100644 --- a/lib/utils/distance_formatter.dart +++ b/lib/utils/distance_formatter.dart @@ -11,6 +11,20 @@ String formatMeters(double meters, {bool isImperial = false}) { return '${meters.toStringAsFixed(0)}m'; } +/// Web-parity distance string for coverage popups (GRID SUMMARY "MAX DIST" and +/// the repeater "Max Range"): "123.26 km" / "150 m" (metric) or "76.55 mi" / +/// "492 ft" (imperial). Mirrors `formatDistance` (dev/index.php:6205) — note the +/// space and 2-decimal km/mi, which differ from [formatMeters]/[formatKilometers]. +String formatCoverageDistance(double meters, {bool isImperial = false}) { + if (isImperial) { + final feet = meters * 3.28084; + if (feet >= 5280) return '${(feet / 5280).toStringAsFixed(2)} mi'; + return '${feet.round()} ft'; + } + if (meters >= 1000) return '${(meters / 1000).toStringAsFixed(2)} km'; + return '${meters.round()} m'; +} + /// Format a distance in kilometers for display /// Returns string like "2.5km" (metric) or "1.6mi" (imperial) String formatKilometers(double kilometers, {bool isImperial = false}) { diff --git a/lib/utils/repeater_format.dart b/lib/utils/repeater_format.dart new file mode 100644 index 0000000..692fe65 --- /dev/null +++ b/lib/utils/repeater_format.dart @@ -0,0 +1,66 @@ +/// Date / clock-skew formatters ported from the web client (`dev/index.php`) so the +/// app's repeater detail sheet matches the web popup text exactly. +/// +/// The web stores timestamps as either Unix seconds or milliseconds; values below +/// 2e10 are treated as seconds (mirrors `formatDateOnly` / `calculateDaysAgo`). +library; + +DateTime? _toDateTime(num? ts) { + if (ts == null) return null; + var v = ts.toDouble(); + if (v <= 0) return null; + if (v < 20000000000) v = v * 1000; // seconds -> ms (web: `if (ts < 20000000000) ts *= 1000`) + return DateTime.fromMillisecondsSinceEpoch(v.toInt()); +} + +/// `MM/DD/YY` (e.g. `06/16/26`). Port of `formatDateOnly` (`dev/index.php:9907`). +String formatDateOnly(num? ts) { + final d = _toDateTime(ts); + if (d == null) return 'N/A'; + final mm = d.month.toString().padLeft(2, '0'); + final dd = d.day.toString().padLeft(2, '0'); + final yy = (d.year % 100).toString().padLeft(2, '0'); + return '$mm/$dd/$yy'; +} + +/// `Today` / `1 day ago` / `N days ago`. Port of `calculateDaysAgo` +/// (`dev/index.php:9931`) — both timestamps compared at local midnight. +String daysAgo(num? ts) { + final d = _toDateTime(ts); + if (d == null) return ''; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final that = DateTime(d.year, d.month, d.day); + final diff = today.difference(that).inDays; + if (diff == 0) return 'Today'; + if (diff == 1) return '1 day ago'; + return '$diff days ago'; +} + +/// `MM/DD/YY (Today)` style — the date plus its parenthesised age, matching the +/// web's `formatDateOnly(ts) + ' (' + calculateDaysAgo(ts) + ')'`. +String formatDateWithAgo(num? ts) { + if (ts == null) return 'N/A'; + final ago = daysAgo(ts); + return ago.isEmpty ? formatDateOnly(ts) : '${formatDateOnly(ts)} ($ago)'; +} + +/// Human-readable clock skew, e.g. `49.4 minutes ahead`. Port of the warning text +/// in `generateRepeaterPopup` (`dev/index.php:12757`). +/// +/// Returns `null` when the offset is null or within the ±120 s tolerance (no +/// warning shown). `offset > 0` ⇒ repeater clock is *behind*; `< 0` ⇒ *ahead*. +String? humanizeClockSkew(int? offsetSecs) { + if (offsetSecs == null) return null; + final abs = offsetSecs.abs(); + if (abs <= 120) return null; + final String mag; + if (abs >= 86400) { + mag = '${(abs / 86400).toStringAsFixed(1)} days'; + } else if (abs >= 3600) { + mag = '${(abs / 3600).toStringAsFixed(1)} hours'; + } else { + mag = '${(abs / 60).toStringAsFixed(1)} minutes'; + } + return '$mag ${offsetSecs > 0 ? 'behind' : 'ahead'}'; +} diff --git a/lib/widgets/cell_summary_sheet.dart b/lib/widgets/cell_summary_sheet.dart new file mode 100644 index 0000000..90648da --- /dev/null +++ b/lib/widgets/cell_summary_sheet.dart @@ -0,0 +1,300 @@ +import 'package:flutter/material.dart'; + +import '../utils/coverage_summary.dart'; +import '../utils/distance_formatter.dart'; + +/// Bottom sheet showing the coverage "GRID SUMMARY" for a tapped map cell — the +/// 3×3 stat grid + the proportional bar graph with a separator line, mirroring +/// the web popup (`generateSummaryContent`, dev/index.php:13345). Deliberately +/// excludes the web's PING HISTORY list. +class CellSummarySheet extends StatelessWidget { + /// Resolves to the aggregated summary, or null on fetch failure. The caller + /// fetches the cell's points and computes [GridSummary] off the UI thread. + final Future summaryFuture; + final bool isImperial; + + const CellSummarySheet({ + super.key, + required this.summaryFuture, + required this.isImperial, + }); + + // Web GRID SUMMARY palette (dev/index.php getPalette 'none'). + static const Color _bidir = Color(0xFF1E7E34); + static const Color _tx = Color(0xFFFD7E14); + static const Color _rx = Color(0xFF6F42C1); + static const Color _disc = Color(0xFF17A2B8); + static const Color _dead = Color(0xFF6C757D); + static const Color _drop = Color(0xFFBD2130); + static const Color _maxDistBlue = Color(0xFF007BFF); + static const Color _snrGood = Color(0xFF1E7E34); + static const Color _snrMedium = Color(0xFF856404); + static const Color _snrBad = Color(0xFFBD2130); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: EdgeInsets.fromLTRB( + 20, 20, 20, 24 + MediaQuery.of(context).viewPadding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Expanded( + child: Text( + 'GRID SUMMARY', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + letterSpacing: 0.5), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + const SizedBox(height: 8), + FutureBuilder( + future: summaryFuture, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: CircularProgressIndicator()), + ); + } + final s = snap.data; + if (s == null) return _note(context, 'Could not load coverage data.'); + if (s.total == 0) return _note(context, 'No coverage data here.'); + return _buildSummary(context, s); + }, + ), + ], + ), + ); + } + + Widget _buildSummary(BuildContext context, GridSummary s) { + final theme = Theme.of(context); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Total Pings: ', + style: TextStyle( + fontSize: 12, color: theme.colorScheme.onSurfaceVariant), + ), + TextSpan( + text: '${s.total}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ), + const SizedBox(height: 14), + Row( + children: [ + _cell(context, value: '${s.bidir}', label: 'BIDIR', color: _bidir), + _cell(context, value: '${s.tx}', label: 'TX', color: _tx), + _cell(context, value: '${s.rx}', label: 'RX', color: _rx), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + _cell(context, value: '${s.disc}', label: 'DISC', color: _disc), + _cell(context, value: '${s.dead}', label: 'DEAD', color: _dead), + _cell(context, value: '${s.drop}', label: 'DROP', color: _drop), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + _snrCell(context, s), + _cell( + context, + value: s.maxDistMeters != null + ? formatCoverageDistance(s.maxDistMeters!, isImperial: isImperial) + : 'N/A', + label: 'MAX DIST', + color: _maxDistBlue, + ), + _cell( + context, + value: s.avgNoise != null ? '${s.avgNoise}' : 'N/A', + valueSuffix: s.avgNoise != null ? ' dBm' : null, + label: 'AVG NOISE', + ), + ], + ), + const SizedBox(height: 16), + _barGraph(context, s), + ], + ); + } + + Widget _cell( + BuildContext context, { + required String value, + required String label, + Color? color, + String? valueSuffix, + Widget? valueWidget, + }) { + final theme = Theme.of(context); + return Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + valueWidget ?? + Text.rich( + TextSpan( + children: [ + TextSpan( + text: value, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: color ?? theme.colorScheme.onSurface, + ), + ), + if (valueSuffix != null) + TextSpan( + text: valueSuffix, + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.3, + ), + ), + ], + ), + ); + } + + Widget _snrCell(BuildContext context, GridSummary s) { + final theme = Theme.of(context); + final bucket = s.snrBucket; + final Widget valueWidget; + if (bucket == null) { + valueWidget = Text( + 'N/A', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + ); + } else { + final IconData icon; + final Color color; + switch (bucket) { + case 'good': + icon = Icons.signal_cellular_4_bar; + color = _snrGood; + break; + case 'medium': + icon = Icons.signal_cellular_alt; + color = _snrMedium; + break; + default: + icon = Icons.signal_cellular_alt_1_bar; + color = _snrBad; + } + valueWidget = SizedBox( + height: 24, + child: Icon(icon, size: 22, color: color), + ); + } + return _cell(context, value: '', label: 'AVG SNR', valueWidget: valueWidget); + } + + Widget _barGraph(BuildContext context, GridSummary s) { + final isDark = Theme.of(context).brightness == Brightness.dark; + // Success segments first (BIDIR, DISC, TX, RX), then a separator "line", + // then fail segments (DEAD, DROP) — flex-weighted by count (web order). + final success = <(Color, int, String)>[ + (_bidir, s.bidir, 'BIDIR'), + (_disc, s.disc, 'DISC/TRACE'), + (_tx, s.tx, 'TX'), + (_rx, s.rx, 'RX'), + ]; + final fail = <(Color, int, String)>[ + (_dead, s.dead, 'DEAD'), + (_drop, s.drop, 'DROP'), + ]; + final hasSuccess = success.any((e) => e.$2 > 0); + final hasFail = fail.any((e) => e.$2 > 0); + if (!hasSuccess && !hasFail) return const SizedBox.shrink(); + + final children = []; + void addSeg((Color, int, String) seg) { + if (seg.$2 <= 0) return; + children.add( + Expanded( + flex: seg.$2, + child: Tooltip( + message: '${seg.$3}: ${seg.$2}', + child: Container(color: seg.$1), + ), + ), + ); + } + + for (final seg in success) { + addSeg(seg); + } + if (hasSuccess && hasFail) { + children.add(Container( + width: 2, + color: isDark ? const Color(0xFFCCCCCC) : const Color(0xFF444444), + )); + } + for (final seg in fail) { + addSeg(seg); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(4), + child: SizedBox(height: 10, child: Row(children: children)), + ); + } + + Widget _note(BuildContext context, String msg) => Padding( + padding: const EdgeInsets.symmetric(vertical: 28), + child: Center( + child: Text( + msg, + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + ); +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 5f12afb..acd2a8d 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -17,11 +17,14 @@ import '../models/ping_data.dart'; import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../services/gps_service.dart'; +import '../utils/coverage_summary.dart'; import '../utils/coverage_tile_palette.dart'; import '../utils/debug_logger_io.dart'; import '../utils/mvt_cells.dart'; import '../utils/distance_formatter.dart'; import '../utils/ping_colors.dart'; +import '../utils/repeater_format.dart'; +import 'cell_summary_sheet.dart'; import 'repeater_id_chip.dart'; import 'rx_path_chain.dart'; @@ -519,6 +522,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { String? _activeCoverageSourceId; String? _activeCoverageLayerId; int _coverageBufferCounter = 0; + // Guards against opening two GRID SUMMARY sheets when both the feature-tap and + // the onMapClick hit-test fire for the same coverage cell tap. + bool _cellSummaryShowing = false; // Grid size is baked into the tile URL and the CVD palette into the layer // paint; a change to either rebuilds the overlay via the build watcher. int? _lastAppliedGridSize; @@ -1906,7 +1912,81 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (!mounted) return; if (_spiderCenter != null) { _collapseSpider(); + return; // dismissing the spider shouldn't also open a cell summary } + // Fallback cell hit-test: some platforms don't dispatch coverage fill-layer + // taps through onFeatureTapped, so query the coverage layer at the tap point. + _maybeShowCellSummaryAt(point, coordinates); + } + + /// Hit-test the active coverage fill layer at [point]; if a cell is there, + /// open its GRID SUMMARY. The onMapClick fallback to _handleFeatureTap. + Future _maybeShowCellSummaryAt( + math.Point point, LatLng coordinates) async { + final layerId = _activeCoverageLayerId; + if (layerId == null || _mapController == null) return; + try { + final features = + await _mapController!.queryRenderedFeatures(point, [layerId], null); + if (!mounted || features.isEmpty) return; + _showCellSummary(coordinates); + } catch (e) { + debugWarn('[COVERAGE] cell hit-test failed: $e'); + } + } + + /// Open the GRID SUMMARY bottom sheet for the coverage cell at [coordinates]. + /// Lazily fetches the cell's coverage points and aggregates them client-side, + /// mirroring the web cell-click popup (minus PING HISTORY). + void _showCellSummary(LatLng coordinates) { + if (!mounted || _cellSummaryShowing) return; + final appState = context.read(); + final zone = appState.zoneCode; + if (zone == null || zone.isEmpty) return; + + final gridSize = appState.preferences.coverageGridSize; + final radius = gridSize.toDouble(); + // Snap to the clicked CELL (same grid as the server/web) so the summary is + // identical anywhere in the cell and matches the web: fetch the cell centre, + // then filter the returned pings to that cell. + final steps = kCoverageGridSteps[gridSize] ?? const [0.0009, 0.00128]; + final cell = GridCell.containing( + coordinates.latitude, coordinates.longitude, steps[0], steps[1]); + final lookup = RepeaterLookup.fromRepeaters(appState.repeaters, + hopBytes: appState.effectiveHopBytes); + final isImperial = appState.preferences.isImperial; + + debugLog('[COVERAGE] cell tap @ ' + '${coordinates.latitude.toStringAsFixed(5)},' + '${coordinates.longitude.toStringAsFixed(5)} ' + 'cell=${cell.i}/${cell.j} r=${radius.toStringAsFixed(0)}m'); + + final Future summaryFuture = appState + .fetchCellCoverage( + lat: cell.centerLat, + lon: cell.centerLon, + radiusMeters: radius, + ) + .then( + (points) => GridSummary.fromPoints(cell.filter(points), lookup)) + .catchError((Object e) { + debugWarn('[COVERAGE] cell summary failed: $e'); + return null; + }); + + _cellSummaryShowing = true; + showModalBottomSheet( + context: context, + useSafeArea: true, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => CellSummarySheet( + summaryFuture: summaryFuture, + isImperial: isImperial, + ), + ).whenComplete(() => _cellSummaryShowing = false); } /// Handles taps on custom layer features (repeater cluster bubbles and @@ -1995,6 +2075,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return; } + // Coverage cell: open the GRID SUMMARY. Coverage is a fill layer below the + // repeaters, so reaching it means no repeater/cluster was hit. Platforms that + // don't dispatch fill-layer taps here fall back to the queryRenderedFeatures + // hit-test in _onMapEmptyTap. + if (layerId == _activeCoverageLayerId) { + _showCellSummary(coordinates); + return; + } + // GPS marker tap: the GPS marker is a non-interactive symbol on the // annotation manager layer (which sits ON TOP of all custom layers in // paint order). Without intervention, taps on the GPS marker hit the @@ -7678,20 +7767,48 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Determine icon badge color based on primary status final iconColor = _getRepeaterMarkerColor(repeater, isDuplicate); - // Determine status label and color + // Determine status label and color (web labels, generateRepeaterPopup). String statusLabel; Color statusColor; - if (repeater.isNew) { - statusLabel = 'New'; + if (repeater.enabled == 2) { + statusLabel = 'Ambiguous'; + statusColor = _repeaterDuplicateColor; + } else if (repeater.enabled == 0) { + statusLabel = 'Disabled'; + statusColor = _repeaterDeadColor; + } else if (repeater.isNew) { + statusLabel = 'New Repeater'; statusColor = _repeaterNewColor; } else if (repeater.isActive) { - statusLabel = 'Active'; + statusLabel = 'Repeater Online'; statusColor = _repeaterMarkerColor; } else { - statusLabel = 'Stale'; + statusLabel = 'Stale Repeater'; statusColor = _repeaterDeadColor; } + // Lazily fetch this repeater's coverage points for the BIDIR/TX/RX/DISC/DEAD + // totals + max range, then aggregate client-side (renderRepeaterChart parity). + final appState = context.read(); + final lookup = RepeaterLookup.fromRepeaters(appState.repeaters, + hopBytes: appState.effectiveHopBytes); + final isImperial = appState.preferences.isImperial; + final Future statsFuture = appState + .fetchRepeaterCoveragePoints(prefix: repeater.id) + .then( + (pts) => RepeaterStats.fromCoverage(pts, repeater, lookup)) + .catchError((Object e) { + debugWarn('[COVERAGE] repeater stats failed: $e'); + return null; + }); + + final fingerprintShort = + repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); + final fingerprintFull = repeater.hexId.length >= 8 + ? repeater.hexId.substring(0, 8).toUpperCase() + : repeater.hexId.toUpperCase(); + final clockSkew = humanizeClockSkew(repeater.timeOffset); + showModalBottomSheet( context: context, useSafeArea: true, @@ -7786,55 +7903,181 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), child: Column( children: [ - // Location row - Row( - children: [ - Icon(Icons.location_on, - size: 16, - color: - Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - '${repeater.lat.toStringAsFixed(5)}, ${repeater.lon.toStringAsFixed(5)}', - style: TextStyle( + // Location + _repRow( + context, + Icons.location_on, + Text( + '${repeater.lat.toStringAsFixed(5)}, ${repeater.lon.toStringAsFixed(5)}', + style: const TextStyle( + fontSize: 13, fontFamily: 'monospace'), + ), + ), + // Fingerprint (id / hex) + _repRow( + context, + Icons.fingerprint, + Text.rich(TextSpan(children: [ + TextSpan( + text: fingerprintShort, + style: const TextStyle( fontSize: 13, fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), + fontWeight: FontWeight.w600), ), - ], - ), - const SizedBox(height: 10), - // Last heard row - Row( - children: [ - Icon(Icons.access_time, - size: 16, + TextSpan( + text: ' ($fingerprintFull)', + style: TextStyle( + fontSize: 12, color: - Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - repeater.lastHeardFormatted, - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurface, - ), + Theme.of(context).colorScheme.onSurfaceVariant, ), ), - ], + ])), + ), + // Hop bytes + _repRow( + context, + Icons.swap_horiz, + Text( + 'Hop Bytes: ${repeater.hopBytes} byte${repeater.hopBytes == 1 ? '' : 's'}', + style: const TextStyle(fontSize: 13), + ), + ), + // Last heard (schedule) + if (repeater.lastHeard > 0) + _repRow( + context, + Icons.schedule, + Text(formatDateWithAgo(repeater.lastHeard), + style: const TextStyle(fontSize: 13)), + ), + // Clock-skew warning + if (clockSkew != null) ...[ + _repRow( + context, + Icons.warning_amber_rounded, + const Text('Repeater time is not set correctly', + style: TextStyle(fontSize: 13)), + color: Colors.red.shade400, + ), + _repRow( + context, + null, + Text(clockSkew, style: const TextStyle(fontSize: 13)), + color: Colors.red.shade400, + ), + ], + // First heard + if (repeater.createdAt != null) + _repRow( + context, + Icons.event, + Text( + 'First Heard: ${formatDateWithAgo(repeater.createdAt)}', + style: const TextStyle(fontSize: 13)), + ), + // Max range (lazy) + FutureBuilder( + future: statsFuture, + builder: (context, snap) { + final loading = + snap.connectionState != ConnectionState.done; + final stats = snap.data; + final range = loading + ? '…' + : (stats?.maxRangeMeters != null + ? formatCoverageDistance(stats!.maxRangeMeters!, + isImperial: isImperial) + : 'N/A'); + return _repRow( + context, + Icons.open_in_full, + Text('Max Range: $range', + style: const TextStyle(fontSize: 13)), + ); + }, ), ], ), ), + const SizedBox(height: 14), + // BIDIR/TX/RX/DISC/DEAD totals (lazy — filled after the fetch) + FutureBuilder( + future: statsFuture, + builder: (context, snap) { + final loading = snap.connectionState != ConnectionState.done; + final stats = snap.data; + String v(int? n) => loading ? '…' : '${n ?? 0}'; + return Row( + children: [ + _repeaterStatCell(context, 'BIDIR', v(stats?.bidir), + const Color(0xFF1E7E34)), + _repeaterStatCell( + context, 'TX', v(stats?.tx), const Color(0xFFFD7E14)), + _repeaterStatCell( + context, 'RX', v(stats?.rx), const Color(0xFF6F42C1)), + _repeaterStatCell(context, 'DISC', v(stats?.disc), + const Color(0xFF17A2B8)), + _repeaterStatCell(context, 'DEAD', v(stats?.dead), + const Color(0xFF6C757D)), + ], + ); + }, + ), ], ), ), ); } + /// One labelled icon row in the repeater detail card. A null [icon] leaves the + /// icon gutter blank (used to indent the clock-skew magnitude under its warning). + Widget _repRow(BuildContext context, IconData? icon, Widget child, + {Color? color}) { + final iconColor = color ?? Theme.of(context).colorScheme.onSurfaceVariant; + final textColor = color ?? Theme.of(context).colorScheme.onSurface; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 20, + child: icon == null ? null : Icon(icon, size: 16, color: iconColor), + ), + const SizedBox(width: 8), + Expanded( + child: DefaultTextStyle.merge( + style: TextStyle(fontSize: 13, color: textColor), + child: child, + ), + ), + ], + ), + ); + } + + /// One cell of the repeater's BIDIR/TX/RX/DISC/DEAD totals row. + Widget _repeaterStatCell( + BuildContext context, String label, String value, Color color) { + return Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(value, + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.bold, color: color)), + const SizedBox(height: 2), + Text(label, + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.onSurfaceVariant)), + ], + ), + ); + } + /// Build a status chip matching the status bar theme /// Same styling as StatusBar._buildStatChip() Widget _buildStatChip({ diff --git a/test/utils/coverage_summary_test.dart b/test/utils/coverage_summary_test.dart new file mode 100644 index 0000000..9cd2f17 --- /dev/null +++ b/test/utils/coverage_summary_test.dart @@ -0,0 +1,254 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/models/repeater.dart'; +import 'package:mesh_mapper/utils/coverage_summary.dart'; + +Repeater _rep(String id, String hexId, double lat, double lon, + {int enabled = 1, String name = 'R'}) => + Repeater( + id: id, + hexId: hexId, + name: name, + lat: lat, + lon: lon, + lastHeard: 0, + enabled: enabled, + hopBytes: 2, + ); + +void main() { + // Target repeater at (45, -75); id 'ab12' (2 bytes), full hex 'ab12cd34'. + final target = _rep('ab12', 'ab12cd34', 45.0, -75.0); + final lookup = RepeaterLookup.fromRepeaters([target], hopBytes: 2); + + group('GridSummary.fromPoints', () { + final points = >[ + // BIDIR, snr 10, noise -100, 0.02° west of the repeater (~1573 m). + { + 'status': 1, + 'local_snr': 10, + 'noisefloor': -100, + 'lat': 45.0, + 'lon': -75.02, + 'heard_repeats': 'ab12(10)[45.0,-75.0]', + }, + // BIDIR, snr 4, noise -110, at the repeater (0 m). + { + 'status': 1, + 'local_snr': 4, + 'noisefloor': -110, + 'lat': 45.0, + 'lon': -75.0, + 'heard_repeats': 'ab12(4)[45.0,-75.0]', + }, + // TX — no local_snr, snr 6 parsed from the heard_repeats token. + { + 'status': 2, + 'lat': 45.0, + 'lon': -75.0, + 'heard_repeats': 'ab12(6)[45.0,-75.0]', + }, + {'status': 5, 'lat': 45.0, 'lon': -75.0}, // RX + {'status': 3, 'lat': 45.0, 'lon': -75.0}, // DEAD + {'status': 3, 'lat': 45.0, 'lon': -75.0}, // DEAD + {'status': 0, 'lat': 45.0, 'lon': -75.0}, // DROP + // DISC matched by full public_key + baked coord in heard_repeats. + { + 'status': 6, + 'public_key': 'ab12cd34', + 'lat': 45.0, + 'lon': -75.0, + 'heard_repeats': '[45.0,-75.0]', + }, + ]; + + final s = GridSummary.fromPoints(points, lookup); + + test('per-status counts + total', () { + expect(s.total, 8); + expect(s.bidir, 2); + expect(s.tx, 1); + expect(s.rx, 1); + expect(s.dead, 2); + expect(s.drop, 1); + expect(s.disc, 1); + }); + + test('AVG SNR averages local_snr + parsed token SNR (10,4,6)', () { + expect(s.avgSnr, isNotNull); + expect(s.avgSnr!, closeTo(20 / 3, 0.001)); + expect(s.snrBucket, 'good'); // > 5 + }); + + test('AVG NOISE averages noisefloor (-100,-110)', () { + expect(s.avgNoise, -105); + }); + + test('MAX DIST is the farthest matched ping→repeater (~1573 m)', () { + expect(s.maxDistMeters, isNotNull); + expect(s.maxDistMeters!, closeTo(1573, 60)); + }); + + test('(0,0) no-location repeater is excluded from MAX DIST', () { + // A repeater at the (0,0) "location not published" sentinel, referenced by + // a baked [0,0] token, must NOT produce a ~8900 km distance to (0,0). + final lk = RepeaterLookup.fromRepeaters( + [target, _rep('00ff', '00ff0000', 0.0, 0.0)], + hopBytes: 2, + ); + final pts = >[ + { + 'status': 1, + 'lat': 45.27, + 'lon': -75.78, + 'heard_repeats': '00ff(5)[0,0]', + }, + ]; + final summary = GridSummary.fromPoints(pts, lk); + expect(summary.bidir, 1); + expect(summary.maxDistMeters, isNull); + }); + + test('MAX DIST resolves a WIDE (full-hex) token + ignores the marker', () { + // The new bake stores tokens WIDER than repeater.id (4-byte cap) + a {U/R} marker. + // 'ab12cd34' (full hex, wider than id 'ab12') must still resolve for MAX DIST — the + // old _byId[token] lookup would have missed it (and shown N/A). + final pts = >[ + { + 'status': 1, + 'lat': 45.0, + 'lon': -75.02, // ~1573 m west of the repeater + 'heard_repeats': 'ab12cd34(8)[45.0,-75.0]{U2}', + }, + ]; + final summary = GridSummary.fromPoints(pts, lookup); + expect(summary.bidir, 1); + expect(summary.maxDistMeters, isNotNull); + expect(summary.maxDistMeters!, closeTo(1573, 60)); + }); + + test('empty input yields a zeroed summary', () { + final e = GridSummary.fromPoints(const [], lookup); + expect(e.total, 0); + expect(e.bidir, 0); + expect(e.avgSnr, isNull); + expect(e.avgNoise, isNull); + expect(e.maxDistMeters, isNull); + expect(e.snrBucket, isNull); + }); + }); + + group('RepeaterStats.fromCoverage', () { + final points = >[ + // BIDIR via heard_repeats token (coords ~repeater); ping ~786 m away. + { + 'status': 1, + 'lat': 45.0, + 'lon': -75.01, + 'heard_repeats': 'ab12(7.5)[45.0,-75.0]', + }, + // TX via the `via` path token. + { + 'status': 2, + 'lat': 45.0, + 'lon': -75.0, + 'via': 'ab12(3.1)[45.0,-75.0]', + }, + { + 'status': 5, + 'lat': 45.0, + 'lon': -75.0, + 'heard_repeats': 'ab12(2.0)[45.0,-75.0]', + }, + { + 'status': 3, + 'lat': 45.0, + 'lon': -75.0, + 'heard_repeats': 'ab12(1.0)[45.0,-75.0]', + }, + // DISC by full public_key; ping ~3931 m away (the max). + { + 'status': 6, + 'public_key': 'ab12cd34', + 'lat': 45.0, + 'lon': -75.05, + 'heard_repeats': '[45.0,-75.0]', + }, + // Token without coords -> must NOT match (dup-logic enabled by default). + { + 'status': 1, + 'lat': 45.5, + 'lon': -75.0, + 'heard_repeats': 'ab12(9)', + }, + // References a different repeater -> must NOT match. + { + 'status': 1, + 'lat': 40.0, + 'lon': -70.0, + 'heard_repeats': 'ff99(9)[40.0,-70.0]', + }, + // DROP -> skipped. + {'status': 0, 'lat': 45.0, 'lon': -75.0}, + ]; + + final stats = RepeaterStats.fromCoverage(points, target, lookup); + + test('per-status counts attributed to the target', () { + expect(stats.bidir, 1); + expect(stats.tx, 1); + expect(stats.rx, 1); + expect(stats.disc, 1); + expect(stats.dead, 1); + expect(stats.totalMatched, 5); + }); + + test('max range is the farthest matched ping (~3931 m)', () { + expect(stats.maxRangeMeters, isNotNull); + expect(stats.maxRangeMeters!, closeTo(3931, 80)); + }); + + test('no matches yields zeros + null range', () { + final none = RepeaterStats.fromCoverage( + const [ + {'status': 1, 'lat': 40.0, 'lon': -70.0, 'heard_repeats': 'ff99(9)[40,-70]'}, + ], + target, + lookup, + ); + expect(none.totalMatched, 0); + expect(none.maxRangeMeters, isNull); + }); + }); + + group('GridCell', () { + // 100 m Detailed grid steps (kCoverageGridSteps[100]). + const latStep = 0.0009, lonStep = 0.00128; + + test('taps anywhere in a cell resolve to the same cell; centre is inside', () { + final cell = GridCell.containing(45.26970, -75.77795, latStep, lonStep); + final a = + GridCell.containing(cell.centerLat, cell.centerLon, latStep, lonStep); + final b = GridCell.containing(cell.centerLat + latStep * 0.3, + cell.centerLon - lonStep * 0.3, latStep, lonStep); + expect(a.i, cell.i); + expect(a.j, cell.j); + expect(b.i, cell.i); + expect(b.j, cell.j); + expect(cell.contains(cell.centerLat, cell.centerLon), isTrue); + }); + + test('filter keeps only in-cell points (parses string coords)', () { + final cell = GridCell.containing(45.0, -75.0, latStep, lonStep); + final pts = >[ + {'lat': cell.centerLat, 'lon': cell.centerLon}, // in cell (num) + {'lat': '${cell.centerLat}', 'lon': '${cell.centerLon}'}, // in cell (string) + { + 'lat': (cell.i + 3) * latStep + latStep * 0.5, + 'lon': cell.centerLon + }, // a different cell + {'lat': null, 'lon': cell.centerLon}, // unparseable + ]; + expect(cell.filter(pts).length, 2); + }); + }); +} diff --git a/test/utils/repeater_format_test.dart b/test/utils/repeater_format_test.dart new file mode 100644 index 0000000..891271a --- /dev/null +++ b/test/utils/repeater_format_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/distance_formatter.dart'; +import 'package:mesh_mapper/utils/repeater_format.dart'; + +void main() { + group('formatDateOnly / daysAgo', () { + test('formats a date as MM/DD/YY (local)', () { + final secs = DateTime(2026, 6, 16).millisecondsSinceEpoch ~/ 1000; + expect(formatDateOnly(secs), '06/16/26'); + }); + + test('accepts millisecond timestamps too', () { + final ms = DateTime(2026, 1, 2).millisecondsSinceEpoch; + expect(formatDateOnly(ms), '01/02/26'); + }); + + test('null / non-positive -> N/A', () { + expect(formatDateOnly(null), 'N/A'); + expect(formatDateOnly(0), 'N/A'); + }); + + test('daysAgo: Today / N days ago', () { + final now = DateTime.now(); + final today = now.millisecondsSinceEpoch ~/ 1000; + final twoDays = + now.subtract(const Duration(days: 2)).millisecondsSinceEpoch ~/ 1000; + expect(daysAgo(today), 'Today'); + expect(daysAgo(twoDays), '2 days ago'); + }); + + test('formatDateWithAgo combines both', () { + final secs = DateTime(2026, 6, 16).millisecondsSinceEpoch ~/ 1000; + expect(formatDateWithAgo(secs), startsWith('06/16/26 (')); + }); + }); + + group('humanizeClockSkew', () { + test('null + within tolerance -> null', () { + expect(humanizeClockSkew(null), isNull); + expect(humanizeClockSkew(60), isNull); + expect(humanizeClockSkew(-120), isNull); + }); + + test('minutes, ahead vs behind', () { + // -2964 s = 49.4 min ahead (matches the spec example). + expect(humanizeClockSkew(-2964), '49.4 minutes ahead'); + expect(humanizeClockSkew(2964), '49.4 minutes behind'); + }); + + test('hours + days', () { + expect(humanizeClockSkew(7200), '2.0 hours behind'); + expect(humanizeClockSkew(-172800), '2.0 days ahead'); + }); + }); + + group('formatCoverageDistance (web parity)', () { + test('metric km + m', () { + expect(formatCoverageDistance(123260), '123.26 km'); + expect(formatCoverageDistance(150), '150 m'); + }); + + test('imperial mi + ft', () { + // 2000 m = 6561.68 ft = 1.2427 mi + expect(formatCoverageDistance(2000, isImperial: true), '1.24 mi'); + expect(formatCoverageDistance(100, isImperial: true), '328 ft'); + }); + }); +} From 402dcbd96fb7fa86c15e8c0bc497551299f06795 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 18 Jun 2026 19:22:34 -0400 Subject: [PATCH 069/100] Fix path-mode warning: app restores original setting on clean disconnect The multi-byte path warning told users the radio would stay in multi-byte mode until they change it. That's wrong: _restorePathHashMode() restores the original setting on a clean disconnect (unless the user changed it themselves). Reword to say the app restores it on a normal disconnect and warn that an unclean drop needs a manual revert in Settings. --- lib/screens/connection_screen.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index 4f8898b..ee9ccb2 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -2476,7 +2476,9 @@ class _ConnectionScreenState extends State ), const SizedBox(height: 12), const Text( - 'Your radio will remain in multi-byte mode until you change it.', + 'On a normal disconnect, the app restores your radio to its ' + 'original path setting. If the connection drops unexpectedly, ' + 'you may need to switch it back yourself in Settings.', style: TextStyle(fontSize: 13, color: Colors.amber), ), ], From 87e445488acce0a7f5d7932a7fc75f2be112a298 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 18 Jun 2026 19:27:04 -0400 Subject: [PATCH 070/100] Detailed-mode cell tap: blob-aware summary + clicked-tile 3x3 highlight Tapping a coverage cell in Detailed mode now (a) fetches and keeps the pings whose 3x3 blob colours the tapped cell, matching the web's lazyShowPingsAt (GridCell.filterWithinBlob / blobFetchRadiusMeters, used by _showCellSummary), and (b) draws an outline-only 3x3 highlight block centred on the tapped tile (GridCell.blockRing + a self-healing line-layer overlay, cleared when the summary sheet closes). Simplified mode collapses to the single tapped cell. Adds GridCell unit tests. --- lib/utils/coverage_summary.dart | 51 ++++++++++ lib/widgets/map_widget.dart | 128 ++++++++++++++++++++++++-- test/utils/coverage_summary_test.dart | 86 +++++++++++++++++ 3 files changed, 258 insertions(+), 7 deletions(-) diff --git a/lib/utils/coverage_summary.dart b/lib/utils/coverage_summary.dart index f30d065..2c7d725 100644 --- a/lib/utils/coverage_summary.dart +++ b/lib/utils/coverage_summary.dart @@ -81,6 +81,57 @@ class GridCell { return la != null && lo != null && contains(la, lo); }).toList(); } + + // The coverage tile smears each ping over a (2·blob+1)² block of cells + // (server `$blob`: Detailed 100 m = 1 → 3×3, Simplified 300 m = 0 → 1×1). So a + // green cell can be coloured by a ping up to [blob] cells away. These two + // helpers mirror the web's `lazyShowPingsAt` (dev/index.php) so the app fetches + // and keeps exactly the pings that coloured the tapped cell — otherwise a + // blob-painted neighbour cell falsely reads "no coverage data here". blob=0 + // collapses both back to the own-cell behaviour, unchanged. + + /// Metres from the cell centre to the far corner of the ±[blob] cell block — + /// the fetch radius that reaches every ping whose blob can colour this cell. + /// Floored at [minMeters] (the active gridSize) so blob=0 keeps the old radius. + double blobFetchRadiusMeters(int blob, double minMeters) { + final latM = (blob + 0.5) * latStep * 111000; + final lonM = + (blob + 0.5) * lonStep * 111000 * math.cos(centerLat * math.pi / 180); + final corner = math.sqrt(latM * latM + lonM * lonM).ceilToDouble() + 5; + return math.max(corner, minMeters); + } + + /// Keep the points whose blob covers this cell: own cell ±[blob] in each axis + /// (blob=0 reduces to own-cell-only, i.e. [filter]). + List> filterWithinBlob( + List> points, int blob) { + return points.where((p) { + final la = _toDouble(p['lat']); + final lo = _toDouble(p['lon']); + if (la == null || lo == null) return false; + return ((la / latStep).floor() - i).abs() <= blob && + ((lo / lonStep).floor() - j).abs() <= blob; + }).toList(); + } + + /// The closed outer ring (`[lon, lat]` pairs, GeoJSON order) of the + /// (2·[blob]+1)² block of cells centred on this cell — the 3×3 highlight + /// block in Detailed (blob = 1), the single cell in Simplified (blob = 0). + /// The cell itself is always the middle, so a tap highlights a block centred + /// on the tapped tile (parity with the web's clicked-tile-centred highlight). + List> blockRing(int blob) { + final minLat = (i - blob) * latStep; + final maxLat = (i + blob + 1) * latStep; + final minLon = (j - blob) * lonStep; + final maxLon = (j + blob + 1) * lonStep; + return [ + [minLon, minLat], + [maxLon, minLat], + [maxLon, maxLat], + [minLon, maxLat], + [minLon, minLat], + ]; + } } // --- repeater lookup (ports repObj / repeaterByHex / repeaterByFullHex) ------ diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index acd2a8d..15bf32d 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -537,6 +537,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { static const String _patchSourceId = 'meshmapper-coverage-patch'; static const String _patchLayerId = 'meshmapper-coverage-patch-layer'; bool _patchLayerReady = false; + + // Tap-to-highlight overlay: an outline-only line layer tracing the clicked + // cell's (2·blob+1)² block (3×3 Detailed / 1 cell Simplified), centred on the + // tapped tile. Installed once (empty); populated on a cell tap and cleared to + // empty when the summary sheet closes — no per-tap layer churn. + static const String _cellHighlightSourceId = 'meshmapper-cell-highlight'; + static const String _cellHighlightLayerId = 'meshmapper-cell-highlight-layer'; + bool _cellHighlightReady = false; int _lastAppliedPatchVersion = -1; bool _styleLoaded = false; bool _hasStyleLoadedOnce = @@ -1945,13 +1953,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (zone == null || zone.isEmpty) return; final gridSize = appState.preferences.coverageGridSize; - final radius = gridSize.toDouble(); // Snap to the clicked CELL (same grid as the server/web) so the summary is - // identical anywhere in the cell and matches the web: fetch the cell centre, - // then filter the returned pings to that cell. + // identical anywhere in the cell and matches the web's lazyShowPingsAt. final steps = kCoverageGridSteps[gridSize] ?? const [0.0009, 0.00128]; final cell = GridCell.containing( coordinates.latitude, coordinates.longitude, steps[0], steps[1]); + // The Detailed (100 m) coverage tile smears each ping over a 3×3 cell block + // (server $blob=1); Simplified (300 m) doesn't (blob=0). So a green cell can + // be coloured by a ping up to blob cells away — fetch out to the block's far + // corner and keep the pings whose blob covers this cell, else a blob-painted + // neighbour cell falsely reads "no coverage data here" (matches the web's + // ($gsize === 100 ? 1 : 0) blob rule for both presets). + final blob = gridSize == 100 ? 1 : 0; + final radius = cell.blobFetchRadiusMeters(blob, gridSize.toDouble()); final lookup = RepeaterLookup.fromRepeaters(appState.repeaters, hopBytes: appState.effectiveHopBytes); final isImperial = appState.preferences.isImperial; @@ -1959,7 +1973,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { debugLog('[COVERAGE] cell tap @ ' '${coordinates.latitude.toStringAsFixed(5)},' '${coordinates.longitude.toStringAsFixed(5)} ' - 'cell=${cell.i}/${cell.j} r=${radius.toStringAsFixed(0)}m'); + 'cell=${cell.i}/${cell.j} blob=$blob r=${radius.toStringAsFixed(0)}m'); + + // Highlight the tapped cell's block (3×3 in Detailed, 1 cell in Simplified), + // always centred on the tapped tile. Fire-and-forget; cleared on sheet close. + _setCellHighlight(cell, blob); final Future summaryFuture = appState .fetchCellCoverage( @@ -1967,8 +1985,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { lon: cell.centerLon, radiusMeters: radius, ) - .then( - (points) => GridSummary.fromPoints(cell.filter(points), lookup)) + .then((points) => + GridSummary.fromPoints(cell.filterWithinBlob(points, blob), lookup)) .catchError((Object e) { debugWarn('[COVERAGE] cell summary failed: $e'); return null; @@ -1986,7 +2004,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { summaryFuture: summaryFuture, isImperial: isImperial, ), - ).whenComplete(() => _cellSummaryShowing = false); + ).whenComplete(() { + _cellSummaryShowing = false; + _clearCellHighlight(); + }); } /// Handles taps on custom layer features (repeater cluster bubbles and @@ -2373,6 +2394,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // style. Clear the flag so _updateFocusLines won't try to remove // already-gone layers next time it's called. _focusLinesInstalled = false; + // The tap cell-highlight overlay is likewise gone; _addCoverageOverlay + // re-installs it below. + _cellHighlightReady = false; // Disable symbol decluttering on the annotation manager. By default, // MapLibre symbol layers hide overlapping icons/labels at lower zoom to @@ -2593,6 +2617,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { await _applyBasePatchFilter(appState, layerId); } + // Tap-to-highlight overlay: installed empty here (renders nothing until a + // cell tap pushes a block into it). Self-heals on first tap if this fails. + _cellHighlightReady = false; + await _ensureCellHighlightLayer(); + _lastAppliedCoverageOpacity = opacity; _lastAppliedGridSize = gridSize; _lastAppliedCvd = prefs.colorVisionType; @@ -2645,6 +2674,91 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + /// Empty FeatureCollection — used to clear a GeoJSON source without removing + /// its layer (an empty source renders nothing, costs nothing). + Map _emptyFeatureCollection() => + {'type': 'FeatureCollection', 'features': []}; + + /// Outline-only FeatureCollection for the tapped cell's (2·blob+1)² block: a + /// single LineString tracing the block border, centred on the tapped tile. + Map _buildCellHighlightGeoJson(GridCell cell, int blob) => + { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'properties': {}, + 'geometry': { + 'type': 'LineString', + 'coordinates': cell.blockRing(blob), + }, + }, + ], + }; + + /// Idempotently (re)create the tap cell-highlight source + line layer. A line + /// layer over a tiny LineString ring — no fill, and nothing renders while the + /// source is empty (the idle state). Self-healing like the patch layer. + Future _ensureCellHighlightLayer() async { + if (_cellHighlightReady) return true; + if (_mapController == null) return false; + try { + final belowLayer = _clusterLayersReady + ? _repeaterIndividualLayerId + : _symbolAnnotationLayerId(); + try { + await _mapController!.removeLayer(_cellHighlightLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_cellHighlightSourceId); + } catch (_) {} + await _mapController! + .addGeoJsonSource(_cellHighlightSourceId, _emptyFeatureCollection()); + await _mapController!.addLineLayer( + _cellHighlightSourceId, + _cellHighlightLayerId, + const LineLayerProperties( + lineColor: '#00d2ff', + lineWidth: 2.5, + lineOpacity: 0.95, + lineCap: 'round', + lineJoin: 'round', + ), + belowLayerId: belowLayer, + ); + _cellHighlightReady = true; + return true; + } catch (e) { + debugLog('[COVERAGE] cell-highlight layer create failed: $e'); + return false; + } + } + + /// Draw the highlight block for the tapped [cell] (centred on it). [blob] = 1 + /// in Detailed (3×3), 0 in Simplified (single cell). + Future _setCellHighlight(GridCell cell, int blob) async { + if (_mapController == null) return; + if (!await _ensureCellHighlightLayer()) return; + try { + await _mapController!.setGeoJsonSource( + _cellHighlightSourceId, _buildCellHighlightGeoJson(cell, blob)); + } catch (e) { + debugLog('[COVERAGE] cell-highlight set failed: $e'); + } + } + + /// Clear the tap highlight (summary sheet closed): set the source to empty so + /// the layer renders nothing. The layer stays installed (cheap, no churn). + Future _clearCellHighlight() async { + if (_mapController == null || !_cellHighlightReady) return; + try { + await _mapController! + .setGeoJsonSource(_cellHighlightSourceId, _emptyFeatureCollection()); + } catch (e) { + debugLog('[COVERAGE] cell-highlight clear failed: $e'); + } + } + /// Returns the layer ID of the symbol annotation manager's first (and only) /// layer, or `null` if the manager isn't initialized yet. Used as a /// `belowLayerId` reference to insert other layers (coverage overlay, focus diff --git a/test/utils/coverage_summary_test.dart b/test/utils/coverage_summary_test.dart index 9cd2f17..6b8487f 100644 --- a/test/utils/coverage_summary_test.dart +++ b/test/utils/coverage_summary_test.dart @@ -250,5 +250,91 @@ void main() { ]; expect(cell.filter(pts).length, 2); }); + + test('Detailed (blob=1) keeps a ping one cell away; own-cell-only drops it', + () { + // The Detailed (100 m) coverage tile paints a 3×3 block per ping, so a + // green cell can be coloured by a ping up to 1 cell away. Mirrors the web's + // lazyShowPingsAt: own-cell-only would falsely show "no coverage data here". + final cell = GridCell.containing(45.0, -75.0, latStep, lonStep); + final pts = >[ + { + 'lat': (cell.i + 1) * latStep + latStep * 0.5, + 'lon': (cell.j - 1) * lonStep + lonStep * 0.5, + }, // diagonal neighbour — own cell is (i+1, j-1) + { + 'lat': (cell.i + 2) * latStep + latStep * 0.5, + 'lon': cell.centerLon, + }, // two cells away — outside the ±1 blob + {'lat': null, 'lon': cell.centerLon}, // unparseable + ]; + // blob=1: the diagonal neighbour is within ±1; the two-away ping is not. + expect(cell.filterWithinBlob(pts, 1).length, 1); + // own-cell-only (the pre-fix behaviour) drops both → empty summary. + expect(cell.filter(pts).length, 0); + }); + + test('Simplified (blob=0) reduces to own-cell-only filtering', () { + const sLat = 0.0027, sLon = 0.00384; // kCoverageGridSteps[300] + final cell = GridCell.containing(45.0, -75.0, sLat, sLon); + final pts = >[ + {'lat': cell.centerLat, 'lon': cell.centerLon}, // in cell + { + 'lat': (cell.i + 1) * sLat + sLat * 0.5, + 'lon': cell.centerLon + }, // a neighbour cell + ]; + expect(cell.filterWithinBlob(pts, 0).length, cell.filter(pts).length); + expect(cell.filterWithinBlob(pts, 0).length, 1); + }); + + test('blob fetch radius reaches the ±blob block corner, floored at gridSize', + () { + // Detailed: blob=1, 100 m floor → must exceed 100 m and reach the block's + // far corner (~212 m here) so blob-neighbour pings get fetched. + final detailed = GridCell.containing(45.0, -75.0, latStep, lonStep); + final rDetailed = detailed.blobFetchRadiusMeters(1, 100); + expect(rDetailed, greaterThan(100)); + expect(rDetailed, greaterThanOrEqualTo(212)); + + // Simplified: blob=0, the own-cell corner (~212 m) is below the 300 m floor + // → returns exactly 300, byte-unchanged from the old gridSize radius. + const sLat = 0.0027, sLon = 0.00384; + final simplified = GridCell.containing(45.0, -75.0, sLat, sLon); + expect(simplified.blobFetchRadiusMeters(0, 300), 300); + }); + }); + + group('GridCell.blockRing', () { + const latStep = 0.0009; + const lonStep = 0.00128; + const cell = GridCell(10, 20, latStep, lonStep); + + test('blob=1 makes a 3x3 block centred on the tapped cell', () { + final ring = cell.blockRing(1); + expect(ring.length, 5); + expect(ring.first, ring.last); // closed ring + final sw = ring[0]; // [minLon, minLat] + final ne = ring[2]; // [maxLon, maxLat] + // 3 cells wide/tall: i-1..i+2 and j-1..j+2 + expect(sw[1], closeTo(9 * latStep, 1e-12)); + expect(ne[1], closeTo(12 * latStep, 1e-12)); + expect(sw[0], closeTo(19 * lonStep, 1e-12)); + expect(ne[0], closeTo(22 * lonStep, 1e-12)); + // The block's centre is the tapped cell's centre — the clicked tile is + // always the middle. + expect((sw[1] + ne[1]) / 2, closeTo(cell.centerLat, 1e-12)); + expect((sw[0] + ne[0]) / 2, closeTo(cell.centerLon, 1e-12)); + }); + + test('blob=0 is just the tapped cell', () { + final ring = cell.blockRing(0); + final sw = ring[0]; + final ne = ring[2]; + expect(sw[1], closeTo(10 * latStep, 1e-12)); + expect(ne[1], closeTo(11 * latStep, 1e-12)); + expect(sw[0], closeTo(20 * lonStep, 1e-12)); + expect(ne[0], closeTo(21 * lonStep, 1e-12)); + }); }); } From 14e45c1258a0bbab028bef624db5dac2212228e8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 18 Jun 2026 19:27:04 -0400 Subject: [PATCH 071/100] TX wire-tag: send tag + plaintext coords in Broadcast-Coordinates mode With a session and 'Broadcast My Coordinates' on, the on-air body is now MM::lat,lon (keyed tag plus plaintext coords) instead of coords-only. The bare wire tag still goes to the API (txWireTag), so /wardrive validation and tx_pings are unchanged, and the 11-bit session-limit counter guard now applies to both privacy and broadcast modes. No session yet still falls back to plaintext MM:lat,lon. --- lib/services/ping_service.dart | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 3a29815..34c29d3 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -597,8 +597,9 @@ class PingService { // Build the on-air body ONCE (same string is used for TxTracker echo // correlation AND the actual transmission). Power is sent per-ping in the API. // - // Default (privacy): a keyed wire tag "MM:..." — coords go only via the API. - // Opt-in (Broadcast My Coordinates) OR no session yet: plaintext "MM:lat,lon". + // With a session: a keyed wire tag "MM:" (privacy default), or + // "MM::lat,lon" when Broadcast My Coordinates is on (tag + plaintext coords). + // No session yet: plaintext "MM:lat,lon" (no tag can be computed). final coordsStr = '${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}'; final broadcastCoords = getBroadcastCoords?.call() ?? false; @@ -606,9 +607,12 @@ class PingService { String pingMessage; int? txPingCounter; String? txWireTag; - if (!broadcastCoords && sessionId != null && sessionId.isNotEmpty) { + final hasSession = sessionId != null && sessionId.isNotEmpty; + if (hasSession) { // Session-limit guard: the wire tag's counter is 11 bits (max 2047). When it // is exhausted, end the session cleanly rather than repeating/corrupting tags. + // Applies to BOTH privacy and broadcast-coords modes — a combined ping consumes + // a counter exactly like a privacy ping, so the session ends identically. if ((getPingCounter?.call() ?? 0) >= 2047) { debugError('[SESSION] Reached session ping limit (2047) — disconnecting'); _pingInProgress = false; @@ -618,8 +622,12 @@ class PingService { txPingCounter = getNextPingCounter?.call() ?? 1; txWireTag = WireTagCodec.encode(sessionId, txPingCounter, getWireKey?.call()); - pingMessage = txWireTag; + // Identical to privacy mode in every way EXCEPT broadcast-coords appends the + // plaintext coords to the on-air body. The bare tag still goes to the API + // (txWireTag → _pendingTxWireTag), so /wardrive validation + tx_pings are unchanged. + pingMessage = broadcastCoords ? '$txWireTag:$coordsStr' : txWireTag; } else { + // No session yet → no tag can be computed; plaintext coords only (unchanged). pingMessage = 'MM:$coordsStr'; } From 4a6a90b9471f145417d7f8c9c646c0f71110ceef Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 18 Jun 2026 19:28:32 -0400 Subject: [PATCH 072/100] Offline sessions: enable mobile download via the native share sheet Mobile offline-session download was a 'coming soon' stub (web already worked). Add AppStateProvider.shareOfflineSession: write the session's pretty JSON to a temp file and open the OS share sheet (Save to Files, Drive, email, ...) via share_plus, mirroring shareDebugLog. settings_screen's download handler now calls it on mobile; the web blob-download path is unchanged. Downloads work before upload and after a failed upload (those sessions stay listed). --- lib/providers/app_state_provider.dart | 24 ++++++++++++++++++++++++ lib/screens/settings_screen.dart | 14 +++++--------- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 62ba715..384e5cb 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; @@ -9,6 +10,7 @@ import 'package:flutter/widgets.dart' import 'package:geolocator/geolocator.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:uuid/uuid.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart' show SharePlus, ShareParams, XFile; import '../models/connection_state.dart'; @@ -6757,6 +6759,28 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// Export an offline session to a file and open the native share sheet (Save + /// to Files, Drive, email, …) — the mobile equivalent of the web JSON + /// download. Mirrors [shareDebugLog]; writes the same pretty JSON the web + /// path produces. Throws on failure so the caller can surface an error. + Future shareOfflineSession(String filename) async { + final data = _offlineSessionService.getSessionData(filename); + if (data == null) { + throw Exception('Session "$filename" not found'); + } + final jsonString = const JsonEncoder.withIndent(' ').convert(data); + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/$filename'); + await file.writeAsString(jsonString); + final result = await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path)], + subject: 'MeshMapper Offline Session', + ), + ); + debugLog('[OFFLINE] Shared session: $filename, status: ${result.status}'); + } + /// View a debug log file in-app /// /// Reads the file contents and stores them for display in a dialog. diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 7314dcf..ea943ea 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2329,8 +2329,8 @@ class _SettingsScreenState extends State { ); } - void _downloadOfflineSession( - BuildContext context, AppStateProvider appState, String filename) { + Future _downloadOfflineSession( + BuildContext context, AppStateProvider appState, String filename) async { try { final sessionData = appState.offlineSessionService.getSessionData(filename); @@ -2366,13 +2366,9 @@ class _SettingsScreenState extends State { ); } } else { - // Mobile: Not yet implemented - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Mobile download coming soon - use web version'), - duration: Duration(seconds: 3), - ), - ); + // Mobile: write the JSON to a temp file and open the native share + // sheet (Save to Files, Drive, email, …) — mirrors the debug-log share. + await appState.shareOfflineSession(filename); } } catch (e) { if (context.mounted) { From a18be02eb351b3728e705b8b4754f47e6b6ce0ea Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 18 Jun 2026 19:28:32 -0400 Subject: [PATCH 073/100] Apply map style + CVD palette changes immediately (bump mapRevision) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setMapStyle and setColorVisionType called plain notifyListeners(), which does not bump mapRevision — so the MapWidget (isolated behind the mapRevision Selector, Critical Rule 9) never rebuilt and the new style/palette never reached the native map. Switch both to _notifyMapNow() so the change actually applies. --- lib/providers/app_state_provider.dart | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 384e5cb..548b07d 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -5195,11 +5195,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _savePreferences(); } - /// Set map style (dark, light, satellite) and persist + /// Set map style (dark, light, satellite) and persist. + /// The base map style is map-rendered state: `MapWidget._buildMap` reads + /// `preferences.mapStyle` and feeds it to `MapLibreMap.styleString`, but the + /// map is isolated behind the `mapRevision` Selector (see Critical Rule 9) and + /// uses `context.read`. Plain `notifyListeners()` leaves `mapRevision` + /// untouched, so the map never rebuilds and the new style never reaches the + /// native `setStyle` — the log fires but the map stays on the old style. Bump + /// the revision via `_notifyMapNow()` so the rebuild applies the style. void setMapStyle(String style) { _preferences = _preferences.copyWith(mapStyle: style); debugLog('[MAP] Map style set to $style'); - notifyListeners(); + _notifyMapNow(); _savePreferences(); } @@ -5233,7 +5240,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { orElse: () => ColorVisionType.none), ); debugLog('[A11Y] Color vision type set to $type'); - notifyListeners(); + // Map-rendered state: the CVD palette recolours both the coverage overlay + // (detected via `overlayPrefChanged` in `MapWidget._buildMap`) and the ping + // markers (`PingColors`). Both only re-apply on a map rebuild, which is + // gated by `mapRevision` (Critical Rule 9) — plain `notifyListeners()` would + // update the preference but leave the map on the old colours. Bump the + // revision via `_notifyMapNow()` so the rebuild applies the new palette. + _notifyMapNow(); _savePreferences(); } From c93540aae42d2c6ac2b5480c3f6d71bd95f5b064 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 05:57:54 -0400 Subject: [PATCH 074/100] perf: incremental coverage-marker sync (stop re-pushing every pin each event) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map re-pushed every accumulated TX/RX/DISC/trace marker to the native layer on every ping event — hundreds of awaited platform round-trips per event late in a session — so marker/ping display lag grew with session length and the coverage tile often repainted before its ping marker showed. Markers now skip the native update when their icon/size is unchanged, bounding per-event work to actually-changed pins. Adds a [MAP] Coverage sync diag line. --- lib/widgets/map_widget.dart | 52 +++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 15bf32d..003b224 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -615,6 +615,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // cluster-enabled GeoJSON source so MapLibre can group nearby markers into // count bubbles at low zoom. See _setupRepeaterClusterLayers(). final Map _coverageSymbols = {}; // key: "{type}_{ts.ms}" + // Last-applied visual signature (iconImage|iconSize) per coverage symbol. + // A symbol's geometry is baked into its key, so only icon image/size can + // change after creation (TX red→green on echo, focus enlarge). We skip the + // native updateSymbol round-trip when the signature is unchanged — without + // this, every sync re-pushed ALL accumulated symbols (O(n)/event), which + // made marker/ping display lag grow with session length. See _syncCoverageSymbols. + final Map _coverageSymbolSig = {}; final Map _distanceLabelSymbols = {}; // key: focused repeater id // Per focused-repeater metadata used by the collision-avoidance reflow: @@ -2368,6 +2375,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // creates fresh symbols in the new manager) instead of updateSymbol. _gpsSymbol = null; _coverageSymbols.clear(); + _coverageSymbolSig.clear(); _distanceLabelSymbols.clear(); // Distance-label companions: the native side wipes registered images on // style reload, so the "already registered" cache must be cleared too or @@ -3892,6 +3900,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final wantedKeys = {}; final focusActive = _focusedPingLocation != null; + // Diagnostics so the O(n)-per-event regression stays measurable on-device: + // a healthy steady-state sync should be mostly "unchanged". + int added = 0, updated = 0, skipped = 0; Future syncOne({ required String type, @@ -3910,29 +3921,46 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (focusActive && !isFocused) return; wantedKeys.add(key); - final options = SymbolOptions( - geometry: LatLng(lat, lon), - iconImage: iconImageOverride ?? _MapImages.coverage(type, success), - iconSize: isFocused ? 1.2 : 1.0, - ); + final iconImage = iconImageOverride ?? _MapImages.coverage(type, success); + final iconSize = isFocused ? 1.2 : 1.0; + // Everything that can change for an existing symbol (geometry is fixed by + // the key). If unchanged, skip the native round-trip entirely. + final sig = '$iconImage|$iconSize'; final existing = _coverageSymbols[key]; if (existing == null) { try { final symbol = await _mapController!.addSymbol( - options, + SymbolOptions( + geometry: LatLng(lat, lon), + iconImage: iconImage, + iconSize: iconSize, + ), {'kind': type, 'id': idForMetadata}, ); _coverageSymbols[key] = symbol; + _coverageSymbolSig[key] = sig; + added++; } catch (e) { debugError('[MAP] addSymbol($type) failed at $ts: $e'); } - } else { + } else if (_coverageSymbolSig[key] != sig) { try { - await _mapController!.updateSymbol(existing, options); + await _mapController!.updateSymbol( + existing, + SymbolOptions( + geometry: LatLng(lat, lon), + iconImage: iconImage, + iconSize: iconSize, + ), + ); + _coverageSymbolSig[key] = sig; + updated++; } catch (e) { debugError('[MAP] updateSymbol($type) failed at $ts: $e'); } + } else { + skipped++; } } @@ -4017,12 +4045,20 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _coverageSymbols.keys.where((k) => !wantedKeys.contains(k)).toList(); for (final key in toRemove) { final sym = _coverageSymbols.remove(key); + _coverageSymbolSig.remove(key); if (sym != null) { try { await _mapController!.removeSymbol(sym); } catch (_) {} } } + + // Only log when work actually happened — a healthy sync over a large + // session should read "+0 added, 0 updated, N unchanged" (no native churn). + if (added > 0 || updated > 0 || toRemove.isNotEmpty) { + debugLog('[MAP] Coverage sync: +$added added, $updated updated, ' + '$skipped unchanged, ${toRemove.length} removed'); + } } /// Returns true if the given GPS marker style should rotate to face the From 117c0ced1b5c01df8d5654591763daea1371a89d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 06:27:04 -0400 Subject: [PATCH 075/100] =?UTF-8?q?-=20Fixed=20offline=20sessions=20failin?= =?UTF-8?q?g=20to=20upload=20=E2=80=94=20the=20app=20now=20waits=20for=20t?= =?UTF-8?q?he=20server=20to=20accept=20the=20upload=20session=20before=20s?= =?UTF-8?q?ending,=20and=20never=20discards=20pings=20it=20couldn't=20uplo?= =?UTF-8?q?ad=20(they're=20kept=20and=20retried=20instead=20of=20being=20?= =?UTF-8?q?=20lost=20as=20a=20"partial=20upload").?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/providers/app_state_provider.dart | 113 +++++++++++++------------- lib/services/api_service.dart | 2 +- 2 files changed, 57 insertions(+), 58 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 548b07d..26f71d0 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -4942,68 +4942,67 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog( '[OFFLINE] Authenticated with isolated session: $offlineSessionId'); - // Server needs ~3-5s to propagate session after auth - await Future.delayed(const Duration(seconds: 4)); + // Server can take several seconds to make a freshly-created offline session + // visible to /wardrive (read-after-write propagation). Give it a brief settle, + // then let the FIRST batch wait it out with a generous backoff — once any batch + // lands the session is valid for the rest. + await Future.delayed(const Duration(seconds: 2)); - // 4. Upload pings in batches of 50 with retry on session timing errors + // 4. Upload pings in batches of 50, retrying session/transient errors. const batchSize = 50; var uploadedCount = 0; - var discardedCount = 0; - var sessionFailed = false; final totalBatches = (pings.length + batchSize - 1) ~/ batchSize; - for (var i = 0; i < pings.length; i += batchSize) { - if (sessionFailed) break; + // Backoff (seconds) for session-propagation / transient errors. The first batch + // absorbs the session-propagation delay, so it gets a much longer budget than + // later batches (which only see a session error if it genuinely expired/revoked). + const firstBatchBackoff = [2, 3, 4, 5, 6, 8, 10]; + const laterBatchBackoff = [2, 4]; + for (var i = 0; i < pings.length; i += batchSize) { final batchNum = (i ~/ batchSize) + 1; onProgress?.call('Batch $batchNum/$totalBatches'); final batch = pings.skip(i).take(batchSize).toList(); + final backoff = i == 0 ? firstBatchBackoff : laterBatchBackoff; + var result = await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); - // Retry logic for session timing errors (server propagation delay) - if (result == UploadResult.sessionError) { - const maxRetries = 3; - for (var retry = 1; retry <= maxRetries; retry++) { - final delay = 2 * retry; - debugLog( - '[OFFLINE] Batch $batchNum session error, retry $retry/$maxRetries after ${delay}s'); - onProgress?.call('Batch $batchNum/$totalBatches (retry $retry)'); - await Future.delayed(Duration(seconds: delay)); - result = await _apiService.uploadBatchWithSessionId( - batch, offlineSessionId); - if (result != UploadResult.sessionError) break; - } - } else if (result == UploadResult.retryable) { - debugLog('[OFFLINE] Batch $batchNum retryable error, retrying after 2s'); - await Future.delayed(const Duration(seconds: 2)); - result = await _apiService.uploadBatchWithSessionId( - batch, offlineSessionId); - } - - // Process final result - switch (result) { - case UploadResult.success: - uploadedCount += batch.length; - debugLog( - '[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); - break; - case UploadResult.nonRetryable: - discardedCount += batch.length; - debugWarn('[OFFLINE] Batch $batchNum discarded (data error)'); - break; - case UploadResult.sessionError: - debugError( - '[OFFLINE] Batch $batchNum session error persists after retries, aborting'); - sessionFailed = true; - break; - case UploadResult.retryable: - debugError( - '[OFFLINE] Batch $batchNum still failing after retry, aborting'); - sessionFailed = true; - break; - } + // Retry only session-propagation / transient errors. nonRetryable + // (data/zone/key) errors are NOT retried — we stop and preserve instead. + for (var retry = 0; + retry < backoff.length && + (result == UploadResult.sessionError || + result == UploadResult.retryable); + retry++) { + final delay = backoff[retry]; + final kind = + result == UploadResult.sessionError ? 'session' : 'transient'; + debugLog( + '[OFFLINE] Batch $batchNum $kind error, retry ${retry + 1}/${backoff.length} after ${delay}s'); + onProgress?.call('Batch $batchNum/$totalBatches (retry ${retry + 1})'); + await Future.delayed(Duration(seconds: delay)); + result = + await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); + } + + if (result == UploadResult.success) { + uploadedCount += batch.length; + debugLog('[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); + continue; + } + + // Any non-success after retries: STOP and preserve the remaining pings. + // We never discard un-uploaded data — it stays in the file for a later retry. + final stopReason = result == UploadResult.sessionError + ? 'session error' + : result == UploadResult.nonRetryable + ? 'data/zone error' + : 'network error'; + debugWarn( + '[OFFLINE] Batch $batchNum stopped ($stopReason) — preserving remaining pings'); + break; } // Delay after posting before disconnect @@ -5018,25 +5017,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); debugLog('[OFFLINE] Isolated upload session released'); - // 6. Clean up session based on results - final totalProcessed = uploadedCount + discardedCount; - final remainingPings = pings.length - totalProcessed; + // 6. Clean up session based on results — prune ONLY successfully-uploaded + // pings; everything not uploaded is preserved in the file for a later retry. + final remainingPings = pings.length - uploadedCount; if (remainingPings <= 0) { await _offlineSessionService.markAsUploaded(filename); debugLog( - '[OFFLINE] Session complete: $uploadedCount uploaded, $discardedCount discarded from $filename'); + '[OFFLINE] Session complete: $uploadedCount uploaded from $filename'); notifyListeners(); return OfflineUploadResult.success; } else { - if (totalProcessed > 0) { + if (uploadedCount > 0) { await _offlineSessionService.removeProcessedPings( - filename, totalProcessed); + filename, uploadedCount); debugLog( - '[OFFLINE] Removed $totalProcessed processed pings, $remainingPings remain in $filename'); + '[OFFLINE] Removed $uploadedCount uploaded pings, $remainingPings preserved in $filename'); } debugWarn( - '[OFFLINE] Partial upload: $uploadedCount uploaded, $discardedCount discarded, $remainingPings remaining from $filename'); + '[OFFLINE] Partial upload: $uploadedCount uploaded, $remainingPings preserved in $filename'); notifyListeners(); return OfflineUploadResult.partialFailure; } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 58c1c78..2298550 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1285,7 +1285,7 @@ class ApiService { }; if (nonRetryableErrors.contains(reason)) { debugWarn( - '[API] Offline upload batch non-retryable error: $reason - discarding batch'); + '[API] Offline upload batch non-retryable error: $reason - stopping upload, pings preserved'); return UploadResult.nonRetryable; } From 1357328532a8764094a5aa9418b1e702cfcebee3 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 15:48:16 -0400 Subject: [PATCH 076/100] =?UTF-8?q?Offline=20sessions:=20capture=20device?= =?UTF-8?q?=20model,=20power=20level,=20and=20app=20version=20at=20record?= =?UTF-8?q?=20time=20for=20parity=20with=20live=20uploads=20=E2=80=94=20mo?= =?UTF-8?q?del=20was=20previously=20sent=20as=20the=20generic=20'Offline?= =?UTF-8?q?=20Upload'=20and=20power/version=20were=20derived=20at=20upload?= =?UTF-8?q?=20time=20instead=20of=20when=20the=20pings=20were=20recorded.?= =?UTF-8?q?=20Snapshotted=20fields=20are=20sent=20on=20upload=20auth=20wit?= =?UTF-8?q?h=20fallbacks=20for=20legacy=20sessions.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/providers/app_state_provider.dart | 31 ++++++++++++---- lib/services/offline_session_service.dart | 45 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 26f71d0..4e137ea 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -4701,6 +4701,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { deviceName: offlineDeviceName, contactUri: _offlineContactUri, radioConfig: _meshCoreConnection?.selfInfo?.radioConfigApi, + deviceModel: _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? + _manufacturerString, + powerLevel: _preferences.powerLevel, + appVersion: _appVersion, ); _offlineSessionService.finalizeCurrentSession(); debugLog('[APP] Saved offline session with ${pings.length} pings'); @@ -4729,6 +4734,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { deviceName: offlineDeviceName, contactUri: _offlineContactUri, radioConfig: _meshCoreConnection?.selfInfo?.radioConfigApi, + deviceModel: _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? + _manufacturerString, + powerLevel: _preferences.powerLevel, + appVersion: _appVersion, ); } @@ -4868,16 +4878,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 4. Authenticate with offline_mode: true, skipSessionStore: true // This prevents writing to shared _sessionId/_txAllowed/etc. + // Use the device/radio metadata snapshotted when the session was recorded so the upload + // matches a live session (feature parity); fall back to current values for legacy + // sessions saved before these fields were captured. + final uploadModel = session.deviceModel ?? 'Offline Upload'; + final uploadPower = session.powerLevel ?? _preferences.powerLevel; + final uploadVersion = session.appVersion ?? _appVersion; debugLog( - '[OFFLINE] Authenticating for offline upload with device: $deviceName'); + '[OFFLINE] Authenticating for offline upload with device: $deviceName ' + '(model: $uploadModel, power: ${uploadPower}w, ver: $uploadVersion)'); final authResult = await _apiService.requestAuth( reason: 'connect', publicKey: publicKey, who: deviceName, - appVersion: _appVersion, - power: _preferences.powerLevel, + appVersion: uploadVersion, + power: uploadPower, iataCode: zoneCode ?? _preferences.iataCode, - model: 'Offline Upload', + model: uploadModel, radioFreq: session.radioConfig, lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, @@ -4905,10 +4922,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { reason: 'register', contactUri: session.contactUri, who: deviceName, - appVersion: _appVersion, - power: _preferences.powerLevel, + appVersion: uploadVersion, + power: uploadPower, iataCode: zoneCode ?? _preferences.iataCode, - model: 'Offline Upload', + model: uploadModel, radioFreq: session.radioConfig, lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index b7ba554..ecd0aca 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -14,6 +14,9 @@ class OfflineSession { final String? deviceName; // Device name for display final String? contactUri; // Signed contact URI for registration during upload final String? radioConfig; // Radio config captured at record time ("910.525,62.5,7,5") + final String? deviceModel; // Device model/manufacturer captured at record time + final double? powerLevel; // TX power level (watts) captured at record time + final String? appVersion; // App version captured at record time final bool uploaded; // Track upload status OfflineSession({ @@ -25,6 +28,9 @@ class OfflineSession { this.deviceName, this.contactUri, this.radioConfig, + this.deviceModel, + this.powerLevel, + this.appVersion, this.uploaded = false, }); @@ -39,6 +45,9 @@ class OfflineSession { deviceName: json['deviceName'] as String?, contactUri: json['contactUri'] as String?, radioConfig: json['radioConfig'] as String?, + deviceModel: json['deviceModel'] as String?, + powerLevel: (json['powerLevel'] as num?)?.toDouble(), + appVersion: json['appVersion'] as String?, uploaded: json['uploaded'] as bool? ?? false, ); } @@ -54,6 +63,9 @@ class OfflineSession { 'deviceName': deviceName, 'contactUri': contactUri, 'radioConfig': radioConfig, + 'deviceModel': deviceModel, + 'powerLevel': powerLevel, + 'appVersion': appVersion, 'uploaded': uploaded, }; } @@ -73,6 +85,9 @@ class OfflineSession { deviceName: deviceName, contactUri: contactUri, radioConfig: radioConfig, + deviceModel: deviceModel, + powerLevel: powerLevel, + appVersion: appVersion, uploaded: uploaded ?? this.uploaded, ); } @@ -167,6 +182,9 @@ class OfflineSessionService { String? deviceName, String? contactUri, String? radioConfig, + String? deviceModel, + double? powerLevel, + String? appVersion, }) async { if (pings.isEmpty) { debugLog('[OFFLINE] No pings to save, skipping session creation'); @@ -185,6 +203,9 @@ class OfflineSessionService { if (devicePublicKey != null) 'device_public_key': devicePublicKey, if (deviceName != null) 'device_name': deviceName, if (radioConfig != null) 'radio_config': radioConfig, + if (deviceModel != null) 'device_model': deviceModel, + if (powerLevel != null) 'power_level': powerLevel, + if (appVersion != null) 'app_version': appVersion, }; final session = OfflineSession( @@ -196,6 +217,9 @@ class OfflineSessionService { deviceName: deviceName, contactUri: contactUri, radioConfig: radioConfig, + deviceModel: deviceModel, + powerLevel: powerLevel, + appVersion: appVersion, ); _sessions.insert(0, session); // Add at beginning (newest first) @@ -214,6 +238,9 @@ class OfflineSessionService { String? deviceName, String? contactUri, String? radioConfig, + String? deviceModel, + double? powerLevel, + String? appVersion, }) async { if (pings.isEmpty) { debugLog('[OFFLINE] No pings to auto-save, skipping'); @@ -233,6 +260,18 @@ class OfflineSessionService { if (effectiveRadioConfig != null) { updatedData['radio_config'] = effectiveRadioConfig; } + final effectiveDeviceModel = deviceModel ?? existing.deviceModel; + if (effectiveDeviceModel != null) { + updatedData['device_model'] = effectiveDeviceModel; + } + final effectivePowerLevel = powerLevel ?? existing.powerLevel; + if (effectivePowerLevel != null) { + updatedData['power_level'] = effectivePowerLevel; + } + final effectiveAppVersion = appVersion ?? existing.appVersion; + if (effectiveAppVersion != null) { + updatedData['app_version'] = effectiveAppVersion; + } _sessions[index] = OfflineSession( filename: existing.filename, @@ -243,6 +282,9 @@ class OfflineSessionService { deviceName: deviceName ?? existing.deviceName, contactUri: contactUri ?? existing.contactUri, radioConfig: effectiveRadioConfig, + deviceModel: effectiveDeviceModel, + powerLevel: effectivePowerLevel, + appVersion: effectiveAppVersion, ); await _saveSessions(); debugLog( @@ -262,6 +304,9 @@ class OfflineSessionService { deviceName: deviceName, contactUri: contactUri, radioConfig: radioConfig, + deviceModel: deviceModel, + powerLevel: powerLevel, + appVersion: appVersion, ); // saveSession inserts at index 0 (newest first) if (_sessions.isNotEmpty) { From 77107e904e9cefaf384c45216e34a494dc4ce7d4 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 15:55:54 -0400 Subject: [PATCH 077/100] Fix app crash on launch / resume-from-background: reject invalid map coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 10 TestFlight crash reports share one root cause: an invalid GPS or stored coordinate (NaN / infinite / out-of-range) reached MapLibre's camera, whose native LatLng constructor throws std::domain_error — uncaught across the C++/Obj-C boundary, so it aborts the app (SIGABRT). iOS can briefly report an invalid CLLocation right after the app resumes from background (the "brought it back up and it crashed" reports), and a corrupted/stale lastKnownPosition from Hive explains the "instacrash on launch" reports. The prior _cameraAnimationReady one-frame delay only addressed GL-surface timing, not the invalid input. Two layers of defense: - Layer A (crash-stopper): validate every coordinate before it reaches the map camera. New shared isValidLatLng() guards the animate helpers, the three center builders feeding initialCameraPosition + style-load zoom, the focus/history fit-bounds (invalid points filtered), and the cluster zoom-ins in map_widget, plus the offline_maps_screen center. - Layer B (stop the bad data at the source): drop invalid fixes at the GPS stream, simulator, getCurrentPosition and getFreshPosition; ignore invalid stored last-known-position on load and never persist invalid coords. This also keeps invalid coordinates out of the API upload payloads. Adds lib/utils/geo_validation.dart + unit test. flutter analyze clean; full suite green (72 tests). --- lib/providers/app_state_provider.dart | 12 +++- lib/screens/offline_maps_screen.dart | 23 +++---- lib/services/gps_service.dart | 34 ++++++++-- lib/utils/geo_validation.dart | 14 ++++ lib/widgets/map_widget.dart | 93 ++++++++++++++++----------- test/utils/geo_validation_test.dart | 40 ++++++++++++ 6 files changed, 163 insertions(+), 53 deletions(-) create mode 100644 lib/utils/geo_validation.dart create mode 100644 test/utils/geo_validation_test.dart diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 4e137ea..28cc3bf 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -47,6 +47,7 @@ import '../services/ping_service.dart'; import '../services/countdown_timer_service.dart'; import '../services/custom_api_service.dart'; import '../utils/constants.dart'; +import '../utils/geo_validation.dart'; import '../utils/ping_colors.dart'; import '../services/wakelock_service.dart'; import '../utils/debug_logger_io.dart'; @@ -7304,10 +7305,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { final lat = box.get('last_position_lat') as double?; final lon = box.get('last_position_lon') as double?; - if (lat != null && lon != null) { + if (lat != null && lon != null && isValidLatLng(lat, lon)) { _lastKnownPosition = (lat: lat, lon: lon); debugLog('[GPS] Loaded last position: $lat, $lon'); notifyListeners(); // Trigger UI rebuild so map can center on last position + } else if (lat != null && lon != null) { + debugWarn('[GPS] Ignoring invalid stored last position: $lat, $lon'); } } catch (e) { debugLog('[GPS] Failed to load last position: $e'); @@ -7316,6 +7319,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Save last known GPS position to Hive storage (throttled to every 30 seconds) Future _saveLastPosition(double lat, double lon) async { + // Never persist invalid coords — a corrupted last-known position would be + // loaded as the initial map center on next launch and abort the app. + if (!isValidLatLng(lat, lon)) { + debugWarn('[GPS] Skipping save of invalid last position: $lat, $lon'); + return; + } + // Throttle saves to every 30 seconds to avoid excessive Hive operations final now = DateTime.now(); if (_lastPositionSaveTime != null && diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart index 4173d44..5549f91 100644 --- a/lib/screens/offline_maps_screen.dart +++ b/lib/screens/offline_maps_screen.dart @@ -10,6 +10,7 @@ import '../providers/app_state_provider.dart'; import '../services/offline_map_service.dart'; import '../services/tile_cache_service.dart'; import '../utils/debug_logger_io.dart'; +import '../utils/geo_validation.dart'; import '../widgets/app_toast.dart'; import '../widgets/map_widget.dart' show MapStyle, MapStyleExtension; @@ -1010,18 +1011,18 @@ class _DownloadRegionPageState extends State<_DownloadRegionPage> { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; - // Determine map center - prefer current GPS, fallback to last known, then Ottawa + // Determine map center - prefer current GPS, fallback to last known, then + // Ottawa. Only adopt a source position when its coords are valid — this + // feeds initialCameraPosition, and an invalid LatLng aborts the app in + // MapLibre's native ctor. LatLng center = _defaultCenter; - if (appState.currentPosition != null) { - center = LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, - ); - } else if (appState.lastKnownPosition != null) { - center = LatLng( - appState.lastKnownPosition!.lat, - appState.lastKnownPosition!.lon, - ); + final pos = appState.currentPosition; + final lastKnown = appState.lastKnownPosition; + if (pos != null && isValidLatLng(pos.latitude, pos.longitude)) { + center = LatLng(pos.latitude, pos.longitude); + } else if (lastKnown != null && + isValidLatLng(lastKnown.lat, lastKnown.lon)) { + center = LatLng(lastKnown.lat, lastKnown.lon); } return Scaffold( diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 43f951a..4167116 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -7,6 +7,7 @@ import 'package:permission_handler/permission_handler.dart'; import '../models/connection_state.dart'; import '../utils/debug_logger_io.dart'; +import '../utils/geo_validation.dart'; import 'gps_simulator_service.dart'; /// GPS service for location tracking @@ -246,6 +247,14 @@ class GpsService { debugLog( '[GPS SERVICE] Position stream fired: lat=${position.latitude.toStringAsFixed(5)}, ' 'lon=${position.longitude.toStringAsFixed(5)}, accuracy=${position.accuracy.toStringAsFixed(1)}m'); + // Drop invalid fixes (NaN/infinite/out-of-range). iOS can briefly + // report an invalid CLLocation after resume; passing it downstream + // aborts the app in MapLibre and poisons the upload payload. + if (!isValidLatLng(position.latitude, position.longitude)) { + debugWarn( + '[GPS] Dropping invalid position: lat=${position.latitude}, lon=${position.longitude}'); + return; + } _lastPosition = position; _positionController.add(position); @@ -390,6 +399,11 @@ class GpsService { debugLog( '[GPS] Fresh position acquired: ${position.latitude.toStringAsFixed(5)}, ' '${position.longitude.toStringAsFixed(5)} (accuracy: ${position.accuracy.toStringAsFixed(1)}m)'); + if (!isValidLatLng(position.latitude, position.longitude)) { + debugWarn( + '[GPS] Fresh position has invalid coords, using cached: lat=${position.latitude}, lon=${position.longitude}'); + return _lastPosition; + } _lastPosition = position; return position; } catch (e) { @@ -411,10 +425,16 @@ class GpsService { } try { - return await Geolocator.getCurrentPosition( + final position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high, timeLimit: const Duration(seconds: 15), ); + if (!isValidLatLng(position.latitude, position.longitude)) { + debugWarn( + '[GPS] getCurrentPosition returned invalid coords: lat=${position.latitude}, lon=${position.longitude}'); + return null; + } + return position; } catch (e) { debugLog('[GPS] getCurrentPosition failed: $e'); return null; @@ -456,6 +476,11 @@ class GpsService { // Subscribe to simulator positions _simulatorSubscription = simulator.positionStream.listen((position) { + if (!isValidLatLng(position.latitude, position.longitude)) { + debugWarn( + '[GPS] Dropping invalid simulator position: lat=${position.latitude}, lon=${position.longitude}'); + return; + } _lastPosition = position; _positionController.add(position); @@ -467,9 +492,10 @@ class GpsService { _simulatorEnabled = true; // Set initial position immediately from simulator - if (simulator.currentPosition != null) { - _lastPosition = simulator.currentPosition; - _positionController.add(simulator.currentPosition!); + final seed = simulator.currentPosition; + if (seed != null && isValidLatLng(seed.latitude, seed.longitude)) { + _lastPosition = seed; + _positionController.add(seed); } _updateStatus(GpsStatus.locked); // Simulator always has "lock" diff --git a/lib/utils/geo_validation.dart b/lib/utils/geo_validation.dart new file mode 100644 index 0000000..274f6be --- /dev/null +++ b/lib/utils/geo_validation.dart @@ -0,0 +1,14 @@ +// Coordinate-validity helpers shared across the GPS, state, and map layers. + +/// Whether [lat]/[lon] are safe to hand to MapLibre's camera. +/// +/// MapLibre's native `LatLng` constructor throws `std::domain_error` on NaN, +/// infinite, or out-of-range (|lat| > 90) coordinates. That C++ exception is +/// uncaught across the C++→Obj-C boundary, so it calls `std::terminate` and +/// aborts the whole app (`SIGABRT`). iOS can briefly report an invalid +/// `CLLocation` (e.g. right after the app resumes from background, or with a +/// negative accuracy), which `geolocator` passes straight through — so every +/// coordinate that can reach the map camera or be persisted/uploaded must be +/// validated against the WGS84 domain first. +bool isValidLatLng(double lat, double lon) => + lat.isFinite && lon.isFinite && lat.abs() <= 90 && lon.abs() <= 180; diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 003b224..fb06c5e 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -20,6 +20,7 @@ import '../services/gps_service.dart'; import '../utils/coverage_summary.dart'; import '../utils/coverage_tile_palette.dart'; import '../utils/debug_logger_io.dart'; +import '../utils/geo_validation.dart'; import '../utils/mvt_cells.dart'; import '../utils/distance_formatter.dart'; import '../utils/ping_colors.dart'; @@ -894,6 +895,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { !_canAnimateCamera) { return; } + // Guard against NaN/infinite/out-of-range coords — MapLibre's native LatLng + // ctor throws (uncaught → SIGABRT) on invalid input. See isValidLatLng. + if (!isValidLatLng(target.latitude, target.longitude)) { + debugWarn('[MAP] Skipping camera move to invalid target ' + '(${target.latitude}, ${target.longitude})'); + return; + } _mapController!.animateCamera( CameraUpdate.newLatLngZoom(target, targetZoom), duration: const Duration(milliseconds: 500), @@ -921,6 +929,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { !_canAnimateCamera) { return; } + // Guard against NaN/infinite/out-of-range coords — MapLibre's native LatLng + // ctor throws (uncaught → SIGABRT) on invalid input. See isValidLatLng. + if (!isValidLatLng(target.latitude, target.longitude)) { + debugWarn('[MAP] Skipping auto-follow camera move to invalid target ' + '(${target.latitude}, ${target.longitude})'); + return; + } _mapController!.animateCamera( CameraUpdate.newCameraPosition(CameraPosition( target: target, @@ -944,7 +959,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final points = [ pingLocation, ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon)) - ]; + ].where((p) => isValidLatLng(p.latitude, p.longitude)).toList(); if (points.length < 2) return; // Build bounding box from all points @@ -1139,31 +1154,28 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// before; only its trigger moved out of `build()`. Also called from `build()` /// as an idempotent safety net (all heavy work is version-gated). void _handleGpsPosition(AppStateProvider appState) { - // Map center — prefer current GPS, fallback to last known. + // Map center — prefer current GPS, fallback to last known. Only adopt a + // source position when its coords are valid; otherwise stay on the safe + // default center (invalid coords abort the app in MapLibre's LatLng ctor). LatLng center = _defaultCenter; - if (appState.currentPosition != null) { - center = LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, - ); - } else if (appState.lastKnownPosition != null) { - center = LatLng( - appState.lastKnownPosition!.lat, - appState.lastKnownPosition!.lon, - ); + final pos = appState.currentPosition; + final lastKnown = appState.lastKnownPosition; + if (pos != null && isValidLatLng(pos.latitude, pos.longitude)) { + center = LatLng(pos.latitude, pos.longitude); + } else if (lastKnown != null && + isValidLatLng(lastKnown.lat, lastKnown.lon)) { + center = LatLng(lastKnown.lat, lastKnown.lon); } // One-time zoom to last known position when GPS is not yet available. if (appState.currentPosition == null && - appState.lastKnownPosition != null && + lastKnown != null && + isValidLatLng(lastKnown.lat, lastKnown.lon) && !_hasZoomedToLastKnown && _isMapReady && _canAnimateCamera) { _hasZoomedToLastKnown = true; - final lastKnownCenter = LatLng( - appState.lastKnownPosition!.lat, - appState.lastKnownPosition!.lon, - ); + final lastKnownCenter = LatLng(lastKnown.lat, lastKnown.lon); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _animateToPositionWithZoom(lastKnownCenter, 15.0 - _zoomEpsilon); @@ -1311,18 +1323,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _rotationLocked = appState.preferences.mapRotationLocked; } - // Determine map center - prefer current GPS, fallback to last known, then Ottawa + // Determine map center - prefer current GPS, fallback to last known, then + // Ottawa. Only adopt a source position when its coords are valid — this + // feeds initialCameraPosition, and an invalid LatLng aborts the app in + // MapLibre's native ctor (the launch/resume crash). LatLng center = _defaultCenter; - if (appState.currentPosition != null) { - center = LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, - ); - } else if (appState.lastKnownPosition != null) { - center = LatLng( - appState.lastKnownPosition!.lat, - appState.lastKnownPosition!.lon, - ); + final centerPos = appState.currentPosition; + final centerLastKnown = appState.lastKnownPosition; + if (centerPos != null && + isValidLatLng(centerPos.latitude, centerPos.longitude)) { + center = LatLng(centerPos.latitude, centerPos.longitude); + } else if (centerLastKnown != null && + isValidLatLng(centerLastKnown.lat, centerLastKnown.lon)) { + center = LatLng(centerLastKnown.lat, centerLastKnown.lon); } // Camera-follow / derived heading / one-time zooms / GPS-marker puck now @@ -2146,7 +2159,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // always null at non-max zoom (the camera-change collapse fires when the // user zooms out), so no collapse-handling is needed here. if (!_isAtMaxZoom()) { - if (_canAnimateCamera) { + if (_canAnimateCamera && + isValidLatLng(coordinates.latitude, coordinates.longitude)) { final currentZoom = _mapController?.cameraPosition?.zoom ?? _defaultZoom; final newZoom = math.min(currentZoom + 2, _maxUserZoom); @@ -2252,7 +2266,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // so read `point_count` directly instead of re-querying. if (properties['cluster'] == true) { if (!_isAtMaxZoom()) { - if (_canAnimateCamera) { + if (_canAnimateCamera && + isValidLatLng(coordinates.latitude, coordinates.longitude)) { final currentZoom = _mapController?.cameraPosition?.zoom ?? _defaultZoom; final newZoom = math.min(currentZoom + 2, _maxUserZoom); @@ -2475,12 +2490,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (!_hasStyleLoadedOnce) { _hasStyleLoadedOnce = true; - // Center on GPS if available (initial centering) - if (appState.currentPosition != null && _canAnimateCamera) { - final center = LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, - ); + // Center on GPS if available (initial centering). Skip on invalid + // coords — an out-of-range/NaN LatLng aborts the app in MapLibre. + final stylePos = appState.currentPosition; + if (stylePos != null && + isValidLatLng(stylePos.latitude, stylePos.longitude) && + _canAnimateCamera) { + final center = LatLng(stylePos.latitude, stylePos.longitude); _mapController!.animateCamera( CameraUpdate.newLatLngZoom(center, _defaultZoom), ); @@ -4693,7 +4709,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Fit camera to show all history session markers void _fitCameraToHistoryMarkers(List markers) { final pts = markers - .where((m) => m.latitude != null && m.longitude != null) + .where((m) => + m.latitude != null && + m.longitude != null && + isValidLatLng(m.latitude!, m.longitude!)) .toList(); if (pts.isEmpty) return; diff --git a/test/utils/geo_validation_test.dart b/test/utils/geo_validation_test.dart new file mode 100644 index 0000000..b2aac14 --- /dev/null +++ b/test/utils/geo_validation_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/geo_validation.dart'; + +void main() { + group('isValidLatLng', () { + test('accepts normal coordinates', () { + expect(isValidLatLng(45.4215, -75.6972), isTrue); // Ottawa + expect(isValidLatLng(0, 0), isTrue); + expect(isValidLatLng(-33.8688, 151.2093), isTrue); // Sydney + }); + + test('accepts exact domain edges', () { + expect(isValidLatLng(90, 180), isTrue); + expect(isValidLatLng(-90, -180), isTrue); + }); + + test('rejects NaN', () { + expect(isValidLatLng(double.nan, 0), isFalse); + expect(isValidLatLng(0, double.nan), isFalse); + expect(isValidLatLng(double.nan, double.nan), isFalse); + }); + + test('rejects infinity', () { + expect(isValidLatLng(double.infinity, 0), isFalse); + expect(isValidLatLng(0, double.negativeInfinity), isFalse); + }); + + test('rejects out-of-range latitude', () { + expect(isValidLatLng(91, 0), isFalse); + expect(isValidLatLng(-91, 0), isFalse); + expect(isValidLatLng(200, 0), isFalse); + }); + + test('rejects out-of-range longitude', () { + expect(isValidLatLng(0, 181), isFalse); + expect(isValidLatLng(0, -181), isFalse); + expect(isValidLatLng(0, 200), isFalse); + }); + }); +} From 146a2e5971fbe8ff499b293a55c52820753c7733 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 16:35:16 -0400 Subject: [PATCH 078/100] Detailed-mode cell tap: filled dominant-colour footprint + dimmed backdrop (web parity) Replaces the cyan outline box around a tapped cell's 3x3 block with the web front end's spot-click look (highlightSpotCoverage): the block fills as a grid of cells in one dominant colour and the coverage backdrop dims to 0.15 so the footprint pops, restored when the summary sheet closes (honouring ping-focus). The dominant colour is the highest-priority status (green > cyan > orange > purple > grey > red) among only the pings whose blob covers the tapped cell, so a red-dominant block repaints green-looking neighbours that were coloured by a ping outside the blob (the intentional smear). Applies in both grid modes (3x3 Detailed, single cell Simplified). Highlight + dim appear with the fetched pings, driven off the same fetch the summary sheet already uses (no extra network call). --- lib/utils/coverage_summary.dart | 59 +++++++++ lib/utils/coverage_tile_palette.dart | 6 + lib/widgets/map_widget.dart | 135 +++++++++++++++------ test/utils/coverage_summary_test.dart | 80 ++++++++++++ test/utils/coverage_tile_palette_test.dart | 32 +++++ 5 files changed, 273 insertions(+), 39 deletions(-) create mode 100644 test/utils/coverage_tile_palette_test.dart diff --git a/lib/utils/coverage_summary.dart b/lib/utils/coverage_summary.dart index 2c7d725..ef701a2 100644 --- a/lib/utils/coverage_summary.dart +++ b/lib/utils/coverage_summary.dart @@ -132,6 +132,65 @@ class GridCell { [minLon, minLat], ]; } + + /// One closed ring (`[lon, lat]` pairs) per individual cell in the + /// (2·[blob]+1)² block centred on this cell — nine cells in Detailed + /// (blob = 1), one in Simplified (blob = 0). Used to fill the tapped footprint + /// as a grid of bordered cells, matching the web's nine `L.rectangle`s. + List>> blockCellPolygons(int blob) { + final rings = >>[]; + for (var di = -blob; di <= blob; di++) { + for (var dj = -blob; dj <= blob; dj++) { + final minLat = (i + di) * latStep; + final maxLat = (i + di + 1) * latStep; + final minLon = (j + dj) * lonStep; + final maxLon = (j + dj + 1) * lonStep; + rings.add([ + [minLon, minLat], + [maxLon, minLat], + [maxLon, maxLat], + [minLon, maxLat], + [minLon, minLat], + ]); + } + } + return rings; + } +} + +/// The single dominant coverage `st` category (1..6) across [points], or null +/// when empty. Port of the web's `highlightSpotCoverage` colour pick: each ping +/// maps to a status bucket, and the highest-priority one present wins +/// (green < cyan < orange < purple < grey < red, i.e. the lowest st). Because +/// the caller passes only the pings whose blob covers the tapped cell, this is +/// the "intentional smear" — a green-looking neighbour repaints to the local +/// dominant when its green came from a ping outside the blob. +int? dominantCoverageStatus(List> points) { + int? best; + for (final p in points) { + final st = _pointStatusCategory(p); + if (best == null || st < best) best = st; + } + return best; +} + +/// A single ping's coverage `st` category (1=green, 2=cyan, 3=orange, +/// 4=purple, 5=grey, 6=red). Mirrors `highlightSpotCoverage` / +/// `coverageStatusColor`: status 1 (BIDIR) is green either way, so the +/// repeats check only documents the web's branch. +int _pointStatusCategory(Map p) { + final status = _toInt(p['status']); + final hr = p['heard_repeats']; + final dh = p['direct_heard']; + final hasRepeats = (hr is String && hr.isNotEmpty && hr != 'None') || + (dh is String && dh.isNotEmpty && dh != 'None'); + if (status == 1 && hasRepeats) return 1; // green + if (status == 2) return 3; // orange (TX) + if (status == 5) return 4; // purple (RX) + if (status == 6 || status == 7) return 2; // cyan (DISC/TRACE) + if (status == 1) return 1; // green (BIDIR) + if (status == 3) return 5; // grey (dead) + return 6; // red (drop / unknown) } // --- repeater lookup (ports repObj / repeaterByHex / repeaterByFullHex) ------ diff --git a/lib/utils/coverage_tile_palette.dart b/lib/utils/coverage_tile_palette.dart index 7d90909..16555fc 100644 --- a/lib/utils/coverage_tile_palette.dart +++ b/lib/utils/coverage_tile_palette.dart @@ -62,6 +62,12 @@ class CoverageTilePalette { static List> _paletteFor(String cvdMode) => _palettes[cvdMode] ?? _palettes['none']!; + /// `[fill, border]` hex for coverage status [st] (1..6, clamped) under + /// [cvdMode] — the literal colours used to paint a tapped cell's footprint + /// in one uniform colour (the web's `highlightSpotCoverage` block fill). + static List colorsForStatus(String cvdMode, int st) => + _paletteFor(cvdMode)[st.clamp(1, 6) - 1]; + /// Builds `['match', ['get','st'], 1, c1, ..., 5, c5, c6]` — st 6 doubles /// as the match default so unknown future codes render as red, the same /// fallthrough the server-side mapping uses. diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index fb06c5e..08b1ea2 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -539,13 +539,21 @@ class _MapWidgetState extends State with WidgetsBindingObserver { static const String _patchLayerId = 'meshmapper-coverage-patch-layer'; bool _patchLayerReady = false; - // Tap-to-highlight overlay: an outline-only line layer tracing the clicked - // cell's (2·blob+1)² block (3×3 Detailed / 1 cell Simplified), centred on the - // tapped tile. Installed once (empty); populated on a cell tap and cleared to - // empty when the summary sheet closes — no per-tap layer churn. + // Tap-to-highlight overlay: a fill layer painting the clicked cell's + // (2·blob+1)² block (3×3 Detailed / 1 cell Simplified), centred on the tapped + // tile, in one uniform dominant colour while the coverage backdrop dims + // (web highlightSpotCoverage parity). Installed once (empty); populated on a + // cell tap and cleared to empty when the summary sheet closes — no per-tap + // layer churn. static const String _cellHighlightSourceId = 'meshmapper-cell-highlight'; static const String _cellHighlightLayerId = 'meshmapper-cell-highlight-layer'; bool _cellHighlightReady = false; + // While a tapped cell's footprint is shown, the coverage backdrop is dimmed so + // the bright footprint pops (parity with the web's spot-click). True between + // _showCellFootprint and _clearCellHighlight; gates the opacity restore and + // suppresses the build-method opacity-sync (which would otherwise un-dim). + bool _coverageDimmedForCell = false; + static const double _kCellHighlightFadeOpacity = 0.15; int _lastAppliedPatchVersion = -1; bool _styleLoaded = false; bool _hasStyleLoadedOnce = @@ -1809,11 +1817,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Settings → General) and push it to the live raster layer without // rebuilding the whole overlay. Skipped while ping focus mode is active — // focus forces opacity to 0 and _dismissPingFocus restores the preference - // value directly. + // value directly — and while a tapped cell dims the backdrop (_clearCellHighlight + // restores it on sheet close), or this would un-dim it mid-sheet. final wantedOpacity = appState.preferences.coverageOverlayOpacity; if (_isMapReady && _styleLoaded && _focusedPingLocation == null && + !_coverageDimmedForCell && _lastAppliedCoverageOpacity != null && _lastAppliedCoverageOpacity != wantedOpacity) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -1995,24 +2005,36 @@ class _MapWidgetState extends State with WidgetsBindingObserver { '${coordinates.longitude.toStringAsFixed(5)} ' 'cell=${cell.i}/${cell.j} blob=$blob r=${radius.toStringAsFixed(0)}m'); - // Highlight the tapped cell's block (3×3 in Detailed, 1 cell in Simplified), - // always centred on the tapped tile. Fire-and-forget; cleared on sheet close. - _setCellHighlight(cell, blob); + _cellSummaryShowing = true; - final Future summaryFuture = appState + // Fetch the pings once, keep the ones whose blob covers the tapped cell, and + // drive BOTH the summary sheet and the footprint highlight off that list. + final Future>> blobPointsFuture = appState .fetchCellCoverage( lat: cell.centerLat, lon: cell.centerLon, radiusMeters: radius, ) - .then((points) => - GridSummary.fromPoints(cell.filterWithinBlob(points, blob), lookup)) + .then((points) => cell.filterWithinBlob(points, blob)); + + // Footprint highlight + backdrop dim appear with the data (web parity with + // highlightSpotCoverage(points)): the block fills in the dominant colour of + // the in-blob pings. No pings → no footprint, no dim (web early-return). + blobPointsFuture.then((pts) { + if (!mounted || !_cellSummaryShowing) return; + final st = dominantCoverageStatus(pts); + if (st != null) _showCellFootprint(cell, blob, st); + }).catchError((Object e) { + debugWarn('[COVERAGE] cell highlight failed: $e'); + }); + + final Future summaryFuture = blobPointsFuture + .then((pts) => GridSummary.fromPoints(pts, lookup)) .catchError((Object e) { debugWarn('[COVERAGE] cell summary failed: $e'); return null; }); - _cellSummaryShowing = true; showModalBottomSheet( context: context, useSafeArea: true, @@ -2703,26 +2725,29 @@ class _MapWidgetState extends State with WidgetsBindingObserver { Map _emptyFeatureCollection() => {'type': 'FeatureCollection', 'features': []}; - /// Outline-only FeatureCollection for the tapped cell's (2·blob+1)² block: a - /// single LineString tracing the block border, centred on the tapped tile. - Map _buildCellHighlightGeoJson(GridCell cell, int blob) => + /// Filled FeatureCollection for the tapped cell's (2·blob+1)² block — one + /// Polygon per cell (nine in Detailed, one in Simplified), centred on the + /// tapped tile. Drawn in a single uniform colour to mirror the web's nine + /// `L.rectangle`s. + Map _buildCellFootprintGeoJson(GridCell cell, int blob) => { 'type': 'FeatureCollection', 'features': [ - { - 'type': 'Feature', - 'properties': {}, - 'geometry': { - 'type': 'LineString', - 'coordinates': cell.blockRing(blob), + for (final ring in cell.blockCellPolygons(blob)) + { + 'type': 'Feature', + 'properties': {}, + 'geometry': { + 'type': 'Polygon', + 'coordinates': [ring], + }, }, - }, ], }; - /// Idempotently (re)create the tap cell-highlight source + line layer. A line - /// layer over a tiny LineString ring — no fill, and nothing renders while the - /// source is empty (the idle state). Self-healing like the patch layer. + /// Idempotently (re)create the tap cell-highlight source + fill layer. Nothing + /// renders while the source is empty (the idle state); colours are set per tap + /// in [_showCellFootprint]. Self-healing like the patch layer. Future _ensureCellHighlightLayer() async { if (_cellHighlightReady) return true; if (_mapController == null) return false; @@ -2738,15 +2763,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } catch (_) {} await _mapController! .addGeoJsonSource(_cellHighlightSourceId, _emptyFeatureCollection()); - await _mapController!.addLineLayer( + await _mapController!.addFillLayer( _cellHighlightSourceId, _cellHighlightLayerId, - const LineLayerProperties( - lineColor: '#00d2ff', - lineWidth: 2.5, - lineOpacity: 0.95, - lineCap: 'round', - lineJoin: 'round', + // Placeholder colours; the per-tap dominant colour is pushed in + // _showCellFootprint. Full opacity so the footprint pops over the + // dimmed coverage backdrop. + const FillLayerProperties( + fillColor: '#bd2130', + fillOutlineColor: '#8b101b', + fillOpacity: 1.0, ), belowLayerId: belowLayer, ); @@ -2758,22 +2784,53 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } - /// Draw the highlight block for the tapped [cell] (centred on it). [blob] = 1 - /// in Detailed (3×3), 0 in Simplified (single cell). - Future _setCellHighlight(GridCell cell, int blob) async { - if (_mapController == null) return; + /// Fill the tapped [cell]'s block in the [st] dominant colour and dim the + /// coverage backdrop so the footprint stands out (web `highlightSpotCoverage` + /// parity). [blob] = 1 in Detailed (3×3), 0 in Simplified (single cell). + Future _showCellFootprint(GridCell cell, int blob, int st) async { + if (_mapController == null || !mounted) return; + // Read the palette before any await so context isn't used across an async gap. + final cvd = context.read().preferences.colorVisionType; + final colors = CoverageTilePalette.colorsForStatus(cvd, st); if (!await _ensureCellHighlightLayer()) return; try { + // setLayerProperties serializes with skipNulls:false (resets omitted + // fields to spec defaults), so resend all three fill props together. + await _mapController!.setLayerProperties( + _cellHighlightLayerId, + FillLayerProperties( + fillColor: colors[0], + fillOutlineColor: colors[1], + fillOpacity: 1.0, + ), + ); await _mapController!.setGeoJsonSource( - _cellHighlightSourceId, _buildCellHighlightGeoJson(cell, blob)); + _cellHighlightSourceId, _buildCellFootprintGeoJson(cell, blob)); + // Dim the coverage backdrop (base + patch) beneath the bright footprint. + _coverageDimmedForCell = true; + await _applyCoverageOverlayOpacity(_kCellHighlightFadeOpacity); } catch (e) { debugLog('[COVERAGE] cell-highlight set failed: $e'); } } - /// Clear the tap highlight (summary sheet closed): set the source to empty so - /// the layer renders nothing. The layer stays installed (cheap, no churn). + /// Clear the tap highlight (summary sheet closed): empty the source so the + /// layer renders nothing, and restore the dimmed coverage backdrop. The layer + /// stays installed (cheap, no churn). Future _clearCellHighlight() async { + if (_coverageDimmedForCell) { + _coverageDimmedForCell = false; + // Restore the backdrop, honouring ping-focus mode (which keeps it hidden). + if (mounted) { + final restore = _focusedPingLocation != null + ? 0.0 + : context + .read() + .preferences + .coverageOverlayOpacity; + await _applyCoverageOverlayOpacity(restore); + } + } if (_mapController == null || !_cellHighlightReady) return; try { await _mapController! diff --git a/test/utils/coverage_summary_test.dart b/test/utils/coverage_summary_test.dart index 6b8487f..535ba33 100644 --- a/test/utils/coverage_summary_test.dart +++ b/test/utils/coverage_summary_test.dart @@ -337,4 +337,84 @@ void main() { expect(ne[0], closeTo(21 * lonStep, 1e-12)); }); }); + + group('GridCell.blockCellPolygons', () { + const latStep = 0.0009; + const lonStep = 0.00128; + const cell = GridCell(10, 20, latStep, lonStep); + + test('blob=0 yields the single tapped cell', () { + final polys = cell.blockCellPolygons(0); + expect(polys.length, 1); + final ring = polys.first; + expect(ring.length, 5); + expect(ring.first, ring.last); // closed ring, [lon, lat] order + expect(ring[0][0], closeTo(20 * lonStep, 1e-12)); // minLon + expect(ring[0][1], closeTo(10 * latStep, 1e-12)); // minLat + expect(ring[2][0], closeTo(21 * lonStep, 1e-12)); // maxLon + expect(ring[2][1], closeTo(11 * latStep, 1e-12)); // maxLat + }); + + test('blob=1 yields nine unit cells covering the 3x3 block', () { + final polys = cell.blockCellPolygons(1); + expect(polys.length, 9); + final iSet = {}; + final jSet = {}; + for (final ring in polys) { + expect(ring.length, 5); + expect(ring.first, ring.last); + // Each polygon is exactly one grid cell (1 lonStep x 1 latStep). + expect(ring[2][0] - ring[0][0], closeTo(lonStep, 1e-12)); + expect(ring[2][1] - ring[0][1], closeTo(latStep, 1e-12)); + jSet.add((ring[0][0] / lonStep).round()); + iSet.add((ring[0][1] / latStep).round()); + } + // The 3x3 covers exactly i in 9..11 and j in 19..21, centred on (10, 20). + expect(iSet, {9, 10, 11}); + expect(jSet, {19, 20, 21}); + }); + }); + + group('dominantCoverageStatus', () { + Map p(int status, + {String? heardRepeats, String? directHeard}) => + { + 'lat': 45.0, + 'lon': -75.0, + 'status': status, + if (heardRepeats != null) 'heard_repeats': heardRepeats, + if (directHeard != null) 'direct_heard': directHeard, + }; + + test('returns null for no points', () { + expect(dominantCoverageStatus(const []), isNull); + }); + + test('maps each status to its st category (web parity)', () { + expect(dominantCoverageStatus([p(1, heardRepeats: 'ab(5)')]), 1); // green + expect(dominantCoverageStatus([p(1)]), 1); // BIDIR is green either way + expect(dominantCoverageStatus([p(2)]), 3); // TX -> orange + expect(dominantCoverageStatus([p(5)]), 4); // RX -> purple + expect(dominantCoverageStatus([p(6)]), 2); // DISC -> cyan + expect(dominantCoverageStatus([p(7)]), 2); // TRACE -> cyan + expect(dominantCoverageStatus([p(3)]), 5); // DEAD -> grey + expect(dominantCoverageStatus([p(0)]), 6); // DROP -> red + expect(dominantCoverageStatus([p(99)]), 6); // unknown -> red + }); + + test('green wins over red when both are in the blob', () { + expect(dominantCoverageStatus([p(0), p(1)]), 1); // red + green -> green + expect(dominantCoverageStatus([p(1), p(0)]), 1); // order independent + }); + + test('an all-red blob stays red (the intentional smear)', () { + // The green a neighbour cell shows came from a ping OUTSIDE this blob, so + // the in-blob pings are all red -> the whole block resolves to red. + expect(dominantCoverageStatus([p(0), p(0)]), 6); + }); + + test('picks the highest-priority (lowest st) across a mix', () { + expect(dominantCoverageStatus([p(2), p(6), p(3)]), 2); // orange/cyan/grey -> cyan + }); + }); } diff --git a/test/utils/coverage_tile_palette_test.dart b/test/utils/coverage_tile_palette_test.dart new file mode 100644 index 0000000..e09c789 --- /dev/null +++ b/test/utils/coverage_tile_palette_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/coverage_tile_palette.dart'; + +void main() { + group('CoverageTilePalette.colorsForStatus', () { + test('none palette maps st to [fill, border]', () { + expect(CoverageTilePalette.colorsForStatus('none', 1), + ['#1e7e34', '#14522d']); // green + expect(CoverageTilePalette.colorsForStatus('none', 2), + ['#17a2b8', '#117a8b']); // cyan + expect(CoverageTilePalette.colorsForStatus('none', 6), + ['#bd2130', '#8b101b']); // red + }); + + test('unknown cvd mode falls back to none', () { + expect(CoverageTilePalette.colorsForStatus('bogus', 1), + CoverageTilePalette.colorsForStatus('none', 1)); + }); + + test('clamps out-of-range st into 1..6', () { + expect(CoverageTilePalette.colorsForStatus('none', 0), + CoverageTilePalette.colorsForStatus('none', 1)); + expect(CoverageTilePalette.colorsForStatus('none', 99), + CoverageTilePalette.colorsForStatus('none', 6)); + }); + + test('a cvd palette differs from none', () { + expect(CoverageTilePalette.colorsForStatus('protanopia', 1), + isNot(CoverageTilePalette.colorsForStatus('none', 1))); + }); + }); +} From a74b53010b71b639312d993b92be2f42dcbda782 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 20:46:31 -0400 Subject: [PATCH 079/100] Cell-summary & repeater-detail popups: bright map (no scrim) + minimize button Both tap popups now match the ping-focus sheets: a transparent modal barrier so the map and markers stay fully bright (the tile footprint's 3x3 highlight + 0.15 coverage dim still pop), plus a minimize button that collapses the sheet to a tappable bottom pill, leaving the map interactive. Adds a generic _MinimizedInfoPopup + pill (kept separate from focus state), extracts _presentCellSummarySheet for reuse on reshow, and threads an optional onMinimize into CellSummarySheet. Opening a new popup supersedes a minimized one (a minimized cell's footprint is cleaned up when superseded by a repeater; cell-to-cell keeps the old footprint until the new data lands, so no flash). --- lib/widgets/cell_summary_sheet.dart | 15 +++ lib/widgets/map_widget.dart | 197 +++++++++++++++++++++++++++- 2 files changed, 205 insertions(+), 7 deletions(-) diff --git a/lib/widgets/cell_summary_sheet.dart b/lib/widgets/cell_summary_sheet.dart index 90648da..4710fcf 100644 --- a/lib/widgets/cell_summary_sheet.dart +++ b/lib/widgets/cell_summary_sheet.dart @@ -13,10 +13,15 @@ class CellSummarySheet extends StatelessWidget { final Future summaryFuture; final bool isImperial; + /// When non-null, a minimize button is shown in the header (collapses the + /// sheet to a pill, like the ping-focus sheets). Null = close-only. + final VoidCallback? onMinimize; + const CellSummarySheet({ super.key, required this.summaryFuture, required this.isImperial, + this.onMinimize, }); // Web GRID SUMMARY palette (dev/index.php getPalette 'none'). @@ -52,6 +57,16 @@ class CellSummarySheet extends StatelessWidget { letterSpacing: 0.5), ), ), + if (onMinimize != null) + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: onMinimize, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + color: theme.colorScheme.onSurfaceVariant, + tooltip: 'Minimize', + ), + if (onMinimize != null) const SizedBox(width: 8), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 08b1ea2..245e335 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -390,6 +390,25 @@ class _ResolvedRepeater { const _ResolvedRepeater(this.repeater, this.snr, this.ambiguous); } +/// A minimized info popup (cell summary or repeater detail) rendered as a +/// bottom pill the user can tap to re-open. [onReshow] re-presents the sheet; +/// [onClose] dismisses it and runs any cleanup (e.g. clearing the cell +/// footprint). Mirrors the ping-focus minimize, but for the tap popups. +class _MinimizedInfoPopup { + final String title; + final IconData icon; + final Color color; + final VoidCallback onReshow; + final VoidCallback onClose; + const _MinimizedInfoPopup({ + required this.title, + required this.icon, + required this.color, + required this.onReshow, + required this.onClose, + }); +} + /// Map widget with TX/RX markers /// Uses MapLibre GL with OpenFreeMap vector tiles class MapWidget extends StatefulWidget { @@ -501,6 +520,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { bool _focusPanelMinimized = false; dynamic _focusedPingSource; // TxPing | RxPing | DiscLogEntry | TraceLogEntry + // A minimized cell-summary / repeater-detail popup, shown as a tappable pill + // (parity with the ping-focus minimize). Separate from focus state so it can't + // entangle focus mode; null when nothing is minimized. + _MinimizedInfoPopup? _minimizedInfoPopup; + // MapLibre style and overlay tracking. // Tracks the zone code we last rendered the coverage overlay for. When the // zone check succeeds after the style has already loaded (e.g. first check @@ -1518,6 +1542,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ), + // Minimized cell-summary / repeater-detail pill — same idea as the focus + // pill, for the tap popups. Map stays interactive while it's shown. + if (_minimizedInfoPopup != null) + Positioned( + bottom: 16 + MediaQuery.of(context).padding.bottom, + left: 16, + right: 16, + child: Center( + child: _buildMinimizedInfoPill(_minimizedInfoPopup!), + ), + ), + // History session pill (bottom, styled like minimized focus panel) if (appState.viewingHistorySession) Positioned( @@ -2005,7 +2041,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { '${coordinates.longitude.toStringAsFixed(5)} ' 'cell=${cell.i}/${cell.j} blob=$blob r=${radius.toStringAsFixed(0)}m'); - _cellSummaryShowing = true; + // Supersede a currently-minimized popup. If it was a cell, its footprint + // stays until this tap's own footprint replaces it (no bright flash); if it + // was a repeater, just drop the pill. + if (_minimizedInfoPopup != null) { + setState(() => _minimizedInfoPopup = null); + } // Fetch the pings once, keep the ones whose blob covers the tapped cell, and // drive BOTH the summary sheet and the footprint highlight off that list. @@ -2035,20 +2076,68 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return null; }); - showModalBottomSheet( + _presentCellSummarySheet( + cell: cell, + blob: blob, + summaryFuture: summaryFuture, + isImperial: isImperial, + ); + } + + /// Present (or re-present) the cell GRID SUMMARY sheet for [cell]. Bright map + /// (transparent barrier so the footprint dim still pops) + a minimize button + /// that collapses to a tappable pill, mirroring the ping-focus sheets. The + /// footprint highlight + dim stay while minimized and are cleared on a real + /// close. Reused by both the initial tap and the pill's reshow. + void _presentCellSummarySheet({ + required GridCell cell, + required int blob, + required Future summaryFuture, + required bool isImperial, + }) { + _cellSummaryShowing = true; + final pillColor = Theme.of(context).colorScheme.primary; + showModalBottomSheet( context: context, useSafeArea: true, + // Transparent barrier so the map stays bright (footprint dim still pops). + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (context) => CellSummarySheet( + builder: (ctx) => CellSummarySheet( summaryFuture: summaryFuture, isImperial: isImperial, + onMinimize: () => Navigator.pop(ctx, 'minimized'), ), - ).whenComplete(() { + ).then((result) { _cellSummaryShowing = false; - _clearCellHighlight(); + if (!mounted) return; + if (result == 'minimized') { + setState(() { + _minimizedInfoPopup = _MinimizedInfoPopup( + title: 'Grid Summary', + icon: Icons.grid_on, + color: pillColor, + onReshow: () { + setState(() => _minimizedInfoPopup = null); + _presentCellSummarySheet( + cell: cell, + blob: blob, + summaryFuture: summaryFuture, + isImperial: isImperial, + ); + }, + onClose: () { + setState(() => _minimizedInfoPopup = null); + _clearCellHighlight(); + }, + ); + }); + } else { + _clearCellHighlight(); + } }); } @@ -6516,6 +6605,69 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + /// Pill for a minimized cell-summary / repeater-detail popup (parity with + /// [_buildMinimizedFocusPanel]). Tap (or the up-arrow) re-opens it; the X + /// closes it and runs its cleanup. + Widget _buildMinimizedInfoPill(_MinimizedInfoPopup popup) { + return GestureDetector( + onTap: popup.onReshow, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(popup.icon, color: popup.color, size: 18), + const SizedBox(width: 8), + Flexible( + child: Text( + popup.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: popup.onReshow, + child: Icon( + Icons.keyboard_arrow_up, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: popup.onClose, + child: Icon( + Icons.close, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + Widget _buildMinimizedFocusPanel() { final source = _focusedPingSource; String title; @@ -7990,6 +8142,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Show repeater details popup void _showRepeaterDetails(Repeater repeater, {bool isDuplicate = false, int? regionHopBytesOverride}) { + // Supersede any currently-minimized popup (clears a prior cell footprint/dim). + _minimizedInfoPopup?.onClose(); + // Determine icon badge color based on primary status final iconColor = _getRepeaterMarkerColor(repeater, isDuplicate); @@ -8035,9 +8190,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { : repeater.hexId.toUpperCase(); final clockSkew = humanizeClockSkew(repeater.timeOffset); - showModalBottomSheet( + showModalBottomSheet( context: context, useSafeArea: true, + // Transparent barrier so the map stays bright (like focus mode). + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), @@ -8092,6 +8249,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ), const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 8), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), @@ -8254,7 +8419,25 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ], ), ), - ); + ).then((result) { + if (!mounted) return; + if (result == 'minimized') { + setState(() { + _minimizedInfoPopup = _MinimizedInfoPopup( + title: repeater.name, + icon: Icons.cell_tower, + color: iconColor, + onReshow: () { + setState(() => _minimizedInfoPopup = null); + _showRepeaterDetails(repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: regionHopBytesOverride); + }, + onClose: () => setState(() => _minimizedInfoPopup = null), + ); + }); + } + }); } /// One labelled icon row in the repeater detail card. A null [icon] leaves the From d8a934624465127e888c32da387b7ad464588f07 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 21:43:17 -0400 Subject: [PATCH 080/100] Minimized cell/repeater pill: hide the control bar (like focus mode) The minimize pill sat underneath the ping control panel. Mirror focus mode's mechanism: add an AppStateProvider.infoPopupMinimized flag and OR it into the same control-panel visibility conditions + map bottom-padding calc in home_screen that isFocusModeActive/viewingHistorySession already use. The pill set/clear is centralized in _setMinimizedInfoPopup/_clearMinimizedInfoPopup so the flag stays in lockstep across every show/reshow/close/supersede path. --- lib/providers/app_state_provider.dart | 11 +++ lib/screens/home_screen.dart | 28 +++++-- lib/widgets/map_widget.dart | 104 +++++++++++++++++--------- 3 files changed, 100 insertions(+), 43 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 28cc3bf..daf227e 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -856,6 +856,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // A cell-summary / repeater-detail popup is minimized to a bottom pill — hide + // the control panel and zero the map's bottom padding for it, like focus mode. + bool _infoPopupMinimized = false; + bool get infoPopupMinimized => _infoPopupMinimized; + set infoPopupMinimized(bool value) { + if (_infoPopupMinimized != value) { + _infoPopupMinimized = value; + notifyListeners(); + } + } + // Repeater markers getters List get repeaters => List.unmodifiable(_repeaters); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index b974b8c..efcd725 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -60,7 +60,10 @@ class _HomeScreenState extends State { rev: p.mapRevision, focus: p.isFocusModeActive, history: p.viewingHistorySession, - padH: isLandscape || p.isFocusModeActive || p.viewingHistorySession + padH: isLandscape || + p.isFocusModeActive || + p.viewingHistorySession || + p.infoPopupMinimized ? 0.0 : _getControlPanelHeight(), ctrl: isLandscape ? _mapControlsExpanded : null, @@ -528,8 +531,12 @@ class _HomeScreenState extends State { ), ), - // Portrait: bottom control panel (hidden during focus mode and history view) - if (!isLandscape && !appState.isFocusModeActive && !appState.viewingHistorySession) + // Portrait: bottom control panel (hidden during focus mode, history view, + // and while a cell/repeater popup is minimized to a pill) + if (!isLandscape && + !appState.isFocusModeActive && + !appState.viewingHistorySession && + !appState.infoPopupMinimized) Positioned( bottom: 0, left: 0, @@ -539,14 +546,23 @@ class _HomeScreenState extends State { : _buildControlPanel(), ), - // Landscape: side control panel or FAB (hidden during focus mode and history view) - if (isLandscape && _showControlPanel && !appState.isFocusModeActive && !appState.viewingHistorySession) + // Landscape: side control panel or FAB (hidden during focus mode, history + // view, and while a cell/repeater popup is minimized to a pill) + if (isLandscape && + _showControlPanel && + !appState.isFocusModeActive && + !appState.viewingHistorySession && + !appState.infoPopupMinimized) Positioned( bottom: 16, left: leftInset, child: _buildLandscapeControlPanel(appState), ), - if (isLandscape && !_showControlPanel && !appState.isFocusModeActive && !appState.viewingHistorySession) + if (isLandscape && + !_showControlPanel && + !appState.isFocusModeActive && + !appState.viewingHistorySession && + !appState.infoPopupMinimized) Positioned( bottom: 16, left: leftInset, diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 245e335..18b3f60 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -2044,9 +2044,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Supersede a currently-minimized popup. If it was a cell, its footprint // stays until this tap's own footprint replaces it (no bright flash); if it // was a repeater, just drop the pill. - if (_minimizedInfoPopup != null) { - setState(() => _minimizedInfoPopup = null); - } + _clearMinimizedInfoPopup(); // Fetch the pings once, keep the ones whose blob covers the tapped cell, and // drive BOTH the summary sheet and the footprint highlight off that list. @@ -2115,26 +2113,24 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _cellSummaryShowing = false; if (!mounted) return; if (result == 'minimized') { - setState(() { - _minimizedInfoPopup = _MinimizedInfoPopup( - title: 'Grid Summary', - icon: Icons.grid_on, - color: pillColor, - onReshow: () { - setState(() => _minimizedInfoPopup = null); - _presentCellSummarySheet( - cell: cell, - blob: blob, - summaryFuture: summaryFuture, - isImperial: isImperial, - ); - }, - onClose: () { - setState(() => _minimizedInfoPopup = null); - _clearCellHighlight(); - }, - ); - }); + _setMinimizedInfoPopup(_MinimizedInfoPopup( + title: 'Grid Summary', + icon: Icons.grid_on, + color: pillColor, + onReshow: () { + _clearMinimizedInfoPopup(); + _presentCellSummarySheet( + cell: cell, + blob: blob, + summaryFuture: summaryFuture, + isImperial: isImperial, + ); + }, + onClose: () { + _clearMinimizedInfoPopup(); + _clearCellHighlight(); + }, + )); } else { _clearCellHighlight(); } @@ -4040,6 +4036,23 @@ class _MapWidgetState extends State with WidgetsBindingObserver { '${type}_${ts.millisecondsSinceEpoch}_' '${lat.toStringAsFixed(5)}_${lon.toStringAsFixed(5)}'; + /// Fixed base epoch for coverage-marker z-ordering. Keeps [_recencyZIndex] + /// small enough to stay exact in the native float32 symbol sort-key (integers + /// are exact up to 16,777,216). + static final DateTime _kZBaseEpoch = DateTime.utc(2025, 1, 1); + + /// Render/tap z-order for a coverage marker: seconds since [_kZBaseEpoch], so a + /// more-recent ping always sorts above (renders on top of, and is tapped in + /// preference to) an older one — globally, across TX/RX/DISC/Trace. + /// + /// The annotation manager's symbol layer uses `symbol-sort-key: ["get", + /// "zIndex"]`; with `icon-allow-overlap = true` a higher sort key overlaps a + /// lower one. Setting this also flips `symbol-z-order: auto` off its default + /// `viewport-y` (southernmost-on-top) fallback. It's a pure function of the + /// ping's own timestamp, so existing symbols never need re-pushing — the + /// incremental-sync skip path (gated on the icon/size signature) is untouched. + int _recencyZIndex(DateTime ts) => ts.difference(_kZBaseEpoch).inSeconds; + /// Diff-syncs native coverage symbols (TX/RX/DISC/Trace) against app state. /// One symbol per ping, image varies by type/success state, opacity reflects /// focus mode (faded if focus active and this isn't the focused ping). @@ -4097,6 +4110,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { geometry: LatLng(lat, lon), iconImage: iconImage, iconSize: iconSize, + // Recency-ordered sort key: the most recent ping renders on top + // and wins the native topmost-first tap hit-test, so an older + // overlapping marker is never selected in its place. + zIndex: _recencyZIndex(ts), ), {'kind': type, 'id': idForMetadata}, ); @@ -6605,6 +6622,21 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + /// Show the minimized info pill and hide the control panel (the focus/history + /// pills do the same — they share the bottom area). [infoPopupMinimized] zeroes + /// the map's bottom padding and removes the control panel in home_screen. + void _setMinimizedInfoPopup(_MinimizedInfoPopup popup) { + setState(() => _minimizedInfoPopup = popup); + context.read().infoPopupMinimized = true; + } + + /// Hide the minimized info pill and restore the control panel. + void _clearMinimizedInfoPopup() { + if (_minimizedInfoPopup == null) return; + setState(() => _minimizedInfoPopup = null); + context.read().infoPopupMinimized = false; + } + /// Pill for a minimized cell-summary / repeater-detail popup (parity with /// [_buildMinimizedFocusPanel]). Tap (or the up-arrow) re-opens it; the X /// closes it and runs its cleanup. @@ -8422,20 +8454,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ).then((result) { if (!mounted) return; if (result == 'minimized') { - setState(() { - _minimizedInfoPopup = _MinimizedInfoPopup( - title: repeater.name, - icon: Icons.cell_tower, - color: iconColor, - onReshow: () { - setState(() => _minimizedInfoPopup = null); - _showRepeaterDetails(repeater, - isDuplicate: isDuplicate, - regionHopBytesOverride: regionHopBytesOverride); - }, - onClose: () => setState(() => _minimizedInfoPopup = null), - ); - }); + _setMinimizedInfoPopup(_MinimizedInfoPopup( + title: repeater.name, + icon: Icons.cell_tower, + color: iconColor, + onReshow: () { + _clearMinimizedInfoPopup(); + _showRepeaterDetails(repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: regionHopBytesOverride); + }, + onClose: _clearMinimizedInfoPopup, + )); } }); } From 2b64b352e7ec63e35513207ca7209dfcdfa8dc69 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 23:10:28 -0400 Subject: [PATCH 081/100] Sounds follow media volume; landscape controls can minimize to a centered bottom bar - audio_service: route TX/RX/alert blips to the Android media stream (usage: media) so they track the media volume slider instead of the ringer/notification stream (#88). - home_screen: add a minimize button to the landscape control panel; the collapsed state shows a compact bar centered along the bottom of the map (#329). --- lib/screens/home_screen.dart | 85 ++++++++++++++++++++++++++++++++- lib/services/audio_service.dart | 7 ++- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index efcd725..e42d7cc 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -546,10 +546,11 @@ class _HomeScreenState extends State { : _buildControlPanel(), ), - // Landscape: side control panel or FAB (hidden during focus mode, history - // view, and while a cell/repeater popup is minimized to a pill) + // Landscape: full side control panel, bottom-left (hidden during focus + // mode, history view, and while a cell/repeater popup is minimized) if (isLandscape && _showControlPanel && + !_isControlsMinimized && !appState.isFocusModeActive && !appState.viewingHistorySession && !appState.infoPopupMinimized) @@ -558,6 +559,20 @@ class _HomeScreenState extends State { left: leftInset, child: _buildLandscapeControlPanel(appState), ), + // Landscape: minimized control bar, centered along the bottom of the map + // (see #329) + if (isLandscape && + _showControlPanel && + _isControlsMinimized && + !appState.isFocusModeActive && + !appState.viewingHistorySession && + !appState.infoPopupMinimized) + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Center(child: _buildLandscapeCompactControlPanel()), + ), if (isLandscape && !_showControlPanel && !appState.isFocusModeActive && @@ -708,6 +723,19 @@ class _HomeScreenState extends State { ), ), ), + // Minimize button - collapse to compact strip (see #329) + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => setState(() => _isControlsMinimized = true), + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(Icons.close_fullscreen, + size: 22, color: Colors.grey.shade400), + ), + ), + ), // Close button - larger touch target Material( color: Colors.transparent, @@ -738,6 +766,59 @@ class _HomeScreenState extends State { ); } + /// Build minimized landscape control panel — a compact strip mirroring the + /// portrait compact panel (CompactPingControls + expand button), sized for the + /// landscape side overlay. Reuses the shared `_isControlsMinimized` flag (see + /// #329). Status/zone stays visible via the floating status bar, so this only + /// needs the ping controls + an expand affordance. + Widget _buildLandscapeCompactControlPanel() { + return Container( + width: _landscapePanelWidth, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + // Compact controls (expands to fill available space) + const Expanded( + child: CompactPingControls(), + ), + // Vertical divider + Container( + height: 24, + width: 1, + margin: const EdgeInsets.symmetric(horizontal: 6), + color: Colors.grey.withValues(alpha: 0.3), + ), + // Expand button - restores the full landscape panel + GestureDetector( + onTap: () => setState(() => _isControlsMinimized = false), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon( + Icons.open_in_full, + size: 20, + color: Colors.grey.shade500, + ), + ), + ), + ], + ), + ), + ); + } + /// Compact zone chip for landscape panel Widget _buildZoneChip(AppStateProvider appState) { IconData icon; diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 64f3707..d0f0c90 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -68,7 +68,9 @@ class AudioService { // Android: transient focus allows other audio to continue androidAudioAttributes: AndroidAudioAttributes( contentType: AndroidAudioContentType.sonification, - usage: AndroidAudioUsage.notification, + // Route blips to the MEDIA stream so they follow the media + // volume slider (not the ringer/notification volume). See #88. + usage: AndroidAudioUsage.media, ), androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransientMayDuck, @@ -267,7 +269,8 @@ class AudioService { avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none, androidAudioAttributes: AndroidAudioAttributes( contentType: AndroidAudioContentType.sonification, - usage: AndroidAudioUsage.notification, + // Route blips to the MEDIA stream (follows media volume). See #88. + usage: AndroidAudioUsage.media, ), androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransientMayDuck, From 2ae6d33c8040891682e9f191c001380abf940d52 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 23:17:14 -0400 Subject: [PATCH 082/100] TX 'Broadcast My Coordinates': drop combined wire-tag+coords on-air format that hid pings from coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In broadcast-coords mode the app sent on-air "MM::," while still posting the bare wire_tag to /wardrive. The server's observer truncates the on-air body to 16 chars and exact-matches it as the wire tag, so it never joined the app's coverage row — the TX stayed at DEAD and never reached the coverage view (visible only in the analyzer). RX and privacy mode were unaffected. Broadcast-coords now uses the plain coords path: "MM:," on air, no wire_tag/ping_counter to the API (coords still travel via API lat/lon), matching the server's documented contract. No ping counter is consumed in this mode. --- lib/services/ping_service.dart | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 34c29d3..6d61b57 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -597,9 +597,15 @@ class PingService { // Build the on-air body ONCE (same string is used for TxTracker echo // correlation AND the actual transmission). Power is sent per-ping in the API. // - // With a session: a keyed wire tag "MM:" (privacy default), or - // "MM::lat,lon" when Broadcast My Coordinates is on (tag + plaintext coords). - // No session yet: plaintext "MM:lat,lon" (no tag can be computed). + // Two mutually-exclusive paths (they cannot be combined — the server's observer + // truncates the on-air body to 16 chars and exact-matches it as the wire tag, so + // appending coords to a tag breaks the observer→app join and the TX is stranded + // at DEAD/never shown in coverage): + // • Privacy (default, session present): keyed wire tag "MM:" on air, bare + // tag + counter posted to the API → server's validated wire-tag path. + // • Broadcast My Coordinates ON, or no session yet: plaintext "MM:lat,lon" on + // air and NO wire_tag/ping_counter to the API → server's coords path (coords + // also always travel via the API lat/lon either way). final coordsStr = '${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}'; final broadcastCoords = getBroadcastCoords?.call() ?? false; @@ -608,11 +614,10 @@ class PingService { int? txPingCounter; String? txWireTag; final hasSession = sessionId != null && sessionId.isNotEmpty; - if (hasSession) { + if (hasSession && !broadcastCoords) { // Session-limit guard: the wire tag's counter is 11 bits (max 2047). When it // is exhausted, end the session cleanly rather than repeating/corrupting tags. - // Applies to BOTH privacy and broadcast-coords modes — a combined ping consumes - // a counter exactly like a privacy ping, so the session ends identically. + // Only the privacy/wire-tag path consumes a counter. if ((getPingCounter?.call() ?? 0) >= 2047) { debugError('[SESSION] Reached session ping limit (2047) — disconnecting'); _pingInProgress = false; @@ -622,12 +627,11 @@ class PingService { txPingCounter = getNextPingCounter?.call() ?? 1; txWireTag = WireTagCodec.encode(sessionId, txPingCounter, getWireKey?.call()); - // Identical to privacy mode in every way EXCEPT broadcast-coords appends the - // plaintext coords to the on-air body. The bare tag still goes to the API - // (txWireTag → _pendingTxWireTag), so /wardrive validation + tx_pings are unchanged. - pingMessage = broadcastCoords ? '$txWireTag:$coordsStr' : txWireTag; + pingMessage = txWireTag; } else { - // No session yet → no tag can be computed; plaintext coords only (unchanged). + // Broadcast-coords mode (or no session yet): plaintext coords, NO wire tag. + // txPingCounter / txWireTag stay null → _pendingTx* null → the queued TX entry + // omits ping_counter/wire_tag → server uses its coords path. pingMessage = 'MM:$coordsStr'; } From 6a7e939757740401aac3907d4f739032fde269c1 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 23:23:05 -0400 Subject: [PATCH 083/100] Revert "TX 'Broadcast My Coordinates': drop combined wire-tag+coords on-air format" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the combined on-air format MM::, for broadcast-coords mode — the keyed region-tag is intentional so cross-region spillover can still be attributed even when a user broadcasts coordinates. The coverage-join breakage is being fixed server-side (api.php normalizes the on-air body back to the bare tag before the join + spillover decode) instead of removing the tag client-side. This reverts commit be1bfea36bf5ff8939f90c53d23e15a3a12921de. --- lib/services/ping_service.dart | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 6d61b57..34c29d3 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -597,15 +597,9 @@ class PingService { // Build the on-air body ONCE (same string is used for TxTracker echo // correlation AND the actual transmission). Power is sent per-ping in the API. // - // Two mutually-exclusive paths (they cannot be combined — the server's observer - // truncates the on-air body to 16 chars and exact-matches it as the wire tag, so - // appending coords to a tag breaks the observer→app join and the TX is stranded - // at DEAD/never shown in coverage): - // • Privacy (default, session present): keyed wire tag "MM:" on air, bare - // tag + counter posted to the API → server's validated wire-tag path. - // • Broadcast My Coordinates ON, or no session yet: plaintext "MM:lat,lon" on - // air and NO wire_tag/ping_counter to the API → server's coords path (coords - // also always travel via the API lat/lon either way). + // With a session: a keyed wire tag "MM:" (privacy default), or + // "MM::lat,lon" when Broadcast My Coordinates is on (tag + plaintext coords). + // No session yet: plaintext "MM:lat,lon" (no tag can be computed). final coordsStr = '${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}'; final broadcastCoords = getBroadcastCoords?.call() ?? false; @@ -614,10 +608,11 @@ class PingService { int? txPingCounter; String? txWireTag; final hasSession = sessionId != null && sessionId.isNotEmpty; - if (hasSession && !broadcastCoords) { + if (hasSession) { // Session-limit guard: the wire tag's counter is 11 bits (max 2047). When it // is exhausted, end the session cleanly rather than repeating/corrupting tags. - // Only the privacy/wire-tag path consumes a counter. + // Applies to BOTH privacy and broadcast-coords modes — a combined ping consumes + // a counter exactly like a privacy ping, so the session ends identically. if ((getPingCounter?.call() ?? 0) >= 2047) { debugError('[SESSION] Reached session ping limit (2047) — disconnecting'); _pingInProgress = false; @@ -627,11 +622,12 @@ class PingService { txPingCounter = getNextPingCounter?.call() ?? 1; txWireTag = WireTagCodec.encode(sessionId, txPingCounter, getWireKey?.call()); - pingMessage = txWireTag; + // Identical to privacy mode in every way EXCEPT broadcast-coords appends the + // plaintext coords to the on-air body. The bare tag still goes to the API + // (txWireTag → _pendingTxWireTag), so /wardrive validation + tx_pings are unchanged. + pingMessage = broadcastCoords ? '$txWireTag:$coordsStr' : txWireTag; } else { - // Broadcast-coords mode (or no session yet): plaintext coords, NO wire tag. - // txPingCounter / txWireTag stay null → _pendingTx* null → the queued TX entry - // omits ping_counter/wire_tag → server uses its coords path. + // No session yet → no tag can be computed; plaintext coords only (unchanged). pingMessage = 'MM:$coordsStr'; } From d99687fe61e821e11e1dde2d03af405ff1acdb4e Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 19 Jun 2026 23:31:11 -0400 Subject: [PATCH 084/100] Coverage tap-to-inspect: connection lines + repeater coverage cells Tapping coverage data now draws connection lines from the points the tap flow already fetches (no new API calls), matching the web client's matching/fan-out: - Tap a coverage tile: fan out blue dashed lines from the cell centre to every unique repeater that heard its pings (with distance labels), hiding the rest. - Tap a repeater: draw its coverage cells (status/tile colours) + status-coloured lines to each cell centre, dim the base tiles, and hide the other repeaters. Cap 5000 cells (farthest-first) so the full footprint draws. - Repeater pill: status shown as a coloured tower icon (green online / grey offline) so the stats row fits on one line. New helpers in coverage_summary.dart (fromCoverageWithPoints, repeaterCoverageCells, heardEndpointsForCell) reuse the existing web-parity matching logic. --- DEVELOPMENT.md | 34 + lib/utils/coverage_summary.dart | 272 +++++++- lib/widgets/map_widget.dart | 1132 ++++++++++++++++++++++++++----- 3 files changed, 1256 insertions(+), 182 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6005f94..784e1b9 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -369,6 +369,40 @@ vector-only — every region server must serve `vector_tile.php` (the legacy ras `lib/services/api_service.dart` (`freshenVectorTile`), `lib/utils/coverage_tile_palette.dart`, `lib/utils/mvt_cells.dart`. +### Coverage Connection Lines (tap-to-inspect) + +Tapping coverage data draws connection lines from points the tap flow ALREADY +fetches (no extra network calls), matching the web client's exact matching + +fan-out logic (`MeshMapper_Server/dev/index.php`): + +- **Tap a coverage tile (Feature A)**: fans out a theme-aware blue dashed line + from the cell centre to every UNIQUE repeater that heard the cell's pings (with + a distance pill per line) and hides the repeaters that didn't. Hooks the + blob-filtered points already computed in `_showCellSummary`. Port of + `updateAllActiveLines`/`updateActiveLinesInternal` via `heardEndpointsForCell`. +- **Tap a repeater (Feature B)**: draws the repeater's matched coverage cells + (status/tile-coloured fills, deduped per grid cell with highest-priority status + winning, red/DROP hidden) plus a status-coloured dashed line from the repeater + to each cell centre. The base coverage tiles DIM and every OTHER repeater is + hidden so the focused repeater's cells/lines pop (web `setSoloCircle` + + tile-dim parity); both restored on close. + Reuses the points fetched in `_showRepeaterDetails`. Port of + `buildChartFromPoints` (`RepeaterStats.fromCoverageWithPoints`) + + `drawRepeaterCoverageFromCache` (`repeaterCoverageCells`). +- **Volume cap**: both cap at the farthest 250 lines/cells (longest reach kept), + logged under `[COVERAGE]` when truncated. +- **Layers** (`map_widget.dart`): `coverage-lines-layer` (shared A/B, per-feature + `color`) and `coverage-cells-layer` (per-feature fill) — install-once empty, + updated via `setGeoJsonSource`, kept separate from the focus-mode lines so the + two features never wipe each other. Imperative draws (no `mapRevision` bump). + Teardown funnels through `_clearCellHighlight` (A) and `_clearRepeaterIsolation` + (B), which also restore the dimmed backdrop and the hidden/all repeaters. +- **Files**: `lib/widgets/map_widget.dart` (`_updateCoverageLines`, + `_updateCoverageCells`, `_drawRepeaterCoverage`, `_syncCoverageDistanceLabels`), + `lib/utils/coverage_summary.dart` (`heardEndpointsForCell`, + `repeaterCoverageCells`, `RepeaterStats.fromCoverageWithPoints`), + `lib/utils/coverage_tile_palette.dart` (`colorsForStatus`). + ### BLE Service UUIDs (MeshCore Companion Protocol) - Service: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` - RX Characteristic: `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` (write to device) diff --git a/lib/utils/coverage_summary.dart b/lib/utils/coverage_summary.dart index ef701a2..a316042 100644 --- a/lib/utils/coverage_summary.dart +++ b/lib/utils/coverage_summary.dart @@ -509,8 +509,26 @@ class RepeaterStats { int get totalMatched => bidir + tx + rx + disc + dead; factory RepeaterStats.fromCoverage( - List> points, Repeater target, RepeaterLookup lookup, - {bool disableDupLogic = false}) { + List> points, + Repeater target, + RepeaterLookup lookup, + {bool disableDupLogic = false}) => + fromCoverageWithPoints(points, target, lookup, + disableDupLogic: disableDupLogic) + .stats; + + /// Like [fromCoverage] but ALSO returns the raw points that matched this + /// repeater (parity with the web client's `buildChartFromPoints` + /// `_matchedPoints`), so callers can draw the repeater's coverage cells + + /// connection lines. [fromCoverage] delegates here, so there is ONE source of + /// matching truth. + static ({RepeaterStats stats, List> matched}) + fromCoverageWithPoints( + List> points, + Repeater target, + RepeaterLookup lookup, + {bool disableDupLogic = false}) { + final matchedPoints = >[]; final repId = target.id.toLowerCase(); final repLat = target.lat; final repLon = target.lon; @@ -599,6 +617,7 @@ class RepeaterStats { } if (matched) { + matchedPoints.add(p); switch (type) { case 'BIDIR': bidir++; @@ -621,7 +640,7 @@ class RepeaterStats { } } - return RepeaterStats( + final stats = RepeaterStats( bidir: bidir, tx: tx, rx: rx, @@ -629,5 +648,252 @@ class RepeaterStats { dead: dead, maxRangeMeters: maxRange > 0 ? maxRange : null, ); + return (stats: stats, matched: matchedPoints); } } + +// --- repeater coverage cells (Feature B) ------------------------------------- + +/// One coverage grid cell that a selected repeater heard, ready to render as a +/// status-coloured footprint cell plus a connection line to the repeater. Port +/// of the cell-dedup loop in the web client's `drawRepeaterCoverageFromCache` +/// (`dev/index.php:12208-12228`). +class RepeaterCoverageCell { + final int li; // latitude grid index + final int lj; // longitude grid index + final int st; // coverage status category 1..6 (lower = higher priority) + final double centerLat; + final double centerLon; + final double distanceMeters; // repeater -> cell centre (for the volume cap) + + const RepeaterCoverageCell({ + required this.li, + required this.lj, + required this.st, + required this.centerLat, + required this.centerLon, + required this.distanceMeters, + }); +} + +/// Dedups [matchedPoints] (from [RepeaterStats.fromCoverageWithPoints]) into the +/// grid cells the repeater heard, dilating each ping over a (2·[blob]+1)² block +/// (3×3 Detailed, 1×1 Simplified) and keeping the highest-priority status per +/// cell. Mirrors `drawRepeaterCoverageFromCache`: our `st` runs 1=green..6=red +/// (lower wins, INVERTED vs the web's `prio` map), so the keep test is +/// `newSt < existingSt`. Drops st==6 (red/DROP) cells — the app's vector overlay +/// is always the tiles/"advanced" equivalent, where the web hides drops +/// (`useAdv && st==='red'`). [latStep]/[lonStep] come from `kCoverageGridSteps`. +List repeaterCoverageCells( + List> matchedPoints, Repeater target, + {required double latStep, required double lonStep, required int blob}) { + final cells = {}; + for (final p in matchedPoints) { + final la = _toDouble(p['lat']); + final lo = _toDouble(p['lon']); + if (la == null || lo == null) continue; + final st = _pointStatusCategory(p); + if (st == 6) continue; // hide red/DROP cells (tiles/advanced parity) + final li0 = (la / latStep).floor(); + final lj0 = (lo / lonStep).floor(); + for (var dx = -blob; dx <= blob; dx++) { + for (var dy = -blob; dy <= blob; dy++) { + final li = li0 + dx; + final lj = lj0 + dy; + final key = '${li}_$lj'; + final existing = cells[key]; + if (existing != null && st >= existing.st) continue; // lower st wins + final centerLat = (li + 0.5) * latStep; + final centerLon = (lj + 0.5) * lonStep; + cells[key] = RepeaterCoverageCell( + li: li, + lj: lj, + st: st, + centerLat: centerLat, + centerLon: centerLon, + distanceMeters: + _haversineMeters(target.lat, target.lon, centerLat, centerLon), + ); + } + } + } + return cells.values.toList(); +} + +// --- cell fan-out endpoints (Feature A) -------------------------------------- + +/// One repeater endpoint that heard a ping in a tapped cell, ready to render as +/// a fan-out connection line from the cell centre. +class HeardEndpoint { + final double lat; // repeater location (the dedup key, 6dp) + final double lon; + final double? snr; // best SNR seen (parity / optional labelling) + final String repeaterId; // resolved repeater id (drives the fade set) + final double distanceMeters; // cell centre -> repeater (for the volume cap) + + const HeardEndpoint({ + required this.lat, + required this.lon, + required this.snr, + required this.repeaterId, + required this.distanceMeters, + }); +} + +final RegExp _hexRunRe = RegExp(r'[a-f0-9]+', caseSensitive: false); + +/// Resolves the UNIQUE repeater endpoints that heard any of [blobPoints] (the +/// pings whose blob covers a tapped cell), for the tile fan-out lines. Exact +/// port of the web client's `updateActiveLinesInternal` (`dev/index.php:13830`) +/// with the `drawLine`-intercept endpoint collection from `updateAllActiveLines` +/// (`:13740`): dedup by endpoint lat/lon (6dp, first-wins), skip duplicate-styled +/// and hidden candidates, skip VIA tokens already heard, honour the DISC +/// public_key pre-branch and the DISC/TRACE coord requirement. The +/// ambiguous-`?`-no-candidate fallback is omitted because it only ever yields +/// duplicate-marked endpoints (which the intercept skips). +List heardEndpointsForCell( + List> blobPoints, RepeaterLookup lookup, + {required double startLat, required double startLon}) { + final seen = {}; + + void addEndpoint(_RepInfo rep, double? snr) { + if (rep.hidden) return; + // Self-line guard (web drawLine skips start==end within 1e-6). + if ((startLat - rep.lat).abs() < 1e-6 && (startLon - rep.lon).abs() < 1e-6) { + return; + } + final key = '${rep.lat.toStringAsFixed(6)},${rep.lon.toStringAsFixed(6)}'; + if (seen.containsKey(key)) return; // first-wins (mirror web seenEndpoints) + seen[key] = HeardEndpoint( + lat: rep.lat, + lon: rep.lon, + snr: snr, + repeaterId: rep.id, + distanceMeters: _haversineMeters(startLat, startLon, rep.lat, rep.lon), + ); + } + + for (final p in blobPoints) { + final status = _toInt(p['status']); + if (status == 0) continue; + + // 0. DISC public_key pre-branch (web :13841): resolve the responding + // repeater by full hex and add it (coord-validated), then skip this ping. + if (status == 6 && p['public_key'] is String) { + final pkClean = + (p['public_key'] as String).replaceAll(_hexOnlyRe, '').toLowerCase(); + final targetRep = lookup._byFullHex[pkClean]; + if (targetRep != null) { + final hr = + p['heard_repeats'] is String ? p['heard_repeats'] as String : ''; + final cm = _coordBracketRe.firstMatch(hr); + if (cm != null) { + final tLat = double.tryParse(cm.group(1)!); + final tLon = double.tryParse(cm.group(2)!); + if (tLat != null && + tLon != null && + _within100m(targetRep.lat, targetRep.lon, tLat, tLon)) { + addEndpoint(targetRep, + _toDouble(p['remote_snr']) ?? _toDouble(p['local_snr'])); + } + } + continue; // web returns the whole ping here + } + } + + // Combined heard list + the heardIDs set used to skip already-heard VIAs. + final rawRepeats = []; + final hrRaw = p['heard_repeats']; + if (hrRaw is String && hrRaw.isNotEmpty && hrRaw != 'None') { + rawRepeats.add(hrRaw); + } + final dhRaw = p['direct_heard']; + if (dhRaw is String && dhRaw.isNotEmpty && dhRaw != 'None') { + rawRepeats.add(dhRaw); + } + final combinedRepeats = rawRepeats.join(' '); + final heardIDs = {}; + for (final m in _hexRunRe.allMatches(combinedRepeats)) { + heardIDs.add(m.group(0)!.toLowerCase()); + } + + // 1. VIA lines (web :13883): path tokens, skipping any also in heardIDs. + final via = p['via']; + if (via is String && via.isNotEmpty && via != 'Direct' && via != 'N/A') { + final cleanVia = via + .replaceAll(RegExp(r'\bDirect\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bNone\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bN/A\b', caseSensitive: false), ''); + for (final m in _tokenRe.allMatches(cleanVia)) { + final normId = m.group(1)!.toLowerCase(); + if (normId == 'd' || normId == 'direct') continue; + if (heardIDs.contains(normId)) continue; + var candidates = lookup._candidatesFor(normId); + final coordStr = m.group(4); + if (coordStr != null) { + final hasDup = candidates.any((rep) => rep.status == 2); + if (!hasDup) { + final c = coordStr.split(','); + final pLat = c.length >= 2 ? double.tryParse(c[0]) : null; + final pLon = c.length >= 2 ? double.tryParse(c[1]) : null; + candidates = (pLat == null || pLon == null) + ? const <_RepInfo>[] + : candidates + .where((rep) => _within100m(rep.lat, rep.lon, pLat, pLon)) + .toList(); + } + } else { + candidates = const <_RepInfo>[]; + } + for (final rep in candidates) { + if (rep.status == 2 && status != 6) continue; // dup -> skip endpoint + addEndpoint(rep, null); + } + } + } + + // 2. HEARD lines (web :13934): signal tokens, SNR-carried. + for (final m in _tokenRe.allMatches(combinedRepeats)) { + final rID = m.group(1)!.toLowerCase(); + var dbm = m.group(3); + final coordStr = m.group(4); + var candidates = lookup._candidatesFor(rID); + if (coordStr != null) { + final hasDup = candidates.any((rep) => rep.status == 2); + final c = coordStr.split(','); + final pLat = c.length >= 2 ? double.tryParse(c[0]) : null; + final pLon = c.length >= 2 ? double.tryParse(c[1]) : null; + if (!hasDup) { + candidates = (pLat == null || pLon == null) + ? const <_RepInfo>[] + : candidates + .where((rep) => _within100m(rep.lat, rep.lon, pLat, pLon)) + .toList(); + } + if (status == 6 || status == 7) { + candidates = (pLat == null || pLon == null) + ? const <_RepInfo>[] + : candidates + .where((rep) => _within100m(rep.lat, rep.lon, pLat, pLon)) + .toList(); + } + } else { + candidates = const <_RepInfo>[]; // no coords -> drop (default dup logic) + } + if ((dbm == null || dbm.isEmpty) && (status == 6 || status == 7)) { + dbm = (_toDouble(p['remote_snr']) ?? _toDouble(p['local_snr'])) + ?.toString(); + } + final snr = dbm != null ? double.tryParse(dbm) : null; + final fullHexMatch = lookup._byFullHex[rID] != null; + for (final rep in candidates) { + final isDup = + rep.status == 2 && !fullHexMatch && status != 6 && status != 7; + if (isDup) continue; // dup -> skip endpoint + addEndpoint(rep, snr); + } + } + } + + return seen.values.toList(); +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 18b3f60..163647b 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -390,20 +390,20 @@ class _ResolvedRepeater { const _ResolvedRepeater(this.repeater, this.snr, this.ambiguous); } -/// A minimized info popup (cell summary or repeater detail) rendered as a -/// bottom pill the user can tap to re-open. [onReshow] re-presents the sheet; -/// [onClose] dismisses it and runs any cleanup (e.g. clearing the cell -/// footprint). Mirrors the ping-focus minimize, but for the tap popups. +/// A minimized info popup (cell summary or repeater detail) rendered as a 2-row +/// bottom pill — and the DEFAULT state a tap opens in. [title] is the row-1 +/// identity (icon + name); [statsBuilder] builds the row-2 stat chips (it may +/// use a FutureBuilder for lazily-fetched values). [onReshow] expands to the +/// full detail sheet; [onClose] dismisses it and runs any cleanup (e.g. clearing +/// the cell footprint). Mirrors the ping-focus minimize, but for the tap popups. class _MinimizedInfoPopup { - final String title; - final IconData icon; - final Color color; + final Widget title; + final WidgetBuilder statsBuilder; final VoidCallback onReshow; final VoidCallback onClose; const _MinimizedInfoPopup({ required this.title, - required this.icon, - required this.color, + required this.statsBuilder, required this.onReshow, required this.onClose, }); @@ -497,6 +497,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Region boundary overlay toggle (on by default) bool _showRegionBorders = true; + // Repeater pins toggle (on by default). When false, _syncRepeaterSymbols + // pushes an empty FeatureCollection so the pins clear without touching the + // layer styling (see #347). + bool _showRepeaters = true; + // Collapsible map controls in landscape bool _mapControlsExpanded = true; @@ -550,6 +555,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Guards against opening two GRID SUMMARY sheets when both the feature-tap and // the onMapClick hit-test fire for the same coverage cell tap. bool _cellSummaryShowing = false; + // True from a tile tap until the cell popup (pill OR sheet) is closed — gates + // drawing the footprint highlight so it isn't drawn after a dismiss. Cleared + // in _clearCellHighlight (the teardown for both the pill and the sheet). + bool _cellPopupActive = false; // Grid size is baked into the tile URL and the CVD palette into the layer // paint; a change to either rebuilds the overlay via the build watcher. int? _lastAppliedGridSize; @@ -577,6 +586,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // _showCellFootprint and _clearCellHighlight; gates the opacity restore and // suppresses the build-method opacity-sync (which would otherwise un-dim). bool _coverageDimmedForCell = false; + // Same backdrop dim while a selected repeater's coverage cells/lines are shown + // (Feature B, web `drawRepeaterCoverageFromCache` parity). True between + // _drawRepeaterCoverage and _clearRepeaterIsolation; gates the opacity restore + // and suppresses the build-method opacity-sync. + bool _coverageDimmedForRepeater = false; static const double _kCellHighlightFadeOpacity = 0.15; int _lastAppliedPatchVersion = -1; bool _styleLoaded = false; @@ -641,6 +655,24 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // nudge the symbol-collision pass. bool _focusLinesInstalled = false; + // Community coverage connection lines/cells (tap a tile -> fan out to heard + // repeaters; tap a repeater -> its coverage cells + lines). Install-once empty + // layers, updated in place via setGeoJsonSource. Separate from the focus-mode + // lines (which key off _focusedPingLocation and are rebuilt by + // _syncAllAnnotations) so the two features never wipe each other. + bool _coverageLinesInstalled = false; + bool _coverageCellsInstalled = false; + // Set while a tapped cell's fan-out is shown: the repeater ids that heard the + // cell's pings. Non-null -> _buildRepeaterFeatureCollection hides every + // repeater NOT in the set (web fades-but-keeps; the app hides for consistency + // with focus/isolation). Built from the capped endpoints. Null = inactive. + Set? _coverageHeardRepeaterIds; + // Distance-label pills for the cell fan-out lines (own tracking, mirrors the + // focus-mode distance labels but keyed by endpoint lat/lon at 6dp). + final Map _coverageDistanceLabelSymbols = {}; + final Set _registeredCoverageLabelImages = {}; + final Map _registeredCoverageLabelImageSizes = {}; + // Native annotation tracking — populated by sync methods. // Maps from app-state IDs to MapLibre Symbol/Line objects so we can diff // (add new, update existing, remove deleted) on each data version change. @@ -728,6 +760,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // _spiderCollapseZoomDelta and collapse (positions become invalid). double? _spiderOpenedAtZoom; + // Repeater isolation — when non-null, ONLY this repeater is shown on the map; + // every other repeater is skipped in _buildRepeaterFeatureCollection (web + // parity: clicking a repeater hides the rest). Set when a single repeater's + // detail sheet opens, cleared when it closes/minimizes or on empty-map tap. + String? _isolatedRepeaterId; + // Default center (Ottawa) static const LatLng _defaultCenter = LatLng(45.4215, -75.6972); static const double _defaultZoom = 15.0; // Closer zoom for driving @@ -1549,9 +1587,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { bottom: 16 + MediaQuery.of(context).padding.bottom, left: 16, right: 16, - child: Center( - child: _buildMinimizedInfoPill(_minimizedInfoPopup!), - ), + child: _buildMinimizedInfoPill(_minimizedInfoPopup!), ), // History session pill (bottom, styled like minimized focus panel) @@ -1800,6 +1836,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (gridChanged) { appState.clearCoveragePatch(); } + // An open community coverage view (cell fan-out / repeater cells) is + // tied to the old grid steps/blob and palette — clear it so it doesn't + // render stale geometry/colours over the rebuilt overlay. + _clearCoverageConnections(); _lastAppliedGridSize = prefsForOverlay.coverageGridSize; _lastAppliedCvd = prefsForOverlay.colorVisionType; } @@ -1860,6 +1900,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _styleLoaded && _focusedPingLocation == null && !_coverageDimmedForCell && + !_coverageDimmedForRepeater && _lastAppliedCoverageOpacity != null && _lastAppliedCoverageOpacity != wantedOpacity) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -1988,6 +2029,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _collapseSpider(); return; // dismissing the spider shouldn't also open a cell summary } + // Safety net for a stranded isolation (active but no sheet and no pill + // keeping the selection alive). While minimized to a pill, the repeater + // stays the active selection, so leave the others hidden — don't restore. + if (_isolatedRepeaterId != null && _minimizedInfoPopup == null) { + _clearRepeaterIsolation(); // restore hidden repeaters, don't open a cell + return; + } + // Safety net for a stranded Feature A fan-out (faded repeaters + lines but + // no pill keeping the cell summary alive) — restore on empty tap. + if (_coverageHeardRepeaterIds != null && _minimizedInfoPopup == null) { + _clearCoverageConnections(); + return; + } // Fallback cell hit-test: some platforms don't dispatch coverage fill-layer // taps through onFeatureTapped, so query the coverage layer at the tap point. _maybeShowCellSummaryAt(point, coordinates); @@ -2043,8 +2097,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Supersede a currently-minimized popup. If it was a cell, its footprint // stays until this tap's own footprint replaces it (no bright flash); if it - // was a repeater, just drop the pill. + // was a repeater, drop the pill AND restore the hidden repeaters. _clearMinimizedInfoPopup(); + _clearRepeaterIsolation(); + _cellPopupActive = true; // Fetch the pings once, keep the ones whose blob covers the tapped cell, and // drive BOTH the summary sheet and the footprint highlight off that list. @@ -2060,13 +2116,44 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // highlightSpotCoverage(points)): the block fills in the dominant colour of // the in-blob pings. No pings → no footprint, no dim (web early-return). blobPointsFuture.then((pts) { - if (!mounted || !_cellSummaryShowing) return; + if (!mounted || !_cellPopupActive) return; final st = dominantCoverageStatus(pts); if (st != null) _showCellFootprint(cell, blob, st); }).catchError((Object e) { debugWarn('[COVERAGE] cell highlight failed: $e'); }); + // Feature A: fan out a dashed line from the cell centre to every UNIQUE + // repeater that heard the cell's pings (web `updateAllActiveLines` parity), + // hide the repeaters that didn't, and label each line with its distance. + // Lines are theme-aware blue (web keys off the basemap; we key off the + // Flutter theme). Read brightness before the async gap. + final fanColor = Theme.of(context).brightness == Brightness.dark + ? '#4da6ff' + : '#00008b'; + blobPointsFuture.then((pts) { + if (!mounted || !_cellPopupActive) return; + final eps = _capByFarthest( + heardEndpointsForCell(pts, lookup, + startLat: cell.centerLat, startLon: cell.centerLon), + (e) => e.distanceMeters, + 250, + '[COVERAGE] cell fan-out', + ); + final segments = [ + for (final e in eps) (lat: e.lat, lon: e.lon, color: fanColor) + ]; + _updateCoverageLines(segments, cell.centerLat, cell.centerLon); + _syncCoverageDistanceLabels( + segments, cell.centerLat, cell.centerLon, isImperial); + // Empty -> null restores all repeaters (never hide-all on an empty set). + _coverageHeardRepeaterIds = + eps.isEmpty ? null : {for (final e in eps) e.repeaterId}; + _syncRepeaterSymbols(appState); + }).catchError((Object e) { + debugWarn('[COVERAGE] cell fan-out failed: $e'); + }); + final Future summaryFuture = blobPointsFuture .then((pts) => GridSummary.fromPoints(pts, lookup)) .catchError((Object e) { @@ -2074,7 +2161,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return null; }); - _presentCellSummarySheet( + // Open minimized by default — a compact stats pill; tap it to expand to the + // full GRID SUMMARY sheet. + _minimizeCellSummary( cell: cell, blob: blob, summaryFuture: summaryFuture, @@ -2094,7 +2183,6 @@ class _MapWidgetState extends State with WidgetsBindingObserver { required bool isImperial, }) { _cellSummaryShowing = true; - final pillColor = Theme.of(context).colorScheme.primary; showModalBottomSheet( context: context, useSafeArea: true, @@ -2113,24 +2201,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _cellSummaryShowing = false; if (!mounted) return; if (result == 'minimized') { - _setMinimizedInfoPopup(_MinimizedInfoPopup( - title: 'Grid Summary', - icon: Icons.grid_on, - color: pillColor, - onReshow: () { - _clearMinimizedInfoPopup(); - _presentCellSummarySheet( - cell: cell, - blob: blob, - summaryFuture: summaryFuture, - isImperial: isImperial, - ); - }, - onClose: () { - _clearMinimizedInfoPopup(); - _clearCellHighlight(); - }, - )); + // Collapse back to the stats pill (keep the footprint). + _minimizeCellSummary( + cell: cell, + blob: blob, + summaryFuture: summaryFuture, + isImperial: isImperial, + ); } else { _clearCellHighlight(); } @@ -2168,7 +2245,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Spider stays open — users frequently compare stacked repeaters back to // back, so collapsing on every selection would be annoying. if (layerId == _spiderSymbolLayerId) { - _showRepeaterDetailsById(id); + // Don't isolate from inside a spider — users compare stacked repeaters + // back-to-back, so keep the spread open and all markers visible. + _showRepeaterDetailsById(id, isolate: false); return; } @@ -2441,7 +2520,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Open the repeater detail sheet for a given [repeaterId]. Looks up the /// Repeater object from app state and recomputes the duplicate/hopOverride /// flags. Used by both direct tap dispatch and the GPS fall-through path. - void _showRepeaterDetailsById(String repeaterId) { + void _showRepeaterDetailsById(String repeaterId, {bool isolate = true}) { if (!mounted) return; final appState = context.read(); final repeater = @@ -2457,9 +2536,39 @@ class _MapWidgetState extends State with WidgetsBindingObserver { repeater, isDuplicate: isDuplicate, regionHopBytesOverride: hopOverride, + isolate: isolate, ); } + /// Restore all repeaters after a repeater isolation (web-parity "click a + /// repeater hides the rest"). No-op if nothing is isolated. No setState — + /// nothing in build() reads [_isolatedRepeaterId]; the source is pushed + /// imperatively, matching the focus/spider resyncs. + void _clearRepeaterIsolation() { + if (_isolatedRepeaterId == null) return; + _isolatedRepeaterId = null; + // Tear down any Feature B coverage cells/lines drawn for the selection and + // restore the base coverage tiles it dimmed. + _clearCoverageLines(); + _clearCoverageCells(); + _restoreCoverageBackdropForRepeater(); + if (!mounted) return; + _syncRepeaterSymbols(context.read()); + } + + /// Restore the base coverage overlay opacity dimmed by a Feature B repeater + /// coverage draw (honours ping focus, which keeps the overlay hidden at 0). + /// No-op when not dimmed. + void _restoreCoverageBackdropForRepeater() { + if (!_coverageDimmedForRepeater) return; + _coverageDimmedForRepeater = false; + if (!mounted) return; + final restore = _focusedPingLocation != null + ? 0.0 + : context.read().preferences.coverageOverlayOpacity; + _applyCoverageOverlayOpacity(restore); + } + Future _onStyleLoaded(AppStateProvider appState) async { // Re-entrance guard. iOS plugin sometimes fires onStyleLoadedCallback // multiple times during a single setStyle. The race causes "Layer not @@ -2527,6 +2636,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // The tap cell-highlight overlay is likewise gone; _addCoverageOverlay // re-installs it below. _cellHighlightReady = false; + // Community coverage line/cell layers + their tracked symbols/images are + // also gone with the style; reset so the next draw treats it as fresh. + _coverageLinesInstalled = false; + _coverageCellsInstalled = false; + _coverageHeardRepeaterIds = null; + _coverageDistanceLabelSymbols.clear(); + _registeredCoverageLabelImages.clear(); + _registeredCoverageLabelImageSizes.clear(); // Disable symbol decluttering on the annotation manager. By default, // MapLibre symbol layers hide overlapping icons/labels at lower zoom to @@ -2903,6 +3020,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// layer renders nothing, and restore the dimmed coverage backdrop. The layer /// stays installed (cheap, no churn). Future _clearCellHighlight() async { + _cellPopupActive = false; + // Tear down the Feature A fan-out (lines + distance labels) and restore any + // repeaters faded to the heard set whenever the cell summary closes. Also + // drop any Feature B cells defensively (cheap no-op when none). + _restoreFadedRepeaters(); + _clearCoverageLines(); + _clearCoverageCells(); + _clearCoverageDistanceLabels(); if (_coverageDimmedForCell) { _coverageDimmedForCell = false; // Restore the backdrop, honouring ping-focus mode (which keeps it hidden). @@ -3350,6 +3475,23 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final features = >[]; for (final repeater in visible) { + // Repeater isolation: while a repeater is focused/selected, hide every + // other repeater entirely (skip the feature, so they also drop out of + // cluster counts) — same approach as focus mode below. Restored on close + // by _clearRepeaterIsolation. + if (_isolatedRepeaterId != null && repeater.id != _isolatedRepeaterId) { + continue; + } + // Feature A (tile fan-out): when a tapped cell's heard-repeater set is + // active and no single repeater is isolated, hide every repeater that did + // NOT hear the cell's pings (the web fades-but-keeps; we hide, matching + // focus/isolation). Cleared by _restoreFadedRepeaters. The set holds + // lowercased ids (from RepeaterLookup), so compare lowercased. + if (_coverageHeardRepeaterIds != null && + _isolatedRepeaterId == null && + !_coverageHeardRepeaterIds!.contains(repeater.id.toLowerCase())) { + continue; + } final isDuplicate = duplicates.contains(repeater.id); final statusKey = _repeaterStatusKey(repeater, isDuplicate); final isConnected = focusActive && @@ -3657,7 +3799,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return; } try { - final geojson = _buildRepeaterFeatureCollection(appState); + // When repeaters are toggled off, push an empty collection so the pins + // clear without removing/recreating the styled layers (see #347). + final geojson = _showRepeaters + ? _buildRepeaterFeatureCollection(appState) + : _emptyFeatureCollection(); // Detailed mode references baked per-chip images — make sure every one is // registered before pushing, or the symbol layer would reference a // missing icon. No-op in Simplified (features use the pre-registered @@ -4343,6 +4489,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { static const _focusLinesAmbiguousLayerId = 'focus-lines-ambiguous-border'; static const _focusLinesAmbiguousLabelId = 'focus-lines-ambiguous-label'; + // Source/layer IDs for the community coverage connection lines (shared by the + // tile fan-out and the repeater coverage view — mutually exclusive in time, + // per-feature `color`) and the repeater coverage cells (per-feature fill). + static const _coverageLinesSourceId = 'coverage-lines-source'; + static const _coverageLinesLayerId = 'coverage-lines-layer'; + static const _coverageCellsSourceId = 'coverage-cells-source'; + static const _coverageCellsLayerId = 'coverage-cells-layer'; + /// Builds and applies the focus-mode dotted polylines that visually connect /// a focused ping to each repeater that heard it. Color-coded by SNR; /// ambiguous matches get a wider white outline drawn underneath. @@ -4507,6 +4661,330 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + // =========================================================================== + // Community coverage connection lines + cells (Features A & B) + // =========================================================================== + + /// Volume cap shared by both coverage features (user choice: keep the + /// FARTHEST). When [items] exceeds [cap], sorts by [dist] DESCENDING and keeps + /// the first [cap] (so the longest-reach lines/cells always survive); logs the + /// truncation. Returns [items] unchanged when within the cap. + List _capByFarthest( + List items, double Function(T) dist, int cap, String logTag) { + if (items.length <= cap) return items; + final sorted = [...items]..sort((a, b) => dist(b).compareTo(dist(a))); + debugLog('$logTag truncated ${items.length}->$cap (kept farthest)'); + return sorted.take(cap).toList(); + } + + /// Idempotently (re)create the empty coverage connection-line layer + /// (data-driven dashed lines, below the repeater chips). Modeled on + /// [_ensureCellHighlightLayer]. + Future _ensureCoverageLinesLayer() async { + if (_coverageLinesInstalled) return true; + if (_mapController == null) return false; + try { + final belowLayer = _clusterLayersReady + ? _repeaterIndividualLayerId + : _symbolAnnotationLayerId(); + try { + await _mapController!.removeLayer(_coverageLinesLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_coverageLinesSourceId); + } catch (_) {} + await _mapController! + .addGeoJsonSource(_coverageLinesSourceId, _emptyFeatureCollection()); + await _mapController!.addLineLayer( + _coverageLinesSourceId, + _coverageLinesLayerId, + const LineLayerProperties( + lineColor: ['get', 'color'], + lineOpacity: 0.9, + lineWidth: 2.5, + lineDasharray: [2, 4], + lineCap: 'round', + ), + belowLayerId: belowLayer, + ); + _coverageLinesInstalled = true; + return true; + } catch (e) { + debugLog('[COVERAGE] coverage-lines layer create failed: $e'); + return false; + } + } + + /// Idempotently (re)create the empty coverage cells fill layer (Feature B), + /// anchored BELOW the lines layer so the lines read on top of the cells. + Future _ensureCoverageCellsLayer() async { + if (_coverageCellsInstalled) return true; + if (_mapController == null) return false; + try { + final belowLayer = _coverageLinesInstalled + ? _coverageLinesLayerId + : (_clusterLayersReady + ? _repeaterIndividualLayerId + : _symbolAnnotationLayerId()); + try { + await _mapController!.removeLayer(_coverageCellsLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_coverageCellsSourceId); + } catch (_) {} + await _mapController! + .addGeoJsonSource(_coverageCellsSourceId, _emptyFeatureCollection()); + await _mapController!.addFillLayer( + _coverageCellsSourceId, + _coverageCellsLayerId, + const FillLayerProperties( + fillColor: ['get', 'fill'], + fillOutlineColor: ['get', 'border'], + fillOpacity: 0.8, + ), + belowLayerId: belowLayer, + ); + _coverageCellsInstalled = true; + return true; + } catch (e) { + debugLog('[COVERAGE] coverage-cells layer create failed: $e'); + return false; + } + } + + /// Draw one dashed connection line per [segments] entry from + /// ([startLat],[startLon]) out to each segment's endpoint, coloured by the + /// segment's `color` (blue for the tile fan-out, status colour for a repeater). + /// Empty list clears the layer. + Future _updateCoverageLines( + List<({double lat, double lon, String color})> segments, + double startLat, + double startLon) async { + if (_mapController == null || !_styleLoaded) return; + if (segments.isEmpty) { + await _clearCoverageLines(); + return; + } + if (!await _ensureCoverageLinesLayer()) return; + final features = >[ + for (final s in segments) + { + 'type': 'Feature', + 'properties': {'color': s.color}, + 'geometry': { + 'type': 'LineString', + 'coordinates': [ + [startLon, startLat], + [s.lon, s.lat], + ], + }, + }, + ]; + try { + await _mapController!.setGeoJsonSource(_coverageLinesSourceId, + {'type': 'FeatureCollection', 'features': features}); + } catch (e) { + debugLog('[COVERAGE] coverage-lines set failed: $e'); + } + } + + /// Empty the coverage-lines source so it renders nothing (layer stays). + Future _clearCoverageLines() async { + if (_mapController == null || !_coverageLinesInstalled) return; + try { + await _mapController! + .setGeoJsonSource(_coverageLinesSourceId, _emptyFeatureCollection()); + } catch (e) { + debugLog('[COVERAGE] coverage-lines clear failed: $e'); + } + } + + /// Draw the selected repeater's coverage [cells] as status-coloured fills + /// (Feature B). [latStep]/[lonStep] size each cell's ring. Empty list clears. + Future _updateCoverageCells(List cells, String cvd, + double latStep, double lonStep) async { + if (_mapController == null || !_styleLoaded) return; + if (cells.isEmpty) { + await _clearCoverageCells(); + return; + } + if (!await _ensureCoverageCellsLayer()) return; + final features = >[]; + for (final c in cells) { + final colors = CoverageTilePalette.colorsForStatus(cvd, c.st); + final ring = GridCell(c.li, c.lj, latStep, lonStep).blockRing(0); + features.add({ + 'type': 'Feature', + 'properties': {'fill': colors[0], 'border': colors[1]}, + 'geometry': { + 'type': 'Polygon', + 'coordinates': [ring], + }, + }); + } + try { + await _mapController!.setGeoJsonSource(_coverageCellsSourceId, + {'type': 'FeatureCollection', 'features': features}); + } catch (e) { + debugLog('[COVERAGE] coverage-cells set failed: $e'); + } + } + + /// Empty the coverage-cells source so it renders nothing (layer stays). + Future _clearCoverageCells() async { + if (_mapController == null || !_coverageCellsInstalled) return; + try { + await _mapController! + .setGeoJsonSource(_coverageCellsSourceId, _emptyFeatureCollection()); + } catch (e) { + debugLog('[COVERAGE] coverage-cells clear failed: $e'); + } + } + + /// Place a distance-pill label at the midpoint of each coverage fan-out line + /// (Feature A). Mirrors [_syncDistanceLabels] but with its own tracking keyed + /// by endpoint lat/lon (6dp). Empty list removes all coverage labels. + Future _syncCoverageDistanceLabels( + List<({double lat, double lon, String color})> segments, + double startLat, + double startLon, + bool isImperial) async { + if (_mapController == null || !_styleLoaded) return; + if (segments.isEmpty) { + await _clearCoverageDistanceLabels(); + return; + } + final wanted = {}; + for (final s in segments) { + final key = '${s.lat.toStringAsFixed(6)},${s.lon.toStringAsFixed(6)}'; + wanted.add(key); + final midLat = (startLat + s.lat) / 2; + final midLon = (startLon + s.lon) / 2; + final meters = + GpsService.distanceBetween(startLat, startLon, s.lat, s.lon); + final labelText = meters < 1000 + ? formatMeters(meters, isImperial: isImperial) + : formatKilometers(meters / 1000, isImperial: isImperial); + final imageName = 'cov-dist-${labelText.hashCode}'; + if (!_registeredCoverageLabelImages.contains(imageName)) { + try { + final rendered = await _renderDistanceLabelPng(labelText); + await _mapController!.addImage(imageName, rendered.bytes); + _registeredCoverageLabelImages.add(imageName); + _registeredCoverageLabelImageSizes[imageName] = rendered.size; + } catch (e) { + debugError('[MAP] render/addImage(coverage label) failed: $e'); + } + } + final options = SymbolOptions( + geometry: LatLng(midLat, midLon), + iconImage: imageName, + iconSize: 1.0, + iconAnchor: 'center', + ); + final existing = _coverageDistanceLabelSymbols[key]; + if (existing == null) { + try { + _coverageDistanceLabelSymbols[key] = await _mapController! + .addSymbol(options, {'kind': 'cov-distance'}); + } catch (e) { + debugError('[MAP] addSymbol(coverage distance) failed: $e'); + } + } else { + try { + await _mapController!.updateSymbol(existing, options); + } catch (e) { + debugError('[MAP] updateSymbol(coverage distance) failed: $e'); + } + } + } + final toRemove = _coverageDistanceLabelSymbols.keys + .where((k) => !wanted.contains(k)) + .toList(); + for (final k in toRemove) { + final sym = _coverageDistanceLabelSymbols.remove(k); + if (sym != null) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + } + } + + /// Remove all coverage distance-label pills. + Future _clearCoverageDistanceLabels() async { + if (_mapController == null) return; + final toRemove = List.of(_coverageDistanceLabelSymbols.values); + _coverageDistanceLabelSymbols.clear(); + for (final sym in toRemove) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + } + + /// Restore all repeaters hidden by a Feature A fan-out (clears the heard set + /// and re-pushes the repeater source). No-op when no fade is active. No + /// setState — the source is pushed imperatively, like [_clearRepeaterIsolation]. + void _restoreFadedRepeaters() { + if (_coverageHeardRepeaterIds == null) return; + _coverageHeardRepeaterIds = null; + if (!mounted) return; + _syncRepeaterSymbols(context.read()); + } + + /// Tear down ALL community coverage visuals (lines, cells, labels, fade). + /// The single funnel used by every entry/exit path. + Future _clearCoverageConnections() async { + _restoreFadedRepeaters(); + _restoreCoverageBackdropForRepeater(); + await _clearCoverageLines(); + await _clearCoverageCells(); + await _clearCoverageDistanceLabels(); + } + + /// Feature B draw: render [matched] (the points that heard [repeater]) as + /// deduped status-coloured coverage cells plus a status-coloured dashed line + /// from the repeater to each cell centre. Caps at the farthest 250. The lines + /// layer is ensured BEFORE the cells layer so the lines read on top. + Future _drawRepeaterCoverage(Repeater repeater, + List> matched, String cvd, int gridSize) async { + final steps = kCoverageGridSteps[gridSize] ?? const [0.0009, 0.00128]; + final blob = gridSize == 100 ? 1 : 0; + // High cap so a repeater's full footprint draws (web parity — it draws all). + // Still bounded (farthest-first) to protect against pathological volumes. + final cells = _capByFarthest( + repeaterCoverageCells(matched, repeater, + latStep: steps[0], lonStep: steps[1], blob: blob), + (c) => c.distanceMeters, + 5000, + '[COVERAGE] repeater coverage', + ); + if (cells.isEmpty) { + await _clearCoverageCells(); + await _clearCoverageLines(); + return; + } + await _ensureCoverageLinesLayer(); + await _updateCoverageCells(cells, cvd, steps[0], steps[1]); + final segments = [ + for (final c in cells) + ( + lat: c.centerLat, + lon: c.centerLon, + color: CoverageTilePalette.colorsForStatus(cvd, c.st)[0], + ), + ]; + await _updateCoverageLines(segments, repeater.lat, repeater.lon); + // Dim the base coverage tiles so the repeater's coloured cells + lines pop + // (web `drawRepeaterCoverageFromCache` tile-dim parity). Restored in + // _clearRepeaterIsolation. Skip while ping focus already hid the overlay. + if (_focusedPingLocation == null) { + _coverageDimmedForRepeater = true; + await _applyCoverageOverlayOpacity(_kCellHighlightFadeOpacity); + } + } + /// Rebuilds the regional boundary layer from `appState.regionBorders`. /// Always-on: renders whenever polygons are present, independent of BLE /// or auth state. Idempotent — safe to call repeatedly (removes existing @@ -5152,86 +5630,123 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return PingColors.signalBad; } - /// Map controls (always vertical, used inside collapsible wrapper) + /// Map controls. Single vertical column in portrait; in landscape the taller + /// set is split into two columns so the lower icons don't run off the short + /// viewport (hidden under the bottom edge) — see issue #329 follow-up. Widget _buildMapControls(AppStateProvider appState) { final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + // Collect the active control buttons (conditionals preserved); dividers are + // interleaved later by _stackControlButtons so the two-column split is clean. + final buttons = [ + // Map style toggle + _buildControlButton( + icon: mapStyle.icon, + tooltip: 'Map Style: ${mapStyle.label}', + onPressed: () => _cycleMapStyle(appState), + ), + // MeshMapper overlay toggle (only show when zone code available) + if (appState.zoneCode != null) + _buildControlButton( + icon: Icons.layers, + tooltip: _showMeshMapperOverlay + ? 'Hide Coverage Overlay' + : 'Show Coverage Overlay', + onPressed: _toggleMeshMapperOverlay, + isActive: _showMeshMapperOverlay, + ), + // Repeater pins toggle (only show when repeaters are present) + if (appState.repeaters.isNotEmpty) + _buildControlButton( + icon: Icons.cell_tower, + tooltip: _showRepeaters ? 'Hide Repeaters' : 'Show Repeaters', + onPressed: () => _toggleRepeaters(appState), + isActive: _showRepeaters, + ), + // Region boundary toggle (only show when borders available) + if (appState.regionBorders.isNotEmpty) + _buildControlButton( + icon: Icons.fence, + tooltip: _showRegionBorders + ? 'Hide Region Boundary' + : 'Show Region Boundary', + onPressed: () => _toggleRegionBorders(appState), + isActive: _showRegionBorders, + ), + // Center on position / toggle auto-follow + _buildControlButton( + icon: _autoFollow ? Icons.my_location : Icons.location_searching, + tooltip: _autoFollow ? 'Following GPS' : 'Center on Position', + onPressed: + appState.currentPosition != null ? _centerOnPosition : null, + isActive: _autoFollow, + ), + // Always North toggle + _buildControlButton( + icon: _alwaysNorth ? Icons.navigation : Icons.explore, + tooltip: _alwaysNorth + ? 'Always North (Click to Rotate with Heading)' + : 'Rotating with Heading (Click for Always North)', + onPressed: _toggleNorthMode, + isActive: !_alwaysNorth, + ), + // Rotation lock toggle + _buildControlButton( + icon: _rotationLocked ? Icons.sync_disabled : Icons.rotate_right, + tooltip: _rotationLocked ? 'Unlock Rotation' : 'Lock Rotation', + onPressed: _toggleRotationLock, + isActive: _rotationLocked, + ), + // Legend button + _buildControlButton( + icon: Icons.info_outline, + tooltip: 'Legend & Info', + onPressed: _showLegendPopup, + ), + ]; + + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; + + final Widget body; + if (isLandscape && buttons.length > 4) { + // Split into two side-by-side columns so the set fits vertically. + final half = (buttons.length / 2).ceil(); + body = Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _stackControlButtons(buttons.sublist(0, half)), + Container(width: 1, color: Colors.white24), + _stackControlButtons(buttons.sublist(half)), + ], + ); + } else { + body = _stackControlButtons(buttons); + } + return Container( decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.7), // Controls are below the toggle button, so rounded bottom only borderRadius: const BorderRadius.vertical(bottom: Radius.circular(8)), ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Map style toggle - _buildControlButton( - icon: mapStyle.icon, - tooltip: 'Map Style: ${mapStyle.label}', - onPressed: () => _cycleMapStyle(appState), - ), - // MeshMapper overlay toggle (only show when zone code available) - if (appState.zoneCode != null) ...[ - _buildControlDivider(), - _buildControlButton( - icon: Icons.layers, - tooltip: _showMeshMapperOverlay - ? 'Hide Coverage Overlay' - : 'Show Coverage Overlay', - onPressed: _toggleMeshMapperOverlay, - isActive: _showMeshMapperOverlay, - ), - ], - // Region boundary toggle (only show when borders available) - if (appState.regionBorders.isNotEmpty) ...[ - _buildControlDivider(), - _buildControlButton( - icon: Icons.fence, - tooltip: _showRegionBorders - ? 'Hide Region Boundary' - : 'Show Region Boundary', - onPressed: () => _toggleRegionBorders(appState), - isActive: _showRegionBorders, - ), - ], - _buildControlDivider(), - // Center on position / toggle auto-follow - _buildControlButton( - icon: _autoFollow ? Icons.my_location : Icons.location_searching, - tooltip: _autoFollow ? 'Following GPS' : 'Center on Position', - onPressed: - appState.currentPosition != null ? _centerOnPosition : null, - isActive: _autoFollow, - ), - _buildControlDivider(), - // Always North toggle - _buildControlButton( - icon: _alwaysNorth ? Icons.navigation : Icons.explore, - tooltip: _alwaysNorth - ? 'Always North (Click to Rotate with Heading)' - : 'Rotating with Heading (Click for Always North)', - onPressed: _toggleNorthMode, - isActive: !_alwaysNorth, - ), - _buildControlDivider(), - // Rotation lock toggle - _buildControlButton( - icon: _rotationLocked ? Icons.sync_disabled : Icons.rotate_right, - tooltip: _rotationLocked ? 'Unlock Rotation' : 'Lock Rotation', - onPressed: _toggleRotationLock, - isActive: _rotationLocked, - ), - _buildControlDivider(), - // Legend button - _buildControlButton( - icon: Icons.info_outline, - tooltip: 'Legend & Info', - onPressed: _showLegendPopup, - ), - ], - ), + child: body, + ); + } + + /// Stack control buttons vertically with a divider between each (no leading or + /// trailing divider), matching the original single-column look. + Widget _stackControlButtons(List buttons) { + final children = []; + for (var i = 0; i < buttons.length; i++) { + if (i > 0) children.add(_buildControlDivider()); + children.add(buttons[i]); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: children, ); } @@ -5349,6 +5864,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } } + void _toggleRepeaters(AppStateProvider appState) { + setState(() { + _showRepeaters = !_showRepeaters; + }); + // Re-push the repeater source: real features when shown, empty when hidden. + _syncRepeaterSymbols(appState); + } + void _removeRegionBorders() { if (_mapController == null) return; try { @@ -6492,6 +7015,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { repeaters.where((r) => r.repeater.hasLocation).toList(growable: false); if (located.isEmpty) return; + // Session ping-focus and the community coverage views are distinct gestures; + // clear any open community fan-out / repeater cells (and their pill/isolation) + // so the two don't stack on the map. + _clearMinimizedInfoPopup(); + _clearRepeaterIsolation(); + _clearCoverageConnections(); + // Only save pre-focus state on first activation. When re-activating // (e.g. user taps a different ping while already in focus, or expanding // from minimized), we keep the original pre-focus snapshot so dismiss @@ -6641,15 +7171,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// [_buildMinimizedFocusPanel]). Tap (or the up-arrow) re-opens it; the X /// closes it and runs its cleanup. Widget _buildMinimizedInfoPill(_MinimizedInfoPopup popup) { + final theme = Theme.of(context); return GestureDetector( onTap: popup.onReshow, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), border: Border.all( - color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + color: theme.colorScheme.outline.withValues(alpha: 0.5), ), boxShadow: [ BoxShadow( @@ -6659,47 +7190,229 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ], ), - child: Row( + child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(popup.icon, color: popup.color, size: 18), - const SizedBox(width: 8), - Flexible( - child: Text( - popup.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, + // Row 1: identity + expand/close controls. + Row( + children: [ + Expanded(child: popup.title), + const SizedBox(width: 8), + GestureDetector( + onTap: popup.onReshow, + child: Icon(Icons.keyboard_arrow_up, + size: 20, color: theme.colorScheme.onSurfaceVariant), ), - ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: popup.onReshow, - child: Icon( - Icons.keyboard_arrow_up, - size: 20, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: popup.onClose, - child: Icon( - Icons.close, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + const SizedBox(width: 8), + GestureDetector( + onTap: popup.onClose, + child: Icon(Icons.close, + size: 18, color: theme.colorScheme.onSurfaceVariant), + ), + ], ), + const SizedBox(height: 8), + // Row 2: stat chips (wraps if the row is too narrow). + popup.statsBuilder(context), ], ), ), ); } + /// One icon+value stat chip for the minimized pill's row 2. [iconColor] tints + /// the icon; [valueColor] tints the value (defaults to onSurface). + Widget _pillStat(IconData icon, String value, + {Color? iconColor, Color? valueColor}) { + final theme = Theme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, + size: 14, color: iconColor ?? theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 3), + Text( + value, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: valueColor ?? theme.colorScheme.onSurface, + ), + ), + ], + ); + } + + // Web GRID SUMMARY / repeater palette colours reused for the pill chips. + static const Color _pillGood = Color(0xFF1E7E34); + static const Color _pillBad = Color(0xFFBD2130); + static const Color _pillDistBlue = Color(0xFF007BFF); + + /// Row-1 identity for the cell pill: grid icon + "Grid Summary". + Widget _cellPillTitle() { + final theme = Theme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.grid_on, size: 16, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text('Grid Summary', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface)), + ], + ); + } + + /// Row-2 stats for the cell pill: Max Dist · Avg SNR · Avg Noise · Total Pings. + Widget _cellPillStats( + BuildContext context, Future summaryFuture, bool isImperial) { + return FutureBuilder( + future: summaryFuture, + builder: (context, snap) { + final loading = snap.connectionState != ConnectionState.done; + final s = snap.data; + final dist = loading + ? '…' + : (s?.maxDistMeters != null + ? formatCoverageDistance(s!.maxDistMeters!, isImperial: isImperial) + : 'N/A'); + final noise = + loading ? '…' : (s?.avgNoise != null ? '${s!.avgNoise} dBm' : 'N/A'); + final total = loading ? '…' : '${s?.total ?? 0}'; + return Wrap( + spacing: 14, + runSpacing: 6, + children: [ + _pillStat(Icons.straighten, dist, iconColor: _pillDistBlue), + _snrPillStat(loading ? null : s), + _pillStat(Icons.graphic_eq, noise), + _pillStat(Icons.tag, total), + ], + ); + }, + ); + } + + /// Avg-SNR pill chip — a signal-bar icon coloured by quality bucket + the + /// averaged value (mirrors CellSummarySheet._snrCell). [s] null = still loading. + Widget _snrPillStat(GridSummary? s) { + final bucket = s?.snrBucket; + if (s == null || bucket == null || s.avgSnr == null) { + return _pillStat(Icons.signal_cellular_alt, s == null ? '…' : 'N/A'); + } + final IconData icon; + final Color color; + switch (bucket) { + case 'good': + icon = Icons.signal_cellular_4_bar; + color = _pillGood; + break; + case 'medium': + icon = Icons.signal_cellular_alt; + color = const Color(0xFF856404); + break; + default: + icon = Icons.signal_cellular_alt_1_bar; + color = _pillBad; + } + return _pillStat(icon, s.avgSnr!.toStringAsFixed(1), + iconColor: color, valueColor: color); + } + + /// Row-1 identity for the repeater pill: a status-coloured dot + the name. + Widget _repeaterPillTitle(Repeater repeater, Color statusColor) { + final theme = Theme.of(context); + return Row( + children: [ + // Status as a coloured tower (green = online, grey = stale/offline, plus + // the new/ambiguous palette colours). Carries the status that used to be + // a word in the stats row, so that row now fits on a single line. + Icon(Icons.cell_tower, size: 16, color: statusColor), + const SizedBox(width: 8), + Flexible( + child: Text( + repeater.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface), + ), + ), + ], + ); + } + + /// Row-2 stats for the repeater pill: Max Range · Hop Bytes · Clock Sync · + /// Last Heard. (Online/offline status is the coloured tower in the title.) + /// Max Range comes from the lazy [statsFuture]. + Widget _repeaterPillStats( + BuildContext context, + Repeater repeater, + Future statsFuture, + String? clockSkew, + bool isImperial) { + final clockOk = clockSkew == null; + final lastHeard = repeater.lastHeard > 0 ? daysAgo(repeater.lastHeard) : 'N/A'; + return FutureBuilder( + future: statsFuture, + builder: (context, snap) { + final loading = snap.connectionState != ConnectionState.done; + final stats = snap.data; + final range = loading + ? '…' + : (stats?.maxRangeMeters != null + ? formatCoverageDistance(stats!.maxRangeMeters!, + isImperial: isImperial) + : 'N/A'); + return Wrap( + spacing: 14, + runSpacing: 6, + children: [ + _pillStat(Icons.open_in_full, range, iconColor: _pillDistBlue), + _pillStat(Icons.swap_horiz, '${repeater.hopBytes}B'), + _pillStat(Icons.schedule, clockOk ? 'Synced' : 'Off', + iconColor: clockOk ? _pillGood : _pillBad, + valueColor: clockOk ? _pillGood : _pillBad), + _pillStat(Icons.history, lastHeard), + ], + ); + }, + ); + } + + /// Show the cell GRID SUMMARY as a minimized pill (the default a tile tap + /// opens in). Tapping it expands to the full sheet; closing clears the + /// footprint. Reused on the initial tap and when the sheet is minimized. + void _minimizeCellSummary({ + required GridCell cell, + required int blob, + required Future summaryFuture, + required bool isImperial, + }) { + _setMinimizedInfoPopup(_MinimizedInfoPopup( + title: _cellPillTitle(), + statsBuilder: (ctx) => _cellPillStats(ctx, summaryFuture, isImperial), + onReshow: () { + _clearMinimizedInfoPopup(); + _presentCellSummarySheet( + cell: cell, + blob: blob, + summaryFuture: summaryFuture, + isImperial: isImperial, + ); + }, + onClose: () { + _clearMinimizedInfoPopup(); + _clearCellHighlight(); + }, + )); + } + Widget _buildMinimizedFocusPanel() { final source = _focusedPingSource; String title; @@ -8171,11 +8884,32 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ); } - /// Show repeater details popup + /// Show repeater details. Opens minimized as a stats pill by default; tapping + /// the pill re-enters with [expand] true to show the full detail sheet, and + /// the sheet's minimize re-enters with [expand] false. [cachedStats] carries + /// the lazily-fetched stats across those toggles so they aren't re-fetched. void _showRepeaterDetails(Repeater repeater, - {bool isDuplicate = false, int? regionHopBytesOverride}) { + {bool isDuplicate = false, + int? regionHopBytesOverride, + bool isolate = true, + bool expand = false, + Future? cachedStats}) { // Supersede any currently-minimized popup (clears a prior cell footprint/dim). + // No-op on the internal pill<->sheet re-entries (nothing is minimized then). _minimizedInfoPopup?.onClose(); + // Drop any lingering Feature A cell fan-out fade so it doesn't reappear when + // this repeater's isolation is later cleared (the fade is suppressed while a + // repeater is isolated, but the set would otherwise linger). + _restoreFadedRepeaters(); + + // Focus this repeater: hide every OTHER repeater while its detail sheet/pill + // is open (web setSoloCircle parity) and track it as the active selection + // (Feature B draw guard + teardown). Restored by _clearRepeaterIsolation on + // sheet/pill close or empty-map tap. + if (isolate && _isolatedRepeaterId != repeater.id) { + _isolatedRepeaterId = repeater.id; + _syncRepeaterSymbols(context.read()); + } // Determine icon badge color based on primary status final iconColor = _getRepeaterMarkerColor(repeater, isDuplicate); @@ -8206,14 +8940,29 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final lookup = RepeaterLookup.fromRepeaters(appState.repeaters, hopBytes: appState.effectiveHopBytes); final isImperial = appState.preferences.isImperial; - final Future statsFuture = appState - .fetchRepeaterCoveragePoints(prefix: repeater.id) - .then( - (pts) => RepeaterStats.fromCoverage(pts, repeater, lookup)) - .catchError((Object e) { - debugWarn('[COVERAGE] repeater stats failed: $e'); - return null; - }); + final cvd = appState.preferences.colorVisionType; + final gridSize = appState.preferences.coverageGridSize; + final Future statsFuture = cachedStats ?? + appState + .fetchRepeaterCoveragePoints(prefix: repeater.id) + .then((pts) { + final res = + RepeaterStats.fromCoverageWithPoints(pts, repeater, lookup); + // Feature B: draw this repeater's coverage cells + status-coloured + // connection lines (web `drawRepeaterCoverageFromCache` parity). Only + // on the first fetch (cachedStats == null reaches here), and only if + // this repeater is still the isolated selection — guards a fast + // repeater switch during the network fetch — and has a known location. + if (mounted && + _isolatedRepeaterId == repeater.id && + repeater.hasLocation) { + _drawRepeaterCoverage(repeater, res.matched, cvd, gridSize); + } + return res.stats; + }).catchError((Object e) { + debugWarn('[COVERAGE] repeater stats failed: $e'); + return null; + }); final fingerprintShort = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); @@ -8222,6 +8971,30 @@ class _MapWidgetState extends State with WidgetsBindingObserver { : repeater.hexId.toUpperCase(); final clockSkew = humanizeClockSkew(repeater.timeOffset); + // Open minimized by default — a compact stats pill; tap it to expand to the + // full detail sheet. Isolation (others hidden) persists across pill<->sheet. + if (!expand) { + _setMinimizedInfoPopup(_MinimizedInfoPopup( + title: _repeaterPillTitle(repeater, statusColor), + statsBuilder: (ctx) => _repeaterPillStats( + ctx, repeater, statsFuture, clockSkew, isImperial), + onReshow: () { + _clearMinimizedInfoPopup(); + _showRepeaterDetails(repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: regionHopBytesOverride, + isolate: false, + expand: true, + cachedStats: statsFuture); + }, + onClose: () { + _clearMinimizedInfoPopup(); + _clearRepeaterIsolation(); + }, + )); + return; + } + showModalBottomSheet( context: context, useSafeArea: true, @@ -8234,12 +9007,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { builder: (context) => Container( padding: EdgeInsets.fromLTRB( 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header with icon badge (containing ID) and name - Row( + // Scrollable so the content can't overflow on shorter screens — this + // (non-scroll-controlled) bottom sheet caps height to ~9/16 of the + // screen, and the detail card is occasionally taller than that. + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header with icon badge (containing ID) and name + Row( children: [ // Icon badge with hex ID (mirrors map marker) Builder(builder: (context) { @@ -8375,22 +9152,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { Text(formatDateWithAgo(repeater.lastHeard), style: const TextStyle(fontSize: 13)), ), - // Clock-skew warning - if (clockSkew != null) ...[ + // Clock-skew warning — single compact row, e.g. "Clock is + // 1.2 days ahead" (was two rows / a long sentence). + if (clockSkew != null) _repRow( context, Icons.warning_amber_rounded, - const Text('Repeater time is not set correctly', - style: TextStyle(fontSize: 13)), - color: Colors.red.shade400, - ), - _repRow( - context, - null, - Text(clockSkew, style: const TextStyle(fontSize: 13)), + Text('Clock is $clockSkew', + style: const TextStyle(fontSize: 13)), color: Colors.red.shade400, ), - ], // First heard if (repeater.createdAt != null) _repRow( @@ -8450,22 +9221,25 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ], ), + ), ), ).then((result) { if (!mounted) return; if (result == 'minimized') { - _setMinimizedInfoPopup(_MinimizedInfoPopup( - title: repeater.name, - icon: Icons.cell_tower, - color: iconColor, - onReshow: () { - _clearMinimizedInfoPopup(); - _showRepeaterDetails(repeater, - isDuplicate: isDuplicate, - regionHopBytesOverride: regionHopBytesOverride); - }, - onClose: _clearMinimizedInfoPopup, - )); + // Collapse back to the stats pill. The selection (and its coverage + // cells/lines + tile dim) persists throughout pill<->sheet; it's torn + // down only on a real close. Reuse the already-fetched stats so the pill + // doesn't re-fetch (and doesn't redraw the coverage). + _showRepeaterDetails(repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: regionHopBytesOverride, + isolate: false, + expand: false, + cachedStats: statsFuture); + } else { + // Dismissed for real (X / swipe / barrier tap): clear the coverage + // cells/lines and restore the dimmed coverage tiles. + _clearRepeaterIsolation(); } }); } From 85bbb353dbd325f4022739c21f71823b2a8f83ce Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 08:18:00 -0400 Subject: [PATCH 085/100] Coverage tap-to-inspect fixes + tweaks - Fix dead taps at z>=15 on freshly-pinged cells: _handleFeatureTap now treats the session patch layer (and the footprint highlight / repeater coverage cells) as a coverage-cell tap, not just the base overlay. Fallback hit-test queries the patch layer too, with a small rect so tiny cells stay tappable. - Ignore cell taps below zoom 12 (cells are sub-pixel when zoomed out, so taps are almost always accidental). - Repeater pill: online/offline shown as a coloured tower icon in the title so the stats row fits one line. - Repeater coverage cap raised 250 -> 5000 so the full footprint draws (web parity). --- lib/widgets/map_widget.dart | 47 +++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 163647b..3502e8a 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -586,6 +586,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // _showCellFootprint and _clearCellHighlight; gates the opacity restore and // suppresses the build-method opacity-sync (which would otherwise un-dim). bool _coverageDimmedForCell = false; + // Below this zoom, coverage cells are only a pixel or two wide, so a tile tap + // is almost always accidental — ignore cell taps when zoomed further out (a + // 300 m cell is ~8 px at z11, ~16 px at z12). Tune if needed. + static const double _kMinCellTapZoom = 12.0; // Same backdrop dim while a selected repeater's coverage cells/lines are shown // (Feature B, web `drawRepeaterCoverageFromCache` parity). True between // _drawRepeaterCoverage and _clearRepeaterIsolation; gates the opacity restore @@ -2047,15 +2051,26 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _maybeShowCellSummaryAt(point, coordinates); } - /// Hit-test the active coverage fill layer at [point]; if a cell is there, + /// Hit-test the active coverage fill layer around [point]; if a cell is there, /// open its GRID SUMMARY. The onMapClick fallback to _handleFeatureTap. Future _maybeShowCellSummaryAt( math.Point point, LatLng coordinates) async { final layerId = _activeCoverageLayerId; if (layerId == null || _mapController == null) return; try { + // Hit-test a small BOX around the tap, not the exact pixel: at low zoom a + // coverage cell is only a pixel or two wide (a 300 m cell ≈ 2 px at z9), so + // an exact-point query nearly always lands in a gap and the tap "doesn't + // register". A ~30 px box makes tiles tappable at any zoom. + const pad = 15.0; + final rect = + Rect.fromLTWH(point.x - pad, point.y - pad, pad * 2, pad * 2); + // Include the session patch layer (the user's own freshly-pinged cells, + // drawn on top of the base) so tapping a patched cell still hit-tests. + final layers = [layerId]; + if (_patchLayerReady) layers.add(_patchLayerId); final features = - await _mapController!.queryRenderedFeatures(point, [layerId], null); + await _mapController!.queryRenderedFeaturesInRect(rect, layers, null); if (!mounted || features.isEmpty) return; _showCellSummary(coordinates); } catch (e) { @@ -2068,6 +2083,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// mirroring the web cell-click popup (minus PING HISTORY). void _showCellSummary(LatLng coordinates) { if (!mounted || _cellSummaryShowing) return; + // Don't open a cell summary when zoomed too far out — cells are barely + // visible there, so taps are almost always accidental. Both tap paths + // (_handleFeatureTap + _maybeShowCellSummaryAt) funnel here, so this one + // guard covers them and also skips the network fetch below. + final zoom = _mapController?.cameraPosition?.zoom ?? 0; + if (zoom < _kMinCellTapZoom) { + debugLog('[COVERAGE] cell tap ignored — zoom ' + '${zoom.toStringAsFixed(2)} < $_kMinCellTapZoom'); + return; + } final appState = context.read(); final zone = appState.zoneCode; if (zone == null || zone.isEmpty) return; @@ -2302,11 +2327,19 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return; } - // Coverage cell: open the GRID SUMMARY. Coverage is a fill layer below the - // repeaters, so reaching it means no repeater/cluster was hit. Platforms that - // don't dispatch fill-layer taps here fall back to the queryRenderedFeatures - // hit-test in _onMapEmptyTap. - if (layerId == _activeCoverageLayerId) { + // Coverage cell: open the GRID SUMMARY. Coverage fills sit below the + // repeaters, so reaching one means no repeater/cluster was hit. Accept ALL + // coverage fill layers, not just the base overlay: the session patch layer + // (the user's own freshly-pinged cells) renders ON TOP of the base, so a tap + // on a patched cell — common when zoomed in near where you've wardriven — + // returns the patch layer id and would otherwise be a dead tap. The tapped + // footprint highlight and the repeater coverage cells are likewise tappable. + // Platforms that don't dispatch fill-layer taps fall back to the + // queryRenderedFeatures hit-test in _onMapEmptyTap. + if (layerId == _activeCoverageLayerId || + layerId == _patchLayerId || + layerId == _cellHighlightLayerId || + layerId == _coverageCellsLayerId) { _showCellSummary(coordinates); return; } From f3277e146ad18da9a54005303d71a354f36d9c81 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 14:34:45 -0400 Subject: [PATCH 086/100] Unify focus camera across coverage views; fix spider in Detailed Mode - Shared focus-camera lifecycle (_enterFocusCamera/_exitFocusCamera/ _fitCameraToPoints/_exitFocusCameraIfDone) so ping focus, tile (Feature A) and repeater (Feature B) coverage views share one save/restore of the user's camera, follow and rotation. - Spider spread-marker tap now collapses the spider and focuses that repeater (focus mode + its coverage cells), matching the GPS fall-through path (was: minimized pill only, no coverage). - Detailed Grid Mode: tapping a stacked pile spiders out at any zoom (un-clustered, no cluster-bubble zoom-in path); spider markers render baked chips so the hex label shows instead of an empty box. --- lib/widgets/map_widget.dart | 750 ++++++++++++++++++++++++------------ 1 file changed, 513 insertions(+), 237 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 3502e8a..77071fa 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -522,6 +522,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { double? _preFocusZoom; bool _wasAutoFollowBeforeFocus = false; bool _wasRotatingBeforeFocus = false; // true if heading mode was active + // True while ANY focus-style camera is engaged (ping focus OR a community + // tile/repeater coverage view). The pre-focus snapshot above is saved ONCE on + // the first entry and restored ONCE when the last view closes, so switching + // directly between views animates only once and restores to the original view. + bool _cameraFocusActive = false; bool _focusPanelMinimized = false; dynamic _focusedPingSource; // TxPing | RxPing | DiscLogEntry | TraceLogEntry @@ -1020,26 +1025,27 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ); } - /// Zoom to fit a focused ping and its connected repeaters on screen - void _zoomToFocusBounds( - LatLng pingLocation, List<_ResolvedRepeater> repeaters) { + // =========================================================================== + // Shared focus-camera lifecycle (ping focus + tile/repeater coverage views) + // =========================================================================== + + /// Animate the camera to fit [points] on screen (north-up, with room for the + /// bottom sheet/pill). No-op with fewer than 2 valid points (nothing to frame). + void _fitCameraToPoints(List points) { if (_mapController == null || !_isMapReady || !mounted || !_canAnimateCamera) { return; } + final valid = points + .where((p) => isValidLatLng(p.latitude, p.longitude)) + .toList(); + if (valid.length < 2) return; - final points = [ - pingLocation, - ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon)) - ].where((p) => isValidLatLng(p.latitude, p.longitude)).toList(); - if (points.length < 2) return; - - // Build bounding box from all points - double minLat = points[0].latitude, maxLat = points[0].latitude; - double minLon = points[0].longitude, maxLon = points[0].longitude; - for (final p in points) { + double minLat = valid[0].latitude, maxLat = valid[0].latitude; + double minLon = valid[0].longitude, maxLon = valid[0].longitude; + for (final p in valid) { if (p.latitude < minLat) minLat = p.latitude; if (p.latitude > maxLat) maxLat = p.latitude; if (p.longitude < minLon) minLon = p.longitude; @@ -1058,6 +1064,76 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ); } + /// Engage the shared focus camera: save the user's current camera / auto-follow + /// / rotation ONCE (so a switch between focus views keeps the original + /// snapshot), then lock north-up and stop following. Idempotent — a no-op when + /// a focus camera is already active. Camera/follow/rotation ONLY: it does NOT + /// touch the coverage overlay opacity or [isFocusModeActive] (each mode owns + /// those). + void _enterFocusCamera() { + if (_cameraFocusActive) return; + _cameraFocusActive = true; + final pos = _mapController?.cameraPosition; + _preFocusCenter = pos?.target; + _preFocusZoom = pos?.zoom; + _wasAutoFollowBeforeFocus = _autoFollow; + _wasRotatingBeforeFocus = !_alwaysNorth; + if (_autoFollow) _autoFollow = false; + if (!_alwaysNorth) { + _alwaysNorth = true; + // Snap rotation to north instantly so the zoom-to-fit view is stable. + if (_isMapReady && _mapController != null && _canAnimateCamera) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 1), + ); + } + } + } + + /// Disengage the shared focus camera: animate back to the saved center/zoom and + /// restore auto-follow / rotation (after the zoom-back settles). Idempotent. + void _exitFocusCamera() { + if (!_cameraFocusActive) return; + _cameraFocusActive = false; + final center = _preFocusCenter; + final zoom = _preFocusZoom; + final restoreFollow = _wasAutoFollowBeforeFocus && !_autoFollow; + final restoreRot = _wasRotatingBeforeFocus && _alwaysNorth; + if (center != null && zoom != null) { + _animateToPositionWithZoom(center, zoom); + // Defer follow/rotation restore so they don't clobber the zoom-back + // animation mid-flight (both share the animation controller). + if (restoreFollow || restoreRot) { + Future.delayed(const Duration(milliseconds: 550), () { + if (!mounted) return; + setState(() { + if (restoreFollow) _autoFollow = true; + if (restoreRot) _alwaysNorth = false; + }); + }); + } + } else { + setState(() { + if (restoreFollow) _autoFollow = true; + if (restoreRot) _alwaysNorth = false; + }); + } + } + + /// True while any focus view (ping focus, tile coverage, repeater coverage) is + /// open. Drives [_exitFocusCameraIfDone] so a view switch (which clears the old + /// view AFTER the new one is claimed) keeps the camera engaged. + bool get _anyFocusViewActive => + _focusedPingLocation != null || + _cellPopupActive || + _isolatedRepeaterId != null; + + /// Restore the shared focus camera only when no focus view remains open. + void _exitFocusCameraIfDone() { + if (!_anyFocusViewActive) _exitFocusCamera(); + } + /// Smoothly animate the map rotation to match heading /// MapLibre bearing is clockwise from north (same as GPS heading) void _animateToRotation(double targetHeading) { @@ -1571,17 +1647,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ), - // Minimized focus panel pill — shown when user minimizes a ping - // details sheet. Not a modal, so the map underneath stays fully - // interactable (zoom, pan, rotation). - if (_focusPanelMinimized && _focusedPingLocation != null) + // Minimized focus panel pill — the DEFAULT view for a ping tap (and what + // the full sheet collapses to). Gated on _focusedPingSource (not + // _focusedPingLocation) so a "missed" ping — heard by nobody, so no focus + // location/camera — still shows its pill. Full-width like the info pill so + // its 2-row layout has room. Not a modal: the map stays interactable. + if (_focusPanelMinimized && _focusedPingSource != null) Positioned( bottom: 16 + MediaQuery.of(context).padding.bottom, left: 16, right: 16, - child: Center( - child: _buildMinimizedFocusPanel(), - ), + child: _buildMinimizedFocusPanel(), ), // Minimized cell-summary / repeater-detail pill — same idea as the focus @@ -1594,8 +1670,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { child: _buildMinimizedInfoPill(_minimizedInfoPopup!), ), - // History session pill (bottom, styled like minimized focus panel) - if (appState.viewingHistorySession) + // History session pill (bottom, styled like minimized focus panel). + // Hidden while a minimized focus/info pill occupies the same bottom + // slot, so the pill takes over the bottom area instead of stacking on + // top of (or under) this banner — parity with how the live-session + // control panel hides on infoPopupMinimized / isFocusModeActive. + if (appState.viewingHistorySession && + _minimizedInfoPopup == null && + !(_focusPanelMinimized && _focusedPingSource != null)) Positioned( bottom: 16 + MediaQuery.of(context).padding.bottom, left: 16, @@ -2041,9 +2123,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return; } // Safety net for a stranded Feature A fan-out (faded repeaters + lines but - // no pill keeping the cell summary alive) — restore on empty tap. + // no pill keeping the cell summary alive) — full teardown (clears the + // footprint/dim/fade and restores the shared focus camera) on empty tap. if (_coverageHeardRepeaterIds != null && _minimizedInfoPopup == null) { - _clearCoverageConnections(); + _clearCellHighlight(); return; } // Fallback cell hit-test: some platforms don't dispatch coverage fill-layer @@ -2123,9 +2206,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Supersede a currently-minimized popup. If it was a cell, its footprint // stays until this tap's own footprint replaces it (no bright flash); if it // was a repeater, drop the pill AND restore the hidden repeaters. + // Mark the cell view active BEFORE the teardowns so the shared focus camera + // isn't restored mid-switch (it stays engaged and re-fits below). + _cellPopupActive = true; _clearMinimizedInfoPopup(); _clearRepeaterIsolation(); - _cellPopupActive = true; + // Switching from a minimized ping focus: tear it down so its pill doesn't + // linger and overlap this cell view's pill at the shared bottom slot + // (symmetric to _enterPingFocus, which tears down the coverage views). The + // cell view is already claimed (_cellPopupActive = true), so this dismiss's + // _exitFocusCameraIfDone no-ops and the shared focus camera stays engaged. + _dismissPingFocus(); + _enterFocusCamera(); // Fetch the pings once, keep the ones whose blob covers the tapped cell, and // drive BOTH the summary sheet and the footprint highlight off that list. @@ -2175,6 +2267,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _coverageHeardRepeaterIds = eps.isEmpty ? null : {for (final e in eps) e.repeaterId}; _syncRepeaterSymbols(appState); + // Match ping focus: frame the cell + the repeaters that heard it (no-op + // when nothing was heard — single point — leaving the north-up view). + _fitCameraToPoints([ + LatLng(cell.centerLat, cell.centerLon), + for (final e in eps) LatLng(e.lat, e.lon), + ]); }).catchError((Object e) { debugWarn('[COVERAGE] cell fan-out failed: $e'); }); @@ -2266,13 +2364,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ) { if (!mounted) return; - // Spider spread marker: open the detail sheet for the tapped repeater. - // Spider stays open — users frequently compare stacked repeaters back to - // back, so collapsing on every selection would be annoying. + // Spider spread marker: the user has picked one of the fanned-out repeaters + // to inspect → collapse the spider and focus that repeater (focus mode + its + // coverage cells), mirroring the GPS fall-through path in + // _fallThroughToRepeaterAt. if (layerId == _spiderSymbolLayerId) { - // Don't isolate from inside a spider — users compare stacked repeaters - // back-to-back, so keep the spread open and all markers visible. - _showRepeaterDetailsById(id, isolate: false); + _collapseSpider(); + _showRepeaterDetailsById(id); // isolate: true (default) return; } @@ -2293,12 +2391,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return; } - // Individual repeater: open the detail sheet. At max zoom we ALSO check - // for stacked siblings within the spider stick threshold and spread them - // out (covers the rare case where clustering didn't pick them up — e.g. - // identical-coordinate markers that just slipped past clusterRadius - // due to a recent data update). Below max zoom, spiderfy is disabled — - // the user is expected to zoom further first. + // Individual repeater: open the detail sheet. We ALSO check for stacked + // siblings within the spider stick threshold and spread them out. In + // Simplified Mode this is gated to max zoom (clustered, so the bubble-tap + // path zooms in first; an individual hit below max zoom is a real lone + // marker). In Detailed Mode there is no cluster bubble / "zoom in first" + // path and stacked markers overlap at every zoom, so spiderfy fires at any + // zoom — otherwise a stacked pile can only ever show one repeater's coverage + // and the rest stay buried. if (layerId == _repeaterIndividualLayerId) { // If a spider is open and the user tapped a non-spider individual // (originals are filtered out via `inSpider`, so this can only be a @@ -2307,8 +2407,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _collapseSpider(); } - if (_isAtMaxZoom()) { - final appState = context.read(); + final appState = context.read(); + final detailed = appState.preferences.coverageGridSize == 100; + if (_isAtMaxZoom() || detailed) { final group = _findSpiderGroup(coordinates, appState); if (group.length >= 2) { _spiderfy(coordinates, group); @@ -2526,22 +2627,27 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (repeaterId == null) return; // For an individual layer hit (not a spider symbol), apply the same - // stacked-siblings test as the direct tap path — but ONLY at max zoom. - // queryRenderedFeatures doesn't expose layerId per result in 0.25, so - // we infer: if a spider is open, the original individuals are filtered - // out, so any individual hit must be a non-stacked marker → just open - // the detail sheet. If no spider is open AND we're at max zoom, run - // the spiderfy test. + // stacked-siblings test as the direct tap path. queryRenderedFeatures + // doesn't expose layerId per result in 0.25, so we infer: if a spider is + // open, the original individuals are filtered out, so any individual hit + // must be a non-stacked marker → just open the detail sheet. Otherwise run + // the spiderfy test — gated to max zoom in Simplified Mode, but at any + // zoom in Detailed Mode (un-clustered, so stacked markers overlap at every + // zoom and have no cluster-bubble "zoom in first" path). Mirrors the + // direct tap path. if (_spiderCenter != null) { // User tapped outside the open spider's group — collapse + show // detail sheet for the tapped marker. _collapseSpider(); - } else if (_isAtMaxZoom()) { + } else { final appState = context.read(); - final group = _findSpiderGroup(coordinates, appState); - if (group.length >= 2) { - _spiderfy(coordinates, group); - return; + final detailed = appState.preferences.coverageGridSize == 100; + if (_isAtMaxZoom() || detailed) { + final group = _findSpiderGroup(coordinates, appState); + if (group.length >= 2) { + _spiderfy(coordinates, group); + return; + } } } _showRepeaterDetailsById(repeaterId); @@ -2587,6 +2693,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _restoreCoverageBackdropForRepeater(); if (!mounted) return; _syncRepeaterSymbols(context.read()); + // Restore the shared focus camera if no focus view remains (no-op during a + // switch, where the incoming view is already marked active). + _exitFocusCameraIfDone(); } /// Restore the base coverage overlay opacity dimmed by a Feature B repeater @@ -3074,6 +3183,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { await _applyCoverageOverlayOpacity(restore); } } + // Restore the shared focus camera if no focus view remains (a no-op during a + // switch, where the incoming view is already marked active). Runs before the + // early-return below so it always fires. + _exitFocusCameraIfDone(); if (_mapController == null || !_cellHighlightReady) return; try { await _mapController! @@ -4098,6 +4211,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final duplicates = _getDuplicateRepeaterIds(_mapVisibleRepeaters(appState)); final hopOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; + // Match the individual layer: Detailed (gsize 100) bakes the hex into the + // chip image (the spider layer reuses the same no-text-field props, so a + // generic shape would render as an empty box); Simplified uses the shared + // shape + text-field hex. See _buildRepeaterFeatureCollection. + final detailed = appState.preferences.coverageGridSize == 100; final features = >[]; for (var i = 0; i < _spiderRepeaters.length; i++) { @@ -4112,8 +4230,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { : effectiveBytes == 2 ? 2 : 1; - final iconImage = _MapImages.repeater(statusKey, shapeBytes); final hex = repeater.displayHexId(overrideHopBytes: hopOverride); + final iconImage = detailed + ? _MapImages.repeaterChip(statusKey, shapeBytes, hex) + : _MapImages.repeater(statusKey, shapeBytes); final colorHex = _colorToHex(_repeaterStatusColor(statusKey)); features.add({ @@ -4172,6 +4292,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { try { final geojson = _buildSpiderFeatureCollection(appState, currentZoom); + // Detailed mode references baked per-chip images. The main collection + // usually registers them first, but a spidered repeater that the main + // builder filtered out (focus / isolation / heard-repeater fade) would + // reference an unregistered chip — bake any missing ones here too. + if (appState.preferences.coverageGridSize == 100) { + await _ensureRepeaterChipImages(geojson); + } await _mapController!.setGeoJsonSource(_spiderSourceId, geojson); } catch (e) { debugError('[MAP] Failed to update spider source: $e'); @@ -4734,7 +4861,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { const LineLayerProperties( lineColor: ['get', 'color'], lineOpacity: 0.9, - lineWidth: 2.5, + // Per-feature width: the tile fan-out (Feature A) and the repeater + // coverage (Feature B) share this layer but use different widths. + lineWidth: ['get', 'width'], lineDasharray: [2, 4], lineCap: 'round', ), @@ -4792,7 +4921,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { Future _updateCoverageLines( List<({double lat, double lon, String color})> segments, double startLat, - double startLon) async { + double startLon, + {double width = 2.5}) async { if (_mapController == null || !_styleLoaded) return; if (segments.isEmpty) { await _clearCoverageLines(); @@ -4803,7 +4933,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { for (final s in segments) { 'type': 'Feature', - 'properties': {'color': s.color}, + 'properties': {'color': s.color, 'width': width}, 'geometry': { 'type': 'LineString', 'coordinates': [ @@ -4877,6 +5007,24 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Place a distance-pill label at the midpoint of each coverage fan-out line /// (Feature A). Mirrors [_syncDistanceLabels] but with its own tracking keyed /// by endpoint lat/lon (6dp). Empty list removes all coverage labels. + /// Screen-space rotation (degrees, clockwise) so a horizontal distance label + /// runs ALONG the line from ([startLat],[startLon]) to ([endLat],[endLon]) + /// instead of staying flat. Annotation symbols rotate in viewport space, so + /// this is the line's on-screen angle — the geographic bearing minus the + /// camera bearing, minus 90° (a horizontal label already lies along an + /// east-west line) — flipped 180° when it would otherwise read upside-down. + double _lineLabelRotation( + double startLat, double startLon, double endLat, double endLon) { + final bearing = + Geolocator.bearingBetween(startLat, startLon, endLat, endLon); + final cam = _mapController?.cameraPosition?.bearing ?? 0; + var angle = bearing - cam - 90; + angle = (angle + 180) % 360 - 180; // normalize to [-180, 180) + if (angle > 90) angle -= 180; // keep upright (text never reads inverted) + if (angle < -90) angle += 180; + return angle; + } + Future _syncCoverageDistanceLabels( List<({double lat, double lon, String color})> segments, double startLat, @@ -4914,6 +5062,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { iconImage: imageName, iconSize: 1.0, iconAnchor: 'center', + // Rotate the pill to follow its line (kept upright) instead of staying + // flat across the fan-out. + iconRotate: _lineLabelRotation(startLat, startLon, s.lat, s.lon), ); final existing = _coverageDistanceLabelSymbols[key]; if (existing == null) { @@ -4978,9 +5129,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Feature B draw: render [matched] (the points that heard [repeater]) as /// deduped status-coloured coverage cells plus a status-coloured dashed line - /// from the repeater to each cell centre. Caps at the farthest 250. The lines - /// layer is ensured BEFORE the cells layer so the lines read on top. - Future _drawRepeaterCoverage(Repeater repeater, + /// from the repeater to each cell centre. The lines layer is ensured BEFORE + /// the cells layer so the lines read on top. Returns the (capped) cells so the + /// caller can frame them with the repeater. + Future> _drawRepeaterCoverage(Repeater repeater, List> matched, String cvd, int gridSize) async { final steps = kCoverageGridSteps[gridSize] ?? const [0.0009, 0.00128]; final blob = gridSize == 100 ? 1 : 0; @@ -4996,7 +5148,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (cells.isEmpty) { await _clearCoverageCells(); await _clearCoverageLines(); - return; + return cells; } await _ensureCoverageLinesLayer(); await _updateCoverageCells(cells, cvd, steps[0], steps[1]); @@ -5008,7 +5160,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { color: CoverageTilePalette.colorsForStatus(cvd, c.st)[0], ), ]; - await _updateCoverageLines(segments, repeater.lat, repeater.lon); + // Skinnier than the tile fan-out — a repeater draws many lines at once. + await _updateCoverageLines(segments, repeater.lat, repeater.lon, width: 1.5); // Dim the base coverage tiles so the repeater's coloured cells + lines pop // (web `drawRepeaterCoverageFromCache` tile-dim parity). Restored in // _clearRepeaterIsolation. Skip while ping focus already hid the overlay. @@ -5016,6 +5169,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _coverageDimmedForRepeater = true; await _applyCoverageOverlayOpacity(_kCellHighlightFadeOpacity); } + return cells; } /// Rebuilds the regional boundary layer from `appState.regionBorders`. @@ -5228,6 +5382,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { iconImage: imageName, iconSize: 1.0, iconAnchor: 'center', + // Rotate the pill to follow its ping→repeater line (kept upright), so + // ping focus matches the tile/repeater coverage labels. + iconRotate: _lineLabelRotation( + ping.latitude, ping.longitude, r.repeater.lat, r.repeater.lon), ); final existing = _distanceLabelSymbols[key]; @@ -5356,7 +5514,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { try { await _mapController!.updateSymbol( symbol, - SymbolOptions(geometry: targetLatLng), + // Re-assert the line rotation (unchanged — sliding along the same + // line) alongside the new position. + SymbolOptions( + geometry: targetLatLng, + iconRotate: _lineLabelRotation(ping.latitude, ping.longitude, + repeaterPos.latitude, repeaterPos.longitude), + ), ); } catch (e) { debugError('[MAP] updateSymbol(distance reflow) failed: $e'); @@ -6637,18 +6801,23 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } void _showTraceDetails(TraceLogEntry entry, {bool fromMinimized = false}) { - // Activate focus mode for successful traces with a known repeater - _focusedPingSource = entry; - - if (!fromMinimized && entry.success) { - final resolved = _resolveRepeatersByHexIds( - [entry.targetRepeaterId], - snrValues: [entry.localSnr], - ); - if (resolved.isNotEmpty) { - _activatePingFocus( - LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); - } + // Default a real tap to the minimized 2-row pill — the full sheet opens only + // on expand (fromMinimized: true). _activatePingFocus engages focus for a + // successful trace's target or clears any prior focus / community view (empty + // list for a failed trace); set _focusedPingSource AFTER it (its missed branch + // dismisses a prior ping, which would otherwise clear the source). + if (!fromMinimized) { + final resolved = entry.success + ? _resolveRepeatersByHexIds( + [entry.targetRepeaterId], + snrValues: [entry.localSnr], + ) + : const <_ResolvedRepeater>[]; + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + _focusedPingSource = entry; + _minimizePingFocus(entry.timestamp); + return; } showModalBottomSheet( @@ -7040,49 +7209,47 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Activate ping focus mode — draw lines, fade markers, zoom to fit. void _activatePingFocus(LatLng pingLocation, DateTime timestamp, List<_ResolvedRepeater> repeaters) { - // Drop repeaters lacking GPS — they would draw lines off to (0, 0). - // The bottom-sheet row builder still surfaces them with a no-location - // icon. If nothing is left to focus on, skip activation entirely so - // the user's current map view (zoom, autofollow, rotation) is kept. + // Repeaters lacking GPS would draw lines off to (0, 0); the bottom-sheet row + // builder still surfaces them with a no-location icon. final located = repeaters.where((r) => r.repeater.hasLocation).toList(growable: false); - if (located.isEmpty) return; - // Session ping-focus and the community coverage views are distinct gestures; - // clear any open community fan-out / repeater cells (and their pill/isolation) - // so the two don't stack on the map. + // "Dead" ping (nothing to focus): don't enter focus (north-up + hiding every + // marker over an empty map is jarring). Tear down any PRIOR ping focus first + // (its lines / hidden markers / overlay-dim — else they'd linger under this + // ping's pill); _dismissPingFocus no-ops when no ping is active. It also + // clears _focusedPingSource, so the caller re-sets it for THIS ping AFTER + // _activatePingFocus returns. Then close any open community view so the pill + // isn't shown over a stale fan-out (req: tapping a ping closes coverage). + if (located.isEmpty) { + _dismissPingFocus(); + _clearMinimizedInfoPopup(); + _clearCellHighlight(); + _clearRepeaterIsolation(); + _clearCoverageConnections(); + return; + } + + final alreadyInFocus = _focusedPingLocation != null; + // Claim ping focus as the active view FIRST, so the community teardowns below + // see a focus view active and don't restore the shared camera mid-switch + // (then ping re-fits) — switching from a coverage view to a ping animates once. + _focusedPingLocation = pingLocation; + + // Close any open community coverage view (pill + footprint + dim + isolation + + // lines/cells/fade). Each teardown's _exitFocusCameraIfDone no-ops because + // ping focus is now the active view. _clearMinimizedInfoPopup(); + _clearCellHighlight(); _clearRepeaterIsolation(); _clearCoverageConnections(); - // Only save pre-focus state on first activation. When re-activating - // (e.g. user taps a different ping while already in focus, or expanding - // from minimized), we keep the original pre-focus snapshot so dismiss - // restores the correct camera position. - final alreadyInFocus = _focusedPingLocation != null; + // Engage the shared focus camera (saves the pre-focus snapshot once, + // north-up, stop follow). isFocusModeActive is a PING-only flag (it gates the + // home-screen map Selector); set it only for ping focus, only on first entry. + _enterFocusCamera(); if (!alreadyInFocus) { context.read().isFocusModeActive = true; - final pos = _mapController?.cameraPosition; - _preFocusCenter = pos?.target; - _preFocusZoom = pos?.zoom; - _wasAutoFollowBeforeFocus = _autoFollow; - _wasRotatingBeforeFocus = !_alwaysNorth; - - if (_autoFollow) { - _autoFollow = false; - } - - // Lock to north-up during focus so the zoom-to-fit view is stable - if (!_alwaysNorth) { - _alwaysNorth = true; - // Snap rotation to north (instant — avoids wobble before zoom-to-fit animation) - if (_isMapReady && _mapController != null && _canAnimateCamera) { - _mapController!.animateCamera( - CameraUpdate.bearingTo(0), - duration: const Duration(milliseconds: 1), - ); - } - } } _focusPanelMinimized = false; @@ -7092,7 +7259,6 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _focusSyncDeferred = true; setState(() { - _focusedPingLocation = pingLocation; _focusedPingTimestamp = timestamp; _focusedRepeaters = located; }); @@ -7104,7 +7270,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _focusedPingLocation != null) { - _zoomToFocusBounds(pingLocation, located); + _fitCameraToPoints([ + pingLocation, + ...located.map((r) => LatLng(r.repeater.lat, r.repeater.lon)), + ]); } }); @@ -7122,18 +7291,14 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Dismiss ping focus mode — restore map state. void _dismissPingFocus() { - if (_focusedPingLocation == null || !mounted) return; + // Key off the SOURCE, not the location: a "missed" ping has a source but no + // focus location, and must still be torn down (clear isFocusModeActive so the + // control bar returns, clear the pill state, etc.). + if (_focusedPingSource == null || !mounted) return; context.read().isFocusModeActive = false; - final center = _preFocusCenter; - final zoom = _preFocusZoom; - final shouldRestoreAutoFollow = _wasAutoFollowBeforeFocus && !_autoFollow; - final shouldRestoreRotation = _wasRotatingBeforeFocus && _alwaysNorth; - - // Clear focus state but do NOT restore auto-follow or rotation yet — - // they would immediately trigger animations in the build method that - // override our zoom-back animation (both share _animationController). + // Clear focus state first so _anyFocusViewActive sees ping focus gone. setState(() { _focusedPingLocation = null; _focusedPingTimestamp = null; @@ -7148,27 +7313,24 @@ class _MapWidgetState extends State with WidgetsBindingObserver { final appState = context.read(); _applyCoverageOverlayOpacity(appState.preferences.coverageOverlayOpacity); - if (center != null && zoom != null) { - _animateToPositionWithZoom(center, zoom); + // Restore the shared focus camera (center/zoom + follow/rotation) now that no + // focus view remains. A no-op if a community view is somehow still open + // (can't happen — ping focus tears them down on entry). + _exitFocusCameraIfDone(); + } - // Restore auto-follow and heading rotation after the zoom-back - // animation completes (500ms) so they don't clobber it mid-flight. - if (shouldRestoreAutoFollow || shouldRestoreRotation) { - Future.delayed(const Duration(milliseconds: 550), () { - if (mounted) { - setState(() { - if (shouldRestoreAutoFollow) _autoFollow = true; - if (shouldRestoreRotation) _alwaysNorth = false; - }); - } - }); - } - } else { - setState(() { - if (shouldRestoreAutoFollow) _autoFollow = true; - if (shouldRestoreRotation) _alwaysNorth = false; - }); - } + /// Collapse a just-tapped ping to the minimized 2-row pill — the DEFAULT for a + /// ping tap (parity with the cell/repeater tap flow). Run right after + /// [_activatePingFocus]: for a HEARD ping that engaged focus this just flips the + /// panel to minimized; for a MISSED ping (no focus location) it also sets + /// isFocusModeActive so the control bar hides under the pill — without any of + /// the focus VISUALS (those key off _focusedPingLocation, which stays null). + void _minimizePingFocus(DateTime timestamp) { + context.read().isFocusModeActive = true; + setState(() { + _focusedPingTimestamp = timestamp; + _focusPanelMinimized = true; + }); } void _reshowFocusPanel() { @@ -7203,12 +7365,32 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Pill for a minimized cell-summary / repeater-detail popup (parity with /// [_buildMinimizedFocusPanel]). Tap (or the up-arrow) re-opens it; the X /// closes it and runs its cleanup. + /// A minimized-pill control (↑ expand / ✕ close) with a generous ~44px hit + /// target so it's hard to mis-tap. Opaque so the tap never falls through to the + /// map (or the pill body's tap-swallow). + Widget _pillIconButton(IconData icon, VoidCallback onTap) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: SizedBox( + width: 44, + height: 44, + child: Icon(icon, + size: 22, color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ); + } + Widget _buildMinimizedInfoPill(_MinimizedInfoPopup popup) { final theme = Theme.of(context); return GestureDetector( - onTap: popup.onReshow, + // Swallow body taps: tapping the pill body must NOT expand (too easy to hit + // when reaching for close) and must NOT fall through to the map. Expand is + // only via the ↑ button; close only via the ✕ button. + behavior: HitTestBehavior.opaque, + onTap: () {}, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.fromLTRB(12, 2, 4, 8), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), @@ -7227,25 +7409,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Row 1: identity + expand/close controls. + // Row 1: identity + expand/close controls (each ~44px hit target). Row( children: [ Expanded(child: popup.title), - const SizedBox(width: 8), - GestureDetector( - onTap: popup.onReshow, - child: Icon(Icons.keyboard_arrow_up, - size: 20, color: theme.colorScheme.onSurfaceVariant), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: popup.onClose, - child: Icon(Icons.close, - size: 18, color: theme.colorScheme.onSurfaceVariant), - ), + const SizedBox(width: 4), + _pillIconButton(Icons.keyboard_arrow_up, popup.onReshow), + _pillIconButton(Icons.close, popup.onClose), ], ), - const SizedBox(height: 8), // Row 2: stat chips (wraps if the row is too narrow). popup.statsBuilder(context), ], @@ -7471,19 +7643,22 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return const SizedBox.shrink(); } - final repeaterCount = _focusedRepeaters.length; + final theme = Theme.of(context); final timeStr = _focusedPingTimestamp != null ? _formatTime(_focusedPingTimestamp!) : ''; return GestureDetector( - onTap: _reshowFocusPanel, + // Swallow body taps (no accidental expand / no fall-through to the map). + // Expand only via ↑, close only via ✕ — parity with _buildMinimizedInfoPill. + behavior: HitTestBehavior.opaque, + onTap: () {}, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.fromLTRB(12, 2, 4, 8), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), border: Border.all( - color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + color: theme.colorScheme.outline.withValues(alpha: 0.5), ), boxShadow: [ BoxShadow( @@ -7493,61 +7668,120 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ), ], ), - child: Row( + child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, color: color, size: 18), - const SizedBox(width: 8), - Text( - title, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(width: 8), - Text( - timeStr, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - if (repeaterCount > 0) ...[ - const SizedBox(width: 8), - Text( - '$repeaterCount repeater${repeaterCount != 1 ? 's' : ''}', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, + // Row 1: type icon + title + timestamp + expand/close (each ~44px hit). + Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), ), - ), - ], - const SizedBox(width: 8), - GestureDetector( - onTap: _reshowFocusPanel, - child: Icon( - Icons.keyboard_arrow_up, - size: 20, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: _dismissPingFocus, - child: Icon( - Icons.close, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + const SizedBox(width: 8), + Expanded( + child: Text( + timeStr, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + _pillIconButton(Icons.keyboard_arrow_up, _reshowFocusPanel), + _pillIconButton(Icons.close, _dismissPingFocus), + ], ), + // Row 2: summary stat chips computed synchronously from the source. + _buildFocusPingStats(source), ], ), ), ); } + /// Row-2 summary chips for the minimized ping pill, computed synchronously from + /// the focused ping [source] (parity with the cell/repeater pill's stats row). + /// Best-of (max) SNR/RSSI across all heard repeaters for multi-repeater TX/DISC; + /// the single value for RX/TRACE. "Missed" pings show a short miss label. + Widget _buildFocusPingStats(Object source) { + final missColor = Theme.of(context).colorScheme.onSurfaceVariant; + final chips = []; + + Widget snrChip(double snr) => _pillStat( + Icons.signal_cellular_alt, + '${snr.toStringAsFixed(1)} SNR', + iconColor: PingColors.snrColor(snr), + valueColor: PingColors.snrColor(snr), + ); + Widget rssiChip(int rssi) => _pillStat( + Icons.cell_tower, + '$rssi dBm', + iconColor: PingColors.rssiColor(rssi), + valueColor: PingColors.rssiColor(rssi), + ); + + if (source is TxPing) { + final reps = source.heardRepeaters; + if (reps.isEmpty) { + chips.add(_pillStat(Icons.close, 'Not heard', iconColor: missColor)); + } else { + final direct = reps.where((r) => r.pathHops == null).length; + final multi = reps.length - direct; + if (direct > 0) { + chips.add(_pillStat(Icons.arrow_upward, '$direct direct')); + } + if (multi > 0) { + chips.add(_pillStat(Icons.alt_route, '$multi multi')); + } + final snrs = reps.map((r) => r.snr).whereType(); + if (snrs.isNotEmpty) chips.add(snrChip(snrs.reduce(math.max))); + final rssis = reps.map((r) => r.rssi).whereType(); + if (rssis.isNotEmpty) chips.add(rssiChip(rssis.reduce(math.max))); + } + } else if (source is RxPing) { + chips.add(snrChip(source.snr)); + chips.add(rssiChip(source.rssi)); + if (source.pathHops.isNotEmpty) { + chips.add(_pillStat(Icons.alt_route, '${source.pathHops.length} hops')); + } + } else if (source is DiscLogEntry) { + final nodes = source.discoveredNodes; + if (nodes.isEmpty) { + chips.add(_pillStat(Icons.close, 'No response', iconColor: missColor)); + } else { + chips.add(_pillStat(Icons.radar, + '${nodes.length} node${nodes.length != 1 ? 's' : ''}')); + chips.add(snrChip(nodes.map((n) => n.localSnr).reduce(math.max))); + chips.add(rssiChip(nodes.map((n) => n.localRssi).reduce(math.max))); + } + } else if (source is TraceLogEntry) { + if (!source.success) { + chips.add(_pillStat(Icons.close, 'No response', iconColor: missColor)); + } else { + chips.add(_pillStat(Icons.check_circle, 'Success', + iconColor: PingColors.txSuccess)); + if (source.localSnr != null) chips.add(snrChip(source.localSnr!)); + if (source.remoteSnr != null) { + chips.add(_pillStat( + Icons.arrow_upward, 'TX ${source.remoteSnr!.toStringAsFixed(1)}')); + } + if (source.localRssi != null) chips.add(rssiChip(source.localRssi!)); + } + } + + if (chips.isEmpty) return const SizedBox.shrink(); + return Wrap(spacing: 14, runSpacing: 6, children: chips); + } + void _showHistoryMarkerAsLive(PingEventMarker marker) { if (marker.latitude == null || marker.longitude == null) return; final lat = marker.latitude!; @@ -7748,12 +7982,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { : <_ResolvedRepeater>[]; final hasAmbiguous = resolved.any((r) => r.ambiguous); - _focusedPingSource = ping; - - // Activate focus mode if the ping was heard by known repeaters - if (!fromMinimized && resolved.isNotEmpty) { + // Default a real tap to the minimized 2-row pill — the full sheet opens only + // on expand (fromMinimized: true, via _reshowFocusPanel). _activatePingFocus + // engages focus when the ping was heard, or clears any prior focus / open + // community view when it wasn't; set _focusedPingSource AFTER it (its missed + // branch dismisses a prior ping, which would otherwise clear a source set + // earlier). + if (!fromMinimized) { _activatePingFocus( LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + _focusedPingSource = ping; + _minimizePingFocus(ping.timestamp); + return; } showModalBottomSheet( @@ -8270,11 +8510,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { snrValues: [ping.snr], ); - _focusedPingSource = ping; - - if (!fromMinimized && resolved.isNotEmpty) { + // Default a real tap to the minimized 2-row pill — the full sheet opens only + // on expand (fromMinimized: true). Set _focusedPingSource AFTER + // _activatePingFocus (its missed branch dismisses a prior ping, clearing the + // source). RX is always a reception, so it always has located repeater data. + if (!fromMinimized) { _activatePingFocus( LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + _focusedPingSource = ping; + _minimizePingFocus(ping.timestamp); + return; } showModalBottomSheet( @@ -8564,20 +8809,25 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Show DISC ping details popup void _showDiscPingDetails(DiscLogEntry entry, {bool fromMinimized = false}) { - // Activate focus mode for discovered nodes with known repeater positions - _focusedPingSource = entry; - - if (!fromMinimized && entry.discoveredNodes.isNotEmpty) { - final resolved = _resolveRepeatersByHexIds( - entry.discoveredNodes.map((n) => n.repeaterId).toList(), - fullHexIds: entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), - snrValues: - entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), - ); - if (resolved.isNotEmpty) { - _activatePingFocus( - LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); - } + // Default a real tap to the minimized 2-row pill — the full sheet opens only + // on expand (fromMinimized: true). _activatePingFocus engages focus for + // located nodes or clears any prior focus / community view (empty list when + // nothing was discovered); set _focusedPingSource AFTER it (its missed branch + // dismisses a prior ping, which would otherwise clear the source). + if (!fromMinimized) { + final resolved = entry.discoveredNodes.isNotEmpty + ? _resolveRepeatersByHexIds( + entry.discoveredNodes.map((n) => n.repeaterId).toList(), + fullHexIds: entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), + snrValues: + entry.discoveredNodes.map((n) => n.localSnr as double?).toList(), + ) + : const <_ResolvedRepeater>[]; + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + _focusedPingSource = entry; + _minimizePingFocus(entry.timestamp); + return; } showModalBottomSheet( @@ -8927,20 +9177,37 @@ class _MapWidgetState extends State with WidgetsBindingObserver { bool isolate = true, bool expand = false, Future? cachedStats}) { - // Supersede any currently-minimized popup (clears a prior cell footprint/dim). - // No-op on the internal pill<->sheet re-entries (nothing is minimized then). - _minimizedInfoPopup?.onClose(); - // Drop any lingering Feature A cell fan-out fade so it doesn't reappear when - // this repeater's isolation is later cleared (the fade is suppressed while a - // repeater is isolated, but the set would otherwise linger). - _restoreFadedRepeaters(); - // Focus this repeater: hide every OTHER repeater while its detail sheet/pill // is open (web setSoloCircle parity) and track it as the active selection - // (Feature B draw guard + teardown). Restored by _clearRepeaterIsolation on - // sheet/pill close or empty-map tap. - if (isolate && _isolatedRepeaterId != repeater.id) { - _isolatedRepeaterId = repeater.id; + // (Feature B draw guard + teardown). Claim it as the active view BEFORE + // tearing down any prior view, so the shared focus camera isn't restored + // mid-switch (it stays engaged and re-fits to this repeater below). Restored + // by _clearRepeaterIsolation on sheet/pill close or empty-map tap. + final wasCell = _cellPopupActive; + final prevRepeater = _isolatedRepeaterId; + if (isolate) _isolatedRepeaterId = repeater.id; + _clearMinimizedInfoPopup(); // drop the prior pill widget only + if (wasCell) { + // Switching from a tile view: tear down its footprint/dim/fade/lines. + _clearCellHighlight(); + } else if (prevRepeater != null && prevRepeater != repeater.id) { + // Switching repeater->repeater: tear down the old cells/lines/dim WITHOUT + // clearing the (now new) _isolatedRepeaterId via _clearRepeaterIsolation. + _clearCoverageLines(); + _clearCoverageCells(); + _restoreCoverageBackdropForRepeater(); + } + // Drop any lingering Feature A fade so it doesn't reappear when this + // repeater's isolation is later cleared. + _restoreFadedRepeaters(); + // Switching from a minimized ping focus: tear it down so its pill doesn't + // linger and overlap this repeater view's pill at the shared bottom slot + // (symmetric to _enterPingFocus). When isolating, the repeater view is + // already claimed (_isolatedRepeaterId set above), so this dismiss's + // _exitFocusCameraIfDone no-ops and the shared focus camera stays engaged. + _dismissPingFocus(); + if (isolate) { + _enterFocusCamera(); // save the pre-focus snapshot once; north-up, stop follow _syncRepeaterSymbols(context.read()); } @@ -8989,7 +9256,16 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (mounted && _isolatedRepeaterId == repeater.id && repeater.hasLocation) { - _drawRepeaterCoverage(repeater, res.matched, cvd, gridSize); + _drawRepeaterCoverage(repeater, res.matched, cvd, gridSize) + .then((cells) { + if (!mounted || _isolatedRepeaterId != repeater.id) return; + // Match ping focus: frame the repeater + its whole coverage + // footprint (no-op when it heard nothing — single point). + _fitCameraToPoints([ + LatLng(repeater.lat, repeater.lon), + for (final c in cells) LatLng(c.centerLat, c.centerLon), + ]); + }); } return res.stats; }).catchError((Object e) { From 63038d362c0b76c720e2bc5c157b880f87da034f Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 21:33:38 -0400 Subject: [PATCH 087/100] feat(offline): per-region placement summary after upload The server now routes each offline ping to the region it occurred in and returns placement_counts + too_far_region per batch. Parse and accumulate these across all batches of an upload, store them on the uploaded OfflineSession, and show a summary ("Uploaded - DSA 88 - EMA 157 - too far 3") that is tappable for a per-region breakdown dialog. Add a defensive guard in WireTagCodec.encode() that rejects offline-/non-region session ids (offline is passive-only and is never wire-tag encoded; this also prevents a RangeError on a malformed id). Session id stays opaque otherwise, so the upload loop is unchanged and older behaviour is preserved. --- lib/providers/app_state_provider.dart | 33 +++++++++- lib/screens/settings_screen.dart | 78 ++++++++++++++++++++++- lib/services/api_service.dart | 8 ++- lib/services/meshcore/wire_tag_codec.dart | 8 +++ lib/services/offline_session_service.dart | 29 ++++++++- 5 files changed, 145 insertions(+), 11 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index daf227e..4702bdc 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -4982,6 +4982,27 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { var uploadedCount = 0; final totalBatches = (pings.length + batchSize - 1) ~/ batchSize; + // Accumulate the server's per-region placement summary across all batches so the uploaded + // session can show where its pings landed (e.g. "DSA 88 · EMA 157 · too far 3"). Offline + // uploads route each ping to its own region server-side; these come back per batch. + final Map placementTotals = {}; + var tooFarTotal = 0; + void accumulatePlacement(Map resp) { + final pc = resp['placement_counts']; + if (pc is Map) { + pc.forEach((k, v) { + final n = (v is int) ? v : (int.tryParse('$v') ?? 0); + placementTotals[k.toString()] = (placementTotals[k.toString()] ?? 0) + n; + }); + } + final tf = resp['too_far_region']; + if (tf is int) { + tooFarTotal += tf; + } else if (tf != null) { + tooFarTotal += int.tryParse('$tf') ?? 0; + } + } + // Backoff (seconds) for session-propagation / transient errors. The first batch // absorbs the session-propagation delay, so it gets a much longer budget than // later batches (which only see a session error if it genuinely expired/revoked). @@ -4996,7 +5017,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final backoff = i == 0 ? firstBatchBackoff : laterBatchBackoff; var result = - await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); + await _apiService.uploadBatchWithSessionId(batch, offlineSessionId, + onResponse: accumulatePlacement); // Retry only session-propagation / transient errors. nonRetryable // (data/zone/key) errors are NOT retried — we stop and preserve instead. @@ -5013,7 +5035,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { onProgress?.call('Batch $batchNum/$totalBatches (retry ${retry + 1})'); await Future.delayed(Duration(seconds: delay)); result = - await _apiService.uploadBatchWithSessionId(batch, offlineSessionId); + await _apiService.uploadBatchWithSessionId(batch, offlineSessionId, + onResponse: accumulatePlacement); } if (result == UploadResult.success) { @@ -5051,7 +5074,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final remainingPings = pings.length - uploadedCount; if (remainingPings <= 0) { - await _offlineSessionService.markAsUploaded(filename); + await _offlineSessionService.markAsUploaded( + filename, + placementCounts: placementTotals.isNotEmpty ? placementTotals : null, + tooFarRegion: tooFarTotal, + ); debugLog( '[OFFLINE] Session complete: $uploadedCount uploaded from $filename'); notifyListeners(); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index ea943ea..f1dedd0 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2869,11 +2869,83 @@ class _OfflineSessionTile extends StatelessWidget { required this.onDownload, }); + bool get _hasSummary => + (session.placementCounts?.isNotEmpty ?? false) || + ((session.tooFarRegion ?? 0) > 0); + + /// e.g. "DSA 88 · EMA 157 · too far 3" + String _placementSummary() { + final parts = []; + final pc = session.placementCounts; + if (pc != null && pc.isNotEmpty) { + final entries = pc.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + parts.addAll(entries.map((e) => '${e.key} ${e.value}')); + } + final tf = session.tooFarRegion ?? 0; + if (tf > 0) parts.add('too far $tf'); + return parts.join(' · '); + } + + void _showSummaryDialog(BuildContext context) { + final pc = session.placementCounts ?? {}; + final tf = session.tooFarRegion ?? 0; + final entries = pc.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Upload Summary'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(session.filename, + style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 10), + if (entries.isEmpty) + const Text('No regional placement recorded.') + else + ...entries.map((e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(e.key, + style: + const TextStyle(fontWeight: FontWeight.w600)), + Text('${e.value} pings'), + ], + ), + )), + if (tf > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + '$tf ping(s) dropped — more than 50 km outside every region', + style: TextStyle(color: Colors.orange.shade800, fontSize: 12), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Close'), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final isUploaded = session.uploaded; return ListTile( + onTap: (isUploaded && _hasSummary) + ? () => _showSummaryDialog(context) + : null, leading: Icon( isUploaded ? Icons.cloud_done : Icons.cloud_off, color: isUploaded ? Colors.green : Colors.orange, @@ -2884,9 +2956,9 @@ class _OfflineSessionTile extends StatelessWidget { children: [ Text('${session.pingCount} pings • ${session.displayDate}'), if (isUploaded) - const Text( - 'Uploaded', - style: TextStyle( + Text( + _hasSummary ? 'Uploaded · ${_placementSummary()}' : 'Uploaded', + style: const TextStyle( color: Colors.green, fontSize: 12, fontWeight: FontWeight.w500), diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 2298550..3d39a60 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1230,8 +1230,9 @@ class ApiService { /// Returns UploadResult only — does NOT call _clearSession(), onSessionError, or onMaintenanceMode Future uploadBatchWithSessionId( List> pings, - String sessionId, - ) async { + String sessionId, { + void Function(Map response)? onResponse, + }) async { if (pings.isEmpty) return UploadResult.success; try { @@ -1243,6 +1244,9 @@ class ApiService { } if (result['success'] == true) { + // Surface the server's per-batch placement_counts / too_far_region + // (offline routing) so the caller can accumulate an upload summary. + onResponse?.call(result); debugLog('[API] Offline upload batch SUCCESS: ${pings.length} items'); return UploadResult.success; } diff --git a/lib/services/meshcore/wire_tag_codec.dart b/lib/services/meshcore/wire_tag_codec.dart index c11f818..61f4994 100644 --- a/lib/services/meshcore/wire_tag_codec.dart +++ b/lib/services/meshcore/wire_tag_codec.dart @@ -106,6 +106,14 @@ class WireTagCodec { /// fallback (still coord-free, just not key-protected). static String encode(String sessionId, int counter, String? key) { final parts = sessionId.split('-'); + // Defensive guard: offline-upload ids ("offline-YYYYMMDD-NNNN") are passive-only and must + // never be wire-tag encoded — parts[0] would be the literal "offline", not a region code. + // The offline flow never calls encode() (only the live TX path does); this protects against + // an accidental future caller and a malformed id (which would otherwise RangeError on parts[2]). + if (parts.length < 3 || parts[0].toLowerCase() == 'offline') { + throw ArgumentError( + 'Cannot wire-tag encode an offline / non-region session id: $sessionId'); + } final v = _regionPack(parts[0]) * _pow25 + int.parse(parts[2]) * _pow11 + counter; diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index ecd0aca..78fead9 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -18,6 +18,9 @@ class OfflineSession { final double? powerLevel; // TX power level (watts) captured at record time final String? appVersion; // App version captured at record time final bool uploaded; // Track upload status + final Map? + placementCounts; // Per-IATA counts the server routed this upload into + final int? tooFarRegion; // Pings dropped for being >50km outside every region OfflineSession({ required this.filename, @@ -32,6 +35,8 @@ class OfflineSession { this.powerLevel, this.appVersion, this.uploaded = false, + this.placementCounts, + this.tooFarRegion, }); /// Create from stored JSON @@ -49,6 +54,9 @@ class OfflineSession { powerLevel: (json['powerLevel'] as num?)?.toDouble(), appVersion: json['appVersion'] as String?, uploaded: json['uploaded'] as bool? ?? false, + placementCounts: (json['placementCounts'] as Map?) + ?.map((k, v) => MapEntry(k.toString(), (v as num).toInt())), + tooFarRegion: json['tooFarRegion'] as int?, ); } @@ -67,6 +75,8 @@ class OfflineSession { 'powerLevel': powerLevel, 'appVersion': appVersion, 'uploaded': uploaded, + 'placementCounts': placementCounts, + 'tooFarRegion': tooFarRegion, }; } @@ -75,6 +85,8 @@ class OfflineSession { bool? uploaded, Map? data, int? pingCount, + Map? placementCounts, + int? tooFarRegion, }) { return OfflineSession( filename: filename, @@ -89,6 +101,8 @@ class OfflineSession { powerLevel: powerLevel, appVersion: appVersion, uploaded: uploaded ?? this.uploaded, + placementCounts: placementCounts ?? this.placementCounts, + tooFarRegion: tooFarRegion ?? this.tooFarRegion, ); } @@ -325,15 +339,24 @@ class OfflineSessionService { } } - /// Mark a session as uploaded without deleting it - Future markAsUploaded(String filename) async { + /// Mark a session as uploaded without deleting it. Optionally records the + /// per-region placement summary + too-far count returned by the server. + Future markAsUploaded( + String filename, { + Map? placementCounts, + int? tooFarRegion, + }) async { final index = _sessions.indexWhere((s) => s.filename == filename); if (index == -1) { debugLog('[OFFLINE] Session not found for marking uploaded: $filename'); return; } - _sessions[index] = _sessions[index].copyWith(uploaded: true); + _sessions[index] = _sessions[index].copyWith( + uploaded: true, + placementCounts: placementCounts, + tooFarRegion: tooFarRegion, + ); await _saveSessions(); debugLog('[OFFLINE] Marked session as uploaded: $filename'); } From 6e1332533a0c1d01c2f521c89e12decf25a3bcbd Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 21:37:57 -0400 Subject: [PATCH 088/100] Fix recurring iOS launch/focus crash: map fit-to-bounds could send MapLibre a non-finite zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The camera fit-to-bounds paths could hand MapLibre a zero-area box (coincident points) or edge padding larger than the visible map (the tall coverage/repeater bottom sheet), both of which make its projection compute a non-finite zoom and abort the app via an uncaught native LatLng throw (SIGABRT) — the same crash signature as an invalid coordinate. All newLatLngBounds fits now route through a guarded _animateFitBounds helper that centers degenerate boxes (isDegenerateBounds) and clamps padding to the live map size (clampFitPadding). The earlier lat/lon-only guard (813273d) never covered zoom/bounds/padding, so the same crash survived in the coverage focus-camera feature. Adds unit tests for the new helpers. --- lib/utils/geo_validation.dart | 58 ++++++++++++++++++ lib/widgets/map_widget.dart | 91 +++++++++++++++++++++++------ test/utils/geo_validation_test.dart | 58 ++++++++++++++++++ 3 files changed, 189 insertions(+), 18 deletions(-) diff --git a/lib/utils/geo_validation.dart b/lib/utils/geo_validation.dart index 274f6be..6151481 100644 --- a/lib/utils/geo_validation.dart +++ b/lib/utils/geo_validation.dart @@ -12,3 +12,61 @@ /// validated against the WGS84 domain first. bool isValidLatLng(double lat, double lon) => lat.isFinite && lon.isFinite && lat.abs() <= 90 && lon.abs() <= 180; + +/// Whether a lat/lon bounding box is zero/near-zero area (a single point, or a +/// cluster of coincident points). +/// +/// Fitting the camera to a degenerate box makes MapLibre divide by a ~0 span, +/// producing a non-finite zoom that propagates into its `unproject` math and +/// aborts the app via the same `LatLng` throw as invalid coordinates. Callers +/// must detect this and fall back to a plain center+zoom move instead. +/// [epsilonDeg] defaults to 1e-6° (≈0.1 m) — below this a fit is meaningless. +bool isDegenerateBounds( + double minLat, + double maxLat, + double minLon, + double maxLon, { + double epsilonDeg = 1e-6, +}) => + (maxLat - minLat).abs() < epsilonDeg && (maxLon - minLon).abs() < epsilonDeg; + +/// Clamp fit-bounds edge padding so it can never meet or exceed the map's +/// rendered size. +/// +/// MapLibre fits a bounds into `size - padding`; if the horizontal or vertical +/// padding sums to ≥ the map dimension the available area goes to zero/negative, +/// yielding a non-finite zoom that aborts the app inside `unproject`. This keeps +/// at least [minVisible] logical pixels visible on each axis, shrinking each +/// side proportionally when the requested padding is too large. When [width] or +/// [height] is unknown (≤ 0, e.g. before first layout) it returns small safe +/// defaults rather than trusting the caller's values. +({double left, double top, double right, double bottom}) clampFitPadding( + double left, + double top, + double right, + double bottom, + double width, + double height, { + double minVisible = 40, +}) { + // Unknown/zero viewport: don't trust the requested padding at all. + if (!width.isFinite || !height.isFinite || width <= 0 || height <= 0) { + return (left: 8, top: 8, right: 8, bottom: 8); + } + + ({double a, double b}) clampPair(double a, double b, double extent) { + a = a.isFinite && a > 0 ? a : 0; + b = b.isFinite && b > 0 ? b : 0; + final budget = extent - minVisible; + if (budget <= 0) return (a: 0, b: 0); + final sum = a + b; + if (sum <= budget) return (a: a, b: b); + // Shrink proportionally to fit the budget (sum > 0 here since sum > budget ≥ 0). + final scale = budget / sum; + return (a: a * scale, b: b * scale); + } + + final h = clampPair(left, right, width); + final v = clampPair(top, bottom, height); + return (left: h.a, top: v.a, right: h.b, bottom: v.b); +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 77071fa..8147141 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1051,15 +1051,77 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (p.longitude < minLon) minLon = p.longitude; if (p.longitude > maxLon) maxLon = p.longitude; } - final bounds = LatLngBounds( - southwest: LatLng(minLat, minLon), - northeast: LatLng(maxLat, maxLon), - ); + // Leave room for the bottom sheet/pill that accompanies focus views. final bottomPad = MediaQuery.of(context).size.height * 0.4; + _animateFitBounds( + minLat: minLat, + maxLat: maxLat, + minLon: minLon, + maxLon: maxLon, + leftPad: 60, + topPad: 60, + rightPad: 60, + bottomPad: bottomPad, + ); + } + + /// Fit the camera to a lat/lon box, guarding the two inputs MapLibre will + /// choke on: a degenerate (zero-area) box and edge padding that exceeds the + /// map's rendered size. Either yields a non-finite zoom that propagates into + /// MapLibre's native `unproject` and aborts the app with an uncaught C++ + /// `LatLng` throw (SIGABRT) — the same failure mode as an invalid coordinate, + /// which a plain lat/lon validity check does not catch. A degenerate box falls + /// back to a plain center+zoom move; padding is clamped to the live map size. + void _animateFitBounds({ + required double minLat, + required double maxLat, + required double minLon, + required double maxLon, + double leftPad = 60, + double topPad = 60, + double rightPad = 60, + double bottomPad = 60, + }) { + if (_mapController == null || + !_isMapReady || + !mounted || + !_canAnimateCamera) { + return; + } + final centerLat = (minLat + maxLat) / 2; + final centerLon = (minLon + maxLon) / 2; + + // A single point (or coincident cluster) has no span to frame — center on it + // instead of asking MapLibre to fit a zero-area box. + if (isDegenerateBounds(minLat, maxLat, minLon, maxLon)) { + _animateToPositionWithZoom( + LatLng(centerLat, centerLon), 16.0 - _zoomEpsilon); + return; + } + + // Belt-and-suspenders: never hand MapLibre a non-finite corner. + if (!isValidLatLng(minLat, minLon) || !isValidLatLng(maxLat, maxLon)) { + debugWarn('[MAP] Skipping fit-bounds with invalid corners ' + '($minLat,$minLon)-($maxLat,$maxLon)'); + return; + } + + final size = context.size ?? MediaQuery.of(context).size; + final pad = clampFitPadding( + leftPad, topPad, rightPad, bottomPad, size.width, size.height); + _mapController!.animateCamera( - CameraUpdate.newLatLngBounds(bounds, - left: 60, top: 60, right: 60, bottom: bottomPad), + CameraUpdate.newLatLngBounds( + LatLngBounds( + southwest: LatLng(minLat, minLon), + northeast: LatLng(maxLat, maxLon), + ), + left: pad.left, + top: pad.top, + right: pad.right, + bottom: pad.bottom, + ), duration: const Duration(milliseconds: 500), ); } @@ -5573,18 +5635,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { if (p.longitude! > maxLon) maxLon = p.longitude!; } - _mapController!.animateCamera( - CameraUpdate.newLatLngBounds( - LatLngBounds( - southwest: LatLng(minLat, minLon), - northeast: LatLng(maxLat, maxLon), - ), - left: 60, - top: 60, - right: 60, - bottom: 60, - ), - duration: const Duration(milliseconds: 500), + _animateFitBounds( + minLat: minLat, + maxLat: maxLat, + minLon: minLon, + maxLon: maxLon, ); } diff --git a/test/utils/geo_validation_test.dart b/test/utils/geo_validation_test.dart index b2aac14..b1f6e87 100644 --- a/test/utils/geo_validation_test.dart +++ b/test/utils/geo_validation_test.dart @@ -37,4 +37,62 @@ void main() { expect(isValidLatLng(0, 200), isFalse); }); }); + + group('isDegenerateBounds', () { + test('true for identical / coincident points', () { + expect(isDegenerateBounds(45.0, 45.0, -75.0, -75.0), isTrue); + // Sub-0.1m jitter still counts as degenerate. + expect(isDegenerateBounds(45.0, 45.0000001, -75.0, -75.0000001), isTrue); + }); + + test('false for a real spread', () { + expect(isDegenerateBounds(45.0, 45.1, -75.0, -75.1), isFalse); + // Degenerate on one axis only is still a fit-able line. + expect(isDegenerateBounds(45.0, 45.0, -75.0, -75.1), isFalse); + expect(isDegenerateBounds(45.0, 45.1, -75.0, -75.0), isFalse); + }); + }); + + group('clampFitPadding', () { + test('passes through padding that fits', () { + final p = clampFitPadding(60, 60, 60, 60, 400, 800); + expect(p.left, 60); + expect(p.top, 60); + expect(p.right, 60); + expect(p.bottom, 60); + }); + + test('shrinks oversized vertical padding to keep the map visible', () { + // bottom alone exceeds the height budget (the reported crash shape). + final p = clampFitPadding(60, 60, 60, 1000, 400, 800); + expect(p.top + p.bottom, lessThanOrEqualTo(800 - 40)); + expect(p.top, greaterThan(0)); + expect(p.bottom, greaterThan(0)); + // Horizontal axis untouched. + expect(p.left, 60); + expect(p.right, 60); + }); + + test('zero/negative budget yields no padding on that axis', () { + final p = clampFitPadding(60, 60, 60, 60, 400, 30); + expect(p.top, 0); + expect(p.bottom, 0); + }); + + test('unknown/zero viewport returns small safe defaults', () { + final p = clampFitPadding(60, 60, 60, 1000, 0, 0); + expect(p.left, 8); + expect(p.top, 8); + expect(p.right, 8); + expect(p.bottom, 8); + }); + + test('non-finite padding is treated as zero', () { + final p = clampFitPadding(double.nan, 60, double.infinity, 60, 400, 800); + expect(p.left, 0); + expect(p.right, 0); + expect(p.top, 60); + expect(p.bottom, 60); + }); + }); } From 0e22a3f7bcdc9c2fde64c4b5a918cc2497d93ed8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 22:26:06 -0400 Subject: [PATCH 089/100] Resume ping counter from /auth resume_counter on session reuse On a fresh /auth, continue the wire-tag ping counter from the server's resume_counter (sent when the session was reused after a force-close + reconnect) instead of resetting to 0, which re-minted duplicate wire-tags the subscriber + coverage dedup silently dropped. Absent/0 -> reset as before. --- lib/services/api_service.dart | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 3d39a60..ec98dc1 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -433,10 +433,17 @@ class ApiService { _rxAllowed = data['rx_allowed'] == true; _sessionExpiresAt = data['expires_at'] as int?; - // TX wire-tag key + per-session ping counter (counter resets on a fresh /auth). - // Log only receipt + length — NEVER the raw key (debug logs are uploadable). + // TX wire-tag key + per-session ping counter. The counter RESUMES from the server's + // resume_counter when our session was reused (force-close + reconnect): resetting to 0 + // here would re-mint wire-tags identical to the pre-restart pings, which the subscriber + // + coverage dedup then silently drop. Absent (new session / old server) -> 0, the + // original behaviour. Log only receipt + length — NEVER the raw key (uploadable logs). _wireKey = data['wire_key'] as String?; - _pingCounter = 0; + final resumeCounter = (data['resume_counter'] as num?)?.toInt() ?? 0; + _pingCounter = resumeCounter; + if (resumeCounter > 0) { + debugLog('[AUTH] resumed ping counter at $resumeCounter (reused session)'); + } if (_wireKey != null) { debugLog('[AUTH] wire-tag key received (len=${_wireKey!.length})'); } else { From da66ff7a8505bc4143ec6339339ac23450457cbd Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 23:42:48 -0400 Subject: [PATCH 090/100] Vendor patched maplibre_gl 0.25.0 with camera-viewport crash guard MapLibre's transform unprojects against the live viewport. On a launch where the GL surface is degenerate/zero-sized (tiles never render a real frame), the first animated flyTo/setCamera makes unproject return NaN and mbgl::LatLng throws an uncaught C++ std::domain_error -> SIGABRT. That throw crosses the native->Dart boundary and cannot be caught from Dart, so the fix has to live inside the plugin. - Vendor maplibre_gl 0.25.0 under third_party/maplibre_gl, consumed via pubspec.yaml dependency_overrides (no longer from pub.dev). - Native MESHMAPPER GUARD in the camera handlers of MapLibreMapController (.swift / .java): bail out (still completing the method-channel result) when the map view has no usable size. - Dart defense-in-depth: _mapHasRenderedOnce (set on first onMapIdle) is folded into _canAnimateCamera, so no programmatic camera move is even attempted until the map has rendered once; the one-shot initial GPS zoom re-attempts on later ticks instead of burning. - analysis_options.yaml excludes third_party/** from our lints. - DEVELOPMENT.md documents the vendoring and the re-apply-on-upgrade step. --- DEVELOPMENT.md | 29 +- analysis_options.yaml | 4 + lib/widgets/map_widget.dart | 32 +- pubspec.lock | 7 +- pubspec.yaml | 12 + third_party/maplibre_gl/CHANGELOG.md | 669 +++++ third_party/maplibre_gl/LICENSE | 181 ++ third_party/maplibre_gl/README.md | 394 +++ third_party/maplibre_gl/analysis_options.yaml | 1 + third_party/maplibre_gl/android/build.gradle | 62 + .../maplibre_gl/android/gradle.properties | 2 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43462 bytes .../gradle/wrapper/gradle-wrapper.properties | 8 + .../maplibre_gl/android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 3 + .../java/org/maplibre/maplibregl/Convert.java | 325 +++ .../maplibregl/GlobalMethodHandler.java | 157 + .../maplibregl/LayerPropertyConverter.java | 659 +++++ .../maplibregl/LocationEngineFactory.kt | 38 + .../MapLibreCustomHttpInterceptor.java | 74 + .../maplibregl/MapLibreGPSLocationEngine.java | 140 + .../maplibregl/MapLibreHttpRequestUtil.java | 43 + .../maplibregl/MapLibreMapBuilder.java | 253 ++ .../maplibregl/MapLibreMapController.java | 2555 +++++++++++++++++ .../maplibregl/MapLibreMapFactory.java | 48 + .../maplibregl/MapLibreMapOptionsSink.kt | 56 + .../maplibregl/MapLibreMapsPlugin.java | 104 + .../maplibre/maplibregl/MapLibreUtils.java | 12 + .../maplibregl/OfflineChannelHandlerImpl.java | 55 + .../maplibregl/OfflineManagerUtils.java | 337 +++ .../maplibregl/SourcePropertyConverter.java | 232 ++ .../org/maplibre/maplibregl/setMapLanguage.kt | 31 + .../maplibre_gl/ios/maplibre_gl.podspec | 23 + .../maplibre_gl/ios/maplibre_gl/Package.swift | 30 + .../Sources/maplibre_gl/Constants.swift | 51 + .../Sources/maplibre_gl/Convert.swift | 229 ++ .../Sources/maplibre_gl/Enums.swift | 3 + .../Sources/maplibre_gl/Extensions.swift | 148 + .../maplibre_gl/LayerPropertyConverter.swift | 536 ++++ .../maplibre_gl/MGLMapView+setLanguage.swift | 55 + .../maplibre_gl/MapLibreCustomHeaders.swift | 39 + .../maplibre_gl/MapLibreMapController.swift | 2131 ++++++++++++++ .../maplibre_gl/MapLibreMapFactory.swift | 25 + .../maplibre_gl/MapLibreMapOptionsSink.swift | 23 + .../maplibre_gl/MapLibreMapsPlugin.swift | 170 ++ .../Sources/maplibre_gl/MethodCallError.swift | 98 + .../maplibre_gl/OfflineChannelHandler.swift | 69 + .../maplibre_gl/OfflineManagerUtils.swift | 101 + .../OfflinePackDownloadManager.swift | 223 ++ .../Sources/maplibre_gl/OfflineRegion.swift | 57 + .../maplibre_gl/OfflineRegionDefinition.swift | 56 + .../maplibre_gl/SourcePropertyConverter.swift | 241 ++ third_party/maplibre_gl/lib/maplibre_gl.dart | 109 + .../lib/src/annotation_manager.dart | 376 +++ .../maplibre_gl/lib/src/color_tools.dart | 12 + .../maplibre_gl/lib/src/controller.dart | 1721 +++++++++++ .../lib/src/download_region_status.dart | 25 + third_party/maplibre_gl/lib/src/global.dart | 136 + .../lib/src/layer_expressions.dart | 657 +++++ .../maplibre_gl/lib/src/layer_properties.dart | 2380 +++++++++++++++ .../maplibre_gl/lib/src/maplibre_map.dart | 563 ++++ .../maplibre_gl/lib/src/maplibre_styles.dart | 15 + .../maplibre_gl/lib/src/offline_region.dart | 76 + third_party/maplibre_gl/lib/src/util.dart | 14 + third_party/maplibre_gl/pubspec.yaml | 35 + 65 files changed, 16944 insertions(+), 7 deletions(-) create mode 100644 third_party/maplibre_gl/CHANGELOG.md create mode 100644 third_party/maplibre_gl/LICENSE create mode 100644 third_party/maplibre_gl/README.md create mode 100644 third_party/maplibre_gl/analysis_options.yaml create mode 100644 third_party/maplibre_gl/android/build.gradle create mode 100644 third_party/maplibre_gl/android/gradle.properties create mode 100644 third_party/maplibre_gl/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 third_party/maplibre_gl/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 third_party/maplibre_gl/android/settings.gradle create mode 100644 third_party/maplibre_gl/android/src/main/AndroidManifest.xml create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/Convert.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/GlobalMethodHandler.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LayerPropertyConverter.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LocationEngineFactory.kt create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreCustomHttpInterceptor.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreGPSLocationEngine.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreHttpRequestUtil.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapBuilder.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapController.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapFactory.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapOptionsSink.kt create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapsPlugin.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreUtils.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineChannelHandlerImpl.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineManagerUtils.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/SourcePropertyConverter.java create mode 100644 third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/setMapLanguage.kt create mode 100644 third_party/maplibre_gl/ios/maplibre_gl.podspec create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Package.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Constants.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Convert.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Enums.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Extensions.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/LayerPropertyConverter.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MGLMapView+setLanguage.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreCustomHeaders.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapController.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapFactory.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapOptionsSink.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapsPlugin.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MethodCallError.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineChannelHandler.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineManagerUtils.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflinePackDownloadManager.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegion.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegionDefinition.swift create mode 100644 third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/SourcePropertyConverter.swift create mode 100644 third_party/maplibre_gl/lib/maplibre_gl.dart create mode 100644 third_party/maplibre_gl/lib/src/annotation_manager.dart create mode 100644 third_party/maplibre_gl/lib/src/color_tools.dart create mode 100644 third_party/maplibre_gl/lib/src/controller.dart create mode 100644 third_party/maplibre_gl/lib/src/download_region_status.dart create mode 100644 third_party/maplibre_gl/lib/src/global.dart create mode 100644 third_party/maplibre_gl/lib/src/layer_expressions.dart create mode 100644 third_party/maplibre_gl/lib/src/layer_properties.dart create mode 100644 third_party/maplibre_gl/lib/src/maplibre_map.dart create mode 100644 third_party/maplibre_gl/lib/src/maplibre_styles.dart create mode 100644 third_party/maplibre_gl/lib/src/offline_region.dart create mode 100644 third_party/maplibre_gl/lib/src/util.dart create mode 100644 third_party/maplibre_gl/pubspec.yaml diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 784e1b9..ae072d1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -457,13 +457,40 @@ Key packages used in this project: - `flutter_blue_plus`: Mobile Bluetooth (Android/iOS) - `flutter_web_bluetooth`: Web Bluetooth (Chrome/Edge) - `geolocator`: GPS/Location -- `maplibre_gl`: Map rendering (MapLibre GL vector tiles via OpenFreeMap) +- `maplibre_gl`: Map rendering (MapLibre GL vector tiles via OpenFreeMap) — **vendored & patched**, see below - `hive`: Local storage - `provider`: State management - `http`: API requests - `pointycastle`: Encryption (AES-ECB, SHA-256) - `usb_serial`: USB Serial communication on Android (USB OTG) +### Vendored `maplibre_gl` (`third_party/maplibre_gl`) + +`maplibre_gl` is consumed from an in-repo copy of the pub.dev `0.25.0` release via +`dependency_overrides` in `pubspec.yaml`, **not** from pub. The ONLY delta from upstream is a +native camera-viewport guard. + +**Why:** MapLibre's transform unprojects against the live viewport. When the GL surface is +degenerate/zero-sized (e.g. a launch where tiles never finish loading, so the surface never renders +a real frame), the very first animated `flyTo`/`setCamera` makes `unproject` produce NaN, and +`mbgl::LatLng`'s constructor throws an **uncaught C++ `std::domain_error` → SIGABRT**. That throw +crosses the Obj-C++→Swift/JNI boundary and **cannot be caught from Dart**, so the only place it can +be reliably stopped is inside the plugin's camera handlers. + +**The patch:** the `camera#animate` / `camera#move` / `camera#ease` cases in +`MapLibreMapController.swift` (iOS) and `MapLibreMapController.java` (Android) bail (completing the +method-channel result so the Dart `await` returns) when the map view has no usable size +(`bounds.width/height < 1` / `getWidth()/getHeight() < 1`). Search the patch with the tag +`MESHMAPPER GUARD`. + +The Dart side (`map_widget.dart`) is defense-in-depth: `_mapHasRenderedOnce` (set on the first +`onMapIdle`) is folded into `_canAnimateCamera`, so no programmatic camera move is even attempted +until the map has rendered once; the one-shot initial GPS zoom re-attempts on later ticks instead of +burning. See the `_canAnimateCamera` getter and `_onMapIdle`. + +**On upgrade:** re-apply the `MESHMAPPER GUARD` blocks to the new plugin version (or drop the +override if upstream gains an equivalent guard). + ## Development Workflow Requirements ### Debug Logging Convention (MANDATORY) diff --git a/analysis_options.yaml b/analysis_options.yaml index 9fd3c2c..3d54c64 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -18,3 +18,7 @@ linter: analyzer: errors: invalid_annotation_target: ignore + exclude: + # Vendored, patched copy of maplibre_gl (see pubspec.yaml dependency_overrides). + # Analyze it under its own options, not ours. + - third_party/** diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 8147141..139f935 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -453,9 +453,23 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // after resume lets the surface reach a valid state first. AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; bool _cameraAnimationReady = false; + + // Set true the first time the map reports idle (`_onMapIdle`). The map only + // goes idle AFTER it has actually rendered a frame / loaded tiles, so this is + // the throw-safe Dart signal that the native viewport is non-degenerate. The + // one-frame `_cameraAnimationReady` latch above proved insufficient: a bad + // launch (tiles never load → viewport stays degenerate for the whole session) + // kept _cameraAnimationReady=true yet still aborted the very first flyTo. Until + // the map renders, every programmatic camera move is held (and the one-shot + // initial zoom re-attempts on later ticks instead of burning). The native + // viewport guard in the vendored maplibre_gl plugin is the final backstop for + // the case where the map reports idle on a still-degenerate surface. + bool _mapHasRenderedOnce = false; + bool get _canAnimateCamera => _appLifecycleState == AppLifecycleState.resumed && - _cameraAnimationReady; + _cameraAnimationReady && + _mapHasRenderedOnce; // Auto-follow GPS like a navigation app bool _autoFollow = false; // Disabled by default - users often zoom out first @@ -1427,7 +1441,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } // Auto-follow: bundle pan, zoom, and bearing into one animateCamera call. - if (_autoFollow && _isMapReady && _cameraAnimationReady) { + // Gate on _canAnimateCamera (not just _cameraAnimationReady) so the first + // follow tick is also held until the map has rendered once — same backstop + // as the initial zoom against the degenerate-viewport flyTo abort. + if (_autoFollow && _isMapReady && _canAnimateCamera) { final newPosition = center; if (_lastGpsPosition == null || _lastGpsPosition!.latitude != newPosition.latitude || @@ -2969,6 +2986,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// that the new tiles have rendered. void _onMapIdle() { _tileLoadTimeoutTimer?.cancel(); + + // First idle = the GL surface has rendered → the native viewport is now + // valid and camera animations are safe (see _mapHasRenderedOnce). Nudge a + // rebuild so the deferred one-shot initial zoom (held while the map was not + // yet rendered) re-attempts via build()'s _handleGpsPosition safety-net. + if (!_mapHasRenderedOnce && mounted) { + _mapHasRenderedOnce = true; + debugLog('[MAP] First map idle — camera animations enabled'); + setState(() {}); + } + if (_consecutiveTileLoadFailures > 0 && mounted) { if (_consecutiveTileLoadFailures >= _tileLoadFailureThreshold) { debugLog('[MAP] Tiles recovered after $_consecutiveTileLoadFailures consecutive load failures'); diff --git a/pubspec.lock b/pubspec.lock index 9a5dbf7..c63f0b2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -707,10 +707,9 @@ packages: maplibre_gl: dependency: "direct main" description: - name: maplibre_gl - sha256: d9773555ae4ebab94bbc3ae2176b077cfda486ec729eefe01e1613f164cb8410 - url: "https://pub.dev" - source: hosted + path: "third_party/maplibre_gl" + relative: true + source: path version: "0.25.0" maplibre_gl_platform_interface: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index 80b57d4..adba300 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,18 @@ dev_dependencies: hive_generator: ^2.0.0 flutter_launcher_icons: ^0.14.3 +# Vendored, patched copy of maplibre_gl 0.25.0. The ONLY delta from the +# upstream pub.dev release is a native guard in the camera handlers +# (ios/.../MapLibreMapController.swift + android/.../MapLibreMapController.java) +# that refuses camera moves while the map view has no usable viewport — +# MapLibre's transform unprojects against the viewport and aborts the app with +# an uncaught C++ std::domain_error (SIGABRT) when it is degenerate/zero-sized. +# That throw cannot be caught from Dart, so the fix must live in the plugin. +# Re-apply the guard when bumping maplibre_gl. See DEVELOPMENT.md. +dependency_overrides: + maplibre_gl: + path: third_party/maplibre_gl + flutter_launcher_icons: android: true ios: true diff --git a/third_party/maplibre_gl/CHANGELOG.md b/third_party/maplibre_gl/CHANGELOG.md new file mode 100644 index 0000000..806122f --- /dev/null +++ b/third_party/maplibre_gl/CHANGELOG.md @@ -0,0 +1,669 @@ +## [0.25.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.1...v0.25.0) - 2026-01-07 + +### Added +* Logo customization options including visibility and position settings (#b4fb174). +* Explicit annotation manager initialization with clear error handling (#668). +* iOS: Attribution support for tile and raster sources with HTML link parsing. + +### Changed +* MapLibre Android SDK upgraded from `11.13.5` to `12.3.0` (#690). + - Includes synchronous GeoJSON source updates + - Support for MLT-format vector tile sources + - Better frustum offset support + - See [MapLibre Native Android 12.3.0 release notes](https://github.com/maplibre/maplibre-native/releases/tag/android-v12.3.0) +* OkHttp updated from `4.12.0` to `5.3.2` for Node.js 24 compatibility (#676, #700). +* Kotlin updated to `2.3.0` (#697, #698). +* Android Gradle Plugin updated to `8.13.2` (#695, #674). +* Android Application Plugin updated to `8.13.2` (#696, #689). +* GitHub Actions: `actions/checkout` updated from v5 to v6 (#672, #693). +* GitHub Actions: `actions/upload-artifact` updated from v4 to v6 (#688, #694). + +### Fixed +* Min/max zoom preference on iOS (#5230fab). +* `queryRenderedFeatures` now returns all targets when supplying empty layers list on iOS, aligning behavior with Android (#680). +* iOS: Enhanced LayerPropertyConverter to handle null values and improve expression parsing (#98660dc). +* Fixed `lineDasharray` and patterns reset to null in layer properties (#2b550ed). +* Improved MapLibreMapController disposing to prevent memory leaks. +* Removed unnecessary disposing of mapController in example app (#f989797). +* Fixed `setLayerProperties` and pattern images on web and Android (#9ce52a6). + - Pattern images now correctly converted to RGBA format on web + - Fixed mismatched image size error when loading pattern images + +### Refactor +* Complete refactor of example app with new UI and improved user experience (#ac877a4). +* Refactored `cameraTargetBounds` implementation on Android and iOS for consistent behavior (#8bcd74a). + +**Full Changelog**: [v0.24.1...v0.25.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.1...v0.25.0) + +## [0.24.1](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.0...v0.24.1) + +### Fixed +* Annotation tap call callbacks twice. (#652) +* Annotation APIs: use null-aware access for manager-backed collections (symbols, lines, circles, fills) to avoid null errors before style load. (#657) +* Add methods enforce explicit manager initialization with clear exceptions when style is not loaded. (#657) +* Calling add* before style load now fails fast with a clear Exception instead of risking null dereferences or silent failures. (#657) + +### Changed +* Rollback maplibre-gl to `4.7.1` version. (#660) + +### Added +* Added `onCameraMove` callback in the controller and in MapLibreMap class. (#643) + +## [0.24.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.23.0...v0.24.0) +> **Note**: This release has breaking changes.\ +> We apologize for the quick change in 0.24.0: this version definitively stabilizes the signatures of feature interaction callbacks. + +This release restores the **feature id** and makes the `Annotation` parameter **nullable** for all feature interaction callbacks (`tap` / `drag` / `hover`).\ +This unblocks interaction with style-layer features not managed by annotation managers (i.e. added via `addLayer*` / style APIs). + +### Breaking Changes + * **Tap**: `OnFeatureInteractionCallback` → `(Point point, LatLng coordinates, String id, String layerId, Annotation? annotation)`. + +* **Drag**: `OnFeatureDragCallback` → `(Point point, LatLng origin, LatLng current, LatLng delta, String id, Annotation? annotation, DragEventType eventType)`. + +* **Hover**: `OnFeatureHoverCallback` → `Point point, LatLng coordinates, String id, Annotation? annotation, HoverEventType eventType)`. + +* **Update existing listeners**: The short‑lived 0.23.0-only signatures (without `id`) are removed. + * For unmanaged style layer features `annotation` is `null` (`unmanaged` means sources/layers you add via style APIs like `addGeoJsonSource` + `addSymbolLayer`). + * For managed annotations it is the `Annotation` object. + +### Reasoning +In 0.23.0 the move to annotation objects inadvertently dropped interaction for unmanaged style features. Reintroducing `id` (and making `annotation` nullable) normalizes all three interaction paths without creating phantom annotation wrappers. + +### Migration Example +Before (0.23.0): +``` +controller.onFeatureTapped.add((p, latLng, annotation, layerId) { + print(annotation.id); +}); +``` +After (>=0.24.0): +``` +controller.onFeatureTapped.add((p, latLng, id, layerId, annotation) { + print('feature id=$id managed=${annotation != null}'); +}); +``` + +### Refactor / Quality +* (web) Refactored `onMapClick` (degenerate bbox + interactive layer filter) to surface features inserted via style APIs (unmanaged style-layer features) in `onFeatureTapped` (previously skipped; returned now with `id`, `layerId` and `annotation = null`) (#646). +* (web) Ensure map container stretches vertically by adding `style.height = '100%'` to the registered div (prevents occasional zero-height layout issues in flexible parents) (#641) + +**Full Changelog**: [v0.23.0...v0.24.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.23.0...v0.24.0) + +## [0.23.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.22.0...v0.23.0) +> **Note**: This release has breaking changes. + +This release aligns the plugin with the latest MapLibre Native (Android 11.9.0 / iOS 6.14.0), introduces runtime style switching APIs, hover interaction callbacks, and several annotation interaction improvements. It also contains a small breaking change for feature interaction callbacks. + +A big thank you to everyone who contributed to this update! + +### Breaking Changes +* `onFeatureDrag` / `onFeatureTapped` callback signatures now provide an `Annotation annotation` object instead of an `id` parameter. Update your handlers to remove the `id` argument and use `annotation.id` (or other annotation fields) as needed. + +### Highlights +* Runtime style switching via controller (`setStyle…`) without tearing down the map (#444, #603). +* Hover interaction events (`onFeatureHover`) for richer desktop/web UX (#614). +* Improved event handling reliability (cancellation & consumption fixes) (#621, #623). +* Offline region download crash fix in example (#569) and style loaded safety checks (#563). +* Updated MapLibre Native bringing PMTiles & performance improvements (#552, #582). + +### Added / Updated +* **Feature:** added set style method on controller (#444) & support setting raw style JSON on iOS/web (#603). +* **Feature:** expose hovering events (`onFeatureHover`) (#614). +* **Update:** bump Android to 11.9.0 & iOS to 6.14.0 (#582). +* **Update:** update maplibre-native to the latest versions / PMTiles support (#552). +* **CI/Tooling:** upgrade Flutter Gradle Plugin & compatibility with Flutter 3.29.0 (#542). + +### Fixed +* iOS code generation: corrected handling of Offset / Translate / expression arrays in generated bindings (#481). +* Annotation tap consumption now respected (`annotationConsumeTapEvents`). +* Prevent calling `notifyListeners()` after controller disposal (#621). +* Web: event listener cancellation & hover handling robustness (#623). +* Example: offline region download crash (#569). +* Added style readiness checks before access (#563). + +### Refactor / Quality +* Enable and fix additional lint rules to enforce consistency (#452). + +**Full Changelog**: [v0.22.0...v0.23.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.22.0...v0.23.0) + +## [0.22.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.21.0...v0.22.0) + +### Breaking changes + +* Updated maplibre-native for iOS to v6.14.0. This mainly introduces PMTiles support. See the + [maplibre-native changelog](https://github.com/maplibre/maplibre-native/blob/main/platform/ios/CHANGELOG.md#6121) + for more information. +* Updated maplibre-native for Android to v11.9.0. This mainly introduces PMTiles support. + Flutter version packed with OpenGL ES 3.0 build for now, later we could probably switch to Vulkan. + See the [maplibre-native changelog](https://github.com/maplibre/maplibre-native/blob/main/platform/android/CHANGELOG.md#1181) + for more information. +* queryRenderedFeaturesInRect support string feature ids on web (#576). + +### Changed + +* Added `await` to all `addLayer` calls (#558). + +### Fixed + +* Fixed `Unsupported operation` error on web (#551). + +## [0.21.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.20.0...v0.21.0) + +### Added + +* added the `clearAmbientCache` functionality (#502). +* added the `contains` functionality to `LatLngBounds` (#498). +* added the possibility to set `LocationEnginePlatforms` properties for better device tracking on Android (#510). + +### Changed + +* BREAKING: `onFeatureTap` returns the `layerId` (#475). +* Changed iOS package name to support Swift Package Manager (#467). +* Move the `maplibre_gl` package to a subdirectory of the repository and add + melos to orchestrate all packages (#453). + +### Removed + +* Removed support for Dart SDKs older than `3.4.0` (`Flutter SDK 3.22.0`) (#542) + +### Fixed + +* Fixed exception when destroying mapView on Android by reordering cleanup (#459). + + +## 0.20.0 + +A lot of files/classes have been renamed and moved around in this release. +If you notice any build errors, please make sure to run `flutter clean`. + +### Breaking changes + +* All Dart enums have been migrated from mixed cases to lower camelcase + according to the `camel_case_types` lint rule. +* Move `MapLibreStyles` to the main `maplibre_gl` package. You can now use the + demo style without adding `maplibre_gl_platform_interface` as a dependency. +* Updated maplibre-native for ios to v6.5.0. This introduces the new + iOS Metal renderer and the OpenGL ES renderer now uses OpenGL ES 3.0. Only + iOS Devices with an Apple A7 GPU or later are supported onwards. See the + [maplibre-native changelog](https://github.com/maplibre/maplibre-native/blob/main/platform/ios/CHANGELOG.md#600) + for more information. +* Updated maplibre-native for android to v11.0.0. This version uses + OpenGL ES 3.0. See the + [maplibre-native changelog](https://github.com/maplibre/maplibre-native/blob/main/platform/android/CHANGELOG.md#1100) + for more information. +* Renamed the method channel to `plugins.flutter.io/maplibre_gl_*` in all + packages. +* Renamed "Maplibre" to "MapLibre" to be in line with maplibre-native + (affects for example the classes `MaplibreMap` and `MaplibreMapController`). + +### Changes + +* Added support for Swift Package Manager usage on iOS. +* Migrated main iOS plugin class from Objective-C to Swift. +* Renamed iOS plugin classes from `Mapbox` to `MapLibre`. +* Removed support for Kotlin versions older than `1.9.0` (#460). + +**Full Changelog**: +[v0.19.0+2...v0.20.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.19.0+2...v0.20.0) + +## 0.19.0 + +This is the first version where all packages are published on pub.dev. Please +use the `maplibre_gl` package directly from pub.dev from now on. Check the +`README` documentation on how to include it in your project. + +### Changes + +* Bump min Dart SDK to 3.0.0 (this was already implicitly required by transitive + dependencies) +* Prepare all packages for publishing + to [pub.dev](https://pub.dev/packages/maplibre_gl) +* Add support for + the [Fill Extrusion layer](https://maplibre.org/maplibre-style-spec/layers/#fill-extrusion) +* Add support for + the [Heatmap layer](https://maplibre.org/maplibre-style-spec/layers/#heatmap) +* Update documentation +* (android) Bump Android `compileSdkVersion` to 34 +* (iOS) Add `iosLongClickDuration` as parameter to customize the long click + duration. +* (web) Loosen the dependency constraint of [js](https://pub.dev/packages/js) to + allow `0.6.x` and `0.7.x`. + +### Bug Fixes + +* (android) Fix support for newer gradle versions (Add support for Gradle/AGP + namespace configuration) +* (android) Fix `NullPointerException` when changing the visibility of a layer ( + layer#setVisibility) +* (android) Fix enum parsing for the `onCameraTracking` callback +* (iOS) Fix tap detection on features if the feature id is null +* (web) Fix flickering when the style takes time to load + +**Full Changelog**: +[0.18.0...v0.19.0](https://github.com/maplibre/flutter-maplibre-gl/compare/0.18.0...v0.19.0) + +## 0.18.0 + +### Breaking Change: + +Already since 0.17.0, developers do not need to adapt their Podfile for iOS apps +anymore as it was previously described in the Readme. Developers who previously +added these lines should remove them, since not removing these lines may cause a +build failure on iOS. (This change actually already landed in 0.17.0, but it may +not have been sufficiently clear that not removing these lines might break +builds). + +### Other Changes: + +* new feature: set arbitrary layer properties by @m0nac0 + in [#303](https://github.com/maplibre/flutter-maplibre-gl/pull/303) +* Update release process by @m0nac0 + in [#315](https://github.com/maplibre/flutter-maplibre-gl/pull/315) +* Add workflows for automated publishing to pub.dev by @m0nac0 + in [#328](https://github.com/maplibre/flutter-maplibre-gl/pull/328) +* Fix example app pubspec by @m0nac0 + in [#329](https://github.com/maplibre/flutter-maplibre-gl/pull/329) +* Updated location plugin version for example app by @varunlohade + in [#334](https://github.com/maplibre/flutter-maplibre-gl/pull/334) +* Housekeeping: Improve docs and update outdated references to point to MapLibre + by @m0nac0 in [#330](https://github.com/maplibre/flutter-maplibre-gl/pull/330) + +**Full Changelog**: +[0.17.0...0.18.0](https://github.com/maplibre/flutter-maplibre-gl/compare/0.17.0...0.18.0) + +## 0.17.0 + +* **Repository transfer**: The project repository was transferred to the + MapLibre GitHub organization. More information + at [#221](https://github.com/maplibre/flutter-maplibre-gl/issues/221) +* Developers do not need to adapt their Podfile for iOS apps anymore as it was + previously described in the + Readme. [#278](https://github.com/maplibre/flutter-maplibre-gl/pull/278) + +### Breaking Change: + +* `maplibre_gl/mapbox_gl.dart` was renamed to `maplibre_gl/maplibre_gl.dart`. + You can do a replace-all from `import 'package:maplibre_gl/mapbox_gl.dart';` + to `import 'package:maplibre_gl/maplibre_gl.dart';` +* `useDelayedDisposal` was removed since its now fixed + in [#259](https://github.com/maplibre/flutter-maplibre-gl/pull/259) +* `useHybridCompositionOverride` was removed since it was added in the following + fix: [#203](https://github.com/maplibre/flutter-maplibre-gl/pull/203) and we + reverted + the fix and used another approach to fix the actual issue. +* The default for `myLocationRenderMode` was changed from `COMPASS` to `NORMAL` + in [#244](https://github.com/maplibre/flutter-maplibre-gl/pull/244), since the + previous default value of `COMPASS` implicitly enables displaying the location + on iOS, which could crash apps that didn't want to display the device + location. If you want to continue to use `MyLocationRenderMode.COMPASS`, + please explicitly specify it in the constructor like this: + +```dart +@override +Widget build() { + return MapLibreMap( + myLocationRenderMode: MyLocationRenderMode.COMPASS, + // ... + ); +} +``` + +* The old api `registerWith` was removed from the MapboxMapsPlugin.java, since + there is no need for that. +* The `minSdkVersion` was bumped to at least 21 now, since the native android + sdk constraint expect that. +* Changed the minimum Dart version from sdk: `2.12.0` to `2.14.0` + in `maplibre_gl_platform_interface/pubspec.yaml`. + +### Further changes + +Note: This list only contains a subset of all contributions, notably excluding +those that e.g. only affect the GitHub Actions CI or documentation. See the link +at the end for a full changelog. + +* feat: add support for reading style json from file in ios by @TimAlber + in [#132](https://github.com/maplibre/flutter-maplibre-gl/pull/132) +* Add podspecs in correct Cocoapods layout by @kuhnroyal + in [#128](https://github.com/maplibre/flutter-maplibre-gl/pull/128) +* fix: fix the queryRenderedFeatures code on iOS by @TimAlber + in [#137](https://github.com/maplibre/flutter-maplibre-gl/pull/137) +* feat: Set layer visibility by @m0nac0 + in [#138](https://github.com/maplibre/flutter-maplibre-gl/pull/138) +* feat: add support for changing the receiver’s viewport to fit given bounds by + @TimAlber in [#133](https://github.com/maplibre/flutter-maplibre-gl/pull/133) +* Change feature JSON encoding from .ascii to .utf8 by @SunBro-Marko + in [#142](https://github.com/maplibre/flutter-maplibre-gl/pull/142) +* web: implement setCameraBounds by @m0nac0 + in [#145](https://github.com/maplibre/flutter-maplibre-gl/pull/145) +* Use offical maplibre-gl.js and add README info by @Robbendebiene + in [#163](https://github.com/maplibre/flutter-maplibre-gl/pull/163) +* android: adding tileSize to raster source by @mariusvn + in [#166](https://github.com/maplibre/flutter-maplibre-gl/pull/166) +* Readme: document git default values for codespaces by @m0nac0 + in [#170](https://github.com/maplibre/flutter-maplibre-gl/pull/170) +* query source features by @Grodien + in [#154](https://github.com/maplibre/flutter-maplibre-gl/pull/154) +* Trimming styleString to simplify the JSON detection by @mariusvn + in [#175](https://github.com/maplibre/flutter-maplibre-gl/pull/175) +* Fix getVisibleRegion method by @BartoszStasiurka + in [#179](https://github.com/maplibre/flutter-maplibre-gl/pull/179) +* Reenable textureMode which was disabled in f8b2d1 by @maxammann + in [#194](https://github.com/maplibre/flutter-maplibre-gl/pull/194) +* android: Bump MapLibre SDK to 9.6.0 & OkHttp to 4.9.3 by @mariusvn + in [#184](https://github.com/maplibre/flutter-maplibre-gl/pull/184) +* Added getSourceIds to the controller by @mariusvn + in [#197](https://github.com/maplibre/flutter-maplibre-gl/pull/197) +* Moved EventChannel creation in the downloadOfflineRegion method by @mariusvn + in [#205](https://github.com/maplibre/flutter-maplibre-gl/pull/205) +* Fix crash android dispose nullpointerdereference by @GaelleJoubert + in [#203](https://github.com/maplibre/flutter-maplibre-gl/pull/203) +* Migrate links in README, pubspec to MapLibre by @kuhnroyal + in [#224](https://github.com/maplibre/flutter-maplibre-gl/pull/224) +* Update LICENSE file by @mariusvn + in [#230](https://github.com/maplibre/flutter-maplibre-gl/pull/230) +* upgrade dependency image by @m0nac0 + in [#248](https://github.com/maplibre/flutter-maplibre-gl/pull/248) +* fix-example-app by @JulianBissekkou + in [#261](https://github.com/maplibre/flutter-maplibre-gl/pull/261) +* 162-animate-camera-on-web-fix by @JulianBissekkou + in [#254](https://github.com/maplibre/flutter-maplibre-gl/pull/254) +* 243-fix-crash-when-no-location-permission by @JulianBissekkou + in [#244](https://github.com/maplibre/flutter-maplibre-gl/pull/244) +* 182-disposal-null-ref-crash by @JulianBissekkou + in [#259](https://github.com/maplibre/flutter-maplibre-gl/pull/259) +* New android sdk version by @stefanschaller + in [#270](https://github.com/maplibre/flutter-maplibre-gl/pull/270) +* 250-change-language-fixes by @stefanschaller + in [#275](https://github.com/maplibre/flutter-maplibre-gl/pull/275) +* upgrade-ios-version by @JulianBissekkou + in [#277](https://github.com/maplibre/flutter-maplibre-gl/pull/277) +* Simplify iOS usage instructions and example podfile by @m0nac0 + in [#278](https://github.com/maplibre/flutter-maplibre-gl/pull/278) +* Add opportunity to use map in widget tests by @ManoyloK + in [#281](https://github.com/maplibre/flutter-maplibre-gl/pull/281) +* fix-layers-prod-build by @stefanschaller + in [#291](https://github.com/maplibre/flutter-maplibre-gl/pull/291) +* Fix the codespace by upgrading the docker image by @ouvreboite + in [#297](https://github.com/maplibre/flutter-maplibre-gl/pull/297) +* Add `updateImageSource`. by @CaviarChen + in [#271](https://github.com/maplibre/flutter-maplibre-gl/pull/271) +* fix "unexpected null value" error when onStyleLoadedCallback is null by + @m0nac0 in [#307](https://github.com/maplibre/flutter-maplibre-gl/pull/307) +* attributionButtonPosition for web by @ouvreboite + in [#304](https://github.com/maplibre/flutter-maplibre-gl/pull/304) + +**Full Changelog**: +https://github.com/maplibre/flutter-maplibre-gl/compare/0.16.0...0.17.0 + +## 0.16.0, Jun 28, 2022 + +* cherry-picked all commits from upstream up + to [https://github.com/flutter-mapbox-gl/maps/commit/3496907955cd4b442e4eb905d67e8d46692174f1), + including up to release 0.16.0 from upstream +* updated MapLibre GL JS for web + +## 0.15.1, May 24, 2022 + +* cherry-picked all commits from upstream up + to [upstream release 0.15.0](https://github.com/flutter-mapbox-gl/maps/releases/tag/0.15.0) +* improved documentation +* betted adapted the example app to MapLibre +* hide logo on Android/iOS to match web + +## 0.15.0, Oct 26, 2021 + +* Fix bug when changing line color (see #448) by @vberthet + in [#15](https://github.com/m0nac0/flutter-maplibre-gl/pull/15) +* Remove unnecessary imports by @m0nac0 + in [#34](https://github.com/m0nac0/flutter-maplibre-gl/pull/34) +* Update example with Flutter 2.5.3 by @kuhnroyal + in [#35](https://github.com/m0nac0/flutter-maplibre-gl/pull/35) +* CI: Use separate scheduled pipeline for Flutter beta builds by @kuhnroyal + in [#28](https://github.com/m0nac0/flutter-maplibre-gl/pull/28) +* Null safety (cherry-pick from upstream) by @m0nac0 + in [#31](https://github.com/m0nac0/flutter-maplibre-gl/pull/31) +* [web] add missing removeLines, removeCircles and removeFills (cherry-pick + tobrun#622) by @m0nac0 + in [#32](https://github.com/m0nac0/flutter-maplibre-gl/pull/32) +* Replace style string in local style example by @m0nac0 + in [#33](https://github.com/m0nac0/flutter-maplibre-gl/pull/33) +* [web] add getSymbolLatLng and getLineLatLngs by @m0nac0 + in [#37](https://github.com/m0nac0/flutter-maplibre-gl/pull/37) + +## 0.14.0 + +### Breaking changes: + +* Remove access token, update libraries, replace example + styles [#25](https://github.com/m0nac0/flutter-maplibre-gl/pull/25) (also + see [#21](https://github.com/m0nac0/flutter-maplibre-gl/issues/21)) + * The parameter `accessToken` of class `MaplibreMap` was removed. If you + want to continue using a tile provider that requires an API key, specify + that key directly in the URL of the tile source ( + see [https://github.com/m0nac0/flutter-maplibre-gl#tile-sources-requiring-an-api-key](https://github.com/m0nac0/flutter-maplibre-gl#tile-sources-requiring-an-api-key)) + * The built-in constants for specific styles were also removed. You can + continue using these styles by using the styles' URL + +### Other changes: + +* Remove warning about missing access token on + Android [#22](https://github.com/m0nac0/flutter-maplibre-gl/pull/22) +* Example: use maplibre styles and add new demo + style [#23](https://github.com/m0nac0/flutter-maplibre-gl/pull/23) +* Add about button to example + app [#26](https://github.com/m0nac0/flutter-maplibre-gl/pull/26) +* various improvements to the CI +* fixed formatting for some files that were not correctly formatted + +## 0.13.0, Oct 6, 2021 + +🎉 The first release of flutter-maplibre-gl with the complete transition to +MapLibre libraries. 🎉 + +Further improvements: + +* Update to MapLibre-Android-SDK 9.4.2 +* Update to MapLibre-iOS-SDK 5.12.0 +* Fix onUserLocationUpdated not firing on + android [#14](https://github.com/m0nac0/flutter-maplibre-gl/pull/14) +* Add speed to + UserLocation [#11](https://github.com/m0nac0/flutter-maplibre-gl/pull/11) +* Fix queryRenderedFeaturesInRect for + iOS [#10](https://github.com/m0nac0/flutter-maplibre-gl/pull/10) + +### Changes cherry-picked/ported from tobrun/flutter-mapbox-gl:0.12.0 + +* Batch creation/removal for circles, fills and + lines [#576](https://github.com/tobrun/flutter-mapbox-gl/pull/576) +* Dependencies: updated image + package [#598](https://github.com/tobrun/flutter-mapbox-gl/pull/598) +* Improve description to enable location + features [#596](https://github.com/tobrun/flutter-mapbox-gl/pull/596) +* Fix feature manager on release + build [#593](https://github.com/tobrun/flutter-mapbox-gl/pull/593) +* Emit onTap only for the feature above the + others [#589](https://github.com/tobrun/flutter-mapbox-gl/pull/589) +* Add annotationOrder to + web [#588](https://github.com/tobrun/flutter-mapbox-gl/pull/588) + +### Changes cherry-picked/ported from tobrun/flutter-mapbox-gl:0.11.0 + +* Fixed issues caused by new android + API [#544](https://github.com/tobrun/flutter-mapbox-gl/pull/544) +* Add option to set maximum offline tile + count [#549](https://github.com/tobrun/flutter-mapbox-gl/pull/549) +* Fixed web build failure due to http package + upgrade [#550](https://github.com/tobrun/flutter-mapbox-gl/pull/550) +* Update OfflineRegion/OfflineRegionDefinition interfaces, synchronize with iOS + and Android [#545](https://github.com/tobrun/flutter-mapbox-gl/pull/545) +* Fix Mapbox GL JS CSS embedding on + web [#551](https://github.com/tobrun/flutter-mapbox-gl/pull/551) +* Update Podfile to fix iOS + CI [#565](https://github.com/tobrun/flutter-mapbox-gl/pull/565) +* Update deprecated patterns to fix CI static + analysis [#568](https://github.com/tobrun/flutter-mapbox-gl/pull/568) +* Add setOffline method on + Android [#537](https://github.com/tobrun/flutter-mapbox-gl/pull/537) +* Add batch mode of screen + locations [#554](https://github.com/tobrun/flutter-mapbox-gl/pull/554) +* Define which annotations consume the tap + events [#575](https://github.com/tobrun/flutter-mapbox-gl/pull/575) +* Remove failed offline region + downloads [#583](https://github.com/tobrun/flutter-mapbox-gl/pull/583) + +## Below is the original changelog of the tobrun/flutter-mapbox-gl project, before the fork. + +## 0.10.0, February 12, 2020 + +* Merge offline + regions [#532](https://github.com/tobrun/flutter-mapbox-gl/pull/532) +* Update offline region + metadata [#530](https://github.com/tobrun/flutter-mapbox-gl/pull/530) +* Added web support for + fills [#501](https://github.com/tobrun/flutter-mapbox-gl/pull/501) +* Support styleString as "Documents directory/Temporary + directory" [#520](https://github.com/tobrun/flutter-mapbox-gl/pull/520) +* Use offline region + ids [#491](https://github.com/tobrun/flutter-mapbox-gl/pull/491) +* Ability to define annotation layer + order [#523](https://github.com/tobrun/flutter-mapbox-gl/pull/523) +* Clear fills API [#527](https://github.com/tobrun/flutter-mapbox-gl/pull/527) +* Add heading to UserLocation and expose UserLocation + type [#522](https://github.com/tobrun/flutter-mapbox-gl/pull/522) +* Patch addFill with data + parameter [#524](https://github.com/tobrun/flutter-mapbox-gl/pull/524) +* Fix style annotation is not deselected on + iOS [#512](https://github.com/tobrun/flutter-mapbox-gl/pull/512) +* Update tracked camera position in + camera#onIdle [#500](https://github.com/tobrun/flutter-mapbox-gl/pull/500) +* Fix iOS implementation of map#toLatLng on + iOS [#495](https://github.com/tobrun/flutter-mapbox-gl/pull/495) +* Migrate to new Android flutter plugin + architecture [#488](https://github.com/tobrun/flutter-mapbox-gl/pull/488) +* Update readme to fix + UnsatisfiedLinkError [#422](https://github.com/tobrun/flutter-mapbox-gl/pull/442) +* Improved Image Source + Support [#469](https://github.com/tobrun/flutter-mapbox-gl/pull/469) +* Avoid white space when resizing map on + web [#474](https://github.com/tobrun/flutter-mapbox-gl/pull/474) +* Allow MapboxMap() to override Widget + Key. [#475](https://github.com/tobrun/flutter-mapbox-gl/pull/475) +* Offline region + feature [#336](https://github.com/tobrun/flutter-mapbox-gl/pull/336) +* Fix iOS symbol tapped + interaction [#443](https://github.com/tobrun/flutter-mapbox-gl/pull/443) + +## 0.9.0, October 24. 2020 + +* Fix data parameter for addLine and + addCircle [#388](https://github.com/tobrun/flutter-mapbox-gl/pull/388) +* Re-enable attribution on + Android [#383](https://github.com/tobrun/flutter-mapbox-gl/pull/383) +* Upgrade annotation plugin to + v0.9 [#381](https://github.com/tobrun/flutter-mapbox-gl/pull/381) +* Breaking change: CameraUpdate.newLatLngBounds() now supports setting different + padding values for left, top, right, bottom with default of 0 for all. + Implementations using the old approach with only one padding value for all + edges have to be + updated. [#382](https://github.com/tobrun/flutter-mapbox-gl/pull/382) +* web:ignore myLocationTrackingMode if myLocationEnabled is + false [#363](https://github.com/tobrun/flutter-mapbox-gl/pull/363) +* Add methods to access + projection [#380](https://github.com/tobrun/flutter-mapbox-gl/pull/380) +* Add fill API support for Android and + iOS [#49](https://github.com/tobrun/flutter-mapbox-gl/pull/49) +* Listen to OnUserLocationUpdated to provide user location to + app [#237](https://github.com/tobrun/flutter-mapbox-gl/pull/237) +* Correct integration in Activity lifecycle on + Android [#266](https://github.com/tobrun/flutter-mapbox-gl/pull/266) +* Add support for custom font stackn in symbol + options [#359](https://github.com/tobrun/flutter-mapbox-gl/pull/359) +* Fix memory leak on iOS caused by strong self + reference [#370](https://github.com/tobrun/flutter-mapbox-gl/pull/370) +* Basic ImageSource + Support [#409](https://github.com/tobrun/flutter-mapbox-gl/pull/409) +* Get meters per pixel at + latitude [#416](https://github.com/tobrun/flutter-mapbox-gl/pull/416) +* Fix + onStyleLoadedCallback [#418](https://github.com/tobrun/flutter-mapbox-gl/pull/418) + +## 0.8.0, August 22, 2020 + +- implementation of feature + querying [#177](https://github.com/tobrun/flutter-mapbox-gl/pull/177) +- Batch create/delete of + symbols [#279](https://github.com/tobrun/flutter-mapbox-gl/pull/279) +- Add multi map + support [#315](https://github.com/tobrun/flutter-mapbox-gl/pull/315) +- Fix OnCameraIdle not being + invoked [#313](https://github.com/tobrun/flutter-mapbox-gl/pull/313) +- Fix android zIndex symbol + option [#312](https://github.com/tobrun/flutter-mapbox-gl/pull/312) +- Set dependencies from + git [#319](https://github.com/tobrun/flutter-mapbox-gl/pull/319) +- Add line#getGeometry and + symbol#getGeometry [#281](https://github.com/tobrun/flutter-mapbox-gl/pull/281) + +## 0.7.0, June 6, 2020 + +* Introduction of mapbox_gl_platform_interface library +* Introduction of mapbox_gl_web library +* Integrate web support through mapbox-gl-js +* Add icon-allow-overlap configurations + +## 0.0.6, May 31, 2020 + +* Update mapbox depdendency to 9.2.0 (android) and 5.6.0 (iOS) +* Long press handlers for both iOS as Android +* Change default location tracking to none +* OnCameraIdle listener support +* Add image to style +* Add animation duration to animateCamera +* Content insets +* Visible region support on iOS +* Numerous bug fixes + +## 0.0.5, December 21, 2019 + +* iOS support for annotation extensions (circle, symbol, line) +* Update SDK to 8.5.0 (Android) and 5.5.0 (iOS) +* Integrate style loaded callback api +* Add Map click event (iOS) +* Cache management API (Android/iOS) +* Various fixes to showing user location and configurations (Android/iOS) +* Last location API (Android) +* Throttle max FPS of user location component (Android) +* Fix for handling permission handling of the test application (Android) +* Support for loading symbol images from assets (iOS/Android) + +## v0.0.4, Nov 2, 2019 + +* Update SDK to 8.4.0 (Android) and 5.4.0 (iOS) +* Add support for sideloading offline maps (Android/iOS) +* Add user tracking mode (iOS) +* Invert compassView.isHidden logic (iOS) +* Specific swift version (iOS) + +## v0.0.3, Mar 30, 2019 + +* Camera API (iOS) +* Line API (Android) +* Update codebase to AndroidX +* Update Mapbox Maps SDK for Android to v7.3.0 + +## v0.0.2, Mar 23, 2019 + +* Support for iOS +* Migration to embedded Android and iOS SDK View system +* Style URL API +* Style JSON API (Android) +* Gesture support +* Gesture restrictions (Android) +* Symbol API (Android) +* Location component (Android) +* Camera API (Android) + +## v0.0.1, May 7, 2018 + +* Initial Android surface rendering POC diff --git a/third_party/maplibre_gl/LICENSE b/third_party/maplibre_gl/LICENSE new file mode 100644 index 0000000..daaf99d --- /dev/null +++ b/third_party/maplibre_gl/LICENSE @@ -0,0 +1,181 @@ +flutter-maplibre-gl Copyright (c) 2023, MapLibre contributors. +flutter-maplibre-gl Copyright (c) 2021, m0nac0. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------------------------------ +------------------------------------------------------------------------------------------------------ +This project was forked from (and therefore contains a lot of modified and unmodified code from) https://github.com/tobrun/flutter-mapbox-gl which at the time of the fork (March 6th, 2021) contained the following LICENSE file: +(Additionally more changes / code was later cherry-picked / ported from that repository (https://github.com/tobrun/flutter-mapbox-gl), while the license continued to be the same as at the time of the fork, which is:) +------------------------------------------------------------------------------------------------------ + +flutter-mapbox-gl copyright (c) 2018, Mapbox. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------------------------------ +------------------------------------------------------------------------------------------------------ +This project uses the MapLibre GL native libraries for Android and iOS from https://github.com/maplibre/maplibre-gl-native, which contain the following LICENSE: +------------------------------------------------------------------------------------------------------ +BSD 2-Clause License + +Copyright (c) 2021 MapLibre contributors + +Copyright (c) 2018-2021 MapTiler.com + +Copyright (c) 2014-2020 Mapbox + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------------------------------ +------------------------------------------------------------------------------------------------------ +This project uses MapLibre GL JS library from https://github.com/maplibre/maplibre-gl-js, which contains the following LICENSE: +------------------------------------------------------------------------------------------------------ +Copyright (c) 2020, MapLibre contributors + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of MapLibre GL JS nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +------------------------------------------------------------------------------- + +Contains code from mapbox-gl-js v1.13 and earlier + +Version v1.13 of mapbox-gl-js and earlier are licensed under a BSD-3-Clause license + +Copyright (c) 2020, Mapbox +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of Mapbox GL JS nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +------------------------------------------------------------------------------- + +Contains code from glfx.js + +Copyright (C) 2011 by Evan Wallace + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +-------------------------------------------------------------------------------- + +Contains a portion of d3-color https://github.com/d3/d3-color + +Copyright 2010-2016 Mike Bostock +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the author nor the names of contributors may be used to + endorse or promote products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/maplibre_gl/README.md b/third_party/maplibre_gl/README.md new file mode 100644 index 0000000..7d323c9 --- /dev/null +++ b/third_party/maplibre_gl/README.md @@ -0,0 +1,394 @@ +

+ + + + MapLibre Logo + +

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

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

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

Returns null if the Flutter engine version does not include the lifecycle extraction code. + * (this probably means the Flutter engine version is too old). + */ + @NonNull + public static Lifecycle getActivityLifecycle( + @NonNull ActivityPluginBinding activityPluginBinding) { + HiddenLifecycleReference reference = + (HiddenLifecycleReference) activityPluginBinding.getLifecycle(); + return reference.getLifecycle(); + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreUtils.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreUtils.java new file mode 100644 index 0000000..dadbd16 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreUtils.java @@ -0,0 +1,12 @@ +package org.maplibre.maplibregl; + +import android.content.Context; +import org.maplibre.android.MapLibre; + +abstract class MapLibreUtils { + private static final String TAG = "MapLibreMapController"; + + static MapLibre getMapLibre(Context context) { + return MapLibre.getInstance(context); + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineChannelHandlerImpl.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineChannelHandlerImpl.java new file mode 100644 index 0000000..c29fe17 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineChannelHandlerImpl.java @@ -0,0 +1,55 @@ +package org.maplibre.maplibregl; + +import androidx.annotation.Nullable; +import com.google.gson.Gson; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.EventChannel; +import java.util.HashMap; +import java.util.Map; + +public class OfflineChannelHandlerImpl implements EventChannel.StreamHandler { + private EventChannel.EventSink sink; + private Gson gson = new Gson(); + + OfflineChannelHandlerImpl(BinaryMessenger messenger, String channelName) { + EventChannel eventChannel = new EventChannel(messenger, channelName); + eventChannel.setStreamHandler(this); + } + + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + sink = events; + } + + @Override + public void onCancel(Object arguments) { + sink = null; + } + + void onError(String errorCode, @Nullable String errorMessage, @Nullable Object errorDetails) { + if (sink == null) return; + sink.error(errorCode, errorMessage, errorDetails); + } + + void onSuccess() { + if (sink == null) return; + Map body = new HashMap<>(); + body.put("status", "success"); + sink.success(gson.toJson(body)); + } + + void onStart() { + if (sink == null) return; + Map body = new HashMap<>(); + body.put("status", "start"); + sink.success(gson.toJson(body)); + } + + void onProgress(double progress) { + if (sink == null) return; + Map body = new HashMap<>(); + body.put("status", "progress"); + body.put("progress", progress); + sink.success(gson.toJson(body)); + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineManagerUtils.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineManagerUtils.java new file mode 100644 index 0000000..b0585a1 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineManagerUtils.java @@ -0,0 +1,337 @@ +package org.maplibre.maplibregl; + +import android.content.Context; +import android.util.Log; +import com.google.gson.Gson; +import org.maplibre.android.geometry.LatLng; +import org.maplibre.android.geometry.LatLngBounds; +import org.maplibre.android.offline.OfflineManager; +import org.maplibre.android.offline.OfflineRegion; +import org.maplibre.android.offline.OfflineRegionDefinition; +import org.maplibre.android.offline.OfflineRegionError; +import org.maplibre.android.offline.OfflineRegionStatus; +import org.maplibre.android.offline.OfflineTilePyramidRegionDefinition; +import io.flutter.plugin.common.MethodChannel; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +abstract class OfflineManagerUtils { + private static final String TAG = "OfflineManagerUtils"; + + static void mergeRegions(MethodChannel.Result result, Context context, String path) { + OfflineManager.Companion.getInstance(context) + .mergeOfflineRegions( + path, + new OfflineManager.MergeOfflineRegionsCallback() { + public void onMerge(OfflineRegion[] offlineRegions) { + if (result == null) return; + List> regionsArgs = new ArrayList<>(); + for (OfflineRegion offlineRegion : offlineRegions) { + regionsArgs.add(offlineRegionToMap(offlineRegion)); + } + String json = new Gson().toJson(regionsArgs); + result.success(json); + } + + public void onError(String error) { + if (result == null) return; + result.error("mergeOfflineRegions Error", error, null); + } + }); + } + + static void setOfflineTileCountLimit(MethodChannel.Result result, Context context, long limit) { + OfflineManager.Companion.getInstance(context).setOfflineMapboxTileCountLimit(limit); + result.success(null); + } + + static void downloadRegion( + MethodChannel.Result result, + Context context, + Map definitionMap, + Map metadataMap, + OfflineChannelHandlerImpl channelHandler) { + float pixelDensity = context.getResources().getDisplayMetrics().density; + OfflineRegionDefinition definition = mapToRegionDefinition(definitionMap, pixelDensity); + String metadata = "{}"; + if (metadataMap != null) { + metadata = new Gson().toJson(metadataMap); + } + AtomicBoolean isComplete = new AtomicBoolean(false); + // Download region + OfflineManager.Companion.getInstance(context) + .createOfflineRegion( + definition, + metadata.getBytes(), + new OfflineManager.CreateOfflineRegionCallback() { + private OfflineRegion _offlineRegion; + + @Override + public void onCreate(OfflineRegion offlineRegion) { + Map regionData = offlineRegionToMap(offlineRegion); + result.success(new Gson().toJson(regionData)); + + _offlineRegion = offlineRegion; + // Observe downloading state + OfflineRegion.OfflineRegionObserver observer = + new OfflineRegion.OfflineRegionObserver() { + @Override + public void onStatusChanged(OfflineRegionStatus status) { + // Calculate progress of + // downloading + double progress = + calculateDownloadingProgress( + status.getRequiredResourceCount(), + status.getCompletedResourceCount()); + // Check if downloading is + // complete + if (status.isComplete()) { + Log.i(TAG, "Region " + "downloaded " + "successfully."); + // Reset downloading state + _offlineRegion.setDownloadState(OfflineRegion.STATE_INACTIVE); + // This can be called + // multiple times, and + // result can be called + // only once, + // so there is need to + // prevent it + if (isComplete.get()) return; + isComplete.set(true); + channelHandler.onSuccess(); + } else { + Log.i(TAG, "Region " + "download " + "progress = " + progress); + channelHandler.onProgress(progress); + } + } + + @Override + public void onError(OfflineRegionError error) { + Log.e(TAG, "onError reason: " + error.getReason()); + Log.e(TAG, "onError message: " + error.getMessage()); + // Reset downloading state + _offlineRegion.setDownloadState(OfflineRegion.STATE_INACTIVE); + isComplete.set(true); + channelHandler.onError( + "Downloading error", error.getMessage(), error.getReason()); + } + + @Override + public void mapboxTileCountLimitExceeded(long limit) { + Log.e(TAG, "MapLibre tile count" + " limit exceeded: " + limit); + // Reset downloading state + _offlineRegion.setDownloadState(OfflineRegion.STATE_INACTIVE); + isComplete.set(true); + channelHandler.onError( + "mapboxTileCountLimitExceeded", + "MapLibre tile count " + "limit " + "exceeded: " + limit, + null); + // MapLibre even after crash + // and not downloading fully + // region still keeps part + // of it in database, so we + // have to remove it + deleteRegion(null, context, _offlineRegion.getId()); + } + }; + + _offlineRegion.setObserver(observer); + + // Start downloading region + _offlineRegion.setDownloadState(OfflineRegion.STATE_ACTIVE); + channelHandler.onStart(); + } + + /** + * This will be call if given region definition is invalid + * + * @param error + */ + @Override + public void onError(String error) { + Log.e(TAG, "Error: " + error); + // Reset downloading state + _offlineRegion.setDownloadState(OfflineRegion.STATE_INACTIVE); + channelHandler.onError("mapboxInvalidRegionDefinition", error, null); + result.error("mapboxInvalidRegionDefinition", error, null); + } + }); + } + + static void regionsList(MethodChannel.Result result, Context context) { + OfflineManager.Companion.getInstance(context) + .listOfflineRegions( + new OfflineManager.ListOfflineRegionsCallback() { + @Override + public void onList(OfflineRegion[] offlineRegions) { + List> regionsArgs = new ArrayList<>(); + for (OfflineRegion offlineRegion : offlineRegions) { + regionsArgs.add(offlineRegionToMap(offlineRegion)); + } + result.success(new Gson().toJson(regionsArgs)); + } + + @Override + public void onError(String error) { + result.error("RegionListError", error, null); + } + }); + } + + static void updateRegionMetadata( + MethodChannel.Result result, Context context, long id, Map metadataMap) { + OfflineManager.Companion.getInstance(context) + .listOfflineRegions( + new OfflineManager.ListOfflineRegionsCallback() { + @Override + public void onList(OfflineRegion[] offlineRegions) { + for (OfflineRegion offlineRegion : offlineRegions) { + if (offlineRegion.getId() != id) continue; + + String metadata = "{}"; + if (metadataMap != null) { + metadata = new Gson().toJson(metadataMap); + } + offlineRegion.updateMetadata( + metadata.getBytes(), + new OfflineRegion.OfflineRegionUpdateMetadataCallback() { + @Override + public void onUpdate(byte[] metadataBytes) { + Map regionData = offlineRegionToMap(offlineRegion); + regionData.put("metadata", metadataBytesToMap(metadataBytes)); + + if (result == null) return; + result.success(new Gson().toJson(regionData)); + } + + @Override + public void onError(String error) { + if (result == null) return; + result.error("UpdateMetadataError", error, null); + } + }); + return; + } + if (result == null) return; + result.error( + "UpdateMetadataError", + "There is no " + "region with given id to " + "update.", + null); + } + + @Override + public void onError(String error) { + if (result == null) return; + result.error("RegionListError", error, null); + } + }); + } + + static void deleteRegion(MethodChannel.Result result, Context context, long id) { + OfflineManager.Companion.getInstance(context) + .listOfflineRegions( + new OfflineManager.ListOfflineRegionsCallback() { + @Override + public void onList(OfflineRegion[] offlineRegions) { + for (OfflineRegion offlineRegion : offlineRegions) { + if (offlineRegion.getId() != id) continue; + + offlineRegion.delete( + new OfflineRegion.OfflineRegionDeleteCallback() { + @Override + public void onDelete() { + if (result == null) return; + result.success(null); + } + + @Override + public void onError(String error) { + if (result == null) return; + result.error("DeleteRegionError", error, null); + } + }); + return; + } + if (result == null) return; + result.error( + "DeleteRegionError", + "There is no " + "region with given id to " + "delete.", + null); + } + + @Override + public void onError(String error) { + if (result == null) return; + result.error("RegionListError", error, null); + } + }); + } + + private static double calculateDownloadingProgress( + long requiredResourceCount, long completedResourceCount) { + return requiredResourceCount > 0 + ? (100.0 * completedResourceCount / requiredResourceCount) + : 0.0; + } + + private static OfflineRegionDefinition mapToRegionDefinition( + Map map, float pixelDensity) { + for (Map.Entry entry : map.entrySet()) { + Log.d(TAG, entry.getKey()); + Log.d(TAG, entry.getValue().toString()); + } + // Create a bounding box for the offline region + return new OfflineTilePyramidRegionDefinition( + (String) map.get("mapStyleUrl"), + listToBounds((List>) map.get("bounds")), + ((Number) map.get("minZoom")).doubleValue(), + ((Number) map.get("maxZoom")).doubleValue(), + pixelDensity, + (Boolean) map.get("includeIdeographs")); + } + + private static LatLngBounds listToBounds(List> bounds) { + return new LatLngBounds.Builder() + .include(new LatLng(bounds.get(1).get(0), bounds.get(1).get(1))) // Northeast + .include(new LatLng(bounds.get(0).get(0), bounds.get(0).get(1))) // Southwest + .build(); + } + + private static Map offlineRegionToMap(OfflineRegion region) { + Map result = new HashMap(); + result.put("id", region.getId()); + result.put("definition", offlineRegionDefinitionToMap(region.getDefinition())); + result.put("metadata", metadataBytesToMap(region.getMetadata())); + return result; + } + + private static Map offlineRegionDefinitionToMap( + OfflineRegionDefinition definition) { + Map result = new HashMap(); + result.put("mapStyleUrl", definition.getStyleURL()); + result.put("bounds", boundsToList(definition.getBounds())); + result.put("minZoom", definition.getMinZoom()); + result.put("maxZoom", definition.getMaxZoom()); + result.put("includeIdeographs", definition.getIncludeIdeographs()); + return result; + } + + private static List> boundsToList(LatLngBounds bounds) { + List> boundsList = new ArrayList<>(); + List northeast = Arrays.asList(bounds.getLatNorth(), bounds.getLonEast()); + List southwest = Arrays.asList(bounds.getLatSouth(), bounds.getLonWest()); + boundsList.add(southwest); + boundsList.add(northeast); + return boundsList; + } + + private static Map metadataBytesToMap(byte[] metadataBytes) { + if (metadataBytes != null) { + return new Gson().fromJson(new String(metadataBytes), HashMap.class); + } + return new HashMap(); + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/SourcePropertyConverter.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/SourcePropertyConverter.java new file mode 100644 index 0000000..de750dd --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/SourcePropertyConverter.java @@ -0,0 +1,232 @@ +package org.maplibre.maplibregl; + +import android.net.Uri; +import com.google.gson.Gson; +import org.maplibre.geojson.FeatureCollection; +import org.maplibre.android.geometry.LatLng; +import org.maplibre.android.geometry.LatLngQuad; +import org.maplibre.android.maps.Style; +import org.maplibre.android.style.sources.GeoJsonOptions; +import org.maplibre.android.style.sources.GeoJsonSource; +import org.maplibre.android.style.sources.ImageSource; +import org.maplibre.android.style.sources.RasterDemSource; +import org.maplibre.android.style.sources.RasterSource; +import org.maplibre.android.style.sources.Source; +import org.maplibre.android.style.sources.TileSet; +import org.maplibre.android.style.sources.VectorSource; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +class SourcePropertyConverter { + private static final String TAG = "SourcePropertyConverter"; + + static TileSet buildTileset(Map data) { + final Object tiles = data.get("tiles"); + + // options are only valid with tiles + if (tiles == null) { + return null; + } + + final TileSet tileSet = + new TileSet("2.1.0", (String[]) Convert.toList(tiles).toArray(new String[0])); + + final Object bounds = data.get("bounds"); + if (bounds != null) { + List boundsFloat = new ArrayList(); + for (Object item : Convert.toList(bounds)) { + boundsFloat.add(Convert.toFloat(item)); + } + tileSet.setBounds(boundsFloat.toArray(new Float[0])); + } + + final Object scheme = data.get("scheme"); + if (scheme != null) { + tileSet.setScheme(Convert.toString(scheme)); + } + + final Object minzoom = data.get("minzoom"); + if (minzoom != null) { + tileSet.setMinZoom(Convert.toFloat(minzoom)); + } + + final Object maxzoom = data.get("maxzoom"); + if (maxzoom != null) { + tileSet.setMaxZoom(Convert.toFloat(maxzoom)); + } + + final Object attribution = data.get("attribution"); + if (attribution != null) { + tileSet.setAttribution(Convert.toString(attribution)); + } + return tileSet; + } + + static GeoJsonOptions buildGeojsonOptions(Map data) { + GeoJsonOptions options = new GeoJsonOptions(); + + final Object buffer = data.get("buffer"); + if (buffer != null) { + options = options.withBuffer(Convert.toInt(buffer)); + } + + final Object cluster = data.get("cluster"); + if (cluster != null) { + options = options.withCluster(Convert.toBoolean(cluster)); + } + + final Object clusterMaxZoom = data.get("clusterMaxZoom"); + if (clusterMaxZoom != null) { + options = options.withClusterMaxZoom(Convert.toInt(clusterMaxZoom)); + } + + final Object clusterRadius = data.get("clusterRadius"); + if (clusterRadius != null) { + options = options.withClusterRadius(Convert.toInt(clusterRadius)); + } + + final Object lineMetrics = data.get("lineMetrics"); + if (lineMetrics != null) { + options = options.withLineMetrics(Convert.toBoolean(lineMetrics)); + } + + final Object maxZoom = data.get("maxZoom"); + if (maxZoom != null) { + options = options.withMaxZoom(Convert.toInt(maxZoom)); + } + + final Object minZoom = data.get("minZoom"); + if (minZoom != null) { + options = options.withMinZoom(Convert.toInt(minZoom)); + } + + final Object tolerance = data.get("tolerance"); + if (tolerance != null) { + options = options.withTolerance(Convert.toFloat(tolerance)); + } + return options; + } + + static GeoJsonSource buildGeojsonSource(String id, Map properties) { + final Object data = properties.get("data"); + final GeoJsonOptions options = buildGeojsonOptions(properties); + if (data != null) { + if (data instanceof String) { + try { + final URI uri = new URI(Convert.toString(data)); + return new GeoJsonSource(id, uri, options); + } catch (URISyntaxException e) { + } + } else { + Gson gson = new Gson(); + String geojson = gson.toJson(data); + final FeatureCollection featureCollection = FeatureCollection.fromJson(geojson); + return new GeoJsonSource(id, featureCollection, options); + } + } + return null; + } + + static ImageSource buildImageSource(String id, Map properties) { + final Object url = properties.get("url"); + List coordinates = Convert.toLatLngList(properties.get("coordinates"), true); + final LatLngQuad quad = + new LatLngQuad( + coordinates.get(0), coordinates.get(1), coordinates.get(2), coordinates.get(3)); + try { + final URI uri = new URI(Convert.toString(url)); + return new ImageSource(id, quad, uri); + } catch (URISyntaxException e) { + } + return null; + } + + static VectorSource buildVectorSource(String id, Map properties) { + final Object url = properties.get("url"); + if (url != null) { + final Uri uri = Uri.parse(Convert.toString(url)); + + if (uri != null) { + return new VectorSource(id, uri); + } + return null; + } + + final TileSet tileSet = buildTileset(properties); + return tileSet != null ? new VectorSource(id, tileSet) : null; + } + + static RasterSource buildRasterSource(String id, Map properties) { + final Object url = properties.get("url"); + final Object tileSizeObj = properties.get("tileSize"); + if (url != null) { + final String uri = Convert.toString(url); + if (tileSizeObj != null) { + final int tileSize = Convert.toInt(tileSizeObj); + return new RasterSource(id, uri, tileSize); + } else { + return new RasterSource(id, uri); + } + } + + final TileSet tileSet = buildTileset(properties); + if (tileSet != null) { + if (tileSizeObj != null) { + final int tileSize = Convert.toInt(tileSizeObj); + return new RasterSource(id, tileSet, tileSize); + } else { + return new RasterSource(id, tileSet); + } + } else { + return null; + } + } + + static RasterDemSource buildRasterDemSource(String id, Map properties) { + final Object url = properties.get("url"); + if (url != null) { + try { + final URI uri = new URI(Convert.toString(url)); + return new RasterDemSource(id, uri); + } catch (URISyntaxException e) { + } + } + + final TileSet tileSet = buildTileset(properties); + return tileSet != null ? new RasterDemSource(id, tileSet) : null; + } + + static void addSource(String id, Map properties, Style style) { + final Object type = properties.get("type"); + Source source = null; + + if (type != null) { + switch (Convert.toString(type)) { + case "vector": + source = buildVectorSource(id, properties); + break; + case "raster": + source = buildRasterSource(id, properties); + break; + case "raster-dem": + source = buildRasterDemSource(id, properties); + break; + case "image": + source = buildImageSource(id, properties); + break; + case "geojson": + source = buildGeojsonSource(id, properties); + break; + default: + // unsupported source type + } + } + + if (source != null) { + style.addSource(source); + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/setMapLanguage.kt b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/setMapLanguage.kt new file mode 100644 index 0000000..bfcd660 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/setMapLanguage.kt @@ -0,0 +1,31 @@ +@file:JvmName("MapLibreMapUtils") + +package org.maplibre.maplibregl + +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.style.expressions.Expression +import org.maplibre.android.style.layers.PropertyFactory +import org.maplibre.android.style.layers.SymbolLayer + +fun MapLibreMap.setMapLanguage(language: String) { + val layers = this.style?.layers ?: emptyList() + + val languageRegex = Regex("(name:[a-z]+)") + + val symbolLayers = layers.filterIsInstance() + + for (layer in symbolLayers) { + // continue when there is no current expression + val expression = layer.textField.expression ?: continue + + // We could skip the current iteration, whenever there is not current language. + if (!expression.toString().contains(languageRegex)) { + continue + } + + val properties = + "[\"coalesce\", [\"get\",\"name:$language\"],[\"get\",\"name:latin\"],[\"get\",\"name\"]]" + + layer.setProperties(PropertyFactory.textField(Expression.raw(properties))) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl.podspec b/third_party/maplibre_gl/ios/maplibre_gl.podspec new file mode 100644 index 0000000..a322e79 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html +# +Pod::Spec.new do |s| + s.name = 'maplibre_gl' + s.version = '0.25.0' + s.summary = 'MapLibre GL Flutter plugin' + s.description = <<-DESC +MapLibre GL Flutter plugin. + DESC + s.homepage = 'https://maplibre.org' + s.license = { :file => '../LICENSE' } + s.author = { 'MapLibre' => 'info@maplibre.org' } + s.source = { :path => '.' } + s.source_files = 'maplibre_gl/Sources/maplibre_gl/**/*' + s.dependency 'Flutter' + # When updating the dependency version, + # make sure to also update the version in Package.swift. + s.dependency 'MapLibre', '6.19.1' + s.swift_version = '5.0' + s.ios.deployment_target = '13.0' +end + diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Package.swift b/third_party/maplibre_gl/ios/maplibre_gl/Package.swift new file mode 100644 index 0000000..810dbe8 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "maplibre_gl", + platforms: [ + .iOS("13.0"), + ], + products: [ + .library(name: "maplibre-gl", targets: ["maplibre_gl"]) + ], + dependencies: [ + // When updating the dependency version, + // make sure to also update the version in maplibre_gl.podspec. + .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution.git", exact: "6.19.1"), + ], + targets: [ + .target( + name: "maplibre_gl", + dependencies: [ + .product(name: "MapLibre", package: "maplibre-gl-native-distribution") + ], + cSettings: [ + .headerSearchPath("include/maplibre_gl") + ] + ) + ] +) diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Constants.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Constants.swift new file mode 100644 index 0000000..1a548ce --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Constants.swift @@ -0,0 +1,51 @@ +import MapLibre + +/* + * The mapping is based on the values defined here: + * https://docs.mapbox.com/android/api/map-sdk/8.4.0/constant-values.html + */ + +class Constants { + static let symbolIconAnchorMapping = [ + "center": MLNIconAnchor.center, + "left": MLNIconAnchor.left, + "right": MLNIconAnchor.right, + "top": MLNIconAnchor.top, + "bottom": MLNIconAnchor.bottom, + "top-left": MLNIconAnchor.topLeft, + "top-right": MLNIconAnchor.topRight, + "bottom-left": MLNIconAnchor.bottomLeft, + "bottom-right": MLNIconAnchor.bottomRight, + ] + + static let symbolTextJustificationMapping = [ + "auto": MLNTextJustification.auto, + "center": MLNTextJustification.center, + "left": MLNTextJustification.left, + "right": MLNTextJustification.right, + ] + + static let symbolTextAnchorMapping = [ + "center": MLNTextAnchor.center, + "left": MLNTextAnchor.left, + "right": MLNTextAnchor.right, + "top": MLNTextAnchor.top, + "bottom": MLNTextAnchor.bottom, + "top-left": MLNTextAnchor.topLeft, + "top-right": MLNTextAnchor.topRight, + "bottom-left": MLNTextAnchor.bottomLeft, + "bottom-right": MLNTextAnchor.bottomRight, + ] + + static let symbolTextTransformationMapping = [ + "none": MLNTextTransform.none, + "lowercase": MLNTextTransform.lowercase, + "uppercase": MLNTextTransform.uppercase, + ] + + static let lineJoinMapping = [ + "bevel": MLNLineJoin.bevel, + "miter": MLNLineJoin.miter, + "round": MLNLineJoin.round, + ] +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Convert.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Convert.swift new file mode 100644 index 0000000..b7fb0c0 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Convert.swift @@ -0,0 +1,229 @@ +import MapLibre + +class Convert { + class func interpretMapLibreMapOptions(options: Any?, delegate: MapLibreMapOptionsSink) { + guard let options = options as? [String: Any] else { return } + if let cameraTargetBounds = options["cameraTargetBounds"] as? [Any?] { + // Handle both [[[Double]]] and [nil] (for unbounded) + if let boundsArray = cameraTargetBounds[0] as? [[Double]] { + let bounds = MLNCoordinateBounds.fromArray(boundsArray) + delegate.setCameraTargetBounds(bounds: bounds) + } else { + // Unbounded - clear the bounds restriction + delegate.setCameraTargetBounds(bounds: nil) + } + } + if let compassEnabled = options["compassEnabled"] as? Bool { + delegate.setCompassEnabled(compassEnabled: compassEnabled) + } + if let minMaxZoomPreference = options["minMaxZoomPreference"] as? [Any] { + // Handle both [Double] and [NSNull] (for unbounded zoom) + let minZoom: Double? = (minMaxZoomPreference[0] is NSNull) ? nil : minMaxZoomPreference[0] as? Double + let maxZoom: Double? = (minMaxZoomPreference[1] is NSNull) ? nil : minMaxZoomPreference[1] as? Double + + delegate.setMinMaxZoomPreference( + min: minZoom, + max: maxZoom + ) + } + if let styleString = options["styleString"] as? String { + delegate.setStyleString(styleString: styleString) + } + if let rotateGesturesEnabled = options["rotateGesturesEnabled"] as? Bool { + delegate.setRotateGesturesEnabled(rotateGesturesEnabled: rotateGesturesEnabled) + } + if let scrollGesturesEnabled = options["scrollGesturesEnabled"] as? Bool { + delegate.setScrollGesturesEnabled(scrollGesturesEnabled: scrollGesturesEnabled) + } + if let tiltGesturesEnabled = options["tiltGesturesEnabled"] as? Bool { + delegate.setTiltGesturesEnabled(tiltGesturesEnabled: tiltGesturesEnabled) + } + if let trackCameraPosition = options["trackCameraPosition"] as? Bool { + delegate.setTrackCameraPosition(trackCameraPosition: trackCameraPosition) + } + if let zoomGesturesEnabled = options["zoomGesturesEnabled"] as? Bool { + delegate.setZoomGesturesEnabled(zoomGesturesEnabled: zoomGesturesEnabled) + } + if let myLocationEnabled = options["myLocationEnabled"] as? Bool { + delegate.setMyLocationEnabled(myLocationEnabled: myLocationEnabled) + } + if let myLocationTrackingMode = options["myLocationTrackingMode"] as? UInt, + let trackingMode = MLNUserTrackingMode(rawValue: myLocationTrackingMode) + { + delegate.setMyLocationTrackingMode(myLocationTrackingMode: trackingMode) + } + if let myLocationRenderMode = options["myLocationRenderMode"] as? Int, + let renderMode = MyLocationRenderMode(rawValue: myLocationRenderMode) + { + delegate.setMyLocationRenderMode(myLocationRenderMode: renderMode) + } + if let logoEnabled = options["logoEnabled"] as? Bool { + delegate.setLogoEnabled(logoEnabled: logoEnabled) + } + if let logoViewPosition = options["logoViewPosition"] as? UInt, + let position = MLNOrnamentPosition(rawValue: logoViewPosition) + { + delegate.setLogoViewPosition(position: position) + } + if let logoViewMargins = options["logoViewMargins"] as? [Double] { + delegate.setLogoViewMargins(x: logoViewMargins[0], y: logoViewMargins[1]) + } + if let compassViewPosition = options["compassViewPosition"] as? UInt, + let position = MLNOrnamentPosition(rawValue: compassViewPosition) + { + delegate.setCompassViewPosition(position: position) + } + if let compassViewMargins = options["compassViewMargins"] as? [Double] { + delegate.setCompassViewMargins(x: compassViewMargins[0], y: compassViewMargins[1]) + } + if let attributionButtonMargins = options["attributionButtonMargins"] as? [Double] { + delegate.setAttributionButtonMargins( + x: attributionButtonMargins[0], + y: attributionButtonMargins[1] + ) + } + if let attributionButtonPosition = options["attributionButtonPosition"] as? UInt, + let position = MLNOrnamentPosition(rawValue: attributionButtonPosition) + { + delegate.setAttributionButtonPosition(position: position) + } + } + + class func parseLatLngBoundsPadding(_ cameraUpdate: [Any]) -> UIEdgeInsets? { + guard let methodName = cameraUpdate[0] as? String else { return nil } + + if(methodName != "newLatLngBounds") { + return nil + } + + guard let paddingLeft = cameraUpdate[2] as? CGFloat else { return nil } + guard let paddingTop = cameraUpdate[3] as? CGFloat else { return nil } + guard let paddingRight = cameraUpdate[4] as? CGFloat else { return nil } + guard let paddingBottom = cameraUpdate[5] as? CGFloat else { return nil } + + return UIEdgeInsets(top: paddingTop, left: paddingLeft, bottom: paddingBottom, right: paddingRight) + } + + class func parseCameraUpdate(cameraUpdate: [Any], mapView: MLNMapView) -> MLNMapCamera? { + guard let type = cameraUpdate[0] as? String else { return nil } + switch type { + case "newCameraPosition": + guard let cameraPosition = cameraUpdate[1] as? [String: Any] else { return nil } + return MLNMapCamera.fromDict(cameraPosition, mapView: mapView) + case "newLatLng": + guard let coordinate = cameraUpdate[1] as? [Double] else { return nil } + let camera = mapView.camera + camera.centerCoordinate = CLLocationCoordinate2D.fromArray(coordinate) + return camera + case "newLatLngBounds": + guard let bounds = cameraUpdate[1] as? [[Double]] else { return nil } + + if let padding = parseLatLngBoundsPadding(cameraUpdate) { + return mapView.cameraThatFitsCoordinateBounds( + MLNCoordinateBounds.fromArray(bounds), + edgePadding: padding + ) + } + + return mapView.cameraThatFitsCoordinateBounds(MLNCoordinateBounds.fromArray(bounds)) + + case "newLatLngZoom": + guard let coordinate = cameraUpdate[1] as? [Double] else { return nil } + guard let zoom = cameraUpdate[2] as? Double else { return nil } + let camera = mapView.camera + camera.centerCoordinate = CLLocationCoordinate2D.fromArray(coordinate) + let altitude = getAltitude(zoom: zoom, mapView: mapView) + return MLNMapCamera( + lookingAtCenter: camera.centerCoordinate, + altitude: altitude, + pitch: camera.pitch, + heading: camera.heading + ) + case "scrollBy": + guard let x = cameraUpdate[1] as? CGFloat else { return nil } + guard let y = cameraUpdate[2] as? CGFloat else { return nil } + let camera = mapView.camera + let mapPoint = mapView.convert(camera.centerCoordinate, toPointTo: mapView) + let movedPoint = CGPoint(x: mapPoint.x + x, y: mapPoint.y + y) + camera.centerCoordinate = mapView.convert(movedPoint, toCoordinateFrom: mapView) + return camera + case "zoomBy": + guard let zoomBy = cameraUpdate[1] as? Double else { return nil } + let camera = mapView.camera + let zoom = getZoom(mapView: mapView) + let altitude = getAltitude(zoom: zoom + zoomBy, mapView: mapView) + camera.altitude = altitude + if cameraUpdate.count == 2 { + return camera + } else { + guard let point = cameraUpdate[2] as? [CGFloat], + point.count == 2 else { return nil } + let movedPoint = CGPoint(x: point[0], y: point[1]) + camera.centerCoordinate = mapView.convert(movedPoint, toCoordinateFrom: mapView) + return camera + } + case "zoomIn": + let camera = mapView.camera + let zoom = getZoom(mapView: mapView) + let altitude = getAltitude(zoom: zoom + 1, mapView: mapView) + camera.altitude = altitude + return camera + case "zoomOut": + let camera = mapView.camera + let zoom = getZoom(mapView: mapView) + let altitude = getAltitude(zoom: zoom - 1, mapView: mapView) + camera.altitude = altitude + return camera + case "zoomTo": + guard let zoom = cameraUpdate[1] as? Double else { return nil } + let camera = mapView.camera + let altitude = getAltitude(zoom: zoom, mapView: mapView) + camera.altitude = altitude + return camera + case "bearingTo": + guard let bearing = cameraUpdate[1] as? Double else { return nil } + let camera = mapView.camera + camera.heading = bearing + return camera + case "tiltTo": + guard let tilt = cameraUpdate[1] as? CGFloat else { return nil } + let camera = mapView.camera + camera.pitch = tilt + return camera + default: + print("\(type) not implemented!") + } + return nil + } + + class func getZoom(mapView: MLNMapView) -> Double { + return MLNZoomLevelForAltitude( + mapView.camera.altitude, + mapView.camera.pitch, + mapView.camera.centerCoordinate.latitude, + mapView.frame.size + ) + } + + class func getAltitude(zoom: Double, mapView: MLNMapView) -> Double { + return MLNAltitudeForZoomLevel( + zoom, + mapView.camera.pitch, + mapView.camera.centerCoordinate.latitude, + mapView.frame.size + ) + } + + class func getCoordinates(options: Any?) -> [CLLocationCoordinate2D] { + var coordinates: [CLLocationCoordinate2D] = [] + + if let options = options as? [String: Any], + let geometry = options["geometry"] as? [[Double]], geometry.count > 0 + { + for coordinate in geometry { + coordinates.append(CLLocationCoordinate2DMake(coordinate[0], coordinate[1])) + } + } + return coordinates + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Enums.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Enums.swift new file mode 100644 index 0000000..6d621d3 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Enums.swift @@ -0,0 +1,3 @@ +enum MyLocationRenderMode: Int { + case Normal, Compass, Gps +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Extensions.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Extensions.swift new file mode 100644 index 0000000..722be04 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Extensions.swift @@ -0,0 +1,148 @@ +import MapLibre + +extension MLNMapCamera { + func toDict(mapView: MLNMapView) -> [String: Any] { + let zoom = MLNZoomLevelForAltitude( + altitude, + pitch, + centerCoordinate.latitude, + mapView.frame.size + ) + return ["bearing": heading, + "target": centerCoordinate.toArray(), + "tilt": pitch, + "zoom": zoom] + } + + static func fromDict(_ dict: [String: Any], mapView: MLNMapView) -> MLNMapCamera? { + guard let target = dict["target"] as? [Double], + let zoom = dict["zoom"] as? Double, + let tilt = dict["tilt"] as? CGFloat, + let bearing = dict["bearing"] as? Double else { return nil } + let location = CLLocationCoordinate2D.fromArray(target) + let altitude = MLNAltitudeForZoomLevel(zoom, tilt, location.latitude, mapView.frame.size) + return MLNMapCamera( + lookingAtCenter: location, + altitude: altitude, + pitch: tilt, + heading: bearing + ) + } +} + +extension CLLocation { + func toDict() -> [String: Any]? { + return ["position": coordinate.toArray(), + "altitude": altitude, + "bearing": course, + "speed": speed, + "horizontalAccuracy": horizontalAccuracy, + "verticalAccuracy": verticalAccuracy, + "timestamp": Int(timestamp.timeIntervalSince1970 * 1000)] + } +} + +extension CLHeading { + func toDict() -> [String: Any]? { + return ["magneticHeading": magneticHeading, + "trueHeading": trueHeading, + "headingAccuracy": headingAccuracy, + "x": x, + "y": y, + "z": z, + "timestamp": Int(timestamp.timeIntervalSince1970 * 1000)] + } +} + +extension CLLocationCoordinate2D { + func toArray() -> [Double] { + return [latitude, longitude] + } + + static func fromArray(_ array: [Double]) -> CLLocationCoordinate2D { + return CLLocationCoordinate2D(latitude: array[0], longitude: array[1]) + } +} + +extension MLNCoordinateBounds { + func toArray() -> [[Double]] { + return [sw.toArray(), ne.toArray()] + } + + static func fromArray(_ array: [[Double]]) -> MLNCoordinateBounds { + let southwest = CLLocationCoordinate2D.fromArray(array[0]) + let northeast = CLLocationCoordinate2D.fromArray(array[1]) + return MLNCoordinateBounds(sw: southwest, ne: northeast) + } +} + +extension UIImage { + static func loadFromFile(imagePath: String, imageName: String) -> UIImage? { + // Add the trailing slash in path if missing. + let path = imagePath.hasSuffix("/") ? imagePath : "\(imagePath)/" + // Build scale dependant image path. + var scale = UIScreen.main.scale + var absolutePath = "\(path)\(scale)x/\(imageName)" + // Check if the image exists, if not try a an unscaled path. + if Bundle.main.path(forResource: absolutePath, ofType: nil) == nil { + absolutePath = "\(path)\(imageName)" + } else { + // found asset with higher resolution - increase scale even further to compensate + scale *= scale + } + // Load image if it exists. + if let path = Bundle.main.path(forResource: absolutePath, ofType: nil) { + let imageUrl = URL(fileURLWithPath: path) + if let imageData: Data = try? Data(contentsOf: imageUrl), + let image = UIImage(data: imageData, scale: scale) + { + return image + } + } + return nil + } +} + +public extension UIColor { + convenience init?(hexString: String) { + let r, g, b, a: CGFloat + + if hexString.hasPrefix("#") { + let start = hexString.index(hexString.startIndex, offsetBy: 1) + let hexColor = hexString[start...] + + let scanner = Scanner(string: String(hexColor)) + var hexNumber: UInt64 = 0 + + if hexColor.count == 6 { + if scanner.scanHexInt64(&hexNumber) { + r = CGFloat((hexNumber & 0xFF0000) >> 16) / 255 + g = CGFloat((hexNumber & 0x00FF00) >> 8) / 255 + b = CGFloat(hexNumber & 0x0000FF) / 255 + a = 255 + + self.init(red: r, green: g, blue: b, alpha: a) + return + } + } else if hexColor.count == 8 { + if scanner.scanHexInt64(&hexNumber) { + a = CGFloat((hexNumber & 0xFF00_0000) >> 24) / 255 + r = CGFloat((hexNumber & 0x00FF_0000) >> 16) / 255 + g = CGFloat((hexNumber & 0x0000_FF00) >> 8) / 255 + b = CGFloat(hexNumber & 0x0000_00FF) / 255 + + self.init(red: r, green: g, blue: b, alpha: a) + return + } + } + } + + return nil + } +} + +extension Array { + var tail: Array { + return Array(dropFirst()) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/LayerPropertyConverter.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/LayerPropertyConverter.swift new file mode 100644 index 0000000..e80a727 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/LayerPropertyConverter.swift @@ -0,0 +1,536 @@ +// This file is generated by +// ./scripts/lib/generate.dart + +import MapLibre + +class LayerPropertyConverter { + class func addSymbolProperties(symbolLayer: MLNSymbolStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "icon-opacity": + symbolLayer.iconOpacity = expression + case "icon-color": + symbolLayer.iconColor = expression + case "icon-halo-color": + symbolLayer.iconHaloColor = expression + case "icon-halo-width": + symbolLayer.iconHaloWidth = expression + case "icon-halo-blur": + symbolLayer.iconHaloBlur = expression + case "icon-translate": + symbolLayer.iconTranslation = expression + case "icon-translate-anchor": + symbolLayer.iconTranslationAnchor = expression + case "text-opacity": + symbolLayer.textOpacity = expression + case "text-color": + symbolLayer.textColor = expression + case "text-halo-color": + symbolLayer.textHaloColor = expression + case "text-halo-width": + symbolLayer.textHaloWidth = expression + case "text-halo-blur": + symbolLayer.textHaloBlur = expression + case "text-translate": + symbolLayer.textTranslation = expression + case "text-translate-anchor": + symbolLayer.textTranslationAnchor = expression + case "symbol-placement": + symbolLayer.symbolPlacement = expression + case "symbol-spacing": + symbolLayer.symbolSpacing = expression + case "symbol-avoid-edges": + symbolLayer.symbolAvoidsEdges = expression + case "symbol-sort-key": + symbolLayer.symbolSortKey = expression + case "symbol-z-order": + symbolLayer.symbolZOrder = expression + case "icon-allow-overlap": + symbolLayer.iconAllowsOverlap = expression + case "icon-ignore-placement": + symbolLayer.iconIgnoresPlacement = expression + case "icon-optional": + symbolLayer.iconOptional = expression + case "icon-rotation-alignment": + symbolLayer.iconRotationAlignment = expression + case "icon-size": + symbolLayer.iconScale = expression + case "icon-text-fit": + symbolLayer.iconTextFit = expression + case "icon-text-fit-padding": + symbolLayer.iconTextFitPadding = expression + case "icon-image": + symbolLayer.iconImageName = expression + case "icon-rotate": + symbolLayer.iconRotation = expression + case "icon-padding": + symbolLayer.iconPadding = expression + case "icon-keep-upright": + symbolLayer.keepsIconUpright = expression + case "icon-offset": + symbolLayer.iconOffset = expression + case "icon-anchor": + symbolLayer.iconAnchor = expression + case "icon-pitch-alignment": + symbolLayer.iconPitchAlignment = expression + case "text-pitch-alignment": + symbolLayer.textPitchAlignment = expression + case "text-rotation-alignment": + symbolLayer.textRotationAlignment = expression + case "text-field": + symbolLayer.text = expression + case "text-font": + symbolLayer.textFontNames = expression + case "text-size": + symbolLayer.textFontSize = expression + case "text-max-width": + symbolLayer.maximumTextWidth = expression + case "text-line-height": + symbolLayer.textLineHeight = expression + case "text-letter-spacing": + symbolLayer.textLetterSpacing = expression + case "text-justify": + symbolLayer.textJustification = expression + case "text-radial-offset": + symbolLayer.textRadialOffset = expression + case "text-variable-anchor": + symbolLayer.textVariableAnchor = expression + case "text-anchor": + symbolLayer.textAnchor = expression + case "text-max-angle": + symbolLayer.maximumTextAngle = expression + case "text-writing-mode": + symbolLayer.textWritingModes = expression + case "text-rotate": + symbolLayer.textRotation = expression + case "text-padding": + symbolLayer.textPadding = expression + case "text-keep-upright": + symbolLayer.keepsTextUpright = expression + case "text-transform": + symbolLayer.textTransform = expression + case "text-offset": + symbolLayer.textOffset = expression + case "text-allow-overlap": + symbolLayer.textAllowsOverlap = expression + case "text-ignore-placement": + symbolLayer.textIgnoresPlacement = expression + case "text-optional": + symbolLayer.textOptional = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + symbolLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addCircleProperties(circleLayer: MLNCircleStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "circle-radius": + circleLayer.circleRadius = expression + case "circle-color": + circleLayer.circleColor = expression + case "circle-blur": + circleLayer.circleBlur = expression + case "circle-opacity": + circleLayer.circleOpacity = expression + case "circle-translate": + circleLayer.circleTranslation = expression + case "circle-translate-anchor": + circleLayer.circleTranslationAnchor = expression + case "circle-pitch-scale": + circleLayer.circleScaleAlignment = expression + case "circle-pitch-alignment": + circleLayer.circlePitchAlignment = expression + case "circle-stroke-width": + circleLayer.circleStrokeWidth = expression + case "circle-stroke-color": + circleLayer.circleStrokeColor = expression + case "circle-stroke-opacity": + circleLayer.circleStrokeOpacity = expression + case "circle-sort-key": + circleLayer.circleSortKey = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + circleLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addLineProperties(lineLayer: MLNLineStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "line-opacity": + lineLayer.lineOpacity = expression + case "line-color": + lineLayer.lineColor = expression + case "line-translate": + lineLayer.lineTranslation = expression + case "line-translate-anchor": + lineLayer.lineTranslationAnchor = expression + case "line-width": + lineLayer.lineWidth = expression + case "line-gap-width": + lineLayer.lineGapWidth = expression + case "line-offset": + lineLayer.lineOffset = expression + case "line-blur": + lineLayer.lineBlur = expression + case "line-dasharray": + lineLayer.lineDashPattern = expression + case "line-pattern": + lineLayer.linePattern = expression + case "line-gradient": + lineLayer.lineGradient = expression + case "line-cap": + lineLayer.lineCap = expression + case "line-join": + lineLayer.lineJoin = expression + case "line-miter-limit": + lineLayer.lineMiterLimit = expression + case "line-round-limit": + lineLayer.lineRoundLimit = expression + case "line-sort-key": + lineLayer.lineSortKey = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + lineLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addFillProperties(fillLayer: MLNFillStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "fill-antialias": + fillLayer.fillAntialiased = expression + case "fill-opacity": + fillLayer.fillOpacity = expression + case "fill-color": + fillLayer.fillColor = expression + case "fill-outline-color": + fillLayer.fillOutlineColor = expression + case "fill-translate": + fillLayer.fillTranslation = expression + case "fill-translate-anchor": + fillLayer.fillTranslationAnchor = expression + case "fill-pattern": + fillLayer.fillPattern = expression + case "fill-sort-key": + fillLayer.fillSortKey = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + fillLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addFillExtrusionProperties(fillExtrusionLayer: MLNFillExtrusionStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "fill-extrusion-opacity": + fillExtrusionLayer.fillExtrusionOpacity = expression + case "fill-extrusion-color": + fillExtrusionLayer.fillExtrusionColor = expression + case "fill-extrusion-translate": + fillExtrusionLayer.fillExtrusionTranslation = expression + case "fill-extrusion-translate-anchor": + fillExtrusionLayer.fillExtrusionTranslationAnchor = expression + case "fill-extrusion-pattern": + fillExtrusionLayer.fillExtrusionPattern = expression + case "fill-extrusion-height": + fillExtrusionLayer.fillExtrusionHeight = expression + case "fill-extrusion-base": + fillExtrusionLayer.fillExtrusionBase = expression + case "fill-extrusion-vertical-gradient": + fillExtrusionLayer.fillExtrusionHasVerticalGradient = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + fillExtrusionLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addRasterProperties(rasterLayer: MLNRasterStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "raster-opacity": + rasterLayer.rasterOpacity = expression + case "raster-hue-rotate": + rasterLayer.rasterHueRotation = expression + case "raster-brightness-min": + rasterLayer.minimumRasterBrightness = expression + case "raster-brightness-max": + rasterLayer.maximumRasterBrightness = expression + case "raster-saturation": + rasterLayer.rasterSaturation = expression + case "raster-contrast": + rasterLayer.rasterContrast = expression + case "raster-resampling": + rasterLayer.rasterResamplingMode = expression + case "raster-fade-duration": + rasterLayer.rasterFadeDuration = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + rasterLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addHillshadeProperties(hillshadeLayer: MLNHillshadeStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "hillshade-illumination-direction": + hillshadeLayer.hillshadeIlluminationDirection = expression + case "hillshade-illumination-anchor": + hillshadeLayer.hillshadeIlluminationAnchor = expression + case "hillshade-exaggeration": + hillshadeLayer.hillshadeExaggeration = expression + case "hillshade-shadow-color": + hillshadeLayer.hillshadeShadowColor = expression + case "hillshade-highlight-color": + hillshadeLayer.hillshadeHighlightColor = expression + case "hillshade-accent-color": + hillshadeLayer.hillshadeAccentColor = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + hillshadeLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addHeatmapProperties(heatmapLayer: MLNHeatmapStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "heatmap-radius": + heatmapLayer.heatmapRadius = expression + case "heatmap-weight": + heatmapLayer.heatmapWeight = expression + case "heatmap-intensity": + heatmapLayer.heatmapIntensity = expression + case "heatmap-color": + heatmapLayer.heatmapColor = expression + case "heatmap-opacity": + heatmapLayer.heatmapOpacity = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + heatmapLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + private class func interpretExpression(propertyName: String, expression: String) -> NSExpression? { + let isColor = propertyName.contains("color"); + let isOffset = propertyName.contains("offset"); + let isTranslate = propertyName.contains("translate"); + + do { + let json = try JSONSerialization.jsonObject(with: expression.data(using: .utf8)!, options: .fragmentsAllowed) + + // Check if JSON contains NSNull - this would create an invalid NSExpression + if json is NSNull { + return nil + } + + // this is required because NSExpression.init(mglJSONObject: json) fails to create + // a proper Expression if the data of is a hexString + if isColor { + if let color = json as? String { + return NSExpression(forConstantValue: UIColor(hexString: color)) + } + } + + if let offset = json as? [Any]{ + // checks on the value of property that are literal expressions + if offset.count == 2 && offset.first is String && offset.first as? String == "literal" { + if let vector = offset.last as? [Any]{ + if(vector.count == 2) { + if isOffset || isTranslate { + // this is required because NSExpression.init(mglJSONObject: json) fails to create + // a proper Expression if the data of a literal is an array destined for a CGVector + if let x = vector.first as? Double, let y = vector.last as? Double { + return NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: x, dy: y))) + } + } + return NSExpression.init(mglJSONObject: json) + } + } + // checks on the value of properties that are arrays + } else if offset.count == 2, let x = offset.first as? Double, let y = offset.last as? Double { + if isOffset || isTranslate { + // this is required because NSExpression.init(mglJSONObject: json) fails to create + // a proper Expression if the data of an array is destined for a CGVector + return NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: x, dy: y))) + } + // this is required because NSExpression.init(mglJSONObject: json) fails to create + // a proper Expression if the data is an array of double + return NSExpression(forConstantValue: [NSNumber(value: x), NSNumber(value: y)]) + } else { + // Handle arrays with any number of elements (e.g., dash arrays with 3+ elements) + // Convert to array of NSNumbers for proper expression creation + let numbers = offset.compactMap { $0 as? Double }.map { NSNumber(value: $0) } + if numbers.count == offset.count { + return NSExpression(forConstantValue: numbers) + } + } + } + + return NSExpression.init(mglJSONObject: json) + } catch { + } + return nil + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MGLMapView+setLanguage.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MGLMapView+setLanguage.swift new file mode 100644 index 0000000..a19be86 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MGLMapView+setLanguage.swift @@ -0,0 +1,55 @@ +// +// MLNMapView+setLanguage.swift +// maplibre_gl +// +// Created by Julian Bissekkou on 09.08.23. +// + +import Foundation +import MapLibre + +extension MLNMapView { + func setMapLanguage(_ language: String) { + guard let style = style else { return } + + let layers = style.layers + + for layer in layers { + if let symbolLayer = layer as? MLNSymbolStyleLayer { + if symbolLayer.text == nil { + continue + } + + // We could skip the current iteration, whenever there is not current language. + if !symbolLayer.text.description.containsLanguage() { + continue + } + + let properties = ["text-field": "[\"coalesce\",[\"get\",\"name:\(language)\"],[\"get\",\"name:latin\"],[\"get\",\"name\"]]"] + + LayerPropertyConverter.addSymbolProperties( + symbolLayer: symbolLayer, + properties: properties + ) + } + } + } +} + + +private extension String { + func containsLanguage() -> Bool { + do { + let regex = try NSRegularExpression(pattern: "(name:[a-z]+)") + let range = NSRange(location: 0, length: self.utf16.count) + + if let _ = regex.firstMatch(in: self, options: [], range: range) { + return true + } else { + return false + } + } catch { + return false + } + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreCustomHeaders.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreCustomHeaders.swift new file mode 100644 index 0000000..78b7290 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreCustomHeaders.swift @@ -0,0 +1,39 @@ +import Foundation +import MapLibre + +public class MapLibreCustomHeaders { + private static var customHeaders: [String: String] = [:] + private static var filterPatterns: [String] = [] + + public static func setCustomHeaders(_ headers: [String: String], filter: [String]) { + customHeaders = headers + filterPatterns = filter + + let sessionConfig = URLSessionConfiguration.default + sessionConfig.httpAdditionalHeaders = headers + MLNNetworkConfiguration.sharedManager.sessionConfiguration = sessionConfig + } + + public static func getCustomHeaders() -> [String: String] { + return customHeaders + } + + public static func getFilterPatterns() -> [String] { + return filterPatterns + } + + public static func shouldApplyHeaders(to url: String) -> Bool { + if filterPatterns.isEmpty { + return true + } + + for pattern in filterPatterns { + if url.range(of: pattern, options: .regularExpression) != nil { + return true + } + } + + return false + } +} + diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapController.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapController.swift new file mode 100644 index 0000000..d49a4af --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapController.swift @@ -0,0 +1,2131 @@ +import Flutter +import MapLibre + +class MapLibreMapController: NSObject, FlutterPlatformView, MLNMapViewDelegate, MapLibreMapOptionsSink, + UIGestureRecognizerDelegate +{ + private var registrar: FlutterPluginRegistrar + private var channel: FlutterMethodChannel? + + private var mapView: MLNMapView + private var isMapReady = false + private var dragEnabled = true + private var isFirstStyleLoad = true + private var onStyleLoadedCalled = false + private var mapReadyResult: FlutterResult? + private var previousDragCoordinate: CLLocationCoordinate2D? + private var originDragCoordinate: CLLocationCoordinate2D? + private var dragFeature: MLNFeature? + + private var initialTilt: CGFloat? + private var trackCameraPosition = false + private var myLocationEnabled = false + private var scrollingEnabled = true + private var isAdjustingCameraProgrammatically = false + + private var interactiveFeatureLayerIds = Set() + private var addedShapesByLayer = [String: MLNShape]() + + func view() -> UIView { + return mapView + } + + private var styleIsReady: Bool { + return onStyleLoadedCalled && mapView.style != nil + } + + private static func createMapView( + args: Any?, + frame: CGRect, + registrar: FlutterPluginRegistrar + ) -> MLNMapView { + if let args = args as? [String: Any], + let styleString = args["styleString"] as? String + { + if Self.styleStringIsJSON(styleString) { + return MLNMapView(frame: frame, styleJSON: styleString) + } + + if let url = Self.styleStringAsURL( + styleString, + registrar: registrar + ) { + return MLNMapView(frame: frame, styleURL: url) + } + } + + // Fallback to default if neither JSON nor valid URL + NSLog( + """ + Warning: MapLibreMapController - Initializing map view with \ + default style. This capability will be removed in a future release. + """ + ) + // https://github.com/maplibre/maplibre-native/issues/709 + return MLNMapView(frame: frame) + } + + init( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any?, + registrar: FlutterPluginRegistrar + ) { + mapView = Self.createMapView( + args: args, + frame: frame, + registrar: registrar + ) + + mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + self.registrar = registrar + + super.init() + + channel = FlutterMethodChannel( + name: "plugins.flutter.io/maplibre_gl_\(viewId)", + binaryMessenger: registrar.messenger() + ) + channel! + .setMethodCallHandler { [weak self] in self?.onMethodCall(methodCall: $0, result: $1) } + + mapView.delegate = self + + let singleTap = UITapGestureRecognizer( + target: self, + action: #selector(handleMapTap(sender:)) + ) + for recognizer in mapView.gestureRecognizers! + where (recognizer as? UITapGestureRecognizer)?.numberOfTapsRequired == 2 { + singleTap.require(toFail: recognizer) + } + mapView.addGestureRecognizer(singleTap) + + let longPress = UILongPressGestureRecognizer( + target: self, + action: #selector(handleMapLongPress(sender:)) + ) + var longPressRecognizerAdded = false + + if let args = args as? [String: Any] { + + Convert.interpretMapLibreMapOptions(options: args["options"], delegate: self) + if let initialCameraPosition = args["initialCameraPosition"] as? [String: Any], + let camera = MLNMapCamera.fromDict(initialCameraPosition, mapView: mapView), + let zoom = initialCameraPosition["zoom"] as? Double + { + mapView.setCenter( + camera.centerCoordinate, + zoomLevel: zoom, + direction: camera.heading, + animated: false + ) + initialTilt = camera.pitch + } + // if let onAttributionClickOverride = args["onAttributionClickOverride"] as? Bool { + // if onAttributionClickOverride { + // setupAttribution(mapView) + // } + // } + + if let enabled = args["dragEnabled"] as? Bool { + dragEnabled = enabled + } + + if let iosLongClickDurationMilliseconds = args["iosLongClickDurationMilliseconds"] as? Int { + longPress.minimumPressDuration = TimeInterval(iosLongClickDurationMilliseconds) / 1000 + mapView.addGestureRecognizer(longPress) + longPressRecognizerAdded = true + } + } + if dragEnabled { + let pan = UIPanGestureRecognizer( + target: self, + action: #selector(handleMapPan(sender:)) + ) + pan.delegate = self + mapView.addGestureRecognizer(pan) + } + + if(!longPressRecognizerAdded) { + mapView.addGestureRecognizer(longPress) + longPressRecognizerAdded = true + } + } + + func gestureRecognizer( + _: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer + ) -> Bool { + return true + } + + func onMethodCall(methodCall: FlutterMethodCall, result: @escaping FlutterResult) { + switch methodCall.method { + case "map#waitForMap": + if isMapReady { + result(nil) + // only call map#onStyleLoaded here if isMapReady has happend and isFirstStyleLoad is true + if isFirstStyleLoad { + isFirstStyleLoad = false + if let channel = channel { + onStyleLoadedCalled = true + channel.invokeMethod("map#onStyleLoaded", arguments: nil) + } + } + } else { + mapReadyResult = result + } + case "map#update": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + Convert.interpretMapLibreMapOptions(options: arguments["options"], delegate: self) + if let camera = getCamera() { + result(camera.toDict(mapView: mapView)) + } else { + result(nil) + } + case "map#invalidateAmbientCache": + MLNOfflineStorage.shared.invalidateAmbientCache { + error in + if let error = error { + result(error) + } else { + result(nil) + } + } + case "map#clearAmbientCache": + MLNOfflineStorage.shared.clearAmbientCache { + error in + if let error = error { + result(error) + } else { + result(nil) + } + } + case "map#updateMyLocationTrackingMode": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + if let myLocationTrackingMode = arguments["mode"] as? UInt, + let trackingMode = MLNUserTrackingMode(rawValue: myLocationTrackingMode) + { + setMyLocationTrackingMode(myLocationTrackingMode: trackingMode) + } + result(nil) + case "map#matchMapLanguageWithDeviceDefault": + if let langStr = Locale.current.languageCode { + setMapLanguage(language: langStr) + } + + result(nil) + case "map#updateContentInsets": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + + if let bounds = arguments["bounds"] as? [String: Any], + let top = bounds["top"] as? CGFloat, + let left = bounds["left"] as? CGFloat, + let bottom = bounds["bottom"] as? CGFloat, + let right = bounds["right"] as? CGFloat, + let animated = arguments["animated"] as? Bool + { + mapView.setContentInset( + UIEdgeInsets(top: top, left: left, bottom: bottom, right: right), + animated: animated + ) { + result(nil) + } + } else { + result(nil) + } + case "locationComponent#getLastLocation": + var reply = [String: NSObject]() + if let loc = mapView.userLocation?.location?.coordinate { + reply["latitude"] = loc.latitude as NSObject + reply["longitude"] = loc.longitude as NSObject + result(reply) + } else { + result(nil) + } + case "map#setMapLanguage": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + if let localIdentifier = arguments["language"] as? String { + setMapLanguage(language: localIdentifier) + } + result(nil) + case "map#queryRenderedFeatures": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + var styleLayerIdentifiers: Set? + if let layerIds = arguments["layerIds"] as? [String], !layerIds.isEmpty { + styleLayerIdentifiers = Set(layerIds) + } + var filterExpression: NSPredicate? + if let filter = arguments["filter"] as? [Any] { + filterExpression = NSPredicate(mglJSONObject: filter) + } + var reply = [String: NSObject]() + var features: [MLNFeature] = [] + if let x = arguments["x"] as? Double, let y = arguments["y"] as? Double { + features = mapView.visibleFeatures( + at: CGPoint(x: x, y: y), + styleLayerIdentifiers: styleLayerIdentifiers, + predicate: filterExpression + ) + } + if let top = arguments["top"] as? Double, + let bottom = arguments["bottom"] as? Double, + let left = arguments["left"] as? Double, + let right = arguments["right"] as? Double + { + var width = right - left + var height = bottom - top + features = mapView.visibleFeatures(in: CGRect(x: left, y: top, width: width, height: height), styleLayerIdentifiers: styleLayerIdentifiers, predicate: filterExpression) + } + var featuresJson = [String]() + for feature in features { + let dictionary = feature.geoJSONDictionary() + if let theJSONData = try? JSONSerialization.data( + withJSONObject: dictionary, + options: [] + ), + let theJSONText = String(data: theJSONData, encoding: .utf8) + { + featuresJson.append(theJSONText) + } + } + reply["features"] = featuresJson as NSObject + result(reply) + case "map#setTelemetryEnabled": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + let telemetryEnabled = arguments["enabled"] as? Bool + UserDefaults.standard.set(telemetryEnabled, forKey: "MLNMapboxMetricsEnabled") + result(nil) + case "map#getTelemetryEnabled": + let telemetryEnabled = UserDefaults.standard.bool(forKey: "MLNMapboxMetricsEnabled") + result(telemetryEnabled) + case "map#setMaximumFps": + result(nil) + case "map#forceOnlineMode": + // Force online mode by ensuring network requests are enabled + // In MapLibre GL iOS, this is typically handled by the style and data sources + result(nil) + case "camera#ease": + guard let arguments = methodCall.arguments as? [String: Any] else { + result(false) + return + } + guard let cameraUpdate = arguments["cameraUpdate"] as? [Any] else { + result(false) + return + } + guard let camera = Convert.parseCameraUpdate(cameraUpdate: cameraUpdate, mapView: mapView) else { + result(false) + return + } + // MESHMAPPER GUARD: refuse camera ops while the map view has no usable + // viewport. MapLibre's transform unprojects against the viewport and + // throws an uncaught C++ std::domain_error (SIGABRT, uncatchable from + // Dart) when it is degenerate/zero-sized. Complete the Future so the + // Dart-side await still returns. + if mapView.bounds.width < 1 || mapView.bounds.height < 1 { + result(false) + return + } + + let completion = { + result(true) + } + + if let duration = arguments["duration"] as? Double, duration > 0 { + let interval: TimeInterval = duration / 1000.0 + mapView.setCamera(camera, withDuration: interval, animationTimingFunction: CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut), completionHandler: completion) + } else { + mapView.setCamera(camera, animated: true) + completion() + } + case "map#queryCameraPosition": + if let camera = getCamera() { + result(camera.toDict(mapView: mapView)) + } else { + result(nil) + } + case "map#editGeoJsonSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let srcId = arguments["id"] as? String else { return } + guard let srcData = arguments["data"] as? String else { return } + guard let style = self.mapView.style else { return } + + var ret: Bool = false + var reply: [String: Bool] = [:] + if let data = srcData.data(using: String.Encoding.utf8) { + let src = style.source(withIdentifier: srcId) + if src != nil && src is MLNShapeSource { + let geojsonSrc = src as! MLNShapeSource + let geojsonData = try? MLNShape(data: data, encoding: String.Encoding.utf8.rawValue) + if geojsonData != nil { + geojsonSrc.shape = geojsonData + ret = true + } + } + } + + reply["result"] = ret + result(reply) + case "map#editGeoJsonUrl": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let srcId = arguments["id"] as? String else { return } + guard let srcUrl = arguments["url"] as? String else { return } + guard let style = self.mapView.style else { return } + + var ret: Bool = false + var reply: [String: Bool] = [:] + let src = style.source(withIdentifier: srcId) + if src != nil && src is MLNShapeSource { + let geojsonSrc = src as! MLNShapeSource + let geojsonUrl = URL(string: srcUrl) + if geojsonUrl != nil { + geojsonSrc.url = geojsonUrl + ret = true + } + } + + reply["result"] = ret + result(reply) + case "map#setLayerFilter": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["id"] as? String else { return } + guard let layerFilter = arguments["filter"] as? String else { return } + guard let style = self.mapView.style else { return } + + var ret: Bool = false + var reply: [String: Bool] = [:] + let layer = style.layer(withIdentifier: layerId) + if layer != nil { + do { + if let data = layerFilter.data(using: .utf8) { + let jsonFilter = try JSONSerialization.jsonObject(with: data, options: []) + let predicate = NSPredicate(mglJSONObject: jsonFilter) + if let layer = layer as? MLNVectorStyleLayer { + layer.predicate = predicate + ret = true + } + } + } catch { + print("Error parsing filter: \(error.localizedDescription)") + } + } + + reply["result"] = ret + result(reply) + case "map#getStyle": + var reply: [String: Bool] = [:] + reply["result"] = false + result(reply) + case "map#setCustomHeaders": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let headers = arguments["headers"] as? [String:String] else { return } + guard let filter = arguments["filter"] as? [String] else { return } + MapLibreCustomHeaders.setCustomHeaders(headers, filter: filter) + result(nil) + case "map#getCustomHeaders": + result(MapLibreCustomHeaders.getCustomHeaders()) + case "map#getVisibleRegion": + var reply = [String: NSObject]() + let visibleRegion = mapView.visibleCoordinateBounds + reply["sw"] = [visibleRegion.sw.latitude, visibleRegion.sw.longitude] as NSObject + reply["ne"] = [visibleRegion.ne.latitude, visibleRegion.ne.longitude] as NSObject + result(reply) + case "map#toScreenLocation": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let latitude = arguments["latitude"] as? Double else { return } + guard let longitude = arguments["longitude"] as? Double else { return } + let latlng = CLLocationCoordinate2DMake(latitude, longitude) + let returnVal = mapView.convert(latlng, toPointTo: mapView) + var reply = [String: NSObject]() + reply["x"] = returnVal.x as NSObject + reply["y"] = returnVal.y as NSObject + result(reply) + case "map#toScreenLocationBatch": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let data = arguments["coordinates"] as? FlutterStandardTypedData else { return } + let latLngs = data.data.withUnsafeBytes { + Array( + UnsafeBufferPointer( + start: $0.baseAddress!.assumingMemoryBound(to: Double.self), + count: Int(data.elementCount) + ) + ) + } + var reply: [Double] = Array(repeating: 0.0, count: latLngs.count) + for i in stride(from: 0, to: latLngs.count, by: 2) { + let coordinate = CLLocationCoordinate2DMake(latLngs[i], latLngs[i + 1]) + let returnVal = mapView.convert(coordinate, toPointTo: mapView) + reply[i] = Double(returnVal.x) + reply[i + 1] = Double(returnVal.y) + } + result(FlutterStandardTypedData( + float64: Data(bytes: &reply, count: reply.count * 8) + )) + case "map#getMetersPerPixelAtLatitude": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + var reply = [String: NSObject]() + guard let latitude = arguments["latitude"] as? Double else { return } + let returnVal = mapView.metersPerPoint(atLatitude: latitude) + reply["metersperpixel"] = returnVal as NSObject + result(reply) + case "map#toLatLng": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let x = arguments["x"] as? Double else { return } + guard let y = arguments["y"] as? Double else { return } + let screenPoint = CGPoint(x: x, y: y) + let coordinates: CLLocationCoordinate2D = mapView.convert( + screenPoint, + toCoordinateFrom: mapView + ) + var reply = [String: NSObject]() + reply["latitude"] = coordinates.latitude as NSObject + reply["longitude"] = coordinates.longitude as NSObject + result(reply) + case "camera#move": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let cameraUpdate = arguments["cameraUpdate"] as? [Any] else { return } + + // MESHMAPPER GUARD: skip camera ops while the map view has no usable + // viewport (see camera#ease) — prevents the uncatchable SIGABRT. + if mapView.bounds.width < 1 || mapView.bounds.height < 1 { + result(nil) + return + } + + if let camera = Convert.parseCameraUpdate(cameraUpdate: cameraUpdate, mapView: mapView) { + mapView.setCamera(camera, animated: false) + } + result(nil) + case "camera#animate": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let cameraUpdate = arguments["cameraUpdate"] as? [Any] else { return } + guard let camera = Convert.parseCameraUpdate(cameraUpdate: cameraUpdate, mapView: mapView) else { return } + + // MESHMAPPER GUARD: skip camera ops while the map view has no usable + // viewport (see camera#ease) — prevents the uncatchable SIGABRT. + if mapView.bounds.width < 1 || mapView.bounds.height < 1 { + result(nil) + return + } + + let completion = { + result(nil) + } + + if let duration = arguments["duration"] as? TimeInterval { + if let padding = Convert.parseLatLngBoundsPadding(cameraUpdate) { + mapView.fly(to: camera, edgePadding: padding, withDuration: duration / 1000, completionHandler: completion) + } else { + mapView.fly(to: camera, withDuration: duration / 1000, completionHandler: completion) + } + } else { + mapView.setCamera(camera, animated: true) + completion() + } + case "symbolLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + let addResult = addSymbolLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "lineLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + let addResult = addLineLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "layer#setProperties": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + + guard let layer = mapView.style?.layer(withIdentifier: layerId) else { + result(FlutterError( + code: "LAYER_NOT_FOUND_ERROR", + message: "Layer " + layerId + "not found", + details: "" + )) + return + } + + //switch depending on the runtime type of layer + switch layer { + case let lineLayer as MLNLineStyleLayer: + LayerPropertyConverter.addLineProperties(lineLayer: lineLayer, properties: properties) + case let fillLayer as MLNFillStyleLayer: + LayerPropertyConverter.addFillProperties(fillLayer: fillLayer, properties: properties) + case let circleLayer as MLNCircleStyleLayer: + LayerPropertyConverter.addCircleProperties(circleLayer: circleLayer, properties: properties) + case let symbolLayer as MLNSymbolStyleLayer: + LayerPropertyConverter.addSymbolProperties(symbolLayer: symbolLayer, properties: properties) + case let rasterLayer as MLNRasterStyleLayer: + LayerPropertyConverter.addRasterProperties(rasterLayer: rasterLayer, properties: properties) + case let hillshadeLayer as MLNHillshadeStyleLayer: + LayerPropertyConverter.addHillshadeProperties(hillshadeLayer: hillshadeLayer, properties: properties) + default: + result(FlutterError( + code: "UNSUPPORTED_LAYER_TYPE", + message: "Layer type not supported", + details: "" + )) + return + } + + result(nil) + + case "fillLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + let addResult = addFillLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "fillExtrusionLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + let addResult = addFillExtrusionLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "circleLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + let addResult = addCircleLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "hillshadeLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + + let addResult = addHillshadeLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + properties: properties + ) + + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "heatmapLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let addResult = addHeatmapLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "rasterLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let addResult = addRasterLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "style#addImage": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let name = arguments["name"] as? String else { return } + // guard let length = arguments["length"] as? NSNumber else { return } + guard let bytes = arguments["bytes"] as? FlutterStandardTypedData else { return } + guard let sdf = arguments["sdf"] as? Bool else { return } + guard let data = bytes.data as? Data else { return } + guard let image = UIImage(data: data, scale: UIScreen.main.scale) else { return } + if sdf { + mapView.style?.setImage(image.withRenderingMode(.alwaysTemplate), forName: name) + } else { + mapView.style?.setImage(image, forName: name) + } + result(nil) + + case "style#addImageSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let imageSourceId = arguments["imageSourceId"] as? String else { return } + guard let bytes = arguments["bytes"] as? FlutterStandardTypedData else { return } + guard let data = bytes.data as? Data else { return } + guard let image = UIImage(data: data) else { return } + + guard let coordinates = arguments["coordinates"] as? [[Double]] else { return } + let quad = MLNCoordinateQuad( + topLeft: CLLocationCoordinate2D( + latitude: coordinates[0][0], + longitude: coordinates[0][1] + ), + bottomLeft: CLLocationCoordinate2D( + latitude: coordinates[3][0], + longitude: coordinates[3][1] + ), + bottomRight: CLLocationCoordinate2D( + latitude: coordinates[2][0], + longitude: coordinates[2][1] + ), + topRight: CLLocationCoordinate2D( + latitude: coordinates[1][0], + longitude: coordinates[1][1] + ) + ) + + // Check for duplicateSource error + if mapView.style?.source(withIdentifier: imageSourceId) != nil { + result(FlutterError( + code: "duplicateSource", + message: "Source with imageSourceId \(imageSourceId) already exists", + details: "Can't add duplicate source with imageSourceId: \(imageSourceId)" + )) + return + } + + let source = MLNImageSource( + identifier: imageSourceId, + coordinateQuad: quad, + image: image + ) + mapView.style?.addSource(source) + + result(nil) + case "style#updateImageSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let imageSourceId = arguments["imageSourceId"] as? String else { return } + guard let imageSource = mapView.style? + .source(withIdentifier: imageSourceId) as? MLNImageSource else { return } + let bytes = arguments["bytes"] as? FlutterStandardTypedData + if bytes != nil { + guard let data = bytes!.data as? Data else { return } + guard let image = UIImage(data: data) else { return } + imageSource.image = image + } + let coordinates = arguments["coordinates"] as? [[Double]] + if coordinates != nil { + let quad = MLNCoordinateQuad( + topLeft: CLLocationCoordinate2D( + latitude: coordinates![0][0], + longitude: coordinates![0][1] + ), + bottomLeft: CLLocationCoordinate2D( + latitude: coordinates![3][0], + longitude: coordinates![3][1] + ), + bottomRight: CLLocationCoordinate2D( + latitude: coordinates![2][0], + longitude: coordinates![2][1] + ), + topRight: CLLocationCoordinate2D( + latitude: coordinates![1][0], + longitude: coordinates![1][1] + ) + ) + imageSource.coordinates = quad + } + result(nil) + case "style#removeSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let source = mapView.style?.source(withIdentifier: sourceId) else { + result(nil) + return + } + mapView.style?.removeSource(source) + result(nil) + case "style#addLayer": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let imageLayerId = arguments["imageLayerId"] as? String else { return } + guard let imageSourceId = arguments["imageSourceId"] as? String else { return } + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + + // Check for duplicateLayer error + if (mapView.style?.layer(withIdentifier: imageLayerId)) != nil { + result(FlutterError( + code: "duplicateLayer", + message: "Layer already exists", + details: "Can't add duplicate layer with imageLayerId: \(imageLayerId)" + )) + return + } + // Check for noSuchSource error + guard let source = mapView.style?.source(withIdentifier: imageSourceId) else { + result(FlutterError( + code: "noSuchSource", + message: "No source found with imageSourceId \(imageSourceId)", + details: "Can't add add layer for imageSourceId \(imageLayerId), as the source does not exist." + )) + return + } + + let layer = MLNRasterStyleLayer(identifier: imageLayerId, source: source) + + if let minzoom = minzoom { + layer.minimumZoomLevel = Float(minzoom) + } + + if let maxzoom = maxzoom { + layer.maximumZoomLevel = Float(maxzoom) + } + + mapView.style?.addLayer(layer) + case "style#addLayerBelow": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let imageLayerId = arguments["imageLayerId"] as? String else { return } + guard let imageSourceId = arguments["imageSourceId"] as? String else { return } + guard let belowLayerId = arguments["belowLayerId"] as? String else { return } + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + + // Check for duplicateLayer error + if (mapView.style?.layer(withIdentifier: imageLayerId)) != nil { + result(FlutterError( + code: "duplicateLayer", + message: "Layer already exists", + details: "Can't add duplicate layer with imageLayerId: \(imageLayerId)" + )) + return + } + // Check for noSuchSource error + guard let source = mapView.style?.source(withIdentifier: imageSourceId) else { + result(FlutterError( + code: "noSuchSource", + message: "No source found with imageSourceId \(imageSourceId)", + details: "Can't add add layer for imageSourceId \(imageLayerId), as the source does not exist." + )) + return + } + // Check for noSuchLayer error + guard let belowLayer = mapView.style?.layer(withIdentifier: belowLayerId) else { + result(FlutterError( + code: "noSuchLayer", + message: "No layer found with layerId \(belowLayerId)", + details: "Can't insert layer below layer with id \(belowLayerId), as no such layer exists." + )) + return + } + + let layer = MLNRasterStyleLayer(identifier: imageLayerId, source: source) + + if let minzoom = minzoom { + layer.minimumZoomLevel = Float(minzoom) + } + + if let maxzoom = maxzoom { + layer.maximumZoomLevel = Float(maxzoom) + } + mapView.style?.insertLayer(layer, below: belowLayer) + result(nil) + + case "style#removeLayer": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let layer = mapView.style?.layer(withIdentifier: layerId) else { + result(MethodCallError.layerNotFound( + layerId: layerId + ).flutterError) + return + } + interactiveFeatureLayerIds.remove(layerId) + mapView.style?.removeLayer(layer) + result(nil) + + case "map#setCameraBounds": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let west = arguments["west"] as? Double else { return } + guard let north = arguments["north"] as? Double else { return } + guard let south = arguments["south"] as? Double else { return } + guard let east = arguments["east"] as? Double else { return } + guard let padding = arguments["padding"] as? CGFloat else { return } + + let southwest = CLLocationCoordinate2D(latitude: south, longitude: west) + let northeast = CLLocationCoordinate2D(latitude: north, longitude: east) + let bounds = MLNCoordinateBounds(sw: southwest, ne: northeast) + mapView.setVisibleCoordinateBounds(bounds, edgePadding: UIEdgeInsets(top: padding, + left: padding, bottom: padding, right: padding) , animated: true) + result(nil) + + case "style#setFilter": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let filter = arguments["filter"] as? String else { return } + guard let layer = mapView.style?.layer(withIdentifier: layerId) else { + result(MethodCallError.layerNotFound( + layerId: layerId + ).flutterError) + return + } + switch setFilter(layer, filter) { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "source#addGeoJson": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let geojson = arguments["geojson"] as? String else { return } + let addResult = addSourceGeojson(sourceId: sourceId, geojson: geojson) + + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "style#addSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: Any] else { return } + let addResult = addSource(sourceId: sourceId, properties: properties) + + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "source#setGeoJson": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let geojson = arguments["geojson"] as? String else { return } + let setResult = setSource(sourceId: sourceId, geojson: geojson) + + switch setResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "source#setFeature": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let geojson = arguments["geojsonFeature"] as? String else { return } + let setResult = setFeature(sourceId: sourceId, geojsonFeature: geojson) + + switch setResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "layer#setVisibility": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let visible = arguments["visible"] as? Bool else { return } + guard let layer = mapView.style?.layer(withIdentifier: layerId) else { + result(MethodCallError.layerNotFound( + layerId: layerId + ).flutterError) + return + } + layer.isVisible = visible + result(nil) + + case "map#querySourceFeatures": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + + var sourceLayerId = Set() + if let layerId = arguments["sourceLayerId"] as? String { + sourceLayerId.insert(layerId) + } + var filterExpression: NSPredicate? + if let filter = arguments["filter"] as? [Any] { + filterExpression = NSPredicate(mglJSONObject: filter) + } + + var reply = [String: NSObject]() + var features: [MLNFeature] = [] + + guard let style = mapView.style else { return } + if let source = style.source(withIdentifier: sourceId) { + if let vectorSource = source as? MLNVectorTileSource { + features = vectorSource.features(sourceLayerIdentifiers: sourceLayerId, predicate: filterExpression) + } else if let shapeSource = source as? MLNShapeSource { + features = shapeSource.features(matching: filterExpression) + } + } + + var featuresJson = [String]() + for feature in features { + let dictionary = feature.geoJSONDictionary() + if let theJSONData = try? JSONSerialization.data( + withJSONObject: dictionary, + options: [] + ), + let theJSONText = String(data: theJSONData, encoding: .utf8) + { + featuresJson.append(theJSONText) + } + } + reply["features"] = featuresJson as NSObject + result(reply) + + case "style#getLayerIds": + var layerIds = [String]() + + guard let style = mapView.style else { return } + + style.layers.forEach { layer in layerIds.append(layer.identifier) } + + var reply = [String: NSObject]() + reply["layers"] = layerIds as NSObject + result(reply) + + case "style#getSourceIds": + var sourceIds = [String]() + + guard let style = mapView.style else { return } + + style.sources.forEach { source in sourceIds.append(source.identifier) } + + var reply = [String: NSObject]() + reply["sources"] = sourceIds as NSObject + result(reply) + + case "style#getFilter": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + + guard let style = mapView.style else { return } + guard let layer = style.layer(withIdentifier: layerId) else { return } + + var currentLayerFilter : String = "" + if let vectorLayer = layer as? MLNVectorStyleLayer { + if let layerFilter = vectorLayer.predicate { + + let jsonExpression = layerFilter.mgl_jsonExpressionObject + if let data = try? JSONSerialization.data(withJSONObject: jsonExpression, options: []) { + currentLayerFilter = String(data: data, encoding: String.Encoding.utf8) ?? "" + } + } + } else { + result(MethodCallError.invalidLayerType( + details: "Layer '\(layer.identifier)' does not support filtering." + ).flutterError) + return; + } + + var reply = [String: NSObject]() + reply["filter"] = currentLayerFilter as NSObject + result(reply) + + case "style#setStyle": + if let arguments = methodCall.arguments as? [String: Any] { + if let style = arguments["style"] as? String { + setStyleString(styleString: style) + result(nil) + } else { + // Error for missing style key in argument + result( + FlutterError( + code: "invalidStyleString", + message: "Missing style key in arguments", + details: nil + ) + ) + } + } else { + // Error for invalid arguments type + result( + FlutterError( + code: "invalidArgumentsType", + message: "Arguments not of type [String: Any]", + details: nil + ) + ) + } + default: + result(FlutterMethodNotImplemented) + } + } + + private func loadIconImage(name: String) -> UIImage? { + // Build up the full path of the asset. + // First find the last '/' ans split the image name in the asset directory and the image file name. + if let range = name.range(of: "/", options: [.backwards]) { + let directory = String(name[.. MLNMapCamera? { + return trackCameraPosition ? mapView.camera : nil + } + + private func setMapLanguage(language: String) { + self.mapView.setMapLanguage(language) + } + + /* + * Scan layers from top to bottom and return the first matching feature + */ + private func firstFeatureOnLayers(at: CGPoint) -> (feature: MLNFeature?, layerId: String?) { + guard let style = mapView.style else { + NSLog("MapLibreMapController - Map style is nil") + return (nil, nil) + } + + guard styleIsReady else { + NSLog("MapLibreMapController - Map style is not ready yet") + return (nil, nil) + } + + // get layers in order (interactiveFeatureLayerIds is unordered) + let clickableLayers = style.layers.filter { layer in + interactiveFeatureLayerIds.contains(layer.identifier) + } + + for layer in clickableLayers.reversed() { + let features = mapView.visibleFeatures( + at: at, + styleLayerIdentifiers: [layer.identifier] + ) + if let feature = features.first { + return (feature, layer.identifier) + } + } + return (nil, nil) + } + + /* + * UITapGestureRecognizer + * On tap invoke the map#onMapClick callback. + */ + @IBAction func handleMapTap(sender: UITapGestureRecognizer) { + // Get the CGPoint where the user tapped. + let point = sender.location(in: mapView) + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + + let result = firstFeatureOnLayers(at: point) + if let feature = result.feature { + channel?.invokeMethod("feature#onTap", arguments: [ + "id": feature.identifier, + "x": point.x, + "y": point.y, + "lng": coordinate.longitude, + "lat": coordinate.latitude, + "layerId": result.layerId, + ]) + } else { + channel?.invokeMethod("map#onMapClick", arguments: [ + "x": point.x, + "y": point.y, + "lng": coordinate.longitude, + "lat": coordinate.latitude, + ]) + } + } + + fileprivate func invokeFeatureDrag( + _ point: CGPoint, + _ coordinate: CLLocationCoordinate2D, + _ eventType: String + ) { + if let feature = dragFeature, + let id = feature.identifier, + let previous = previousDragCoordinate, + let origin = originDragCoordinate + { + channel?.invokeMethod("feature#onDrag", arguments: [ + "id": id, + "x": point.x, + "y": point.y, + "originLng": origin.longitude, + "originLat": origin.latitude, + "currentLng": coordinate.longitude, + "currentLat": coordinate.latitude, + "eventType": eventType, + "deltaLng": coordinate.longitude - previous.longitude, + "deltaLat": coordinate.latitude - previous.latitude, + ]) + } + } + + @IBAction func handleMapPan(sender: UIPanGestureRecognizer) { + let began = sender.state == UIGestureRecognizer.State.began + let end = sender.state == UIGestureRecognizer.State.ended + let point = sender.location(in: mapView) + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + + if dragFeature == nil, began, sender.numberOfTouches == 1 { + let result = firstFeatureOnLayers(at: point) + if let feature = result.feature, + let draggable = feature.attribute(forKey: "draggable") as? Bool, + draggable { + sender.state = UIGestureRecognizer.State.began + dragFeature = feature + originDragCoordinate = coordinate + previousDragCoordinate = coordinate + mapView.allowsScrolling = false + let eventType = "start" + invokeFeatureDrag(point, coordinate, eventType) + for gestureRecognizer in mapView.gestureRecognizers! { + if let _ = gestureRecognizer as? UIPanGestureRecognizer { + gestureRecognizer.addTarget(self, action: #selector(handleMapPan)) + break + } + } + } + } + if end, dragFeature != nil { + mapView.allowsScrolling = true + let eventType = "end" + invokeFeatureDrag(point, coordinate, eventType) + dragFeature = nil + originDragCoordinate = nil + previousDragCoordinate = nil + } + + if !began, !end, dragFeature != nil { + let eventType = "drag" + invokeFeatureDrag(point, coordinate, eventType) + previousDragCoordinate = coordinate + } + } + + /* + * UILongPressGestureRecognizer + * After a long press invoke the map#onMapLongClick callback. + */ + @IBAction func handleMapLongPress(sender: UILongPressGestureRecognizer) { + // Fire when the long press starts + if sender.state == .began { + // Get the CGPoint where the user tapped. + let point = sender.location(in: mapView) + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + channel?.invokeMethod("map#onMapLongClick", arguments: [ + "x": point.x, + "y": point.y, + "lng": coordinate.longitude, + "lat": coordinate.latitude, + ]) + } + } + + /* /* + * Override the attribution button's click target to handle the event locally. + * Called if the application supplies an onAttributionClick handler. + */ + func setupAttribution(_ mapView: MLNMapView) { + mapView.attributionButton.removeTarget( + mapView, + action: #selector(mapView.showAttribution), + for: .touchUpInside + ) + mapView.attributionButton.addTarget( + self, + action: #selector(showAttribution), + for: UIControl.Event.touchUpInside + ) + } + + /* + * Custom click handler for the attribution button. This callback is bound when + * the application specifies an onAttributionClick handler. + */ + @objc func showAttribution() { + channel?.invokeMethod("map#onAttributionClick", arguments: []) + } */ + + /* + * MLNMapViewDelegate + */ + func mapView(_ mapView: MLNMapView, didFinishLoading _: MLNStyle) { + isMapReady = true + updateMyLocationEnabled() + + if let initialTilt = initialTilt { + let camera = mapView.camera + camera.pitch = initialTilt + mapView.setCamera(camera, animated: false) + } + + addedShapesByLayer.removeAll() + interactiveFeatureLayerIds.removeAll() + + mapReadyResult?(nil) + + // On first launch we only call map#onStyleLoaded if map#waitForMap has already been called + if !isFirstStyleLoad || mapReadyResult != nil { + isFirstStyleLoad = false + + if let channel = channel { + onStyleLoadedCalled = true + channel.invokeMethod("map#onStyleLoaded", arguments: nil) + } + } + } + + // handle missing images + func mapView(_: MLNMapView, didFailToLoadImage name: String) -> UIImage? { + return loadIconImage(name: name) + } + + func mapView(_: MLNMapView, didUpdate userLocation: MLNUserLocation?) { + if let channel = channel, let userLocation = userLocation, + let location = userLocation.location + { + channel.invokeMethod("map#onUserLocationUpdated", arguments: [ + "userLocation": location.toDict(), + "heading": userLocation.heading?.toDict(), + ]) + } + } + + func mapView(_: MLNMapView, didChange mode: MLNUserTrackingMode, animated _: Bool) { + if let channel = channel { + channel.invokeMethod("map#onCameraTrackingChanged", arguments: ["mode": mode.rawValue]) + if mode == .none { + channel.invokeMethod("map#onCameraTrackingDismissed", arguments: []) + } + } + } + + private func validateBeforeLayerAdd( + sourceId: String, + layerId: String + ) -> Result<(MLNStyle, MLNSource), MethodCallError> { + guard let style = mapView.style else { + return .failure(.styleNotFound) + } + guard let source = style.source(withIdentifier: sourceId) else { + return .failure(.sourceNotFound(sourceId: sourceId)) + } + guard style.layer(withIdentifier: layerId) == nil else { + return .failure(.layerAlreadyExists(layerId: layerId)) + } + return .success((style, source)) + } + + + func addSymbolLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNSymbolStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addSymbolProperties( + symbolLayer: layer, + properties: properties + ) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + return .success(()) + } + } + + func addLineLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNLineStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addLineProperties(lineLayer: layer, properties: properties) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + return .success(()) + } + } + + func addFillLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNFillStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addFillProperties(fillLayer: layer, properties: properties) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + return .success(()) + } + } + + func addFillExtrusionLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNFillExtrusionStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addFillExtrusionProperties( + fillExtrusionLayer: layer, + properties: properties + ) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + return .success(()) + } + } + + + + func addCircleLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNCircleStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addCircleProperties( + circleLayer: layer, + properties: properties + ) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + return .success(()) + } + } + + func setFilter(_ layer: MLNStyleLayer, _ filter: String) -> Result { + do { + let filter = try JSONSerialization.jsonObject( + with: filter.data(using: .utf8)!, + options: .fragmentsAllowed + ) + if filter is NSNull { + return .success(()) + } + let predicate = NSPredicate(mglJSONObject: filter) + if let layer = layer as? MLNVectorStyleLayer { + layer.predicate = predicate + } else { + return .failure(MethodCallError.invalidLayerType( + details: "Layer '\(layer.identifier)' does not support filtering." + )) + } + return .success(()) + } catch { + return .failure(MethodCallError.invalidExpression) + } + } + + func addHillshadeLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNHillshadeStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addHillshadeProperties( + hillshadeLayer: layer, + properties: properties + ) + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + return .success(()) + } + } + + func addHeatmapLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNHeatmapStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addHeatmapProperties( + heatmapLayer: layer, + properties: properties + ) + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + return .success(()) + } + } + + func addRasterLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNRasterStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addRasterProperties( + rasterLayer: layer, + properties: properties + ) + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + return .success(()) + } + } + + func addSource(sourceId: String, properties: [String: Any]) -> Result { + guard let style = mapView.style else { + return .failure(.styleNotFound) + } + guard style.source(withIdentifier: sourceId) == nil else { + return .failure(.sourceAlreadyExists(sourceId: sourceId)) + } + guard let type = properties["type"] as? String else { + return .failure(.invalidSourceType( + details: "Source '\(sourceId)' does not have a type." + )) + } + + var source: MLNSource? + switch type { + case "vector": + source = SourcePropertyConverter.buildVectorTileSource( + identifier: sourceId, + properties: properties + ) + case "raster": + source = SourcePropertyConverter.buildRasterTileSource( + identifier: sourceId, + properties: properties + ) + case "raster-dem": + source = SourcePropertyConverter.buildRasterDemSource( + identifier: sourceId, + properties: properties + ) + case "image": + source = SourcePropertyConverter.buildImageSource( + identifier: sourceId, + properties: properties + ) + case "geojson": + source = SourcePropertyConverter.buildShapeSource( + identifier: sourceId, + properties: properties + ) + default: + // unsupported source type + source = nil + } + if let source = source { + style.addSource(source) + return .success(()) + } + return .failure(.invalidSourceType( + details: "Source '\(sourceId)' does not support type '\(type)'." + )) + + } + + func mapViewDidBecomeIdle(_: MLNMapView) { + if let channel = channel { + channel.invokeMethod("map#onIdle", arguments: []) + } + } + + func mapView(_: MLNMapView, regionWillChangeAnimated _: Bool) { + if let channel = channel { + channel.invokeMethod("camera#onMoveStarted", arguments: []) + } + } + + func mapViewRegionIsChanging(_ mapView: MLNMapView) { + if !trackCameraPosition { return } + if let channel = channel { + channel.invokeMethod("camera#onMove", arguments: [ + "position": getCamera()?.toDict(mapView: mapView), + ]) + } + } + + func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) { + let arguments = trackCameraPosition ? [ + "position": getCamera()?.toDict(mapView: mapView) + ] : [:] + if let channel = channel { + channel.invokeMethod("camera#onIdle", arguments: arguments) + } + } + + func addSourceGeojson(sourceId: String, geojson: String) -> Result { + do{ + guard let style = mapView.style else { + return .failure(.styleNotFound) + } + guard style.source(withIdentifier: sourceId) == nil else { + return .failure(.sourceAlreadyExists(sourceId: sourceId)) + } + + let parsed = try MLNShape( + data: geojson.data(using: .utf8)!, + encoding: String.Encoding.utf8.rawValue + ) + let source = MLNShapeSource(identifier: sourceId, shape: parsed, options: [:]) + addedShapesByLayer[sourceId] = parsed + style.addSource(source) + return .success(()) + } catch { + return .failure(.geojsonParseError(sourceId: sourceId)) + } + } + + func setSource(sourceId: String, geojson: String) -> Result { + guard let style = mapView.style else { + return .failure(.styleNotFound) + } + + do{ + let parsed = try MLNShape( + data: geojson.data(using: .utf8)!, + encoding: String.Encoding.utf8.rawValue + ) + guard let source = style.source(withIdentifier: sourceId) as? MLNShapeSource else { + return .failure(.sourceNotFound(sourceId: sourceId)) + } + addedShapesByLayer[sourceId] = parsed + source.shape = parsed + return .success(()) + }catch{ + return .failure(.geojsonParseError(sourceId: sourceId)) + } + + } + + + func setFeature(sourceId: String, geojsonFeature: String) -> Result { + guard let style = mapView.style else { + return .failure(.styleNotFound) + } + do { + let newShape = try MLNShape( + data: geojsonFeature.data(using: .utf8)!, + encoding: String.Encoding.utf8.rawValue + ) + guard let source = style.source(withIdentifier: sourceId) as? MLNShapeSource else { + return .failure(.sourceNotFound(sourceId: sourceId)) + } + if let shape = addedShapesByLayer[sourceId] as? MLNShapeCollectionFeature, + let feature = newShape as? MLNShape & MLNFeature + { + if let index = shape.shapes + .firstIndex(where: { + if let id = $0.identifier as? String, + let featureId = feature.identifier as? String + { return id == featureId } + + if let id = $0.identifier as? NSNumber, + let featureId = feature.identifier as? NSNumber + { return id == featureId } + return false + }) + { + var shapes = shape.shapes + shapes[index] = feature + + source.shape = MLNShapeCollectionFeature(shapes: shapes) + } + + addedShapesByLayer[sourceId] = source.shape + return .success(()) + } + return .failure(.genericError(details: "Failed to set feature for sourceId \(sourceId)")) + + } catch { + return .failure(.geojsonParseError(sourceId: sourceId)) + } + } + + /* + * MapLibreMapOptionsSink + */ + func setCameraTargetBounds(bounds: MLNCoordinateBounds?) { + let bounds = bounds ?? MLNCoordinateBounds( + sw: CLLocationCoordinate2D(latitude: -90, longitude: -180), + ne: CLLocationCoordinate2D(latitude: 90, longitude: 180) + ) + mapView.maximumScreenBounds = bounds; + } + + func setCompassEnabled(compassEnabled: Bool) { + mapView.compassView.isHidden = compassEnabled + mapView.compassView.isHidden = !compassEnabled + } + + func setMinMaxZoomPreference(min: Double?, max: Double?) { + // Use MapLibre defaults (0 for min, 22 for max) when unbounded (nil) + let minZoom = min ?? 0.0 + let maxZoom = max ?? 22.0 + + mapView.minimumZoomLevel = minZoom + mapView.maximumZoomLevel = maxZoom + } + + private static func styleStringIsJSON(_ styleString: String) -> Bool { + return styleString.hasPrefix("{") || styleString.hasPrefix("[") + } + + private static func styleStringAsURL( + _ styleString: String, + registrar: FlutterPluginRegistrar + ) -> URL? { + if styleString.isEmpty { + NSLog("styleStringAsURL - style string is empty, ignoring") + return nil + } else if styleStringIsJSON(styleString) { + return nil + } else if styleString.hasPrefix("/") { + // Absolute path + return URL(fileURLWithPath: styleString, isDirectory: false) + } else if !styleString.hasPrefix("http://"), + !styleString.hasPrefix("https://"), + !styleString.hasPrefix("mapbox://") + { + // We are assuming that the style will be loaded from an asset here. + let assetPath = registrar.lookupKey(forAsset: styleString) + return URL(string: assetPath, relativeTo: Bundle.main.resourceURL) + } else if (styleString.hasPrefix("file://")) { + if let path = Bundle.main.path( + forResource: styleString.deletingPrefix("file://"), + ofType: "json" + ) { + return URL(fileURLWithPath: path) + } else { + NSLog( + "styleStringAsURL - path not found: \(styleString), ignoring" + ) + return nil + } + } else { + return URL(string: styleString) + } + } + + func setStyleString(styleString: String) { + interactiveFeatureLayerIds.removeAll() + addedShapesByLayer.removeAll() + + if Self.styleStringIsJSON(styleString) { + mapView.styleJSON = styleString + } else if let url = Self.styleStringAsURL( + styleString, + registrar: registrar + ) { + mapView.styleURL = url; + } + } + + func setRotateGesturesEnabled(rotateGesturesEnabled: Bool) { + mapView.allowsRotating = rotateGesturesEnabled + } + + func setScrollGesturesEnabled(scrollGesturesEnabled: Bool) { + mapView.allowsScrolling = scrollGesturesEnabled + scrollingEnabled = scrollGesturesEnabled + } + + func setTiltGesturesEnabled(tiltGesturesEnabled: Bool) { + mapView.allowsTilting = tiltGesturesEnabled + } + + func setTrackCameraPosition(trackCameraPosition: Bool) { + self.trackCameraPosition = trackCameraPosition + } + + func setZoomGesturesEnabled(zoomGesturesEnabled: Bool) { + mapView.allowsZooming = zoomGesturesEnabled + } + + func setMyLocationEnabled(myLocationEnabled: Bool) { + if self.myLocationEnabled == myLocationEnabled { + return + } + self.myLocationEnabled = myLocationEnabled + updateMyLocationEnabled() + } + + func setMyLocationTrackingMode(myLocationTrackingMode: MLNUserTrackingMode) { + mapView.userTrackingMode = myLocationTrackingMode + } + + func setMyLocationRenderMode(myLocationRenderMode: MyLocationRenderMode) { + switch myLocationRenderMode { + case .Normal: + mapView.showsUserHeadingIndicator = false + case .Compass: + mapView.showsUserHeadingIndicator = true + case .Gps: + NSLog("RenderMode.GPS currently not supported") + } + } + + func setLogoEnabled(logoEnabled: Bool) { + mapView.logoView.isHidden = !logoEnabled + } + + func setLogoViewPosition(position: MLNOrnamentPosition) { + mapView.logoViewPosition = position + } + + func setLogoViewMargins(x: Double, y: Double) { + mapView.logoViewMargins = CGPoint(x: x, y: y) + } + + func setCompassViewPosition(position: MLNOrnamentPosition) { + mapView.compassViewPosition = position + } + + func setCompassViewMargins(x: Double, y: Double) { + mapView.compassViewMargins = CGPoint(x: x, y: y) + } + + func setAttributionButtonMargins(x: Double, y: Double) { + mapView.attributionButtonMargins = CGPoint(x: x, y: y) + } + + func setAttributionButtonPosition(position: MLNOrnamentPosition) { + mapView.attributionButtonPosition = position + } +} + +extension String { + func deletingPrefix(_ prefix: String) -> String { + guard self.hasPrefix(prefix) else { return self } + return String(self.dropFirst(prefix.count)) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapFactory.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapFactory.swift new file mode 100644 index 0000000..a292c4f --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapFactory.swift @@ -0,0 +1,25 @@ +import Flutter + +class MapLibreMapFactory: NSObject, FlutterPlatformViewFactory { + var registrar: FlutterPluginRegistrar + + init(withRegistrar registrar: FlutterPluginRegistrar) { + self.registrar = registrar + super.init() + } + + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + return FlutterStandardMessageCodec.sharedInstance() + } + + func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, + arguments args: Any?) -> FlutterPlatformView + { + return MapLibreMapController( + withFrame: frame, + viewIdentifier: viewId, + arguments: args, + registrar: registrar + ) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapOptionsSink.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapOptionsSink.swift new file mode 100644 index 0000000..4625b12 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapOptionsSink.swift @@ -0,0 +1,23 @@ +import MapLibre + +protocol MapLibreMapOptionsSink { + func setCameraTargetBounds(bounds: MLNCoordinateBounds?) + func setCompassEnabled(compassEnabled: Bool) + func setStyleString(styleString: String) + func setMinMaxZoomPreference(min: Double?, max: Double?) + func setRotateGesturesEnabled(rotateGesturesEnabled: Bool) + func setScrollGesturesEnabled(scrollGesturesEnabled: Bool) + func setTiltGesturesEnabled(tiltGesturesEnabled: Bool) + func setTrackCameraPosition(trackCameraPosition: Bool) + func setZoomGesturesEnabled(zoomGesturesEnabled: Bool) + func setMyLocationEnabled(myLocationEnabled: Bool) + func setMyLocationTrackingMode(myLocationTrackingMode: MLNUserTrackingMode) + func setMyLocationRenderMode(myLocationRenderMode: MyLocationRenderMode) + func setLogoEnabled(logoEnabled: Bool) + func setLogoViewPosition(position: MLNOrnamentPosition) + func setLogoViewMargins(x: Double, y: Double) + func setCompassViewPosition(position: MLNOrnamentPosition) + func setCompassViewMargins(x: Double, y: Double) + func setAttributionButtonMargins(x: Double, y: Double) + func setAttributionButtonPosition(position: MLNOrnamentPosition) +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapsPlugin.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapsPlugin.swift new file mode 100644 index 0000000..802e479 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapsPlugin.swift @@ -0,0 +1,170 @@ +import Flutter +import Foundation +import MapLibre +import UIKit + +public class MapLibreMapsPlugin: NSObject, FlutterPlugin { + static var downloadOfflineRegionChannelHandler: OfflineChannelHandler? = nil + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = MapLibreMapFactory(withRegistrar: registrar) + registrar.register(instance, withId: "plugins.flutter.io/maplibre_gl") + + let channel = FlutterMethodChannel( + name: "plugins.flutter.io/maplibre_gl", + binaryMessenger: registrar.messenger() + ) + + channel.setMethodCallHandler { methodCall, result in + switch methodCall.method { + case "setHttpHeaders": + guard let arguments = methodCall.arguments as? [String: Any], + let headers = arguments["headers"] as? [String: String] + else { + result(FlutterError( + code: "setHttpHeadersError", + message: "could not decode arguments", + details: nil + )) + result(nil) + return + } + let sessionConfig = URLSessionConfiguration.default + sessionConfig.httpAdditionalHeaders = headers // your headers here + MLNNetworkConfiguration.sharedManager.sessionConfiguration = sessionConfig + result(nil) + case "installOfflineMapTiles": + guard let arguments = methodCall.arguments as? [String: String] else { return } + let tilesdb = arguments["tilesdb"] + installOfflineMapTiles(registrar: registrar, tilesdb: tilesdb!) + result(nil) + case "downloadOfflineRegion#setup": + guard let args = methodCall.arguments as? [String: Any], + let channelName = args["channelName"] as? String + else { + print( + "downloadOfflineRegion#setup unexpected arguments: \(String(describing: methodCall.arguments))" + ) + result(nil) + return + } + + downloadOfflineRegionChannelHandler = OfflineChannelHandler( + messenger: registrar.messenger(), + channelName: channelName + ) + + result(nil) + case "downloadOfflineRegion": + // Get download region arguments from caller + guard let args = methodCall.arguments as? [String: Any], + let definitionDictionary = args["definition"] as? [String: Any], + let metadata = args["metadata"] as? [String: Any], + let defintion = OfflineRegionDefinition.fromDictionary(definitionDictionary) + else { + print( + "downloadOfflineRegion unexpected arguments: \(String(describing: methodCall.arguments))" + ) + result(nil) + return + } + + if (downloadOfflineRegionChannelHandler == nil) { + result(FlutterError( + code: "downloadOfflineRegion#setup NOT CALLED", + message: "The setup has not been called, please call downloadOfflineRegion#setup before", + details: nil + )) + return + } + + OfflineManagerUtils.downloadRegion( + definition: defintion, + metadata: metadata, + result: result, + registrar: registrar, + channelHandler: downloadOfflineRegionChannelHandler! + ) + downloadOfflineRegionChannelHandler = nil; + case "setOfflineTileCountLimit": + guard let arguments = methodCall.arguments as? [String: Any], + let limit = arguments["limit"] as? UInt64 + else { + result(FlutterError( + code: "SetOfflineTileCountLimitError", + message: "could not decode arguments", + details: nil + )) + return + } + OfflineManagerUtils.setOfflineTileCountLimit(result: result, maximumCount: limit) + case "getListOfRegions": + // Note: this does not download anything from internet, it only fetches data drom database + OfflineManagerUtils.regionsList(result: result) + case "deleteOfflineRegion": + guard let args = methodCall.arguments as? [String: Any], + let id = args["id"] as? Int + else { + result(nil) + return + } + OfflineManagerUtils.deleteRegion(result: result, id: id) + default: + result(FlutterMethodNotImplemented) + } + } + } + + private static func getTilesUrl() -> URL { + guard var cachesUrl = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first, + let bundleId = Bundle.main + .object(forInfoDictionaryKey: kCFBundleIdentifierKey as String) as? String + else { + fatalError("Could not get map tiles directory") + } + cachesUrl.appendPathComponent(bundleId) + cachesUrl.appendPathComponent(".mapbox") + cachesUrl.appendPathComponent("cache.db") + return cachesUrl + } + + // Copies the "offline" tiles to where MapLibre expects them + private static func installOfflineMapTiles(registrar: FlutterPluginRegistrar, tilesdb: String) { + var tilesUrl = getTilesUrl() + let bundlePath = getTilesDbPath(registrar: registrar, tilesdb: tilesdb) + NSLog( + "Cached tiles not found, copying from bundle... \(String(describing: bundlePath)) ==> \(tilesUrl)" + ) + do { + let parentDir = tilesUrl.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: parentDir, + withIntermediateDirectories: true, + attributes: nil + ) + if FileManager.default.fileExists(atPath: tilesUrl.path) { + try FileManager.default.removeItem(atPath: tilesUrl.path) + } + try FileManager.default.copyItem(atPath: bundlePath!, toPath: tilesUrl.path) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try tilesUrl.setResourceValues(resourceValues) + } catch { + NSLog("Error copying bundled tiles: \(error)") + } + } + + private static func getTilesDbPath(registrar: FlutterPluginRegistrar, + tilesdb: String) -> String? + { + if tilesdb.starts(with: "/") { + return tilesdb + } else { + let key = registrar.lookupKey(forAsset: tilesdb) + return Bundle.main.path(forResource: key, ofType: nil) + } + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MethodCallError.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MethodCallError.swift new file mode 100644 index 0000000..d97ad1d --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MethodCallError.swift @@ -0,0 +1,98 @@ +import Flutter + +enum MethodCallError: Error { + case genericError(details: String) + case invalidLayerType(details: String) + case invalidSourceType(details: String) + case invalidExpression + case sourceNotFound(sourceId: String) + case layerNotFound(layerId: String) + case styleNotFound + case sourceAlreadyExists(sourceId: String) + case layerAlreadyExists(layerId: String) + case geojsonParseError(sourceId: String) + + var code: String { + switch self { + case .genericError: + return "genericError" + case .invalidLayerType: + return "invalidLayerType" + case .invalidSourceType: + return "invalidSourceType" + case .invalidExpression: + return "invalidExpression" + case .sourceNotFound: + return "sourceNotFound" + case .layerNotFound: + return "layerNotFound" + case .styleNotFound: + return "styleNotFound" + case .sourceAlreadyExists: + return "sourceAlreadyExists" + case .layerAlreadyExists: + return "layerAlreadyExists" + case .geojsonParseError: + return "parseError" + + } + } + + var message: String { + switch self { + case .genericError: + return "Generic error" + case .invalidLayerType: + return "Invalid layer type" + case .invalidSourceType: + return "Invalid source type" + case .invalidExpression: + return "Invalid expression" + case .sourceNotFound: + return "Source not found" + case .layerNotFound: + return "Layer not found" + case .styleNotFound: + return "Style not found" + case .sourceAlreadyExists: + return "Source already exists" + case .layerAlreadyExists: + return "Layer already exists" + case .geojsonParseError: + return "Geojson parse error" + } + } + + var details: String { + switch self { + case let .genericError(details): + return details + case let .invalidLayerType(details): + return details + case let .invalidSourceType(details): + return details + case .invalidExpression: + return "Could not parse expression." + case let .sourceNotFound(sourceId): + return "Source with id \(sourceId) not found." + case let .layerNotFound(layerId): + return "Layer with id \(layerId) not found." + case .styleNotFound: + return "Style not found." + case let .sourceAlreadyExists(sourceId): + return "Source with id \(sourceId) already exists." + case let .layerAlreadyExists(layerId): + return "Layer with id \(layerId) already exists." + case let .geojsonParseError(sourceId): + return "Geojson parse error for source with id \(sourceId)." + } + } + + var flutterError: FlutterError { + return FlutterError( + code: code, + message: message, + details: details + ) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineChannelHandler.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineChannelHandler.swift new file mode 100644 index 0000000..b5f4746 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineChannelHandler.swift @@ -0,0 +1,69 @@ +// +// OfflineChannelHandler.swift +// location +// +// Created by Patryk on 03/06/2020. +// + +import Flutter +import Foundation + +class OfflineChannelHandler: NSObject, FlutterStreamHandler { + private var sink: FlutterEventSink? + + init(messenger: FlutterBinaryMessenger, channelName: String) { + super.init() + let eventChannel = FlutterEventChannel(name: channelName, binaryMessenger: messenger) + eventChannel.setStreamHandler(self) + } + + // MARK: FlutterStreamHandler protocol compliance + + func onListen(withArguments _: Any?, + eventSink events: @escaping FlutterEventSink) -> FlutterError? + { + sink = events + return nil + } + + func onCancel(withArguments _: Any?) -> FlutterError? { + sink = nil + return nil + } + + // MARK: Util methods + + func onError(errorCode: String, errorMessage: String?, errorDetails: Any?) { + sink?(FlutterError(code: errorCode, message: errorMessage, details: errorDetails)) + } + + func onSuccess() { + let body = ["status": "success"] + guard let jsonData = try? JSONSerialization.data( + withJSONObject: body, + options: .prettyPrinted + ), + let jsonString = String(data: jsonData, encoding: .utf8) else { return } + sink?(jsonString) + } + + func onStart() { + let body = ["status": "start"] + guard let jsonData = try? JSONSerialization.data( + withJSONObject: body, + options: .prettyPrinted + ), + let jsonString = String(data: jsonData, encoding: .utf8) else { return } + sink?(jsonString) + } + + func onProgress(progress: Double) { + let body: [String: Any] = ["status": "progress", "progress": progress] + guard let jsonData = try? JSONSerialization.data( + withJSONObject: body, + options: .prettyPrinted + ), + let jsonString = String(data: jsonData, encoding: .utf8) else { return } + sink?(jsonString) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineManagerUtils.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineManagerUtils.swift new file mode 100644 index 0000000..4a725d7 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineManagerUtils.swift @@ -0,0 +1,101 @@ +// +// OfflineManagerUtils.swift +// location +// +// Created by Patryk on 02/06/2020. +// + +import Flutter +import Foundation +import MapLibre + +class OfflineManagerUtils { + static var activeDownloaders: [Int: OfflinePackDownloader] = [:] + + static func downloadRegion( + definition: OfflineRegionDefinition, + metadata: [String: Any], + result: @escaping FlutterResult, + registrar _: FlutterPluginRegistrar, + channelHandler: OfflineChannelHandler + ) { + // Prepare downloader + let downloader = OfflinePackDownloader( + result: result, + channelHandler: channelHandler, + regionDefintion: definition, + metadata: metadata + ) + + // Download region + let id = downloader.download() + // retain downloader by its generated id + activeDownloaders[id] = downloader + } + + static func regionsList(result: @escaping FlutterResult) { + let offlineStorage = MLNOfflineStorage.shared + guard let packs = offlineStorage.packs else { + result("[]") + return + } + let regionsArgs = packs.compactMap { pack in + OfflineRegion.fromOfflinePack(pack)?.toDictionary() + } + guard let regionsArgsJsonData = try? JSONSerialization.data(withJSONObject: regionsArgs), + let regionsArgsJsonString = String(data: regionsArgsJsonData, encoding: .utf8) + else { + result(FlutterError(code: "RegionListError", message: nil, details: nil)) + return + } + result(regionsArgsJsonString) + } + + static func setOfflineTileCountLimit(result: @escaping FlutterResult, maximumCount: UInt64) { + let offlineStorage = MLNOfflineStorage.shared + offlineStorage.setMaximumAllowedMapboxTiles(maximumCount) + result(nil) + } + + static func deleteRegion(result: @escaping FlutterResult, id: Int) { + let offlineStorage = MLNOfflineStorage.shared + guard let pacs = offlineStorage.packs else { return } + let packToRemove = pacs.first(where: { pack -> Bool in + let contextJsonObject = try? JSONSerialization.jsonObject(with: pack.context) + let contextJsonDict = contextJsonObject as? [String: Any] + if let regionId = contextJsonDict?["id"] as? Int { + return regionId == id + } else { + return false + } + }) + if let packToRemoveUnwrapped = packToRemove { + // deletion is only safe if the download is suspended + packToRemoveUnwrapped.suspend() + OfflineManagerUtils.releaseDownloader(id: id) + + offlineStorage.removePack(packToRemoveUnwrapped) { error in + if let error = error { + result(FlutterError( + code: "DeleteRegionError", + message: error.localizedDescription, + details: nil + )) + } else { + result(nil) + } + } + } else { + result(FlutterError( + code: "DeleteRegionError", + message: "There is no region with given id to delete", + details: nil + )) + } + } + + /// Removes downloader from cache so it's memory can be deallocated + static func releaseDownloader(id: Int) { + activeDownloaders.removeValue(forKey: id) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflinePackDownloadManager.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflinePackDownloadManager.swift new file mode 100644 index 0000000..350dd3f --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflinePackDownloadManager.swift @@ -0,0 +1,223 @@ +// +// OfflinePackDownloadManager.swift +// location +// +// Created by Patryk on 03/06/2020. +// + +import Flutter +import Foundation +import MapLibre + +class OfflinePackDownloader { + // MARK: Properties + + private let result: FlutterResult + private let channelHandler: OfflineChannelHandler + private let regionDefinition: OfflineRegionDefinition + private let metadata: [String: Any] + + /// Currently managed pack + private var pack: MLNOfflinePack? + + /// This variable is set to true when this downloader has finished downloading and called the result method. It is used to prevent + /// the result method being called multiple times + private var isCompleted = false + + // MARK: Initializers + + init( + result: @escaping FlutterResult, + channelHandler: OfflineChannelHandler, + regionDefintion: OfflineRegionDefinition, + metadata: [String: Any] + ) { + self.result = result + self.channelHandler = channelHandler + regionDefinition = regionDefintion + self.metadata = metadata + + setupNotifications() + } + + deinit { + print("Removing offline pack notification observers") + NotificationCenter.default.removeObserver(self) + } + + // MARK: Public methods + + func download() -> Int { + let storage = MLNOfflineStorage.shared + // While the Android SDK generates a region ID in createOfflineRegion, the iOS + // SDK does not have this feature. Therefore, we generate a region ID here. + let id = UUID().hashValue + let regionData = OfflineRegion(id: id, metadata: metadata, definition: regionDefinition) + let tilePyramidRegion = regionDefinition.toMLNTilePyramidOfflineRegion() + storage + .addPack(for: tilePyramidRegion, + withContext: regionData.prepareContext()) { [weak self] pack, error in + if let pack = pack { + self?.onPackCreated(pack: pack) + } else { + self?.onPackCreationError(error: error) + } + } + return id + } + + // MARK: Pack management + + private func onPackCreated(pack: MLNOfflinePack) { + if let region = OfflineRegion.fromOfflinePack(pack), + let regionData = try? JSONSerialization.data(withJSONObject: region.toDictionary()) + { + // Start downloading + self.pack = pack + pack.resume() + // Provide region with generated + result(String(data: regionData, encoding: .utf8)) + channelHandler.onStart() + } else { + onPackCreationError(error: OfflinePackError.InvalidPackData) + } + } + + private func onPackCreationError(error: Error?) { + // Reset downloading state + channelHandler.onError( + errorCode: "mapboxInvalidRegionDefinition", + errorMessage: error?.localizedDescription, + errorDetails: nil + ) + result(FlutterError( + code: "mapboxInvalidRegionDefinition", + message: error?.localizedDescription, + details: nil + )) + } + + // MARK: Progress obseration + + @objc private func onPackDownloadProgress(notification: NSNotification) { + // Verify if correct pack is checked + guard let pack = notification.object as? MLNOfflinePack, + verifyPack(pack: pack) else { return } + // Calculate progress of downloading + let packProgress = pack.progress + let downloadProgress = calculateDownloadingProgress( + requiredResourceCount: packProgress.countOfResourcesExpected, + completedResourceCount: packProgress.countOfResourcesCompleted + ) + // Check if downloading is complete + if pack.state == .complete { + print("Region downloaded successfully") + // set download state to inactive + // This can be called multiple times but result can only be called once. We use this + // check to ensure that + guard !isCompleted else { return } + isCompleted = true + channelHandler.onSuccess() + result(nil) + if let region = OfflineRegion.fromOfflinePack(pack) { + OfflineManagerUtils.releaseDownloader(id: region.id) + } + } else { + print("Region download progress \(downloadProgress)") + channelHandler.onProgress(progress: downloadProgress) + } + } + + @objc private func onPackDownloadError(notification: NSNotification) { + guard let pack = notification.object as? MLNOfflinePack, + verifyPack(pack: pack) else { return } + let error = notification.userInfo?[MLNOfflinePackUserInfoKey.error] as? NSError + print("Pack download error: \(String(describing: error?.localizedDescription))") + // set download state to inactive + isCompleted = true + channelHandler.onError( + errorCode: "Downloading error", + errorMessage: error?.localizedDescription, + errorDetails: nil + ) + result(FlutterError( + code: "Downloading error", + message: error?.localizedDescription, + details: nil + )) + if let region = OfflineRegion.fromOfflinePack(pack) { + OfflineManagerUtils.deleteRegion(result: result, id: region.id) + } + } + + @objc private func onMaximumAllowedMapboxTiles(notification: NSNotification) { + guard let pack = notification.object as? MLNOfflinePack, + verifyPack(pack: pack) else { return } + let maximumCount = (notification.userInfo?[MLNOfflinePackUserInfoKey.maximumCount] + as AnyObject).uint64Value ?? 0 + print("MapLibre tile count limit exceeded: \(maximumCount)") + // set download state to inactive + isCompleted = true + channelHandler.onError( + errorCode: "mapboxTileCountLimitExceeded", + errorMessage: "MapLibre tile count limit exceeded: \(maximumCount)", + errorDetails: nil + ) + result(FlutterError( + code: "mapboxTileCountLimitExceeded", + message: "MapLibre tile count limit exceeded: \(maximumCount)", + details: nil + )) + if let region = OfflineRegion.fromOfflinePack(pack) { + OfflineManagerUtils.deleteRegion(result: result, id: region.id) + } + } + + // MARK: Util methods + + private func setupNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(onPackDownloadProgress(notification:)), + name: NSNotification.Name.MLNOfflinePackProgressChanged, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(onPackDownloadError(notification:)), + name: NSNotification.Name.MLNOfflinePackError, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(onMaximumAllowedMapboxTiles(notification:)), + name: NSNotification.Name.MLNOfflinePackMaximumMapboxTilesReached, + object: nil + ) + } + + /// Since NotificationCenter will send notifications about all packs downloads we need to make sure we only handle packs + /// managed by this downloader. So this method checks if the pack we got from a notification is the same as the pack being + /// managed by this downloader and if it is it returns true. Otherwise it returns false + private func verifyPack(pack: MLNOfflinePack) -> Bool { + guard let currentlyManagedPack = self.pack else { + // No pack is being managed yet + return false + } + // We can tell whether 2 packs are the same by comparing metadata we assigned earlier + return pack.state != .invalid && pack.context == currentlyManagedPack.context + } + + private func calculateDownloadingProgress( + requiredResourceCount: UInt64, + completedResourceCount: UInt64 + ) -> Double { + return requiredResourceCount > 0 + ? 100.0 * Double(completedResourceCount) / Double(requiredResourceCount) + : 0.0 + } +} + +enum OfflinePackError: Error { + case InvalidPackData +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegion.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegion.swift new file mode 100644 index 0000000..1181366 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegion.swift @@ -0,0 +1,57 @@ +// +// OfflineRegionData.swift +// location +// +// Created by Patryk on 02/06/2020. +// + +import Foundation +import MapLibre + +class OfflineRegion { + let id: Int + let metadata: [String: Any] + let definition: OfflineRegionDefinition + + enum CodingKeys: CodingKey { + case id, metadata, definition + } + + init(id: Int, metadata: [String: Any], definition: OfflineRegionDefinition) { + self.id = id + self.metadata = metadata + self.definition = definition + } + + func prepareContext() -> Data { + let context = ["metadata": metadata, "id": id] as [String: Any] + let jsonData = try? JSONSerialization.data(withJSONObject: context, options: []) + return jsonData ?? Data() + } + + func toDictionary() -> [String: Any] { + return [ + "id": id, + "metadata": metadata, + "definition": definition.toDictionary(), + ] + } + + static func fromOfflinePack(_ pack: MLNOfflinePack) -> OfflineRegion? { + guard let region = pack.region as? MLNTilePyramidOfflineRegion, + let dataObject = try? JSONSerialization.jsonObject(with: pack.context, options: []), + let dict = dataObject as? [String: Any], + let id = dict["id"] as? Int, + let metadata = dict["metadata"] as? [String: Any] else { return nil } + return OfflineRegion( + id: id, + metadata: metadata, + definition: OfflineRegionDefinition( + bounds: [region.bounds.sw, region.bounds.ne].map { [$0.latitude, $0.longitude] }, + mapStyleUrl: region.styleURL, + minZoom: region.minimumZoomLevel, + maxZoom: region.maximumZoomLevel + ) + ) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegionDefinition.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegionDefinition.swift new file mode 100644 index 0000000..9e57cde --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegionDefinition.swift @@ -0,0 +1,56 @@ +import Foundation +import MapLibre + +class OfflineRegionDefinition { + let bounds: [[Double]] + let mapStyleUrl: URL + let minZoom: Double + let maxZoom: Double + + init(bounds: [[Double]], mapStyleUrl: URL, minZoom: Double, maxZoom: Double) { + self.bounds = bounds + self.mapStyleUrl = mapStyleUrl + self.minZoom = minZoom + self.maxZoom = maxZoom + } + + func getBounds() -> MLNCoordinateBounds { + return MLNCoordinateBounds( + sw: CLLocationCoordinate2D(latitude: bounds[0][0], longitude: bounds[0][1]), + ne: CLLocationCoordinate2D(latitude: bounds[1][0], longitude: bounds[1][1]) + ) + } + + static func fromDictionary(_ jsonDict: [String: Any]) -> OfflineRegionDefinition? { + guard let bounds = jsonDict["bounds"] as? [[Double]], + let mapStyleUrlString = jsonDict["mapStyleUrl"] as? String, + let mapStyleUrl = URL(string: mapStyleUrlString), + let minZoom = jsonDict["minZoom"] as? Double, + let maxZoom = jsonDict["maxZoom"] as? Double + else { return nil } + return OfflineRegionDefinition( + bounds: bounds, + mapStyleUrl: mapStyleUrl, + minZoom: minZoom, + maxZoom: maxZoom + ) + } + + func toDictionary() -> [String: Any] { + return [ + "bounds": bounds, + "mapStyleUrl": mapStyleUrl.absoluteString, + "minZoom": minZoom, + "maxZoom": maxZoom, + ] + } + + func toMLNTilePyramidOfflineRegion() -> MLNTilePyramidOfflineRegion { + return MLNTilePyramidOfflineRegion( + styleURL: mapStyleUrl, + bounds: getBounds(), + fromZoomLevel: minZoom, + toZoomLevel: maxZoom + ) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/SourcePropertyConverter.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/SourcePropertyConverter.swift new file mode 100644 index 0000000..a4c5ec9 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/SourcePropertyConverter.swift @@ -0,0 +1,241 @@ +import Foundation +import MapLibre + +class SourcePropertyConverter { + class func interpretTileOptions(properties: [String: Any]) -> [MLNTileSourceOption: Any] { + var options = [MLNTileSourceOption: Any]() + + if let bounds = properties["bounds"] as? [Double] { + options[.coordinateBounds] = + NSValue(mlnCoordinateBounds: boundsFromArray(coordinates: bounds)) + } + if let minzoom = properties["minzoom"] as? Double { + options[.minimumZoomLevel] = minzoom + } + if let maxzoom = properties["maxzoom"] as? Double { + options[.maximumZoomLevel] = maxzoom + } + if let tileSize = properties["tileSize"] as? Double { + options[.tileSize] = Int(tileSize) + } + if let scheme = properties["scheme"] as? String { + let system: MLNTileCoordinateSystem = (scheme == "tms" ? .TMS : .XYZ) + options[.tileCoordinateSystem] = system.rawValue + } + if let attribution = properties["attribution"] as? String { + options[.attributionInfos] = parseAttributionHTML(attribution) + } + return options + } + + class func parseAttributionHTML(_ html: String) -> [MLNAttributionInfo] { + // Parse HTML attribution string and create a single attributed string with clickable links + let pattern = #"]*>([^<]+)"# + + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + // Regex failed, use plain text + let title = NSAttributedString(string: html) + return [MLNAttributionInfo(title: title, url: nil)] + } + + let nsString = html as NSString + let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsString.length)) + + if matches.isEmpty { + // No links found, create simple attribution + let title = NSAttributedString(string: html) + return [MLNAttributionInfo(title: title, url: nil)] + } + + // Build a single attributed string with all text and apply link attributes + let attributedString = NSMutableAttributedString() + var lastLocation = 0 + var primaryURL: URL? + + for match in matches { + // Add text before the link + if match.range.location > lastLocation { + let plainRange = NSRange(location: lastLocation, length: match.range.location - lastLocation) + let plainText = nsString.substring(with: plainRange) + attributedString.append(NSAttributedString(string: plainText)) + } + + // Add the link text with link attribute + if match.numberOfRanges >= 3 { + let urlRange = match.range(at: 1) + let textRange = match.range(at: 2) + + let urlString = nsString.substring(with: urlRange) + let linkText = nsString.substring(with: textRange) + + if let url = URL(string: urlString) { + if primaryURL == nil { + primaryURL = url + } + let linkAttributedString = NSMutableAttributedString(string: linkText) + linkAttributedString.addAttribute(.link, value: url, range: NSRange(location: 0, length: linkText.count)) + attributedString.append(linkAttributedString) + } + } + + lastLocation = match.range.location + match.range.length + } + + // Add any remaining text after the last link + if lastLocation < nsString.length { + let remainingText = nsString.substring(from: lastLocation) + attributedString.append(NSAttributedString(string: remainingText)) + } + + return [MLNAttributionInfo(title: attributedString, url: primaryURL)] + } + + class func buildRasterTileSource(identifier: String, + properties: [String: Any]) -> MLNRasterTileSource? + { + if let rawUrl = properties["url"] as? String, let url = URL(string: rawUrl) { + return MLNRasterTileSource(identifier: identifier, configurationURL: url) + } + if let tiles = properties["tiles"] as? [String] { + let options = interpretTileOptions(properties: properties) + return MLNRasterTileSource( + identifier: identifier, + tileURLTemplates: tiles, + options: options + ) + } + return nil + } + + class func buildVectorTileSource(identifier: String, + properties: [String: Any]) -> MLNVectorTileSource? + { + if let rawUrl = properties["url"] as? String, let url = URL(string: rawUrl) { + return MLNVectorTileSource(identifier: identifier, configurationURL: url) + } + if let tiles = properties["tiles"] as? [String] { + return MLNVectorTileSource( + identifier: identifier, + tileURLTemplates: tiles, + options: interpretTileOptions(properties: properties) + ) + } + return nil + } + + class func buildRasterDemSource(identifier: String, + properties: [String: Any]) -> MLNRasterDEMSource? + { + if let rawUrl = properties["url"] as? String, let url = URL(string: rawUrl) { + return MLNRasterDEMSource(identifier: identifier, configurationURL: url) + } + if let tiles = properties["tiles"] as? [String] { + return MLNRasterDEMSource( + identifier: identifier, + tileURLTemplates: tiles, + options: interpretTileOptions(properties: properties) + ) + } + return nil + } + + class func interpretShapeOptions(properties: [String: Any]) -> [MLNShapeSourceOption: Any] { + var options = [MLNShapeSourceOption: Any]() + + if let maxzoom = properties["maxzoom"] as? Double { + options[.maximumZoomLevel] = maxzoom + } + + if let buffer = properties["buffer"] as? Double { + options[.buffer] = buffer + } + if let tolerance = properties["tolerance"] as? Double { + options[.simplificationTolerance] = tolerance + } + + if let cluster = properties["cluster"] as? Bool { + options[.clustered] = cluster + } + if let clusterRadius = properties["clusterRadius"] as? Double { + options[.clusterRadius] = clusterRadius + } + if let clusterMaxZoom = properties["clusterMaxZoom"] as? Double { + options[.maximumZoomLevelForClustering] = clusterMaxZoom + } + + // TODO: clusterProperties not implemneted for IOS + + if let lineMetrics = properties["lineMetrics"] as? Bool { + options[.lineDistanceMetrics] = lineMetrics + } + return options + } + + class func buildShapeSource(identifier: String, properties: [String: Any]) -> MLNShapeSource? { + let options = interpretShapeOptions(properties: properties) + if let data = properties["data"] as? String, let url = URL(string: data) { + return MLNShapeSource(identifier: identifier, url: url, options: options) + } + if let data = properties["data"] { + do { + let geoJsonData = try JSONSerialization.data(withJSONObject: data) + let shape = try MLNShape(data: geoJsonData, encoding: String.Encoding.utf8.rawValue) + return MLNShapeSource(identifier: identifier, shape: shape, options: options) + } catch {} + } + return nil + } + + class func buildImageSource(identifier: String, properties: [String: Any]) -> MLNImageSource? { + if let rawUrl = properties["url"] as? String, let url = URL(string: rawUrl), + let coordinates = properties["coordinates"] as? [[Double]] + { + return MLNImageSource( + identifier: identifier, + coordinateQuad: quadFromArray(coordinates: coordinates), + url: url + ) + } + return nil + } + + class func addShapeProperties(properties: [String: Any], source: MLNShapeSource) { + do { + if let data = properties["data"] as? String { + let parsed = try MLNShape( + data: data.data(using: .utf8)!, + encoding: String.Encoding.utf8.rawValue + ) + source.shape = parsed + } + } catch {} + } + + class func quadFromArray(coordinates: [[Double]]) -> MLNCoordinateQuad { + return MLNCoordinateQuad( + topLeft: CLLocationCoordinate2D( + latitude: coordinates[0][1], + longitude: coordinates[0][0] + ), + bottomLeft: CLLocationCoordinate2D( + latitude: coordinates[3][1], + longitude: coordinates[3][0] + ), + bottomRight: CLLocationCoordinate2D( + latitude: coordinates[2][1], + longitude: coordinates[2][0] + ), + topRight: CLLocationCoordinate2D( + latitude: coordinates[1][1], + longitude: coordinates[1][0] + ) + ) + } + + class func boundsFromArray(coordinates: [Double]) -> MLNCoordinateBounds { + return MLNCoordinateBounds( + sw: CLLocationCoordinate2D(latitude: coordinates[1], longitude: coordinates[0]), + ne: CLLocationCoordinate2D(latitude: coordinates[3], longitude: coordinates[2]) + ) + } +} diff --git a/third_party/maplibre_gl/lib/maplibre_gl.dart b/third_party/maplibre_gl/lib/maplibre_gl.dart new file mode 100644 index 0000000..f7e9304 --- /dev/null +++ b/third_party/maplibre_gl/lib/maplibre_gl.dart @@ -0,0 +1,109 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// This library contains the MapLibre GL plugin for Flutter. +/// +/// To display a map, add a [MapLibreMap] widget to the widget tree. +/// +/// In this plugin, the map is configured through the parameters passed to the [MapLibreMap] constructor and through the [MapLibreMapController]. +/// The [MapLibreMapController] is provided at runtime by the [MapLibreMap.onMapCreated] callback. +/// The controller also allows adding annotations (icons, lines etc.) to the map at runtime and provides some callbacks to get notified when the user clicks those. +/// +/// The visual appearance of the map is configured through a MapLibre style passed to the +/// [styleString] parameter of the [MapLibreMap] constructor. +/// The MapLibre style is a JSON document following the specification at https://maplibre.org/maplibre-style-spec/. +/// The following is supposed to serve as a short introduction to the MapLibre style specification: +/// The style document contains (among other things) sources and layers. +/// Sources determine which data is displayed on the map, layers determine how the data is displayed. +/// +/// Typical types of sources are raster and vector tiles, as well as GeoJson data. +/// For raster and vector tiles, the entire world is divided into a set of tiles in different zoom levels. +/// Depending on the map's zoom level and viewport, the MapLibre client libraries decide, which tiles are needed to fill the viewport and request them from the source. +/// +/// The difference between raster and vector tiles is that raster tiles are images that are pre-rendered on a server, whereas vector tiles contain raw geometric information that is rendered on the client. +/// Vector tiles are in the Mapbox Vector Tile (MVT) format, the de-facto standard for vector tiles that is implemented by multiple libraries. +/// +/// Vector tiles have a number of advantages over raster tiles, including (often) smaller size, +/// the possibility to style them dynamically at runtime (e.g. change the color or visibility of certain features), +/// and the possibility to rotate them and keep text labels horizontal. +/// Raster and vector tiles can be generated from a variety of sources, including OpenStreetMap data and are also available from a number of providers. +/// +/// Raster sources are displayed by adding a "raster" layer to the MapLibre GL style. +/// Vector and GeoJson sources are displayed by adding a "line", "fill", "symbol" or "circle" layer to the MapLibre GL style and specifying +/// which source to use (by setting the "source" property of the layer to the id of the source) as well as how to style the data by setting other properties of the layer such as "line-color" or "fill-outline-color". +/// For example, a vector source layer (or a GeoJson source layer) with the outlines of countries could be displayed both by a fill layer to fill the countries with a color and by a line layer to draw the outlines of the countries. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:maplibre_gl_platform_interface/maplibre_gl_platform_interface.dart'; + +export 'package:maplibre_gl_platform_interface/maplibre_gl_platform_interface.dart' + show + Annotation, + ArgumentCallbacks, + AttributionButtonPosition, + CameraPosition, + CameraTargetBounds, + CameraUpdate, + Circle, + CircleOptions, + CompassViewPosition, + Fill, + FillOptions, + GeojsonSourceProperties, + ImageSourceProperties, + LatLng, + LatLngBounds, + LatLngQuad, + Line, + LineOptions, + LocationEngineAndroidProperties, + LocationEnginePlatforms, + LocationPriority, + LogoViewPosition, + MapLibreMethodChannel, + MapLibrePlatform, + MinMaxZoomPreference, + MyLocationRenderMode, + MyLocationTrackingMode, + OnPlatformViewCreatedCallback, + RasterDemSourceProperties, + RasterSourceProperties, + SourceProperties, + Symbol, + SymbolOptions, + UserHeading, + UserLocation, + VectorSourceProperties, + VideoSourceProperties; + +part 'src/controller.dart'; + +part 'src/maplibre_map.dart'; + +part 'src/global.dart'; + +part 'src/offline_region.dart'; + +part 'src/download_region_status.dart'; + +part 'src/layer_expressions.dart'; + +part 'src/layer_properties.dart'; + +part 'src/color_tools.dart'; + +part 'src/annotation_manager.dart'; + +part 'src/util.dart'; + +part 'src/maplibre_styles.dart'; diff --git a/third_party/maplibre_gl/lib/src/annotation_manager.dart b/third_party/maplibre_gl/lib/src/annotation_manager.dart new file mode 100644 index 0000000..8ab4687 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/annotation_manager.dart @@ -0,0 +1,376 @@ +part of '../maplibre_gl.dart'; + +/// Manages a homogeneous set of [Annotation]s (e.g. symbols, lines, fills) by +/// owning their backing style source(s)/layer(s) and performing efficient +/// batched updates. +/// +/// The [initialize] method must be called before [AnnotationManager] instance +/// can be used. Once [AnnotationManager] is initialized, the [isInitialized] +/// getter will return true. +/// +/// An [AnnotationManager] keeps an internal mapping from annotation id to its +/// model object and mirrors the collection into one or more GeoJSON sources; +/// each source is bound to a style layer whose visual properties come from +/// [allLayerProperties]. +/// +/// When [enableInteraction] is true, drag events are listened to and the +/// underlying annotation is translated & re-set. +abstract class AnnotationManager { + final MapLibreMapController controller; + + bool _isInitializing = false; + bool _isInitialized = false; + final _idToAnnotation = {}; + final _idToLayerIndex = {}; + + /// Base identifier of the manager. Use [layerIds] for concrete layer ids. + final String id; + + /// Tracks whether the manager and its layers were initialized. + bool get isInitialized => _isInitialized; + + List get layerIds => + [for (int i = 0; i < allLayerProperties.length; i++) _makeLayerId(i)]; + + /// If false, the manager disables user interaction (e.g. dragging) for + /// its annotations. + final bool enableInteraction; + + /// Layer property definitions (one per backing style layer). Override in + /// subclasses to specify visual styling for data-driven attributes. + List get allLayerProperties; + + /// Optional function used to select which layer/source a given annotation + /// should live in (e.g. pattern vs non-pattern lines). If null, a single + /// layer/source is used. + final int Function(T)? selectLayer; + + /// Returns the annotation with the given [id], or null if not found. + T? byId(String id) => _idToAnnotation[id]; + + /// Current set of managed annotations. + Set get annotations => _idToAnnotation.values.toSet(); + + AnnotationManager( + this.controller, { + this.selectLayer, + required this.enableInteraction, + }) : id = getRandomString(); + + @mustCallSuper + Future initialize() async { + if (_isInitializing || _isInitialized) { + return; + } + + // Mark initialization process start, so that it cannot be entered again + _isInitializing = true; + + try { + for (var i = 0; i < allLayerProperties.length; i++) { + final layerId = _makeLayerId(i); + + await controller.addGeoJsonSource( + layerId, + buildFeatureCollection([]), + promoteId: "id", + ); + await controller.addLayer( + layerId, + layerId, + allLayerProperties[i], + enableInteraction: enableInteraction, + ); + } + + controller.onFeatureDrag.add(_onDrag); + + // Mark as initialized + _isInitialized = true; + } finally { + // Mark initialization process end + _isInitializing = false; + } + } + + /// Rebuilds all backing style layers (e.g. after overlap settings changed). + Future _rebuildLayers() async { + for (var i = 0; i < allLayerProperties.length; i++) { + final layerId = _makeLayerId(i); + await controller.removeLayer(layerId); + await controller.addLayer(layerId, layerId, allLayerProperties[i], + enableInteraction: enableInteraction); + } + } + + String _makeLayerId(int layerIndex) => "${id}_$layerIndex"; + + Future _setAll() async { + if (selectLayer != null) { + final featureBuckets = [for (final _ in allLayerProperties) []]; + + for (final annotation in _idToAnnotation.values) { + final layerIndex = selectLayer!(annotation); + _idToLayerIndex[annotation.id] = layerIndex; + featureBuckets[layerIndex].add(annotation); + } + + for (var i = 0; i < featureBuckets.length; i++) { + await controller.setGeoJsonSource( + _makeLayerId(i), + buildFeatureCollection( + [for (final l in featureBuckets[i]) l.toGeoJson()])); + } + } else { + await controller.setGeoJsonSource( + _makeLayerId(0), + buildFeatureCollection( + [for (final l in _idToAnnotation.values) l.toGeoJson()])); + } + } + + /// Adds multiple annotations (faster than adding one-by-one). + Future addAll(Iterable annotations) async { + for (final a in annotations) { + _idToAnnotation[a.id] = a; + } + await _setAll(); + } + + /// Adds a single annotation. + Future add(T annotation) async { + _idToAnnotation[annotation.id] = annotation; + await _setAll(); + } + + /// Removes multiple annotations. + Future removeAll(Iterable annotations) async { + for (final a in annotations) { + _idToAnnotation.remove(a.id); + } + await _setAll(); + } + + /// Removes a single annotation. + Future remove(T annotation) async { + _idToAnnotation.remove(annotation.id); + await _setAll(); + } + + /// Removes all annotations. + Future clear() async { + _idToAnnotation.clear(); + await _setAll(); + } + + /// Fully dispose resources (layers & sources). Manager is unusable after. + Future dispose() async { + _idToAnnotation.clear(); + await _setAll(); + for (var i = 0; i < allLayerProperties.length; i++) { + await controller.removeLayer(_makeLayerId(i)); + await controller.removeSource(_makeLayerId(i)); + } + } + + Future _onDrag( + Point point, + LatLng origin, + LatLng current, + LatLng delta, + String id, + Annotation? annotation, + DragEventType eventType, + ) async { + if (annotation is T) { + annotation.translate(delta); + await set(annotation); + } + } + + /// Updates (re-sets) an existing annotation quickly by only replacing its + /// underlying GeoJSON feature if it remains on the same logical layer. + Future set(T annotation) async { + assert(_idToAnnotation.containsKey(annotation.id), + "you can only set existing annotations"); + _idToAnnotation[annotation.id] = annotation; + final oldLayerIndex = _idToLayerIndex[annotation.id]; + final layerIndex = selectLayer != null ? selectLayer!(annotation) : 0; + if (oldLayerIndex != layerIndex) { + // Layer changed; must rewrite all sources. + await _setAll(); + } else { + await controller.setGeoJsonFeature( + _makeLayerId(layerIndex), annotation.toGeoJson()); + } + } +} + +class LineManager extends AnnotationManager { + LineManager( + super.controller, { + super.enableInteraction = true, + }) : super( + selectLayer: (Line line) => line.options.linePattern == null ? 0 : 1, + ); + + static const _baseProperties = LineLayerProperties( + lineJoin: [Expressions.get, 'lineJoin'], + lineOpacity: [Expressions.get, 'lineOpacity'], + lineColor: [Expressions.get, 'lineColor'], + lineWidth: [Expressions.get, 'lineWidth'], + lineGapWidth: [Expressions.get, 'lineGapWidth'], + lineOffset: [Expressions.get, 'lineOffset'], + lineBlur: [Expressions.get, 'lineBlur'], + ); + + @override + List get allLayerProperties => [ + _baseProperties, + _baseProperties.copyWith(const LineLayerProperties( + linePattern: [Expressions.get, 'linePattern'])), + ]; +} + +class FillManager extends AnnotationManager { + FillManager( + super.controller, { + super.enableInteraction = true, + }) : super( + selectLayer: (Fill fill) => fill.options.fillPattern == null ? 0 : 1, + ); + + @override + List get allLayerProperties => const [ + FillLayerProperties( + fillOpacity: [Expressions.get, 'fillOpacity'], + fillColor: [Expressions.get, 'fillColor'], + fillOutlineColor: [Expressions.get, 'fillOutlineColor'], + ), + FillLayerProperties( + fillOpacity: [Expressions.get, 'fillOpacity'], + fillColor: [Expressions.get, 'fillColor'], + fillOutlineColor: [Expressions.get, 'fillOutlineColor'], + fillPattern: [Expressions.get, 'fillPattern'], + ) + ]; +} + +class CircleManager extends AnnotationManager { + CircleManager( + super.controller, { + super.enableInteraction = true, + }); + + @override + List get allLayerProperties => const [ + CircleLayerProperties( + circleRadius: [Expressions.get, 'circleRadius'], + circleColor: [Expressions.get, 'circleColor'], + circleBlur: [Expressions.get, 'circleBlur'], + circleOpacity: [Expressions.get, 'circleOpacity'], + circleStrokeWidth: [Expressions.get, 'circleStrokeWidth'], + circleStrokeColor: [Expressions.get, 'circleStrokeColor'], + circleStrokeOpacity: [Expressions.get, 'circleStrokeOpacity'], + ) + ]; +} + +class SymbolManager extends AnnotationManager { + SymbolManager( + super.controller, { + bool iconAllowOverlap = false, + bool textAllowOverlap = false, + bool iconIgnorePlacement = false, + bool textIgnorePlacement = false, + super.enableInteraction = true, + }) : _iconAllowOverlap = iconAllowOverlap, + _textAllowOverlap = textAllowOverlap, + _iconIgnorePlacement = iconIgnorePlacement, + _textIgnorePlacement = textIgnorePlacement; + + bool _iconAllowOverlap; + bool _textAllowOverlap; + bool _iconIgnorePlacement; + bool _textIgnorePlacement; + + /// If true, the icon will be visible even if it collides with other previously drawn symbols. + Future setIconAllowOverlap(bool value) async { + if (value == _iconAllowOverlap) return; + + _iconAllowOverlap = value; + await _rebuildLayers(); + } + + /// If true, other symbols can be visible even if they collide with the icon. + Future setTextAllowOverlap(bool value) async { + if (value == _textAllowOverlap) return; + + _textAllowOverlap = value; + await _rebuildLayers(); + } + + /// If true, the text will be visible even if it collides with other previously drawn symbols. + Future setIconIgnorePlacement(bool value) async { + if (value == _iconIgnorePlacement) return; + + _iconIgnorePlacement = value; + await _rebuildLayers(); + } + + /// If true, other symbols can be visible even if they collide with the text. + Future setTextIgnorePlacement(bool value) async { + if (value == _textIgnorePlacement) return; + + _textIgnorePlacement = value; + await _rebuildLayers(); + } + + @override + List get allLayerProperties => [ + SymbolLayerProperties( + iconSize: [Expressions.get, 'iconSize'], + iconImage: [Expressions.get, 'iconImage'], + iconRotate: [Expressions.get, 'iconRotate'], + iconOffset: [Expressions.get, 'iconOffset'], + iconAnchor: [Expressions.get, 'iconAnchor'], + iconOpacity: [Expressions.get, 'iconOpacity'], + iconColor: [Expressions.get, 'iconColor'], + iconHaloColor: [Expressions.get, 'iconHaloColor'], + iconHaloWidth: [Expressions.get, 'iconHaloWidth'], + iconHaloBlur: [Expressions.get, 'iconHaloBlur'], + // note that web does not support setting this in a fully data driven + // way this is a upstream issue + textFont: kIsWeb + ? null + : [ + Expressions.caseExpression, + [Expressions.has, 'fontNames'], + [Expressions.get, 'fontNames'], + [ + Expressions.literal, + ["Open Sans Regular", "Arial Unicode MS Regular"] + ], + ], + textField: [Expressions.get, 'textField'], + textSize: [Expressions.get, 'textSize'], + textMaxWidth: [Expressions.get, 'textMaxWidth'], + textLetterSpacing: [Expressions.get, 'textLetterSpacing'], + textJustify: [Expressions.get, 'textJustify'], + textAnchor: [Expressions.get, 'textAnchor'], + textRotate: [Expressions.get, 'textRotate'], + textTransform: [Expressions.get, 'textTransform'], + textOffset: [Expressions.get, 'textOffset'], + textOpacity: [Expressions.get, 'textOpacity'], + textColor: [Expressions.get, 'textColor'], + textHaloColor: [Expressions.get, 'textHaloColor'], + textHaloWidth: [Expressions.get, 'textHaloWidth'], + textHaloBlur: [Expressions.get, 'textHaloBlur'], + symbolSortKey: [Expressions.get, 'zIndex'], + iconAllowOverlap: _iconAllowOverlap, + iconIgnorePlacement: _iconIgnorePlacement, + textAllowOverlap: _textAllowOverlap, + textIgnorePlacement: _textIgnorePlacement, + ) + ]; +} diff --git a/third_party/maplibre_gl/lib/src/color_tools.dart b/third_party/maplibre_gl/lib/src/color_tools.dart new file mode 100644 index 0000000..026302c --- /dev/null +++ b/third_party/maplibre_gl/lib/src/color_tools.dart @@ -0,0 +1,12 @@ +// ignore_for_file: deprecated_member_use --- IGNORE --- + +part of '../maplibre_gl.dart'; + +extension MapLibreColorConversion on Color { + String toHexStringRGB() { + final r = red.toRadixString(16).padLeft(2, '0'); + final g = green.toRadixString(16).padLeft(2, '0'); + final b = blue.toRadixString(16).padLeft(2, '0'); + return '#$r$g$b'; + } +} diff --git a/third_party/maplibre_gl/lib/src/controller.dart b/third_party/maplibre_gl/lib/src/controller.dart new file mode 100644 index 0000000..f511b3c --- /dev/null +++ b/third_party/maplibre_gl/lib/src/controller.dart @@ -0,0 +1,1721 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of '../maplibre_gl.dart'; + +typedef OnMapClickCallback = void Function( + Point point, LatLng coordinates); + +// New generalized feature interaction callback that always provides a raw feature id. +// If the feature also corresponds to a managed annotation, the annotation parameter +// is non-null; otherwise it is null (e.g. raw style layer feature not managed by an AnnotationManager). +typedef OnFeatureInteractionCallback = void Function( + Point point, + LatLng coordinates, + String id, + String layerId, + Annotation? annotation, +); + +typedef OnFeatureDragCallback = void Function( + Point point, + LatLng origin, + LatLng current, + LatLng delta, + String id, + Annotation? annotation, + DragEventType eventType, +); + +typedef OnFeatureHoverCallback = void Function( + Point point, + LatLng coordinates, + String id, + Annotation? annotation, + HoverEventType eventType, +); + +typedef OnMapLongClickCallback = void Function( + Point point, LatLng coordinates); + +typedef OnStyleLoadedCallback = void Function(); + +typedef OnUserLocationUpdated = void Function(UserLocation location); + +typedef OnCameraTrackingDismissedCallback = void Function(); +typedef OnCameraTrackingChangedCallback = void Function( + MyLocationTrackingMode mode); + +typedef OnCameraMoveCallback = void Function(CameraPosition cameraPosition); + +typedef OnCameraIdleCallback = void Function(); + +typedef OnMapIdleCallback = void Function(); + +@Deprecated('MaplibreMapController was renamed to MapLibreMapController.') +typedef MaplibreMapController = MapLibreMapController; + +/// Controller for a single [MapLibreMap] instance running on the host platform. +/// +/// Some of its methods can only be called after the [onStyleLoaded] callback has been invoked. +/// +/// To add annotations ([Circle]s, [Line]s, [Symbol]s and [Fill]s) on the map, there are two ways: +/// +/// 1. *Simple way to add annotations*: Use the corresponding add* methods ([addCircle], [addLine], [addSymbol] and [addFill]) on the MapLibreMapController to add one annotation at a time to the map. +/// There are also corresponding [addCircles], [addLines] etc. methods which work the same but add multiple annotations at a time. +/// +/// (If you are interested how this works: under the hood, this uses AnnotationManagers to manage the annotations. +/// An annotation manager performs the steps from the advanced way, but hides the complexity from the developer. +/// E.g. the [addCircle] method uses the [CircleManager], which in turn adds a GeoJson source to the map's style with the circle's locations as features. +/// The CircleManager also adds a circle style layer to the map's style that references that GeoJson source, therefore rendering all circles added with [addCircle] on the map.) +/// +/// There are also corresponding clear* methods like [clearCircles] to remove all circles from the map, which had been added with [addCircle] or [addCircles]. +/// +/// There are also properties like [circles] to get the current set of circles on the map, which had been added with [addCircle] or [addCircles]. +/// +/// Click events on annotations that are added this way (with the [addCircle], [addLine] etc. methods) can be received by adding callbacks to [onCircleTapped], [onLineTapped] etc. +/// +/// Note: [circles], [clearCircles] and [onCircleTapped] only work for circles added with [addCircle] or [addCircles], +/// not for circles that are already contained in the map's style when the map is loaded or are added to that map's style with the methods from the advanced way (see below). +/// The same of course applies for fills, lines and symbols. +/// +/// 2. *Advanced way to add annotations*: Modify the underlying MapLibre Style of the map to add a new data source (e.g. with the [addSource] method or the more specific methods like [addGeoJsonSource]) +/// and add a new layer to display the data of that source on the map (either with the [addLayer] method or with the more specific methods like [addCircleLayer], [addLineLayer] etc.). +/// For more information about MapLibre Styles, see the documentation of [maplibre_gl] as well as the specification at [https://maplibre.org/maplibre-style-spec/]. +/// +/// A MapLibreMapController is also a [ChangeNotifier]. Subscribers (change listeners) are notified upon changes to any of +/// +/// * the configuration options of the [MapLibreMap] widget +/// * the [symbols], [lines], [circles] or [fills] properties +/// (i.e. the collection of [Symbol]s, [Line]s, [Circle]s and [Fill]s added to this map via the "simple way" (see above)) +/// * the [isCameraMoving] property +/// * the [cameraPosition] property +/// +/// Listeners are notified after changes have been applied on the platform side. +class MapLibreMapController extends ChangeNotifier { + MapLibreMapController({ + required MapLibrePlatform maplibrePlatform, + required CameraPosition initialCameraPosition, + required Iterable annotationOrder, + required Iterable annotationConsumeTapEvents, + this.onStyleLoadedCallback, + this.onMapClick, + this.onMapLongClick, + this.onCameraTrackingDismissed, + this.onCameraTrackingChanged, + this.onMapIdle, + this.onUserLocationUpdated, + this.onCameraIdle, + this.onCameraMove, + }) : _maplibrePlatform = maplibrePlatform { + _cameraPosition = initialCameraPosition; + + _maplibrePlatform.onFeatureTappedPlatform.add((payload) { + final id = payload["id"].toString(); + final layerId = payload["layerId"]; + final point = payload["point"]; + final latLng = payload["latLng"]; + final annotation = getAnnotationById(id); + + // Call all generic feature tapped callbacks + for (final fun in List.of(onFeatureTapped)) { + // New signature supplies id and (possibly null) annotation + fun(point, latLng, id, layerId, annotation); + } + + // If we have a managed annotation, call specific annotation callbacks (onSymbolTapped, onLineTapped...) + if (annotation != null) { + ArgumentCallbacks? annotationTappedCallbacks; + if (annotation is Line) { + annotationTappedCallbacks = onLineTapped; + } else if (annotation is Symbol) { + annotationTappedCallbacks = onSymbolTapped; + } else if (annotation is Fill) { + annotationTappedCallbacks = onFillTapped; + } else if (annotation is Circle) { + annotationTappedCallbacks = onCircleTapped; + } + annotationTappedCallbacks?.call(annotation); + } + }); + + _maplibrePlatform.onFeatureDraggedPlatform.add((payload) { + final id = payload["id"]; + final annotation = getAnnotationById(id); + final enmDragEventType = DragEventType.values + .firstWhere((element) => element.name == payload["eventType"]); + for (final fun in List.of(onFeatureDrag)) { + fun( + payload["point"], + payload["origin"], + payload["current"], + payload["delta"], + id, + annotation, + enmDragEventType, + ); + } + }); + + _maplibrePlatform.onFeatureHoverPlatform.add((payload) { + final id = payload["id"]; + final annotation = getAnnotationById(id); + final hoverEventType = HoverEventType.values + .firstWhere((e) => e.name == payload["eventType"]); + for (final fun in List.of(onFeatureHover)) { + fun( + payload["point"], + payload["latLng"], + id, + annotation, + hoverEventType, + ); + } + }); + + _maplibrePlatform.onCameraMoveStartedPlatform.add((_) { + _isCameraMoving = true; + if (!isDisposed) notifyListeners(); + }); + + _maplibrePlatform.onCameraMovePlatform.add((cameraPosition) { + _cameraPosition = cameraPosition; + onCameraMove?.call(cameraPosition); + if (!isDisposed) notifyListeners(); + }); + + _maplibrePlatform.onCameraIdlePlatform.add((cameraPosition) { + _isCameraMoving = false; + if (cameraPosition != null) { + _cameraPosition = cameraPosition; + } + onCameraIdle?.call(); + if (!isDisposed) notifyListeners(); + }); + + _maplibrePlatform.onMapStyleLoadedPlatform.add((_) async { + final interactionEnabled = annotationConsumeTapEvents.toSet(); + for (final type in annotationOrder.toSet()) { + final enableInteraction = interactionEnabled.contains(type); + switch (type) { + case AnnotationType.fill: + fillManager = FillManager( + this, + enableInteraction: enableInteraction, + ); + await fillManager!.initialize(); + case AnnotationType.line: + lineManager = LineManager( + this, + enableInteraction: enableInteraction, + ); + await lineManager!.initialize(); + case AnnotationType.circle: + circleManager = CircleManager( + this, + enableInteraction: enableInteraction, + ); + await circleManager!.initialize(); + case AnnotationType.symbol: + symbolManager = SymbolManager( + this, + enableInteraction: enableInteraction, + ); + await symbolManager!.initialize(); + } + } + onStyleLoadedCallback?.call(); + }); + + _maplibrePlatform.onMapClickPlatform.add((dict) { + onMapClick?.call(dict['point'], dict['latLng']); + }); + + _maplibrePlatform.onMapLongClickPlatform.add((dict) { + onMapLongClick?.call(dict['point'], dict['latLng']); + }); + + _maplibrePlatform.onCameraTrackingChangedPlatform.add((mode) { + onCameraTrackingChanged?.call(mode); + }); + + _maplibrePlatform.onCameraTrackingDismissedPlatform.add((_) { + onCameraTrackingDismissed?.call(); + }); + + _maplibrePlatform.onMapIdlePlatform.add((_) { + onMapIdle?.call(); + }); + _maplibrePlatform.onUserLocationUpdatedPlatform.add((location) { + onUserLocationUpdated?.call(location); + }); + } + + Annotation? getAnnotationById(dynamic id) { + if (id == null) return null; + + final formattedId = id.toString(); + return fillManager?.byId(formattedId) ?? + lineManager?.byId(formattedId) ?? + symbolManager?.byId(formattedId) ?? + circleManager?.byId(formattedId); + } + + FillManager? fillManager; + LineManager? lineManager; + CircleManager? circleManager; + SymbolManager? symbolManager; + + final OnStyleLoadedCallback? onStyleLoadedCallback; + final OnMapClickCallback? onMapClick; + final OnMapLongClickCallback? onMapLongClick; + + final OnUserLocationUpdated? onUserLocationUpdated; + + final OnCameraTrackingDismissedCallback? onCameraTrackingDismissed; + final OnCameraTrackingChangedCallback? onCameraTrackingChanged; + + final OnCameraMoveCallback? onCameraMove; + final OnCameraIdleCallback? onCameraIdle; + + final OnMapIdleCallback? onMapIdle; + + /// Callbacks to receive tap events for symbols placed on this map. + final ArgumentCallbacks onSymbolTapped = ArgumentCallbacks(); + + /// Callbacks to receive tap events for circles placed on this map. + final ArgumentCallbacks onCircleTapped = ArgumentCallbacks(); + + /// Callbacks to receive tap events for fills placed on this map. + final ArgumentCallbacks onFillTapped = ArgumentCallbacks(); + + /// Callbacks to receive tap events for lines placed on this map. + final ArgumentCallbacks onLineTapped = ArgumentCallbacks(); + + /// Callbacks to receive tap events for features (geojson layer) placed on this map. + final onFeatureTapped = []; + + /// Callbacks to receive drag events for features (geojson layer) placed on this map. + final onFeatureDrag = []; + + /// Callbacks to receive mouse events(enter,move,leave) on web for features (geojson layer) placed on this map. + final onFeatureHover = []; + + /// Callbacks to receive tap events for info windows on symbols + @Deprecated("InfoWindow tapped is no longer supported") + final ArgumentCallbacks onInfoWindowTapped = + ArgumentCallbacks(); + + /// The current set of symbols on this map added with the [addSymbol] or [addSymbols] methods. + /// + /// The returned set will be a detached snapshot of the symbols collection. + Set get symbols => symbolManager?.annotations ?? {}; + + /// The current set of lines on this map added with the [addLine] or [addLines] methods. + /// + /// The returned set will be a detached snapshot of the lines collection. + Set get lines => lineManager?.annotations ?? {}; + + /// The current set of circles on this map added with the [addCircle] or [addCircles] methods. + /// + /// The returned set will be a detached snapshot of the circles collection. + Set get circles => circleManager?.annotations ?? {}; + + /// The current set of fills on this map added with the [addFill] or [addFills] methods. + /// + /// The returned set will be a detached snapshot of the fills collection. + Set get fills => fillManager?.annotations ?? {}; + + /// True if the map camera is currently moving. + bool get isCameraMoving => _isCameraMoving; + bool _isCameraMoving = false; + + /// Returns the most recent camera position reported by the platform side. + /// Will be null, if [MapLibreMap.trackCameraPosition] is false. + CameraPosition? get cameraPosition => _cameraPosition; + CameraPosition? _cameraPosition; + + final MapLibrePlatform _maplibrePlatform; + + /// Tracks whether the controller has already been disposed + bool _isDisposed = false; + + /// Return whether the controller has already been disposed. + bool get isDisposed => _isDisposed; + + /// Updates configuration options of the map user interface. + /// + /// Change listeners are notified once the update has been made on the + /// platform side. + /// + /// The returned [Future] completes after listeners have been notified. + Future _updateMapOptions(Map optionsUpdate) async { + _cameraPosition = await _maplibrePlatform.updateMapOptions(optionsUpdate); + if (!isDisposed) notifyListeners(); + } + + /// Triggers a resize event for the map on web (ignored on Android or iOS). + /// + /// Checks first if a resize is required or if it looks like it is already correctly resized. + /// If it looks good, the resize call will be skipped. + /// + /// To force resize map (without any checks) have a look at forceResizeWebMap() + void resizeWebMap() { + _maplibrePlatform.resizeWebMap(); + } + + /// Triggers a hard map resize event on web and does not check if it is required or not. + void forceResizeWebMap() { + _maplibrePlatform.forceResizeWebMap(); + } + + /// Starts an animated change of the map camera position. + /// + /// [duration] is the amount of time, that the transition animation should take. + /// + /// The returned [Future] completes after the change has been started on the + /// platform side. + /// It returns true if the camera was successfully moved and false if the movement was canceled. + /// Note: this currently always returns immediately with a value of null on iOS + Future animateCamera(CameraUpdate cameraUpdate, + {Duration? duration}) async { + return _maplibrePlatform.animateCamera(cameraUpdate, duration: duration); + } + + /// Instantaneously re-position the camera. + /// Note: moveCamera() quickly moves the camera, which can be visually jarring for a user. Strongly consider using the animateCamera() methods instead because it's less abrupt. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// It returns true if the camera was successfully moved and false if the movement was canceled. + /// Note: this currently always returns immediately with a value of null on iOS + Future moveCamera(CameraUpdate cameraUpdate) async { + return _maplibrePlatform.moveCamera(cameraUpdate); + } + + /// Adds a new geojson source + /// + /// The json in [geojson] has to comply with the schema for FeatureCollection + /// as specified in https://datatracker.ietf.org/doc/html/rfc7946#section-3.3 + /// + /// [promoteId] can be used on web to promote an id from properties to be the + /// id of the feature. This is useful because by default maplibre-gl-js does not + /// support string ids + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future addGeoJsonSource(String sourceId, Map geojson, + {String? promoteId}) async { + await _maplibrePlatform.addGeoJsonSource(sourceId, geojson, + promoteId: promoteId); + } + + /// Sets new geojson data to and existing source + /// + /// This only works as exected if the source has been created with + /// [addGeoJsonSource] before. This is very useful if you want to update and + /// existing source with modified data. + /// + /// The json in [geojson] has to comply with the schema for FeatureCollection + /// as specified in https://datatracker.ietf.org/doc/html/rfc7946#section-3.3 + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setGeoJsonSource( + String sourceId, Map geojson) async { + await _maplibrePlatform.setGeoJsonSource(sourceId, geojson); + } + + /// Sets new geojson data to and existing source + /// + /// This only works as exected if the source has been created with + /// [addGeoJsonSource] before. This is very useful if you want to update and + /// existing source with modified data. + /// + /// The json in [geojson] has to comply with the schema for FeatureCollection + /// as specified in https://datatracker.ietf.org/doc/html/rfc7946#section-3.3 + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setGeoJsonFeature( + String sourceId, Map geojsonFeature) async { + await _maplibrePlatform.setFeatureForGeoJsonSource( + sourceId, geojsonFeature); + } + + /// Add a symbol layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addSymbolLayer( + String sourceId, String layerId, SymbolLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + await _maplibrePlatform.addSymbolLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Add a line layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addLineLayer( + String sourceId, String layerId, LineLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + await _maplibrePlatform.addLineLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Set one or multiple properties of a layer. + /// You can only use properties that are supported for the layer's type. + /// So you can e.g. only use LineLayerProperties on a line layer. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// NOTE: The [properties] will not skip null values, so setting a property to null will potentially reset it to default. + Future setLayerProperties( + String layerId, + LayerProperties properties, + ) async { + await _maplibrePlatform.setLayerProperties( + layerId, + properties.toJson(skipNulls: false), + ); + } + + /// Add a fill layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addFillLayer( + String sourceId, String layerId, FillLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + await _maplibrePlatform.addFillLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Add a fill extrusion layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addFillExtrusionLayer( + String sourceId, String layerId, FillExtrusionLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + await _maplibrePlatform.addFillExtrusionLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Add a circle layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addCircleLayer( + String sourceId, String layerId, CircleLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + await _maplibrePlatform.addCircleLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Add a raster layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// [sourceLayer] is used to selected a specific source layer from + /// Raster source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + Future addRasterLayer( + String sourceId, String layerId, RasterLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}) async { + await _maplibrePlatform.addRasterLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + ); + } + + /// Add a hillshade layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// [sourceLayer] is used to selected a specific source layer from + /// Raster source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + Future addHillshadeLayer( + String sourceId, String layerId, HillshadeLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}) async { + await _maplibrePlatform.addHillshadeLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + ); + } + + /// Add a heatmap layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// [sourceLayer] is used to selected a specific source layer from + /// Raster source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + Future addHeatmapLayer( + String sourceId, String layerId, HeatmapLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}) async { + await _maplibrePlatform.addHeatmapLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + ); + } + + /// Updates user location tracking mode. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future updateMyLocationTrackingMode( + MyLocationTrackingMode myLocationTrackingMode) async { + return _maplibrePlatform + .updateMyLocationTrackingMode(myLocationTrackingMode); + } + + /// Updates the language of the map labels to match the device's language. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future matchMapLanguageWithDeviceDefault() async { + return _maplibrePlatform.matchMapLanguageWithDeviceDefault(); + } + + /// Updates the distance from the edges of the map view’s frame to the edges + /// of the map view’s logical viewport, optionally animating the change. + /// + /// When the value of this property is equal to `EdgeInsets.zero`, viewport + /// properties such as centerCoordinate assume a viewport that matches the map + /// view’s frame. Otherwise, those properties are inset, excluding part of the + /// frame from the viewport. For instance, if the only the top edge is inset, + /// the map center is effectively shifted downward. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future updateContentInsets(EdgeInsets insets, + [bool animated = false]) async { + return _maplibrePlatform.updateContentInsets(insets, animated); + } + + /// Updates the language of the map labels to match the specified language. + /// This will use labels with "name:$language" if available, otherwise "name:latin" or "name". + /// This naming schema is used by OpenStreetMap (see [https://wiki.openstreetmap.org/wiki/Multilingual_names]), + /// and is also used by some other vector tile generation software and vector tile providers. + /// Commonly, (and according to the OSM wiki) [language] should be + /// "a lowercase language's ISO 639-1 alpha2 code (second column), a lowercase ISO 639-2 code if an ISO 639-1 code doesn't exist, or a ISO 639-3 code if neither of those exist". + /// + /// If your vector tiles do not follow this schema of having labels with "name:$language" for different language, this method will not work for you. + /// In that case, you need to adapt your MapLibre style accordingly yourself to use labels in your preferred language. + /// + /// Attention: This may only be called after onStyleLoaded() has been invoked. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setMapLanguage(String language) async { + return _maplibrePlatform.setMapLanguage(language); + } + + /// Enables or disables the collection of anonymized telemetry data. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setTelemetryEnabled(bool enabled) async { + return _maplibrePlatform.setTelemetryEnabled(enabled); + } + + /// Retrieves whether collection of anonymized telemetry data is enabled. + /// + /// The returned [Future] completes after the query has been made on the + /// platform side. + Future getTelemetryEnabled() async { + return _maplibrePlatform.getTelemetryEnabled(); + } + + /// Sets the maximum frames per second for the map rendering. + /// + /// This can help optimize performance on lower-end devices by limiting + /// the rendering frequency. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setMaximumFps(int fps) async { + return _maplibrePlatform.setMaximumFps(fps); + } + + /// Forces the map to use online mode, disabling any offline functionality. + /// + /// This is useful for testing or when you want to ensure the map always + /// uses the latest data from the network. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future forceOnlineMode() async { + return _maplibrePlatform.forceOnlineMode(); + } + + /// Eases the camera to a new position with an optional duration. + /// + /// The [cameraUpdate] specifies the target camera position, and [duration] + /// specifies the animation duration in milliseconds (optional). + /// + /// The returned [Future] completes with true if the animation finished successfully, + /// or false if it was cancelled. + Future easeCamera(CameraUpdate cameraUpdate, + {Duration? duration}) async { + return _maplibrePlatform.easeCamera(cameraUpdate, duration: duration); + } + + /// Queries the current camera position. + /// + /// Returns the current camera position including center, zoom, bearing, and tilt. + /// Returns null if the camera position cannot be determined. + /// + /// The returned [Future] completes with the current camera position. + Future queryCameraPosition() async { + return _maplibrePlatform.queryCameraPosition(); + } + + /// Edits a GeoJSON source with new data. + /// + /// The [id] specifies the source identifier, and [data] contains the new + /// GeoJSON data as a string. + /// + /// The returned [Future] completes with true if the source was successfully + /// updated, false otherwise. + Future editGeoJsonSource(String id, String data) async { + return _maplibrePlatform.editGeoJsonSource(id, data); + } + + /// Edits a GeoJSON source with a new URL. + /// + /// The [id] specifies the source identifier, and [url] contains the new + /// URL for the GeoJSON data. + /// + /// The returned [Future] completes with true if the source was successfully + /// updated, false otherwise. + Future editGeoJsonUrl(String id, String url) async { + return _maplibrePlatform.editGeoJsonUrl(id, url); + } + + /// Sets a filter for a layer. + /// + /// The [layerId] specifies the layer identifier, and [filter] contains the + /// filter expression as a JSON string. + /// + /// The returned [Future] completes with true if the filter was successfully + /// applied, false otherwise. + Future setLayerFilter(String layerId, String filter) async { + return _maplibrePlatform.setLayerFilter(layerId, filter); + } + + /// Gets the current map style as JSON string. + /// + /// The returned [Future] completes with the style JSON string if successful, + /// null otherwise. + Future getStyle() async { + return _maplibrePlatform.getStyle(); + } + + /// Sets custom HTTP headers for map requests. + /// + /// The [headers] map contains the header key-value pairs to set, and [filter] + /// contains URL patterns to determine which requests should include these headers. + /// + /// The returned [Future] completes when the headers are successfully set. + Future setCustomHeaders( + Map headers, List filter) async { + return _maplibrePlatform.setCustomHeaders(headers, filter); + } + + /// Gets the current custom HTTP headers. + /// + /// The returned [Future] completes with a map of the current custom headers. + Future> getCustomHeaders() async { + return _maplibrePlatform.getCustomHeaders(); + } + + /// Adds a symbol to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the symbol has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added symbol once listeners have + /// been notified.\ + /// An [Exception] is thrown if the SymbolManager is not initialized (style not loaded yet). + Future addSymbol(SymbolOptions options, [Map? data]) async { + _ensureManagerInitialized(symbolManager); + + final effectiveOptions = SymbolOptions.defaultOptions.copyWith(options); + final symbol = Symbol(getRandomString(), effectiveOptions, data); + await symbolManager?.add(symbol); + if (!isDisposed) notifyListeners(); + return symbol; + } + + /// Adds multiple symbols to the map, configured using the specified custom + /// [options]. + /// + /// Change listeners are notified once the symbol has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added symbol once listeners have + /// been notified.\ + /// An [Exception] is thrown if the SymbolManager is not initialized (style not loaded yet). + Future> addSymbols( + List options, [ + List? data, + ]) async { + _ensureManagerInitialized(symbolManager); + + final symbols = [ + for (var i = 0; i < options.length; i++) + Symbol( + getRandomString(), + SymbolOptions.defaultOptions.copyWith(options[i]), + data?[i], + ) + ]; + await symbolManager?.addAll(symbols); + if (!isDisposed) notifyListeners(); + return symbols; + } + + /// Updates the specified [symbol] with the given [changes]. The symbol must + /// be a current member of the [symbols] set. + /// + /// Change listeners are notified once the symbol has been updated on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified.\ + /// An [Exception] is thrown if the SymbolManager is not initialized (style not loaded yet). + Future updateSymbol(Symbol symbol, SymbolOptions changes) async { + await symbolManager?.set( + symbol..options = symbol.options.copyWith(changes), + ); + if (!isDisposed) notifyListeners(); + } + + /// Retrieves the current position of the symbol. + /// This may be different from the value of `symbol.options.geometry` if the symbol is draggable. + /// In that case this method provides the symbol's actual position, and `symbol.options.geometry` the last programmatically set position.\ + /// An [Exception] is thrown if the Symbol has no geometry set. + LatLng getSymbolLatLng(Symbol symbol) { + if (symbol.options.geometry == null) { + throw ArgumentError( + "Symbol geometry is null. Cannot determine position.", + ); + } + + return symbol.options.geometry!; + } + + /// Removes the specified [symbol] from the map. The symbol must be a current + /// member of the [symbols] set. + /// + /// Change listeners are notified once the symbol has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeSymbol(Symbol symbol) async { + await symbolManager?.remove(symbol); + if (!isDisposed) notifyListeners(); + } + + /// Removes the specified [symbols] from the map. The symbols must be current + /// members of the [symbols] set. + /// + /// Change listeners are notified once the symbol has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeSymbols(Iterable symbols) async { + await symbolManager?.removeAll(symbols); + if (!isDisposed) notifyListeners(); + } + + /// Removes all [symbols] from the map added with the [addSymbol] or [addSymbols] methods. + /// + /// Change listeners are notified once all symbols have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future clearSymbols() async { + await symbolManager?.clear(); + if (!isDisposed) notifyListeners(); + } + + /// Adds a line to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the line has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added line once listeners have + /// been notified.\ + /// An [Exception] is thrown if the LineManager is not initialized (style not loaded yet). + Future addLine(LineOptions options, [Map? data]) async { + _ensureManagerInitialized(lineManager); + + final effectiveOptions = LineOptions.defaultOptions.copyWith(options); + final line = Line(getRandomString(), effectiveOptions, data); + await lineManager?.add(line); + if (!isDisposed) notifyListeners(); + return line; + } + + /// Adds multiple lines to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the lines have been added on the + /// platform side. + /// + /// The returned [Future] completes with the added line once listeners have + /// been notified.\ + /// An [Exception] is thrown if the LineManager is not initialized (style not loaded yet). + Future> addLines( + List options, [ + List? data, + ]) async { + _ensureManagerInitialized(lineManager); + + final lines = [ + for (var i = 0; i < options.length; i++) + Line( + getRandomString(), + LineOptions.defaultOptions.copyWith(options[i]), + data?[i], + ) + ]; + await lineManager?.addAll(lines); + if (!isDisposed) notifyListeners(); + return lines; + } + + /// Updates the specified [line] with the given [changes]. The line must + /// be a current member of the [lines] set.‚ + /// + /// Change listeners are notified once the line has been updated on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future updateLine(Line line, LineOptions changes) async { + line.options = line.options.copyWith(changes); + await lineManager?.set(line); + if (!isDisposed) notifyListeners(); + } + + /// Retrieves the current position of the line. + /// This may be different from the value of `line.options.geometry` if the line is draggable. + /// In that case this method provides the line's actual position, and `line.options.geometry` the last programmatically set position. + /// An [Exception] is thrown if the Line has no geometry set. + List getLineLatLngs(Line line) { + if (line.options.geometry == null) { + throw ArgumentError( + "Line geometry is null. Cannot determine position.", + ); + } + + return line.options.geometry!; + } + + /// Removes the specified [line] from the map. The line must be a current + /// member of the [lines] set. + /// + /// Change listeners are notified once the line has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeLine(Line line) async { + await lineManager?.remove(line); + if (!isDisposed) notifyListeners(); + } + + /// Removes the specified [lines] from the map. The lines must be current + /// members of the [lines] set. + /// + /// Change listeners are notified once the lines have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeLines(Iterable lines) async { + await lineManager?.removeAll(lines); + if (!isDisposed) notifyListeners(); + } + + /// Removes all [lines] from the map added with the [addLine] or [addLines] methods. + /// + /// Change listeners are notified once all lines have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future clearLines() async { + await lineManager?.clear(); + if (!isDisposed) notifyListeners(); + } + + /// Adds a circle to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the circle has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added circle once listeners have + /// been notified.\ + /// An [Exception] is thrown if the CircleManager is not initialized (style not loaded yet). + Future addCircle(CircleOptions options, [Map? data]) async { + _ensureManagerInitialized(circleManager); + + final effectiveOptions = CircleOptions.defaultOptions.copyWith(options); + final circle = Circle(getRandomString(), effectiveOptions, data); + await circleManager?.add(circle); + if (!isDisposed) notifyListeners(); + return circle; + } + + /// Adds multiple circles to the map, configured using the specified custom + /// [options]. + /// + /// Change listeners are notified once the circles have been added on the + /// platform side. + /// + /// The returned [Future] completes with the added circle once listeners have + /// been notified.\ + /// An [Exception] is thrown if the CircleManager is not initialized (style not loaded yet). + Future> addCircles( + List options, [ + List? data, + ]) async { + _ensureManagerInitialized(circleManager); + + final circles = [ + for (var i = 0; i < options.length; i++) + Circle( + getRandomString(), + CircleOptions.defaultOptions.copyWith(options[i]), + data?[i], + ) + ]; + await circleManager?.addAll(circles); + if (!isDisposed) notifyListeners(); + return circles; + } + + /// Updates the specified [circle] with the given [changes]. The circle must + /// be a current member of the [circles] set. + /// + /// Change listeners are notified once the circle has been updated on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future updateCircle(Circle circle, CircleOptions changes) async { + circle.options = circle.options.copyWith(changes); + await circleManager?.set(circle); + if (!isDisposed) notifyListeners(); + } + + /// Retrieves the current position of the circle. + /// This may be different from the value of `circle.options.geometry` if the circle is draggable. + /// In that case this method provides the circle's actual position, and `circle.options.geometry` the last programmatically set position.\ + /// An [Exception] is thrown if the Circle has no geometry set. + LatLng getCircleLatLng(Circle circle) { + if (circle.options.geometry == null) { + throw ArgumentError( + "Circle geometry is null. Cannot determine position.", + ); + } + + return circle.options.geometry!; + } + + /// Removes the specified [circle] from the map. The circle must be a current + /// member of the [circles] set. + /// + /// Change listeners are notified once the circle has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeCircle(Circle circle) async { + await circleManager?.remove(circle); + if (!isDisposed) notifyListeners(); + } + + /// Removes the specified [circles] from the map. The circles must be current + /// members of the [circles] set. + /// + /// Change listeners are notified once the circles have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeCircles(Iterable circles) async { + await circleManager?.removeAll(circles); + if (!isDisposed) notifyListeners(); + } + + /// Removes all [circles] from the map added with the [addCircle] or [addCircles] methods. + /// + /// Change listeners are notified once all circles have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future clearCircles() async { + await circleManager?.clear(); + if (!isDisposed) notifyListeners(); + } + + /// Adds a fill to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the fill has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added fill once listeners have + /// been notified.\ + /// An [Exception] is thrown if the FillManager is not initialized (style not loaded yet). + Future addFill(FillOptions options, [Map? data]) async { + _ensureManagerInitialized(fillManager); + + final effectiveOptions = FillOptions.defaultOptions.copyWith(options); + final fill = Fill(getRandomString(), effectiveOptions, data); + await fillManager?.add(fill); + if (!isDisposed) notifyListeners(); + return fill; + } + + /// Adds multiple fills to the map, configured using the specified custom + /// [options]. + /// + /// Change listeners are notified once the fills has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added fills once listeners have + /// been notified.\ + /// An [Exception] is thrown if the FillManager is not initialized (style not loaded yet). + Future> addFills( + List options, [ + List? data, + ]) async { + _ensureManagerInitialized(fillManager); + + final fills = [ + for (var i = 0; i < options.length; i++) + Fill( + getRandomString(), + FillOptions.defaultOptions.copyWith(options[i]), + data?[i], + ) + ]; + await fillManager?.addAll(fills); + if (!isDisposed) notifyListeners(); + return fills; + } + + /// Updates the specified [fill] with the given [changes]. The fill must + /// be a current member of the [fills] set. + /// + /// Change listeners are notified once the fill has been updated on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future updateFill(Fill fill, FillOptions changes) async { + fill.options = fill.options.copyWith(changes); + await fillManager?.set(fill); + if (!isDisposed) notifyListeners(); + } + + /// Removes all [fills] from the map added with the [addFill] or [addFills] methods. + /// + /// Change listeners are notified once all fills have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future clearFills() async { + await fillManager?.clear(); + if (!isDisposed) notifyListeners(); + } + + /// Removes the specified [fill] from the map. The fill must be a current + /// member of the [fills] set. + /// + /// Change listeners are notified once the fill has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeFill(Fill fill) async { + await fillManager?.remove(fill); + if (!isDisposed) notifyListeners(); + } + + /// Removes the specified [fills] from the map. The fills must be current + /// members of the [fills] set. + /// + /// Change listeners are notified once the fills have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeFills(Iterable fills) async { + await fillManager?.removeAll(fills); + if (!isDisposed) notifyListeners(); + } + + /// Retrieves the current position of the fill. + /// This may be different from the value of `fill.options.geometry` if the fill is + /// draggable. In that case this method provides the fill's actual position, + /// and `fill.options.geometry` the last programmatically set position.\ + /// An [Exception] is thrown if the Fill has no geometry set. + List> getFillLatLngs(Fill fill) { + if (fill.options.geometry == null) { + throw ArgumentError( + "Fill geometry is null. Cannot determine position.", + ); + } + + return fill.options.geometry!; + } + + /// Query rendered (i.e. visible) features at a point in screen coordinates + Future queryRenderedFeatures( + Point point, List layerIds, List? filter) async { + return _maplibrePlatform.queryRenderedFeatures(point, layerIds, filter); + } + + /// Query rendered (i.e. visible) features in a Rect in screen coordinates + Future queryRenderedFeaturesInRect( + Rect rect, List layerIds, String? filter) async { + return _maplibrePlatform.queryRenderedFeaturesInRect( + rect, layerIds, filter); + } + + /// Query features contained in the source with the specified [sourceId]. + /// + /// In contrast to [queryRenderedFeatures], this returns all features in the source, + /// regardless of whether they are currently rendered by the current style. + /// + /// Note: On web, this will probably only work for GeoJson source, not for vector tiles + Future querySourceFeatures( + String sourceId, String? sourceLayerId, List? filter) async { + return _maplibrePlatform.querySourceFeatures( + sourceId, sourceLayerId, filter); + } + + Future invalidateAmbientCache() async { + return _maplibrePlatform.invalidateAmbientCache(); + } + + Future clearAmbientCache() async { + return _maplibrePlatform.clearAmbientCache(); + } + + /// Get last my location + /// + /// Return last latlng, nullable + Future requestMyLocationLatLng() async { + return _maplibrePlatform.requestMyLocationLatLng(); + } + + /// This method returns the boundaries of the region currently displayed in the map. + Future getVisibleRegion() async { + return _maplibrePlatform.getVisibleRegion(); + } + + /// Adds an image to the style currently displayed in the map, so that it can later be referred to by the provided name. + /// + /// This allows you to add an image to the currently displayed style once, and from there on refer to it e.g. in the [Symbol.iconImage] anytime you add a [Symbol] later on. + /// Set [sdf] to true if the image you add is an SDF image. + /// Returns after the image has successfully been added to the style. + /// Note: This can only be called after OnStyleLoadedCallback has been invoked and any added images will have to be re-added if a new style is loaded. + /// + /// Example: Adding an asset image and using it in a new symbol: + /// ```dart + /// Future addImageFromAsset() async{ + /// final ByteData bytes = await rootBundle.load("assets/someAssetImage.jpg"); + /// final Uint8List list = bytes.buffer.asUint8List(); + /// await controller.addImage("assetImage", list); + /// controller.addSymbol( + /// SymbolOptions( + /// geometry: LatLng(0,0), + /// iconImage: "assetImage", + /// ), + /// ); + /// } + /// ``` + /// + /// Example: Adding a network image (with the http package) and using it in a new symbol: + /// ```dart + /// Future addImageFromUrl() async{ + /// var response = await get("https://example.com/image.png"); + /// await controller.addImage("testImage", response.bodyBytes); + /// controller.addSymbol( + /// SymbolOptions( + /// geometry: LatLng(0,0), + /// iconImage: "testImage", + /// ), + /// ); + /// } + /// ``` + Future addImage(String name, Uint8List bytes, [bool sdf = false]) { + return _maplibrePlatform.addImage(name, bytes, sdf); + } + + /// If true, the icon will be visible even if it collides with other previously drawn symbols. + Future setSymbolIconAllowOverlap(bool enable) async { + await symbolManager?.setIconAllowOverlap(enable); + } + + /// If true, other symbols can be visible even if they collide with the icon. + Future setSymbolIconIgnorePlacement(bool enable) async { + await symbolManager?.setIconIgnorePlacement(enable); + } + + /// If true, the text will be visible even if it collides with other previously drawn symbols. + Future setSymbolTextAllowOverlap(bool enable) async { + await symbolManager?.setTextAllowOverlap(enable); + } + + /// If true, other symbols can be visible even if they collide with the text. + Future setSymbolTextIgnorePlacement(bool enable) async { + await symbolManager?.setTextIgnorePlacement(enable); + } + + /// Adds an image source to the style currently displayed in the map, so that it can later be referred to by the provided id. + /// Not implemented on web. + Future addImageSource( + String imageSourceId, Uint8List bytes, LatLngQuad coordinates) { + return _maplibrePlatform.addImageSource(imageSourceId, bytes, coordinates); + } + + /// Update the image and/or coordinates of an image source. + /// Not implemented on web. + Future updateImageSource( + String imageSourceId, Uint8List? bytes, LatLngQuad? coordinates) { + return _maplibrePlatform.updateImageSource( + imageSourceId, bytes, coordinates); + } + + /// Removes previously added image source by id + @Deprecated("This method was renamed to removeSource") + Future removeImageSource(String imageSourceId) { + return _maplibrePlatform.removeSource(imageSourceId); + } + + /// Removes previously added source by id + Future removeSource(String sourceId) { + return _maplibrePlatform.removeSource(sourceId); + } + + /// Adds an image layer to the map's style at render time. + Future addImageLayer(String layerId, String imageSourceId, + {double? minzoom, double? maxzoom}) { + return _maplibrePlatform.addLayer(layerId, imageSourceId, minzoom, maxzoom); + } + + /// Adds an image layer below the layer provided with belowLayerId to the map's style at render time. + Future addImageLayerBelow( + String layerId, String sourceId, String imageSourceId, + {double? minzoom, double? maxzoom}) { + return _maplibrePlatform.addLayerBelow( + layerId, sourceId, imageSourceId, minzoom, maxzoom); + } + + /// Adds an image layer below the layer provided with belowLayerId to the map's style at render time. Only works for image sources! + @Deprecated("This method was renamed to addImageLayerBelow for clarity.") + Future addLayerBelow( + String layerId, String sourceId, String imageSourceId, + {double? minzoom, double? maxzoom}) { + return _maplibrePlatform.addLayerBelow( + layerId, sourceId, imageSourceId, minzoom, maxzoom); + } + + /// Removes a MapLibre style layer + Future removeLayer(String layerId) { + return _maplibrePlatform.removeLayer(layerId); + } + + Future setFilter(String layerId, dynamic filter) { + return _maplibrePlatform.setFilter(layerId, filter); + } + + Future getFilter(String layerId) { + return _maplibrePlatform.getFilter(layerId); + } + + /// Returns the point on the screen that corresponds to a geographical coordinate ([latLng]). The screen location is in screen pixels (not display pixels) relative to the top left of the map (not of the whole screen) + /// + /// Note: The resulting x and y coordinates are rounded to [int] on web, on other platforms they may differ very slightly (in the range of about 10^-10) from the actual nearest screen coordinate. + /// You therefore might want to round them appropriately, depending on your use case. + /// + /// Returns null if [latLng] is not currently visible on the map. + Future toScreenLocation(LatLng latLng) async { + return _maplibrePlatform.toScreenLocation(latLng); + } + + Future> toScreenLocationBatch(Iterable latLngs) async { + return _maplibrePlatform.toScreenLocationBatch(latLngs); + } + + /// Returns the geographic location (as [LatLng]) that corresponds to a point on the screen. The screen location is specified in screen pixels (not display pixels) relative to the top left of the map (not the top left of the whole screen). + Future toLatLng(Point screenLocation) async { + return _maplibrePlatform.toLatLng(screenLocation); + } + + /// Returns the distance spanned by one pixel at the specified [latitude] and current zoom level. + /// The distance between pixels decreases as the latitude approaches the poles. This relationship parallels the relationship between longitudinal coordinates at different latitudes. + Future getMetersPerPixelAtLatitude(double latitude) async { + return _maplibrePlatform.getMetersPerPixelAtLatitude(latitude); + } + + /// Add a new source to the map + Future addSource(String sourceid, SourceProperties properties) async { + return _maplibrePlatform.addSource(sourceid, properties); + } + + /// Pans and zooms the map to contain its visible area within the specified geographical bounds. + /// + /// Also consider using [animateCamera] or [moveCamera], which allow you to set camera bounds (with different padding values per side) + /// as well as other camera properties. + Future setCameraBounds({ + required double west, + required double north, + required double south, + required double east, + required int padding, + }) async { + return _maplibrePlatform.setCameraBounds( + west: west, + north: north, + south: south, + east: east, + padding: padding, + ); + } + + /// Add a layer to the map with the given properties + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events this has no effect for [RasterLayerProperties] and + /// [HillshadeLayerProperties]. + /// [sourceLayer] is used to selected a specific source layer from Vector + /// source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// [filter] is not supported by RasterLayer and HillshadeLayer. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addLayer( + String sourceId, String layerId, LayerProperties properties, + {String? belowLayerId, + bool enableInteraction = true, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter}) async { + if (properties is FillLayerProperties) { + await addFillLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + enableInteraction: enableInteraction, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter); + } else if (properties is FillExtrusionLayerProperties) { + await addFillExtrusionLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + } else if (properties is LineLayerProperties) { + await addLineLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + enableInteraction: enableInteraction, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter); + } else if (properties is SymbolLayerProperties) { + await addSymbolLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + enableInteraction: enableInteraction, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter); + } else if (properties is CircleLayerProperties) { + await addCircleLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + enableInteraction: enableInteraction, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter); + } else if (properties is RasterLayerProperties) { + if (filter != null) { + throw UnimplementedError("RasterLayer does not support filter"); + } + await addRasterLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + } else if (properties is HillshadeLayerProperties) { + if (filter != null) { + throw UnimplementedError("HillShadeLayer does not support filter"); + } + await addHillshadeLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + } else if (properties is HeatmapLayerProperties) { + await addHeatmapLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + } else { + throw UnimplementedError("Unknown layer type $properties"); + } + } + + Future setLayerVisibility(String layerId, bool visible) async { + return _maplibrePlatform.setLayerVisibility(layerId, visible); + } + + Future getLayerIds() { + return _maplibrePlatform.getLayerIds(); + } + + /// Retrieve every source ids of the map as a [String] list, including the ones added internally + /// + /// This method is not currently implemented on the web + Future> getSourceIds() async { + return (await _maplibrePlatform.getSourceIds()) + .whereType() + .toList(); + } + + /// Method to set style string + /// A MapLibre GL style document defining the map's appearance. + /// The style document specification is at [https://maplibre.org/maplibre-style-spec]. + /// A short introduction can be found in the documentation of the [maplibre_gl] library. + /// The [styleString] supports following formats: + /// + /// 1. Passing the URL of the map style. This should be a custom map style served remotely using a URL that start with 'http(s)://' + /// 2. Passing the style as a local asset. Create a JSON file in the `assets` and add a reference in `pubspec.yml`. Set the style string to the relative path for this asset in order to load it into the map. + /// 3. Passing the style as a local file. create an JSON file in app directory (e.g. ApplicationDocumentsDirectory). Set the style string to the absolute path of this JSON file. + /// 4. Passing the raw JSON of the map style. This is only supported on Android. + Future setStyle(String styleString) async { + return _maplibrePlatform.setStyle(styleString); + } + + @override + void dispose() { + _isDisposed = true; + super.dispose(); + _maplibrePlatform.dispose(); + } + + /// Ensures that the given manager is initialized. + /// If not, throws an [Exception]. + void _ensureManagerInitialized(AnnotationManager? manager) { + if (manager == null || !manager.isInitialized) { + throw Exception( + "This Annotation Manager has not been initialized. Make sure that the map style has been loaded.", + ); + } + } +} diff --git a/third_party/maplibre_gl/lib/src/download_region_status.dart b/third_party/maplibre_gl/lib/src/download_region_status.dart new file mode 100644 index 0000000..2f9aed2 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/download_region_status.dart @@ -0,0 +1,25 @@ +part of '../maplibre_gl.dart'; + +abstract class DownloadRegionStatus {} + +class Success extends DownloadRegionStatus {} + +class InProgress extends DownloadRegionStatus { + final double progress; + + InProgress(this.progress); + + @override + String toString() => + "Instance of 'DownloadRegionStatus.InProgress', progress = $progress"; +} + +class Error extends DownloadRegionStatus { + final PlatformException cause; + + Error(this.cause); + + @override + String toString() => + "Instance of 'DownloadRegionStatus.Error', cause = $cause"; +} diff --git a/third_party/maplibre_gl/lib/src/global.dart b/third_party/maplibre_gl/lib/src/global.dart new file mode 100644 index 0000000..d9bfe46 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/global.dart @@ -0,0 +1,136 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of '../maplibre_gl.dart'; + +const _globalChannel = MethodChannel('plugins.flutter.io/maplibre_gl'); + +/// Copy tiles db file passed in to the tiles cache directory (sideloaded) to +/// make tiles available offline. +Future installOfflineMapTiles(String tilesDb) async { + await _globalChannel.invokeMethod( + 'installOfflineMapTiles', + { + 'tilesdb': tilesDb, + }, + ); +} + +enum DragEventType { start, drag, end } + +enum HoverEventType { enter, move, leave } + +Future setOffline(bool offline) => _globalChannel.invokeMethod( + 'setOffline', + { + 'offline': offline, + }, + ); + +Future setHttpHeaders(Map headers) { + return _globalChannel.invokeMethod( + 'setHttpHeaders', + { + 'headers': headers, + }, + ); +} + +Future> mergeOfflineRegions(String path) async { + final String regionsJson = await _globalChannel.invokeMethod( + 'mergeOfflineRegions', + { + 'path': path, + }, + ); + final regions = List>.from(json.decode(regionsJson)); + return regions.map(OfflineRegion.fromMap).toList(); +} + +Future> getListOfRegions() async { + final String regionsJson = await _globalChannel.invokeMethod( + 'getListOfRegions', + {}, + ); + final regions = List>.from(json.decode(regionsJson)); + return regions.map(OfflineRegion.fromMap).toList(); +} + +Future updateOfflineRegionMetadata( + int id, Map metadata) async { + final regionJson = await _globalChannel.invokeMethod( + 'updateOfflineRegionMetadata', + { + 'id': id, + 'metadata': metadata, + }, + ); + + return OfflineRegion.fromMap(json.decode(regionJson)); +} + +Future setOfflineTileCountLimit(int limit) => + _globalChannel.invokeMethod( + 'setOfflineTileCountLimit', + { + 'limit': limit, + }, + ); + +Future deleteOfflineRegion(int id) => _globalChannel.invokeMethod( + 'deleteOfflineRegion', + { + 'id': id, + }, + ); + +Future downloadOfflineRegion( + OfflineRegionDefinition definition, { + Map metadata = const {}, + Function(DownloadRegionStatus event)? onEvent, +}) async { + final channelName = + 'downloadOfflineRegion_${DateTime.now().microsecondsSinceEpoch}'; + + await _globalChannel + .invokeMethod('downloadOfflineRegion#setup', { + 'channelName': channelName, + }); + + if (onEvent != null) { + EventChannel(channelName).receiveBroadcastStream().handleError((error) { + if (error is PlatformException) { + onEvent(Error(error)); + return Error(error); + } + final unknownError = Error( + PlatformException( + code: 'UnknowException', + message: + 'This error is unhandled by plugin. Please contact us if needed.', + details: error, + ), + ); + onEvent(unknownError); + return unknownError; + }).listen((data) { + final Map jsonData = json.decode(data); + final status = switch (jsonData['status']) { + 'start' => InProgress(0.0), + 'progress' => InProgress((jsonData['progress']! as num).toDouble()), + 'success' => Success(), + _ => throw Exception('Invalid event status ${jsonData['status']}'), + }; + onEvent(status); + }); + } + + final result = await _globalChannel + .invokeMethod('downloadOfflineRegion', { + 'definition': definition.toMap(), + 'metadata': metadata, + }); + + return OfflineRegion.fromMap(json.decode(result)); +} diff --git a/third_party/maplibre_gl/lib/src/layer_expressions.dart b/third_party/maplibre_gl/lib/src/layer_expressions.dart new file mode 100644 index 0000000..258df0d --- /dev/null +++ b/third_party/maplibre_gl/lib/src/layer_expressions.dart @@ -0,0 +1,657 @@ +// This file is generated by +// ./scripts/lib/generate.dart + +part of '../maplibre_gl.dart'; + +class Expressions { + /// Binds expressions to named variables, which can then be referenced in + /// the result expression using ["var", "variable_name"]. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const let = "let"; + + /// References variable bound using "let". + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const varExpression = "var"; + + /// Provides a literal array or object value. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const literal = "literal"; + + /// Asserts that the input is an array (optionally with a specific item + /// type and length). If, when the input expression is evaluated, it is + /// not of the asserted type, then this assertion will cause the whole + /// expression to be aborted. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const array = "array"; + + /// Retrieves an item from an array. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const at = "at"; + + /// Determines whether an item exists in an array or a substring exists in + /// a string. + /// + /// Sdk Support: + /// basic functionality with js + static const inExpression = "in"; + + /// Selects the first output whose corresponding test condition evaluates + /// to true, or the fallback value otherwise. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const caseExpression = "case"; + + /// Selects the output whose label value matches the input value, or the + /// fallback value if no match is found. The input can be any expression + /// (e.g. `["get", "building_type"]`). Each label must be either: + /// * a single literal value; or + /// * an array of literal values, whose values must be all strings or all + /// numbers (e.g. `[100, 101]` or `["c", "b"]`). The input matches if any + /// of the values in the array matches, similar to the `"in"` + /// operator.Each label must be unique. If the input type does not match + /// the type of the labels, the result will be the fallback value. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const match = "match"; + + /// Evaluates each expression in turn until the first non-null value is + /// obtained, and returns that value. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const coalesce = "coalesce"; + + /// Produces discrete, stepped results by evaluating a piecewise-constant + /// function defined by pairs of input and output values ("stops"). The + /// `input` may be any numeric expression (e.g., `["get", "population"]`). + /// Stop inputs must be numeric literals in strictly ascending order. + /// Returns the output value of the stop just less than the input, or the + /// first output if the input is less than the first stop. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const step = "step"; + + /// Produces continuous, smooth results by interpolating between pairs of + /// input and output values ("stops"). The `input` may be any numeric + /// expression (e.g., `["get", "population"]`). Stop inputs must be + /// numeric literals in strictly ascending order. The output type must be + /// `number`, `array`, or `color`.Interpolation types:- + /// `["linear"]`: interpolates linearly between the pair of stops just + /// less than and just greater than the input.- `["exponential", base]`: + /// interpolates exponentially between the stops just less than and just + /// greater than the input. `base` controls the rate at which the output + /// increases: higher values make the output increase more towards the + /// high end of the range. With values close to 1 the output increases + /// linearly.- `["cubic-bezier", x1, y1, x2, y2]`: interpolates using the + /// cubic bezier curve defined by the given control points. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const interpolate = "interpolate"; + + /// Produces continuous, smooth results by interpolating between pairs of + /// input and output values ("stops"). Works like `interpolate`, but the + /// output type must be `color`, and the interpolation is performed in the + /// Hue-Chroma-Luminance color space. + /// + /// Sdk Support: + /// basic functionality with js + static const interpolateHcl = "interpolate-hcl"; + + /// Produces continuous, smooth results by interpolating between pairs of + /// input and output values ("stops"). Works like `interpolate`, but the + /// output type must be `color`, and the interpolation is performed in the + /// CIELAB color space. + /// + /// Sdk Support: + /// basic functionality with js + static const interpolateLab = "interpolate-lab"; + + /// Returns mathematical constant ln(2). + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const ln2 = "ln2"; + + /// Returns the mathematical constant pi. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const pi = "pi"; + + /// Returns the mathematical constant e. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const e = "e"; + + /// Returns a string describing the type of the given value. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const typeof = "typeof"; + + /// Asserts that the input value is a string. If multiple values are + /// provided, each one is evaluated in order until a string is obtained. + /// If none of the inputs are strings, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const string = "string"; + + /// Asserts that the input value is a number. If multiple values are + /// provided, each one is evaluated in order until a number is obtained. + /// If none of the inputs are numbers, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const number = "number"; + + /// Asserts that the input value is a boolean. If multiple values are + /// provided, each one is evaluated in order until a boolean is obtained. + /// If none of the inputs are booleans, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const boolean = "boolean"; + + /// Asserts that the input value is an object. If multiple values are + /// provided, each one is evaluated in order until an object is obtained. + /// If none of the inputs are objects, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const object = "object"; + + /// Returns a `collator` for use in locale-dependent comparison + /// operations. The `case-sensitive` and `diacritic-sensitive` options + /// default to `false`. The `locale` argument specifies the IETF language + /// tag of the locale to use. If none is provided, the default locale is + /// used. If the requested locale is not available, the `collator` will + /// use a system-defined fallback locale. Use `resolved-locale` to test + /// the results of locale fallback behavior. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const collator = "collator"; + + /// Returns `formatted` text containing annotations for use in + /// mixed-format `text-field` entries. For a `text-field` entries of a + /// string type, following option object's properties are supported: If + /// set, the `text-font` value overrides the font specified by the root + /// layout properties. If set, the `font-scale` value specifies a scaling + /// factor relative to the `text-size` specified in the root layout + /// properties. If set, the `text-color` value overrides the color + /// specified by the root paint properties for this layer. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const format = "format"; + + /// Returns an `image` type for use in `icon-image`, `*-pattern` entries + /// and as a section in the `format` expression. If set, the `image` + /// argument will check that the requested image exists in the style and + /// will return either the resolved image name or `null`, depending on + /// whether or not the image is currently in the style. This validation + /// process is synchronous and requires the image to have been added to + /// the style before requesting it in the `image` argument. + /// + /// Sdk Support: + /// basic functionality with js, android, ios + static const image = "image"; + + /// Converts the input number into a string representation using the + /// providing formatting rules. If set, the `locale` argument specifies + /// the locale to use, as a BCP 47 language tag. If set, the `currency` + /// argument specifies an ISO 4217 code to use for currency-style + /// formatting. If set, the `min-fraction-digits` and + /// `max-fraction-digits` arguments specify the minimum and maximum number + /// of fractional digits to include. + /// + /// Sdk Support: + /// basic functionality with js + static const numberFormat = "number-format"; + + /// Converts the input value to a string. If the input is `null`, the + /// result is `""`. If the input is a boolean, the result is `"true"` or + /// `"false"`. If the input is a number, it is converted to a string as + /// specified by the ["NumberToString" + /// algorithm](https://tc39.github.io/ecma262/#sec-tostring-applied-to-the-number-type) + /// of the ECMAScript Language Specification. If the input is a color, it + /// is converted to a string of the form `"rgba(r,g,b,a)"`, where `r`, + /// `g`, and `b` are numerals ranging from 0 to 255, and `a` ranges from 0 + /// to 1. Otherwise, the input is converted to a string in the format + /// specified by the + /// [`JSON.stringify`](https://tc39.github.io/ecma262/#sec-json.stringify) + /// function of the ECMAScript Language Specification. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toStringExpression = "to-string"; + + /// Converts the input value to a number, if possible. If the input is + /// `null` or `false`, the result is 0. If the input is `true`, the result + /// is 1. If the input is a string, it is converted to a number as + /// specified by the ["ToNumber Applied to the String Type" + /// algorithm](https://tc39.github.io/ecma262/#sec-tonumber-applied-to-the-string-type) + /// of the ECMAScript Language Specification. If multiple values are + /// provided, each one is evaluated in order until the first successful + /// conversion is obtained. If none of the inputs can be converted, the + /// expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toNumber = "to-number"; + + /// Converts the input value to a boolean. The result is `false` when then + /// input is an empty string, 0, `false`, `null`, or `NaN`; otherwise it + /// is `true`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toBoolean = "to-boolean"; + + /// Returns a four-element array containing the input color's red, green, + /// blue, and alpha components, in that order. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toRgba = "to-rgba"; + + /// Converts the input value to a color. If multiple values are provided, + /// each one is evaluated in order until the first successful conversion + /// is obtained. If none of the inputs can be converted, the expression is + /// an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toColor = "to-color"; + + /// Creates a color value from red, green, and blue components, which must + /// range between 0 and 255, and an alpha component of 1. If any component + /// is out of range, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const rgb = "rgb"; + + /// Creates a color value from red, green, blue components, which must + /// range between 0 and 255, and an alpha component which must range + /// between 0 and 1. If any component is out of range, the expression is + /// an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const rgba = "rgba"; + + /// Retrieves a property value from the current feature's properties, or + /// from another object if a second argument is provided. Returns null if + /// the requested property is missing. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const get = "get"; + + /// Tests for the presence of an property value in the current feature's + /// properties, or from another object if a second argument is provided. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const has = "has"; + + /// Gets the length of an array or string. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const length = "length"; + + /// Gets the feature properties object. Note that in some cases, it may + /// be more efficient to use ["get", "property_name"] directly. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const properties = "properties"; + + /// Retrieves a property value from the current feature's state. Returns + /// null if the requested property is not present on the feature's state. + /// A feature's state is not part of the GeoJSON or vector tile data, and + /// must be set programmatically on each feature. Features are identified + /// by their `id` attribute, which must be an integer or a string that can + /// be cast to an integer. Note that ["feature-state"] can only be used + /// with paint properties that support data-driven styling. + /// + /// Sdk Support: + /// basic functionality with js + static const featureState = "feature-state"; + + /// Gets the feature's geometry type: Point, MultiPoint, LineString, + /// MultiLineString, Polygon, MultiPolygon. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const geometryType = "geometry-type"; + + /// Gets the feature's id, if it has one. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const id = "id"; + + /// Gets the current zoom level. Note that in style layout and paint + /// properties, ["zoom"] may only appear as the input to a top-level + /// "step" or "interpolate" expression. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const zoom = "zoom"; + + /// Gets the kernel density estimation of a pixel in a heatmap layer, + /// which is a relative measure of how many data points are crowded around + /// a particular pixel. Can only be used in the `heatmap-color` property. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const heatmapDensity = "heatmap-density"; + + /// Gets the progress along a gradient line. Can only be used in the + /// `line-gradient` property. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const lineProgress = "line-progress"; + + /// Gets the value of a cluster property accumulated so far. Can only be + /// used in the `clusterProperties` option of a clustered GeoJSON source. + /// + /// Sdk Support: + /// basic functionality with js + static const accumulated = "accumulated"; + + /// Returns the sum of the inputs. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const plus = "+"; + + /// Returns the product of the inputs. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const multiply = "*"; + + /// For two inputs, returns the result of subtracting the second input + /// from the first. For a single input, returns the result of subtracting + /// it from 0. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const minus = "-"; + + /// Returns the result of floating point division of the first input by + /// the second. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const divide = "/"; + + /// Returns the remainder after integer division of the first input by the + /// second. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const precent = "%"; + + /// Returns the result of raising the first input to the power specified + /// by the second. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const xor = "^"; + + /// Returns the square root of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const sqrt = "sqrt"; + + /// Returns the base-ten logarithm of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const log10 = "log10"; + + /// Returns the natural logarithm of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const ln = "ln"; + + /// Returns the base-two logarithm of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const log2 = "log2"; + + /// Returns the sine of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const sin = "sin"; + + /// Returns the cosine of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const cos = "cos"; + + /// Returns the tangent of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const tan = "tan"; + + /// Returns the arcsine of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const asin = "asin"; + + /// Returns the arccosine of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const acos = "acos"; + + /// Returns the arctangent of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const atan = "atan"; + + /// Returns the minimum value of the inputs. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const min = "min"; + + /// Returns the maximum value of the inputs. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const max = "max"; + + /// Rounds the input to the nearest integer. Halfway values are rounded + /// away from zero. For example, `["round", -1.5]` evaluates to -2. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const round = "round"; + + /// Returns the absolute value of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const abs = "abs"; + + /// Returns the smallest integer that is greater than or equal to the + /// input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const ceil = "ceil"; + + /// Returns the largest integer that is less than or equal to the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const floor = "floor"; + + /// Returns `true` if the input values are equal, `false` otherwise. The + /// comparison is strictly typed: values of different runtime types are + /// always considered unequal. Cases where the types are known to be + /// different at parse time are considered invalid and will produce a + /// parse error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const equal = "=="; + + /// Returns `true` if the input values are not equal, `false` otherwise. + /// The comparison is strictly typed: values of different runtime types + /// are always considered unequal. Cases where the types are known to be + /// different at parse time are considered invalid and will produce a + /// parse error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const notEqual = "!="; + + /// Returns `true` if the first input is strictly greater than the second, + /// `false` otherwise. The arguments are required to be either both + /// strings or both numbers; if during evaluation they are not, expression + /// evaluation produces an error. Cases where this constraint is known not + /// to hold at parse time are considered in valid and will produce a parse + /// error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const larger = ">"; + + /// Returns `true` if the first input is strictly less than the second, + /// `false` otherwise. The arguments are required to be either both + /// strings or both numbers; if during evaluation they are not, expression + /// evaluation produces an error. Cases where this constraint is known not + /// to hold at parse time are considered in valid and will produce a parse + /// error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const smaller = "<"; + + /// Returns `true` if the first input is greater than or equal to the + /// second, `false` otherwise. The arguments are required to be either + /// both strings or both numbers; if during evaluation they are not, + /// expression evaluation produces an error. Cases where this constraint + /// is known not to hold at parse time are considered in valid and will + /// produce a parse error. Accepts an optional `collator` argument to + /// control locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const largerOrEqual = ">="; + + /// Returns `true` if the first input is less than or equal to the second, + /// `false` otherwise. The arguments are required to be either both + /// strings or both numbers; if during evaluation they are not, expression + /// evaluation produces an error. Cases where this constraint is known not + /// to hold at parse time are considered in valid and will produce a parse + /// error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const smallerOrEqual = "<="; + + /// Returns `true` if all the inputs are `true`, `false` otherwise. The + /// inputs are evaluated in order, and evaluation is short-circuiting: + /// once an input expression evaluates to `false`, the result is `false` + /// and no further input expressions are evaluated. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const all = "all"; + + /// Returns `true` if any of the inputs are `true`, `false` otherwise. The + /// inputs are evaluated in order, and evaluation is short-circuiting: + /// once an input expression evaluates to `true`, the result is `true` and + /// no further input expressions are evaluated. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const any = "any"; + + /// Logical negation. Returns `true` if the input is `false`, and `false` + /// if the input is `true`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const not = "!"; + + /// Returns `true` if the input string is expected to render legibly. + /// Returns `false` if the input string contains sections that cannot be + /// rendered without potential loss of meaning (e.g. Indic scripts that + /// require complex text shaping, or right-to-left scripts if the the + /// `mapbox-gl-rtl-text` plugin is not in use in MapLibre GL JS). + /// + /// Sdk Support: + /// basic functionality with js, android + static const isSupportedScript = "is-supported-script"; + + /// Returns the input string converted to uppercase. Follows the Unicode + /// Default Case Conversion algorithm and the locale-insensitive case + /// mappings in the Unicode Character Database. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const upcase = "upcase"; + + /// Returns the input string converted to lowercase. Follows the Unicode + /// Default Case Conversion algorithm and the locale-insensitive case + /// mappings in the Unicode Character Database. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const downcase = "downcase"; + + /// Returns a `string` consisting of the concatenation of the inputs. Each + /// input is converted to a string as if by `to-string`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const concat = "concat"; + + /// Returns the IETF language tag of the locale being used by the provided + /// `collator`. This can be used to determine the default system locale, + /// or to determine if a requested locale was successfully loaded. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const resolvedLocale = "resolved-locale"; +} diff --git a/third_party/maplibre_gl/lib/src/layer_properties.dart b/third_party/maplibre_gl/lib/src/layer_properties.dart new file mode 100644 index 0000000..7e0e38d --- /dev/null +++ b/third_party/maplibre_gl/lib/src/layer_properties.dart @@ -0,0 +1,2380 @@ +// This file is generated by +// ./scripts/lib/generate.dart + +part of '../maplibre_gl.dart'; + +abstract class LayerProperties { + Map toJson({bool skipNulls = true}); +} + +class SymbolLayerProperties implements LayerProperties { + // Paint Properties + /// The opacity at which the icon will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconOpacity; + + /// The color of the icon. This can only be used with sdf icons. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconColor; + + /// The color of the icon's halo. Icon halos can only be used with SDF + /// icons. + /// + /// Type: color + /// default: rgba(0, 0, 0, 0) + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconHaloColor; + + /// Distance of halo to the icon outline. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconHaloWidth; + + /// Fade out the halo towards the outside. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconHaloBlur; + + /// Distance that the icon's anchor is moved from its original placement. + /// Positive values indicate right and down, while negative values + /// indicate left and up. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconTranslate; + + /// Controls the frame of reference for `icon-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// Icons are translated relative to the map. + /// "viewport" + /// Icons are translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconTranslateAnchor; + + /// The opacity at which the text will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textOpacity; + + /// The color with which the text will be drawn. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textColor; + + /// The color of the text's halo, which helps it stand out from + /// backgrounds. + /// + /// Type: color + /// default: rgba(0, 0, 0, 0) + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textHaloColor; + + /// Distance of halo to the font outline. Max text halo width is 1/4 of + /// the font-size. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textHaloWidth; + + /// The halo's fadeout distance towards the outside. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textHaloBlur; + + /// Distance that the text's anchor is moved from its original placement. + /// Positive values indicate right and down, while negative values + /// indicate left and up. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textTranslate; + + /// Controls the frame of reference for `text-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The text is translated relative to the map. + /// "viewport" + /// The text is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textTranslateAnchor; + + // Layout Properties + /// Label placement relative to its geometry. + /// + /// Type: enum + /// default: point + /// Options: + /// "point" + /// The label is placed at the point where the geometry is located. + /// "line" + /// The label is placed along the line of the geometry. Can only be + /// used on `LineString` and `Polygon` geometries. + /// "line-center" + /// The label is placed at the center of the line of the geometry. + /// Can only be used on `LineString` and `Polygon` geometries. Note + /// that a single feature in a vector tile may contain multiple line + /// geometries. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic symbolPlacement; + + /// Distance between two symbol anchors. + /// + /// Type: number + /// default: 250 + /// minimum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic symbolSpacing; + + /// If true, the symbols will not cross tile edges to avoid mutual + /// collisions. Recommended in layers that don't have enough padding in + /// the vector tile to prevent collisions, or if it is a point symbol + /// layer placed after a line symbol layer. When using a client that + /// supports global collision detection, like MapLibre GL JS, enabling + /// this property is not needed to prevent clipped labels at tile + /// boundaries. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic symbolAvoidEdges; + + /// Sorts features in ascending order based on this value. Features with + /// lower sort keys are drawn and placed first. When `icon-allow-overlap` + /// or `text-allow-overlap` is `false`, features with a lower sort key + /// will have priority during placement. When `icon-allow-overlap` or + /// `text-allow-overlap` is set to `true`, features with a higher sort key + /// will overlap over features with a lower sort key. + /// + /// Type: number + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic symbolSortKey; + + /// Controls the order in which overlapping symbols in the same layer are + /// rendered + /// + /// Type: enum + /// default: auto + /// Options: + /// "auto" + /// If `symbol-sort-key` is set, sort based on that. Otherwise sort + /// symbols by their y-position relative to the viewport. + /// "viewport-y" + /// Symbols will be sorted by their y-position relative to the + /// viewport. + /// "source" + /// Symbols will be rendered in the same order as the source data + /// with no sorting applied. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic symbolZOrder; + + /// If true, the icon will be visible even if it collides with other + /// previously drawn symbols. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconAllowOverlap; + + /// If true, other symbols can be visible even if they collide with the + /// icon. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconIgnorePlacement; + + /// If true, text will display without their corresponding icons when the + /// icon collides with other symbols and the text does not. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconOptional; + + /// In combination with `symbol-placement`, determines the rotation + /// behavior of icons. + /// + /// Type: enum + /// default: auto + /// Options: + /// "map" + /// When `symbol-placement` is set to `point`, aligns icons + /// east-west. When `symbol-placement` is set to `line` or + /// `line-center`, aligns icon x-axes with the line. + /// "viewport" + /// Produces icons whose x-axes are aligned with the x-axis of the + /// viewport, regardless of the value of `symbol-placement`. + /// "auto" + /// When `symbol-placement` is set to `point`, this is equivalent to + /// `viewport`. When `symbol-placement` is set to `line` or + /// `line-center`, this is equivalent to `map`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconRotationAlignment; + + /// Scales the original size of the icon by the provided factor. The new + /// pixel size of the image will be the original pixel size multiplied by + /// `icon-size`. 1 is the original size; 3 triples the size of the image. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconSize; + + /// Scales the icon to fit around the associated text. + /// + /// Type: enum + /// default: none + /// Options: + /// "none" + /// The icon is displayed at its intrinsic aspect ratio. + /// "width" + /// The icon is scaled in the x-dimension to fit the width of the + /// text. + /// "height" + /// The icon is scaled in the y-dimension to fit the height of the + /// text. + /// "both" + /// The icon is scaled in both x- and y-dimensions. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconTextFit; + + /// Size of the additional area added to dimensions determined by + /// `icon-text-fit`, in clockwise order: top, right, bottom, left. + /// + /// Type: array + /// default: [0, 0, 0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconTextFitPadding; + + /// Name of image in sprite to use for drawing an image background. + /// + /// Type: resolvedImage + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconImage; + + /// Rotates the icon clockwise. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconRotate; + + /// Size of the additional area around the icon bounding box used for + /// detecting symbol collisions. + /// + /// Type: number + /// default: 2 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconPadding; + + /// If true, the icon may be flipped to prevent it from being rendered + /// upside-down. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconKeepUpright; + + /// Offset distance of icon from its anchor. Positive values indicate + /// right and down, while negative values indicate left and up. Each + /// component is multiplied by the value of `icon-size` to obtain the + /// final offset in pixels. When combined with `icon-rotate` the offset + /// will be as if the rotated direction was up. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconOffset; + + /// Part of the icon placed closest to the anchor. + /// + /// Type: enum + /// default: center + /// Options: + /// "center" + /// The center of the icon is placed closest to the anchor. + /// "left" + /// The left side of the icon is placed closest to the anchor. + /// "right" + /// The right side of the icon is placed closest to the anchor. + /// "top" + /// The top of the icon is placed closest to the anchor. + /// "bottom" + /// The bottom of the icon is placed closest to the anchor. + /// "top-left" + /// The top left corner of the icon is placed closest to the anchor. + /// "top-right" + /// The top right corner of the icon is placed closest to the anchor. + /// "bottom-left" + /// The bottom left corner of the icon is placed closest to the + /// anchor. + /// "bottom-right" + /// The bottom right corner of the icon is placed closest to the + /// anchor. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconAnchor; + + /// Orientation of icon when map is pitched. + /// + /// Type: enum + /// default: auto + /// Options: + /// "map" + /// The icon is aligned to the plane of the map. + /// "viewport" + /// The icon is aligned to the plane of the viewport. + /// "auto" + /// Automatically matches the value of `icon-rotation-alignment`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconPitchAlignment; + + /// Orientation of text when map is pitched. + /// + /// Type: enum + /// default: auto + /// Options: + /// "map" + /// The text is aligned to the plane of the map. + /// "viewport" + /// The text is aligned to the plane of the viewport. + /// "auto" + /// Automatically matches the value of `text-rotation-alignment`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textPitchAlignment; + + /// In combination with `symbol-placement`, determines the rotation + /// behavior of the individual glyphs forming the text. + /// + /// Type: enum + /// default: auto + /// Options: + /// "map" + /// When `symbol-placement` is set to `point`, aligns text east-west. + /// When `symbol-placement` is set to `line` or `line-center`, aligns + /// text x-axes with the line. + /// "viewport" + /// Produces glyphs whose x-axes are aligned with the x-axis of the + /// viewport, regardless of the value of `symbol-placement`. + /// "auto" + /// When `symbol-placement` is set to `point`, this is equivalent to + /// `viewport`. When `symbol-placement` is set to `line` or + /// `line-center`, this is equivalent to `map`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textRotationAlignment; + + /// Value to use for a text label. If a plain `string` is provided, it + /// will be treated as a `formatted` with default/inherited formatting + /// options. + /// + /// Type: formatted + /// default: + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textField; + + /// Font stack to use for displaying text. + /// + /// Type: array + /// default: [Open Sans Regular, Arial Unicode MS Regular] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textFont; + + /// Font size. + /// + /// Type: number + /// default: 16 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textSize; + + /// The maximum line width for text wrapping. + /// + /// Type: number + /// default: 10 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textMaxWidth; + + /// Text leading value for multi-line text. + /// + /// Type: number + /// default: 1.2 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textLineHeight; + + /// Text tracking amount. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textLetterSpacing; + + /// Text justification options. + /// + /// Type: enum + /// default: center + /// Options: + /// "auto" + /// The text is aligned towards the anchor position. + /// "left" + /// The text is aligned to the left. + /// "center" + /// The text is centered. + /// "right" + /// The text is aligned to the right. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textJustify; + + /// Radial offset of text, in the direction of the symbol's anchor. Useful + /// in combination with `text-variable-anchor`, which defaults to using + /// the two-dimensional `text-offset` if present. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textRadialOffset; + + /// To increase the chance of placing high-priority labels on the map, you + /// can provide an array of `text-anchor` locations: the renderer will + /// attempt to place the label at each location, in order, before moving + /// onto the next label. Use `text-justify: auto` to choose justification + /// based on anchor position. To apply an offset, use the + /// `text-radial-offset` or the two-dimensional `text-offset`. + /// + /// Type: array + /// Options: + /// "center" + /// The center of the text is placed closest to the anchor. + /// "left" + /// The left side of the text is placed closest to the anchor. + /// "right" + /// The right side of the text is placed closest to the anchor. + /// "top" + /// The top of the text is placed closest to the anchor. + /// "bottom" + /// The bottom of the text is placed closest to the anchor. + /// "top-left" + /// The top left corner of the text is placed closest to the anchor. + /// "top-right" + /// The top right corner of the text is placed closest to the anchor. + /// "bottom-left" + /// The bottom left corner of the text is placed closest to the + /// anchor. + /// "bottom-right" + /// The bottom right corner of the text is placed closest to the + /// anchor. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textVariableAnchor; + + /// Part of the text placed closest to the anchor. + /// + /// Type: enum + /// default: center + /// Options: + /// "center" + /// The center of the text is placed closest to the anchor. + /// "left" + /// The left side of the text is placed closest to the anchor. + /// "right" + /// The right side of the text is placed closest to the anchor. + /// "top" + /// The top of the text is placed closest to the anchor. + /// "bottom" + /// The bottom of the text is placed closest to the anchor. + /// "top-left" + /// The top left corner of the text is placed closest to the anchor. + /// "top-right" + /// The top right corner of the text is placed closest to the anchor. + /// "bottom-left" + /// The bottom left corner of the text is placed closest to the + /// anchor. + /// "bottom-right" + /// The bottom right corner of the text is placed closest to the + /// anchor. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textAnchor; + + /// Maximum angle change between adjacent characters. + /// + /// Type: number + /// default: 45 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textMaxAngle; + + /// The property allows control over a symbol's orientation. Note that the + /// property values act as a hint, so that a symbol whose language doesn’t + /// support the provided orientation will be laid out in its natural + /// orientation. Example: English point symbol will be rendered + /// horizontally even if array value contains single 'vertical' enum + /// value. The order of elements in an array define priority order for the + /// placement of an orientation variant. + /// + /// Type: array + /// Options: + /// "horizontal" + /// If a text's language supports horizontal writing mode, symbols + /// with point placement would be laid out horizontally. + /// "vertical" + /// If a text's language supports vertical writing mode, symbols with + /// point placement would be laid out vertically. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textWritingMode; + + /// Rotates the text clockwise. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textRotate; + + /// Size of the additional area around the text bounding box used for + /// detecting symbol collisions. + /// + /// Type: number + /// default: 2 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textPadding; + + /// If true, the text may be flipped vertically to prevent it from being + /// rendered upside-down. + /// + /// Type: boolean + /// default: true + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textKeepUpright; + + /// Specifies how to capitalize text, similar to the CSS `text-transform` + /// property. + /// + /// Type: enum + /// default: none + /// Options: + /// "none" + /// The text is not altered. + /// "uppercase" + /// Forces all letters to be displayed in uppercase. + /// "lowercase" + /// Forces all letters to be displayed in lowercase. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textTransform; + + /// Offset distance of text from its anchor. Positive values indicate + /// right and down, while negative values indicate left and up. If used + /// with text-variable-anchor, input values will be taken as absolute + /// values. Offsets along the x- and y-axis will be applied automatically + /// based on the anchor position. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textOffset; + + /// If true, the text will be visible even if it collides with other + /// previously drawn symbols. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textAllowOverlap; + + /// If true, other symbols can be visible even if they collide with the + /// text. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textIgnorePlacement; + + /// If true, icons will display without their corresponding text when the + /// text collides with other symbols and the icon does not. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textOptional; + + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const SymbolLayerProperties({ + this.iconOpacity, + this.iconColor, + this.iconHaloColor, + this.iconHaloWidth, + this.iconHaloBlur, + this.iconTranslate, + this.iconTranslateAnchor, + this.textOpacity, + this.textColor, + this.textHaloColor, + this.textHaloWidth, + this.textHaloBlur, + this.textTranslate, + this.textTranslateAnchor, + this.symbolPlacement, + this.symbolSpacing, + this.symbolAvoidEdges, + this.symbolSortKey, + this.symbolZOrder, + this.iconAllowOverlap, + this.iconIgnorePlacement, + this.iconOptional, + this.iconRotationAlignment, + this.iconSize, + this.iconTextFit, + this.iconTextFitPadding, + this.iconImage, + this.iconRotate, + this.iconPadding, + this.iconKeepUpright, + this.iconOffset, + this.iconAnchor, + this.iconPitchAlignment, + this.textPitchAlignment, + this.textRotationAlignment, + this.textField, + this.textFont, + this.textSize, + this.textMaxWidth, + this.textLineHeight, + this.textLetterSpacing, + this.textJustify, + this.textRadialOffset, + this.textVariableAnchor, + this.textAnchor, + this.textMaxAngle, + this.textWritingMode, + this.textRotate, + this.textPadding, + this.textKeepUpright, + this.textTransform, + this.textOffset, + this.textAllowOverlap, + this.textIgnorePlacement, + this.textOptional, + this.visibility, + }); + + SymbolLayerProperties copyWith(SymbolLayerProperties changes) { + return SymbolLayerProperties( + iconOpacity: changes.iconOpacity ?? iconOpacity, + iconColor: changes.iconColor ?? iconColor, + iconHaloColor: changes.iconHaloColor ?? iconHaloColor, + iconHaloWidth: changes.iconHaloWidth ?? iconHaloWidth, + iconHaloBlur: changes.iconHaloBlur ?? iconHaloBlur, + iconTranslate: changes.iconTranslate ?? iconTranslate, + iconTranslateAnchor: changes.iconTranslateAnchor ?? iconTranslateAnchor, + textOpacity: changes.textOpacity ?? textOpacity, + textColor: changes.textColor ?? textColor, + textHaloColor: changes.textHaloColor ?? textHaloColor, + textHaloWidth: changes.textHaloWidth ?? textHaloWidth, + textHaloBlur: changes.textHaloBlur ?? textHaloBlur, + textTranslate: changes.textTranslate ?? textTranslate, + textTranslateAnchor: changes.textTranslateAnchor ?? textTranslateAnchor, + symbolPlacement: changes.symbolPlacement ?? symbolPlacement, + symbolSpacing: changes.symbolSpacing ?? symbolSpacing, + symbolAvoidEdges: changes.symbolAvoidEdges ?? symbolAvoidEdges, + symbolSortKey: changes.symbolSortKey ?? symbolSortKey, + symbolZOrder: changes.symbolZOrder ?? symbolZOrder, + iconAllowOverlap: changes.iconAllowOverlap ?? iconAllowOverlap, + iconIgnorePlacement: changes.iconIgnorePlacement ?? iconIgnorePlacement, + iconOptional: changes.iconOptional ?? iconOptional, + iconRotationAlignment: + changes.iconRotationAlignment ?? iconRotationAlignment, + iconSize: changes.iconSize ?? iconSize, + iconTextFit: changes.iconTextFit ?? iconTextFit, + iconTextFitPadding: changes.iconTextFitPadding ?? iconTextFitPadding, + iconImage: changes.iconImage ?? iconImage, + iconRotate: changes.iconRotate ?? iconRotate, + iconPadding: changes.iconPadding ?? iconPadding, + iconKeepUpright: changes.iconKeepUpright ?? iconKeepUpright, + iconOffset: changes.iconOffset ?? iconOffset, + iconAnchor: changes.iconAnchor ?? iconAnchor, + iconPitchAlignment: changes.iconPitchAlignment ?? iconPitchAlignment, + textPitchAlignment: changes.textPitchAlignment ?? textPitchAlignment, + textRotationAlignment: + changes.textRotationAlignment ?? textRotationAlignment, + textField: changes.textField ?? textField, + textFont: changes.textFont ?? textFont, + textSize: changes.textSize ?? textSize, + textMaxWidth: changes.textMaxWidth ?? textMaxWidth, + textLineHeight: changes.textLineHeight ?? textLineHeight, + textLetterSpacing: changes.textLetterSpacing ?? textLetterSpacing, + textJustify: changes.textJustify ?? textJustify, + textRadialOffset: changes.textRadialOffset ?? textRadialOffset, + textVariableAnchor: changes.textVariableAnchor ?? textVariableAnchor, + textAnchor: changes.textAnchor ?? textAnchor, + textMaxAngle: changes.textMaxAngle ?? textMaxAngle, + textWritingMode: changes.textWritingMode ?? textWritingMode, + textRotate: changes.textRotate ?? textRotate, + textPadding: changes.textPadding ?? textPadding, + textKeepUpright: changes.textKeepUpright ?? textKeepUpright, + textTransform: changes.textTransform ?? textTransform, + textOffset: changes.textOffset ?? textOffset, + textAllowOverlap: changes.textAllowOverlap ?? textAllowOverlap, + textIgnorePlacement: changes.textIgnorePlacement ?? textIgnorePlacement, + textOptional: changes.textOptional ?? textOptional, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('icon-opacity', iconOpacity); + addIfPresent('icon-color', iconColor); + addIfPresent('icon-halo-color', iconHaloColor); + addIfPresent('icon-halo-width', iconHaloWidth); + addIfPresent('icon-halo-blur', iconHaloBlur); + addIfPresent('icon-translate', iconTranslate); + addIfPresent('icon-translate-anchor', iconTranslateAnchor); + addIfPresent('text-opacity', textOpacity); + addIfPresent('text-color', textColor); + addIfPresent('text-halo-color', textHaloColor); + addIfPresent('text-halo-width', textHaloWidth); + addIfPresent('text-halo-blur', textHaloBlur); + addIfPresent('text-translate', textTranslate); + addIfPresent('text-translate-anchor', textTranslateAnchor); + addIfPresent('symbol-placement', symbolPlacement); + addIfPresent('symbol-spacing', symbolSpacing); + addIfPresent('symbol-avoid-edges', symbolAvoidEdges); + addIfPresent('symbol-sort-key', symbolSortKey); + addIfPresent('symbol-z-order', symbolZOrder); + addIfPresent('icon-allow-overlap', iconAllowOverlap); + addIfPresent('icon-ignore-placement', iconIgnorePlacement); + addIfPresent('icon-optional', iconOptional); + addIfPresent('icon-rotation-alignment', iconRotationAlignment); + addIfPresent('icon-size', iconSize); + addIfPresent('icon-text-fit', iconTextFit); + addIfPresent('icon-text-fit-padding', iconTextFitPadding); + addIfPresent('icon-image', iconImage); + addIfPresent('icon-rotate', iconRotate); + addIfPresent('icon-padding', iconPadding); + addIfPresent('icon-keep-upright', iconKeepUpright); + addIfPresent('icon-offset', iconOffset); + addIfPresent('icon-anchor', iconAnchor); + addIfPresent('icon-pitch-alignment', iconPitchAlignment); + addIfPresent('text-pitch-alignment', textPitchAlignment); + addIfPresent('text-rotation-alignment', textRotationAlignment); + addIfPresent('text-field', textField); + addIfPresent('text-font', textFont); + addIfPresent('text-size', textSize); + addIfPresent('text-max-width', textMaxWidth); + addIfPresent('text-line-height', textLineHeight); + addIfPresent('text-letter-spacing', textLetterSpacing); + addIfPresent('text-justify', textJustify); + addIfPresent('text-radial-offset', textRadialOffset); + addIfPresent('text-variable-anchor', textVariableAnchor); + addIfPresent('text-anchor', textAnchor); + addIfPresent('text-max-angle', textMaxAngle); + addIfPresent('text-writing-mode', textWritingMode); + addIfPresent('text-rotate', textRotate); + addIfPresent('text-padding', textPadding); + addIfPresent('text-keep-upright', textKeepUpright); + addIfPresent('text-transform', textTransform); + addIfPresent('text-offset', textOffset); + addIfPresent('text-allow-overlap', textAllowOverlap); + addIfPresent('text-ignore-placement', textIgnorePlacement); + addIfPresent('text-optional', textOptional); + addIfPresent('visibility', visibility); + return json; + } + + factory SymbolLayerProperties.fromJson(Map json) { + return SymbolLayerProperties( + iconOpacity: json['icon-opacity'], + iconColor: json['icon-color'], + iconHaloColor: json['icon-halo-color'], + iconHaloWidth: json['icon-halo-width'], + iconHaloBlur: json['icon-halo-blur'], + iconTranslate: json['icon-translate'], + iconTranslateAnchor: json['icon-translate-anchor'], + textOpacity: json['text-opacity'], + textColor: json['text-color'], + textHaloColor: json['text-halo-color'], + textHaloWidth: json['text-halo-width'], + textHaloBlur: json['text-halo-blur'], + textTranslate: json['text-translate'], + textTranslateAnchor: json['text-translate-anchor'], + symbolPlacement: json['symbol-placement'], + symbolSpacing: json['symbol-spacing'], + symbolAvoidEdges: json['symbol-avoid-edges'], + symbolSortKey: json['symbol-sort-key'], + symbolZOrder: json['symbol-z-order'], + iconAllowOverlap: json['icon-allow-overlap'], + iconIgnorePlacement: json['icon-ignore-placement'], + iconOptional: json['icon-optional'], + iconRotationAlignment: json['icon-rotation-alignment'], + iconSize: json['icon-size'], + iconTextFit: json['icon-text-fit'], + iconTextFitPadding: json['icon-text-fit-padding'], + iconImage: json['icon-image'], + iconRotate: json['icon-rotate'], + iconPadding: json['icon-padding'], + iconKeepUpright: json['icon-keep-upright'], + iconOffset: json['icon-offset'], + iconAnchor: json['icon-anchor'], + iconPitchAlignment: json['icon-pitch-alignment'], + textPitchAlignment: json['text-pitch-alignment'], + textRotationAlignment: json['text-rotation-alignment'], + textField: json['text-field'], + textFont: json['text-font'], + textSize: json['text-size'], + textMaxWidth: json['text-max-width'], + textLineHeight: json['text-line-height'], + textLetterSpacing: json['text-letter-spacing'], + textJustify: json['text-justify'], + textRadialOffset: json['text-radial-offset'], + textVariableAnchor: json['text-variable-anchor'], + textAnchor: json['text-anchor'], + textMaxAngle: json['text-max-angle'], + textWritingMode: json['text-writing-mode'], + textRotate: json['text-rotate'], + textPadding: json['text-padding'], + textKeepUpright: json['text-keep-upright'], + textTransform: json['text-transform'], + textOffset: json['text-offset'], + textAllowOverlap: json['text-allow-overlap'], + textIgnorePlacement: json['text-ignore-placement'], + textOptional: json['text-optional'], + visibility: json['visibility'], + ); + } +} + +class CircleLayerProperties implements LayerProperties { + // Paint Properties + /// Circle radius. + /// + /// Type: number + /// default: 5 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleRadius; + + /// The fill color of the circle. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleColor; + + /// Amount to blur the circle. 1 blurs the circle such that only the + /// centerpoint is full opacity. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleBlur; + + /// The opacity at which the circle will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleOpacity; + + /// The geometry's offset. Values are [x, y] where negatives indicate left + /// and up, respectively. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic circleTranslate; + + /// Controls the frame of reference for `circle-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The circle is translated relative to the map. + /// "viewport" + /// The circle is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic circleTranslateAnchor; + + /// Controls the scaling behavior of the circle when the map is pitched. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// Circles are scaled according to their apparent distance to the + /// camera. + /// "viewport" + /// Circles are not scaled. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic circlePitchScale; + + /// Orientation of circle when map is pitched. + /// + /// Type: enum + /// default: viewport + /// Options: + /// "map" + /// The circle is aligned to the plane of the map. + /// "viewport" + /// The circle is aligned to the plane of the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic circlePitchAlignment; + + /// The width of the circle's stroke. Strokes are placed outside of the + /// `circle-radius`. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleStrokeWidth; + + /// The stroke color of the circle. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleStrokeColor; + + /// The opacity of the circle's stroke. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleStrokeOpacity; + + // Layout Properties + /// Sorts features in ascending order based on this value. Features with a + /// higher sort key will appear above features with a lower sort key. + /// + /// Type: number + /// + /// Sdk Support: + /// basic functionality with js + /// data-driven styling with js + final dynamic circleSortKey; + + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const CircleLayerProperties({ + this.circleRadius, + this.circleColor, + this.circleBlur, + this.circleOpacity, + this.circleTranslate, + this.circleTranslateAnchor, + this.circlePitchScale, + this.circlePitchAlignment, + this.circleStrokeWidth, + this.circleStrokeColor, + this.circleStrokeOpacity, + this.circleSortKey, + this.visibility, + }); + + CircleLayerProperties copyWith(CircleLayerProperties changes) { + return CircleLayerProperties( + circleRadius: changes.circleRadius ?? circleRadius, + circleColor: changes.circleColor ?? circleColor, + circleBlur: changes.circleBlur ?? circleBlur, + circleOpacity: changes.circleOpacity ?? circleOpacity, + circleTranslate: changes.circleTranslate ?? circleTranslate, + circleTranslateAnchor: + changes.circleTranslateAnchor ?? circleTranslateAnchor, + circlePitchScale: changes.circlePitchScale ?? circlePitchScale, + circlePitchAlignment: + changes.circlePitchAlignment ?? circlePitchAlignment, + circleStrokeWidth: changes.circleStrokeWidth ?? circleStrokeWidth, + circleStrokeColor: changes.circleStrokeColor ?? circleStrokeColor, + circleStrokeOpacity: changes.circleStrokeOpacity ?? circleStrokeOpacity, + circleSortKey: changes.circleSortKey ?? circleSortKey, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('circle-radius', circleRadius); + addIfPresent('circle-color', circleColor); + addIfPresent('circle-blur', circleBlur); + addIfPresent('circle-opacity', circleOpacity); + addIfPresent('circle-translate', circleTranslate); + addIfPresent('circle-translate-anchor', circleTranslateAnchor); + addIfPresent('circle-pitch-scale', circlePitchScale); + addIfPresent('circle-pitch-alignment', circlePitchAlignment); + addIfPresent('circle-stroke-width', circleStrokeWidth); + addIfPresent('circle-stroke-color', circleStrokeColor); + addIfPresent('circle-stroke-opacity', circleStrokeOpacity); + addIfPresent('circle-sort-key', circleSortKey); + addIfPresent('visibility', visibility); + return json; + } + + factory CircleLayerProperties.fromJson(Map json) { + return CircleLayerProperties( + circleRadius: json['circle-radius'], + circleColor: json['circle-color'], + circleBlur: json['circle-blur'], + circleOpacity: json['circle-opacity'], + circleTranslate: json['circle-translate'], + circleTranslateAnchor: json['circle-translate-anchor'], + circlePitchScale: json['circle-pitch-scale'], + circlePitchAlignment: json['circle-pitch-alignment'], + circleStrokeWidth: json['circle-stroke-width'], + circleStrokeColor: json['circle-stroke-color'], + circleStrokeOpacity: json['circle-stroke-opacity'], + circleSortKey: json['circle-sort-key'], + visibility: json['visibility'], + ); + } +} + +class LineLayerProperties implements LayerProperties { + // Paint Properties + /// The opacity at which the line will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineOpacity; + + /// The color with which the line will be drawn. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineColor; + + /// The geometry's offset. Values are [x, y] where negatives indicate left + /// and up, respectively. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineTranslate; + + /// Controls the frame of reference for `line-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The line is translated relative to the map. + /// "viewport" + /// The line is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineTranslateAnchor; + + /// Stroke thickness. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineWidth; + + /// Draws a line casing outside of a line's actual path. Value indicates + /// the width of the inner gap. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineGapWidth; + + /// The line's offset. For linear features, a positive value offsets the + /// line to the right, relative to the direction of the line, and a + /// negative value to the left. For polygon features, a positive value + /// results in an inset, and a negative value results in an outset. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineOffset; + + /// Blur applied to the line, in pixels. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineBlur; + + /// Specifies the lengths of the alternating dashes and gaps that form the + /// dash pattern. The lengths are later scaled by the line width. To + /// convert a dash length to pixels, multiply the length by the current + /// line width. Note that GeoJSON sources with `lineMetrics: true` + /// specified won't render dashed lines to the expected scale. Also note + /// that zoom-dependent expressions will be evaluated only at integer zoom + /// levels. + /// + /// Type: array + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineDasharray; + + /// Name of image in sprite to use for drawing image lines. For seamless + /// patterns, image width must be a factor of two (2, 4, 8, ..., 512). + /// Note that zoom-dependent expressions will be evaluated only at integer + /// zoom levels. + /// + /// Type: resolvedImage + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, macos, ios + final dynamic linePattern; + + /// Defines a gradient with which to color a line feature. Can only be + /// used with GeoJSON sources that specify `"lineMetrics": true`. + /// + /// Type: color + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineGradient; + + // Layout Properties + /// The display of line endings. + /// + /// Type: enum + /// default: butt + /// Options: + /// "butt" + /// A cap with a squared-off end which is drawn to the exact endpoint + /// of the line. + /// "round" + /// A cap with a rounded end which is drawn beyond the endpoint of + /// the line at a radius of one-half of the line's width and centered + /// on the endpoint of the line. + /// "square" + /// A cap with a squared-off end which is drawn beyond the endpoint + /// of the line at a distance of one-half of the line's width. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineCap; + + /// The display of lines when joining. + /// + /// Type: enum + /// default: miter + /// Options: + /// "bevel" + /// A join with a squared-off end which is drawn beyond the endpoint + /// of the line at a distance of one-half of the line's width. + /// "round" + /// A join with a rounded end which is drawn beyond the endpoint of + /// the line at a radius of one-half of the line's width and centered + /// on the endpoint of the line. + /// "miter" + /// A join with a sharp, angled corner which is drawn with the outer + /// sides beyond the endpoint of the path until they meet. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineJoin; + + /// Used to automatically convert miter joins to bevel joins for sharp + /// angles. + /// + /// Type: number + /// default: 2 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineMiterLimit; + + /// Used to automatically convert round joins to miter joins for shallow + /// angles. + /// + /// Type: number + /// default: 1.05 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineRoundLimit; + + /// Sorts features in ascending order based on this value. Features with a + /// higher sort key will appear above features with a lower sort key. + /// + /// Type: number + /// + /// Sdk Support: + /// basic functionality with js + /// data-driven styling with js + final dynamic lineSortKey; + + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const LineLayerProperties({ + this.lineOpacity, + this.lineColor, + this.lineTranslate, + this.lineTranslateAnchor, + this.lineWidth, + this.lineGapWidth, + this.lineOffset, + this.lineBlur, + this.lineDasharray, + this.linePattern, + this.lineGradient, + this.lineCap, + this.lineJoin, + this.lineMiterLimit, + this.lineRoundLimit, + this.lineSortKey, + this.visibility, + }); + + LineLayerProperties copyWith(LineLayerProperties changes) { + return LineLayerProperties( + lineOpacity: changes.lineOpacity ?? lineOpacity, + lineColor: changes.lineColor ?? lineColor, + lineTranslate: changes.lineTranslate ?? lineTranslate, + lineTranslateAnchor: changes.lineTranslateAnchor ?? lineTranslateAnchor, + lineWidth: changes.lineWidth ?? lineWidth, + lineGapWidth: changes.lineGapWidth ?? lineGapWidth, + lineOffset: changes.lineOffset ?? lineOffset, + lineBlur: changes.lineBlur ?? lineBlur, + lineDasharray: changes.lineDasharray ?? lineDasharray, + linePattern: changes.linePattern ?? linePattern, + lineGradient: changes.lineGradient ?? lineGradient, + lineCap: changes.lineCap ?? lineCap, + lineJoin: changes.lineJoin ?? lineJoin, + lineMiterLimit: changes.lineMiterLimit ?? lineMiterLimit, + lineRoundLimit: changes.lineRoundLimit ?? lineRoundLimit, + lineSortKey: changes.lineSortKey ?? lineSortKey, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('line-opacity', lineOpacity); + addIfPresent('line-color', lineColor); + addIfPresent('line-translate', lineTranslate); + addIfPresent('line-translate-anchor', lineTranslateAnchor); + addIfPresent('line-width', lineWidth); + addIfPresent('line-gap-width', lineGapWidth); + addIfPresent('line-offset', lineOffset); + addIfPresent('line-blur', lineBlur); + addIfPresent('line-dasharray', lineDasharray); + addIfPresent('line-pattern', linePattern); + addIfPresent('line-gradient', lineGradient); + addIfPresent('line-cap', lineCap); + addIfPresent('line-join', lineJoin); + addIfPresent('line-miter-limit', lineMiterLimit); + addIfPresent('line-round-limit', lineRoundLimit); + addIfPresent('line-sort-key', lineSortKey); + addIfPresent('visibility', visibility); + return json; + } + + factory LineLayerProperties.fromJson(Map json) { + return LineLayerProperties( + lineOpacity: json['line-opacity'], + lineColor: json['line-color'], + lineTranslate: json['line-translate'], + lineTranslateAnchor: json['line-translate-anchor'], + lineWidth: json['line-width'], + lineGapWidth: json['line-gap-width'], + lineOffset: json['line-offset'], + lineBlur: json['line-blur'], + lineDasharray: json['line-dasharray'], + linePattern: json['line-pattern'], + lineGradient: json['line-gradient'], + lineCap: json['line-cap'], + lineJoin: json['line-join'], + lineMiterLimit: json['line-miter-limit'], + lineRoundLimit: json['line-round-limit'], + lineSortKey: json['line-sort-key'], + visibility: json['visibility'], + ); + } +} + +class FillLayerProperties implements LayerProperties { + // Paint Properties + /// Whether or not the fill should be antialiased. + /// + /// Type: boolean + /// default: true + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillAntialias; + + /// The opacity of the entire fill layer. In contrast to the `fill-color`, + /// this value will also affect the 1px stroke around the fill, if the + /// stroke is used. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillOpacity; + + /// The color of the filled part of this layer. This color can be + /// specified as `rgba` with an alpha component and the color's opacity + /// will not affect the opacity of the 1px stroke, if it is used. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillColor; + + /// The outline color of the fill. Matches the value of `fill-color` if + /// unspecified. + /// + /// Type: color + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillOutlineColor; + + /// The geometry's offset. Values are [x, y] where negatives indicate left + /// and up, respectively. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillTranslate; + + /// Controls the frame of reference for `fill-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The fill is translated relative to the map. + /// "viewport" + /// The fill is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillTranslateAnchor; + + /// Name of image in sprite to use for drawing image fills. For seamless + /// patterns, image width and height must be a factor of two (2, 4, 8, + /// ..., 512). Note that zoom-dependent expressions will be evaluated only + /// at integer zoom levels. + /// + /// Type: resolvedImage + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, macos, ios + final dynamic fillPattern; + + // Layout Properties + /// Sorts features in ascending order based on this value. Features with a + /// higher sort key will appear above features with a lower sort key. + /// + /// Type: number + /// + /// Sdk Support: + /// basic functionality with js + /// data-driven styling with js + final dynamic fillSortKey; + + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const FillLayerProperties({ + this.fillAntialias, + this.fillOpacity, + this.fillColor, + this.fillOutlineColor, + this.fillTranslate, + this.fillTranslateAnchor, + this.fillPattern, + this.fillSortKey, + this.visibility, + }); + + FillLayerProperties copyWith(FillLayerProperties changes) { + return FillLayerProperties( + fillAntialias: changes.fillAntialias ?? fillAntialias, + fillOpacity: changes.fillOpacity ?? fillOpacity, + fillColor: changes.fillColor ?? fillColor, + fillOutlineColor: changes.fillOutlineColor ?? fillOutlineColor, + fillTranslate: changes.fillTranslate ?? fillTranslate, + fillTranslateAnchor: changes.fillTranslateAnchor ?? fillTranslateAnchor, + fillPattern: changes.fillPattern ?? fillPattern, + fillSortKey: changes.fillSortKey ?? fillSortKey, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('fill-antialias', fillAntialias); + addIfPresent('fill-opacity', fillOpacity); + addIfPresent('fill-color', fillColor); + addIfPresent('fill-outline-color', fillOutlineColor); + addIfPresent('fill-translate', fillTranslate); + addIfPresent('fill-translate-anchor', fillTranslateAnchor); + addIfPresent('fill-pattern', fillPattern); + addIfPresent('fill-sort-key', fillSortKey); + addIfPresent('visibility', visibility); + return json; + } + + factory FillLayerProperties.fromJson(Map json) { + return FillLayerProperties( + fillAntialias: json['fill-antialias'], + fillOpacity: json['fill-opacity'], + fillColor: json['fill-color'], + fillOutlineColor: json['fill-outline-color'], + fillTranslate: json['fill-translate'], + fillTranslateAnchor: json['fill-translate-anchor'], + fillPattern: json['fill-pattern'], + fillSortKey: json['fill-sort-key'], + visibility: json['visibility'], + ); + } +} + +class FillExtrusionLayerProperties implements LayerProperties { + // Paint Properties + /// The opacity of the entire fill extrusion layer. This is rendered on a + /// per-layer, not per-feature, basis, and data-driven styling is not + /// available. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillExtrusionOpacity; + + /// The base color of the extruded fill. The extrusion's surfaces will be + /// shaded differently based on this color in combination with the root + /// `light` settings. If this color is specified as `rgba` with an alpha + /// component, the alpha component will be ignored; use + /// `fill-extrusion-opacity` to set layer opacity. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillExtrusionColor; + + /// The geometry's offset. Values are [x, y] where negatives indicate left + /// and up (on the flat plane), respectively. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillExtrusionTranslate; + + /// Controls the frame of reference for `fill-extrusion-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The fill extrusion is translated relative to the map. + /// "viewport" + /// The fill extrusion is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillExtrusionTranslateAnchor; + + /// Name of image in sprite to use for drawing images on extruded fills. + /// For seamless patterns, image width and height must be a factor of two + /// (2, 4, 8, ..., 512). Note that zoom-dependent expressions will be + /// evaluated only at integer zoom levels. + /// + /// Type: resolvedImage + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, macos, ios + final dynamic fillExtrusionPattern; + + /// The height with which to extrude this layer. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillExtrusionHeight; + + /// The height with which to extrude the base of this layer. Must be less + /// than or equal to `fill-extrusion-height`. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillExtrusionBase; + + /// Whether to apply a vertical gradient to the sides of a fill-extrusion + /// layer. If true, sides will be shaded slightly darker farther down. + /// + /// Type: boolean + /// default: true + /// + /// Sdk Support: + /// basic functionality with js, ios, macos + final dynamic fillExtrusionVerticalGradient; + + // Layout Properties + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const FillExtrusionLayerProperties({ + this.fillExtrusionOpacity, + this.fillExtrusionColor, + this.fillExtrusionTranslate, + this.fillExtrusionTranslateAnchor, + this.fillExtrusionPattern, + this.fillExtrusionHeight, + this.fillExtrusionBase, + this.fillExtrusionVerticalGradient, + this.visibility, + }); + + FillExtrusionLayerProperties copyWith(FillExtrusionLayerProperties changes) { + return FillExtrusionLayerProperties( + fillExtrusionOpacity: + changes.fillExtrusionOpacity ?? fillExtrusionOpacity, + fillExtrusionColor: changes.fillExtrusionColor ?? fillExtrusionColor, + fillExtrusionTranslate: + changes.fillExtrusionTranslate ?? fillExtrusionTranslate, + fillExtrusionTranslateAnchor: + changes.fillExtrusionTranslateAnchor ?? fillExtrusionTranslateAnchor, + fillExtrusionPattern: + changes.fillExtrusionPattern ?? fillExtrusionPattern, + fillExtrusionHeight: changes.fillExtrusionHeight ?? fillExtrusionHeight, + fillExtrusionBase: changes.fillExtrusionBase ?? fillExtrusionBase, + fillExtrusionVerticalGradient: changes.fillExtrusionVerticalGradient ?? + fillExtrusionVerticalGradient, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('fill-extrusion-opacity', fillExtrusionOpacity); + addIfPresent('fill-extrusion-color', fillExtrusionColor); + addIfPresent('fill-extrusion-translate', fillExtrusionTranslate); + addIfPresent( + 'fill-extrusion-translate-anchor', fillExtrusionTranslateAnchor); + addIfPresent('fill-extrusion-pattern', fillExtrusionPattern); + addIfPresent('fill-extrusion-height', fillExtrusionHeight); + addIfPresent('fill-extrusion-base', fillExtrusionBase); + addIfPresent( + 'fill-extrusion-vertical-gradient', fillExtrusionVerticalGradient); + addIfPresent('visibility', visibility); + return json; + } + + factory FillExtrusionLayerProperties.fromJson(Map json) { + return FillExtrusionLayerProperties( + fillExtrusionOpacity: json['fill-extrusion-opacity'], + fillExtrusionColor: json['fill-extrusion-color'], + fillExtrusionTranslate: json['fill-extrusion-translate'], + fillExtrusionTranslateAnchor: json['fill-extrusion-translate-anchor'], + fillExtrusionPattern: json['fill-extrusion-pattern'], + fillExtrusionHeight: json['fill-extrusion-height'], + fillExtrusionBase: json['fill-extrusion-base'], + fillExtrusionVerticalGradient: json['fill-extrusion-vertical-gradient'], + visibility: json['visibility'], + ); + } +} + +class RasterLayerProperties implements LayerProperties { + // Paint Properties + /// The opacity at which the image will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterOpacity; + + /// Rotates hues around the color wheel. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterHueRotate; + + /// Increase or reduce the brightness of the image. The value is the + /// minimum brightness. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterBrightnessMin; + + /// Increase or reduce the brightness of the image. The value is the + /// maximum brightness. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterBrightnessMax; + + /// Increase or reduce the saturation of the image. + /// + /// Type: number + /// default: 0 + /// minimum: -1 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterSaturation; + + /// Increase or reduce the contrast of the image. + /// + /// Type: number + /// default: 0 + /// minimum: -1 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterContrast; + + /// The resampling/interpolation method to use for overscaling, also known + /// as texture magnification filter + /// + /// Type: enum + /// default: linear + /// Options: + /// "linear" + /// (Bi)linear filtering interpolates pixel values using the weighted + /// average of the four closest original source pixels creating a + /// smooth but blurry look when overscaled + /// "nearest" + /// Nearest neighbor filtering interpolates pixel values using the + /// nearest original source pixel creating a sharp but pixelated look + /// when overscaled + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterResampling; + + /// Fade duration when a new tile is added. + /// + /// Type: number + /// default: 300 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterFadeDuration; + + // Layout Properties + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const RasterLayerProperties({ + this.rasterOpacity, + this.rasterHueRotate, + this.rasterBrightnessMin, + this.rasterBrightnessMax, + this.rasterSaturation, + this.rasterContrast, + this.rasterResampling, + this.rasterFadeDuration, + this.visibility, + }); + + RasterLayerProperties copyWith(RasterLayerProperties changes) { + return RasterLayerProperties( + rasterOpacity: changes.rasterOpacity ?? rasterOpacity, + rasterHueRotate: changes.rasterHueRotate ?? rasterHueRotate, + rasterBrightnessMin: changes.rasterBrightnessMin ?? rasterBrightnessMin, + rasterBrightnessMax: changes.rasterBrightnessMax ?? rasterBrightnessMax, + rasterSaturation: changes.rasterSaturation ?? rasterSaturation, + rasterContrast: changes.rasterContrast ?? rasterContrast, + rasterResampling: changes.rasterResampling ?? rasterResampling, + rasterFadeDuration: changes.rasterFadeDuration ?? rasterFadeDuration, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('raster-opacity', rasterOpacity); + addIfPresent('raster-hue-rotate', rasterHueRotate); + addIfPresent('raster-brightness-min', rasterBrightnessMin); + addIfPresent('raster-brightness-max', rasterBrightnessMax); + addIfPresent('raster-saturation', rasterSaturation); + addIfPresent('raster-contrast', rasterContrast); + addIfPresent('raster-resampling', rasterResampling); + addIfPresent('raster-fade-duration', rasterFadeDuration); + addIfPresent('visibility', visibility); + return json; + } + + factory RasterLayerProperties.fromJson(Map json) { + return RasterLayerProperties( + rasterOpacity: json['raster-opacity'], + rasterHueRotate: json['raster-hue-rotate'], + rasterBrightnessMin: json['raster-brightness-min'], + rasterBrightnessMax: json['raster-brightness-max'], + rasterSaturation: json['raster-saturation'], + rasterContrast: json['raster-contrast'], + rasterResampling: json['raster-resampling'], + rasterFadeDuration: json['raster-fade-duration'], + visibility: json['visibility'], + ); + } +} + +class HillshadeLayerProperties implements LayerProperties { + // Paint Properties + /// The direction of the light source used to generate the hillshading + /// with 0 as the top of the viewport if `hillshade-illumination-anchor` + /// is set to `viewport` and due north if `hillshade-illumination-anchor` + /// is set to `map`. + /// + /// Type: number + /// default: 335 + /// minimum: 0 + /// maximum: 359 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeIlluminationDirection; + + /// Direction of light source when map is rotated. + /// + /// Type: enum + /// default: viewport + /// Options: + /// "map" + /// The hillshade illumination is relative to the north direction. + /// "viewport" + /// The hillshade illumination is relative to the top of the + /// viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeIlluminationAnchor; + + /// Intensity of the hillshade + /// + /// Type: number + /// default: 0.5 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeExaggeration; + + /// The shading color of areas that face away from the light source. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeShadowColor; + + /// The shading color of areas that faces towards the light source. + /// + /// Type: color + /// default: #FFFFFF + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeHighlightColor; + + /// The shading color used to accentuate rugged terrain like sharp cliffs + /// and gorges. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeAccentColor; + + // Layout Properties + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const HillshadeLayerProperties({ + this.hillshadeIlluminationDirection, + this.hillshadeIlluminationAnchor, + this.hillshadeExaggeration, + this.hillshadeShadowColor, + this.hillshadeHighlightColor, + this.hillshadeAccentColor, + this.visibility, + }); + + HillshadeLayerProperties copyWith(HillshadeLayerProperties changes) { + return HillshadeLayerProperties( + hillshadeIlluminationDirection: changes.hillshadeIlluminationDirection ?? + hillshadeIlluminationDirection, + hillshadeIlluminationAnchor: + changes.hillshadeIlluminationAnchor ?? hillshadeIlluminationAnchor, + hillshadeExaggeration: + changes.hillshadeExaggeration ?? hillshadeExaggeration, + hillshadeShadowColor: + changes.hillshadeShadowColor ?? hillshadeShadowColor, + hillshadeHighlightColor: + changes.hillshadeHighlightColor ?? hillshadeHighlightColor, + hillshadeAccentColor: + changes.hillshadeAccentColor ?? hillshadeAccentColor, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent( + 'hillshade-illumination-direction', hillshadeIlluminationDirection); + addIfPresent('hillshade-illumination-anchor', hillshadeIlluminationAnchor); + addIfPresent('hillshade-exaggeration', hillshadeExaggeration); + addIfPresent('hillshade-shadow-color', hillshadeShadowColor); + addIfPresent('hillshade-highlight-color', hillshadeHighlightColor); + addIfPresent('hillshade-accent-color', hillshadeAccentColor); + addIfPresent('visibility', visibility); + return json; + } + + factory HillshadeLayerProperties.fromJson(Map json) { + return HillshadeLayerProperties( + hillshadeIlluminationDirection: json['hillshade-illumination-direction'], + hillshadeIlluminationAnchor: json['hillshade-illumination-anchor'], + hillshadeExaggeration: json['hillshade-exaggeration'], + hillshadeShadowColor: json['hillshade-shadow-color'], + hillshadeHighlightColor: json['hillshade-highlight-color'], + hillshadeAccentColor: json['hillshade-accent-color'], + visibility: json['visibility'], + ); + } +} + +class HeatmapLayerProperties implements LayerProperties { + // Paint Properties + /// Radius of influence of one heatmap point in pixels. Increasing the + /// value makes the heatmap smoother, but less detailed. + /// + /// Type: number + /// default: 30 + /// minimum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic heatmapRadius; + + /// A measure of how much an individual point contributes to the heatmap. + /// A value of 10 would be equivalent to having 10 points of weight 1 in + /// the same spot. Especially useful when combined with clustering. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic heatmapWeight; + + /// Similar to `heatmap-weight` but controls the intensity of the heatmap + /// globally. Primarily used for adjusting the heatmap based on zoom + /// level. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic heatmapIntensity; + + /// Defines the color of each pixel based on its density value in a + /// heatmap. Should be an expression that uses `["heatmap-density"]` as + /// input. + /// + /// Type: color + /// default: [interpolate, [linear], [heatmap-density], 0, rgba(0, 0, 255, 0), 0.1, royalblue, 0.3, cyan, 0.5, lime, 0.7, yellow, 1, red] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic heatmapColor; + + /// The global opacity at which the heatmap layer will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic heatmapOpacity; + + // Layout Properties + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const HeatmapLayerProperties({ + this.heatmapRadius, + this.heatmapWeight, + this.heatmapIntensity, + this.heatmapColor, + this.heatmapOpacity, + this.visibility, + }); + + HeatmapLayerProperties copyWith(HeatmapLayerProperties changes) { + return HeatmapLayerProperties( + heatmapRadius: changes.heatmapRadius ?? heatmapRadius, + heatmapWeight: changes.heatmapWeight ?? heatmapWeight, + heatmapIntensity: changes.heatmapIntensity ?? heatmapIntensity, + heatmapColor: changes.heatmapColor ?? heatmapColor, + heatmapOpacity: changes.heatmapOpacity ?? heatmapOpacity, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('heatmap-radius', heatmapRadius); + addIfPresent('heatmap-weight', heatmapWeight); + addIfPresent('heatmap-intensity', heatmapIntensity); + addIfPresent('heatmap-color', heatmapColor); + addIfPresent('heatmap-opacity', heatmapOpacity); + addIfPresent('visibility', visibility); + return json; + } + + factory HeatmapLayerProperties.fromJson(Map json) { + return HeatmapLayerProperties( + heatmapRadius: json['heatmap-radius'], + heatmapWeight: json['heatmap-weight'], + heatmapIntensity: json['heatmap-intensity'], + heatmapColor: json['heatmap-color'], + heatmapOpacity: json['heatmap-opacity'], + visibility: json['visibility'], + ); + } +} diff --git a/third_party/maplibre_gl/lib/src/maplibre_map.dart b/third_party/maplibre_gl/lib/src/maplibre_map.dart new file mode 100644 index 0000000..87f4ba5 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/maplibre_map.dart @@ -0,0 +1,563 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of '../maplibre_gl.dart'; + +enum AnnotationType { fill, line, circle, symbol } + +typedef MapCreatedCallback = void Function(MapLibreMapController controller); + +@Deprecated('MaplibreMap was renamed to MapLibreMap. ') +typedef MaplibreMap = MapLibreMap; + +/// Shows a MapLibre map. +/// Also refer to the documentation of [maplibre_gl] and [MapLibreMapController]. +class MapLibreMap extends StatefulWidget { + const MapLibreMap({ + super.key, + required this.initialCameraPosition, + this.styleString = MapLibreStyles.demo, + this.onMapCreated, + this.onStyleLoadedCallback, + this.locationEnginePlatforms = LocationEnginePlatforms.defaultPlatform, + this.gestureRecognizers, + this.compassEnabled = true, + this.cameraTargetBounds = CameraTargetBounds.unbounded, + this.minMaxZoomPreference = MinMaxZoomPreference.unbounded, + this.rotateGesturesEnabled = true, + this.scrollGesturesEnabled = true, + this.zoomGesturesEnabled = true, + this.tiltGesturesEnabled = true, + this.doubleClickZoomEnabled, + this.dragEnabled = true, + this.trackCameraPosition = false, + this.myLocationEnabled = false, + this.myLocationTrackingMode = MyLocationTrackingMode.none, + this.myLocationRenderMode = MyLocationRenderMode.normal, + this.logoEnabled = false, + this.logoViewPosition, + this.logoViewMargins, + this.compassViewPosition, + this.compassViewMargins, + this.attributionButtonPosition = AttributionButtonPosition.bottomRight, + this.attributionButtonMargins, + this.iosLongClickDuration, + this.webPreserveDrawingBuffer = false, + this.onMapClick, + this.onUserLocationUpdated, + this.onMapLongClick, + this.onCameraTrackingDismissed, + this.onCameraTrackingChanged, + this.onCameraMove, + this.onCameraIdle, + this.onMapIdle, + this.annotationOrder = const [ + AnnotationType.line, + AnnotationType.symbol, + AnnotationType.circle, + AnnotationType.fill, + ], + this.annotationConsumeTapEvents = const [ + AnnotationType.symbol, + AnnotationType.fill, + AnnotationType.line, + AnnotationType.circle, + ], + this.foregroundLoadColor = Colors.transparent, + this.translucentTextureSurface = false, + }) : assert( + myLocationRenderMode == MyLocationRenderMode.normal || + myLocationEnabled, + "$myLocationRenderMode requires [myLocationEnabled] set to true.", + ), + assert(annotationOrder.length <= 4), + assert(annotationConsumeTapEvents.length > 0); + + /// The properties for the platform-specific location engine. + /// Only has an impact if [myLocationEnabled] is set to true. + final LocationEnginePlatforms locationEnginePlatforms; + + /// The color used for the map loading foreground. + /// Pass a [Color] and it will be converted to ARGB int for the platform. + /// + /// **Available only on Android. Has no effect on iOS or Web.** + final Color? foregroundLoadColor; + + /// Enable translucent texture surface for the map. + /// This allows the map to have a transparent background, useful for overlay scenarios. + /// + /// **Available only on Android. Has no effect on iOS or Web.** + final bool translucentTextureSurface; + + /// Defines the layer order of annotations displayed on map. + /// Order them from bottom to top. Bottom annotation will be rendered first. + /// + /// Any annotation type can only be contained once, so 0 to 4 types. + /// + /// Note that setting this to be empty gives a big perfomance boost for + /// android. However if you do so annotations will not work. + final List annotationOrder; + + /// Defines the layer order of click annotations + /// + /// (must contain at least 1 annotation type, 4 items max) + final List annotationConsumeTapEvents; + + /// Please note: you should only add annotations (e.g. symbols or circles) after `onStyleLoadedCallback` has been called. + final MapCreatedCallback? onMapCreated; + + /// Called when the map style has been successfully loaded and the annotation managers have been enabled. + /// Please note: you should only add annotations (e.g. symbols or circles) after this callback has been called. + final OnStyleLoadedCallback? onStyleLoadedCallback; + + /// The initial position of the map's camera. + final CameraPosition initialCameraPosition; + + /// How long a user has to click the map **on iOS** until a long click is registered. + /// Has no effect on web or Android. Can not be changed at runtime, only the initial value is used. + /// If null, the default value of the native MapLibre library / of the OS is used. + final Duration? iosLongClickDuration; + + /// If true, the map's canvas can be exported to a PNG using map.getCanvas().toDataURL(). + /// This is false by default as a performance optimization. + /// **Web only** - has no effect on other platforms. + final bool? webPreserveDrawingBuffer; + + /// True if the map should show a compass when rotated. + final bool compassEnabled; + + /// True if drag functionality should be enabled. + /// + /// Disable to avoid performance issues that from the drag event listeners. + /// Biggest impact in android + final bool dragEnabled; + + /// Geographical bounding box for the camera target. + final CameraTargetBounds cameraTargetBounds; + + /// A MapLibre GL style document defining the map's appearance. + /// The style document specification is at [https://maplibre.org/maplibre-style-spec]. + /// A short introduction can be found in the documentation of the [maplibre_gl] library. + /// The following formats are supported: + /// + /// 1. Passing the URL of the map style. This should be a custom map style served remotely using a URL that start with 'http(s)://' + /// 2. Passing the style as a local asset. Create a JSON file in the `assets` and add a reference in `pubspec.yml`. Set the style string to the relative path for this asset in order to load it into the map. + /// 3. Passing the style as a local file. create an JSON file in app directory (e.g. ApplicationDocumentsDirectory). Set the style string to the absolute path of this JSON file. + /// 4. Passing the raw JSON of the map style. + final String styleString; + + /// Preferred bounds for the camera zoom level. + /// + /// Actual bounds depend on map data and device. + final MinMaxZoomPreference minMaxZoomPreference; + + /// True if the map view should respond to rotate gestures. + final bool rotateGesturesEnabled; + + /// True if the map view should respond to scroll gestures. + final bool scrollGesturesEnabled; + + /// True if the map view should respond to zoom gestures. + final bool zoomGesturesEnabled; + + /// True if the map view should respond to tilt gestures. + final bool tiltGesturesEnabled; + + /// Set to true to forcefully disable/enable if map should respond to double + /// click to zoom. + /// + /// This takes presedence over zoomGesturesEnabled. Only supported for web. + final bool? doubleClickZoomEnabled; + + /// True if you want to be notified of map camera movements by the [MapLibreMapController]. Default is false. + /// + /// If this is set to true and the user pans/zooms/rotates the map, [MapLibreMapController] (which is a [ChangeNotifier]) + /// will notify it's listeners and you can then get the new [MapLibreMapController].cameraPosition. + final bool trackCameraPosition; + + /// True if a "My Location" layer should be shown on the map. + /// + /// This layer includes a location indicator at the current device location, + /// as well as a My Location button. + /// * The indicator is a small blue dot if the device is stationary, or a + /// chevron if the device is moving. + /// * The My Location button animates to focus on the user's current location + /// if the user's location is currently known. + /// + /// Enabling this feature requires adding location permissions to both native + /// platforms of your app. + /// * On Android add either + /// `` + /// or `` + /// to your `AndroidManifest.xml` file. `ACCESS_COARSE_LOCATION` returns a + /// location with an accuracy approximately equivalent to a city block, while + /// `ACCESS_FINE_LOCATION` returns as precise a location as possible, although + /// it consumes more battery power. You will also need to request these + /// permissions during run-time. If they are not granted, the My Location + /// feature will fail silently. + /// * On iOS add a `NSLocationWhenInUseUsageDescription` key to your + /// `Info.plist` file. This will automatically prompt the user for permissions + /// when the map tries to turn on the My Location layer. + final bool myLocationEnabled; + + /// The mode used to let the map's camera follow the device's physical location. + /// `myLocationEnabled` needs to be true for values other than `MyLocationTrackingMode.None` to work. + final MyLocationTrackingMode myLocationTrackingMode; + + /// Specifies if and how the user's heading/bearing is rendered in the user location indicator. + /// See the documentation of [MyLocationRenderMode] for details. + /// If this is set to a value other than [MyLocationRenderMode.normal], [myLocationEnabled] needs to be true. + final MyLocationRenderMode myLocationRenderMode; + + /// True if the MapLibre logo should be shown on the map. + /// Defaults to false. + final bool logoEnabled; + + /// Set the position for the Logo + final LogoViewPosition? logoViewPosition; + + /// Set the layout margins for the Logo + final Point? logoViewMargins; + + /// Set the position for the Compass + final CompassViewPosition? compassViewPosition; + + /// Set the layout margins for the Compass + final Point? compassViewMargins; + + /// Set the position for the MapLibre Attribution Button + /// When set to null, the default value of the underlying MapLibre libraries is used, + /// which differs depending on the operating system the app is being run on. + final AttributionButtonPosition? attributionButtonPosition; + + /// Set the layout margins for the MapLibre Attribution Buttons. If you set this + /// value, you may also want to set [attributionButtonPosition] to harmonize + /// the layout between iOS and Android, since the underlying frameworks have + /// different defaults. + final Point? attributionButtonMargins; + + /// Which gestures should be consumed by the map. + /// + /// It is possible for other gesture recognizers to be competing with the map on pointer + /// events, e.g if the map is inside a [ListView] the [ListView] will want to handle + /// vertical drags. The map will claim gestures that are recognized by any of the + /// recognizers on this list. + /// + /// When this set is empty or null, the map will only handle pointer events for gestures that + /// were not claimed by any other gesture recognizer. + final Set>? gestureRecognizers; + + final OnMapClickCallback? onMapClick; + final OnMapClickCallback? onMapLongClick; + + /// While the `myLocationEnabled` property is set to `true`, this method is + /// called whenever a new location update is received by the map view. + final OnUserLocationUpdated? onUserLocationUpdated; + + /// Called when the map's camera no longer follows the physical device location, e.g. because the user moved the map + final OnCameraTrackingDismissedCallback? onCameraTrackingDismissed; + + /// Called when the location tracking mode changes + final OnCameraTrackingChangedCallback? onCameraTrackingChanged; + + /// Called when camera is moving. + final OnCameraMoveCallback? onCameraMove; + + /// Called when camera movement has ended. + final OnCameraIdleCallback? onCameraIdle; + + /// Called when map view is entering an idle state, and no more drawing will + /// be necessary until new data is loaded or there is some interaction with + /// the map. + /// * No camera transitions are in progress + /// * All currently requested tiles have loaded + /// * All fade/transition animations have completed + final OnMapIdleCallback? onMapIdle; + + /// Set `MapLibreMap.useHybridComposition` to `false` in order use Virtual-Display + /// (better for Android 9 and below but may result in errors on Android 12) + /// or leave it `true` (default) to use Hybrid composition (Slower on Android 9 and below). + static bool get useHybridComposition => + MapLibreMethodChannel.useHybridComposition; + + static set useHybridComposition(bool useHybridComposition) => + MapLibreMethodChannel.useHybridComposition = useHybridComposition; + + @override + State createState() => _MapLibreMapState(); +} + +class _MapLibreMapState extends State { + final Completer _controller = + Completer(); + MapLibreMapController? _mapController; + + late _MapLibreMapOptions _maplibreMapOptions; + final MapLibrePlatform _maplibrePlatform = MapLibrePlatform.createInstance(); + + @override + Widget build(BuildContext context) { + assert( + widget.annotationOrder.toSet().length == widget.annotationOrder.length, + "annotationOrder must not have duplicate types"); + final creationParams = { + 'initialCameraPosition': widget.initialCameraPosition.toMap(), + 'styleString': widget.styleString, + 'options': _MapLibreMapOptions.fromWidget(widget).toMap(), + 'dragEnabled': widget.dragEnabled, + if (widget.iosLongClickDuration != null) + 'iosLongClickDurationMilliseconds': + widget.iosLongClickDuration!.inMilliseconds, + if (widget.webPreserveDrawingBuffer != null) + 'webPreserveDrawingBuffer': widget.webPreserveDrawingBuffer, + }; + return _maplibrePlatform.buildView( + creationParams, onPlatformViewCreated, widget.gestureRecognizers); + } + + @override + void initState() { + super.initState(); + _maplibreMapOptions = _MapLibreMapOptions.fromWidget(widget); + } + + @override + void dispose() { + if (_controller.isCompleted) { + _mapController?.dispose(); + } + + super.dispose(); + } + + @override + void didUpdateWidget(MapLibreMap oldWidget) { + super.didUpdateWidget(oldWidget); + final newOptions = _MapLibreMapOptions.fromWidget(widget); + final updates = _maplibreMapOptions.updatesMap(newOptions); + + if (updates.isNotEmpty) { + // Intentionally not awaited: updating map options asynchronously to avoid blocking widget update. + unawaited(_updateOptions(updates)); + } + _maplibreMapOptions = newOptions; + } + + Future _updateOptions(Map updates) async { + if (updates.isEmpty) { + return; + } + final controller = await _controller.future; + await controller._updateMapOptions(updates); + } + + Future onPlatformViewCreated(int id) async { + final controller = MapLibreMapController( + maplibrePlatform: _maplibrePlatform, + initialCameraPosition: widget.initialCameraPosition, + onStyleLoadedCallback: () async { + if (_controller.isCompleted) { + widget.onStyleLoadedCallback?.call(); + } else { + await _controller.future + .then((_) => widget.onStyleLoadedCallback?.call()); + } + }, + onMapClick: widget.onMapClick, + onUserLocationUpdated: widget.onUserLocationUpdated, + onMapLongClick: widget.onMapLongClick, + onCameraTrackingDismissed: widget.onCameraTrackingDismissed, + onCameraTrackingChanged: widget.onCameraTrackingChanged, + onCameraMove: widget.onCameraMove, + onCameraIdle: widget.onCameraIdle, + onMapIdle: widget.onMapIdle, + annotationOrder: widget.annotationOrder, + annotationConsumeTapEvents: widget.annotationConsumeTapEvents, + ); + await _maplibrePlatform.initPlatform(id); + _mapController = controller; + _controller.complete(controller); + widget.onMapCreated?.call(controller); + } +} + +/// Configuration options for the MapLibreMap user interface. +/// +/// When used to change configuration, null values will be interpreted as +/// "do not change this configuration option". +class _MapLibreMapOptions { + _MapLibreMapOptions( + {this.compassEnabled, + this.cameraTargetBounds, + this.styleString, + this.minMaxZoomPreference, + required this.rotateGesturesEnabled, + required this.scrollGesturesEnabled, + required this.tiltGesturesEnabled, + required this.zoomGesturesEnabled, + required this.doubleClickZoomEnabled, + this.trackCameraPosition, + this.myLocationEnabled, + this.myLocationTrackingMode, + this.myLocationRenderMode, + this.logoEnabled, + this.logoViewPosition, + this.logoViewMargins, + this.compassViewPosition, + this.compassViewMargins, + this.attributionButtonPosition, + this.attributionButtonMargins, + this.locationEnginePlatforms, + this.foregroundLoadColor, + this.translucentTextureSurface}); + + _MapLibreMapOptions.fromWidget(MapLibreMap map) + : this( + locationEnginePlatforms: map.locationEnginePlatforms, + compassEnabled: map.compassEnabled, + cameraTargetBounds: map.cameraTargetBounds, + styleString: map.styleString, + minMaxZoomPreference: map.minMaxZoomPreference, + rotateGesturesEnabled: map.rotateGesturesEnabled, + scrollGesturesEnabled: map.scrollGesturesEnabled, + tiltGesturesEnabled: map.tiltGesturesEnabled, + trackCameraPosition: map.trackCameraPosition, + zoomGesturesEnabled: map.zoomGesturesEnabled, + doubleClickZoomEnabled: + map.doubleClickZoomEnabled ?? map.zoomGesturesEnabled, + myLocationEnabled: map.myLocationEnabled, + myLocationTrackingMode: map.myLocationTrackingMode, + myLocationRenderMode: map.myLocationRenderMode, + logoEnabled: map.logoEnabled, + logoViewPosition: map.logoViewPosition, + logoViewMargins: map.logoViewMargins, + compassViewPosition: map.compassViewPosition, + compassViewMargins: map.compassViewMargins, + attributionButtonPosition: map.attributionButtonPosition, + attributionButtonMargins: map.attributionButtonMargins, + foregroundLoadColor: map.foregroundLoadColor, + translucentTextureSurface: map.translucentTextureSurface, + ); + + final bool? compassEnabled; + + final CameraTargetBounds? cameraTargetBounds; + + final String? styleString; + + final MinMaxZoomPreference? minMaxZoomPreference; + + final bool rotateGesturesEnabled; + + final bool scrollGesturesEnabled; + + final bool tiltGesturesEnabled; + + final bool zoomGesturesEnabled; + + final bool doubleClickZoomEnabled; + + final bool? trackCameraPosition; + + final bool? myLocationEnabled; + + final MyLocationTrackingMode? myLocationTrackingMode; + + final MyLocationRenderMode? myLocationRenderMode; + + final bool? logoEnabled; + + final LogoViewPosition? logoViewPosition; + + final Point? logoViewMargins; + + final CompassViewPosition? compassViewPosition; + + final Point? compassViewMargins; + + final AttributionButtonPosition? attributionButtonPosition; + + final Point? attributionButtonMargins; + + final LocationEnginePlatforms? locationEnginePlatforms; + + final Color? foregroundLoadColor; + + final bool? translucentTextureSurface; + + final _gestureGroup = { + 'rotateGesturesEnabled', + 'scrollGesturesEnabled', + 'tiltGesturesEnabled', + 'zoomGesturesEnabled', + 'doubleClickZoomEnabled' + }; + + Map toMap() { + final optionsMap = {}; + + void addIfNonNull(String fieldName, dynamic value) { + if (value != null) { + optionsMap[fieldName] = value; + } + } + + List? pointToArray(Point? fieldName) { + if (fieldName != null) { + return [fieldName.x, fieldName.y]; + } + + return null; + } + + addIfNonNull('compassEnabled', compassEnabled); + addIfNonNull('cameraTargetBounds', cameraTargetBounds?.toJson()); + addIfNonNull('styleString', styleString); + addIfNonNull('minMaxZoomPreference', minMaxZoomPreference?.toJson()); + + addIfNonNull('rotateGesturesEnabled', rotateGesturesEnabled); + addIfNonNull('scrollGesturesEnabled', scrollGesturesEnabled); + addIfNonNull('tiltGesturesEnabled', tiltGesturesEnabled); + addIfNonNull('zoomGesturesEnabled', zoomGesturesEnabled); + addIfNonNull('doubleClickZoomEnabled', doubleClickZoomEnabled); + + addIfNonNull('trackCameraPosition', trackCameraPosition); + addIfNonNull('myLocationEnabled', myLocationEnabled); + addIfNonNull('myLocationTrackingMode', myLocationTrackingMode?.index); + addIfNonNull('myLocationRenderMode', myLocationRenderMode?.index); + addIfNonNull('logoEnabled', logoEnabled); + addIfNonNull('logoViewPosition', logoViewPosition?.index); + addIfNonNull('logoViewMargins', pointToArray(logoViewMargins)); + addIfNonNull('compassViewPosition', compassViewPosition?.index); + addIfNonNull('compassViewMargins', pointToArray(compassViewMargins)); + addIfNonNull('attributionButtonPosition', attributionButtonPosition?.index); + addIfNonNull( + 'attributionButtonMargins', pointToArray(attributionButtonMargins)); + addIfNonNull('locationEngineProperties', locationEnginePlatforms?.toList()); + addIfNonNull('foregroundLoadColor', foregroundLoadColor?.toARGB32()); + addIfNonNull('translucentTextureSurface', translucentTextureSurface); + return optionsMap; + } + + Map updatesMap(_MapLibreMapOptions newOptions) { + final prevOptionsMap = toMap(); + final newOptionsMap = newOptions.toMap(); + + // if any gesture is updated also all other gestures have to the saved to + // the update + + final gesturesRequireUpdate = + _gestureGroup.any((key) => newOptionsMap[key] != prevOptionsMap[key]); + + return newOptionsMap + ..removeWhere((String key, dynamic value) { + if (_gestureGroup.contains(key)) return !gesturesRequireUpdate; + final oldValue = prevOptionsMap[key]; + if (oldValue is List && value is List) { + return listEquals(oldValue, value); + } + return oldValue == value; + }); + } +} diff --git a/third_party/maplibre_gl/lib/src/maplibre_styles.dart b/third_party/maplibre_gl/lib/src/maplibre_styles.dart new file mode 100644 index 0000000..c1f55cc --- /dev/null +++ b/third_party/maplibre_gl/lib/src/maplibre_styles.dart @@ -0,0 +1,15 @@ +part of '../maplibre_gl.dart'; + +@Deprecated('MaplibreStyles was renamed to MapLibreStyles.') +typedef MaplibreStyles = MapLibreStyles; + +/// MapLibre styles used mostly for demonstration. +abstract class MapLibreStyles { + /// A very simple MapLibre demo style that shows only countries with their + /// boundaries. + static const String demo = 'https://demotiles.maplibre.org/style.json'; + + /// The OpenFreeMap liberty style + static const String openfreemapLiberty = + 'https://tiles.openfreemap.org/styles/liberty'; +} diff --git a/third_party/maplibre_gl/lib/src/offline_region.dart b/third_party/maplibre_gl/lib/src/offline_region.dart new file mode 100644 index 0000000..e5e70cc --- /dev/null +++ b/third_party/maplibre_gl/lib/src/offline_region.dart @@ -0,0 +1,76 @@ +part of '../maplibre_gl.dart'; + +/// Description of region to be downloaded. Identifier will be generated when +/// the download is initiated. +class OfflineRegionDefinition { + const OfflineRegionDefinition({ + required this.bounds, + required this.mapStyleUrl, + required this.minZoom, + required this.maxZoom, + this.includeIdeographs = false, + }); + + final LatLngBounds bounds; + final String mapStyleUrl; + final double minZoom; + final double maxZoom; + final bool includeIdeographs; + + @override + String toString() => + "OfflineRegionDefinition, bounds = $bounds, mapStyleUrl = $mapStyleUrl, minZoom = $minZoom, maxZoom = $maxZoom"; + + Map toMap() { + final data = {}; + data['bounds'] = bounds.toList(); + data['mapStyleUrl'] = mapStyleUrl; + data['minZoom'] = minZoom; + data['maxZoom'] = maxZoom; + data['includeIdeographs'] = includeIdeographs; + return data; + } + + factory OfflineRegionDefinition.fromMap(Map map) { + return OfflineRegionDefinition( + bounds: _latLngBoundsFromList(map['bounds']), + mapStyleUrl: map['mapStyleUrl'], + // small integers may deserialize to Int + minZoom: map['minZoom'].toDouble(), + maxZoom: map['maxZoom'].toDouble(), + includeIdeographs: map['includeIdeographs'] ?? false, + ); + } + + static LatLngBounds _latLngBoundsFromList(List json) { + return LatLngBounds( + southwest: LatLng(json[0][0], json[0][1]), + northeast: LatLng(json[1][0], json[1][1]), + ); + } +} + +/// Description of a downloaded region including its identifier. +class OfflineRegion { + const OfflineRegion({ + required this.id, + required this.definition, + required this.metadata, + }); + + final int id; + final OfflineRegionDefinition definition; + final Map metadata; + + factory OfflineRegion.fromMap(Map json) { + return OfflineRegion( + id: json['id'], + definition: OfflineRegionDefinition.fromMap(json['definition']), + metadata: json['metadata'], + ); + } + + @override + String toString() => + "OfflineRegion, id = $id, definition = $definition, metadata = $metadata"; +} diff --git a/third_party/maplibre_gl/lib/src/util.dart b/third_party/maplibre_gl/lib/src/util.dart new file mode 100644 index 0000000..22945ea --- /dev/null +++ b/third_party/maplibre_gl/lib/src/util.dart @@ -0,0 +1,14 @@ +part of '../maplibre_gl.dart'; + +Map buildFeatureCollection( + List> features) { + return {"type": "FeatureCollection", "features": features}; +} + +final _random = Random(); +String getRandomString([int length = 10]) { + const charSet = + 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890'; + return String.fromCharCodes(Iterable.generate( + length, (_) => charSet.codeUnitAt(_random.nextInt(charSet.length)))); +} diff --git a/third_party/maplibre_gl/pubspec.yaml b/third_party/maplibre_gl/pubspec.yaml new file mode 100644 index 0000000..f5d99da --- /dev/null +++ b/third_party/maplibre_gl/pubspec.yaml @@ -0,0 +1,35 @@ +name: maplibre_gl +description: A Flutter plugin for integrating MapLibre Maps inside a Flutter application on Android, iOS and web platforms. +version: 0.25.0 +repository: https://github.com/maplibre/flutter-maplibre-gl +issue_tracker: https://github.com/maplibre/flutter-maplibre-gl/issues +resolution: workspace + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: '>=3.22.0' + +dependencies: + flutter: + sdk: flutter + maplibre_gl_platform_interface: ^0.25.0 + maplibre_gl_web: ^0.25.0 + +dev_dependencies: + very_good_analysis: ^10.0.0 + +flutter: + plugin: + platforms: + android: + package: org.maplibre.maplibregl + pluginClass: MapLibreMapsPlugin + ios: + pluginClass: MapLibreMapsPlugin + web: + default_package: maplibre_gl_web + +platforms: + android: + ios: + web: From 3323e4eb557b02ceeba0241af8e77528ef42a249 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 23:43:11 -0400 Subject: [PATCH 091/100] Fix RX pin z-order: newest pin always renders on top When many RX pins arrived within a few seconds, newer pins could render underneath older ones. The recency sort key was seconds-since-2025-01-01, now ~46M, which exceeds the native float32 symbol-sort-key's exact-integer ceiling (2^24). Float32 quantizes that range to multiples of 4, so any pings within ~4s collided on one sort key and MapLibre ordered the ties arbitrarily. (Even as a true int, .inSeconds tied pings in the same second.) Replace the timestamp-derived zIndex with a monotonic counter assigned once per coverage symbol key (_coverageZIndex / _coverageZCounter): - Stays well under 2^24, so float32 stays exact. - Unique per key, so no ties. - Assign-once, so existing symbols never need re-pushing (the incremental sync skip path is untouched). - Newest key gets the highest value -> always on top and wins the topmost-first tap hit-test. An SNR-updated RX changes its key (new ts) -> fresh top counter, correctly redrawn on top. Drop the counter entry in the symbol cleanup loop (prevents an unbounded leak) and clear the map on style reload (the SymbolManager is rebuilt empty there; keep the counter climbing). --- lib/widgets/map_widget.dart | 44 +++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 139f935..274094c 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -710,6 +710,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // this, every sync re-pushed ALL accumulated symbols (O(n)/event), which // made marker/ping display lag grow with session length. See _syncCoverageSymbols. final Map _coverageSymbolSig = {}; + // Per-key render order: a monotonic counter assigned on first sight, so a + // newer symbol always sorts above an older one. Replaces a timestamp-derived + // sort key that overflowed float32 (the native symbol-sort-key) and quantized + // recent pings into ~4s buckets, stacking simultaneous RX unpredictably. + final Map _coverageZIndex = {}; + int _coverageZCounter = 0; final Map _distanceLabelSymbols = {}; // key: focused repeater id // Per focused-repeater metadata used by the collision-avoidance reflow: @@ -2828,6 +2834,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _gpsSymbol = null; _coverageSymbols.clear(); _coverageSymbolSig.clear(); + // The native SymbolManager is rebuilt empty here, so the next sync re-adds + // every symbol via addSymbol. Drop the stale z-order map so re-added keys + // get fresh (higher) counter values and stay monotonic with re-creation + // order — but keep _coverageZCounter climbing (never reset it). + _coverageZIndex.clear(); _distanceLabelSymbols.clear(); // Distance-label companions: the native side wipes registered images on // style reload, so the "already registered" cache must be cleared too or @@ -4432,22 +4443,26 @@ class _MapWidgetState extends State with WidgetsBindingObserver { '${type}_${ts.millisecondsSinceEpoch}_' '${lat.toStringAsFixed(5)}_${lon.toStringAsFixed(5)}'; - /// Fixed base epoch for coverage-marker z-ordering. Keeps [_recencyZIndex] - /// small enough to stay exact in the native float32 symbol sort-key (integers - /// are exact up to 16,777,216). - static final DateTime _kZBaseEpoch = DateTime.utc(2025, 1, 1); - - /// Render/tap z-order for a coverage marker: seconds since [_kZBaseEpoch], so a - /// more-recent ping always sorts above (renders on top of, and is tapped in - /// preference to) an older one — globally, across TX/RX/DISC/Trace. + /// Render/tap z-order for a coverage marker: a monotonic counter assigned + /// once per [_coverageKey], so a newer symbol always renders on top of (and + /// wins the topmost-first tap hit-test over) an older overlapping one. /// /// The annotation manager's symbol layer uses `symbol-sort-key: ["get", /// "zIndex"]`; with `icon-allow-overlap = true` a higher sort key overlaps a - /// lower one. Setting this also flips `symbol-z-order: auto` off its default - /// `viewport-y` (southernmost-on-top) fallback. It's a pure function of the - /// ping's own timestamp, so existing symbols never need re-pushing — the - /// incremental-sync skip path (gated on the icon/size signature) is untouched. - int _recencyZIndex(DateTime ts) => ts.difference(_kZBaseEpoch).inSeconds; + /// lower one (and flips `symbol-z-order: auto` off its `viewport-y` default). + /// + /// Assign-once → an existing symbol's key never changes value, so the + /// incremental-sync skip path never needs to re-push it. The counter stays + /// well under 2^24, the exact-integer limit of the native float32 sort-key — + /// the previous timestamp-in-seconds key had grown to ~46M, where float32 + /// quantizes to multiples of 4, so pings within ~4s collided on one sort key + /// and stacked in undefined order (the RX burst bug this replaces). + /// + /// NOTE: within a single sync, brand-new keys are numbered in iteration order + /// (TX→RX→DISC→Trace, each oldest→newest); cross-type ties in the same ~250ms + /// window are cosmetic (different icons) and intentionally not pre-sorted. + int _zIndexFor(String key) => + _coverageZIndex.putIfAbsent(key, () => ++_coverageZCounter); /// Diff-syncs native coverage symbols (TX/RX/DISC/Trace) against app state. /// One symbol per ping, image varies by type/success state, opacity reflects @@ -4509,7 +4524,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // Recency-ordered sort key: the most recent ping renders on top // and wins the native topmost-first tap hit-test, so an older // overlapping marker is never selected in its place. - zIndex: _recencyZIndex(ts), + zIndex: _zIndexFor(key), ), {'kind': type, 'id': idForMetadata}, ); @@ -4621,6 +4636,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { for (final key in toRemove) { final sym = _coverageSymbols.remove(key); _coverageSymbolSig.remove(key); + _coverageZIndex.remove(key); if (sym != null) { try { await _mapController!.removeSymbol(sym); From 3e78a43fb1a681cd6819653f4651d0d7f54ab0d8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 23:57:04 -0400 Subject: [PATCH 092/100] fix: text out of bounds --- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- lib/widgets/repeater_id_chip.dart | 83 ++++++++++++------- 2 files changed, 56 insertions(+), 29 deletions(-) diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c3fedb2..95d6e55 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 32371b8..aeedbb0 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -43,38 +43,65 @@ class RepeaterIdChip extends StatelessWidget { ? fontSize - 1.0 // 4-char IDs (2-byte) : fontSize; // 2-char IDs (1-byte) - final child = Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - repeaterId, - softWrap: false, - overflow: TextOverflow.clip, - style: TextStyle( - fontSize: effectiveFontSize, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(width: 2), - Icon( - Icons.info_outline, - size: fontSize - 1, - color: isAmbiguous - ? const Color(0xFFF59E0B) - : Theme.of(context) - .colorScheme - .onSurfaceVariant - .withValues(alpha: 0.5), - ), - ], + final text = Text( + repeaterId, + softWrap: false, + overflow: TextOverflow.clip, + style: TextStyle( + fontSize: effectiveFontSize, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), + ); + final icon = Icon( + Icons.info_outline, + size: fontSize - 1, + color: isAmbiguous + ? const Color(0xFFF59E0B) + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.5), ); + // Fixed-width form: the box can be narrower than the ID's intrinsic width + // (long 3-byte IDs), so wrap the text in Flexible to let it shrink/clip and + // avoid a sub-pixel RenderFlex overflow. Safe here because the SizedBox + // bounds the Row's width. if (width != null) { - return SizedBox(width: width, child: child); + return SizedBox( + width: width, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: text), + const SizedBox(width: 2), + icon, + ], + ), + ); } - return child; + + // Intrinsic-width form: usually laid out under unbounded width (e.g. as a + // non-flex child of another Row), where a Flexible child would throw — but + // it can also land inside a Flexible/bounded Row (the DISC node column), + // where a non-flex text overflows on long 3-byte IDs. Probe the incoming + // constraint: shrink/clip the text only when the parent bounds our width, + // otherwise keep it non-flex so it sizes to its content. + return LayoutBuilder( + builder: (context, constraints) { + final bounded = constraints.maxWidth.isFinite; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + bounded ? Flexible(child: text) : text, + const SizedBox(width: 2), + icon, + ], + ); + }, + ); } /// Show a dialog with matching repeater names for the given [repeaterId]. From be7d5bea45506169660c67a9a17ddb5644e6a5f9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 21 Jun 2026 00:05:28 -0400 Subject: [PATCH 093/100] update: podfile --- ios/Podfile | 8 ++++++++ ios/Podfile.lock | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/ios/Podfile b/ios/Podfile index 620e46e..22d33ba 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -39,5 +39,13 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + # Clang module verifier builds each pod's module in isolation, where the + # `Flutter` module isn't on the search path. Swift plugins (e.g. + # disk_space_plus) import Flutter in their generated -Swift.h, so the + # verifier fails with "module 'Flutter' not found". Disable it for pods. + # (Xcode's "Update to recommended settings" silently turns this on.) + config.build_settings['ENABLE_MODULE_VERIFIER'] = 'NO' + end end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 3eb0bde..a367b48 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -35,6 +35,6 @@ SPEC CHECKSUMS: flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d -PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e +PODFILE CHECKSUM: 1b71fceb7f7c8618409f0f855f3558d90a1d2b33 COCOAPODS: 1.16.2 From a2ae7402b3975e5d3735a6c3f8d00a85a170edad Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 21 Jun 2026 00:38:19 -0400 Subject: [PATCH 094/100] Offline upload: distinguish network/timeout from auth failure - Add OfflineUploadResult.networkError; a null /auth (timeout) is now a retryable network error, not a misleading auth rejection - Retry /auth on timeout (2s/4s backoff) before giving up, mirroring the batch-upload retry - Success snackbar reads 'Upload Success'; network errors show 'Network error - tap again to retry'. The 'advert your device' message is reserved for genuine auth rejections. --- lib/providers/app_state_provider.dart | 71 +++++++++++++++++++-------- lib/screens/settings_screen.dart | 6 ++- 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 4702bdc..997e8a1 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -81,9 +81,12 @@ enum OfflineUploadResult { /// Session data is invalid or empty invalidSession, - /// API authentication failed + /// API authentication failed (device not registered / genuine rejection) authFailed, + /// Network/timeout error reaching the API (not an auth rejection) — retryable + networkError, + /// Some pings failed to upload partialFailure, @@ -4698,6 +4701,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final pings = _apiQueueService.getAndClearOfflinePings(); if (pings.isEmpty) { debugLog('[APP] No offline pings to save'); + // Still break the auto-save tracker so the next offline session starts a + // fresh file instead of appending to the previously tracked session. + // No-op when nothing is tracked. + _offlineSessionService.finalizeCurrentSession(); return; } @@ -4707,7 +4714,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ? _originalDeviceName : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); - await _offlineSessionService.saveSession( + // Finalize the in-progress session (created by periodic auto-save) in place + // rather than creating a new one — otherwise the auto-saved session and this + // final save become two identical sessions at the same time. updateCurrentSession + // creates a fresh session only when no auto-save has run yet. + await _offlineSessionService.updateCurrentSession( pings, devicePublicKey: _devicePublicKey, deviceName: offlineDeviceName, @@ -4899,27 +4910,41 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog( '[OFFLINE] Authenticating for offline upload with device: $deviceName ' '(model: $uploadModel, power: ${uploadPower}w, ver: $uploadVersion)'); - final authResult = await _apiService.requestAuth( - reason: 'connect', - publicKey: publicKey, - who: deviceName, - appVersion: uploadVersion, - power: uploadPower, - iataCode: zoneCode ?? _preferences.iataCode, - model: uploadModel, - radioFreq: session.radioConfig, - lat: _currentPosition?.latitude, - lon: _currentPosition?.longitude, - accuracyMeters: _currentPosition?.accuracy, - offlineMode: true, - skipSessionStore: true, - ); + // Retry the auth on transient network/timeout errors — requestAuth returns null + // on a TimeoutException. A single slow first request shouldn't abort the upload + // and surface a misleading "auth failed" (mirrors the batch-upload retry below). + const authRetryBackoff = [2, 4]; // seconds, after the initial attempt + Map? authResult; + for (var attempt = 0;; attempt++) { + authResult = await _apiService.requestAuth( + reason: 'connect', + publicKey: publicKey, + who: deviceName, + appVersion: uploadVersion, + power: uploadPower, + iataCode: zoneCode ?? _preferences.iataCode, + model: uploadModel, + radioFreq: session.radioConfig, + lat: _currentPosition?.latitude, + lon: _currentPosition?.longitude, + accuracyMeters: _currentPosition?.accuracy, + offlineMode: true, + skipSessionStore: true, + ); + if (authResult != null) break; // got a response (success OR a real rejection) + if (attempt >= authRetryBackoff.length) break; // retries exhausted + final delay = authRetryBackoff[attempt]; + debugWarn( + '[OFFLINE] Auth network error, retry ${attempt + 1}/${authRetryBackoff.length} after ${delay}s'); + onProgress?.call('Authenticating (retry ${attempt + 1})...'); + await Future.delayed(Duration(seconds: delay)); + } Map? effectiveAuth = authResult; if (authResult == null) { - debugError('[OFFLINE] Auth failed: network error'); - return OfflineUploadResult.authFailed; + debugError('[OFFLINE] Auth failed: network error after retries'); + return OfflineUploadResult.networkError; } if (authResult['success'] != true) { @@ -4946,8 +4971,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { skipSessionStore: true, ); - if (registerResult == null || registerResult['success'] != true) { - final regReason = registerResult?['reason'] as String? ?? 'unknown'; + if (registerResult == null) { + debugError('[OFFLINE] Stage 2 registration network error'); + return OfflineUploadResult.networkError; + } + if (registerResult['success'] != true) { + final regReason = registerResult['reason'] as String? ?? 'unknown'; debugError('[OFFLINE] Stage 2 registration failed: $regReason'); return OfflineUploadResult.authFailed; } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index f1dedd0..b168195 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2259,7 +2259,7 @@ class _SettingsScreenState extends State { switch (result) { case OfflineUploadResult.success: - message = 'Uploaded: $filename'; + message = 'Upload Success'; backgroundColor = Colors.green; break; case OfflineUploadResult.notFound: @@ -2274,6 +2274,10 @@ class _SettingsScreenState extends State { message = 'Authentication failed - Advert your device on the mesh'; backgroundColor = Colors.red; break; + case OfflineUploadResult.networkError: + message = 'Network error - tap again to retry'; + backgroundColor = Colors.orange; + break; case OfflineUploadResult.gpsRequired: message = 'GPS required - enable location services to upload'; backgroundColor = Colors.red; From 42446e60768c1d5ef29c3295ad551a846b892ca8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 21 Jun 2026 00:39:06 -0400 Subject: [PATCH 095/100] add test --- .../offline_session_service_test.dart | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 test/services/offline_session_service_test.dart diff --git a/test/services/offline_session_service_test.dart b/test/services/offline_session_service_test.dart new file mode 100644 index 0000000..e7e7230 --- /dev/null +++ b/test/services/offline_session_service_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:mesh_mapper/services/offline_session_service.dart'; + +/// Regression tests for the offline-session lifecycle duplicate bug. +/// +/// Repro: during offline wardriving the 60s auto-save (and app-pause) call +/// `updateCurrentSession()`, which on first fire creates and *tracks* a session. +/// The final save (`_saveOfflineSession` in AppStateProvider) must finalize that +/// SAME tracked session — if it instead calls `saveSession()` it creates a second +/// session holding the same pings, surfacing as two identical sessions at the +/// same time under Settings → Offline Sessions. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + List> pings(int n) => + List.generate(n, (i) => {'type': 'TX', 'seq': i}); + + group('OfflineSessionService lifecycle', () { + test( + 'auto-save then final save through updateCurrentSession yields ONE session (the fix)', + () async { + SharedPreferences.setMockInitialValues({}); + final service = OfflineSessionService(); + await service.init(); + + // Auto-save fires (60s timer / app pause) — creates + tracks session A. + await service.updateCurrentSession(pings(3), deviceName: 'Test'); + expect(service.sessionCount, 1); + + // Final save (stop/disconnect) routes through updateCurrentSession with the + // full ping set, then finalizes the tracker. + await service.updateCurrentSession(pings(5), deviceName: 'Test'); + service.finalizeCurrentSession(); + + expect(service.sessionCount, 1, + reason: 'final save must update the tracked session in place'); + expect(service.sessions.first.pingCount, 5, + reason: 'session holds the complete final ping set'); + }); + + test( + 'auto-save then final save through saveSession DUPLICATES (the bug being fixed)', + () async { + SharedPreferences.setMockInitialValues({}); + final service = OfflineSessionService(); + await service.init(); + + // Auto-save creates + tracks session A. + await service.updateCurrentSession(pings(5), deviceName: 'Test'); + // Old buggy final-save path: saveSession() ignores the tracked session. + await service.saveSession(pings(5), deviceName: 'Test'); + + expect(service.sessionCount, 2, + reason: + 'documents the duplicate: saveSession creates a second session ' + 'alongside the tracked auto-save session'); + }); + + test('finalize then save creates a fresh session (clean break preserved)', + () async { + SharedPreferences.setMockInitialValues({}); + final service = OfflineSessionService(); + await service.init(); + + await service.updateCurrentSession(pings(3), deviceName: 'Test'); + service.finalizeCurrentSession(); + // New offline session after a finalize should be a distinct file. + await service.updateCurrentSession(pings(2), deviceName: 'Test'); + + expect(service.sessionCount, 2, + reason: 'finalize breaks tracking so the next save is a new session'); + }); + + test( + 'final save with empty queue still finalizes, so next session does not append to the old one', + () async { + SharedPreferences.setMockInitialValues({}); + final service = OfflineSessionService(); + await service.init(); + + // Offline session 1: auto-save tracks a 3-ping session. + await service.updateCurrentSession(pings(3), deviceName: 'Test'); + + // Final save runs with no new pings — the provider's empty-queue path now + // finalizes the tracker instead of leaving it set. + service.finalizeCurrentSession(); + + // Offline session 2 starts and auto-saves 2 pings. + await service.updateCurrentSession(pings(2), deviceName: 'Test'); + + // sessions are sorted newest-first: .last is session 1, .first is session 2. + expect(service.sessionCount, 2, + reason: + 'second session must be its own file, not appended to the first'); + expect(service.sessions.last.pingCount, 3, + reason: 'first session keeps its original 3 pings (no append)'); + expect(service.sessions.first.pingCount, 2, + reason: 'second session holds only its own 2 pings'); + }); + }); +} From c72cd645e216743110a93d13f86d77c931c71a38 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 21 Jun 2026 23:24:09 -0400 Subject: [PATCH 096/100] Fix multi-hop TX display in history; GPS puck z-order; cooldown UI - History map popups: pass pathHops through when reconstructing a TxPing from stored markers so multi-hop repeats render distinctly instead of all appearing as direct repeats - Map: keep the GPS puck above all coverage pings via a fixed z-index - ping_controls: landscape button keeps its color during cooldown - app_state_provider: ping/auto-mode adjustments and added debug logging --- lib/providers/app_state_provider.dart | 248 ++++++++++++++------------ lib/widgets/map_widget.dart | 9 + lib/widgets/ping_controls.dart | 6 +- 3 files changed, 152 insertions(+), 111 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 997e8a1..0142946 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -191,6 +191,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { DateTime? _idleAutoStopReference; static const Duration _autoStopIdleTimeout = Duration(minutes: 30); bool _isPingSending = false; // True immediately when ping button clicked + bool _autoPingStarting = + false; // True while an auto mode is starting (before the first notify) int _queueSize = 0; int? _currentNoiseFloor; int? _currentBatteryPercent; @@ -490,6 +492,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get autoPingEnabled => _autoPingEnabled; AutoMode get autoMode => _autoMode; bool get isPingSending => _isPingSending; + bool get isAutoPingStarting => _autoPingStarting; bool get isPingInProgress => _pingService?.pingInProgress ?? false; // True during entire ping + RX window (for auto pings) @@ -3945,24 +3948,33 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return false; } - // Check session validity before starting (skip in offline mode) - if (!_preferences.offlineMode) { - final sessionCheck = await _checkSessionBeforeAction(); - if (!sessionCheck) return false; + // Ignore re-taps while a ping is already being sent (prevents the + // double-tap / concurrent-heartbeat storm during the session check) + if (_isPingSending) { + debugLog('[PING] Ignoring tap — ping already sending'); + return false; } - // Reset idle disconnect timer (user is actively pinging) - _startIdleDisconnectTimer(); - - // Set sending state immediately for instant UI feedback + // Set sending state immediately for instant UI feedback, BEFORE the + // (awaited, network) session check so the button locks the moment it's tapped _isPingSending = true; notifyListeners(); - debugLog('[PING] Sending manual TX ping'); try { + // Check session validity before starting (skip in offline mode) + if (!_preferences.offlineMode) { + final sessionCheck = await _checkSessionBeforeAction(); + if (!sessionCheck) return false; + } + + // Reset idle disconnect timer (user is actively pinging) + _startIdleDisconnectTimer(); + + debugLog('[PING] Sending manual TX ping'); return await _pingService!.sendTxPing(manual: true); } finally { - // Clear sending state when done (RX window timer will show listening state) + // Clear sending state on every path: session-check failure, exception, + // or success (RX window timer takes over showing the listening state) _isPingSending = false; notifyListeners(); } @@ -4075,115 +4087,131 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[PASSIVE MODE] Stopped - no cooldown (listen-only mode)'); } } else { - // Cancel idle disconnect timer — auto-ping keeps the session active - _cancelIdleDisconnectTimer(); + // Ignore re-taps while a start is already in flight (prevents the + // double-tap / concurrent-heartbeat storm during the session check) + if (_autoPingStarting) return false; - // Check session validity before starting (skip in offline mode) - if (!_preferences.offlineMode) { - final sessionCheck = await _checkSessionBeforeAction(); - if (!sessionCheck) return false; - } + // Set starting state immediately for instant UI feedback, BEFORE the + // (awaited, network) session check so the buttons lock the moment it's tapped + _autoPingStarting = true; + notifyListeners(); - // Block starting if shared cooldown is active (TX modes only) - // Passive Mode is listening only and can start during cooldown - if (isTxMode && _cooldownTimer.isRunning) { - debugLog( - '[${mode.name.toUpperCase()} MODE] Start blocked by shared cooldown'); - return false; - } + try { + // Cancel idle disconnect timer — auto-ping keeps the session active + _cancelIdleDisconnectTimer(); - // Stop any existing mode first - if (_autoPingEnabled) { - await _pingService!.forceDisableAutoPing(); - // Stop TX echo tracking to prevent late timer callbacks - _pingService!.stopEchoTracking(); - _rxLogger?.stopWardriving(trigger: 'mode_switch'); - await BackgroundServiceManager.stopService(); - // Stop countdown timers when switching modes - _autoPingTimer.stop(); - _rxWindowTimer.stop(); - // Clear top-heard overlay on mode switch - _clearOverlayState(); - // Save offline session if offline mode is enabled - if (_preferences.offlineMode) { - await _saveOfflineSession(); + // Check session validity before starting (skip in offline mode) + if (!_preferences.offlineMode) { + final sessionCheck = await _checkSessionBeforeAction(); + if (!sessionCheck) return false; + } + + // Block starting if shared cooldown is active (TX modes only) + // Passive Mode is listening only and can start during cooldown + if (isTxMode && _cooldownTimer.isRunning) { + debugLog( + '[${mode.name.toUpperCase()} MODE] Start blocked by shared cooldown'); + return false; } - // End existing noise floor session before starting new mode - await _endNoiseFloorSession(); - } - // Start new mode - debugLog('[PING] Starting auto mode: ${mode.name}'); - _autoMode = mode; + // Stop any existing mode first + if (_autoPingEnabled) { + await _pingService!.forceDisableAutoPing(); + // Stop TX echo tracking to prevent late timer callbacks + _pingService!.stopEchoTracking(); + _rxLogger?.stopWardriving(trigger: 'mode_switch'); + await BackgroundServiceManager.stopService(); + // Stop countdown timers when switching modes + _autoPingTimer.stop(); + _rxWindowTimer.stop(); + // Clear top-heard overlay on mode switch + _clearOverlayState(); + // Save offline session if offline mode is enabled + if (_preferences.offlineMode) { + await _saveOfflineSession(); + } + // End existing noise floor session before starting new mode + await _endNoiseFloorSession(); + } - // Set interval from user preferences before starting - final intervalMs = _preferences.autoPingInterval * 1000; - _pingService!.setAutoPingInterval(intervalMs); - debugLog( - '[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); + // Start new mode + debugLog('[PING] Starting auto mode: ${mode.name}'); + _autoMode = mode; - final started = await _pingService!.enableAutoPing( - passiveMode: isPassive, - hybridMode: isHybrid, - targetedMode: isTargeted, - targetRepeaterId: isTargeted ? _targetRepeaterId : null, - ); - if (!started) { - // Blocked by cooldown or already enabled - if (_pingService!.isInCooldown()) { - debugLog( - '[PING] Auto mode start blocked by cooldown (${_pingService!.getRemainingCooldownSeconds()}s remaining)'); - } else { - debugLog('[PING] Auto mode start blocked'); + // Set interval from user preferences before starting + final intervalMs = _preferences.autoPingInterval * 1000; + _pingService!.setAutoPingInterval(intervalMs); + debugLog( + '[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); + + final started = await _pingService!.enableAutoPing( + passiveMode: isPassive, + hybridMode: isHybrid, + targetedMode: isTargeted, + targetRepeaterId: isTargeted ? _targetRepeaterId : null, + ); + if (!started) { + // Blocked by cooldown or already enabled + if (_pingService!.isInCooldown()) { + debugLog( + '[PING] Auto mode start blocked by cooldown (${_pingService!.getRemainingCooldownSeconds()}s remaining)'); + } else { + debugLog('[PING] Auto mode start blocked'); + } + return false; } - return false; - } - // Start RX wardriving for all modes - // Reference: state.rxTracking.isWardriving = true in wardrive.js - _rxLogger?.startWardriving(); - _autoPingEnabled = true; - _idleAutoStopReference = DateTime.now(); - - // Start noise floor session for graph tracking - final sessionLabel = isPassive - ? 'passive' - : isHybrid - ? 'hybrid' - : isTargeted - ? 'targeted' - : 'active'; - _startNoiseFloorSession(sessionLabel); - - // Enable heartbeat for all auto-ping modes (not offline mode) - // Heartbeat sends keepalive ~1 min before session expiry (4 min timer) - // Active/Hybrid pings renew session when moving, but heartbeat is the - // safety net when stationary (25m distance filter skips TX pings) - if (!_preferences.offlineMode) { - _apiService.enableHeartbeat( - gpsProvider: () { - // Provide current GPS coordinates for heartbeat (matching wardrive.js) - final pos = _gpsService.lastPosition; - if (pos == null) return null; - return (lat: pos.latitude, lon: pos.longitude); - }, + // Start RX wardriving for all modes + // Reference: state.rxTracking.isWardriving = true in wardrive.js + _rxLogger?.startWardriving(); + _autoPingEnabled = true; + _idleAutoStopReference = DateTime.now(); + + // Start noise floor session for graph tracking + final sessionLabel = isPassive + ? 'passive' + : isHybrid + ? 'hybrid' + : isTargeted + ? 'targeted' + : 'active'; + _startNoiseFloorSession(sessionLabel); + + // Enable heartbeat for all auto-ping modes (not offline mode) + // Heartbeat sends keepalive ~1 min before session expiry (4 min timer) + // Active/Hybrid pings renew session when moving, but heartbeat is the + // safety net when stationary (25m distance filter skips TX pings) + if (!_preferences.offlineMode) { + _apiService.enableHeartbeat( + gpsProvider: () { + // Provide current GPS coordinates for heartbeat (matching wardrive.js) + final pos = _gpsService.lastPosition; + if (pos == null) return null; + return (lat: pos.latitude, lon: pos.longitude); + }, + ); + debugLog('[HEARTBEAT] Enabled for ${mode.name} Mode'); + } + + // Start background service for continuous operation + final modeName = isPassive + ? 'Passive Mode' + : isHybrid + ? 'Hybrid Mode' + : isTargeted + ? 'Trace Mode' + : 'Active Mode'; + await BackgroundServiceManager.startService( + mode: modeName, + txCount: _pingStats.txCount, + rxCount: _pingStats.rxCount, + queueSize: _queueSize, ); - debugLog('[HEARTBEAT] Enabled for ${mode.name} Mode'); - } - - // Start background service for continuous operation - final modeName = isPassive - ? 'Passive Mode' - : isHybrid - ? 'Hybrid Mode' - : isTargeted - ? 'Trace Mode' - : 'Active Mode'; - await BackgroundServiceManager.startService( - mode: modeName, - txCount: _pingStats.txCount, - rxCount: _pingStats.rxCount, - queueSize: _queueSize, - ); + } finally { + // Clear starting state on every path (session/cooldown/blocked early + // returns, exceptions, and success) so the buttons never stay disabled + _autoPingStarting = false; + notifyListeners(); + } } notifyListeners(); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 274094c..20f366e 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -716,6 +716,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // recent pings into ~4s buckets, stacking simultaneous RX unpredictably. final Map _coverageZIndex = {}; int _coverageZCounter = 0; + /// GPS puck z-order: a fixed sort key far above the coverage counter + /// (_coverageZCounter, which climbs from 1 per unique ping) so the GPS + /// marker always renders on top of every TX/RX/DISC/Trace pin. Stays exact + /// in the native float32 symbol-sort-key (< 2^24). See _syncCoverageSymbols. + static const int _gpsZIndex = 1 << 23; final Map _distanceLabelSymbols = {}; // key: focused repeater id // Per focused-repeater metadata used by the collision-avoidance reflow: @@ -4711,6 +4716,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { geometry: LatLng(pos.latitude, pos.longitude), iconImage: _MapImages.gps(style), iconRotate: iconRotate, + // Keep the GPS puck above all coverage pings in the shared annotation + // layer (Critical Rule 9 / _zIndexFor). See _gpsZIndex. + zIndex: _gpsZIndex, ); if (_gpsSymbol == null) { @@ -7902,6 +7910,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { repeaterId: r.repeaterId, snr: r.snr, rssi: r.rssi, + pathHops: r.pathHops, )) .toList(), )); diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index a43174a..4de3218 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -1732,7 +1732,11 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final showColor = widget.enabled || widget.isActive; + // Keep the button's color (not grey) whenever the countdown badge is shown, + // mirroring portrait's `_ActionButton` showCooldown handling. Otherwise the + // badge renders white text on grey (onSurfaceVariant) during cooldown. + final showColor = + widget.enabled || widget.isActive || widget.countdown != null; final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; // Static active-state opacity (continuous pulse animation removed — see From f8979a0e33f7f32045d406be8f76bd1d67e91756 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 22 Jun 2026 13:16:51 -0400 Subject: [PATCH 097/100] Encode session date in TX wire-tag to fix cross-day collision (DEAD tiles) The wire-tag packed only (region, NNNN, counter) with no date. NNNN recycles daily, so a tag re-minted on a later day was byte-identical to the prior day's and the server's idempotency skip dropped the new ping -> DEAD tiles when driving new areas. Encode the session date too: region(15) | date(15: year-2020/month/day) | NNNN(14) | counter(11) = 55 bits -> "MM:"+10 chars. decode() now returns the date and reconstructs the full session_id. Web-safe on dart2js (value > 2^53) by working on two <=28-bit halves with arithmetic only; verified byte-identical to the PHP twin and under flutter test --platform chrome. Canonical vectors regenerated. Also: [COVERAGE] Patched log now includes the cell status histogram, and the API queue logs wire_tag + ping_counter on upload, so collisions self-document. --- lib/providers/app_state_provider.dart | 13 +- lib/services/api_queue_service.dart | 8 +- lib/services/meshcore/wire_tag_codec.dart | 143 ++++++++++------- test/services/wire_tag_codec_test.dart | 182 ++++++++++++++++------ 4 files changed, 241 insertions(+), 105 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 0142946..dca61ab 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -748,8 +748,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _coveragePatchCells.remove(_coveragePatchCells.keys.first); } _coveragePatchVersion++; + // Status histogram of the patched cells (st = server-rendered status category), + // so a debug log carries hard proof of what the server actually rendered for the + // user's own pings (e.g. st={1:3,2:5}) — see the "tiles not green is server-side" note. + final stHist = {}; + for (final cell in patched) { + stHist[cell.st] = (stHist[cell.st] ?? 0) + 1; + } + final stSummary = (stHist.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key))) + .map((e) => '${e.key}:${e.value}') + .join(','); debugLog( - '[COVERAGE] Patched ${patched.length} cell(s) at your position onto the overlay (attempt $attempt)'); + '[COVERAGE] Patched ${patched.length} cell(s) at your position onto the overlay (attempt $attempt) st={$stSummary}'); notifyListeners(); } else { debugLog( diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 50ea4d1..ced5ea8 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -579,11 +579,15 @@ class ApiQueueService { // Convert to API format final pings = items.map((item) => item.toApiJson()).toList(); - // Log each item with external_antenna value + // Log each item with external_antenna value. Token-mode TX entries also log their + // wire_tag + ping_counter so a debug log self-documents any tag collision/drop. for (int i = 0; i < items.length; i++) { final item = items[i]; + final tagInfo = item.wireTag != null + ? ', wire_tag=${item.wireTag}, ping_counter=${item.pingCounter}' + : ''; debugLog( - '[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}'); + '[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}$tagInfo'); } final memoryCount = memoryItems.length; diff --git a/lib/services/meshcore/wire_tag_codec.dart b/lib/services/meshcore/wire_tag_codec.dart index 61f4994..f4cfd8a 100644 --- a/lib/services/meshcore/wire_tag_codec.dart +++ b/lib/services/meshcore/wire_tag_codec.dart @@ -6,25 +6,28 @@ import 'package:crypto/crypto.dart'; /// Encodes/decodes the on-air TX "wire tag" that replaces plaintext GPS /// coordinates on the `#wardriving` channel. /// -/// A tag is `"MM:" + 7 base64url chars` (10 chars total → a single 16-byte AES -/// block once the channel layer encrypts it). It packs the origin region, the -/// session number, and the per-session ping counter into 40 bits, then runs a -/// keyed 4-round Feistel so it can only be decoded with the shared secret. +/// A tag is `"MM:" + 10 base64url chars` (13 chars total). It packs the origin +/// region, the **session date**, the session number, and the per-session ping +/// counter into 55 bits, then runs a keyed 4-round Feistel so it can only be +/// decoded with the shared secret. /// -/// The session *date* is intentionally NOT encoded — a decoder always holds a -/// receive timestamp (or, server-side, the full session_id), so the date is -/// supplied from context. The full session_id is reconstructed as -/// `(decoded region)-(date from context)-(decoded session#)`. +/// Layout (MSB→LSB): `region(15) | date(15) | NNNN(14) | counter(11)` where +/// `date = (year-2020)*512 + month*32 + day`. The date IS encoded (unlike the +/// original 40-bit tag) so a recycled daily NNNN mints a globally-unique tag — +/// this is the "green TX → DEAD tile" fix. `decode` fully reconstructs the +/// session_id `(region)-(YYYYMMDD)-(NNNN)`; no external date context is needed. /// /// This MUST stay byte-identical to the PHP `wireTagEncode`/`wireTagDecode` /// twins — see `test/services/wire_tag_codec_test.dart` for the canonical /// cross-language vectors. /// /// Web safety: this app runs on web (dart2js), where the bitwise operators -/// `<<`, `>>`, `&`, `|` truncate to 32 bits. Our packed value is up to 40 bits, -/// so anything that can exceed 31 bits uses plain arithmetic (`*`, `~/`, `%`), -/// which is exact for integers below 2^53. Bitwise ops are used only on the -/// two ≤20-bit Feistel halves, which are always < 2^31. +/// `<<`, `>>`, `&`, `|` truncate to 32 bits. The packed value is up to 55 bits +/// (> 2^53, the exact-integer ceiling for JS doubles), so the codec NEVER +/// materializes the full value — it works on two ≤28-bit halves and keeps every +/// intermediate below 2^40, using plain arithmetic (`*`, `~/`, `%`) which is +/// exact below 2^53. Bitwise ops appear only on the two ≤28-bit Feistel halves +/// (always < 2^31). class WireTagCodec { WireTagCodec._(); @@ -32,9 +35,15 @@ class WireTagCodec { static const String prefix = 'MM:'; static const int _pow11 = 2048; // 2^11 (counter field) - static const int _pow20 = 1048576; // 2^20 (Feistel half) - static const int _pow25 = 33554432; // 2^25 (region field shift) - static const int _mask20 = 0xFFFFF; // 20 bits — safe (< 2^31) + static const int _pow25 = 33554432; // 2^25 (date field shift) + static const int _pow28 = 268435456; // 2^28 (Feistel half size) + static const int _pow12 = 4096; // 2^12 (region within the high half) + static const int _mask28 = 0xFFFFFFF; // 28 bits — safe (< 2^31) + + // Date sub-fields: date = (year - _yearBase)*_pow9 + month*_pow5 + day. + static const int _yearBase = 2020; + static const int _pow9 = 512; // year shift (6-bit year → 2020..2083) + static const int _pow5 = 32; // month shift (4-bit month / 5-bit day) static int _regionPack(String iata) { final u = iata.toUpperCase(); @@ -46,53 +55,61 @@ class WireTagCodec { static String _regionUnpack(int n) => String.fromCharCodes([n ~/ 676 + 65, (n % 676) ~/ 26 + 65, n % 26 + 65]); - /// Feistel round function: first 3 bytes of SHA-256(secret ‖ round ‖ half), - /// masked to 20 bits. `half` is serialized big-endian as 3 bytes. + /// Feistel round function: first 4 bytes of SHA-256(secret ‖ round ‖ half), + /// masked to 28 bits. `half` (< 2^28) is serialized big-endian as 4 bytes. static int _f(List secret, int half, int round) { final input = [ ...secret, round, - (half ~/ 65536) % 256, + (half ~/ 16777216) % 256, // 2^24 + (half ~/ 65536) % 256, // 2^16 (half ~/ 256) % 256, half % 256, ]; final d = sha256.convert(input).bytes; - return (d[0] * 65536 + d[1] * 256 + d[2]) % _pow20; + return (d[0] * 16777216 + d[1] * 65536 + d[2] * 256 + d[3]) % _pow28; } - static int _feistel(List secret, int v, {required bool decrypt}) { - var l = v ~/ _pow20; // top 20 bits - var r = v % _pow20; // bottom 20 bits + /// 4-round balanced Feistel on the two 28-bit halves `(hi, lo)`. + /// Returns the transformed `(left, right)` pair — the full value is never + /// formed (it would exceed 2^53 on web). + static (int, int) _feistel(List secret, int hi, int lo, + {required bool decrypt}) { + var l = hi; + var r = lo; for (final round in decrypt ? const [3, 2, 1, 0] : const [0, 1, 2, 3]) { if (!decrypt) { final newL = r; - r = (l ^ _f(secret, r, round)) & _mask20; + r = (l ^ _f(secret, r, round)) & _mask28; l = newL; } else { final newR = l; - l = (r ^ _f(secret, l, round)) & _mask20; + l = (r ^ _f(secret, l, round)) & _mask28; r = newR; } } - return l * _pow20 + r; + return (l, r); } - static Uint8List _toBytes5(int v) { - final b = Uint8List(5); - var x = v; - for (var i = 4; i >= 0; i--) { - b[i] = x % 256; - x = x ~/ 256; - } - return b; + /// Serialize the 56-bit value `hi*2^28 + lo` (each half < 2^28) as 7 bytes, + /// big-endian — without ever forming the full integer. + static Uint8List _toBytes7(int hi, int lo) { + return Uint8List.fromList([ + (hi ~/ 1048576) % 256, // 2^20 → bits 48-55 + (hi ~/ 4096) % 256, // 2^12 → bits 40-47 + (hi ~/ 16) % 256, // 2^4 → bits 32-39 + (hi % 16) * 16 + (lo ~/ 16777216) % 16, // bits 24-31 (straddle) + (lo ~/ 65536) % 256, // bits 16-23 + (lo ~/ 256) % 256, // bits 8-15 + lo % 256, // bits 0-7 + ]); } - static int _fromBytes5(List b) { - var v = 0; - for (final byte in b) { - v = v * 256 + byte; - } - return v; + /// Inverse of [_toBytes7]: 7 bytes → `(hi, lo)`, each half < 2^28. + static (int, int) _fromBytes7(List b) { + final lo = b[6] + b[5] * 256 + b[4] * 65536 + (b[3] % 16) * 16777216; + final hi = (b[3] ~/ 16) + b[2] * 16 + b[1] * 4096 + b[0] * 1048576; + return (hi, lo); } static String _b64url(List bytes) => @@ -114,25 +131,47 @@ class WireTagCodec { throw ArgumentError( 'Cannot wire-tag encode an offline / non-region session id: $sessionId'); } - final v = _regionPack(parts[0]) * _pow25 + - int.parse(parts[2]) * _pow11 + - counter; - final cipher = _feistel(utf8.encode(key ?? ''), v, decrypt: false); - return prefix + _b64url(_toBytes5(cipher)); + final ymd = parts[1]; + final year = int.parse(ymd.substring(0, 4)); + final month = int.parse(ymd.substring(4, 6)); + final day = int.parse(ymd.substring(6, 8)); + final date = (year - _yearBase) * _pow9 + month * _pow5 + day; + + // Pack the low 40 bits, then split at bit 28 into the two Feistel halves. + // lowPart < 2^40, so it (and the carry) stay well below 2^53 — web-safe. + final lowPart = date * _pow25 + int.parse(parts[2]) * _pow11 + counter; + final lo = lowPart % _pow28; + final hi = _regionPack(parts[0]) * _pow12 + lowPart ~/ _pow28; + + final (cl, cr) = _feistel(utf8.encode(key ?? ''), hi, lo, decrypt: false); + return prefix + _b64url(_toBytes7(cl, cr)); } - /// Decode a wire body back to region / session# / counter using the key alone - /// (no database needed). The date is not recoverable from the tag. - static ({String region, int sessionNum, int counter}) decode( - String body, String? key) { + /// Decode a wire body back to region / date / session# / counter using the + /// key alone (no database needed). The session_id is fully recoverable as + /// `region-YYYYMMDD-NNNN`. + static ({ + String region, + int year, + int month, + int day, + int sessionNum, + int counter + }) decode(String body, String? key) { final token = body.startsWith(prefix) ? body.substring(prefix.length) : body; - final v = - _feistel(utf8.encode(key ?? ''), _fromBytes5(_unb64url(token)), decrypt: true); - final region = v ~/ _pow25; - final rem = v % _pow25; + final (chi, clo) = _fromBytes7(_unb64url(token)); + final (hi, lo) = _feistel(utf8.encode(key ?? ''), chi, clo, decrypt: true); + + final region = hi ~/ _pow12; + final lowPart = (hi % _pow12) * _pow28 + lo; + final date = lowPart ~/ _pow25; + final rem = lowPart % _pow25; return ( region: _regionUnpack(region), + year: date ~/ _pow9 + _yearBase, + month: (date % _pow9) ~/ _pow5, + day: date % _pow5, sessionNum: rem ~/ _pow11, counter: rem % _pow11, ); diff --git a/test/services/wire_tag_codec_test.dart b/test/services/wire_tag_codec_test.dart index bcf047c..926a723 100644 --- a/test/services/wire_tag_codec_test.dart +++ b/test/services/wire_tag_codec_test.dart @@ -1,71 +1,106 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mesh_mapper/services/meshcore/wire_tag_codec.dart'; -/// Canonical cross-language vectors. These MUST stay byte-identical to the PHP -/// `wireTagEncode` and the Python reference oracle — they are the wire contract. -/// See docs / the TX Wire-Tag plan for how they were generated. +/// Wire-tag codec contract. The tag now encodes the SESSION DATE as well, so a +/// `(region, NNNN, counter)` triple mints a DIFFERENT tag on a different day — +/// this is what makes a recycled daily NNNN globally unique (the "green TX → +/// DEAD tile" fix). The codec MUST stay byte-identical to the PHP +/// `wireTagEncode`/`wireTagDecode` twins — the pinned canonical vectors below +/// are the cross-language wire contract. void main() { - group('WireTagCodec.encode (canonical vectors, key=TESTKEY)', () { - const key = 'TESTKEY'; - final vectors = <(String, int), String>{ - ('PAR-20260611-0013', 1): 'MM:zpCFQwc', - ('JKG-20260611-0009', 1): 'MM:zJHa-B8', - ('AAR-20260611-0014', 1): 'MM:GD59I2Q', - ('AAR-20260611-0123', 1000): 'MM:x6laqiY', - ('YOW-20260504-0005', 1): 'MM:2Oj9Xyg', - ('ZZZ-20260101-9999', 2047): 'MM:ETfo5FI', - }; + const key = 'TESTKEY'; - vectors.forEach((input, expected) { - test('${input.$1} ping ${input.$2} -> $expected', () { - expect(WireTagCodec.encode(input.$1, input.$2, key), expected); - }); + group('shape', () { + test('tag is "MM:" + 10 base64url chars (13 chars; 7-byte payload)', () { + final body = WireTagCodec.encode('PAR-20260611-0013', 1, key); + expect(body.length, 13); + expect(RegExp(r'^MM:[A-Za-z0-9_-]{10}$').hasMatch(body), isTrue); }); - test('body is always "MM:" + 7 base64url chars (10 chars, one AES block)', () { - final body = WireTagCodec.encode('PAR-20260611-0013', 1, key); - expect(body.length, 10); - expect(RegExp(r'^MM:[A-Za-z0-9_-]{7}$').hasMatch(body), isTrue); + test('same inputs are deterministic', () { + expect( + WireTagCodec.encode('PAR-20260611-0013', 7, key), + WireTagCodec.encode('PAR-20260611-0013', 7, key), + ); }); }); - group('WireTagCodec.encode (empty-key fallback)', () { - final vectors = <(String, int), String>{ - ('PAR-20260611-0013', 1): 'MM:jHHz-gQ', - ('JKG-20260611-0009', 1): 'MM:ozT0SI8', - ('AAR-20260611-0014', 1): 'MM:4y-cINQ', - ('AAR-20260611-0123', 1000): 'MM:ATsK8_8', - ('YOW-20260504-0005', 1): 'MM:EiC-3p4', - ('ZZZ-20260101-9999', 2047): 'MM:_CI9Xfs', - }; + group('date is encoded (the bug fix)', () { + test('same region/NNNN/counter on DIFFERENT days mint DIFFERENT tags', () { + final jun20 = WireTagCodec.encode('PAE-20260620-0007', 18, key); + final jun21 = WireTagCodec.encode('PAE-20260621-0007', 18, key); + expect(jun20, isNot(jun21), + reason: 'recycled daily NNNN must not collide across days'); + }); - vectors.forEach((input, expected) { - test('null key == "" key, ${input.$1} ping ${input.$2} -> $expected', () { - expect(WireTagCodec.encode(input.$1, input.$2, null), expected); - expect(WireTagCodec.encode(input.$1, input.$2, ''), expected); - }); + test('all six WX7RAW counters differ across the two days', () { + for (var c = 18; c <= 23; c++) { + expect( + WireTagCodec.encode('PAE-20260620-0007', c, key), + isNot(WireTagCodec.encode('PAE-20260621-0007', c, key)), + reason: 'counter $c collided across days', + ); + } }); }); - group('WireTagCodec.decode (key only, no DB)', () { - test('recovers region/session#/counter from a known body', () { - final r = WireTagCodec.decode('MM:zpCFQwc', 'TESTKEY'); - expect(r.region, 'PAR'); - expect(r.sessionNum, 13); - expect(r.counter, 1); + group('key fallback', () { + test('null key == empty-string key, and both differ from a real key', () { + const sid = 'AAR-20260611-0123'; + expect(WireTagCodec.encode(sid, 5, null), WireTagCodec.encode(sid, 5, '')); + expect(WireTagCodec.encode(sid, 5, null), + isNot(WireTagCodec.encode(sid, 5, key))); }); + }); - test('decode with the wrong key yields a different region', () { - final right = WireTagCodec.decode('MM:zpCFQwc', 'TESTKEY'); - final wrong = WireTagCodec.decode('MM:zpCFQwc', 'nope'); - expect(right.region, 'PAR'); - expect(wrong.region, isNot('PAR')); + group('decode recovers region / full date / session# / counter', () { + test('a specific tag round-trips with the date intact', () { + final body = WireTagCodec.encode('PAE-20260621-0007', 18, key); + final d = WireTagCodec.decode(body, key); + expect(d.region, 'PAE'); + expect(d.year, 2026); + expect(d.month, 6); + expect(d.day, 21); + expect(d.sessionNum, 7); + expect(d.counter, 18); + }); + + test('reconstructs the full session_id', () { + final body = WireTagCodec.encode('YOW-20260504-0005', 42, key); + final d = WireTagCodec.decode(body, key); + final rebuilt = + '${d.region}-${d.year.toString().padLeft(4, '0')}${d.month.toString().padLeft(2, '0')}${d.day.toString().padLeft(2, '0')}-${d.sessionNum.toString().padLeft(4, '0')}'; + expect(rebuilt, 'YOW-20260504-0005'); + }); + + test('wrong key yields a different region', () { + final body = WireTagCodec.encode('PAR-20260611-0013', 1, key); + expect(WireTagCodec.decode(body, 'nope').region, isNot('PAR')); }); }); - group('round-trip exactness across ping 1..1000', () { - test('encode then decode recovers the exact triple, all unique', () { - const key = 'TESTKEY'; + group('round-trip exactness (dates, NNNN, counters)', () { + test('boundary dates and the 14-bit NNNN / 11-bit counter extremes', () { + final cases = <(String, int)>[ + ('AAA-20200101-0001', 1), // epoch floor of the date field (year 2020) + ('ZZZ-20831231-9999', 2047), // ceilings: year 2083, NNNN 9999, counter 2047 + ('PAE-20260101-0007', 1), + ('PAE-20261231-0007', 2047), + ('JKG-20260229-0009', 100), // leap day + ]; + for (final (sid, c) in cases) { + final d = WireTagCodec.decode(WireTagCodec.encode(sid, c, key), key); + final p = sid.split('-'); + expect(d.region, p[0]); + expect(d.year, int.parse(p[1].substring(0, 4))); + expect(d.month, int.parse(p[1].substring(4, 6))); + expect(d.day, int.parse(p[1].substring(6, 8))); + expect(d.sessionNum, int.parse(p[2])); + expect(d.counter, c); + } + }); + + test('1..1000 counters all unique and exactly recovered', () { const sid = 'AAR-20260611-0123'; final bodies = {}; for (var c = 1; c <= 1000; c++) { @@ -73,10 +108,57 @@ void main() { bodies.add(body); final d = WireTagCodec.decode(body, key); expect(d.region, 'AAR'); + expect(d.year, 2026); + expect(d.month, 6); + expect(d.day, 11); expect(d.sessionNum, 123); expect(d.counter, c); } - expect(bodies.length, 1000, reason: 'every ping must be unique on the wire'); + expect(bodies.length, 1000); + }); + }); + + // Cross-language canonical vectors — confirmed byte-identical to PHP + // wireTagEncode (run MeshMapper_Server/wire_tag_codec.php on the same inputs). + // These are the wire contract; changing the codec MUST regenerate both sides. + group('canonical vectors (key=TESTKEY)', () { + final vectors = <(String, int), String>{ + ('PAR-20260611-0013', 1): 'MM:YVNPAr5OIw', + ('JKG-20260611-0009', 1): 'MM:_ZqTR9KmUQ', + ('AAR-20260611-0014', 1): 'MM:xJkY4fMf0A', + ('AAR-20260611-0123', 1000): 'MM:yPPRVhdweg', + ('YOW-20260504-0005', 1): 'MM:q2REy6j1xQ', + ('ZZZ-20260101-9999', 2047): 'MM:tbOGo9kJHg', + ('PAE-20260620-0007', 18): 'MM:YPKG3YBefw', + ('PAE-20260621-0007', 18): 'MM:gCXS1s-0ew', // same NNNN/counter, next day → different tag + ('AAA-20200101-0001', 1): 'MM:mcvZkYjWyw', + ('ZZZ-20831231-9999', 2047): 'MM:lT-SF6aZAw', + }; + vectors.forEach((input, expected) { + test('${input.$1} ping ${input.$2} -> $expected', () { + expect(WireTagCodec.encode(input.$1, input.$2, key), expected); + }); + }); + }); + + group('canonical vectors (empty-key fallback)', () { + final vectors = <(String, int), String>{ + ('PAR-20260611-0013', 1): 'MM:sYVCDwfmyg', + ('JKG-20260611-0009', 1): 'MM:KzK7sA1D2w', + ('AAR-20260611-0014', 1): 'MM:5KWUc6SGLQ', + ('AAR-20260611-0123', 1000): 'MM:XVn2vjKTzQ', + ('YOW-20260504-0005', 1): 'MM:UIcMpMVBng', + ('ZZZ-20260101-9999', 2047): 'MM:f9QkqSCBKg', + ('PAE-20260620-0007', 18): 'MM:NWGgGQHdUg', + ('PAE-20260621-0007', 18): 'MM:bhBWfbLBfg', + ('AAA-20200101-0001', 1): 'MM:GlX5oUogpg', + ('ZZZ-20831231-9999', 2047): 'MM:v7UAnOrSZw', + }; + vectors.forEach((input, expected) { + test('null/empty key, ${input.$1} ping ${input.$2} -> $expected', () { + expect(WireTagCodec.encode(input.$1, input.$2, null), expected); + expect(WireTagCodec.encode(input.$1, input.$2, ''), expected); + }); }); }); } From 0e8858ba8dbdcc3899ab4bf0f5b8ed058e31e217 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 22 Jun 2026 13:20:05 -0400 Subject: [PATCH 098/100] Ping controls: instant start-mode feedback + cut rebuild churn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the Active/Hybrid and Passive buttons (portrait, compact, and landscape layouts) to the new `isAutoPingStarting` flag so they disable the instant a mode-start is tapped, instead of looking dead during the awaited session-check round-trip. Send Ping already disables via `isPingSending`. Replace each control widget's top-level `context.watch` with `context.select(_controlsDepsOf)` + `context.read`, so the controls rebuild only when a control-relevant field changes — not on every GPS / noise-floor / battery notify (~1-2 Hz during wardriving). Countdown values still update via the inner ListenableBuilder(timerListenable). The dependency records cover every appState.* read in the build bodies (incl. isConnected / targetRepeaterId / repeaters for the Trace section) so no button goes stale while idle. Companion to the sendPing()/toggleAutoPing() instant-feedback + re-entrancy guards already landed in app_state_provider.dart. --- lib/widgets/ping_controls.dart | 101 +++++++++++++++++++++++++++++++-- 1 file changed, 97 insertions(+), 4 deletions(-) diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index 4de3218..5aa4560 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -7,13 +7,90 @@ import '../services/ping_service.dart'; import '../utils/debug_logger_io.dart'; import 'repeater_picker_sheet.dart'; +/// Fields the ping-control widgets depend on for their enabled/label state. +/// Used with `context.select` so the controls rebuild ONLY when one of these +/// changes — not on every GPS / noise-floor / battery `notifyListeners()` +/// (~1–2 Hz during wardriving). Timer (countdown) values are deliberately +/// excluded; they update via the inner `ListenableBuilder(timerListenable)` +/// in each widget. Keep this in sync with the `appState.*` reads in the build +/// bodies below — a missing field means a button can go stale while idle. +typedef _ControlsDeps = ({ + PingValidation pingValidation, + PingValidation manualValidation, + PingValidation autoValidation, + bool autoPingEnabled, + AutoMode autoMode, + bool isTargetedModeRunning, + bool hybridModeEnabled, + bool isPendingDisable, + bool isPingSending, + bool isAutoPingStarting, + bool isPingInProgress, + bool isConnected, + bool offlineMode, + bool txAllowed, + bool externalAntenna, + bool externalAntennaSet, + bool isPowerSet, + bool floodTrafficEnabled, + bool hasTargetRepeaterId, +}); + +_ControlsDeps _controlsDepsOf(AppStateProvider s) { + final prefs = s.preferences; + final targetId = s.targetRepeaterId; + return ( + pingValidation: s.pingValidation, + manualValidation: s.manualPingValidation, + autoValidation: s.autoModeValidation, + autoPingEnabled: s.autoPingEnabled, + autoMode: s.autoMode, + isTargetedModeRunning: s.isTargetedModeRunning, + hybridModeEnabled: prefs.hybridModeEnabled, + isPendingDisable: s.isPendingDisable, + isPingSending: s.isPingSending, + isAutoPingStarting: s.isAutoPingStarting, + isPingInProgress: s.isPingInProgress, + isConnected: s.isConnected, + offlineMode: s.offlineMode, + txAllowed: s.txAllowed, + externalAntenna: prefs.externalAntenna, + externalAntennaSet: prefs.externalAntennaSet, + isPowerSet: + prefs.autoPowerSet || prefs.powerLevelSet || s.deviceModel != null, + floodTrafficEnabled: s.floodTrafficEnabled, + hasTargetRepeaterId: targetId != null && targetId.isNotEmpty, + ); +} + +/// Subset of provider state the Trace Mode section depends on. +typedef _TargetedDeps = ({ + bool isTargetedModeRunning, + int traceHopBytes, + String? targetRepeaterId, + bool isConnected, + bool hasRepeaters, +}); + +_TargetedDeps _targetedDepsOf(AppStateProvider s) => ( + isTargetedModeRunning: s.isTargetedModeRunning, + traceHopBytes: s.traceHopBytes, + targetRepeaterId: s.targetRepeaterId, + isConnected: s.isConnected, + hasRepeaters: s.repeaters.isNotEmpty, + ); + /// Modern ping control panel with icon-based buttons and animated status class PingControls extends StatelessWidget { const PingControls({super.key}); @override Widget build(BuildContext context) { - final appState = context.watch(); + // Rebuild only when control-relevant state changes — NOT on every GPS / + // noise-floor / battery notify. Countdown values update via the inner + // ListenableBuilder(timerListenable) below, so they're excluded here. + context.select(_controlsDepsOf); + final appState = context.read(); return ListenableBuilder( listenable: appState.timerListenable, builder: (_, __) { @@ -35,6 +112,8 @@ class PingControls extends StatelessWidget { final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState .isPendingDisable; // Disable pending, waiting for RX window to complete + final isAutoStarting = appState + .isAutoPingStarting; // True while an auto mode is starting (pre-first-notify) final cooldownActive = appState .cooldownTimer.isRunning; // Shared cooldown after disabling Active Mode final cooldownRemaining = appState.cooldownTimer.remainingSec; @@ -212,6 +291,7 @@ class PingControls extends StatelessWidget { : const Color(0xFF6366F1), // indigo-500 enabled: !isPendingDisable && !isTargetedRunning && + !isAutoStarting && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && @@ -271,6 +351,7 @@ class PingControls extends StatelessWidget { !isTxModeRunning && !isTargetedRunning && !isPendingDisable && + !isAutoStarting && !isPingSending && !rxWindowActive && !cooldownActive && @@ -557,7 +638,9 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { @override Widget build(BuildContext context) { - final appState = context.watch(); + // Rebuild only when Trace-relevant state changes (not on GPS/noise/battery). + context.select(_targetedDepsOf); + final appState = context.read(); final colorScheme = Theme.of(context).colorScheme; return ListenableBuilder( listenable: appState.timerListenable, @@ -774,7 +857,9 @@ class _CompactPingControlsState extends State { @override Widget build(BuildContext context) { - final appState = context.watch(); + // Rebuild only when control-relevant state changes (not on GPS/noise/battery). + context.select(_controlsDepsOf); + final appState = context.read(); return ListenableBuilder( listenable: appState.timerListenable, builder: (_, __) { @@ -794,6 +879,7 @@ class _CompactPingControlsState extends State { final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; + final isAutoStarting = appState.isAutoPingStarting; final cooldownActive = appState.cooldownTimer.isRunning; final cooldownRemaining = appState.cooldownTimer.remainingSec; final manualCooldownActive = appState @@ -880,6 +966,7 @@ class _CompactPingControlsState extends State { final activeModeEnabled = !isPendingDisable && !isTargetedRunning && + !isAutoStarting && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && @@ -896,6 +983,7 @@ class _CompactPingControlsState extends State { !isTxModeRunning && !isTargetedRunning && !isPendingDisable && + !isAutoStarting && !isPingSending && !rxWindowActive && !cooldownActive && @@ -1340,7 +1428,9 @@ class LandscapePingControls extends StatelessWidget { @override Widget build(BuildContext context) { - final appState = context.watch(); + // Rebuild only when control-relevant state changes (not on GPS/noise/battery). + context.select(_controlsDepsOf); + final appState = context.read(); return ListenableBuilder( listenable: appState.timerListenable, builder: (_, __) { @@ -1360,6 +1450,7 @@ class LandscapePingControls extends StatelessWidget { final isTargetedRunning = appState.isTargetedModeRunning; final hybridEnabled = appState.preferences.hybridModeEnabled; final isPendingDisable = appState.isPendingDisable; + final isAutoStarting = appState.isAutoPingStarting; final cooldownActive = appState.cooldownTimer.isRunning; final cooldownRemaining = appState.cooldownTimer.remainingSec; final manualCooldownActive = appState @@ -1454,6 +1545,7 @@ class LandscapePingControls extends StatelessWidget { : const Color(0xFF6366F1), // indigo-500 enabled: !isPendingDisable && !isTargetedRunning && + !isAutoStarting && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && @@ -1498,6 +1590,7 @@ class LandscapePingControls extends StatelessWidget { !isTxModeRunning && !isTargetedRunning && !isPendingDisable && + !isAutoStarting && !isPingSending && !rxWindowActive && !cooldownActive && From d145dd4602035af8da2050669e5584078cd8f0c0 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 23 Jun 2026 07:34:47 -0400 Subject: [PATCH 099/100] Fix GPS puck blink; log stuck-timer ping lockout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 (GPS puck blink, regression from 764f0d4): the puck shared one MapLibre symbol layer with every coverage pin. Adding a pin rewrote and re-laid-out the whole layer, so for ~1 frame the new pin painted over the puck before the sort-key reordered it on top. New since 764f0d4 because the puck previously sat UNDER the pins. Move the puck into its own dedicated gps-puck-source + gps-puck-layer, installed topmost (no belowLayerId), mirroring _ensureCoverageLinesLayer. Always-on-top is now guaranteed by layer order, not sort-key contention, and a pin can never paint in the puck's isolated layer -> blink gone by construction. enableInteraction:false so taps fall through to repeaters. Re-installs on style reload. Removes the old _gpsZIndex sort-key + _gpsSymbol annotation approach. Bug 2 (intermittent "Send Ping locks out Hybrid/Passive", force-close to clear): NOT a regression from recent commits — the new _isPingSending / _autoPingStarting flags both clear in finally and the disable/RX-window lifecycle was untouched. Latent issue: CountdownTimerService.isRunning is _timer != null with no wall-clock check, so if the 500ms _update() stops firing (iOS suspends timers while backgrounded/driving) the timer never self-cancels; a stuck rxWindowActive then disables Send Ping itself, so the user can't ping to reset it. Add diagnostic logging only (_logStuckTimers): warns [TIMER] isRunning past its deadline when a countdown timer reads isRunning && remainingMs == 0. Runs on app-resume and throttled-5s off the GPS notify (no new timer). Real one-line fix deferred until a debug log confirms the trigger. --- lib/providers/app_state_provider.dart | 58 ++++++++++ lib/widgets/map_widget.dart | 147 +++++++++++++++++++------- 2 files changed, 166 insertions(+), 39 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index dca61ab..ae3f558 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -423,6 +423,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (state == AppLifecycleState.resumed) { debugLog('[APP] App resumed from background'); _checkTcpHealthAfterResume(); + // Diagnostic: timers can be suspended while backgrounded; on resume, log + // any countdown timer stuck past its deadline (intermittent ping lockout). + _logStuckTimers('resume'); } else if (state == AppLifecycleState.paused) { debugLog('[APP] App paused (backgrounded)'); // Save offline pings immediately on pause to prevent data loss if OS kills app @@ -460,6 +463,57 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + // Throttle for the stuck-timer diagnostic (driven off the ~1-2Hz GPS notify). + DateTime? _lastStuckTimerCheck; + + /// Diagnostic ONLY (no state change, no notify): logs any countdown timer that + /// still reports `isRunning` after its deadline has passed (`remainingMs == 0`). + /// That is the fingerprint of the intermittent "Send Ping locks out + /// Hybrid/Passive" lockout — a [CountdownTimerService] whose 500ms `_update()` + /// stopped firing (e.g. iOS suspended its timers while backgrounded/driving) + /// never self-cancels, so `isRunning` (`_timer != null`) sticks true and keeps + /// the ping controls disabled until a force-close. A stuck `rxWindowTimer` + /// additionally disables Send Ping itself, so the user can't ping to reset it. + /// This logging is here to capture the real trigger on-device; the actual fix + /// is deferred until a debug log confirms it. See countdown_timer_service.dart. + void _logStuckTimers(String reason) { + void check(String name, CountdownTimerService t) { + if (t.isRunning && t.remainingMs == 0) { + debugWarn( + '[TIMER] $name isRunning past its deadline (stuck, remaining=0) — ' + 'locks ping controls until restart [$reason]'); + } + } + + check('rxWindowTimer', _rxWindowTimer); + check('cooldownTimer', _cooldownTimer); + check('manualPingCooldownTimer', _manualPingCooldownTimer); + check('discoveryWindowTimer', _discoveryWindowTimer); + check('autoPingTimer', _autoPingTimer); + + // pendingDisable should clear when the RX/discovery window it is waiting on + // completes. Still true while no such window is counting down => stuck. + if (isPendingDisable && + !_rxWindowTimer.isRunning && + !_discoveryWindowTimer.isRunning) { + debugWarn( + '[TIMER] pendingDisable stuck true with no RX/discovery window ' + 'running — locks ping controls until restart [$reason]'); + } + } + + /// Runs [_logStuckTimers] at most once every 5s. Called from the GPS position + /// notify (~1-2Hz during wardriving) so the stuck condition is caught in the + /// foreground without adding a dedicated timer. Connected-only to avoid noise. + void _maybeLogStuckTimers() { + if (!isConnected) return; + final now = DateTime.now(); + final last = _lastStuckTimerCheck; + if (last != null && now.difference(last).inSeconds < 5) return; + _lastStuckTimerCheck = now; + _logStuckTimers('watchdog'); + } + // ============================================ // Getters // ============================================ @@ -1265,6 +1319,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // (keyed on mapRevision) stays cached. notifyListeners(); + // Diagnostic: catch a stuck countdown timer (the intermittent ping-control + // lockout) in the foreground. Throttled to 5s; logs only when stuck. + _maybeLogStuckTimers(); + // Save last position for next app launch (already throttled to 30s) _saveLastPosition(position.latitude, position.longitude); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 20f366e..6ccd661 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -716,11 +716,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // recent pings into ~4s buckets, stacking simultaneous RX unpredictably. final Map _coverageZIndex = {}; int _coverageZCounter = 0; - /// GPS puck z-order: a fixed sort key far above the coverage counter - /// (_coverageZCounter, which climbs from 1 per unique ping) so the GPS - /// marker always renders on top of every TX/RX/DISC/Trace pin. Stays exact - /// in the native float32 symbol-sort-key (< 2^24). See _syncCoverageSymbols. - static const int _gpsZIndex = 1 << 23; + // GPS puck lives in its OWN dedicated GeoJSON source + symbol layer rendered + // ABOVE every other layer (coverage fills/pins, repeaters), so it is always on + // top by LAYER ORDER — not by competing for a symbol-sort-key inside the shared + // annotation source. Keeping it out of that shared source also removes the + // one-frame blink where a freshly-added coverage pin painted over the puck + // during the source's full re-layout (the old `_gpsZIndex` sort-key approach). + // Installed via _ensureGpsPuckLayer (re-installed on style reload, see + // _onStyleLoaded) and updated in place via setGeoJsonSource. + static const String _gpsPuckSourceId = 'gps-puck-source'; + static const String _gpsPuckLayerId = 'gps-puck-layer'; + bool _gpsPuckLayerInstalled = false; final Map _distanceLabelSymbols = {}; // key: focused repeater id // Per focused-repeater metadata used by the collision-avoidance reflow: @@ -737,7 +743,6 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // addImage (deduped by "repchip_{status}_{hop}_{hex}"). Lazily grown by // _ensureRepeaterChipImages; cleared on style reload (native drops images). final Set _registeredChipImages = {}; - Symbol? _gpsSymbol; // single GPS marker // When true, _syncAllAnnotations skips _updateFocusLines and // _syncDistanceLabels so the 500ms zoom-to-fit animation runs without @@ -2830,13 +2835,15 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // CRITICAL: clear stale Symbol references from any previous style load. // Style reloads cause maplibre_gl to construct a brand-new SymbolManager - // with empty internal _idToAnnotation maps. Our _gpsSymbol / - // _coverageSymbols / _distanceLabelSymbols still reference the OLD + // with empty internal _idToAnnotation maps. Our _coverageSymbols / + // _distanceLabelSymbols still reference the OLD // Symbol objects whose IDs are not in the new manager — calling // updateSymbol on them throws "you can only set existing annotations". // Clearing them now means the next sync will call addSymbol (which // creates fresh symbols in the new manager) instead of updateSymbol. - _gpsSymbol = null; + // The dedicated GPS-puck source/layer is wiped by the style reload too; + // reset the guard so _ensureGpsPuckLayer recreates it (topmost) below. + _gpsPuckLayerInstalled = false; _coverageSymbols.clear(); _coverageSymbolSig.clear(); // The native SymbolManager is rebuilt empty here, so the next sync re-adds @@ -2916,6 +2923,11 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _lastBordersSignature = -1; await _refreshRegionBorders(appState); + // GPS puck: dedicated top-most source+layer. Install AFTER coverage + + // repeaters + borders so it sits above them all (always-on-top by layer + // order). Idempotent; _syncGpsSymbol also ensures it before its first push. + await _ensureGpsPuckLayer(); + // Start tile-load timeout. If onMapIdle doesn't fire within N seconds, // we assume tiles are failing to load (network down, server error, etc.) // and surface a banner. Cleared as soon as onMapIdle fires. @@ -4689,19 +4701,27 @@ class _MapWidgetState extends State with WidgetsBindingObserver { return 0; } - /// Adds, updates, or removes the single GPS position symbol to match - /// [appState.currentPosition]. Called from the post-frame sync trigger. + /// Pushes the single GPS position feature into the dedicated puck source to + /// match [appState.currentPosition]. Called from the post-frame sync trigger. + /// The puck lives in its own top-most layer (see [_ensureGpsPuckLayer]), so + /// this only ever rewrites a one-feature source — it never touches the shared + /// coverage-pin source, which is what eliminated the blink. Future _syncGpsSymbol(AppStateProvider appState) async { if (_mapController == null || !_styleLoaded || !_imagesRegistered) return; + // Ensure the dedicated puck source+layer exists (idempotent). On style + // reload _gpsPuckLayerInstalled is reset so this recreates it topmost. + if (!await _ensureGpsPuckLayer()) return; + final pos = appState.currentPosition; if (pos == null) { - // No GPS lock — remove existing GPS symbol if present - if (_gpsSymbol != null) { - try { - await _mapController!.removeSymbol(_gpsSymbol!); - } catch (_) {} - _gpsSymbol = null; + // No GPS lock — clear the puck by pushing an empty collection. Keeps the + // styled layer in place (no remove/recreate). + try { + await _mapController! + .setGeoJsonSource(_gpsPuckSourceId, _emptyFeatureCollection()); + } catch (e) { + debugError('[MAP] clear gps puck failed: $e'); } return; } @@ -4712,27 +4732,75 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // even when pos.heading is stale or unset. final iconRotate = _gpsIconRotate(style, _computedHeading ?? 0); - final options = SymbolOptions( - geometry: LatLng(pos.latitude, pos.longitude), - iconImage: _MapImages.gps(style), - iconRotate: iconRotate, - // Keep the GPS puck above all coverage pings in the shared annotation - // layer (Critical Rule 9 / _zIndexFor). See _gpsZIndex. - zIndex: _gpsZIndex, - ); + try { + await _mapController!.setGeoJsonSource( + _gpsPuckSourceId, + _buildGpsPuckFeatureCollection( + pos.latitude, pos.longitude, style, iconRotate), + ); + } catch (e) { + debugError('[MAP] update gps puck failed: $e'); + } + } + + /// One-Point FeatureCollection for the GPS puck source. `iconImage` and + /// `iconRotate` are read by the puck symbol layer's data-driven expressions. + Map _buildGpsPuckFeatureCollection( + double lat, double lon, String style, double iconRotate) { + return { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'properties': { + 'iconImage': _MapImages.gps(style), + 'iconRotate': iconRotate, + }, + 'geometry': { + 'type': 'Point', + // GeoJSON convention: [longitude, latitude] + 'coordinates': [lon, lat], + }, + }, + ], + }; + } - if (_gpsSymbol == null) { + /// Idempotently (re)create the dedicated GPS-puck source + symbol layer. + /// The layer is added with NO belowLayerId so it sits on TOP of every other + /// layer (coverage fills/pins, repeaters) — the puck is always-on-top by layer + /// order, with no symbol-sort-key contention. enableInteraction:false so taps + /// pass through to the repeaters/clusters underneath. Mirrors + /// [_ensureCoverageLinesLayer]. + Future _ensureGpsPuckLayer() async { + if (_gpsPuckLayerInstalled) return true; + if (_mapController == null) return false; + try { try { - _gpsSymbol = await _mapController!.addSymbol(options, {'kind': 'gps'}); - } catch (e) { - debugError('[MAP] addSymbol(gps) failed: $e'); - } - } else { + await _mapController!.removeLayer(_gpsPuckLayerId); + } catch (_) {} try { - await _mapController!.updateSymbol(_gpsSymbol!, options); - } catch (e) { - debugError('[MAP] updateSymbol(gps) failed: $e'); - } + await _mapController!.removeSource(_gpsPuckSourceId); + } catch (_) {} + await _mapController! + .addGeoJsonSource(_gpsPuckSourceId, _emptyFeatureCollection()); + await _mapController!.addSymbolLayer( + _gpsPuckSourceId, + _gpsPuckLayerId, + const SymbolLayerProperties( + iconImage: ['get', 'iconImage'], + iconRotate: ['get', 'iconRotate'], + iconAllowOverlap: true, + iconIgnorePlacement: true, + ), + // No belowLayerId => topmost, above all coverage pings and repeaters. + enableInteraction: false, + ); + _gpsPuckLayerInstalled = true; + return true; + } catch (e) { + debugError('[MAP] gps-puck layer create failed: $e'); + return false; } } @@ -4743,16 +4811,17 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// bearing animates. Non-rotating styles use iconRotate = 0 and don't care. /// Cheaper than calling [_syncGpsSymbol] which also updates position. Future _updateGpsSymbolRotation() async { - if (_gpsSymbol == null || _mapController == null) return; + if (!_gpsPuckLayerInstalled || _mapController == null) return; final appState = context.read(); final pos = appState.currentPosition; if (pos == null) return; final style = appState.preferences.gpsMarkerStyle; if (!_gpsStyleFacesHeading(style)) return; try { - await _mapController!.updateSymbol( - _gpsSymbol!, - SymbolOptions(iconRotate: _gpsIconRotate(style, _computedHeading ?? 0)), + await _mapController!.setGeoJsonSource( + _gpsPuckSourceId, + _buildGpsPuckFeatureCollection(pos.latitude, pos.longitude, style, + _gpsIconRotate(style, _computedHeading ?? 0)), ); } catch (_) {} } From 222a300decf0731d1bc08e4ece9d0ff70ff95368 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 25 Jun 2026 06:46:54 -0400 Subject: [PATCH 100/100] Hide donation link on iOS to satisfy App Store guideline 3.1.1 External 'Buy us a coffee' (Buy Me a Coffee) link triggered Apple rejection under 3.1.1. Gate the About-section tile behind a platform check so it's omitted on iOS while remaining on Android/Web, where external donation links are permitted. --- lib/screens/settings_screen.dart | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index b168195..d458128 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -818,12 +818,15 @@ class _SettingsScreenState extends State { 'Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), onTap: () => _launchUrl('https://ottawamesh.ca/'), ), - ListTile( - leading: const Icon(Icons.coffee), - title: const Text('Buy us a coffee'), - subtitle: const Text('Support MeshMapper development'), - onTap: () => _launchUrl('https://buymeacoffee.com/meshmapper'), - ), + // Buy Me a Coffee — external donation links are fine on Android/Web + // but violate Apple guideline 3.1.1 on iOS, so omit it on iOS. + if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) + ListTile( + leading: const Icon(Icons.coffee), + title: const Text('Buy us a coffee'), + subtitle: const Text('Support MeshMapper development'), + onTap: () => _launchUrl('https://buymeacoffee.com/meshmapper'), + ), ]), // Exit Options (Android only)