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);
+ });
+}