mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
- Updated localization files for German, English, Spanish, French, Croatian, Italian, and Slovenian to include new overlay labels. - Implemented new WMS layers in the map provider and map tab for displaying hiking trails, main roads, house numbers, fire hazard zones, historical fires, firebreaks, Kras fire zones, place names, and municipality borders. - Added toggle functionality for each new overlay in the map provider. - Enhanced the map tab UI to include checkboxes for the new overlays, allowing users to enable or disable them. - Integrated the new WMS layers into the map rendering logic.
2535 lines
114 KiB
Dart
2535 lines
114 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:flutter_compass/flutter_compass.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:vector_map_tiles/vector_map_tiles.dart';
|
|
import 'package:vector_tile_renderer/vector_tile_renderer.dart' as vtr;
|
|
import 'package:http/http.dart' as http;
|
|
import '../utils/slovenian_crs.dart';
|
|
import '../providers/contacts_provider.dart';
|
|
import '../providers/messages_provider.dart';
|
|
import '../providers/map_provider.dart';
|
|
import '../providers/drawing_provider.dart';
|
|
import '../providers/app_provider.dart';
|
|
import '../providers/connection_provider.dart';
|
|
import '../models/contact.dart';
|
|
import '../models/sar_marker.dart';
|
|
import '../models/map_layer.dart';
|
|
import '../models/message.dart';
|
|
import '../services/tile_cache_service.dart';
|
|
import '../services/background_location_service.dart';
|
|
import '../services/location_tracking_service.dart';
|
|
import '../services/map_marker_service.dart';
|
|
import '../services/mbtiles_service.dart';
|
|
import '../services/trail_color_service.dart';
|
|
import '../widgets/map_debug_info.dart';
|
|
import '../widgets/map/compass_widget.dart';
|
|
import '../widgets/map/detailed_compass_dialog.dart';
|
|
import '../widgets/map/drawing_layer.dart';
|
|
import '../widgets/map/drawing_toolbar.dart';
|
|
import '../widgets/map/location_trail_layer.dart';
|
|
import '../widgets/map/trail_controls.dart';
|
|
import '../widgets/map/map_message_overlay.dart';
|
|
import '../widgets/map/download_area_overlay.dart';
|
|
import '../widgets/messages/sar_update_sheet.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import 'map_management_screen.dart';
|
|
|
|
class MapTab extends StatefulWidget {
|
|
final Function(bool)? onFullscreenChanged;
|
|
final VoidCallback? onNavigateToMessages;
|
|
|
|
const MapTab({
|
|
super.key,
|
|
this.onFullscreenChanged,
|
|
this.onNavigateToMessages,
|
|
});
|
|
|
|
@override
|
|
State<MapTab> createState() => _MapTabState();
|
|
}
|
|
|
|
class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|
final MapController _mapController = MapController();
|
|
final TileCacheService _tileCache = TileCacheService();
|
|
// DO NOT create a new LocationTrackingService instance here
|
|
// Use the singleton from AppProvider instead via _locationService getter
|
|
final MapMarkerService _markerService = MapMarkerService();
|
|
bool _isInitialized = false;
|
|
bool _isMapReady = false; // Track when map widget is actually rendered
|
|
MapLayer _currentLayer = MapLayer.openStreetMap;
|
|
double? _compassHeading; // Compass sensor heading
|
|
bool _rotateMarkerWithHeading = false; // Toggle for rotation
|
|
bool _showMapDebugInfo = false; // Toggle for debug info
|
|
bool _isFullscreen = false; // Toggle for fullscreen mode
|
|
double _gpsUpdateDistance = 3.0; // meters
|
|
bool _backgroundTrackingEnabled = false; // Toggle for background tracking
|
|
StreamSubscription<CompassEvent>? _compassStreamSubscription;
|
|
final BackgroundLocationService _backgroundLocationService = BackgroundLocationService();
|
|
bool _isDisposing = false; // Flag to prevent updates during disposal
|
|
|
|
// MBTiles layers
|
|
List<MapLayer> _mbtilesLayers = [];
|
|
|
|
// WMS layers (Slovenian)
|
|
late final MapLayer _slovenianAerialLayer;
|
|
late final MapLayer _dtk25Layer;
|
|
|
|
// Vector tile theme
|
|
vtr.Theme? _vectorTheme;
|
|
bool _isLoadingTheme = false;
|
|
|
|
// Dropped pin state
|
|
LatLng? _droppedPinLocation;
|
|
bool _isDraggingPin = false;
|
|
final GlobalKey _pinMarkerKey = GlobalKey();
|
|
|
|
// Saved map position (loaded from SharedPreferences)
|
|
LatLng? _savedMapCenter;
|
|
double? _savedMapZoom;
|
|
|
|
// Default center point (will be updated based on markers)
|
|
static const LatLng _defaultCenter = LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
|
|
static const double _defaultZoom = 13.0;
|
|
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
|
|
// Access the singleton LocationTrackingService from AppProvider
|
|
LocationTrackingService get _locationService => LocationTrackingService();
|
|
|
|
/// Helper to compare public keys byte-by-byte
|
|
bool _publicKeysMatch(Uint8List key1, Uint8List key2) {
|
|
if (key1.length != key2.length) return false;
|
|
for (int i = 0; i < key1.length; i++) {
|
|
if (key1[i] != key2[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Initialize Slovenian WMS layers with CRS
|
|
_slovenianAerialLayer = MapLayer.getSlovenianAerial2024(slovenianCrs);
|
|
_dtk25Layer = MapLayer.getDTK25(slovenianCrs);
|
|
_loadSettings();
|
|
_loadMbtilesLayers();
|
|
_initializeTileCache();
|
|
_setupLocationCallbacks();
|
|
_startCompassTracking();
|
|
|
|
// Listen to map provider for navigation requests
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final mapProvider = context.read<MapProvider>();
|
|
mapProvider.addListener(_handleMapNavigation);
|
|
// Load WMS overlay state
|
|
mapProvider.loadOverlayState();
|
|
|
|
// Initialize background location service with BLE service
|
|
final appProvider = context.read<AppProvider>();
|
|
_backgroundLocationService.initialize(appProvider.connectionProvider.bleService);
|
|
|
|
// Restore background tracking state
|
|
_restoreBackgroundTracking();
|
|
});
|
|
}
|
|
|
|
/// Setup location tracking callbacks for map-specific features
|
|
/// Note: LocationTrackingService is initialized and started by AppProvider
|
|
/// This method only adds map-specific callbacks for rotation and UI updates
|
|
void _setupLocationCallbacks() {
|
|
// Store the original callback from AppProvider
|
|
final originalCallback = _locationService.onPositionUpdate;
|
|
|
|
// Add map-specific callback that chains with the original
|
|
_locationService.onPositionUpdate = (position) {
|
|
// Call original callback first (AppProvider's logging)
|
|
originalCallback?.call(position);
|
|
|
|
// Then handle map-specific logic
|
|
if (mounted) {
|
|
setState(() {
|
|
// Position updates trigger UI rebuild for markers
|
|
});
|
|
|
|
// Add location point to trail when tracking is active
|
|
final mapProvider = context.read<MapProvider>();
|
|
if (_locationService.isTracking) {
|
|
mapProvider.addTrailPoint(
|
|
LatLng(position.latitude, position.longitude),
|
|
accuracy: position.accuracy,
|
|
speed: position.speed,
|
|
);
|
|
}
|
|
|
|
// Rotate map if rotation mode is enabled and heading is available
|
|
if (_isMapReady && _rotateMarkerWithHeading && position.heading >= 0) {
|
|
try {
|
|
final camera = _mapController.camera;
|
|
_mapController.moveAndRotate(
|
|
camera.center,
|
|
camera.zoom,
|
|
-position.heading,
|
|
);
|
|
} catch (e) {
|
|
// Map not ready yet, ignore
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
void _startCompassTracking() {
|
|
final compassStream = FlutterCompass.events;
|
|
if (compassStream == null) {
|
|
return;
|
|
}
|
|
|
|
// 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;
|
|
|
|
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');
|
|
}
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Load MBTiles layers from file system
|
|
Future<void> _loadMbtilesLayers() async {
|
|
try {
|
|
final mbtilesService = MbtilesService();
|
|
final metadata = await mbtilesService.getAllMetadata();
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_mbtilesLayers = metadata.map((meta) {
|
|
// Determine if data is gzipped (for Geofabrik files)
|
|
final isGzipped = meta.format == 'pbf';
|
|
|
|
return MapLayer.fromMbtilesFile(
|
|
name: meta.name,
|
|
mbtilesFile: meta.file,
|
|
styleUrl: 'https://tiles.openfreemap.org/styles/bright',
|
|
sourceName: 'openmaptiles',
|
|
maxZoom: 20.0, // Override to 20 for overzooming
|
|
isGzipped: isGzipped,
|
|
attribution: meta.attribution,
|
|
);
|
|
}).toList();
|
|
});
|
|
debugPrint('Loaded ${_mbtilesLayers.length} MBTiles layers');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Error loading MBTiles layers: $e');
|
|
}
|
|
}
|
|
|
|
/// Get all available layers (default + WMS + MBTiles)
|
|
List<MapLayer> get _allLayers => [...MapLayer.allLayers, _slovenianAerialLayer, _dtk25Layer, ..._mbtilesLayers];
|
|
|
|
Future<void> _loadSettings() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
if (mounted) {
|
|
// Load last map position if available
|
|
final lastLat = prefs.getDouble('map_last_latitude');
|
|
final lastLon = prefs.getDouble('map_last_longitude');
|
|
final lastZoom = prefs.getDouble('map_last_zoom');
|
|
|
|
// Load last map layer
|
|
final lastLayerType = prefs.getInt('map_last_layer_type');
|
|
final lastLayerName = prefs.getString('map_last_layer_name');
|
|
|
|
setState(() {
|
|
_rotateMarkerWithHeading = prefs.getBool('map_rotate_with_heading') ?? false;
|
|
_showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false;
|
|
_isFullscreen = prefs.getBool('map_fullscreen') ?? false;
|
|
|
|
// Notify parent about initial fullscreen state
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
widget.onFullscreenChanged?.call(_isFullscreen);
|
|
});
|
|
_gpsUpdateDistance = prefs.getDouble('map_gps_update_distance') ?? 3.0;
|
|
_backgroundTrackingEnabled = prefs.getBool('background_tracking_enabled') ?? false;
|
|
|
|
// Store saved position for use in build
|
|
if (lastLat != null && lastLon != null && lastZoom != null) {
|
|
_savedMapCenter = LatLng(lastLat, lastLon);
|
|
_savedMapZoom = lastZoom;
|
|
}
|
|
|
|
// 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) {
|
|
// Find MBTiles layer by name
|
|
final mbtilesLayer = _mbtilesLayers.firstWhere(
|
|
(layer) => layer.name == lastLayerName,
|
|
orElse: () => MapLayer.openStreetMap,
|
|
);
|
|
_currentLayer = mbtilesLayer;
|
|
} else if (layerType == MapLayerType.wmsBase) {
|
|
// Use Slovenian aerial layer if that's what was saved
|
|
_currentLayer = _slovenianAerialLayer;
|
|
} else {
|
|
// Use default layer
|
|
_currentLayer = MapLayer.allLayers.firstWhere(
|
|
(layer) => layer.type == layerType,
|
|
orElse: () => MapLayer.openStreetMap,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Clamp saved zoom if it exceeds the current layer's maximum
|
|
// For WMS layers, use a middle zoom (11) instead of max zoom to avoid extreme close-up
|
|
if (_savedMapZoom != null && _savedMapZoom! > _currentLayer.maxZoom) {
|
|
_savedMapZoom = _currentLayer.isWms ? 11.0 : _currentLayer.maxZoom;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _saveSettings() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setBool('map_rotate_with_heading', _rotateMarkerWithHeading);
|
|
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);
|
|
|
|
// Save layer type and name (for MBTiles layers)
|
|
await prefs.setInt('map_last_layer_type', _currentLayer.type.index);
|
|
if (_currentLayer.type == MapLayerType.vectorMbtiles) {
|
|
await prefs.setString('map_last_layer_name', _currentLayer.name);
|
|
}
|
|
}
|
|
|
|
Future<void> _saveMapPosition() async {
|
|
if (!_isMapReady) return;
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final camera = _mapController.camera;
|
|
await prefs.setDouble('map_last_latitude', camera.center.latitude);
|
|
await prefs.setDouble('map_last_longitude', camera.center.longitude);
|
|
await prefs.setDouble('map_last_zoom', camera.zoom);
|
|
} catch (e) {
|
|
debugPrint('Error saving map position: $e');
|
|
}
|
|
}
|
|
|
|
|
|
void _handleMapNavigation() {
|
|
final mapProvider = context.read<MapProvider>();
|
|
if (mapProvider.targetLocation != null && _isMapReady) {
|
|
try {
|
|
_mapController.move(
|
|
mapProvider.targetLocation!,
|
|
mapProvider.targetZoom ?? _defaultZoom,
|
|
);
|
|
// Clear the navigation request after handling
|
|
mapProvider.clearNavigation();
|
|
} catch (e) {
|
|
// Map not ready yet, ignore
|
|
debugPrint('Map controller not ready for navigation: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _initializeTileCache() async {
|
|
try {
|
|
await _tileCache.initialize();
|
|
if (mounted) {
|
|
setState(() {
|
|
_isInitialized = true;
|
|
});
|
|
// Wait for the map to render, then mark it as ready
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
// Give the map widget one more frame to fully initialize
|
|
Future.delayed(const Duration(milliseconds: 100), () {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isMapReady = true;
|
|
});
|
|
debugPrint('Map is now ready for controller operations');
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Error initializing tile cache: $e');
|
|
if (mounted) {
|
|
setState(() {
|
|
_isInitialized = true; // Continue without caching
|
|
});
|
|
// Still mark map as ready after a delay
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
Future.delayed(const Duration(milliseconds: 100), () {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isMapReady = true;
|
|
});
|
|
debugPrint('Map is now ready for controller operations');
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// Set flag immediately to prevent any async callbacks from firing
|
|
_isDisposing = true;
|
|
|
|
// Cancel compass subscription first to stop new events
|
|
_compassStreamSubscription?.cancel();
|
|
_compassStreamSubscription = null;
|
|
|
|
// Save map position before disposing
|
|
_saveMapPosition();
|
|
|
|
final mapProvider = context.read<MapProvider>();
|
|
mapProvider.removeListener(_handleMapNavigation);
|
|
|
|
// DO NOT stop location tracking - it's managed by AppProvider
|
|
// Just clear the map-specific callback
|
|
_locationService.onPositionUpdate = null;
|
|
_mapController.dispose();
|
|
_tileCache.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
// Get the current heading from compass or GPS
|
|
double? get _currentHeading {
|
|
// Prefer compass heading as it works when stationary
|
|
if (_compassHeading != null) {
|
|
return _compassHeading;
|
|
}
|
|
// Fall back to GPS heading when moving
|
|
final currentPosition = _locationService.currentPosition;
|
|
if (currentPosition?.heading != null && currentPosition!.heading >= 0) {
|
|
return currentPosition.heading;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Safely get map rotation, returns 0.0 if map is not ready
|
|
double _getMapRotation() {
|
|
if (!_isMapReady) return 0.0;
|
|
try {
|
|
return _mapController.camera.rotation;
|
|
} catch (e) {
|
|
// Map controller not ready yet
|
|
return 0.0;
|
|
}
|
|
}
|
|
|
|
LatLng _calculateCenter(List<Contact> contacts, List<SarMarker> sarMarkers) {
|
|
return _markerService.calculateCenter(
|
|
contacts: contacts,
|
|
sarMarkers: sarMarkers,
|
|
defaultCenter: _defaultCenter,
|
|
);
|
|
}
|
|
|
|
/// Load vector tile theme from URL
|
|
Future<void> _loadVectorTheme(String styleUrl) async {
|
|
if (_isLoadingTheme) return;
|
|
|
|
setState(() {
|
|
_isLoadingTheme = true;
|
|
});
|
|
|
|
try {
|
|
final response = await http.get(Uri.parse(styleUrl));
|
|
if (response.statusCode == 200) {
|
|
final styleJson = jsonDecode(response.body) as Map<String, Object?>;
|
|
final theme = vtr.ThemeReader().read(styleJson);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_vectorTheme = theme;
|
|
_isLoadingTheme = false;
|
|
});
|
|
}
|
|
} else {
|
|
throw Exception('Failed to load style: ${response.statusCode}');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Error loading vector theme: $e');
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoadingTheme = false;
|
|
});
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Failed to load map style: $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _showLayerSelector(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
builder: (context) => Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.layers),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
AppLocalizations.of(context)!.selectMapLayer,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.download),
|
|
tooltip: AppLocalizations.of(context)!.downloadVisibleArea,
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_navigateToDownload(context);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(),
|
|
Expanded(
|
|
child: ListView(
|
|
shrinkWrap: true,
|
|
children: [
|
|
// Online layers section
|
|
Padding(
|
|
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],
|
|
),
|
|
),
|
|
),
|
|
...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') ...[
|
|
ListTile(
|
|
leading: _currentLayer == _slovenianAerialLayer
|
|
? const Icon(Icons.check_circle, color: Colors.green)
|
|
: const Icon(Icons.radio_button_unchecked),
|
|
title: Text(_slovenianAerialLayer.name),
|
|
subtitle: Text(_slovenianAerialLayer.attribution),
|
|
onTap: () async {
|
|
setState(() {
|
|
_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) {
|
|
_mapController.move(
|
|
_mapController.camera.center,
|
|
11.0, // Middle zoom for WMS
|
|
);
|
|
}
|
|
});
|
|
_saveSettings();
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: _currentLayer == _dtk25Layer
|
|
? const Icon(Icons.check_circle, color: Colors.green)
|
|
: const Icon(Icons.radio_button_unchecked),
|
|
title: Text(AppLocalizations.of(context)!.topographicMap),
|
|
subtitle: Text(_dtk25Layer.attribution),
|
|
onTap: () async {
|
|
setState(() {
|
|
_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) {
|
|
_mapController.move(
|
|
_mapController.camera.center,
|
|
11.0, // Middle zoom for WMS
|
|
);
|
|
}
|
|
});
|
|
_saveSettings();
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
],
|
|
// Offline MBTiles layers section
|
|
if (_mbtilesLayers.isNotEmpty) ...[
|
|
const Divider(),
|
|
Padding(
|
|
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],
|
|
),
|
|
),
|
|
),
|
|
..._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 {
|
|
// Load vector theme if switching to vector layer
|
|
if (layer.isVector && layer.styleUrl != null) {
|
|
Navigator.pop(context);
|
|
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) {
|
|
Navigator.pop(context);
|
|
}
|
|
},
|
|
)),
|
|
],
|
|
// 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') &&
|
|
_currentLayer.isWms) ...[
|
|
const Divider(),
|
|
Padding(
|
|
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],
|
|
),
|
|
),
|
|
),
|
|
Consumer<MapProvider>(
|
|
builder: (context, mapProvider, _) {
|
|
return Column(
|
|
children: [
|
|
CheckboxListTile(
|
|
secondary: const Icon(Icons.grid_on, color: Colors.blue),
|
|
title: Text(AppLocalizations.of(context)!.cadastralParcels),
|
|
subtitle: const Text('© GURS'),
|
|
value: mapProvider.showCadastralOverlay,
|
|
onChanged: (value) {
|
|
mapProvider.toggleCadastralOverlay();
|
|
},
|
|
),
|
|
CheckboxListTile(
|
|
secondary: const Icon(Icons.route, color: Colors.green),
|
|
title: Text(AppLocalizations.of(context)!.forestRoads),
|
|
subtitle: const Text('© GURS'),
|
|
value: mapProvider.showForestRoadsOverlay,
|
|
onChanged: (value) {
|
|
mapProvider.toggleForestRoadsOverlay();
|
|
},
|
|
),
|
|
CheckboxListTile(
|
|
secondary: const Icon(Icons.hiking, color: Colors.brown),
|
|
title: Text(AppLocalizations.of(context)!.hikingTrails),
|
|
subtitle: const Text('© GURS'),
|
|
value: mapProvider.showHikingTrailsOverlay,
|
|
onChanged: (value) {
|
|
mapProvider.toggleHikingTrailsOverlay();
|
|
},
|
|
),
|
|
CheckboxListTile(
|
|
secondary: const Icon(Icons.alt_route, color: Colors.grey),
|
|
title: Text(AppLocalizations.of(context)!.mainRoads),
|
|
subtitle: const Text('© GURS'),
|
|
value: mapProvider.showMainRoadsOverlay,
|
|
onChanged: (value) {
|
|
mapProvider.toggleMainRoadsOverlay();
|
|
},
|
|
),
|
|
CheckboxListTile(
|
|
secondary: const Icon(Icons.numbers, color: Colors.purple),
|
|
title: Text(AppLocalizations.of(context)!.houseNumbers),
|
|
subtitle: const Text('© GURS'),
|
|
value: mapProvider.showHouseNumbersOverlay,
|
|
onChanged: (value) {
|
|
mapProvider.toggleHouseNumbersOverlay();
|
|
},
|
|
),
|
|
CheckboxListTile(
|
|
secondary: const Icon(Icons.warning_amber, color: Colors.orange),
|
|
title: Text(AppLocalizations.of(context)!.fireHazardZones),
|
|
subtitle: const Text('© GURS'),
|
|
value: mapProvider.showFireHazardZonesOverlay,
|
|
onChanged: (value) {
|
|
mapProvider.toggleFireHazardZonesOverlay();
|
|
},
|
|
),
|
|
CheckboxListTile(
|
|
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) {
|
|
mapProvider.toggleHistoricalFiresOverlay();
|
|
},
|
|
),
|
|
CheckboxListTile(
|
|
secondary: const Icon(Icons.forest, color: Colors.teal),
|
|
title: Text(AppLocalizations.of(context)!.firebreaks),
|
|
subtitle: const Text('© GURS'),
|
|
value: mapProvider.showFirebreaksOverlay,
|
|
onChanged: (value) {
|
|
mapProvider.toggleFirebreaksOverlay();
|
|
},
|
|
),
|
|
CheckboxListTile(
|
|
secondary: const Icon(Icons.warning, color: Colors.deepOrange),
|
|
title: Text(AppLocalizations.of(context)!.krasFireZones),
|
|
subtitle: const Text('© GURS'),
|
|
value: mapProvider.showKrasFireZonesOverlay,
|
|
onChanged: (value) {
|
|
mapProvider.toggleKrasFireZonesOverlay();
|
|
},
|
|
),
|
|
CheckboxListTile(
|
|
secondary: const Icon(Icons.place, color: Colors.indigo),
|
|
title: Text(AppLocalizations.of(context)!.placeNames),
|
|
subtitle: const Text('© GURS'),
|
|
value: mapProvider.showPlaceNamesOverlay,
|
|
onChanged: (value) {
|
|
mapProvider.togglePlaceNamesOverlay();
|
|
},
|
|
),
|
|
CheckboxListTile(
|
|
secondary: const Icon(Icons.border_outer, color: Colors.cyan),
|
|
title: Text(AppLocalizations.of(context)!.municipalityBorders),
|
|
subtitle: const Text('© GURS'),
|
|
value: mapProvider.showMunicipalityBordersOverlay,
|
|
onChanged: (value) {
|
|
mapProvider.toggleMunicipalityBordersOverlay();
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _navigateToDownload(BuildContext context) {
|
|
if (!_isMapReady) return;
|
|
|
|
try {
|
|
// Get current map bounds
|
|
final bounds = _mapController.camera.visibleBounds;
|
|
|
|
// Enter download area selection mode (show preview overlay)
|
|
final mapProvider = context.read<MapProvider>();
|
|
mapProvider.enterDownloadAreaMode(bounds);
|
|
} catch (e) {
|
|
debugPrint('Error accessing map camera: $e');
|
|
}
|
|
}
|
|
|
|
void _showOptionsMenu(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (context) => StatefulBuilder(
|
|
builder: (context, setModalState) => Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
child: SingleChildScrollView(
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
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);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showDetailedCompass(BuildContext context, List<Contact> contacts, List<SarMarker> sarMarkers) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (context) => Container(
|
|
height: MediaQuery.of(context).size.height * 0.9,
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surface,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
child: DetailedCompassDialog(
|
|
initialPosition: _locationService.currentPosition,
|
|
initialHeading: _currentHeading,
|
|
contacts: contacts,
|
|
sarMarkers: sarMarkers,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showDetailedCompassWithContact(
|
|
BuildContext context,
|
|
List<Contact> contacts,
|
|
List<SarMarker> sarMarkers,
|
|
Contact selectedContact,
|
|
) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (context) => Container(
|
|
height: MediaQuery.of(context).size.height * 0.9,
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surface,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
child: DetailedCompassDialog(
|
|
initialPosition: _locationService.currentPosition,
|
|
initialHeading: _currentHeading,
|
|
contacts: contacts,
|
|
sarMarkers: sarMarkers,
|
|
preSelectedContact: selectedContact,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showDetailedCompassWithSarMarker(
|
|
BuildContext context,
|
|
List<Contact> contacts,
|
|
List<SarMarker> sarMarkers,
|
|
SarMarker selectedMarker,
|
|
) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (context) => Container(
|
|
height: MediaQuery.of(context).size.height * 0.9,
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surface,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
child: DetailedCompassDialog(
|
|
initialPosition: _locationService.currentPosition,
|
|
initialHeading: _currentHeading,
|
|
contacts: contacts,
|
|
sarMarkers: sarMarkers,
|
|
preSelectedSarMarker: selectedMarker,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _restartLocationStream() {
|
|
// Update distance threshold in location service
|
|
_locationService.updateDistanceThreshold(_gpsUpdateDistance);
|
|
|
|
// Update background tracking distance if active
|
|
if (_backgroundTrackingEnabled) {
|
|
_backgroundLocationService.updateDistanceThreshold(_gpsUpdateDistance);
|
|
}
|
|
}
|
|
|
|
/// Restore background tracking state on app start
|
|
Future<void> _restoreBackgroundTracking() async {
|
|
if (_backgroundTrackingEnabled) {
|
|
await _startBackgroundTracking();
|
|
}
|
|
}
|
|
|
|
/// Start background location tracking
|
|
Future<void> _startBackgroundTracking() async {
|
|
final success = await _backgroundLocationService.startTracking(
|
|
distanceThreshold: _gpsUpdateDistance,
|
|
);
|
|
|
|
if (!success) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_backgroundTrackingEnabled = false;
|
|
});
|
|
_saveSettings();
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(AppLocalizations.of(context)!.failedToStartBackgroundTracking),
|
|
duration: Duration(seconds: 3),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stop background location tracking
|
|
Future<void> _stopBackgroundTracking() async {
|
|
await _backgroundLocationService.stopTracking();
|
|
}
|
|
|
|
/// Calculate distance between two points in meters
|
|
double _calculateDistanceInMeters(double lat1, double lon1, double lat2, double lon2) {
|
|
return _markerService.calculateDistance(
|
|
lat1: lat1,
|
|
lon1: lon1,
|
|
lat2: lat2,
|
|
lon2: lon2,
|
|
);
|
|
}
|
|
|
|
/// Format distance for display
|
|
String _formatDistance(double meters) {
|
|
if (meters < 1000) {
|
|
return '${meters.toStringAsFixed(1)} m';
|
|
} else {
|
|
return '${(meters / 1000).toStringAsFixed(2)} km';
|
|
}
|
|
}
|
|
|
|
/// Show SAR dialog with pre-populated location from map long press
|
|
void _showSarDialogWithLocation(LatLng location) {
|
|
// Create a Position object from the LatLng coordinates
|
|
final position = Position(
|
|
latitude: location.latitude,
|
|
longitude: location.longitude,
|
|
timestamp: DateTime.now(),
|
|
accuracy: 0.0, // Unknown accuracy for map-selected point
|
|
altitude: 0.0,
|
|
altitudeAccuracy: 0.0,
|
|
heading: 0.0,
|
|
headingAccuracy: 0.0,
|
|
speed: 0.0,
|
|
speedAccuracy: 0.0,
|
|
);
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (context) => SarUpdateSheet(
|
|
prePopulatedPosition: position,
|
|
allowLocationUpdate: false, // Don't allow changing to current location
|
|
onSend: (emoji, name, position, roomPublicKey, sendToChannel, colorIndex) async {
|
|
await _sendSarMessage(emoji, name, position, roomPublicKey, sendToChannel, colorIndex);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _sendSarMessage(
|
|
String emoji,
|
|
String name,
|
|
Position position,
|
|
Uint8List? roomPublicKey,
|
|
bool sendToChannel,
|
|
int colorIndex,
|
|
) async {
|
|
final connectionProvider = context.read<ConnectionProvider>();
|
|
final messagesProvider = context.read<MessagesProvider>();
|
|
|
|
if (!connectionProvider.deviceInfo.isConnected) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(AppLocalizations.of(context)!.deviceNotConnected),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!sendToChannel && roomPublicKey == null) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Please select a room to send SAR marker'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// New format: S:<emoji>:<colorIndex>:<latitude>,<longitude>:<name>
|
|
// 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';
|
|
|
|
if (sendToChannel) {
|
|
// Create message ID
|
|
final messageId = '${DateTime.now().millisecondsSinceEpoch}_channel_sent';
|
|
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
|
|
// Get current device's public key (first 6 bytes)
|
|
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
|
|
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
|
|
|
|
// Create sent message object
|
|
final sentMessage = Message(
|
|
id: messageId,
|
|
messageType: MessageType.channel,
|
|
senderPublicKeyPrefix: senderPublicKeyPrefix,
|
|
pathLen: 0,
|
|
textType: MessageTextType.plain,
|
|
senderTimestamp: timestamp,
|
|
text: sarMessage,
|
|
receivedAt: DateTime.now(),
|
|
deliveryStatus: MessageDeliveryStatus.sending,
|
|
channelIdx: 0,
|
|
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
|
|
);
|
|
|
|
// Add to messages list with "sending" status
|
|
messagesProvider.addSentMessage(sentMessage);
|
|
|
|
// Send to public channel (ephemeral, over-the-air only)
|
|
await connectionProvider.sendChannelMessage(
|
|
channelIdx: 0,
|
|
text: sarMessage,
|
|
messageId: messageId,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('SAR marker broadcast to public channel'),
|
|
backgroundColor: Colors.orange,
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
} else {
|
|
// Create message ID
|
|
final messageId = '${DateTime.now().millisecondsSinceEpoch}_sent';
|
|
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
|
|
// Get current device's public key (first 6 bytes)
|
|
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
|
|
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
|
|
|
|
// Create sent message object with recipient public key for retry support
|
|
final sentMessage = Message(
|
|
id: messageId,
|
|
messageType: MessageType.contact,
|
|
senderPublicKeyPrefix: senderPublicKeyPrefix,
|
|
pathLen: 0,
|
|
textType: MessageTextType.plain,
|
|
senderTimestamp: timestamp,
|
|
text: sarMessage,
|
|
receivedAt: DateTime.now(),
|
|
deliveryStatus: MessageDeliveryStatus.sending,
|
|
recipientPublicKey: roomPublicKey, // Store recipient for retry
|
|
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
|
|
);
|
|
|
|
// Add to messages list with "sending" status
|
|
messagesProvider.addSentMessage(sentMessage);
|
|
|
|
// Look up the room contact for path logging
|
|
final contactsProvider = context.read<ContactsProvider>();
|
|
final roomContact = contactsProvider.contacts.where((c) {
|
|
return c.publicKey.length >= roomPublicKey!.length &&
|
|
_publicKeysMatch(c.publicKey, roomPublicKey);
|
|
}).firstOrNull;
|
|
|
|
// Send SAR message to selected room (persisted and immutable)
|
|
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
|
contactPublicKey: roomPublicKey!,
|
|
text: sarMessage,
|
|
messageId: messageId, // Pass message ID so it can be tracked
|
|
contact: roomContact, // Include contact for path status logging
|
|
);
|
|
|
|
if (!sentSuccessfully) {
|
|
// Mark message as failed if sending failed
|
|
messagesProvider.markMessageFailed(messageId);
|
|
}
|
|
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('SAR marker sent to room'),
|
|
backgroundColor: Colors.green,
|
|
duration: Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Failed to send SAR marker: $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
|
final appProvider = context.watch<AppProvider>();
|
|
final isSimpleMode = appProvider.isSimpleMode;
|
|
|
|
return Consumer3<ContactsProvider, MessagesProvider, DrawingProvider>(
|
|
builder: (context, contactsProvider, messagesProvider, drawingProvider, child) {
|
|
final contactsWithLocation = contactsProvider.contactsWithLocation;
|
|
// Filter SAR markers based on visibility toggle
|
|
final allSarMarkers = messagesProvider.sarMarkers;
|
|
final sarMarkers = drawingProvider.showSarMarkers
|
|
? allSarMarkers
|
|
: <SarMarker>[];
|
|
final center = _calculateCenter(contactsWithLocation, sarMarkers);
|
|
|
|
return Stack(
|
|
children: [
|
|
// Map widget
|
|
_isInitialized
|
|
? Listener(
|
|
onPointerMove: (PointerMoveEvent event) {
|
|
// Track pointer movement for mobile drag (onPointerHover doesn't work on mobile)
|
|
if (_isDraggingPin) {
|
|
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);
|
|
}
|
|
// Don't drop SAR marker pin in measurement mode
|
|
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) {
|
|
setState(() {
|
|
_isDraggingPin = true;
|
|
});
|
|
}
|
|
}
|
|
},
|
|
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);
|
|
}
|
|
return;
|
|
} else if (drawingProvider.drawingMode == DrawingMode.rectangle) {
|
|
if (drawingProvider.rectangleStartPoint == null) {
|
|
// Start rectangle
|
|
drawingProvider.startRectangle(point);
|
|
} else {
|
|
// Complete rectangle
|
|
drawingProvider.completeRectangle(point);
|
|
}
|
|
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) {
|
|
setState(() {
|
|
_droppedPinLocation = null;
|
|
});
|
|
}
|
|
}
|
|
},
|
|
),
|
|
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<MapProvider>(
|
|
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<MapProvider>(
|
|
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<MapProvider>(
|
|
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<MapProvider>(
|
|
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<MapProvider>(
|
|
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<MapProvider>(
|
|
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<MapProvider>(
|
|
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<MapProvider>(
|
|
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<MapProvider>(
|
|
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<MapProvider>(
|
|
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<MapProvider>(
|
|
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<MapProvider>(
|
|
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<MapProvider>(
|
|
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,
|
|
strokeWidth: 2.5, // Thinner than user trail
|
|
borderColor: Colors.white.withValues(alpha: 0.3),
|
|
borderStrokeWidth: 1.0,
|
|
// 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]),
|
|
),
|
|
],
|
|
),
|
|
// 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,
|
|
);
|
|
},
|
|
),
|
|
// 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();
|
|
},
|
|
),
|
|
// 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: 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<MapProvider>(
|
|
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;
|
|
|
|
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,
|
|
children: [
|
|
const CircularProgressIndicator(),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
AppLocalizations.of(context)!.initializingMap,
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Download area overlay (shown when in download area selection mode)
|
|
Consumer<MapProvider>(
|
|
builder: (context, mapProvider, _) {
|
|
if (!mapProvider.isSelectingDownloadArea || mapProvider.downloadAreaBounds == null) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return DownloadAreaOverlay(
|
|
bounds: mapProvider.downloadAreaBounds!,
|
|
onConfirm: () {
|
|
// Navigate to map management screen with selected bounds
|
|
final bounds = mapProvider.downloadAreaBounds!;
|
|
final zoom = _mapController.camera.zoom.round();
|
|
|
|
// Find matching layer from MapLayer.allLayers to avoid instance mismatch
|
|
// Only pass initialLayer if it's in allLayers (standard layers only)
|
|
MapLayer? initialLayer;
|
|
try {
|
|
initialLayer = MapLayer.allLayers.firstWhere(
|
|
(layer) => layer.type == _currentLayer.type,
|
|
);
|
|
} catch (e) {
|
|
// Current layer not in allLayers (WMS/vector), don't pass it
|
|
initialLayer = null;
|
|
}
|
|
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => MapManagementScreen(
|
|
tileCacheService: _tileCache,
|
|
initialBounds: bounds,
|
|
initialLayer: initialLayer,
|
|
initialZoom: zoom,
|
|
),
|
|
),
|
|
);
|
|
|
|
// Exit download area selection mode
|
|
mapProvider.exitDownloadAreaMode();
|
|
},
|
|
onCancel: () {
|
|
// Exit download area selection mode
|
|
mapProvider.exitDownloadAreaMode();
|
|
},
|
|
);
|
|
},
|
|
),
|
|
// Exit fullscreen button - top left (only shown in fullscreen mode)
|
|
if (_isFullscreen)
|
|
Positioned(
|
|
top: 60,
|
|
left: 16,
|
|
child: FloatingActionButton.small(
|
|
heroTag: 'exit_fullscreen',
|
|
onPressed: () {
|
|
setState(() {
|
|
_isFullscreen = false;
|
|
});
|
|
_saveSettings();
|
|
// Notify parent about fullscreen change
|
|
widget.onFullscreenChanged?.call(false);
|
|
},
|
|
backgroundColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9),
|
|
child: const Icon(Icons.fullscreen_exit),
|
|
),
|
|
),
|
|
// Message overlay - right side (only shown in fullscreen mode on large screens)
|
|
if (_isFullscreen && MediaQuery.of(context).size.width >= 800)
|
|
Positioned(
|
|
top: 60,
|
|
bottom: 60,
|
|
right: 16,
|
|
width: 300,
|
|
child: Consumer<MessagesProvider>(
|
|
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 displayMessages = recentMessages.length > 20
|
|
? recentMessages.sublist(recentMessages.length - 20)
|
|
: recentMessages;
|
|
|
|
return MapMessageOverlay(
|
|
messages: displayMessages,
|
|
onNavigateToMessages: widget.onNavigateToMessages,
|
|
onMessageTap: (messageId) {
|
|
messagesProvider.navigateToMessage(messageId);
|
|
widget.onNavigateToMessages?.call();
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
// Compass widget - top right (hidden in fullscreen mode)
|
|
if (!_isFullscreen)
|
|
Positioned(
|
|
top: 16,
|
|
right: 16,
|
|
child: GestureDetector(
|
|
onTap: () => _showDetailedCompass(
|
|
context,
|
|
contactsProvider.contactsWithLocation,
|
|
messagesProvider.sarMarkers,
|
|
),
|
|
child: CompassWidget(
|
|
heading: _currentHeading ?? 0,
|
|
hasHeading: _currentHeading != null,
|
|
),
|
|
),
|
|
),
|
|
// Measurement distance overlay
|
|
if (drawingProvider.drawingMode == DrawingMode.measure)
|
|
Positioned(
|
|
top: 16,
|
|
left: 16,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.yellow.shade700.withValues(alpha: 0.95),
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.3),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(
|
|
Icons.straighten,
|
|
color: Colors.white,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
AppLocalizations.of(context)!.measureDistance,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (drawingProvider.measuredDistance != null)
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
AppLocalizations.of(context)!.distanceLabel(_formatDistance(drawingProvider.measuredDistance!)),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
AppLocalizations.of(context)!.longPressToStartNewMeasurement,
|
|
style: const TextStyle(
|
|
color: Colors.white70,
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
else if (drawingProvider.measurementPoint1 != null)
|
|
Text(
|
|
AppLocalizations.of(context)!.longPressForSecondPoint,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 12,
|
|
),
|
|
)
|
|
else
|
|
Text(
|
|
AppLocalizations.of(context)!.longPressToStartMeasurement,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
// Map controls - right side (hidden in fullscreen mode)
|
|
if (!_isFullscreen)
|
|
Positioned(
|
|
bottom: 16,
|
|
right: 16,
|
|
child: Column(
|
|
children: [
|
|
// Drawing toolbar (hidden in simple mode)
|
|
Consumer<AppProvider>(
|
|
builder: (context, appProvider, _) {
|
|
if (appProvider.isSimpleMode) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return const DrawingToolbar();
|
|
},
|
|
),
|
|
// Hide other buttons when in drawing mode
|
|
if (!drawingProvider.isDrawing) ...[
|
|
// 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;
|
|
|
|
// 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),
|
|
// Map Rotation Lock - toggle rotate with heading
|
|
FloatingActionButton.small(
|
|
heroTag: 'rotation_lock',
|
|
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();
|
|
},
|
|
child: Icon(
|
|
Icons.screen_lock_rotation,
|
|
color: _rotateMarkerWithHeading ? Colors.white : null,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
// In simple mode: show ruler FAB directly
|
|
Consumer<AppProvider>(
|
|
builder: (context, appProvider, _) {
|
|
if (!appProvider.isSimpleMode) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
// Show ruler button
|
|
return Column(
|
|
children: [
|
|
FloatingActionButton.small(
|
|
heroTag: 'ruler_tool',
|
|
backgroundColor: drawingProvider.drawingMode == DrawingMode.measure
|
|
? Theme.of(context).colorScheme.primary
|
|
: null,
|
|
onPressed: () {
|
|
if (drawingProvider.drawingMode == DrawingMode.measure) {
|
|
// Exit measurement mode
|
|
drawingProvider.exitDrawingMode();
|
|
} else {
|
|
// Enter measurement mode
|
|
drawingProvider.setDrawingMode(DrawingMode.measure);
|
|
}
|
|
},
|
|
child: Icon(
|
|
Icons.straighten,
|
|
color: drawingProvider.drawingMode == DrawingMode.measure
|
|
? Colors.white
|
|
: null,
|
|
),
|
|
),
|
|
if (!drawingProvider.isDrawing)
|
|
const SizedBox(height: 8),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
// Continue with other buttons when not in drawing mode
|
|
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<AppProvider>().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),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
// Map debug info - bottom left (hidden in fullscreen mode)
|
|
if (_showMapDebugInfo && _isMapReady && !_isFullscreen)
|
|
Positioned(
|
|
bottom: 16,
|
|
left: 16,
|
|
child: MapDebugInfo(mapController: _mapController),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|