From 96feb7571205862b7870257a8e0f42172b2698c0 Mon Sep 17 00:00:00 2001 From: Janez T Date: Fri, 6 Mar 2026 14:11:50 +0100 Subject: [PATCH] feat: save all pending work ref: --- ios/Podfile | 8 + ios/Podfile.lock | 9 +- ios/Runner.xcodeproj/project.pbxproj | 12 +- ios/Runner/Info.plist | 2 +- ios/fastlane/report.xml | 8 +- lib/models/device_info.dart | 33 +- lib/providers/connection_provider.dart | 52 + lib/screens/device_config_screen.dart | 5 +- lib/screens/home_screen.dart | 21 + lib/screens/map_tab.dart | 2666 ++++++++++------- lib/screens/spectrum_scan_screen.dart | 601 ++++ lib/utils/avatar_label_helper.dart | 32 + lib/widgets/common/contact_avatar.dart | 129 + lib/widgets/device/spectrum_scan_panel.dart | 394 +++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 74 +- pubspec.yaml | 7 +- test/utils/avatar_label_helper_test.dart | 26 + test/widgets/contact_avatar_test.dart | 106 + 19 files changed, 2943 insertions(+), 1244 deletions(-) create mode 100644 lib/screens/spectrum_scan_screen.dart create mode 100644 lib/utils/avatar_label_helper.dart create mode 100644 lib/widgets/common/contact_avatar.dart create mode 100644 lib/widgets/device/spectrum_scan_panel.dart create mode 100644 test/utils/avatar_label_helper_test.dart create mode 100644 test/widgets/contact_avatar_test.dart diff --git a/ios/Podfile b/ios/Podfile index 620e46e..b24e2b1 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) + # codec2_flutter uses ARM-specific register asm("sp") in debug_alloc.h that + # fails to compile for iOS simulators. Voice codec support is only required + # on real iOS devices, so skip the native Codec2 sources for simulator SDKs. + if target.name == 'codec2_flutter' + target.build_configurations.each do |config| + config.build_settings['EXCLUDED_SOURCE_FILE_NAMES[sdk=iphonesimulator*]'] = '*.c *.cpp' + end + end end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 13d7b30..333b0a2 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -65,6 +65,9 @@ PODS: - ObjectBox (= 4.4.1) - package_info_plus (0.4.5): - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - record_ios (1.2.0): @@ -102,6 +105,7 @@ DEPENDENCIES: - nsd_ios (from `.symlinks/plugins/nsd_ios/ios`) - objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - record_ios (from `.symlinks/plugins/record_ios/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) @@ -149,6 +153,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/objectbox_flutter_libs/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" record_ios: @@ -183,6 +189,7 @@ SPEC CHECKSUMS: ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757 objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf @@ -193,6 +200,6 @@ SPEC CHECKSUMS: url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb -PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e +PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index e4c6c62..44bd20c 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -489,7 +489,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 84; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -511,7 +511,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 84; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -530,7 +530,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 84; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -547,7 +547,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 84; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -679,7 +679,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 84; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -702,7 +702,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 84; + CURRENT_PROJECT_VERSION = 88; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index f0d510f..6c2d25c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -43,7 +43,7 @@ CFBundleSignature ???? CFBundleVersion - 84 + 88 LSRequiresIPhoneOS NSBluetoothAlwaysUsageDescription diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 59bb3ca..8392b5a 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/models/device_info.dart b/lib/models/device_info.dart index 1c466cd..f067564 100644 --- a/lib/models/device_info.dart +++ b/lib/models/device_info.dart @@ -73,12 +73,14 @@ class DeviceInfo { final String? selfName; // Additional device capabilities (from RESP_CODE_DEVICE_INFO) - final int? maxContacts; // Max contacts device supports - final int? maxChannels; // Max channels device supports - final int? telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location) - final int? blePin; // BLE PIN code - final int? multiAcks; // Extra ACK mode (0=no, 1=yes) - final int? advertLocPolicy; // Location sharing policy (0=don't share, 1=share) + final int? maxContacts; // Max contacts device supports + final int? maxChannels; // Max channels device supports + final int? + telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location) + final int? blePin; // BLE PIN code + final int? multiAcks; // Extra ACK mode (0=no, 1=yes) + final int? + advertLocPolicy; // Location sharing policy (0=don't share, 1=share) // Firmware info final int? firmwareVersion; @@ -88,6 +90,9 @@ class DeviceInfo { // Repeat mode (firmware v9+) final bool? clientRepeat; + final bool? supportsSpectrumScan; + final int? spectrumScanMinKhz; + final int? spectrumScanMaxKhz; final List<({int lower, int upper})>? allowedRepeatFreqRanges; DeviceInfo({ @@ -124,6 +129,9 @@ class DeviceInfo { this.manufacturerModel, this.semanticVersion, this.clientRepeat, + this.supportsSpectrumScan, + this.spectrumScanMinKhz, + this.spectrumScanMaxKhz, this.allowedRepeatFreqRanges, }); @@ -160,7 +168,9 @@ class DeviceInfo { /// Get storage usage percentage (0-100) double? get storageUsedPercent { - if (storageUsedKb == null || storageTotalKb == null || storageTotalKb == 0) { + if (storageUsedKb == null || + storageTotalKb == null || + storageTotalKb == 0) { return null; } return (storageUsedKb! / storageTotalKb!) * 100.0; @@ -245,6 +255,9 @@ class DeviceInfo { String? manufacturerModel, String? semanticVersion, bool? clientRepeat, + bool? supportsSpectrumScan, + int? spectrumScanMinKhz, + int? spectrumScanMaxKhz, List<({int lower, int upper})>? allowedRepeatFreqRanges, }) { return DeviceInfo( @@ -281,7 +294,11 @@ class DeviceInfo { manufacturerModel: manufacturerModel ?? this.manufacturerModel, semanticVersion: semanticVersion ?? this.semanticVersion, clientRepeat: clientRepeat ?? this.clientRepeat, - allowedRepeatFreqRanges: allowedRepeatFreqRanges ?? this.allowedRepeatFreqRanges, + supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan, + spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz, + spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz, + allowedRepeatFreqRanges: + allowedRepeatFreqRanges ?? this.allowedRepeatFreqRanges, ); } diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 2424538..8ffbbad 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -89,6 +89,8 @@ class ConnectionProvider with ChangeNotifier { bool _isScanning = false; bool get isScanning => _isScanning; + bool _isSpectrumScanActive = false; + bool get isSpectrumScanActive => _isSpectrumScanActive; String? _error; String? get error => _error; @@ -312,6 +314,10 @@ class ConnectionProvider with ChangeNotifier { }; service.onMessageWaiting = () { + if (_isSpectrumScanActive) { + debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan'); + return; + } debugPrint('📥 [Provider] MSG_WAITING - auto-syncing'); if (_isSyncingMessages) { _syncRequestedWhileBusy = true; @@ -379,6 +385,9 @@ class ConnectionProvider with ChangeNotifier { manufacturerModel: deviceInfo['manufacturerModel'] as String?, semanticVersion: deviceInfo['semanticVersion'] as String?, clientRepeat: deviceInfo['clientRepeat'] as bool?, + supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?, + spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?, + spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?, ); notifyListeners(); if (_sseServer.isRunning) { @@ -1555,6 +1564,43 @@ class ConnectionProvider with ChangeNotifier { } } + Future scanSpectrum({ + required int startFrequencyKhz, + required int stopFrequencyKhz, + required int bandwidthKhz, + required int stepKhz, + required int dwellMs, + required int thresholdDb, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return null; + } + + try { + _isSpectrumScanActive = true; + _activeService.setSpectrumScanActive(true); + notifyListeners(); + return await _activeService.scanSpectrum( + startFrequencyKhz: startFrequencyKhz, + stopFrequencyKhz: stopFrequencyKhz, + bandwidthKhz: bandwidthKhz, + stepKhz: stepKhz, + dwellMs: dwellMs, + thresholdDb: thresholdDb, + ); + } catch (e) { + _error = 'Failed to scan spectrum: $e'; + notifyListeners(); + return null; + } finally { + _isSpectrumScanActive = false; + _activeService.setSpectrumScanActive(false); + notifyListeners(); + } + } + /// Set transmit power Future setTxPower(int powerDbm) async { if (!_activeService.isConnected) { @@ -1599,6 +1645,7 @@ class ConnectionProvider with ChangeNotifier { /// Request fresh device info (triggers SelfInfo response) Future refreshDeviceInfo() async { + if (_isSpectrumScanActive) return; if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); @@ -1642,6 +1689,7 @@ class ConnectionProvider with ChangeNotifier { /// Sync messages from device queue /// Call this repeatedly until no more messages are available Future syncNextMessage() async { + if (_isSpectrumScanActive) return false; // Prevent re-entrancy and too-fast triggers if (_isSyncingMessages) { // Another sync (single or loop) is in progress @@ -1680,6 +1728,10 @@ class ConnectionProvider with ChangeNotifier { /// Sync all waiting messages from device Future syncAllMessages() async { + if (_isSpectrumScanActive) { + debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan'); + return 0; + } if (_isSyncingMessages) { // Already syncing; avoid overlapping loops _syncRequestedWhileBusy = true; diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart index e8243c1..c072cfb 100644 --- a/lib/screens/device_config_screen.dart +++ b/lib/screens/device_config_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; import 'package:geolocator/geolocator.dart'; +import 'package:provider/provider.dart'; import '../providers/connection_provider.dart'; import '../services/validation_service.dart'; import '../l10n/app_localizations.dart'; @@ -722,7 +722,8 @@ class _DeviceConfigScreenState extends State { SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Client Repeat Mode'), - subtitle: deviceInfo.allowedRepeatFreqRanges != null && + subtitle: + deviceInfo.allowedRepeatFreqRanges != null && deviceInfo.allowedRepeatFreqRanges!.isNotEmpty ? Text( 'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}–${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}', diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index e1463ed..03ffe2c 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -17,6 +17,7 @@ import 'map_management_screen.dart'; import 'settings_screen.dart'; import 'device_config_screen.dart'; import 'packet_log_screen.dart'; +import 'spectrum_scan_screen.dart'; import '../utils/toast_logger.dart'; import '../l10n/app_localizations.dart'; import '../widgets/permission_request_dialog.dart'; @@ -381,6 +382,26 @@ class _HomeScreenState extends State }); }, ), + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.radar), + const SizedBox(width: 8), + const Text('Spectrum Scan'), + ], + ), + onTap: () { + final navigator = Navigator.of(context); + Future.delayed(Duration.zero, () { + if (!mounted) return; + navigator.push( + MaterialPageRoute( + builder: (context) => const SpectrumScanScreen(), + ), + ); + }); + }, + ), PopupMenuItem( child: Row( children: [ diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index 20f368b..aaf8da7 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -73,7 +73,8 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { double _gpsUpdateDistance = 3.0; // meters bool _backgroundTrackingEnabled = false; // Toggle for background tracking StreamSubscription? _compassStreamSubscription; - final BackgroundLocationService _backgroundLocationService = BackgroundLocationService(); + final BackgroundLocationService _backgroundLocationService = + BackgroundLocationService(); bool _isDisposing = false; // Flag to prevent updates during disposal // MBTiles layers @@ -100,7 +101,10 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { double? _savedMapZoom; // Default center point (will be updated based on markers) - static const LatLng _defaultCenter = LatLng(46.0569, 14.5058); // Ljubljana, Slovenia + static const LatLng _defaultCenter = LatLng( + 46.0569, + 14.5058, + ); // Ljubljana, Slovenia static const double _defaultZoom = 13.0; @override @@ -131,7 +135,9 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { // Initialize background location service with BLE service final appProvider = context.read(); - _backgroundLocationService.initialize(appProvider.connectionProvider.bleService); + _backgroundLocationService.initialize( + appProvider.connectionProvider.bleService, + ); // Restore background tracking state _restoreBackgroundTracking(); @@ -197,39 +203,40 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } // Start listening to compass events - _compassStreamSubscription = compassStream.listen( - (CompassEvent event) { - // Check if widget is disposing, mounted, and event has valid heading - if (_isDisposing || !mounted || event.heading == null) return; + _compassStreamSubscription = compassStream.listen((CompassEvent event) { + // Check if widget is disposing, mounted, and event has valid heading + if (_isDisposing || !mounted || event.heading == null) return; - try { - setState(() { - _compassHeading = event.heading; - }); + try { + setState(() { + _compassHeading = event.heading; + }); - // Rotate map if rotation mode is enabled and we have compass heading - // Only rotate if map is ready - if (_rotateMarkerWithHeading && event.heading != null && _isMapReady && !_isDisposing) { - try { - // Use moveAndRotate to set absolute rotation - final camera = _mapController.camera; - _mapController.moveAndRotate( - camera.center, - camera.zoom, - -event.heading!, - ); - } catch (e) { - // Map not ready yet, ignore - } - } - } catch (e) { - // Widget disposed during setState, ignore - if (!_isDisposing) { - debugPrint('Compass tracking error: $e'); + // Rotate map if rotation mode is enabled and we have compass heading + // Only rotate if map is ready + if (_rotateMarkerWithHeading && + event.heading != null && + _isMapReady && + !_isDisposing) { + try { + // Use moveAndRotate to set absolute rotation + final camera = _mapController.camera; + _mapController.moveAndRotate( + camera.center, + camera.zoom, + -event.heading!, + ); + } catch (e) { + // Map not ready yet, ignore } } - }, - ); + } catch (e) { + // Widget disposed during setState, ignore + if (!_isDisposing) { + debugPrint('Compass tracking error: $e'); + } + } + }); } /// Load MBTiles layers from file system @@ -275,7 +282,8 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { final lastLayerName = prefs.getString('map_last_layer_name'); setState(() { - _rotateMarkerWithHeading = prefs.getBool('map_rotate_with_heading') ?? false; + _rotateMarkerWithHeading = + prefs.getBool('map_rotate_with_heading') ?? false; _showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false; _isFullscreen = prefs.getBool('map_fullscreen') ?? false; @@ -284,7 +292,8 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { widget.onFullscreenChanged?.call(_isFullscreen); }); _gpsUpdateDistance = prefs.getDouble('map_gps_update_distance') ?? 3.0; - _backgroundTrackingEnabled = prefs.getBool('background_tracking_enabled') ?? false; + _backgroundTrackingEnabled = + prefs.getBool('background_tracking_enabled') ?? false; // Store saved position for use in build if (lastLat != null && lastLon != null && lastZoom != null) { @@ -295,7 +304,8 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { // Restore last used map layer (by type and name for MBTiles) if (lastLayerType != null) { final layerType = MapLayerType.values[lastLayerType]; - if (layerType == MapLayerType.vectorMbtiles && lastLayerName != null) { + if (layerType == MapLayerType.vectorMbtiles && + lastLayerName != null) { // Find MBTiles layer by name final mbtilesLayer = _mbtilesLayers.firstWhere( (layer) => layer.name == lastLayerName, @@ -329,7 +339,10 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { await prefs.setBool('map_show_debug_info', _showMapDebugInfo); await prefs.setBool('map_fullscreen', _isFullscreen); await prefs.setDouble('map_gps_update_distance', _gpsUpdateDistance); - await prefs.setBool('background_tracking_enabled', _backgroundTrackingEnabled); + await prefs.setBool( + 'background_tracking_enabled', + _backgroundTrackingEnabled, + ); // Save layer type and name (for MBTiles layers) await prefs.setInt('map_last_layer_type', _currentLayer.type.index); @@ -351,7 +364,6 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } } - void _handleMapNavigation() { final mapProvider = context.read(); if (mapProvider.targetLocation != null && _isMapReady) { @@ -527,8 +539,8 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { child: Text( AppLocalizations.of(context)!.selectMapLayer, style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + fontWeight: FontWeight.bold, + ), ), ), IconButton( @@ -549,35 +561,41 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { children: [ // Online layers section Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), child: Text( AppLocalizations.of(context)!.onlineLayers, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Colors.grey[600], - ), + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: Colors.grey[600]), + ), + ), + ...MapLayer.allLayers.map( + (layer) => ListTile( + leading: _currentLayer == layer + ? const Icon(Icons.check_circle, color: Colors.green) + : const Icon(Icons.radio_button_unchecked), + title: Text(layer.getLocalizedName(context)), + subtitle: Text(layer.attribution), + onTap: () async { + setState(() { + _currentLayer = layer; + // Clamp zoom level if current zoom exceeds new layer's max + if (_isMapReady && + _mapController.camera.zoom > layer.maxZoom) { + _mapController.move( + _mapController.camera.center, + layer.maxZoom, + ); + } + }); + _saveSettings(); + Navigator.pop(context); + }, ), ), - ...MapLayer.allLayers.map((layer) => ListTile( - leading: _currentLayer == layer - ? const Icon(Icons.check_circle, color: Colors.green) - : const Icon(Icons.radio_button_unchecked), - title: Text(layer.getLocalizedName(context)), - subtitle: Text(layer.attribution), - onTap: () async { - setState(() { - _currentLayer = layer; - // Clamp zoom level if current zoom exceeds new layer's max - if (_isMapReady && _mapController.camera.zoom > layer.maxZoom) { - _mapController.move( - _mapController.camera.center, - layer.maxZoom, - ); - } - }); - _saveSettings(); - Navigator.pop(context); - }, - )), // Slovenian WMS base layers (only for Slovenian/Croatian regions) if (AppLocalizations.of(context)!.localeName == 'sl' || AppLocalizations.of(context)!.localeName == 'hr') ...[ @@ -592,7 +610,9 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { _currentLayer = _slovenianAerialLayer; // Clamp zoom level if current zoom exceeds new layer's max // For WMS layers, use a middle zoom (11) instead of max zoom to avoid extreme close-up - if (_isMapReady && _mapController.camera.zoom > _slovenianAerialLayer.maxZoom) { + if (_isMapReady && + _mapController.camera.zoom > + _slovenianAerialLayer.maxZoom) { _mapController.move( _mapController.camera.center, 11.0, // Middle zoom for WMS @@ -614,7 +634,9 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { _currentLayer = _dtk25Layer; // Clamp zoom level if current zoom exceeds new layer's max // For WMS layers, use a middle zoom (11) instead of max zoom to avoid extreme close-up - if (_isMapReady && _mapController.camera.zoom > _dtk25Layer.maxZoom) { + if (_isMapReady && + _mapController.camera.zoom > + _dtk25Layer.maxZoom) { _mapController.move( _mapController.camera.center, 11.0, // Middle zoom for WMS @@ -630,62 +652,76 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { if (_mbtilesLayers.isNotEmpty) ...[ const Divider(), Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), child: Text( AppLocalizations.of(context)!.offlineLayers, style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Colors.grey[600], - ), + color: Colors.grey[600], + ), ), ), - ..._mbtilesLayers.map((layer) => ListTile( - leading: _currentLayer == layer - ? const Icon(Icons.check_circle, color: Colors.green) - : Icon( - layer.isVector ? Icons.layers : Icons.image, - color: layer.isVector ? Colors.blue : Colors.orange, - ), - title: Text(layer.name), - subtitle: Text(layer.attribution), - onTap: () async { - // Capture navigator before async operation - final navigator = Navigator.of(context); - // Load vector theme if switching to vector layer - if (layer.isVector && layer.styleUrl != null) { - navigator.pop(); - await _loadVectorTheme(layer.styleUrl!); - } + ..._mbtilesLayers.map( + (layer) => ListTile( + leading: _currentLayer == layer + ? const Icon( + Icons.check_circle, + color: Colors.green, + ) + : Icon( + layer.isVector ? Icons.layers : Icons.image, + color: layer.isVector + ? Colors.blue + : Colors.orange, + ), + title: Text(layer.name), + subtitle: Text(layer.attribution), + onTap: () async { + // Capture navigator before async operation + final navigator = Navigator.of(context); + // Load vector theme if switching to vector layer + if (layer.isVector && layer.styleUrl != null) { + navigator.pop(); + await _loadVectorTheme(layer.styleUrl!); + } - setState(() { - _currentLayer = layer; - // Clamp zoom level if current zoom exceeds new layer's max - if (_isMapReady && _mapController.camera.zoom > layer.maxZoom) { - _mapController.move( - _mapController.camera.center, - layer.maxZoom, - ); - } - }); - _saveSettings(); - - if (!layer.isVector && mounted) { - navigator.pop(); + setState(() { + _currentLayer = layer; + // Clamp zoom level if current zoom exceeds new layer's max + if (_isMapReady && + _mapController.camera.zoom > layer.maxZoom) { + _mapController.move( + _mapController.camera.center, + layer.maxZoom, + ); } - }, - )), + }); + _saveSettings(); + + if (!layer.isVector && mounted) { + navigator.pop(); + } + }, + ), + ), ], // WMS Overlays section (only for Slovenian/Croatian regions and when WMS base layer is selected) if ((AppLocalizations.of(context)!.localeName == 'sl' || - AppLocalizations.of(context)!.localeName == 'hr') && + AppLocalizations.of(context)!.localeName == 'hr') && _currentLayer.isWms) ...[ const Divider(), Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), child: Text( AppLocalizations.of(context)!.wmsOverlays, style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Colors.grey[600], - ), + color: Colors.grey[600], + ), ), ), Consumer( @@ -693,8 +729,13 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { return Column( children: [ CheckboxListTile( - secondary: const Icon(Icons.grid_on, color: Colors.blue), - title: Text(AppLocalizations.of(context)!.cadastralParcels), + secondary: const Icon( + Icons.grid_on, + color: Colors.blue, + ), + title: Text( + AppLocalizations.of(context)!.cadastralParcels, + ), subtitle: const Text('© GURS'), value: mapProvider.showCadastralOverlay, onChanged: (value) { @@ -702,8 +743,13 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }, ), CheckboxListTile( - secondary: const Icon(Icons.route, color: Colors.green), - title: Text(AppLocalizations.of(context)!.forestRoads), + secondary: const Icon( + Icons.route, + color: Colors.green, + ), + title: Text( + AppLocalizations.of(context)!.forestRoads, + ), subtitle: const Text('© GURS'), value: mapProvider.showForestRoadsOverlay, onChanged: (value) { @@ -711,8 +757,13 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }, ), CheckboxListTile( - secondary: const Icon(Icons.hiking, color: Colors.brown), - title: Text(AppLocalizations.of(context)!.hikingTrails), + secondary: const Icon( + Icons.hiking, + color: Colors.brown, + ), + title: Text( + AppLocalizations.of(context)!.hikingTrails, + ), subtitle: const Text('© GURS'), value: mapProvider.showHikingTrailsOverlay, onChanged: (value) { @@ -720,8 +771,13 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }, ), CheckboxListTile( - secondary: const Icon(Icons.alt_route, color: Colors.grey), - title: Text(AppLocalizations.of(context)!.mainRoads), + secondary: const Icon( + Icons.alt_route, + color: Colors.grey, + ), + title: Text( + AppLocalizations.of(context)!.mainRoads, + ), subtitle: const Text('© GURS'), value: mapProvider.showMainRoadsOverlay, onChanged: (value) { @@ -729,8 +785,13 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }, ), CheckboxListTile( - secondary: const Icon(Icons.numbers, color: Colors.purple), - title: Text(AppLocalizations.of(context)!.houseNumbers), + secondary: const Icon( + Icons.numbers, + color: Colors.purple, + ), + title: Text( + AppLocalizations.of(context)!.houseNumbers, + ), subtitle: const Text('© GURS'), value: mapProvider.showHouseNumbersOverlay, onChanged: (value) { @@ -738,8 +799,13 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }, ), CheckboxListTile( - secondary: const Icon(Icons.warning_amber, color: Colors.orange), - title: Text(AppLocalizations.of(context)!.fireHazardZones), + secondary: const Icon( + Icons.warning_amber, + color: Colors.orange, + ), + title: Text( + AppLocalizations.of(context)!.fireHazardZones, + ), subtitle: const Text('© GURS'), value: mapProvider.showFireHazardZonesOverlay, onChanged: (value) { @@ -747,8 +813,13 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }, ), CheckboxListTile( - secondary: const Icon(Icons.local_fire_department, color: Colors.red), - title: Text(AppLocalizations.of(context)!.historicalFires), + secondary: const Icon( + Icons.local_fire_department, + color: Colors.red, + ), + title: Text( + AppLocalizations.of(context)!.historicalFires, + ), subtitle: const Text('© GURS'), value: mapProvider.showHistoricalFiresOverlay, onChanged: (value) { @@ -756,8 +827,13 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }, ), CheckboxListTile( - secondary: const Icon(Icons.forest, color: Colors.teal), - title: Text(AppLocalizations.of(context)!.firebreaks), + secondary: const Icon( + Icons.forest, + color: Colors.teal, + ), + title: Text( + AppLocalizations.of(context)!.firebreaks, + ), subtitle: const Text('© GURS'), value: mapProvider.showFirebreaksOverlay, onChanged: (value) { @@ -765,8 +841,13 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }, ), CheckboxListTile( - secondary: const Icon(Icons.warning, color: Colors.deepOrange), - title: Text(AppLocalizations.of(context)!.krasFireZones), + secondary: const Icon( + Icons.warning, + color: Colors.deepOrange, + ), + title: Text( + AppLocalizations.of(context)!.krasFireZones, + ), subtitle: const Text('© GURS'), value: mapProvider.showKrasFireZonesOverlay, onChanged: (value) { @@ -774,8 +855,13 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }, ), CheckboxListTile( - secondary: const Icon(Icons.place, color: Colors.indigo), - title: Text(AppLocalizations.of(context)!.placeNames), + secondary: const Icon( + Icons.place, + color: Colors.indigo, + ), + title: Text( + AppLocalizations.of(context)!.placeNames, + ), subtitle: const Text('© GURS'), value: mapProvider.showPlaceNamesOverlay, onChanged: (value) { @@ -783,8 +869,15 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }, ), CheckboxListTile( - secondary: const Icon(Icons.border_outer, color: Colors.cyan), - title: Text(AppLocalizations.of(context)!.municipalityBorders), + secondary: const Icon( + Icons.border_outer, + color: Colors.cyan, + ), + title: Text( + AppLocalizations.of( + context, + )!.municipalityBorders, + ), subtitle: const Text('© GURS'), value: mapProvider.showMunicipalityBordersOverlay, onChanged: (value) { @@ -831,54 +924,60 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - const Icon(Icons.settings), - const SizedBox(width: 12), - Text( - AppLocalizations.of(context)!.mapOptions, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + const Icon(Icons.settings), + const SizedBox(width: 12), + Text( + AppLocalizations.of(context)!.mapOptions, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ], + ), ), - ), - const Divider(), - // Map Debug Info toggle - SwitchListTile( - secondary: const Icon(Icons.developer_mode), - title: Text(AppLocalizations.of(context)!.showMapDebugInfo), - subtitle: Text(AppLocalizations.of(context)!.displayZoomLevelBounds), - value: _showMapDebugInfo, - onChanged: (value) { - setState(() { - _showMapDebugInfo = value; - }); - setModalState(() {}); - _saveSettings(); - }, - ), - const Divider(), - // Fullscreen mode toggle - SwitchListTile( - secondary: const Icon(Icons.fullscreen), - title: Text(AppLocalizations.of(context)!.fullscreenMode), - subtitle: Text(AppLocalizations.of(context)!.hideUiFullMapView), - value: _isFullscreen, - onChanged: (value) { - setState(() { - _isFullscreen = value; - }); - setModalState(() {}); - _saveSettings(); - // Notify parent about fullscreen change - widget.onFullscreenChanged?.call(value); - }, - ), - ], + const Divider(), + // Map Debug Info toggle + SwitchListTile( + secondary: const Icon(Icons.developer_mode), + title: Text(AppLocalizations.of(context)!.showMapDebugInfo), + subtitle: Text( + AppLocalizations.of(context)!.displayZoomLevelBounds, + ), + value: _showMapDebugInfo, + onChanged: (value) { + setState(() { + _showMapDebugInfo = value; + }); + setModalState(() {}); + _saveSettings(); + }, + ), + const Divider(), + // Fullscreen mode toggle + SwitchListTile( + secondary: const Icon(Icons.fullscreen), + title: Text(AppLocalizations.of(context)!.fullscreenMode), + subtitle: Text( + AppLocalizations.of(context)!.hideUiFullMapView, + ), + value: _isFullscreen, + onChanged: (value) { + setState(() { + _isFullscreen = value; + }); + setModalState(() {}); + _saveSettings(); + // Notify parent about fullscreen change + widget.onFullscreenChanged?.call(value); + }, + ), + ], ), ), ), @@ -886,7 +985,11 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ); } - void _showDetailedCompass(BuildContext context, List contacts, List sarMarkers) { + void _showDetailedCompass( + BuildContext context, + List contacts, + List sarMarkers, + ) { showModalBottomSheet( context: context, isScrollControlled: true, @@ -956,7 +1059,9 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.failedToStartBackgroundTracking), + content: Text( + AppLocalizations.of(context)!.failedToStartBackgroundTracking, + ), duration: Duration(seconds: 3), ), ); @@ -965,7 +1070,12 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } /// Calculate distance between two points in meters - double _calculateDistanceInMeters(double lat1, double lon1, double lat2, double lon2) { + double _calculateDistanceInMeters( + double lat1, + double lon1, + double lat2, + double lon2, + ) { return _markerService.calculateDistance( lat1: lat1, lon1: lon1, @@ -1006,9 +1116,26 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { builder: (context) => SarUpdateSheet( prePopulatedPosition: position, allowLocationUpdate: false, // Don't allow changing to current location - onSend: (emoji, name, position, roomPublicKey, sendToChannel, sendToAllContacts, colorIndex) async { - await _sendSarMessage(emoji, name, position, roomPublicKey, sendToChannel, sendToAllContacts, colorIndex); - }, + onSend: + ( + emoji, + name, + position, + roomPublicKey, + sendToChannel, + sendToAllContacts, + colorIndex, + ) async { + await _sendSarMessage( + emoji, + name, + position, + roomPublicKey, + sendToChannel, + sendToAllContacts, + colorIndex, + ); + }, ), ); } @@ -1050,7 +1177,8 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { try { // New format: S:::,: // Round coordinates to 5 decimal places (~1m accuracy) since most GPS is only that accurate - final sarMessage = 'S:$emoji:$colorIndex:${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}:$name'; + final sarMessage = + 'S:$emoji:$colorIndex:${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}:$name'; if (sendToAllContacts) { // Send to all chat contacts (ContactType.chat) @@ -1145,14 +1273,19 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.sarMarkerSentToContacts(successCount)), + content: Text( + AppLocalizations.of( + context, + )!.sarMarkerSentToContacts(successCount), + ), backgroundColor: Colors.green, duration: const Duration(seconds: 2), ), ); } else if (sendToChannel) { // Create message ID - final messageId = '${DateTime.now().millisecondsSinceEpoch}_channel_sent'; + final messageId = + '${DateTime.now().millisecondsSinceEpoch}_channel_sent'; final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; // Get current device's public key (first 6 bytes) @@ -1283,929 +1416,1111 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { onPointerMove: (PointerMoveEvent event) { // Track pointer movement for mobile drag (onPointerHover doesn't work on mobile) if (_isDraggingPin) { - final latLng = _mapController.camera.screenOffsetToLatLng(event.localPosition); + final latLng = _mapController.camera + .screenOffsetToLatLng(event.localPosition); setState(() { _droppedPinLocation = latLng; }); } }, child: FlutterMap( - mapController: _mapController, - options: MapOptions( - // Use the layer's CRS if it has one (for WMS layers), otherwise default to EPSG:3857 - crs: _currentLayer.crs ?? const Epsg3857(), - // Use saved position if available, otherwise use calculated center - initialCenter: _savedMapCenter ?? center, - initialZoom: _savedMapZoom ?? _defaultZoom, - minZoom: 0, // Allow full zoom out to see world view - maxZoom: _currentLayer.maxZoom, // Respect current layer's maximum - interactionOptions: InteractionOptions( - flags: _isDraggingPin - ? InteractiveFlag.none // Disable map interaction while dragging pin - : InteractiveFlag.all, - ), - onMapEvent: (event) { - // Save map position when user stops panning/zooming - if (event is MapEventMoveEnd || event is MapEventScrollWheelZoom) { - _saveMapPosition(); - } - // Trigger rebuild on rotation change to show/hide reset button - if (event is MapEventRotateEnd || event is MapEventRotateStart) { - setState(() {}); - } - }, - onLongPress: (tapPosition, point) { - // Handle measurement mode - set measurement points only, no SAR marker - if (drawingProvider.drawingMode == DrawingMode.measure) { - if (drawingProvider.measurementPoint1 == null) { - // Set first measurement point - drawingProvider.setMeasurementPoint1(point); - } else if (drawingProvider.measurementPoint2 == null) { - // Set second measurement point - drawingProvider.setMeasurementPoint2(point); - } else { - // Clear and start new measurement - drawingProvider.clearMeasurement(); - drawingProvider.setMeasurementPoint1(point); + mapController: _mapController, + options: MapOptions( + // Use the layer's CRS if it has one (for WMS layers), otherwise default to EPSG:3857 + crs: _currentLayer.crs ?? const Epsg3857(), + // Use saved position if available, otherwise use calculated center + initialCenter: _savedMapCenter ?? center, + initialZoom: _savedMapZoom ?? _defaultZoom, + minZoom: 0, // Allow full zoom out to see world view + maxZoom: _currentLayer + .maxZoom, // Respect current layer's maximum + interactionOptions: InteractionOptions( + flags: _isDraggingPin + ? InteractiveFlag + .none // Disable map interaction while dragging pin + : InteractiveFlag.all, + ), + onMapEvent: (event) { + // Save map position when user stops panning/zooming + if (event is MapEventMoveEnd || + event is MapEventScrollWheelZoom) { + _saveMapPosition(); + } + // Trigger rebuild on rotation change to show/hide reset button + if (event is MapEventRotateEnd || + event is MapEventRotateStart) { + setState(() {}); + } + }, + onLongPress: (tapPosition, point) { + // Handle measurement mode - set measurement points only, no SAR marker + if (drawingProvider.drawingMode == + DrawingMode.measure) { + if (drawingProvider.measurementPoint1 == null) { + // Set first measurement point + drawingProvider.setMeasurementPoint1(point); + } else if (drawingProvider.measurementPoint2 == + null) { + // Set second measurement point + drawingProvider.setMeasurementPoint2(point); + } else { + // Clear and start new measurement + drawingProvider.clearMeasurement(); + drawingProvider.setMeasurementPoint1(point); + } + // Don't drop SAR marker pin in measurement mode + return; } - // Don't drop SAR marker pin in measurement mode - return; - } - // Skip if in other drawing modes - if (drawingProvider.isDrawing) return; + // Skip if in other drawing modes + if (drawingProvider.isDrawing) return; - // Drop a pin at long press location for SAR marker creation - if (_droppedPinLocation == null) { - setState(() { - _droppedPinLocation = point; - }); - } - }, - onPointerDown: (event, point) { - // Check if pointer is near the pin to start dragging - if (_droppedPinLocation != null) { - final distance = _calculateDistanceInMeters( - _droppedPinLocation!.latitude, - _droppedPinLocation!.longitude, - point.latitude, - point.longitude, - ); - // If within ~50m of pin, start dragging - if (distance <= 50) { + // Drop a pin at long press location for SAR marker creation + if (_droppedPinLocation == null) { setState(() { - _isDraggingPin = true; + _droppedPinLocation = point; }); } - } - }, - onPointerHover: (event, point) { - // Update rectangle preview while dragging - if (drawingProvider.drawingMode == DrawingMode.rectangle && - drawingProvider.rectangleStartPoint != null) { - drawingProvider.updateRectangleEndPoint(point); - return; - } - - // Update pin location while dragging - if (_isDraggingPin) { - setState(() { - _droppedPinLocation = point; - }); - } - }, - onPointerUp: (event, point) { - // Stop dragging on pointer release - if (_isDraggingPin) { - setState(() { - _isDraggingPin = false; - }); - } - }, - onTap: (tapPosition, point) { - // Handle drawing mode taps - if (drawingProvider.drawingMode == DrawingMode.line) { - if (drawingProvider.currentLinePoints.isEmpty) { - // Start new line - drawingProvider.startLine(point); - } else { - // Add point to current line - drawingProvider.addLinePoint(point); + }, + onPointerDown: (event, point) { + // Check if pointer is near the pin to start dragging + if (_droppedPinLocation != null) { + final distance = _calculateDistanceInMeters( + _droppedPinLocation!.latitude, + _droppedPinLocation!.longitude, + point.latitude, + point.longitude, + ); + // If within ~50m of pin, start dragging + if (distance <= 50) { + setState(() { + _isDraggingPin = true; + }); + } } - return; - } else if (drawingProvider.drawingMode == DrawingMode.rectangle) { - if (drawingProvider.rectangleStartPoint == null) { - // Start rectangle - drawingProvider.startRectangle(point); - } else { - // Complete rectangle - drawingProvider.completeRectangle(point); + }, + onPointerHover: (event, point) { + // Update rectangle preview while dragging + if (drawingProvider.drawingMode == + DrawingMode.rectangle && + drawingProvider.rectangleStartPoint != null) { + drawingProvider.updateRectangleEndPoint(point); + return; } - return; - } - // Clear dropped pin if tapping elsewhere (not on the pin itself) - if (_droppedPinLocation != null && !_isDraggingPin) { - // Check if tap is far from the pin - final distance = _calculateDistanceInMeters( - _droppedPinLocation!.latitude, - _droppedPinLocation!.longitude, - point.latitude, - point.longitude, - ); - // If tap is more than ~50m away, clear pin - if (distance > 50) { + // Update pin location while dragging + if (_isDraggingPin) { setState(() { - _droppedPinLocation = null; + _droppedPinLocation = point; }); } - } - }, - ), - children: [ - // Render vector or raster tile layer based on layer type - if (_currentLayer.isVector && _vectorTheme != null) - VectorTileLayer( - theme: _vectorTheme!, - tileProviders: TileProviders({ - _currentLayer.sourceName ?? 'default': - _tileCache.getVectorTileProvider(_currentLayer)!, - }), - maximumZoom: _currentLayer.maxZoom, - ) - else if (_currentLayer.isWms && _currentLayer.wmsBaseUrl != null && _currentLayer.crs != null) - // WMS Base Layer (e.g., Slovenian Aerial Imagery) - flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: _currentLayer.wmsBaseUrl!, - layers: _currentLayer.wmsLayers ?? [], - styles: _currentLayer.wmsStyles ?? [], - format: _currentLayer.wmsFormat ?? 'image/jpeg', - transparent: _currentLayer.wmsTransparent ?? false, - crs: _currentLayer.crs!, - ), - // Use cached tile provider for offline support - tileProvider: _tileCache.getTileProviderForWms(_currentLayer), - userAgentPackageName: 'com.meshcore.sar', - maxZoom: _currentLayer.maxZoom, - errorTileCallback: (tile, error, stackTrace) { - debugPrint('🔴 WMS Base Layer tile error at ${tile.coordinates}: $error'); - }, - ) - else if (!_currentLayer.isVector && !_currentLayer.isWms) - flutter_map.TileLayer( - urlTemplate: _currentLayer.urlTemplate, - tileProvider: _tileCache.getTileProvider(_currentLayer), - userAgentPackageName: 'com.meshcore.sar', - maxZoom: _currentLayer.maxZoom, - ), - // WMS Overlays (rendered after base layer, before polylines) - // Note: These overlays only work with EPSG:3794 CRS (Slovenian coordinate system) - // Cadastral parcels overlay - Consumer( - builder: (context, mapProvider, _) { - // Only show if enabled and map is using Slovenian CRS - if (!mapProvider.showCadastralOverlay || _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geowebcache/service/wms?', - layers: const ['pregledovalnik:kn_parcele'], - styles: const ['parcele'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileCache.getTileProviderForWms( - MapLayer( - type: MapLayerType.wmsBase, - name: 'Cadastral Parcels', - urlTemplate: '', - attribution: '© GURS', - maxZoom: 19, - isWms: true, - wmsBaseUrl: 'https://prostor.zgs.gov.si/geowebcache/service/wms?', - wmsLayers: const ['pregledovalnik:kn_parcele'], - wmsFormat: 'image/png', - crs: slovenianCrs, - ), - ), - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint('🔴 Cadastral overlay tile error at ${tile.coordinates}: $error'); - if (stackTrace != null) { - debugPrint(' StackTrace: $stackTrace'); - } - }, - ); }, - ), - // Forest roads overlay - Consumer( - builder: (context, mapProvider, _) { - // Only show if enabled and map is using Slovenian CRS - if (!mapProvider.showForestRoadsOverlay || _currentLayer.crs == null) { - return const SizedBox.shrink(); + onPointerUp: (event, point) { + // Stop dragging on pointer release + if (_isDraggingPin) { + setState(() { + _isDraggingPin = false; + }); } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:gozdne_ceste'], - styles: const ['gozdne_ceste'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileCache.getTileProviderForWms( - MapLayer( - type: MapLayerType.wmsBase, - name: 'Forest Roads', - urlTemplate: '', - attribution: '© GURS', - maxZoom: 19, - isWms: true, - wmsBaseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - wmsLayers: const ['pregledovalnik:gozdne_ceste'], - wmsFormat: 'image/png', - crs: slovenianCrs, - ), - ), - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint('🔴 Forest roads overlay tile error at ${tile.coordinates}: $error'); - if (stackTrace != null) { - debugPrint(' StackTrace: $stackTrace'); - } - }, - ); }, - ), - // Hiking trails overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showHikingTrailsOverlay || _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:KGI_LINIJE_PLANINSKE_POTI_G'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileCache.getTileProviderForWms( - MapLayer( - type: MapLayerType.wmsBase, - name: 'Hiking Trails', - urlTemplate: '', - attribution: '© GURS', - maxZoom: 19, - isWms: true, - wmsBaseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - wmsLayers: const ['pregledovalnik:KGI_LINIJE_PLANINSKE_POTI_G'], - wmsFormat: 'image/png', - crs: slovenianCrs, - ), - ), - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint('🔴 Hiking trails overlay tile error at ${tile.coordinates}: $error'); - }, - ); - }, - ), - // Main roads overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showMainRoadsOverlay || _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:KGI_LINIJE_CESTE_G'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileCache.getTileProviderForWms( - MapLayer( - type: MapLayerType.wmsBase, - name: 'Main Roads', - urlTemplate: '', - attribution: '© GURS', - maxZoom: 19, - isWms: true, - wmsBaseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - wmsLayers: const ['pregledovalnik:KGI_LINIJE_CESTE_G'], - wmsFormat: 'image/png', - crs: slovenianCrs, - ), - ), - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint('🔴 Main roads overlay tile error at ${tile.coordinates}: $error'); - }, - ); - }, - ), - // House numbers overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showHouseNumbersOverlay || _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:NEP_HISNE_STEVILKE'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileCache.getTileProviderForWms( - MapLayer( - type: MapLayerType.wmsBase, - name: 'House Numbers', - urlTemplate: '', - attribution: '© GURS', - maxZoom: 19, - isWms: true, - wmsBaseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - wmsLayers: const ['pregledovalnik:NEP_HISNE_STEVILKE'], - wmsFormat: 'image/png', - crs: slovenianCrs, - ), - ), - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint('🔴 House numbers overlay tile error at ${tile.coordinates}: $error'); - }, - ); - }, - ), - // Fire hazard zones overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showFireHazardZonesOverlay || _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:pozarna_ogrozenost'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileCache.getTileProviderForWms( - MapLayer( - type: MapLayerType.wmsBase, - name: 'Fire Hazard Zones', - urlTemplate: '', - attribution: '© GURS', - maxZoom: 19, - isWms: true, - wmsBaseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - wmsLayers: const ['pregledovalnik:pozarna_ogrozenost'], - wmsFormat: 'image/png', - crs: slovenianCrs, - ), - ), - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint('🔴 Fire hazard zones overlay tile error at ${tile.coordinates}: $error'); - }, - ); - }, - ), - // Historical fires overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showHistoricalFiresOverlay || _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:gozdni_pozari'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileCache.getTileProviderForWms( - MapLayer( - type: MapLayerType.wmsBase, - name: 'Historical Fires', - urlTemplate: '', - attribution: '© GURS', - maxZoom: 19, - isWms: true, - wmsBaseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - wmsLayers: const ['pregledovalnik:gozdni_pozari'], - wmsFormat: 'image/png', - crs: slovenianCrs, - ), - ), - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint('🔴 Historical fires overlay tile error at ${tile.coordinates}: $error'); - }, - ); - }, - ), - // Firebreaks overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showFirebreaksOverlay || _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:protipozarne_preseke'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileCache.getTileProviderForWms( - MapLayer( - type: MapLayerType.wmsBase, - name: 'Firebreaks', - urlTemplate: '', - attribution: '© GURS', - maxZoom: 19, - isWms: true, - wmsBaseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - wmsLayers: const ['pregledovalnik:protipozarne_preseke'], - wmsFormat: 'image/png', - crs: slovenianCrs, - ), - ), - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint('🔴 Firebreaks overlay tile error at ${tile.coordinates}: $error'); - }, - ); - }, - ), - // Kras fire zones overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showKrasFireZonesOverlay || _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:pozarisce_kras'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileCache.getTileProviderForWms( - MapLayer( - type: MapLayerType.wmsBase, - name: 'Kras Fire Zones', - urlTemplate: '', - attribution: '© GURS', - maxZoom: 19, - isWms: true, - wmsBaseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - wmsLayers: const ['pregledovalnik:pozarisce_kras'], - wmsFormat: 'image/png', - crs: slovenianCrs, - ), - ), - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint('🔴 Kras fire zones overlay tile error at ${tile.coordinates}: $error'); - }, - ); - }, - ), - // Place names overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showPlaceNamesOverlay || _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:zemljepisna_imena'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileCache.getTileProviderForWms( - MapLayer( - type: MapLayerType.wmsBase, - name: 'Place Names', - urlTemplate: '', - attribution: '© GURS', - maxZoom: 19, - isWms: true, - wmsBaseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - wmsLayers: const ['pregledovalnik:zemljepisna_imena'], - wmsFormat: 'image/png', - crs: slovenianCrs, - ), - ), - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint('🔴 Place names overlay tile error at ${tile.coordinates}: $error'); - }, - ); - }, - ), - // Municipality borders overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showMunicipalityBordersOverlay || _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:NEP_RPE_OBCINE'], - styles: const ['obcine'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileCache.getTileProviderForWms( - MapLayer( - type: MapLayerType.wmsBase, - name: 'Municipality Borders', - urlTemplate: '', - attribution: '© GURS', - maxZoom: 19, - isWms: true, - wmsBaseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - wmsLayers: const ['pregledovalnik:NEP_RPE_OBCINE'], - wmsFormat: 'image/png', - crs: slovenianCrs, - ), - ), - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint('🔴 Municipality borders overlay tile error at ${tile.coordinates}: $error'); - }, - ); - }, - ), - // Imported trail layer (rendered at bottom for reference) - Consumer( - builder: (context, mapProvider, _) { - if (mapProvider.importedTrail == null || - mapProvider.importedTrail!.points.length < 2) { - return const SizedBox.shrink(); + onTap: (tapPosition, point) { + // Handle drawing mode taps + if (drawingProvider.drawingMode == DrawingMode.line) { + if (drawingProvider.currentLinePoints.isEmpty) { + // Start new line + drawingProvider.startLine(point); + } else { + // Add point to current line + drawingProvider.addLinePoint(point); + } + return; + } else if (drawingProvider.drawingMode == + DrawingMode.rectangle) { + if (drawingProvider.rectangleStartPoint == null) { + // Start rectangle + drawingProvider.startRectangle(point); + } else { + // Complete rectangle + drawingProvider.completeRectangle(point); + } + return; } - return PolylineLayer( - polylines: [ - Polyline( - points: mapProvider.importedTrail!.latLngPoints, - color: Colors.green.withValues(alpha: 0.7), - strokeWidth: 3.0, - borderColor: Colors.white.withValues(alpha: 0.4), - borderStrokeWidth: 1.0, - // DOTTED pattern to distinguish from other trails - pattern: StrokePattern.dotted(spacingFactor: 2), - ), - ], - ); + // Clear dropped pin if tapping elsewhere (not on the pin itself) + if (_droppedPinLocation != null && !_isDraggingPin) { + // Check if tap is far from the pin + final distance = _calculateDistanceInMeters( + _droppedPinLocation!.latitude, + _droppedPinLocation!.longitude, + point.latitude, + point.longitude, + ); + // If tap is more than ~50m away, clear pin + if (distance > 50) { + setState(() { + _droppedPinLocation = null; + }); + } + } }, ), - // Contact trail polylines (rendered before user trail and markers) - Consumer( - builder: (context, mapProvider, _) { - // Determine which contacts to show trails for - final contactsToShow = mapProvider.showAllContactTrails - ? contactsWithLocation // Show all when master toggle is ON - : contactsWithLocation.where((contact) => - mapProvider.isContactPathVisible(contact.publicKeyHex)); // Individual toggles - - return PolylineLayer( - polylines: contactsToShow - .where((contact) => contact.advertHistory.length >= 2) - .map((contact) { - // Use TrailColorService for consistent, emoji-based colors - final color = TrailColorService.getTrailColor(contact); - - return Polyline( - points: contact.advertHistory - .map((advert) => advert.location) - .toList(), - color: color.withValues(alpha: 0.95), // More opaque for better visibility - strokeWidth: 4.5, // Thicker for better visibility on all map backgrounds - borderColor: Colors.white.withValues(alpha: 0.6), // Stronger border contrast - borderStrokeWidth: 2.0, // Wider border - // DASHED pattern to distinguish from solid user trail - pattern: StrokePattern.dashed(segments: [8, 4]), - ); - }) - .toList(), - ); - }, - ), - // Location trail layer (rendered after paths, before drawings) - const LocationTrailLayer(), - // Measurement line layer (rendered before drawings) - if (drawingProvider.measurementPoint1 != null && drawingProvider.measurementPoint2 != null) - PolylineLayer( - polylines: [ - Polyline( - points: [ - drawingProvider.measurementPoint1!, - drawingProvider.measurementPoint2!, - ], - color: Colors.yellow.withValues(alpha: 0.8), - strokeWidth: 3.0, - borderColor: Colors.black.withValues(alpha: 0.5), - borderStrokeWidth: 1.0, - pattern: StrokePattern.dashed(segments: [10, 5]), + children: [ + // Render vector or raster tile layer based on layer type + if (_currentLayer.isVector && _vectorTheme != null) + VectorTileLayer( + theme: _vectorTheme!, + tileProviders: TileProviders({ + _currentLayer.sourceName ?? 'default': _tileCache + .getVectorTileProvider(_currentLayer)!, + }), + maximumZoom: _currentLayer.maxZoom, + ) + else if (_currentLayer.isWms && + _currentLayer.wmsBaseUrl != null && + _currentLayer.crs != null) + // WMS Base Layer (e.g., Slovenian Aerial Imagery) + flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: _currentLayer.wmsBaseUrl!, + layers: _currentLayer.wmsLayers ?? [], + styles: _currentLayer.wmsStyles ?? [], + format: _currentLayer.wmsFormat ?? 'image/jpeg', + transparent: + _currentLayer.wmsTransparent ?? false, + crs: _currentLayer.crs!, ), - ], - ), - // Drawing layer (rendered after paths, before markers) - DrawingLayer( - drawings: drawingProvider.drawings, - previewDrawing: drawingProvider.getPreviewDrawing(), - isSimpleMode: isSimpleMode, - ), - MarkerLayer( - markers: [ - // Contact markers - ..._markerService.generateContactMarkers( - contacts: contactsWithLocation, - context: context, - mapRotation: _getMapRotation(), - userPosition: _locationService.currentPosition, - onTap: (contact) { - _showDetailedCompassWithContact( - context, - contactsProvider.contactsWithLocation, - messagesProvider.sarMarkers, - contact, + // Use cached tile provider for offline support + tileProvider: _tileCache.getTileProviderForWms( + _currentLayer, + ), + userAgentPackageName: 'com.meshcore.sar', + maxZoom: _currentLayer.maxZoom, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 WMS Base Layer tile error at ${tile.coordinates}: $error', ); }, + ) + else if (!_currentLayer.isVector && + !_currentLayer.isWms) + flutter_map.TileLayer( + urlTemplate: _currentLayer.urlTemplate, + tileProvider: _tileCache.getTileProvider( + _currentLayer, + ), + userAgentPackageName: 'com.meshcore.sar', + maxZoom: _currentLayer.maxZoom, ), - // SAR markers - ..._markerService.generateSarMarkers( - sarMarkers: sarMarkers, - context: context, - mapRotation: _getMapRotation(), - onTap: (marker) { - // Navigate to the corresponding message in Messages tab - messagesProvider.navigateToMessage(marker.id); - widget.onNavigateToMessages?.call(); - }, + // WMS Overlays (rendered after base layer, before polylines) + // Note: These overlays only work with EPSG:3794 CRS (Slovenian coordinate system) + // Cadastral parcels overlay + Consumer( + builder: (context, mapProvider, _) { + // Only show if enabled and map is using Slovenian CRS + if (!mapProvider.showCadastralOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geowebcache/service/wms?', + layers: const ['pregledovalnik:kn_parcele'], + styles: const ['parcele'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileCache.getTileProviderForWms( + MapLayer( + type: MapLayerType.wmsBase, + name: 'Cadastral Parcels', + urlTemplate: '', + attribution: '© GURS', + maxZoom: 19, + isWms: true, + wmsBaseUrl: + 'https://prostor.zgs.gov.si/geowebcache/service/wms?', + wmsLayers: const [ + 'pregledovalnik:kn_parcele', + ], + wmsFormat: 'image/png', + crs: slovenianCrs, + ), + ), + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Cadastral overlay tile error at ${tile.coordinates}: $error', + ); + if (stackTrace != null) { + debugPrint(' StackTrace: $stackTrace'); + } + }, + ); + }, + ), + // Forest roads overlay + Consumer( + builder: (context, mapProvider, _) { + // Only show if enabled and map is using Slovenian CRS + if (!mapProvider.showForestRoadsOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:gozdne_ceste'], + styles: const ['gozdne_ceste'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileCache.getTileProviderForWms( + MapLayer( + type: MapLayerType.wmsBase, + name: 'Forest Roads', + urlTemplate: '', + attribution: '© GURS', + maxZoom: 19, + isWms: true, + wmsBaseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + wmsLayers: const [ + 'pregledovalnik:gozdne_ceste', + ], + wmsFormat: 'image/png', + crs: slovenianCrs, + ), + ), + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Forest roads overlay tile error at ${tile.coordinates}: $error', + ); + if (stackTrace != null) { + debugPrint(' StackTrace: $stackTrace'); + } + }, + ); + }, + ), + // Hiking trails overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showHikingTrailsOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const [ + 'pregledovalnik:KGI_LINIJE_PLANINSKE_POTI_G', + ], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileCache.getTileProviderForWms( + MapLayer( + type: MapLayerType.wmsBase, + name: 'Hiking Trails', + urlTemplate: '', + attribution: '© GURS', + maxZoom: 19, + isWms: true, + wmsBaseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + wmsLayers: const [ + 'pregledovalnik:KGI_LINIJE_PLANINSKE_POTI_G', + ], + wmsFormat: 'image/png', + crs: slovenianCrs, + ), + ), + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Hiking trails overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Main roads overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showMainRoadsOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const [ + 'pregledovalnik:KGI_LINIJE_CESTE_G', + ], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileCache.getTileProviderForWms( + MapLayer( + type: MapLayerType.wmsBase, + name: 'Main Roads', + urlTemplate: '', + attribution: '© GURS', + maxZoom: 19, + isWms: true, + wmsBaseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + wmsLayers: const [ + 'pregledovalnik:KGI_LINIJE_CESTE_G', + ], + wmsFormat: 'image/png', + crs: slovenianCrs, + ), + ), + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Main roads overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // House numbers overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showHouseNumbersOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const [ + 'pregledovalnik:NEP_HISNE_STEVILKE', + ], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileCache.getTileProviderForWms( + MapLayer( + type: MapLayerType.wmsBase, + name: 'House Numbers', + urlTemplate: '', + attribution: '© GURS', + maxZoom: 19, + isWms: true, + wmsBaseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + wmsLayers: const [ + 'pregledovalnik:NEP_HISNE_STEVILKE', + ], + wmsFormat: 'image/png', + crs: slovenianCrs, + ), + ), + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 House numbers overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Fire hazard zones overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showFireHazardZonesOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const [ + 'pregledovalnik:pozarna_ogrozenost', + ], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileCache.getTileProviderForWms( + MapLayer( + type: MapLayerType.wmsBase, + name: 'Fire Hazard Zones', + urlTemplate: '', + attribution: '© GURS', + maxZoom: 19, + isWms: true, + wmsBaseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + wmsLayers: const [ + 'pregledovalnik:pozarna_ogrozenost', + ], + wmsFormat: 'image/png', + crs: slovenianCrs, + ), + ), + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Fire hazard zones overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Historical fires overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showHistoricalFiresOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:gozdni_pozari'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileCache.getTileProviderForWms( + MapLayer( + type: MapLayerType.wmsBase, + name: 'Historical Fires', + urlTemplate: '', + attribution: '© GURS', + maxZoom: 19, + isWms: true, + wmsBaseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + wmsLayers: const [ + 'pregledovalnik:gozdni_pozari', + ], + wmsFormat: 'image/png', + crs: slovenianCrs, + ), + ), + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Historical fires overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Firebreaks overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showFirebreaksOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const [ + 'pregledovalnik:protipozarne_preseke', + ], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileCache.getTileProviderForWms( + MapLayer( + type: MapLayerType.wmsBase, + name: 'Firebreaks', + urlTemplate: '', + attribution: '© GURS', + maxZoom: 19, + isWms: true, + wmsBaseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + wmsLayers: const [ + 'pregledovalnik:protipozarne_preseke', + ], + wmsFormat: 'image/png', + crs: slovenianCrs, + ), + ), + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Firebreaks overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Kras fire zones overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showKrasFireZonesOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:pozarisce_kras'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileCache.getTileProviderForWms( + MapLayer( + type: MapLayerType.wmsBase, + name: 'Kras Fire Zones', + urlTemplate: '', + attribution: '© GURS', + maxZoom: 19, + isWms: true, + wmsBaseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + wmsLayers: const [ + 'pregledovalnik:pozarisce_kras', + ], + wmsFormat: 'image/png', + crs: slovenianCrs, + ), + ), + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Kras fire zones overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Place names overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showPlaceNamesOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const [ + 'pregledovalnik:zemljepisna_imena', + ], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileCache.getTileProviderForWms( + MapLayer( + type: MapLayerType.wmsBase, + name: 'Place Names', + urlTemplate: '', + attribution: '© GURS', + maxZoom: 19, + isWms: true, + wmsBaseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + wmsLayers: const [ + 'pregledovalnik:zemljepisna_imena', + ], + wmsFormat: 'image/png', + crs: slovenianCrs, + ), + ), + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Place names overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Municipality borders overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showMunicipalityBordersOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:NEP_RPE_OBCINE'], + styles: const ['obcine'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileCache.getTileProviderForWms( + MapLayer( + type: MapLayerType.wmsBase, + name: 'Municipality Borders', + urlTemplate: '', + attribution: '© GURS', + maxZoom: 19, + isWms: true, + wmsBaseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + wmsLayers: const [ + 'pregledovalnik:NEP_RPE_OBCINE', + ], + wmsFormat: 'image/png', + crs: slovenianCrs, + ), + ), + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + '🔴 Municipality borders overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Imported trail layer (rendered at bottom for reference) + Consumer( + builder: (context, mapProvider, _) { + if (mapProvider.importedTrail == null || + mapProvider.importedTrail!.points.length < 2) { + return const SizedBox.shrink(); + } + + return PolylineLayer( + polylines: [ + Polyline( + points: + mapProvider.importedTrail!.latLngPoints, + color: Colors.green.withValues(alpha: 0.7), + strokeWidth: 3.0, + borderColor: Colors.white.withValues( + alpha: 0.4, + ), + borderStrokeWidth: 1.0, + // DOTTED pattern to distinguish from other trails + pattern: StrokePattern.dotted( + spacingFactor: 2, + ), + ), + ], + ); + }, + ), + // Contact trail polylines (rendered before user trail and markers) + Consumer( + builder: (context, mapProvider, _) { + // Determine which contacts to show trails for + final contactsToShow = + mapProvider.showAllContactTrails + ? contactsWithLocation // Show all when master toggle is ON + : contactsWithLocation.where( + (contact) => + mapProvider.isContactPathVisible( + contact.publicKeyHex, + ), + ); // Individual toggles + + return PolylineLayer( + polylines: contactsToShow + .where( + (contact) => + contact.advertHistory.length >= 2, + ) + .map((contact) { + // Use TrailColorService for consistent, emoji-based colors + final color = + TrailColorService.getTrailColor( + contact, + ); + + return Polyline( + points: contact.advertHistory + .map((advert) => advert.location) + .toList(), + color: color.withValues( + alpha: 0.95, + ), // More opaque for better visibility + strokeWidth: + 4.5, // Thicker for better visibility on all map backgrounds + borderColor: Colors.white.withValues( + alpha: 0.6, + ), // Stronger border contrast + borderStrokeWidth: 2.0, // Wider border + // DASHED pattern to distinguish from solid user trail + pattern: StrokePattern.dashed( + segments: [8, 4], + ), + ); + }) + .toList(), + ); + }, + ), + // Location trail layer (rendered after paths, before drawings) + const LocationTrailLayer(), + // Measurement line layer (rendered before drawings) + if (drawingProvider.measurementPoint1 != null && + drawingProvider.measurementPoint2 != null) + PolylineLayer( + polylines: [ + Polyline( + points: [ + drawingProvider.measurementPoint1!, + drawingProvider.measurementPoint2!, + ], + color: Colors.yellow.withValues(alpha: 0.8), + strokeWidth: 3.0, + borderColor: Colors.black.withValues( + alpha: 0.5, + ), + borderStrokeWidth: 1.0, + pattern: StrokePattern.dashed( + segments: [10, 5], + ), + ), + ], ), - // User location marker with directional pointer - if (_markerService.generateUserLocationMarker( - position: _locationService.currentPosition, - heading: _currentHeading, - context: context, - ) != null) - _markerService.generateUserLocationMarker( - position: _locationService.currentPosition, - heading: _currentHeading, + // Drawing layer (rendered after paths, before markers) + DrawingLayer( + drawings: drawingProvider.drawings, + previewDrawing: drawingProvider.getPreviewDrawing(), + isSimpleMode: isSimpleMode, + ), + MarkerLayer( + markers: [ + // Contact markers + ..._markerService.generateContactMarkers( + contacts: contactsWithLocation, context: context, - )!, - // Measurement point 1 marker - if (drawingProvider.measurementPoint1 != null) - Marker( - point: drawingProvider.measurementPoint1!, - width: 60, - height: 80, - rotate: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.yellow.shade700, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'Start', - style: TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 2), - Container( - decoration: BoxDecoration( - color: Colors.yellow, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - padding: const EdgeInsets.all(6), - child: const Icon( - Icons.location_on, - color: Colors.white, - size: 18, - ), - ), - ], - ), + mapRotation: _getMapRotation(), + userPosition: _locationService.currentPosition, + onTap: (contact) { + _showDetailedCompassWithContact( + context, + contactsProvider.contactsWithLocation, + messagesProvider.sarMarkers, + contact, + ); + }, ), - // Measurement point 2 marker - if (drawingProvider.measurementPoint2 != null) - Marker( - point: drawingProvider.measurementPoint2!, - width: 60, - height: 80, - rotate: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.yellow.shade700, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'End', - style: TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 2), - Container( - decoration: BoxDecoration( - color: Colors.yellow, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - padding: const EdgeInsets.all(6), - child: const Icon( - Icons.location_on, - color: Colors.white, - size: 18, - ), - ), - ], - ), + // SAR markers + ..._markerService.generateSarMarkers( + sarMarkers: sarMarkers, + context: context, + mapRotation: _getMapRotation(), + onTap: (marker) { + // Navigate to the corresponding message in Messages tab + messagesProvider.navigateToMessage(marker.id); + widget.onNavigateToMessages?.call(); + }, ), - // Dropped pin marker with label - if (_droppedPinLocation != null) - Marker( - key: _pinMarkerKey, - point: _droppedPinLocation!, - width: 200, - height: 100, - rotate: false, - child: GestureDetector( - onTap: () { - // Only open dialog if not dragging - if (!_isDraggingPin) { - _showSarDialogWithLocation(_droppedPinLocation!); - // Clear the pin after opening dialog - setState(() { - _droppedPinLocation = null; - }); - } - }, - child: Opacity( - // Make pin slightly transparent while dragging - opacity: _isDraggingPin ? 0.7 : 1.0, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Label - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: _isDraggingPin ? Colors.orange : Colors.red, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Text( - _isDraggingPin ? AppLocalizations.of(context)!.dragToPosition : AppLocalizations.of(context)!.createSarMarker, - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.bold, - ), + // User location marker with directional pointer + if (_markerService.generateUserLocationMarker( + position: _locationService.currentPosition, + heading: _currentHeading, + context: context, + ) != + null) + _markerService.generateUserLocationMarker( + position: _locationService.currentPosition, + heading: _currentHeading, + context: context, + )!, + // Measurement point 1 marker + if (drawingProvider.measurementPoint1 != null) + Marker( + point: drawingProvider.measurementPoint1!, + width: 60, + height: 80, + rotate: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.yellow.shade700, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'Start', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, ), ), - const SizedBox(height: 4), - // Pin icon pointing down - Icon( - Icons.location_pin, - color: _isDraggingPin ? Colors.orange : Colors.red, - size: 48, - shadows: const [ - Shadow( + ), + const SizedBox(height: 2), + Container( + decoration: BoxDecoration( + color: Colors.yellow, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 2, + ), + boxShadow: const [ + BoxShadow( color: Colors.black26, blurRadius: 4, offset: Offset(0, 2), ), ], ), - ], + padding: const EdgeInsets.all(6), + child: const Icon( + Icons.location_on, + color: Colors.white, + size: 18, + ), + ), + ], + ), + ), + // Measurement point 2 marker + if (drawingProvider.measurementPoint2 != null) + Marker( + point: drawingProvider.measurementPoint2!, + width: 60, + height: 80, + rotate: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.yellow.shade700, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'End', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 2), + Container( + decoration: BoxDecoration( + color: Colors.yellow, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 2, + ), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(6), + child: const Icon( + Icons.location_on, + color: Colors.white, + size: 18, + ), + ), + ], + ), + ), + // Dropped pin marker with label + if (_droppedPinLocation != null) + Marker( + key: _pinMarkerKey, + point: _droppedPinLocation!, + width: 200, + height: 100, + rotate: false, + child: GestureDetector( + onTap: () { + // Only open dialog if not dragging + if (!_isDraggingPin) { + _showSarDialogWithLocation( + _droppedPinLocation!, + ); + // Clear the pin after opening dialog + setState(() { + _droppedPinLocation = null; + }); + } + }, + child: Opacity( + // Make pin slightly transparent while dragging + opacity: _isDraggingPin ? 0.7 : 1.0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Label + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: _isDraggingPin + ? Colors.orange + : Colors.red, + borderRadius: BorderRadius.circular( + 8, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues( + alpha: 0.3, + ), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Text( + _isDraggingPin + ? AppLocalizations.of( + context, + )!.dragToPosition + : AppLocalizations.of( + context, + )!.createSarMarker, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 4), + // Pin icon pointing down + Icon( + Icons.location_pin, + color: _isDraggingPin + ? Colors.orange + : Colors.red, + size: 48, + shadows: const [ + Shadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + ], + ), ), ), ), - ), - ], - ), - // Drawing markers layer (delete buttons on drawings, only shown when in drawing mode) - DrawingMarkersLayer( - drawings: drawingProvider.drawings, - showDeleteButtons: drawingProvider.isDrawing, - isSimpleMode: isSimpleMode, - onDeleteDrawing: (drawingId) { - drawingProvider.removeDrawing(drawingId); - }, - onTapDrawing: (drawing) { - // Navigate to the corresponding message in Messages tab - if (drawing.messageId != null) { - messagesProvider.navigateToMessage(drawing.messageId!); - widget.onNavigateToMessages?.call(); - } - }, - ), - // Download area selection polygon (rendered on top when in selection mode) - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.isSelectingDownloadArea || mapProvider.downloadAreaBounds == null) { - return const SizedBox.shrink(); - } - final bounds = mapProvider.downloadAreaBounds!; + ], + ), + // Drawing markers layer (delete buttons on drawings, only shown when in drawing mode) + DrawingMarkersLayer( + drawings: drawingProvider.drawings, + showDeleteButtons: drawingProvider.isDrawing, + isSimpleMode: isSimpleMode, + onDeleteDrawing: (drawingId) { + drawingProvider.removeDrawing(drawingId); + }, + onTapDrawing: (drawing) { + // Navigate to the corresponding message in Messages tab + if (drawing.messageId != null) { + messagesProvider.navigateToMessage( + drawing.messageId!, + ); + widget.onNavigateToMessages?.call(); + } + }, + ), + // Download area selection polygon (rendered on top when in selection mode) + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.isSelectingDownloadArea || + mapProvider.downloadAreaBounds == null) { + return const SizedBox.shrink(); + } + final bounds = mapProvider.downloadAreaBounds!; - // Add padding to the bounds so the rectangle is visible within the screen - // Calculate 5% padding on each side - final latPadding = (bounds.north - bounds.south) * 0.05; - final lonPadding = (bounds.east - bounds.west) * 0.05; + // Add padding to the bounds so the rectangle is visible within the screen + // Calculate 5% padding on each side + final latPadding = + (bounds.north - bounds.south) * 0.05; + final lonPadding = + (bounds.east - bounds.west) * 0.05; - return PolygonLayer( - polygons: [ - Polygon( - points: [ - LatLng(bounds.north - latPadding, bounds.west + lonPadding), // Top-left - LatLng(bounds.north - latPadding, bounds.east - lonPadding), // Top-right - LatLng(bounds.south + latPadding, bounds.east - lonPadding), // Bottom-right - LatLng(bounds.south + latPadding, bounds.west + lonPadding), // Bottom-left - ], - color: Colors.blue.withValues(alpha: 0.2), - borderColor: Colors.blue, - borderStrokeWidth: 3.0, - ), - ], - ); - }, - ), - ], - ), - ) + return PolygonLayer( + polygons: [ + Polygon( + points: [ + LatLng( + bounds.north - latPadding, + bounds.west + lonPadding, + ), // Top-left + LatLng( + bounds.north - latPadding, + bounds.east - lonPadding, + ), // Top-right + LatLng( + bounds.south + latPadding, + bounds.east - lonPadding, + ), // Bottom-right + LatLng( + bounds.south + latPadding, + bounds.west + lonPadding, + ), // Bottom-left + ], + color: Colors.blue.withValues(alpha: 0.2), + borderColor: Colors.blue, + borderStrokeWidth: 3.0, + ), + ], + ); + }, + ), + ], + ), + ) : Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -2222,7 +2537,8 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { // Download area overlay (shown when in download area selection mode) Consumer( builder: (context, mapProvider, _) { - if (!mapProvider.isSelectingDownloadArea || mapProvider.downloadAreaBounds == null) { + if (!mapProvider.isSelectingDownloadArea || + mapProvider.downloadAreaBounds == null) { return const SizedBox.shrink(); } return DownloadAreaOverlay( @@ -2280,7 +2596,9 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { // Notify parent about fullscreen change widget.onFullscreenChanged?.call(false); }, - backgroundColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9), + backgroundColor: Theme.of( + context, + ).colorScheme.surface.withValues(alpha: 0.9), child: const Icon(Icons.fullscreen_exit), ), ), @@ -2294,10 +2612,11 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { child: Consumer( builder: (context, messagesProvider, _) { // Get last 20 non-system and non-drawing messages, sorted chronologically - final recentMessages = messagesProvider.messages - .where((m) => !m.isSystemMessage && !m.isDrawing) - .toList() - ..sort((a, b) => a.sentAt.compareTo(b.sentAt)); + final recentMessages = + messagesProvider.messages + .where((m) => !m.isSystemMessage && !m.isDrawing) + .toList() + ..sort((a, b) => a.sentAt.compareTo(b.sentAt)); final displayMessages = recentMessages.length > 20 ? recentMessages.sublist(recentMessages.length - 20) : recentMessages; @@ -2336,7 +2655,10 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { top: 16, left: 16, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), decoration: BoxDecoration( color: Colors.yellow.shade700.withValues(alpha: 0.95), borderRadius: BorderRadius.circular(12), @@ -2377,7 +2699,11 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - AppLocalizations.of(context)!.distanceLabel(_formatDistance(drawingProvider.measuredDistance!)), + AppLocalizations.of(context)!.distanceLabel( + _formatDistance( + drawingProvider.measuredDistance!, + ), + ), style: const TextStyle( color: Colors.white, fontSize: 16, @@ -2386,7 +2712,9 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ), const SizedBox(height: 4), Text( - AppLocalizations.of(context)!.longPressToStartNewMeasurement, + AppLocalizations.of( + context, + )!.longPressToStartNewMeasurement, style: const TextStyle( color: Colors.white70, fontSize: 10, @@ -2404,7 +2732,9 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ) else Text( - AppLocalizations.of(context)!.longPressToStartMeasurement, + AppLocalizations.of( + context, + )!.longPressToStartMeasurement, style: const TextStyle( color: Colors.white, fontSize: 12, @@ -2435,36 +2765,43 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { // Current Location - always center to GPS FloatingActionButton.small( heroTag: 'center_map', - onPressed: !_isMapReady ? null : () async { - // Get current zoom to retain it - final currentZoom = _mapController.camera.zoom; + onPressed: !_isMapReady + ? null + : () async { + // Get current zoom to retain it + final currentZoom = _mapController.camera.zoom; - // Force update GPS location and jump to it - final position = await _locationService.getCurrentPosition(); - if (position != null && mounted) { - setState(() { - // Position updated in service - }); - _mapController.move( - LatLng(position.latitude, position.longitude), - currentZoom, - ); - } else { - // Fallback to cached position or default center - final currentPosition = _locationService.currentPosition; - if (currentPosition != null) { - _mapController.move( - LatLng( - currentPosition.latitude, - currentPosition.longitude, - ), - currentZoom, - ); - } else { - _mapController.move(center, currentZoom); - } - } - }, + // Force update GPS location and jump to it + final position = await _locationService + .getCurrentPosition(); + if (position != null && mounted) { + setState(() { + // Position updated in service + }); + _mapController.move( + LatLng( + position.latitude, + position.longitude, + ), + currentZoom, + ); + } else { + // Fallback to cached position or default center + final currentPosition = + _locationService.currentPosition; + if (currentPosition != null) { + _mapController.move( + LatLng( + currentPosition.latitude, + currentPosition.longitude, + ), + currentZoom, + ); + } else { + _mapController.move(center, currentZoom); + } + } + }, child: const Icon(Icons.my_location), ), const SizedBox(height: 8), @@ -2474,31 +2811,40 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { backgroundColor: _rotateMarkerWithHeading ? Theme.of(context).colorScheme.primary : null, - onPressed: !_isMapReady ? null : () { - setState(() { - _rotateMarkerWithHeading = !_rotateMarkerWithHeading; - // Reset map rotation when disabling - if (_isMapReady) { - try { - final camera = _mapController.camera; - if (!_rotateMarkerWithHeading) { - // Disable: reset to north - _mapController.moveAndRotate(camera.center, camera.zoom, 0); - } else if (_currentHeading != null) { - // Enable: apply current heading rotation - _mapController.moveAndRotate( - camera.center, - camera.zoom, - -_currentHeading!, - ); - } - } catch (e) { - debugPrint('Failed to toggle rotation lock: $e'); - } - } - }); - _saveSettings(); - }, + onPressed: !_isMapReady + ? null + : () { + setState(() { + _rotateMarkerWithHeading = + !_rotateMarkerWithHeading; + // Reset map rotation when disabling + if (_isMapReady) { + try { + final camera = _mapController.camera; + if (!_rotateMarkerWithHeading) { + // Disable: reset to north + _mapController.moveAndRotate( + camera.center, + camera.zoom, + 0, + ); + } else if (_currentHeading != null) { + // Enable: apply current heading rotation + _mapController.moveAndRotate( + camera.center, + camera.zoom, + -_currentHeading!, + ); + } + } catch (e) { + debugPrint( + 'Failed to toggle rotation lock: $e', + ); + } + } + }); + _saveSettings(); + }, child: Icon( Icons.screen_lock_rotation, color: _rotateMarkerWithHeading ? Colors.white : null, @@ -2517,21 +2863,28 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { children: [ FloatingActionButton.small( heroTag: 'ruler_tool', - backgroundColor: drawingProvider.drawingMode == DrawingMode.measure + backgroundColor: + drawingProvider.drawingMode == + DrawingMode.measure ? Theme.of(context).colorScheme.primary : null, onPressed: () { - if (drawingProvider.drawingMode == DrawingMode.measure) { + if (drawingProvider.drawingMode == + DrawingMode.measure) { // Exit measurement mode drawingProvider.exitDrawingMode(); } else { // Enter measurement mode - drawingProvider.setDrawingMode(DrawingMode.measure); + drawingProvider.setDrawingMode( + DrawingMode.measure, + ); } }, child: Icon( Icons.straighten, - color: drawingProvider.drawingMode == DrawingMode.measure + color: + drawingProvider.drawingMode == + DrawingMode.measure ? Colors.white : null, ), @@ -2546,33 +2899,37 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { if (!drawingProvider.isDrawing) ...[ // Trail controls button const TrailControls(), - const SizedBox(height: 8), - FloatingActionButton.small( - heroTag: 'layer_selector', - onPressed: () => _showLayerSelector(context), - child: const Icon(Icons.layers), - ), - const SizedBox(height: 8), - // In simple mode: show fullscreen button directly - // In normal mode: show options menu (which includes fullscreen) - if (context.watch().isSimpleMode) + const SizedBox(height: 8), FloatingActionButton.small( - heroTag: 'fullscreen_toggle', - onPressed: () { - setState(() { - _isFullscreen = !_isFullscreen; - }); - _saveSettings(); - widget.onFullscreenChanged?.call(_isFullscreen); - }, - child: Icon(_isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen), - ) - else - FloatingActionButton.small( - heroTag: 'options_menu', - onPressed: () => _showOptionsMenu(context), - child: const Icon(Icons.more_vert), + heroTag: 'layer_selector', + onPressed: () => _showLayerSelector(context), + child: const Icon(Icons.layers), ), + const SizedBox(height: 8), + // In simple mode: show fullscreen button directly + // In normal mode: show options menu (which includes fullscreen) + if (context.watch().isSimpleMode) + FloatingActionButton.small( + heroTag: 'fullscreen_toggle', + onPressed: () { + setState(() { + _isFullscreen = !_isFullscreen; + }); + _saveSettings(); + widget.onFullscreenChanged?.call(_isFullscreen); + }, + child: Icon( + _isFullscreen + ? Icons.fullscreen_exit + : Icons.fullscreen, + ), + ) + else + FloatingActionButton.small( + heroTag: 'options_menu', + onPressed: () => _showOptionsMenu(context), + child: const Icon(Icons.more_vert), + ), ], ], ), @@ -2590,4 +2947,3 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ); } } - diff --git a/lib/screens/spectrum_scan_screen.dart b/lib/screens/spectrum_scan_screen.dart new file mode 100644 index 0000000..7f6b4a7 --- /dev/null +++ b/lib/screens/spectrum_scan_screen.dart @@ -0,0 +1,601 @@ +import 'package:flutter/material.dart'; +import 'package:meshcore_client/meshcore_client.dart'; +import 'package:provider/provider.dart'; +import '../models/device_info.dart'; +import '../providers/connection_provider.dart'; +import '../widgets/device/spectrum_scan_panel.dart'; + +class SpectrumScanScreen extends StatefulWidget { + const SpectrumScanScreen({super.key}); + + @override + State createState() => _SpectrumScanScreenState(); +} + +class _SpectrumScanScreenState extends State { + static const List _bandwidthOptions = [ + '7.8 kHz', + '10.4 kHz', + '15.6 kHz', + '20.8 kHz', + '31.25 kHz', + '41.7 kHz', + '62.5 kHz', + '125 kHz', + '250 kHz', + '500 kHz', + ]; + + String _selectedBandwidth = '62.5 kHz'; + bool _isSpectrumScanRunning = false; + bool _rangeInitialized = false; + String? _lastRangeSourceKey; + late double _scanRangeMinMhz; + late double _scanRangeMaxMhz; + late RangeValues _scanRangeValues; + List _scanCandidates = const []; + int? _selectedScanFrequencyKhz; + int _completedScanSectors = 0; + int _totalScanSectors = 0; + + List get _recommendedScanCandidates => + _scanCandidates.take(8).toList(); + + @override + void initState() { + super.initState(); + final deviceInfo = _deviceInfo; + if (deviceInfo.radioBw != null && + deviceInfo.radioBw! >= 0 && + deviceInfo.radioBw! < _bandwidthOptions.length) { + _selectedBandwidth = _bandwidthOptions[deviceInfo.radioBw!]; + } + _syncRangeFromDevice(deviceInfo); + _selectedScanFrequencyKhz = deviceInfo.radioFreq; + } + + DeviceInfo get _deviceInfo => context.read().deviceInfo; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _syncRangeFromDevice(context.watch().deviceInfo); + } + + void _syncRangeFromDevice(DeviceInfo deviceInfo) { + final minKhz = deviceInfo.spectrumScanMinKhz; + final maxKhz = deviceInfo.spectrumScanMaxKhz; + late double normalizedMinMhz; + late double normalizedMaxMhz; + late String sourceKey; + + if (minKhz != null && maxKhz != null && maxKhz > minKhz) { + final normalized = _normalizeScanRange( + minKhz / 1000.0, + maxKhz / 1000.0, + deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null, + ); + normalizedMinMhz = normalized.$1; + normalizedMaxMhz = normalized.$2; + sourceKey = 'fw:$minKhz:$maxKhz'; + } else { + final normalized = _normalizeScanRange( + null, + null, + deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null, + ); + normalizedMinMhz = normalized.$1; + normalizedMaxMhz = normalized.$2; + sourceKey = + 'fallback:${deviceInfo.radioFreq != null ? deviceInfo.radioFreq! ~/ 1000 : 869525}'; + } + + if (_rangeInitialized && _lastRangeSourceKey == sourceKey) { + return; + } + + _scanRangeMinMhz = normalizedMinMhz; + _scanRangeMaxMhz = normalizedMaxMhz; + + if (_scanRangeMaxMhz <= _scanRangeMinMhz) { + _scanRangeMaxMhz = _scanRangeMinMhz + 0.5; + } + + _scanRangeValues = RangeValues(_scanRangeMinMhz, _scanRangeMaxMhz); + _rangeInitialized = true; + _lastRangeSourceKey = sourceKey; + } + + (double, double) _normalizeScanRange( + double? minMhz, + double? maxMhz, + double? centerMhz, + ) { + const hardMinMhz = 800.0; + const hardMaxMhz = 950.0; + final center = centerMhz ?? 869.525; + + if (minMhz != null && maxMhz != null) { + final clampedMin = minMhz.clamp(hardMinMhz, hardMaxMhz); + final clampedMax = maxMhz.clamp(hardMinMhz, hardMaxMhz); + if (clampedMax > clampedMin) { + return (clampedMin, clampedMax); + } + } + + if (center >= 900.0 && center <= 930.0) { + return (902.0, 928.0); + } + + return (863.0, 870.0); + } + + double _bandwidthToKhz(String bw) { + switch (bw) { + case '7.8 kHz': + return 7.8; + case '10.4 kHz': + return 10.4; + case '15.6 kHz': + return 15.6; + case '20.8 kHz': + return 20.8; + case '31.25 kHz': + return 31.25; + case '41.7 kHz': + return 41.7; + case '62.5 kHz': + return 62.5; + case '125 kHz': + return 125.0; + case '250 kHz': + return 250.0; + case '500 kHz': + return 500.0; + default: + return 62.5; + } + } + + String _currentParamProfile(DeviceInfo deviceInfo) { + final sf = deviceInfo.radioSf ?? 8; + final cr = deviceInfo.radioCr ?? 8; + return 'BW $_selectedBandwidth | SF$sf | CR 4/$cr'; + } + + String _recommendationTitle(int index) { + switch (index) { + case 0: + return 'Best candidate'; + case 1: + return 'Alternate'; + case 2: + return 'Fallback'; + default: + return 'Candidate ${index + 1}'; + } + } + + List _possibleBracketFrequenciesKhz() { + final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth); + final halfBandwidthKhz = bandwidthKhz / 2.0; + final startKhz = (_scanRangeValues.start * 1000).round(); + final stopKhz = (_scanRangeValues.end * 1000).round(); + final firstCenterKhz = (startKhz + halfBandwidthKhz).round(); + final lastCenterKhz = (stopKhz - halfBandwidthKhz).round(); + + if (lastCenterKhz < firstCenterKhz) { + return const []; + } + + final stepKhz = bandwidthKhz >= 125.0 ? bandwidthKhz.round() : 25; + final centers = []; + for ( + var centerKhz = firstCenterKhz; + centerKhz <= lastCenterKhz && centers.length < 8; + centerKhz += stepKhz + ) { + centers.add(centerKhz); + } + + if (centers.isEmpty || centers.last != lastCenterKhz) { + centers.add(lastCenterKhz); + } + + return centers.toSet().toList()..sort(); + } + + void _resetDerivedScanResults() { + _scanCandidates = const []; + _selectedScanFrequencyKhz = null; + } + + List<(int startKhz, int stopKhz)> _buildScanSectors(double bandwidthKhz) { + final startKhz = (_scanRangeValues.start * 1000).round(); + final stopKhz = (_scanRangeValues.end * 1000).round(); + final sectorWidthKhz = (bandwidthKhz * 24).round().clamp(250, 1200); + final overlapKhz = bandwidthKhz.round().clamp(8, 500); + final sectors = <(int startKhz, int stopKhz)>[]; + + var sectorStartKhz = startKhz; + while (sectorStartKhz < stopKhz) { + final sectorStopKhz = (sectorStartKhz + sectorWidthKhz).clamp( + sectorStartKhz + overlapKhz, + stopKhz, + ); + sectors.add((sectorStartKhz, sectorStopKhz)); + if (sectorStopKhz >= stopKhz) { + break; + } + sectorStartKhz = sectorStopKhz - overlapKhz; + } + + return sectors; + } + + List _mergeSectorCandidates( + Iterable candidates, + ) { + final byFrequency = {}; + for (final candidate in candidates) { + final existing = byFrequency[candidate.centerFrequencyKhz]; + if (existing == null || + candidate.occupancyPercent < existing.occupancyPercent || + (candidate.occupancyPercent == existing.occupancyPercent && + candidate.peakRssiDbm < existing.peakRssiDbm) || + (candidate.occupancyPercent == existing.occupancyPercent && + candidate.peakRssiDbm == existing.peakRssiDbm && + candidate.avgRssiDbm < existing.avgRssiDbm)) { + byFrequency[candidate.centerFrequencyKhz] = candidate; + } + } + + final merged = byFrequency.values.toList() + ..sort((a, b) { + final occupancyCompare = a.occupancyPercent.compareTo( + b.occupancyPercent, + ); + if (occupancyCompare != 0) return occupancyCompare; + final peakCompare = a.peakRssiDbm.compareTo(b.peakRssiDbm); + if (peakCompare != 0) return peakCompare; + return a.avgRssiDbm.compareTo(b.avgRssiDbm); + }); + return merged; + } + + Future _runSpectrumScan() async { + final connectionProvider = context.read(); + final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth); + final sectors = _buildScanSectors(bandwidthKhz); + final sectorCandidates = []; + + setState(() { + _isSpectrumScanRunning = true; + _resetDerivedScanResults(); + _completedScanSectors = 0; + _totalScanSectors = sectors.length; + }); + + try { + for (var i = 0; i < sectors.length; i++) { + final sector = sectors[i]; + final result = await connectionProvider.scanSpectrum( + startFrequencyKhz: sector.$1, + stopFrequencyKhz: sector.$2, + bandwidthKhz: bandwidthKhz.round(), + stepKhz: (bandwidthKhz / 2).round().clamp(1, 1000), + dwellMs: 160, + thresholdDb: 8, + ); + if (result != null) { + sectorCandidates.addAll(result.candidates); + final mergedCandidates = _mergeSectorCandidates(sectorCandidates); + if (mounted) { + setState(() { + _scanCandidates = mergedCandidates; + if (_selectedScanFrequencyKhz == null && + mergedCandidates.isNotEmpty) { + _selectedScanFrequencyKhz = + mergedCandidates.first.centerFrequencyKhz; + } + _completedScanSectors = i + 1; + }); + } + } else if (mounted) { + setState(() { + _completedScanSectors = i + 1; + }); + } + } + } finally { + if (mounted) { + setState(() { + _isSpectrumScanRunning = false; + if (_completedScanSectors == 0) { + _totalScanSectors = sectors.length; + } + }); + } + } + + if (!mounted) return; + + final mergedCandidates = _mergeSectorCandidates(sectorCandidates); + if (mergedCandidates.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Spectrum scan returned no candidate frequencies'), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() { + _scanCandidates = mergedCandidates; + _selectedScanFrequencyKhz = mergedCandidates.first.centerFrequencyKhz; + }); + } + + Future _applySelectedScanFrequency() async { + if (_selectedScanFrequencyKhz == null) return; + + final connectionProvider = context.read(); + final deviceInfo = connectionProvider.deviceInfo; + + await connectionProvider.setRadioParams( + frequency: _selectedScanFrequencyKhz!, + bandwidth: _bandwidthOptions.indexOf(_selectedBandwidth), + spreadingFactor: deviceInfo.radioSf ?? 8, + codingRate: deviceInfo.radioCr ?? 8, + repeat: deviceInfo.clientRepeat, + ); + await connectionProvider.refreshDeviceInfo(); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Applied ${(_selectedScanFrequencyKhz! / 1000.0).toStringAsFixed(3)} MHz', + ), + backgroundColor: Colors.green, + ), + ); + } + + int? _currentPreviewFrequencyKhz(DeviceInfo deviceInfo) { + if (_selectedScanFrequencyKhz != null) { + return _selectedScanFrequencyKhz; + } + return deviceInfo.radioFreq; + } + + @override + Widget build(BuildContext context) { + final deviceInfo = context.watch().deviceInfo; + final theme = Theme.of(context); + final possibleBracketFrequencies = _possibleBracketFrequenciesKhz(); + final recommendedScanCandidates = _recommendedScanCandidates; + final recommendationFrequencies = recommendedScanCandidates.isNotEmpty + ? recommendedScanCandidates + .map((candidate) => candidate.centerFrequencyKhz) + .toList() + : possibleBracketFrequencies; + + return Scaffold( + appBar: AppBar(title: const Text('Spectrum Scan')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + initialValue: _selectedBandwidth, + decoration: const InputDecoration( + labelText: 'Bandwidth', + border: OutlineInputBorder(), + helperText: + 'Scan and apply frequencies for this bandwidth', + ), + items: _bandwidthOptions.map((value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + onChanged: (value) { + if (value == null) return; + setState(() { + _selectedBandwidth = value; + _resetDerivedScanResults(); + }); + }, + ), + const SizedBox(height: 16), + Text( + deviceInfo.spectrumScanMinKhz != null && + deviceInfo.spectrumScanMaxKhz != null + ? 'Firmware scan range: ${_scanRangeMinMhz.toStringAsFixed(3)}-${_scanRangeMaxMhz.toStringAsFixed(3)} MHz' + : 'Fallback scan range selected from current band. MeshCore commonly uses EU868 or US915.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (_isSpectrumScanRunning) ...[ + const SizedBox(height: 8), + Text( + 'Scanning sector ${_completedScanSectors + 1} of $_totalScanSectors. Results update as each sector completes.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ], + const SizedBox(height: 12), + SpectrumScanPanel( + theme: theme, + scanSupported: deviceInfo.supportsSpectrumScan == true, + isRunning: _isSpectrumScanRunning, + rangeMinMhz: _scanRangeMinMhz, + rangeMaxMhz: _scanRangeMaxMhz, + rangeValues: _scanRangeValues, + bandwidthKhz: _bandwidthToKhz(_selectedBandwidth), + selectedFrequencyKhz: _currentPreviewFrequencyKhz( + deviceInfo, + ), + graphCandidates: _scanCandidates, + selectableCandidates: recommendedScanCandidates, + onRangeChanged: (values) { + setState(() { + _scanRangeValues = values; + _resetDerivedScanResults(); + }); + }, + onCandidateChanged: (value) { + setState(() { + _selectedScanFrequencyKhz = value; + }); + }, + onRunScan: _runSpectrumScan, + onApplySelected: _applySelectedScanFrequency, + ), + if (recommendationFrequencies.isNotEmpty) ...[ + const SizedBox(height: 18), + Text( + _scanCandidates.isNotEmpty + ? 'Recommended profiles' + : 'Possible brackets', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + _scanCandidates.isNotEmpty + ? 'Suggested frequencies from the latest scan with the radio parameters to keep alongside them.' + : 'Usable frequency brackets derived from the selected span and bandwidth, even without live scan data.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + for ( + var i = 0; + i < recommendationFrequencies.length; + i++ + ) ...[ + _RecommendationTile( + title: _recommendationTitle(i), + frequencyKhz: recommendationFrequencies[i], + candidate: recommendedScanCandidates.isNotEmpty + ? recommendedScanCandidates[i] + : null, + bandwidthKhz: _bandwidthToKhz(_selectedBandwidth), + paramsLabel: _currentParamProfile(deviceInfo), + isSelected: + _selectedScanFrequencyKhz == + recommendationFrequencies[i], + onSelect: () { + setState(() { + _selectedScanFrequencyKhz = + recommendationFrequencies[i]; + }); + }, + ), + if (i != recommendationFrequencies.length - 1) + const SizedBox(height: 10), + ], + ], + ], + ), + ), + ), + ], + ), + ); + } +} + +class _RecommendationTile extends StatelessWidget { + final String title; + final int frequencyKhz; + final SpectrumScanCandidate? candidate; + final double bandwidthKhz; + final String paramsLabel; + final bool isSelected; + final VoidCallback onSelect; + + const _RecommendationTile({ + required this.title, + required this.frequencyKhz, + required this.candidate, + required this.bandwidthKhz, + required this.paramsLabel, + required this.isSelected, + required this.onSelect, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + return InkWell( + onTap: onSelect, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isSelected + ? scheme.primaryContainer.withValues(alpha: 0.55) + : scheme.surfaceContainerHighest.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isSelected ? scheme.primary : scheme.outlineVariant, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + if (isSelected) + Icon(Icons.check_circle, color: scheme.primary, size: 18), + ], + ), + const SizedBox(height: 8), + Text( + '${(frequencyKhz / 1000.0).toStringAsFixed(3)} MHz', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + Text(paramsLabel, style: theme.textTheme.bodyMedium), + const SizedBox(height: 6), + Text( + candidate != null + ? 'Occupancy ${candidate!.occupancyPercent}% | Avg ${candidate!.avgRssiDbm} dBm | Peak ${candidate!.peakRssiDbm} dBm' + : 'Bracket ${(frequencyKhz / 1000.0 - bandwidthKhz / 2000.0).toStringAsFixed(3)}-${(frequencyKhz / 1000.0 + bandwidthKhz / 2000.0).toStringAsFixed(3)} MHz', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/utils/avatar_label_helper.dart b/lib/utils/avatar_label_helper.dart new file mode 100644 index 0000000..c0c404e --- /dev/null +++ b/lib/utils/avatar_label_helper.dart @@ -0,0 +1,32 @@ +class AvatarLabelHelper { + static String buildLabel(String name) { + final trimmed = name.trim(); + if (trimmed.isEmpty) return '?'; + + if (trimmed.startsWith('#')) { + final hashBody = trimmed.substring(1).replaceAll(RegExp(r'[\s_-]+'), ''); + if (hashBody.isEmpty) { + return '#'; + } + return '#${_take(hashBody, 2)}'.toUpperCase(); + } + + final parts = trimmed + .split(RegExp(r'[\s_-]+')) + .where((part) => part.isNotEmpty) + .toList(); + + if (parts.length >= 2) { + final first = _take(parts[0], 1); + final second = _take(parts[1], 1); + return '$first$second'.toUpperCase(); + } + + return _take(trimmed, 2).toUpperCase(); + } + + static String _take(String value, int count) { + if (value.length <= count) return value; + return value.substring(0, count); + } +} diff --git a/lib/widgets/common/contact_avatar.dart b/lib/widgets/common/contact_avatar.dart new file mode 100644 index 0000000..6cbcc10 --- /dev/null +++ b/lib/widgets/common/contact_avatar.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; + +import '../../models/contact.dart'; +import '../../services/trail_color_service.dart'; +import '../../utils/avatar_label_helper.dart'; + +class ContactAvatar extends StatelessWidget { + final Contact contact; + final double radius; + final String? displayName; + + const ContactAvatar({ + super.key, + required this.contact, + this.radius = 20, + this.displayName, + }); + + @override + Widget build(BuildContext context) { + final backgroundColor = _getBackgroundColor(context); + final foregroundColor = _getForegroundColor(backgroundColor); + final emoji = contact.roleEmoji; + + if (emoji != null && emoji.isNotEmpty) { + return _buildAvatarFrame( + backgroundColor: backgroundColor, + child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)), + ); + } + + if (_shouldUseLabelFallback) { + return _buildAvatarFrame( + backgroundColor: backgroundColor, + child: Text( + AvatarLabelHelper.buildLabel(displayName ?? contact.displayName), + style: TextStyle( + color: foregroundColor, + fontSize: radius * 0.68, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + ), + ), + ); + } + + return _buildAvatarFrame( + backgroundColor: backgroundColor, + child: Icon( + _getTypeIcon(contact.type), + color: foregroundColor, + size: radius, + ), + ); + } + + Widget _buildAvatarFrame({ + required Color backgroundColor, + required Widget child, + }) { + if (_usesSquareShape) { + return Container( + width: radius * 2, + height: radius * 2, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(radius * 0.6), + ), + alignment: Alignment.center, + child: child, + ); + } + + return CircleAvatar( + radius: radius, + backgroundColor: backgroundColor, + child: child, + ); + } + + bool get _shouldUseLabelFallback => + contact.type == ContactType.chat || + contact.type == ContactType.channel || + contact.type == ContactType.room; + + bool get _usesSquareShape => + contact.type == ContactType.channel || contact.type == ContactType.room; + + Color _getBackgroundColor(BuildContext context) { + if (_shouldUseLabelFallback || (contact.roleEmoji?.isNotEmpty ?? false)) { + return TrailColorService.getTrailColor(contact); + } + + switch (contact.type) { + case ContactType.none: + return Theme.of(context).colorScheme.surfaceContainerHighest; + case ContactType.chat: + return Colors.blue; + case ContactType.repeater: + return Colors.orange; + case ContactType.room: + return Colors.purple; + case ContactType.channel: + return Colors.teal; + } + } + + Color _getForegroundColor(Color backgroundColor) { + return ThemeData.estimateBrightnessForColor(backgroundColor) == + Brightness.dark + ? Colors.white + : Colors.black87; + } + + IconData _getTypeIcon(ContactType type) { + switch (type) { + case ContactType.none: + return Icons.help_outline; + case ContactType.chat: + return Icons.person; + case ContactType.repeater: + return Icons.router; + case ContactType.room: + return Icons.meeting_room; + case ContactType.channel: + return Icons.public; + } + } +} diff --git a/lib/widgets/device/spectrum_scan_panel.dart b/lib/widgets/device/spectrum_scan_panel.dart new file mode 100644 index 0000000..c626e11 --- /dev/null +++ b/lib/widgets/device/spectrum_scan_panel.dart @@ -0,0 +1,394 @@ +import 'package:flutter/material.dart'; +import 'package:meshcore_client/meshcore_client.dart'; + +class SpectrumScanPanel extends StatelessWidget { + final ThemeData theme; + final bool scanSupported; + final bool isRunning; + final double rangeMinMhz; + final double rangeMaxMhz; + final RangeValues rangeValues; + final double bandwidthKhz; + final int? selectedFrequencyKhz; + final List graphCandidates; + final List selectableCandidates; + final ValueChanged onRangeChanged; + final ValueChanged onCandidateChanged; + final VoidCallback onRunScan; + final VoidCallback onApplySelected; + + const SpectrumScanPanel({ + super.key, + required this.theme, + required this.scanSupported, + required this.isRunning, + required this.rangeMinMhz, + required this.rangeMaxMhz, + required this.rangeValues, + required this.bandwidthKhz, + required this.selectedFrequencyKhz, + required this.graphCandidates, + required this.selectableCandidates, + required this.onRangeChanged, + required this.onCandidateChanged, + required this.onRunScan, + required this.onApplySelected, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.45, + ), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.tune, color: theme.colorScheme.primary), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Power Scan', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + FilledButton.icon( + onPressed: scanSupported && !isRunning ? onRunScan : null, + icon: isRunning + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.radar), + label: Text( + scanSupported + ? (isRunning ? 'Scanning' : 'Scan') + : 'Unavailable', + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + scanSupported + ? 'Full range with bandwidth footprint. Firmware enforces hardware band limits and pauses the mesh while scanning.' + : 'Full range with bandwidth footprint. This companion does not support spectrum scan mode, so scanning is disabled.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 14), + _FrequencyRangePreview( + minMhz: rangeMinMhz, + maxMhz: rangeMaxMhz, + selectedRange: rangeValues, + selectedBandwidthKhz: bandwidthKhz, + selectedFrequencyKhz: selectedFrequencyKhz, + candidates: graphCandidates, + ), + const SizedBox(height: 10), + Wrap( + spacing: 14, + runSpacing: 6, + children: [ + _LegendChip( + color: theme.colorScheme.primary, + label: 'Quiet', + ), + const _LegendChip(color: Colors.orange, label: 'Moderate'), + _LegendChip( + color: theme.colorScheme.error, + label: 'Busy', + ), + ], + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${rangeValues.start.toStringAsFixed(3)} MHz', + style: theme.textTheme.labelMedium, + ), + Text( + '${rangeValues.end.toStringAsFixed(3)} MHz', + style: theme.textTheme.labelMedium, + ), + ], + ), + RangeSlider( + values: rangeValues, + min: rangeMinMhz, + max: rangeMaxMhz, + divisions: (((rangeMaxMhz - rangeMinMhz) * 20).round()).clamp( + 1, + 400, + ), + labels: RangeLabels( + rangeValues.start.toStringAsFixed(3), + rangeValues.end.toStringAsFixed(3), + ), + onChanged: onRangeChanged, + ), + if (selectableCandidates.isEmpty) ...[ + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: theme.colorScheme.surface.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Text( + scanSupported + ? 'No scan results yet. Adjust the range and run a scan to populate candidate frequencies.' + : 'Spectrum preview only. This companion can display the configured span, but cannot scan for open channels.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ] else ...[ + const SizedBox(height: 10), + DropdownButtonFormField( + initialValue: selectedFrequencyKhz, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Candidate frequency', + border: OutlineInputBorder(), + helperText: 'Best frequencies for the current bandwidth', + ), + items: selectableCandidates.map((candidate) { + return DropdownMenuItem( + value: candidate.centerFrequencyKhz, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz', + overflow: TextOverflow.ellipsis, + ), + Text( + '${candidate.occupancyPercent}% occupied | peak ${candidate.peakRssiDbm} dBm', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + }).toList(), + selectedItemBuilder: (context) { + return selectableCandidates.map((candidate) { + return Align( + alignment: Alignment.centerLeft, + child: Text( + '${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz', + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(); + }, + onChanged: onCandidateChanged, + ), + const SizedBox(height: 10), + Align( + alignment: Alignment.centerRight, + child: OutlinedButton.icon( + onPressed: selectedFrequencyKhz == null + ? null + : onApplySelected, + icon: const Icon(Icons.north_east), + label: const Text('Use selected frequency'), + ), + ), + ], + ], + ), + ); + } +} + +class _LegendChip extends StatelessWidget { + final Color color; + final String label; + + const _LegendChip({required this.color, required this.label}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text(label, style: Theme.of(context).textTheme.labelMedium), + ], + ); + } +} + +class _FrequencyRangePreview extends StatelessWidget { + final double minMhz; + final double maxMhz; + final RangeValues selectedRange; + final double selectedBandwidthKhz; + final int? selectedFrequencyKhz; + final List candidates; + + const _FrequencyRangePreview({ + required this.minMhz, + required this.maxMhz, + required this.selectedRange, + required this.selectedBandwidthKhz, + required this.selectedFrequencyKhz, + required this.candidates, + }); + + double _positionFor(double mhz) { + final span = maxMhz - minMhz; + if (span <= 0) return 0; + return ((mhz - minMhz) / span).clamp(0.0, 1.0); + } + + Color _candidateColor(BuildContext context, int occupancyPercent) { + final scheme = Theme.of(context).colorScheme; + if (occupancyPercent <= 10) return scheme.primary; + if (occupancyPercent <= 35) return Colors.orange; + return scheme.error; + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final spanMhz = maxMhz - minMhz; + final selectedFreqMhz = selectedFrequencyKhz != null + ? selectedFrequencyKhz! / 1000.0 + : null; + final bwMhz = selectedBandwidthKhz / 1000.0; + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final rangeLeft = _positionFor(selectedRange.start) * width; + final rangeRight = _positionFor(selectedRange.end) * width; + + double? bwLeft; + double? bwWidth; + if (selectedFreqMhz != null && spanMhz > 0) { + bwLeft = _positionFor(selectedFreqMhz - (bwMhz / 2)) * width; + final bwRight = _positionFor(selectedFreqMhz + (bwMhz / 2)) * width; + bwWidth = (bwRight - bwLeft).clamp(4.0, width); + } + + return Container( + height: 108, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: LinearGradient( + colors: [ + scheme.surface, + scheme.surfaceContainerHighest.withValues(alpha: 0.9), + ], + ), + border: Border.all(color: scheme.outlineVariant), + ), + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 12, + ), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: LinearGradient( + colors: [ + scheme.primary.withValues(alpha: 0.12), + scheme.tertiary.withValues(alpha: 0.08), + scheme.primary.withValues(alpha: 0.12), + ], + ), + ), + ), + ), + ), + Positioned( + left: rangeLeft, + top: 12, + width: (rangeRight - rangeLeft).clamp(8.0, width), + height: 56, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: scheme.primary.withValues(alpha: 0.20), + border: Border.all(color: scheme.primary), + ), + ), + ), + if (bwLeft != null && bwWidth != null) + Positioned( + left: bwLeft, + top: 28, + width: bwWidth, + height: 24, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + color: scheme.tertiary.withValues(alpha: 0.32), + border: Border.all(color: scheme.tertiary), + ), + ), + ), + for (final candidate in candidates) + Positioned( + left: (_positionFor(candidate.centerFrequencyMhz) * width) + .clamp(10.0, width - 18.0), + top: 72, + child: Column( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _candidateColor( + context, + candidate.occupancyPercent, + ), + ), + ), + const SizedBox(height: 4), + Text( + candidate.centerFrequencyMhz.toStringAsFixed(3), + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a2ed06c..4f494a5 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -16,6 +16,7 @@ import geolocator_apple import nsd_macos import objectbox_flutter_libs import package_info_plus +import path_provider_foundation import record_macos import share_plus import shared_preferences_foundation @@ -34,6 +35,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin")) ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 9e1163b..9ae1fa2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -129,14 +129,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" - url: "https://pub.dev" - source: hosted - version: "1.0.0" codec2_flutter: dependency: "direct main" description: @@ -646,14 +638,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.5" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" gsettings: dependency: transitive description: @@ -662,14 +646,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.8" - hooks: - dependency: transitive - description: - name: hooks - sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" - url: "https://pub.dev" - source: hosted - version: "1.0.1" http: dependency: "direct main" description: @@ -846,14 +822,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.2" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" matcher: dependency: transitive description: @@ -881,11 +849,9 @@ packages: meshcore_client: dependency: "direct main" description: - path: "." - ref: main - resolved-ref: "11f51ccaba850531496179bf63e023cb4c9ad797" - url: "https://github.com/dz0ny/meshcore_client.git" - source: git + path: "../meshcore_client" + relative: true + source: path version: "0.1.0" meta: dependency: transitive @@ -911,14 +877,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" - url: "https://pub.dev" - source: hosted - version: "0.17.4" nested: dependency: transitive description: @@ -991,14 +949,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.3.1" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" - url: "https://pub.dev" - source: hosted - version: "9.3.0" package_info_plus: dependency: "direct main" description: @@ -1040,13 +990,13 @@ packages: source: hosted version: "2.2.22" path_provider_foundation: - dependency: transitive + dependency: "direct overridden" description: name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.5.1" path_provider_linux: dependency: transitive description: @@ -1183,14 +1133,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" record: dependency: "direct main" description: @@ -1702,5 +1644,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.3 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml index 52047fd..b62d7f2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2026.0305.3+9 +version: 2026.0306.1+11 environment: sdk: ^3.9.2 @@ -145,6 +145,11 @@ dev_dependencies: flutter_lints: ^6.0.0 flutter_launcher_icons: "^0.14.4" +dependency_overrides: + meshcore_client: + path: ../meshcore_client + path_provider_foundation: 2.5.1 + flutter_launcher_icons: android: "launcher_icon" ios: true diff --git a/test/utils/avatar_label_helper_test.dart b/test/utils/avatar_label_helper_test.dart new file mode 100644 index 0000000..3e5cd76 --- /dev/null +++ b/test/utils/avatar_label_helper_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/utils/avatar_label_helper.dart'; + +void main() { + group('AvatarLabelHelper.buildLabel', () { + test('returns two initials for multi-word names', () { + expect(AvatarLabelHelper.buildLabel('John Smith'), 'JS'); + }); + + test('returns first two characters for single-word names', () { + expect(AvatarLabelHelper.buildLabel('Alpha'), 'AL'); + }); + + test('allows three compact characters for hash-prefixed names', () { + expect(AvatarLabelHelper.buildLabel('#ops'), '#OP'); + }); + + test('removes spacing after hash-prefixed names', () { + expect(AvatarLabelHelper.buildLabel('# foo alpha'), '#FO'); + }); + + test('keeps non-hash labels at two characters', () { + expect(AvatarLabelHelper.buildLabel('abc'), 'AB'); + }); + }); +} diff --git a/test/widgets/contact_avatar_test.dart b/test/widgets/contact_avatar_test.dart new file mode 100644 index 0000000..4859cb5 --- /dev/null +++ b/test/widgets/contact_avatar_test.dart @@ -0,0 +1,106 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/widgets/common/contact_avatar.dart'; + +void main() { + Contact buildContact({ + required String name, + required ContactType type, + int secondByte = 0, + }) { + final publicKey = Uint8List(32); + publicKey[1] = secondByte; + + return Contact( + publicKey: publicKey, + type: type, + flags: 0, + outPathLen: 0, + outPath: Uint8List(0), + advName: name, + lastAdvert: 0, + advLat: 0, + advLon: 0, + lastMod: 0, + ); + } + + Future pumpAvatar(WidgetTester tester, Contact contact) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: Center(child: ContactAvatar(contact: contact))), + ), + ); + } + + testWidgets('renders label avatar for rooms', (tester) async { + await pumpAvatar( + tester, + buildContact(name: 'Operations Room', type: ContactType.room), + ); + + expect(find.text('OR'), findsOneWidget); + expect(find.byType(CircleAvatar), findsNothing); + expect(find.byIcon(Icons.meeting_room), findsNothing); + }); + + testWidgets('renders hash label avatar for rooms', (tester) async { + await pumpAvatar( + tester, + buildContact(name: '#ops-room', type: ContactType.room), + ); + + expect(find.text('#OP'), findsOneWidget); + expect(find.byType(CircleAvatar), findsNothing); + expect(find.byIcon(Icons.meeting_room), findsNothing); + }); + + testWidgets('renders compact hash label avatar when name includes spacing', ( + tester, + ) async { + await pumpAvatar( + tester, + buildContact(name: '# foo alpha', type: ContactType.room), + ); + + expect(find.text('#FO'), findsOneWidget); + expect(find.byType(CircleAvatar), findsNothing); + expect(find.byIcon(Icons.meeting_room), findsNothing); + }); + + testWidgets('renders label avatar for channels', (tester) async { + await pumpAvatar( + tester, + buildContact(name: '#ops', type: ContactType.channel, secondByte: 3), + ); + + expect(find.text('#OP'), findsOneWidget); + expect(find.byType(CircleAvatar), findsNothing); + expect(find.byIcon(Icons.public), findsNothing); + }); + + testWidgets('renders non-hash label avatar for channels', (tester) async { + await pumpAvatar( + tester, + buildContact(name: 'Command Net', type: ContactType.channel, secondByte: 3), + ); + + expect(find.text('CN'), findsOneWidget); + expect(find.byType(CircleAvatar), findsNothing); + expect(find.byIcon(Icons.public), findsNothing); + }); + + testWidgets('renders round avatar for chat contacts', (tester) async { + await pumpAvatar( + tester, + buildContact(name: 'John Smith', type: ContactType.chat), + ); + + expect(find.text('JS'), findsOneWidget); + expect(find.byType(CircleAvatar), findsOneWidget); + expect(find.byIcon(Icons.person), findsNothing); + }); +}