diff --git a/lib/main.dart b/lib/main.dart index 15d6e99..45cca58 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,7 +17,6 @@ import 'providers/app_provider.dart'; import 'providers/sensors_provider.dart'; import 'services/voice_codec_service.dart'; import 'services/voice_player_service.dart'; -import 'services/tile_cache_service.dart'; import 'services/notification_service.dart'; import 'services/locale_preferences.dart'; import 'services/update_checker_service.dart'; @@ -248,18 +247,14 @@ class _MeshCoreSarAppState extends State { // Image provider (fragment reassembly + outgoing session cache) ChangeNotifierProvider(create: (_) => ip.ImageProvider()), - // Tile cache service - Provider(create: (_) => TileCacheService()), - // App provider that coordinates everything // VoiceProvider is read via context.read inside create since it's already registered above - ChangeNotifierProxyProvider6< + ChangeNotifierProxyProvider5< ConnectionProvider, ContactsProvider, MessagesProvider, DrawingProvider, ChannelsProvider, - TileCacheService, AppProvider >( create: (context) => AppProvider( @@ -270,7 +265,6 @@ class _MeshCoreSarAppState extends State { channelsProvider: context.read(), voiceProvider: context.read(), imageProvider: context.read(), - tileCacheService: context.read(), ), update: ( @@ -280,7 +274,6 @@ class _MeshCoreSarAppState extends State { messages, drawings, channels, - tileCache, previous, ) => previous ?? @@ -292,7 +285,6 @@ class _MeshCoreSarAppState extends State { channelsProvider: channels, voiceProvider: context.read(), imageProvider: context.read(), - tileCacheService: tileCache, ), ), ], diff --git a/lib/models/map_layer.dart b/lib/models/map_layer.dart index 14733d8..6020ee1 100644 --- a/lib/models/map_layer.dart +++ b/lib/models/map_layer.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import '../l10n/app_localizations.dart'; @@ -10,6 +9,7 @@ enum MapLayerType { googleHybrid, googleRoadmap, googleTerrain, + // Kept for stored preference compatibility after MBTiles removal. vectorMbtiles, wmsBase, } @@ -21,13 +21,6 @@ class MapLayer { final String attribution; final double maxZoom; - // Vector tile specific properties - final bool isVector; - final File? mbtilesFile; - final String? styleUrl; - final String? sourceName; - final bool? isGzipped; - // WMS specific properties final bool isWms; final String? wmsBaseUrl; @@ -43,11 +36,6 @@ class MapLayer { required this.urlTemplate, required this.attribution, required this.maxZoom, - this.isVector = false, - this.mbtilesFile, - this.styleUrl, - this.sourceName, - this.isGzipped, this.isWms = false, this.wmsBaseUrl, this.wmsLayers, @@ -74,7 +62,7 @@ class MapLayer { case MapLayerType.googleTerrain: return localizations.googleTerrain; case MapLayerType.vectorMbtiles: - // For vector tiles, use the name from metadata + // Legacy value kept only for preference migration compatibility. return name; case MapLayerType.wmsBase: // For WMS layers, use the name (will be localized separately) @@ -182,28 +170,4 @@ class MapLayer { static MapLayer fromType(MapLayerType type) { return allLayers.firstWhere((layer) => layer.type == type); } - - /// Create a MapLayer from an MBTiles file - static MapLayer fromMbtilesFile({ - required String name, - required File mbtilesFile, - required String styleUrl, - required String sourceName, - required double maxZoom, - required bool isGzipped, - String? attribution, - }) { - return MapLayer( - type: MapLayerType.vectorMbtiles, - name: name, - urlTemplate: '', // Not used for vector tiles - attribution: attribution ?? 'MBTiles', - maxZoom: maxZoom, - isVector: true, - mbtilesFile: mbtilesFile, - styleUrl: styleUrl, - sourceName: sourceName, - isGzipped: isGzipped, - ); - } } diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 00fe3bb..f736f25 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -11,7 +11,6 @@ import 'voice_provider.dart'; import 'image_provider.dart' as ip; import 'helpers/fragment_ack_wait_registry.dart'; import 'helpers/session_metadata_restore.dart'; -import '../services/tile_cache_service.dart'; import '../services/location_tracking_service.dart'; import '../services/packet_capture_storage_service.dart'; import '../models/contact.dart'; @@ -24,6 +23,7 @@ import '../utils/voice_message_parser.dart'; import '../utils/image_message_parser.dart'; import '../utils/media_swarm_protocol.dart'; import '../utils/message_airtime_estimator.dart'; +import '../utils/fast_gps_packet.dart'; /// Main App Provider - coordinates all other providers class AppProvider with ChangeNotifier { @@ -35,7 +35,6 @@ class AppProvider with ChangeNotifier { final ChannelsProvider channelsProvider; final VoiceProvider voiceProvider; final ip.ImageProvider imageProvider; - final TileCacheService tileCacheService; final LocationTrackingService locationTrackingService = LocationTrackingService(); final PacketCaptureStorageService packetCaptureStorageService = @@ -79,6 +78,7 @@ class AppProvider with ChangeNotifier { final Map> _pendingMediaSwarmFetches = {}; final Map> _pendingMediaSwarmResponses = {}; + bool _fastLocationScreenActive = false; Timer? _packetCaptureFlushTimer; String? _lastPersistedPacketSignature; bool _isPersistingPacketCapture = false; @@ -91,10 +91,8 @@ class AppProvider with ChangeNotifier { required this.channelsProvider, required this.voiceProvider, required this.imageProvider, - required this.tileCacheService, }) { _setupCallbacks(); - _initializeTileCache(); _initializeLocationTracking(); _loadSimpleMode(); _loadMapEnabled(); @@ -436,16 +434,6 @@ class AppProvider with ChangeNotifier { } } - /// Initialize tile cache service - Future _initializeTileCache() async { - try { - await tileCacheService.initialize(); - debugPrint('Tile cache initialized'); - } catch (e) { - debugPrint('Error initializing tile cache: $e'); - } - } - /// Initialize location tracking service Future _initializeLocationTracking() async { try { @@ -473,6 +461,10 @@ class AppProvider with ChangeNotifier { ); }; + locationTrackingService.onFastLocationUpdate = (position, reason) { + unawaited(_sendFastLocationUpdate(position, reason: reason)); + }; + debugPrint('βœ… [AppProvider] Location tracking service initialized'); } catch (e) { debugPrint('❌ [AppProvider] Error initializing location tracking: $e'); @@ -836,6 +828,18 @@ class AppProvider with ChangeNotifier { // Magic 0x69 'i' = image fetch request; 0x56 'V' = voice packet. // Magic 0x49 'I' = image packet. connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { + final fastGpsPacket = FastGpsPacket.tryParseBinary(payload); + if (fastGpsPacket != null) { + final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6); + if (sender != null) { + contactsProvider.updateFastGps( + sender.publicKey.sublist(0, 6), + fastGpsPacket, + ); + } + return; + } + final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload); if (rawProbeRequest != null) { debugPrint( @@ -1382,6 +1386,46 @@ class AppProvider with ChangeNotifier { return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase()); } + void setFastLocationUiActive(bool isActive) { + if (_fastLocationScreenActive == isActive) return; + _fastLocationScreenActive = isActive; + locationTrackingService.setFastLocationActiveUse(isActive); + } + + Future _sendFastLocationUpdate( + dynamic position, { + required String reason, + }) async { + if (!connectionProvider.deviceInfo.isConnected) { + return; + } + + final publicKey = connectionProvider.deviceInfo.publicKey; + if (publicKey == null || publicKey.length < 6) { + return; + } + + final senderKey6 = publicKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + final packet = FastGpsPacket( + senderKey6: senderKey6, + latitude: position.latitude as double, + longitude: position.longitude as double, + timestampSeconds: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + debugPrint( + 'πŸ“ [AppProvider] Sending fast GPS update ($reason): ' + '${position.latitude}, ${position.longitude}', + ); + try { + await connectionProvider.sendRawPrivateMulticast(packet.encodeBinary()); + } catch (e) { + debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e'); + } + } + Contact? _resolveVoiceFetchRequester(VoiceFetchRequest request) { final liveContact = _resolveContactByPrefixHex(request.requesterKey6); if (liveContact != null) { @@ -2294,6 +2338,7 @@ class AppProvider with ChangeNotifier { locationTrackingService.onBroadcastSent = null; locationTrackingService.onError = null; locationTrackingService.onTrackingStateChanged = null; + locationTrackingService.onFastLocationUpdate = null; // Dispose the location tracking service to stop GPS stream and clean up resources locationTrackingService.dispose(); for (final timer in _voiceMissingRetryTimers.values) { diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index d251082..5258eb9 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -1290,6 +1290,19 @@ class ConnectionProvider with ChangeNotifier { ); } + /// Send a raw private zero-hop payload. + /// + /// This wraps the raw custom transport using an empty path to match the + /// firmware's private multicast behavior. + Future sendRawPrivateMulticast(Uint8List payload) async { + if (!_activeService.isConnected) return; + await _activeService.sendRawVoicePacket( + contactPathLen: 0, + contactPath: Uint8List(0), + payload: payload, + ); + } + /// Request telemetry from contact /// /// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39). diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 0813545..f8692e6 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -4,6 +4,7 @@ import '../models/contact.dart'; import '../models/message_contact_location.dart'; import '../services/cayenne_lpp_parser.dart'; import '../services/contact_storage_service.dart'; +import '../utils/fast_gps_packet.dart'; import '../utils/key_comparison.dart'; class PendingAdvert { @@ -464,6 +465,42 @@ class ContactsProvider with ChangeNotifier { } } + void updateFastGps(Uint8List publicKeyPrefix, FastGpsPacket packet) { + final contact = _findContactByPrefix(publicKeyPrefix); + if (contact == null) { + debugPrint( + '⚠️ [ContactsProvider] Fast GPS sender not found: ${packet.senderKey6}', + ); + return; + } + + final updatedTelemetry = _mergeTelemetryForContact( + existingTelemetry: contact.telemetry, + incomingTelemetry: ContactTelemetry( + gpsLocation: LatLng(packet.latitude, packet.longitude), + batteryPercentage: null, + batteryMilliVolts: null, + temperature: null, + timestamp: DateTime.fromMillisecondsSinceEpoch( + packet.timestampSeconds * 1000, + ), + humidity: null, + pressure: null, + extraSensorData: null, + ), + ); + + final updatedContact = contact.copyWith( + telemetry: updatedTelemetry, + lastAdvert: packet.timestampSeconds, + advLat: _coordinateToAdvertMicrodegrees(packet.latitude), + advLon: _coordinateToAdvertMicrodegrees(packet.longitude), + ); + _contacts[contact.publicKeyHex] = updatedContact; + _persistContacts(); + notifyListeners(); + } + bool _isInvalidTelemetryGps(LatLng? location) { if (location == null) return false; final lat = location.latitude; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 1ad96ad..b438e80 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -14,7 +14,6 @@ import 'messages_tab.dart'; import 'contacts_tab.dart'; import 'sensors_tab.dart'; import 'map_tab.dart'; -import 'map_management_screen.dart'; import 'settings_screen.dart'; import 'device_config_screen.dart'; import 'packet_log_screen.dart'; @@ -49,7 +48,8 @@ class HomeScreen extends StatefulWidget { State createState() => _HomeScreenState(); } -class _HomeScreenState extends State with TickerProviderStateMixin { +class _HomeScreenState extends State + with TickerProviderStateMixin, WidgetsBindingObserver { late TabController _tabController; late final AppProvider _appProvider; int _currentIndex = 0; @@ -58,6 +58,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { bool _isMapEnabled = true; bool _isContactsEnabled = true; bool _isSensorsEnabled = false; + AppLifecycleState _lifecycleState = AppLifecycleState.resumed; List<_HomeTab> get _enabledTabs { return [ @@ -79,6 +80,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _appProvider = context.read(); _isMapEnabled = _appProvider.isMapEnabled; _isContactsEnabled = _appProvider.isContactsEnabled; @@ -184,6 +186,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { } void _handleTabActivated(_HomeTab tab) { + _syncFastLocationUiState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -202,6 +205,20 @@ class _HomeScreenState extends State with TickerProviderStateMixin { }); } + void _syncFastLocationUiState() { + final isActiveTab = + _currentTab == _HomeTab.map || _currentTab == _HomeTab.messages; + _appProvider.setFastLocationUiActive( + _lifecycleState == AppLifecycleState.resumed && isActiveTab, + ); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _lifecycleState = state; + _syncFastLocationUiState(); + } + Future _loadRxTxPreference() async { final prefs = await SharedPreferences.getInstance(); if (mounted) { @@ -213,6 +230,8 @@ class _HomeScreenState extends State with TickerProviderStateMixin { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); + _appProvider.setFastLocationUiActive(false); _appProvider.removeListener(_handleAppProviderChanged); _tabController.removeListener(_onTabChanged); _tabController.dispose(); @@ -580,30 +599,6 @@ class _HomeScreenState extends State with TickerProviderStateMixin { PopupMenuButton( icon: const Icon(Icons.more_vert), itemBuilder: (context) => [ - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.map), - const SizedBox(width: 8), - Text(AppLocalizations.of(context)!.mapManagement), - ], - ), - onTap: () { - // Capture context-dependent objects before async gap - final navigator = Navigator.of(context); - final appProvider = context.read(); - Future.delayed(Duration.zero, () { - if (!mounted) return; - navigator.push( - MaterialPageRoute( - builder: (context) => MapManagementScreen( - tileCacheService: appProvider.tileCacheService, - ), - ), - ); - }); - }, - ), PopupMenuItem( child: Row( children: [ @@ -854,10 +849,11 @@ class _HomeScreenState extends State with TickerProviderStateMixin { Text( deviceInfo.selfName ?? AppLocalizations.of(context)!.appTitle, - style: (isTight - ? theme.textTheme.titleSmall - : theme.textTheme.titleMedium) - ?.copyWith(fontWeight: FontWeight.w700), + style: + (isTight + ? theme.textTheme.titleSmall + : theme.textTheme.titleMedium) + ?.copyWith(fontWeight: FontWeight.w700), overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), @@ -927,7 +923,8 @@ class _HomeScreenState extends State with TickerProviderStateMixin { Navigator.push( context, MaterialPageRoute( - builder: (context) => const DeviceConfigScreen(), + builder: (context) => + const DeviceConfigScreen(), ), ); }, diff --git a/lib/screens/map_management_screen.dart b/lib/screens/map_management_screen.dart deleted file mode 100644 index 3acb221..0000000 --- a/lib/screens/map_management_screen.dart +++ /dev/null @@ -1,1323 +0,0 @@ -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:share_plus/share_plus.dart'; -import '../services/tile_cache_service.dart'; -import '../services/validation_service.dart'; -import '../services/mbtiles_service.dart'; -import '../models/map_layer.dart'; -import '../l10n/app_localizations.dart'; - -class MapManagementScreen extends StatefulWidget { - final TileCacheService tileCacheService; - final MapLayer? initialLayer; - final LatLngBounds? initialBounds; - final int? initialZoom; - - const MapManagementScreen({ - super.key, - required this.tileCacheService, - this.initialLayer, - this.initialBounds, - this.initialZoom, - }); - - @override - State createState() => _MapManagementScreenState(); -} - -class _MapManagementScreenState extends State { - bool _isLoading = false; - String? _statusMessage; - Map? _cacheStats; - final MbtilesService _mbtilesService = MbtilesService(); - List _mbtilesFiles = []; - - // Download parameters - late MapLayer _selectedLayer; - late TextEditingController _northController; - late TextEditingController _southController; - late TextEditingController _eastController; - late TextEditingController _westController; - late int _minZoom; - late int _maxZoom; - double _downloadProgress = 0.0; - bool _isDownloading = false; - - @override - void initState() { - super.initState(); - - // Initialize with provided values or defaults - _selectedLayer = widget.initialLayer ?? MapLayer.openStreetMap; - - if (widget.initialBounds != null) { - _northController = TextEditingController( - text: widget.initialBounds!.north.toStringAsFixed(4), - ); - _southController = TextEditingController( - text: widget.initialBounds!.south.toStringAsFixed(4), - ); - _eastController = TextEditingController( - text: widget.initialBounds!.east.toStringAsFixed(4), - ); - _westController = TextEditingController( - text: widget.initialBounds!.west.toStringAsFixed(4), - ); - } else { - _northController = TextEditingController(text: '46.1'); - _southController = TextEditingController(text: '46.0'); - _eastController = TextEditingController(text: '14.6'); - _westController = TextEditingController(text: '14.4'); - } - - // Set zoom levels - if (widget.initialZoom != null) { - _minZoom = (widget.initialZoom! - 2).clamp(1, 19); - _maxZoom = (widget.initialZoom! + 2).clamp(1, 19); - } else { - _minZoom = 10; - _maxZoom = 16; - } - - _loadCacheStats(); - _loadMbtilesFiles(); - } - - @override - void dispose() { - _northController.dispose(); - _southController.dispose(); - _eastController.dispose(); - _westController.dispose(); - super.dispose(); - } - - Future _loadCacheStats() async { - if (!mounted) return; - setState(() => _isLoading = true); - try { - final stats = await widget.tileCacheService.getStoreStats(); - if (!mounted) return; - setState(() { - _cacheStats = stats; - _isLoading = false; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _statusMessage = AppLocalizations.of( - context, - )!.errorLoadingStats(e.toString()); - _isLoading = false; - }); - } - } - - Future _loadMbtilesFiles() async { - if (!mounted) return; - try { - final files = await _mbtilesService.getAllMetadata(); - if (!mounted) return; - setState(() { - _mbtilesFiles = files; - }); - } catch (e) { - debugPrint('Error loading MBTiles files: $e'); - } - } - - Future _importMbtilesFile() async { - try { - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['mbtiles'], - ); - - if (result == null || result.files.isEmpty) return; - - final sourcePath = result.files.first.path; - if (sourcePath == null) return; - - if (!mounted) return; - setState(() => _isLoading = true); - - final importedFile = await _mbtilesService.importMbtilesFile(sourcePath); - - if (!mounted) return; - setState(() => _isLoading = false); - - if (importedFile != null) { - await _loadMbtilesFiles(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context)!.mbtilesImportedSuccessfully, - ), - backgroundColor: Colors.green, - ), - ); - } - } else { - _showError(AppLocalizations.of(context)!.failedToImportMbtiles); - } - } catch (e) { - if (!mounted) return; - setState(() => _isLoading = false); - _showError('${AppLocalizations.of(context)!.failedToImportMbtiles}: $e'); - } - } - - Future _deleteMbtilesFile(MbtilesMetadata metadata) async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(AppLocalizations.of(context)!.deleteMbtilesConfirmTitle), - content: Text( - AppLocalizations.of( - context, - )!.deleteMbtilesConfirmMessage(metadata.name), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(AppLocalizations.of(context)!.cancel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: Text(AppLocalizations.of(context)!.delete), - ), - ], - ), - ); - - if (confirmed != true) return; - - if (!mounted) return; - setState(() => _isLoading = true); - - try { - final success = await _mbtilesService.deleteMbtilesFile(metadata.file); - if (!mounted) return; - setState(() => _isLoading = false); - - if (success) { - await _loadMbtilesFiles(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context)!.mbtilesDeletedSuccessfully, - ), - backgroundColor: Colors.green, - ), - ); - } - } else { - _showError(AppLocalizations.of(context)!.failedToDeleteMbtiles); - } - } catch (e) { - if (!mounted) return; - setState(() => _isLoading = false); - _showError('${AppLocalizations.of(context)!.failedToDeleteMbtiles}: $e'); - } - } - - Future _downloadRegion() async { - final validator = ValidationService(); - - try { - // Parse coordinates - final north = double.tryParse(_northController.text); - final south = double.tryParse(_southController.text); - final east = double.tryParse(_eastController.text); - final west = double.tryParse(_westController.text); - - // Validate bounds - final boundsResult = validator.validateBounds( - north: north, - south: south, - east: east, - west: west, - ); - - if (!boundsResult.isValid) { - _showError(boundsResult.errorMessage!); - return; - } - - // Validate zoom levels - final minZoomResult = validator.validateZoomLevel(_minZoom); - if (!minZoomResult.isValid) { - _showError( - AppLocalizations.of( - context, - )!.minZoomError(minZoomResult.errorMessage!), - ); - return; - } - - final maxZoomResult = validator.validateZoomLevel(_maxZoom); - if (!maxZoomResult.isValid) { - _showError( - AppLocalizations.of( - context, - )!.maxZoomError(maxZoomResult.errorMessage!), - ); - return; - } - - if (_minZoom > _maxZoom) { - _showError(AppLocalizations.of(context)!.minZoomGreaterThanMax); - return; - } - - final bounds = LatLngBounds(LatLng(south!, west!), LatLng(north!, east!)); - - if (!mounted) return; - setState(() { - _isDownloading = true; - _downloadProgress = 0.0; - _statusMessage = AppLocalizations.of(context)!.startingDownload; - }); - - await widget.tileCacheService.downloadRegion( - layer: _selectedLayer, - bounds: bounds, - minZoom: _minZoom, - maxZoom: _maxZoom, - onProgress: (progress) { - debugPrint('UI received progress update: $progress%'); - if (!mounted) return; - setState(() { - _downloadProgress = progress; - _statusMessage = AppLocalizations.of(context)!.downloadingMapTiles; - }); - }, - ); - - if (!mounted) return; - setState(() { - _isDownloading = false; - _statusMessage = AppLocalizations.of( - context, - )!.downloadCompletedSuccessfully; - }); - - await _loadCacheStats(); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.mapDownloadCompleted), - backgroundColor: Colors.green, - ), - ); - } - } catch (e) { - if (!mounted) return; - setState(() { - _isDownloading = false; - _statusMessage = AppLocalizations.of( - context, - )!.downloadFailed(e.toString()); - }); - _showError(AppLocalizations.of(context)!.downloadFailed(e.toString())); - } - } - - Future _cancelDownload() async { - try { - if (!mounted) return; - setState( - () => _statusMessage = AppLocalizations.of(context)!.cancellingDownload, - ); - - await widget.tileCacheService.cancelDownload(); - - if (!mounted) return; - setState(() { - _isDownloading = false; - _statusMessage = AppLocalizations.of(context)!.downloadCancelled; - }); - - await _loadCacheStats(); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.cancel), - backgroundColor: Colors.orange, - ), - ); - } - } catch (e) { - if (!mounted) return; - setState(() { - _isDownloading = false; - _statusMessage = AppLocalizations.of( - context, - )!.cancelFailed(e.toString()); - }); - _showError(AppLocalizations.of(context)!.cancelFailed(e.toString())); - } - } - - Future _clearCache() async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(AppLocalizations.of(context)!.clearMapsConfirmTitle), - content: Text(AppLocalizations.of(context)!.clearMapsConfirmMessage), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(AppLocalizations.of(context)!.cancel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: Text(AppLocalizations.of(context)!.clear), - ), - ], - ), - ); - - if (confirmed != true) return; - - if (!mounted) return; - setState(() => _isLoading = true); - try { - await widget.tileCacheService.clearCache(); - if (!mounted) return; - setState(() => _isLoading = false); - await _loadCacheStats(); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context)!.cacheClearedSuccessfully, - ), - backgroundColor: Colors.green, - ), - ); - } - } catch (e) { - if (!mounted) return; - setState(() => _isLoading = false); - _showError(AppLocalizations.of(context)!.clearCacheFailed(e.toString())); - } - } - - Future _exportTiles() async { - try { - // Check if there are tiles to export - final tileCount = await widget.tileCacheService.getCachedTileCount(); - if (tileCount == 0) { - if (!mounted) return; - _showError(AppLocalizations.of(context)!.noTilesToExport); - return; - } - - if (!mounted) return; - setState(() { - _isLoading = true; - _statusMessage = AppLocalizations.of(context)!.exportingTiles; - }); - - // Export to temporary directory first (works on all platforms) - final tempDir = await getTemporaryDirectory(); - final fileName = - 'meshcore_tiles_${DateTime.now().millisecondsSinceEpoch}.fmtc'; - final tempFilePath = '${tempDir.path}/$fileName'; - - final exportedCount = await widget.tileCacheService.exportStore( - tempFilePath, - ); - - if (!mounted) return; - setState(() { - _isLoading = false; - _statusMessage = null; - }); - - // Share the file using share_plus (works on all platforms) - final file = File(tempFilePath); - if (await file.exists()) { - if (!mounted) return; - // Get the button position for iPad popover - final box = context.findRenderObject() as RenderBox?; - final sharePositionOrigin = box != null - ? box.localToGlobal(Offset.zero) & box.size - : null; - - final result = await SharePlus.instance.share( - ShareParams( - files: [XFile(tempFilePath)], - subject: 'MeshCore Map Tiles Export', - text: 'Exported $exportedCount map tiles', - sharePositionOrigin: sharePositionOrigin, - ), - ); - - if (mounted) { - if (result.status == ShareResultStatus.success) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context)!.exportSuccess(exportedCount), - ), - backgroundColor: Colors.green, - ), - ); - } - } - } else { - _showError('Export file not found'); - } - } catch (e) { - if (!mounted) return; - setState(() { - _isLoading = false; - _statusMessage = null; - }); - _showError(AppLocalizations.of(context)!.exportFailed(e.toString())); - } - } - - Future _importTiles() async { - try { - // Use file picker to select import file - final result = await FilePicker.platform.pickFiles( - dialogTitle: AppLocalizations.of(context)!.selectImportFile, - type: FileType.custom, - allowedExtensions: ['fmtc'], - ); - - if (result == null || result.files.isEmpty) return; - - final filePath = result.files.first.path; - if (filePath == null) return; - - if (!mounted) return; - setState(() { - _isLoading = true; - _statusMessage = AppLocalizations.of(context)!.importingTiles; - }); - - // Optional: Preview stores in archive before importing - try { - final stores = await widget.tileCacheService.listArchiveStores( - filePath, - ); - debugPrint('Archive contains stores: $stores'); - } catch (e) { - debugPrint('Could not list stores: $e'); - } - - final importResult = await widget.tileCacheService.importStore(filePath); - - if (!mounted) return; - setState(() { - _isLoading = false; - _statusMessage = null; - }); - - await _loadCacheStats(); // Refresh stats after import - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of( - context, - )!.importSuccess(importResult['successfulStores'] as int), - ), - backgroundColor: Colors.green, - ), - ); - } - } catch (e) { - if (!mounted) return; - setState(() { - _isLoading = false; - _statusMessage = null; - }); - _showError(AppLocalizations.of(context)!.importFailed(e.toString())); - } - } - - void _showError(String message) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - backgroundColor: Colors.red, - duration: const Duration(seconds: 4), - ), - ); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text(AppLocalizations.of(context)!.mapManagement)), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Cache Statistics - _buildStatisticsCard(), - const SizedBox(height: 16), - - // Offline Vector Maps (MBTiles) - _buildMbtilesCard(), - const SizedBox(height: 16), - - // Import/Export Cached Tiles - _buildImportExportCard(), - const SizedBox(height: 16), - - // Download Region - _buildDownloadCard(), - const SizedBox(height: 16), - - // Clear Cache - _buildActionsCard(), - ], - ), - ), - ); - } - - Widget _buildStatisticsCard() { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - AppLocalizations.of(context)!.cacheStatistics, - style: Theme.of(context).textTheme.titleLarge, - ), - IconButton( - icon: const Icon(Icons.refresh), - onPressed: _loadCacheStats, - ), - ], - ), - const SizedBox(height: 16), - if (_cacheStats != null) ...[ - _buildStatRow( - AppLocalizations.of(context)!.totalTiles, - '${_cacheStats!['tileCount'] ?? 0}', - Icons.grid_on, - ), - _buildStatRow( - AppLocalizations.of(context)!.cacheSize, - '${(_cacheStats!['sizeMB'] ?? 0.0).toStringAsFixed(2)} MB', - Icons.storage, - ), - _buildStatRow( - AppLocalizations.of(context)!.storeName, - _cacheStats!['storeName'] ?? 'Unknown', - Icons.folder, - ), - ] else - Text(AppLocalizations.of(context)!.noCacheStatistics), - ], - ), - ), - ); - } - - Widget _buildStatRow(String label, String value, IconData icon) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Icon(icon, size: 20, color: Colors.grey[600]), - const SizedBox(width: 12), - Expanded( - child: Text( - label, - style: const TextStyle(fontWeight: FontWeight.w500), - ), - ), - Text(value, style: TextStyle(color: Colors.grey[600])), - ], - ), - ); - } - - Widget _buildMbtilesCard() { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - AppLocalizations.of(context)!.offlineVectorMaps, - style: Theme.of(context).textTheme.titleLarge, - overflow: TextOverflow.ellipsis, - ), - ), - IconButton( - icon: const Icon(Icons.refresh), - onPressed: _loadMbtilesFiles, - ), - ], - ), - const SizedBox(height: 8), - Text( - AppLocalizations.of(context)!.offlineVectorMapsDescription, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - const SizedBox(height: 16), - - // List of MBTiles files - if (_mbtilesFiles.isEmpty) - Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Icon( - Icons.map_outlined, - size: 48, - color: Colors.grey[400], - ), - const SizedBox(height: 8), - Text( - AppLocalizations.of(context)!.noMbtilesFiles, - style: TextStyle(color: Colors.grey[600]), - ), - ], - ), - ), - ) - else - ..._mbtilesFiles.map( - (metadata) => Card( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - margin: const EdgeInsets.only(bottom: 8), - child: ExpansionTile( - leading: Icon( - metadata.isVector ? Icons.layers : Icons.image, - color: metadata.isVector ? Colors.blue : Colors.orange, - ), - title: Text( - metadata.name, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text( - '${metadata.fileSizeFormatted} β€’ ${metadata.format?.toUpperCase() ?? "Unknown"}', - ), - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (metadata.description != null) ...[ - Text( - metadata.description!, - style: TextStyle(color: Colors.grey[700]), - ), - const SizedBox(height: 12), - ], - _buildInfoRow( - AppLocalizations.of(context)!.zoomLevels, - '${metadata.minZoom ?? "?"} - ${metadata.maxZoom ?? "?"}', - ), - if (metadata.bounds != null) - _buildInfoRow( - AppLocalizations.of(context)!.bounds, - metadata.bounds!, - ), - if (metadata.isVector) ...[ - _buildInfoRow( - AppLocalizations.of(context)!.type, - AppLocalizations.of(context)!.vectorTiles, - ), - _buildInfoRow( - AppLocalizations.of(context)!.schema, - _mbtilesService.getVectorSchema(metadata) ?? - AppLocalizations.of(context)!.unknown, - ), - ], - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton.icon( - onPressed: () => _deleteMbtilesFile(metadata), - icon: const Icon( - Icons.delete, - color: Colors.red, - ), - label: Text( - AppLocalizations.of(context)!.delete, - style: const TextStyle(color: Colors.red), - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ), - - const SizedBox(height: 16), - - // Import button - ElevatedButton.icon( - onPressed: _importMbtilesFile, - icon: const Icon(Icons.file_upload), - label: Text(AppLocalizations.of(context)!.importMbtiles), - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(48), - ), - ), - const SizedBox(height: 8), - Text( - AppLocalizations.of(context)!.importMbtilesNote, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - ], - ), - ), - ); - } - - Widget _buildImportExportCard() { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context)!.importExportCachedTiles, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - AppLocalizations.of(context)!.importExportDescription, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - const SizedBox(height: 16), - - // Export Section - ElevatedButton.icon( - onPressed: _isDownloading || _isLoading ? null : _exportTiles, - icon: const Icon(Icons.file_upload), - label: Text(AppLocalizations.of(context)!.exportTilesToFile), - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(48), - ), - ), - const SizedBox(height: 8), - Text( - AppLocalizations.of(context)!.exportNote, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - const SizedBox(height: 16), - - // Import Section - ElevatedButton.icon( - onPressed: _isDownloading || _isLoading ? null : _importTiles, - icon: const Icon(Icons.file_download), - label: Text(AppLocalizations.of(context)!.importTilesFromFile), - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(48), - ), - ), - const SizedBox(height: 8), - Text( - AppLocalizations.of(context)!.importNote, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - ], - ), - ), - ); - } - - Widget _buildInfoRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 100, - child: Text( - label, - style: TextStyle( - fontWeight: FontWeight.w500, - color: Colors.grey[700], - ), - ), - ), - Expanded( - child: Text(value, style: TextStyle(color: Colors.grey[800])), - ), - ], - ), - ); - } - - /// Convert zoom level to user-friendly description - String _getZoomDescription(int zoom) { - if (zoom <= 5) { - return 'Continental view (very low detail)'; - } else if (zoom <= 8) { - return 'Country view (low detail)'; - } else if (zoom <= 10) { - return 'Regional view (basic detail)'; - } else if (zoom <= 12) { - return 'City view (moderate detail)'; - } else if (zoom <= 15) { - return 'Neighborhood view (good detail)'; - } else if (zoom <= 17) { - return 'Street view (high detail)'; - } else { - return 'Building view (very high detail)'; - } - } - - Widget _buildDownloadCard() { - // Calculate current bounds for preview - LatLngBounds? previewBounds; - try { - final north = double.tryParse(_northController.text); - final south = double.tryParse(_southController.text); - final east = double.tryParse(_eastController.text); - final west = double.tryParse(_westController.text); - - if (north != null && south != null && east != null && west != null) { - previewBounds = LatLngBounds( - LatLng(south, west), - LatLng(north, east), - ); - } - } catch (e) { - // Invalid bounds, preview will be null - } - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context)!.downloadRegion, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 16), - - // Preview map (if bounds are valid) - if (previewBounds != null) ...[ - Container( - height: 200, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey[300]!), - borderRadius: BorderRadius.circular(8), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Stack( - children: [ - FlutterMap( - options: MapOptions( - initialCenter: previewBounds.center, - initialZoom: 12.0, - minZoom: 1, - maxZoom: 19, - interactionOptions: const InteractionOptions( - flags: InteractiveFlag.none, // Static preview - ), - onMapReady: () { - // Fit bounds after map is ready would require MapController - // For now, center on bounds center - }, - ), - children: [ - TileLayer( - urlTemplate: _selectedLayer.urlTemplate, - userAgentPackageName: 'com.meshcore.sar', - ), - // Blue rectangle showing download area - PolygonLayer( - polygons: [ - Polygon( - points: [ - LatLng(previewBounds.north, previewBounds.west), - LatLng(previewBounds.north, previewBounds.east), - LatLng(previewBounds.south, previewBounds.east), - LatLng(previewBounds.south, previewBounds.west), - ], - color: Colors.blue.withValues(alpha: 0.2), - borderColor: Colors.blue, - borderStrokeWidth: 3.0, - ), - ], - ), - ], - ), - // Label overlay - Positioned( - top: 8, - left: 8, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - 'Download Area Preview', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.white, - ), - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 16), - ], - - // Map Layer Selection - DropdownButtonFormField( - initialValue: _selectedLayer, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.mapLayer, - border: const OutlineInputBorder(), - ), - items: MapLayer.allLayers.map((layer) { - return DropdownMenuItem( - value: layer, - child: Text(layer.getLocalizedName(context)), - ); - }).toList(), - onChanged: _isDownloading - ? null - : (layer) { - if (layer != null) { - setState(() => _selectedLayer = layer); - } - }, - ), - const SizedBox(height: 16), - - // Coordinates - Text( - AppLocalizations.of(context)!.regionBounds, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextField( - controller: _northController, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.north, - border: const OutlineInputBorder(), - hintText: '46.1', - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - enabled: !_isDownloading, - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: _southController, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.south, - border: const OutlineInputBorder(), - hintText: '46.0', - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - enabled: !_isDownloading, - ), - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextField( - controller: _eastController, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.east, - border: const OutlineInputBorder(), - hintText: '14.6', - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - enabled: !_isDownloading, - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: _westController, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.west, - border: const OutlineInputBorder(), - hintText: '14.4', - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - enabled: !_isDownloading, - ), - ), - ], - ), - const SizedBox(height: 16), - - // Zoom Levels - Text( - AppLocalizations.of(context)!.zoomLevels, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context)!.minZoom(_minZoom), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - Text( - _getZoomDescription(_minZoom), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey[600], - ), - ), - Slider( - value: _minZoom.toDouble(), - min: 1, - max: 19, - divisions: 18, - label: '$_minZoom - ${_getZoomDescription(_minZoom)}', - onChanged: _isDownloading - ? null - : (value) { - setState(() { - _minZoom = value.toInt(); - if (_minZoom > _maxZoom) { - _maxZoom = _minZoom; - } - }); - }, - ), - ], - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context)!.maxZoom(_maxZoom), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - Text( - _getZoomDescription(_maxZoom), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey[600], - ), - ), - Slider( - value: _maxZoom.toDouble(), - min: 1, - max: 19, - divisions: 18, - label: '$_maxZoom - ${_getZoomDescription(_maxZoom)}', - onChanged: _isDownloading - ? null - : (value) { - setState(() { - _maxZoom = value.toInt(); - if (_maxZoom < _minZoom) { - _minZoom = _maxZoom; - } - }); - }, - ), - ], - ), - ), - ], - ), - - // Download Progress - if (_isDownloading) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.3), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - _statusMessage ?? - AppLocalizations.of(context)!.downloadingDots, - style: TextStyle( - fontWeight: FontWeight.w500, - color: Theme.of( - context, - ).colorScheme.onPrimaryContainer, - ), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - Text( - '${_downloadProgress.toStringAsFixed(1)}%', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: Theme.of( - context, - ).colorScheme.onPrimaryContainer, - ), - ), - ], - ), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: _downloadProgress / 100, - minHeight: 8, - backgroundColor: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.2), - valueColor: AlwaysStoppedAnimation( - Theme.of(context).colorScheme.primary, - ), - ), - ), - ], - ), - ), - ], - - const SizedBox(height: 16), - - // Download/Cancel Button - if (_isDownloading) - ElevatedButton.icon( - onPressed: _cancelDownload, - icon: const Icon(Icons.cancel), - label: Text(AppLocalizations.of(context)!.cancelDownload), - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(48), - backgroundColor: Colors.red, - foregroundColor: Colors.white, - ), - ) - else - ElevatedButton.icon( - onPressed: _downloadRegion, - icon: const Icon(Icons.download), - label: Text(AppLocalizations.of(context)!.downloadRegionButton), - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(48), - ), - ), - - const SizedBox(height: 8), - Text( - AppLocalizations.of(context)!.downloadNote, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - ], - ), - ), - ); - } - - Widget _buildActionsCard() { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context)!.cacheManagement, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 16), - - // Clear Cache Button - OutlinedButton.icon( - onPressed: _isDownloading ? null : _clearCache, - icon: const Icon(Icons.delete_forever), - label: Text(AppLocalizations.of(context)!.clearAllMaps), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.red, - minimumSize: const Size.fromHeight(48), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index 992b747..3bba388 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -1,5 +1,4 @@ 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; @@ -9,9 +8,6 @@ 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'; @@ -23,11 +19,9 @@ 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'; @@ -37,11 +31,9 @@ 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 '../utils/key_comparison.dart'; import '../l10n/app_localizations.dart'; -import 'map_management_screen.dart'; class MapTab extends StatefulWidget { final Function(bool)? onFullscreenChanged; @@ -59,11 +51,15 @@ class MapTab extends StatefulWidget { class _MapTabState extends State with AutomaticKeepAliveClientMixin { final MapController _mapController = MapController(); - late final TileCacheService _tileCache; + static final TileProvider _tileProvider = NetworkTileProvider( + cachingProvider: BuiltInMapCachingProvider.getOrCreateInstance( + maxCacheSize: 10_000_000_000, + overrideFreshAge: const Duration(days: 365), + ), + ); // 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 @@ -78,9 +74,6 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { bool _isDisposing = false; // Flag to prevent updates during disposal MapProvider? _mapProvider; - // MBTiles layers - List _mbtilesLayers = []; - // Store original location callback to restore in dispose void Function(Position)? _originalLocationCallback; @@ -88,10 +81,6 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { 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; @@ -117,13 +106,11 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { @override void initState() { super.initState(); - _tileCache = context.read(); // Initialize Slovenian WMS layers with CRS _slovenianAerialLayer = MapLayer.getSlovenianAerial2024(slovenianCrs); _dtk25Layer = MapLayer.getDTK25(slovenianCrs); _loadSettings(); - _loadMbtilesLayers(); - _initializeTileCache(); + _markMapReadyWhenMounted(); _setupLocationCallbacks(); _startCompassTracking(); @@ -242,36 +229,6 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }); } - /// Load MBTiles layers from file system - Future _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'); - } - } - Future _loadSettings() async { final prefs = await SharedPreferences.getInstance(); if (mounted) { @@ -282,8 +239,6 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { // 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; @@ -304,17 +259,11 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { _savedMapZoom = lastZoom; } - // Restore last used map layer (by type and name for MBTiles) + // Restore last used map layer. 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; + if (layerType == MapLayerType.vectorMbtiles) { + _currentLayer = MapLayer.openStreetMap; } else if (layerType == MapLayerType.wmsBase) { // Use Slovenian aerial layer if that's what was saved _currentLayer = _slovenianAerialLayer; @@ -347,11 +296,9 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { _backgroundTrackingEnabled, ); - // Save layer type and name (for MBTiles layers) + // Save layer type. await prefs.setInt('map_last_layer_type', _currentLayer.type.index); - if (_currentLayer.type == MapLayerType.vectorMbtiles) { - await prefs.setString('map_last_layer_name', _currentLayer.name); - } + await prefs.remove('map_last_layer_name'); } Future _saveMapPosition() async { @@ -384,49 +331,17 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } } - Future _initializeTileCache() async { - try { - await _tileCache.initialize(); - if (mounted) { + void _markMapReadyWhenMounted() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + Future.delayed(const Duration(milliseconds: 100), () { + if (!mounted) return; setState(() { - _isInitialized = true; + _isMapReady = 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'); - } - }); - } - }); - } - } + debugPrint('Map is now ready for controller operations'); + }); + }); } @override @@ -483,45 +398,6 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ); } - /// Load vector tile theme from URL - Future _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; - 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, @@ -544,14 +420,6 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ), ), ), - IconButton( - icon: const Icon(Icons.download), - tooltip: AppLocalizations.of(context)!.downloadVisibleArea, - onPressed: () { - Navigator.pop(context); - _navigateToDownload(context); - }, - ), ], ), ), @@ -649,65 +517,6 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }, ), ], - // 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 { - // 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(); - } - }, - ), - ), - ], // 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') && @@ -899,21 +708,6 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ); } - 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.enterDownloadAreaMode(bounds); - } catch (e) { - debugPrint('Error accessing map camera: $e'); - } - } - void _showOptionsMenu(BuildContext context) { showModalBottomSheet( context: context, @@ -1412,8 +1206,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { return Stack( children: [ // Map widget - _isInitialized - ? Listener( + Listener( onPointerMove: (PointerMoveEvent event) { // Track pointer movement for mobile drag (onPointerHover doesn't work on mobile) if (_isDraggingPin) { @@ -1566,17 +1359,8 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }, ), 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 && + // Render raster or WMS tile layer based on layer type + if (_currentLayer.isWms && _currentLayer.wmsBaseUrl != null && _currentLayer.crs != null) // WMS Base Layer (e.g., Slovenian Aerial Imagery) @@ -1591,9 +1375,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { crs: _currentLayer.crs!, ), // Use cached tile provider for offline support - tileProvider: _tileCache.getTileProviderForWms( - _currentLayer, - ), + tileProvider: _tileProvider, userAgentPackageName: 'com.meshcore.sar', maxZoom: _currentLayer.maxZoom, errorTileCallback: (tile, error, stackTrace) { @@ -1602,13 +1384,10 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ); }, ) - else if (!_currentLayer.isVector && - !_currentLayer.isWms) + else if (!_currentLayer.isWms) flutter_map.TileLayer( urlTemplate: _currentLayer.urlTemplate, - tileProvider: _tileCache.getTileProvider( - _currentLayer, - ), + tileProvider: _tileProvider, userAgentPackageName: 'com.meshcore.sar', maxZoom: _currentLayer.maxZoom, ), @@ -1632,23 +1411,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { 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, - ), - ), + tileProvider: _tileProvider, userAgentPackageName: 'com.meshcore.sar', maxZoom: 19, errorTileCallback: (tile, error, stackTrace) { @@ -1680,23 +1443,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { 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, - ), - ), + tileProvider: _tileProvider, userAgentPackageName: 'com.meshcore.sar', maxZoom: 19, errorTileCallback: (tile, error, stackTrace) { @@ -1728,23 +1475,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { 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, - ), - ), + tileProvider: _tileProvider, userAgentPackageName: 'com.meshcore.sar', maxZoom: 19, errorTileCallback: (tile, error, stackTrace) { @@ -1773,23 +1504,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { 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, - ), - ), + tileProvider: _tileProvider, userAgentPackageName: 'com.meshcore.sar', maxZoom: 19, errorTileCallback: (tile, error, stackTrace) { @@ -1818,23 +1533,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { 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, - ), - ), + tileProvider: _tileProvider, userAgentPackageName: 'com.meshcore.sar', maxZoom: 19, errorTileCallback: (tile, error, stackTrace) { @@ -1863,23 +1562,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { 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, - ), - ), + tileProvider: _tileProvider, userAgentPackageName: 'com.meshcore.sar', maxZoom: 19, errorTileCallback: (tile, error, stackTrace) { @@ -1906,23 +1589,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { 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, - ), - ), + tileProvider: _tileProvider, userAgentPackageName: 'com.meshcore.sar', maxZoom: 19, errorTileCallback: (tile, error, stackTrace) { @@ -1951,23 +1618,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { 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, - ), - ), + tileProvider: _tileProvider, userAgentPackageName: 'com.meshcore.sar', maxZoom: 19, errorTileCallback: (tile, error, stackTrace) { @@ -1994,23 +1645,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { 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, - ), - ), + tileProvider: _tileProvider, userAgentPackageName: 'com.meshcore.sar', maxZoom: 19, errorTileCallback: (tile, error, stackTrace) { @@ -2039,23 +1674,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { 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, - ), - ), + tileProvider: _tileProvider, userAgentPackageName: 'com.meshcore.sar', maxZoom: 19, errorTileCallback: (tile, error, stackTrace) { @@ -2083,23 +1702,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { 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, - ), - ), + tileProvider: _tileProvider, userAgentPackageName: 'com.meshcore.sar', maxZoom: 19, errorTileCallback: (tile, error, stackTrace) { @@ -2474,114 +2077,9 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } }, ), - // 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; - - 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( - 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( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 2c5f374..fc13e87 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -67,6 +67,9 @@ class _SettingsScreenState extends State { Uint8List? _previewCompressedBytes; bool _isPreviewLoading = false; bool _showCurrentImagePreview = true; + bool _fastLocationUpdatesEnabled = false; + double _fastLocationMovementThresholdMeters = 10.0; + int _fastLocationActiveCadenceSeconds = 10; final ImagePicker _imagePicker = ImagePicker(); final LocationTrackingService _locationService = LocationTrackingService(); @@ -81,6 +84,7 @@ class _SettingsScreenState extends State { _loadVoiceBitratePreference(); _loadRouteHashSizePreference(); _loadImagePreferences(); + _loadFastLocationSettings(); } @override @@ -169,6 +173,108 @@ class _SettingsScreenState extends State { await _refreshImageModePreview(); } + Future _loadFastLocationSettings() async { + await _locationService.loadSettings(); + if (!mounted) return; + setState(() { + _fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled; + _fastLocationMovementThresholdMeters = + _locationService.fastLocationMovementThresholdMeters; + _fastLocationActiveCadenceSeconds = + _locationService.fastLocationActiveCadenceSeconds; + }); + } + + Future _setFastLocationUpdatesEnabled(bool enabled) async { + await _locationService.setFastLocationUpdatesEnabled(enabled); + if (!mounted) return; + setState(() { + _fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled; + }); + } + + Future _editFastLocationMovementThreshold() async { + final controller = TextEditingController( + text: _fastLocationMovementThresholdMeters.toStringAsFixed(0), + ); + final value = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Fast GPS movement threshold'), + content: TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Meters', + helperText: 'Valid range: 1 to 1000 meters', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + onPressed: () { + final parsed = double.tryParse(controller.text.trim()); + if (parsed == null) return; + Navigator.pop(context, parsed.clamp(1.0, 1000.0)); + }, + child: const Text('Save'), + ), + ], + ), + ); + if (value == null) return; + await _locationService.updateFastLocationMovementThreshold(value); + if (!mounted) return; + setState(() { + _fastLocationMovementThresholdMeters = + _locationService.fastLocationMovementThresholdMeters; + }); + } + + Future _editFastLocationActiveCadence() async { + final controller = TextEditingController( + text: _fastLocationActiveCadenceSeconds.toString(), + ); + final value = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Fast GPS active-use interval'), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Seconds', + helperText: 'Valid range: 5 to 60 seconds', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + onPressed: () { + final parsed = int.tryParse(controller.text.trim()); + if (parsed == null) return; + Navigator.pop(context, parsed.clamp(5, 60)); + }, + child: const Text('Save'), + ), + ], + ), + ); + if (value == null) return; + await _locationService.updateFastLocationActiveCadenceSeconds(value); + if (!mounted) return; + setState(() { + _fastLocationActiveCadenceSeconds = + _locationService.fastLocationActiveCadenceSeconds; + }); + } + Future _saveImageMaxSize(int size) async { await ImagePreferences.setMaxSize(size); if (!mounted) return; @@ -1018,6 +1124,31 @@ class _SettingsScreenState extends State { _buildSectionHeader(AppLocalizations.of(context)!.permissionsSection), _buildSettingsCard([ + SwitchListTile( + secondary: const Icon(Icons.gps_fixed), + title: const Text('Fast private GPS updates'), + subtitle: const Text( + 'Use private zero-hop updates while moving significantly or while actively using map/messages.', + ), + value: _fastLocationUpdatesEnabled, + onChanged: _setFastLocationUpdatesEnabled, + ), + ListTile( + leading: const Icon(Icons.straighten), + title: const Text('Movement threshold'), + subtitle: Text( + '${_fastLocationMovementThresholdMeters.toStringAsFixed(0)} m', + ), + trailing: const Icon(Icons.chevron_right), + onTap: _editFastLocationMovementThreshold, + ), + ListTile( + leading: const Icon(Icons.timer), + title: const Text('Active-use update interval'), + subtitle: Text('$_fastLocationActiveCadenceSeconds s'), + trailing: const Icon(Icons.chevron_right), + onTap: _editFastLocationActiveCadence, + ), ListTile( leading: const Icon(Icons.location_on), title: Text(AppLocalizations.of(context)!.locationPermission), diff --git a/lib/services/location_tracking_service.dart b/lib/services/location_tracking_service.dart index a836afa..c70c339 100644 --- a/lib/services/location_tracking_service.dart +++ b/lib/services/location_tracking_service.dart @@ -41,6 +41,12 @@ class LocationTrackingService { static const String _prefKeyGpsUpdateDistance = 'map_gps_update_distance'; static const String _prefKeyLastLat = 'background_last_lat'; static const String _prefKeyLastLon = 'background_last_lon'; + static const String _prefKeyFastLocationEnabled = + 'fast_location_updates_enabled'; + static const String _prefKeyFastMovementThreshold = + 'fast_location_movement_threshold_meters'; + static const String _prefKeyFastActiveCadence = + 'fast_location_active_cadence_seconds'; // ============================================================================ // Configuration Properties @@ -58,6 +64,15 @@ class LocationTrackingService { /// GPS update distance filter for position stream double gpsUpdateDistance = 10.0; + /// Whether private fast GPS updates are enabled + bool fastLocationUpdatesEnabled = false; + + /// Distance threshold for fast GPS updates + double fastLocationMovementThresholdMeters = 10.0; + + /// Cadence for active-use fast GPS updates + int fastLocationActiveCadenceSeconds = 10; + // ============================================================================ // State Properties // ============================================================================ @@ -84,6 +99,11 @@ class LocationTrackingService { /// Position stream subscription StreamSubscription? _positionSubscription; + Timer? _fastLocationTimer; + bool _isFastLocationActiveUse = false; + DateTime? _lastFastLocationSentAt; + Position? _lastFastLocationSentPosition; + // ============================================================================ // Callback Properties // ============================================================================ @@ -100,6 +120,9 @@ class LocationTrackingService { /// Called when tracking state changes void Function(bool isTracking)? onTrackingStateChanged; + /// Called when a fast private GPS update should be sent + void Function(Position position, String reason)? onFastLocationUpdate; + // ============================================================================ // Initialization // ============================================================================ @@ -178,7 +201,9 @@ class LocationTrackingService { for (int attempt = 0; attempt <= retryCount; attempt++) { try { if (attempt > 0) { - debugPrint('πŸ”„ [LocationTracking] Retry attempt $attempt/$retryCount'); + debugPrint( + 'πŸ”„ [LocationTracking] Retry attempt $attempt/$retryCount', + ); // Exponential backoff: wait 2^attempt seconds before retry await Future.delayed(Duration(seconds: 1 << attempt)); } @@ -192,21 +217,29 @@ class LocationTrackingService { currentPosition = position; if (attempt > 0) { - debugPrint('βœ… [LocationTracking] Position acquired after $attempt retries'); + debugPrint( + 'βœ… [LocationTracking] Position acquired after $attempt retries', + ); } return position; } catch (e) { final isLastAttempt = attempt == retryCount; if (isLastAttempt) { - debugPrint('❌ [LocationTracking] Failed to get position after $retryCount retries: $e'); + debugPrint( + '❌ [LocationTracking] Failed to get position after $retryCount retries: $e', + ); // Only call error callback on final failure, and make it user-friendly if (e.toString().contains('TimeoutException')) { - onError?.call('GPS signal weak. Position stream will continue trying...'); + onError?.call( + 'GPS signal weak. Position stream will continue trying...', + ); } else { onError?.call('Failed to get GPS position. Check device settings.'); } } else { - debugPrint('⚠️ [LocationTracking] Position attempt $attempt failed: $e'); + debugPrint( + '⚠️ [LocationTracking] Position attempt $attempt failed: $e', + ); } if (isLastAttempt) { @@ -244,16 +277,16 @@ class LocationTrackingService { /// GPS tracking works WITHOUT BLE connection - device broadcasts are simply skipped. Future startTracking({double? distanceThreshold}) async { if (!_isInitialized) { - debugPrint( - '⚠️ [LocationTracking] Service not initialized', - ); + debugPrint('⚠️ [LocationTracking] Service not initialized'); onError?.call('Location tracking service not initialized'); return false; } // Allow tracking without BLE connection - broadcasts will be skipped if (_bleService == null || !_bleService!.isConnected) { - debugPrint('ℹ️ [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)'); + debugPrint( + 'ℹ️ [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)', + ); } // Check permissions @@ -271,17 +304,20 @@ class LocationTrackingService { // Try to get initial position in background (non-blocking) // This will populate currentPosition but won't block tracking startup - getCurrentPosition( - timeLimit: const Duration(seconds: 10), - retryCount: 1, - ).then((position) { - if (position != null) { - debugPrint('βœ… [LocationTracking] Initial position acquired in background'); - } - }).catchError((error) { - debugPrint('⚠️ [LocationTracking] Background initial position failed: $error'); - // Not critical - position stream will eventually provide position - }); + getCurrentPosition(timeLimit: const Duration(seconds: 10), retryCount: 1) + .then((position) { + if (position != null) { + debugPrint( + 'βœ… [LocationTracking] Initial position acquired in background', + ); + } + }) + .catchError((error) { + debugPrint( + '⚠️ [LocationTracking] Background initial position failed: $error', + ); + // Not critical - position stream will eventually provide position + }); // Start position stream immediately (don't wait for initial position) try { @@ -296,6 +332,7 @@ class LocationTrackingService { isTracking = true; onTrackingStateChanged?.call(true); + _refreshFastLocationTimer(); debugPrint( 'βœ… [LocationTracking] Tracking started with ${threshold}m threshold', @@ -318,6 +355,7 @@ class LocationTrackingService { isTracking = false; onTrackingStateChanged?.call(false); + _refreshFastLocationTimer(); // Reset first position flag so next connection starts fresh _firstPositionSet = false; @@ -361,6 +399,8 @@ class LocationTrackingService { // Notify listeners onPositionUpdate?.call(position); + _evaluateFastLocationMovement(position); + // SPECIAL CASE: First stable position after connection // Set lat/lon on device WITHOUT broadcasting to mesh network if (!_firstPositionSet) { @@ -378,12 +418,16 @@ class LocationTrackingService { /// Updates the device's advertised lat/lon but does NOT send an advertisement. void _setInitialPosition(Position position) async { if (_bleService == null || !_bleService!.isConnected) { - debugPrint('⚠️ [LocationTracking] Cannot set initial position: BLE not connected'); + debugPrint( + '⚠️ [LocationTracking] Cannot set initial position: BLE not connected', + ); return; } try { - debugPrint('πŸ“ [LocationTracking] Setting initial position (no broadcast)'); + debugPrint( + 'πŸ“ [LocationTracking] Setting initial position (no broadcast)', + ); // Update device's advertised location WITHOUT sending advertisement await _bleService!.setAdvertLatLon( @@ -414,7 +458,94 @@ class LocationTrackingService { void _checkAndBroadcast(Position position) { // Automatic broadcasting disabled // Use the manual advert button instead - debugPrint(' ⏸️ [LocationTracking] Automatic broadcasting disabled (use advert button)'); + debugPrint( + ' ⏸️ [LocationTracking] Automatic broadcasting disabled (use advert button)', + ); + } + + void setFastLocationActiveUse(bool isActive) { + if (_isFastLocationActiveUse == isActive) return; + _isFastLocationActiveUse = isActive; + _refreshFastLocationTimer(); + } + + Future setFastLocationUpdatesEnabled(bool enabled) async { + fastLocationUpdatesEnabled = enabled; + await saveSettings(); + _refreshFastLocationTimer(); + } + + Future updateFastLocationMovementThreshold(double meters) async { + fastLocationMovementThresholdMeters = meters.clamp(1.0, 1000.0); + await saveSettings(); + } + + Future updateFastLocationActiveCadenceSeconds(int seconds) async { + fastLocationActiveCadenceSeconds = seconds.clamp(5, 60); + await saveSettings(); + _refreshFastLocationTimer(); + } + + void _evaluateFastLocationMovement(Position position) { + if (!fastLocationUpdatesEnabled) return; + final previous = _lastFastLocationSentPosition; + if (previous == null) { + _emitFastLocationUpdate(position, reason: 'initial'); + return; + } + + final distance = Geolocator.distanceBetween( + previous.latitude, + previous.longitude, + position.latitude, + position.longitude, + ); + if (distance >= fastLocationMovementThresholdMeters) { + _emitFastLocationUpdate(position, reason: 'movement'); + } + } + + void _refreshFastLocationTimer() { + _fastLocationTimer?.cancel(); + _fastLocationTimer = null; + if (!isTracking || + !fastLocationUpdatesEnabled || + !_isFastLocationActiveUse) { + return; + } + + _fastLocationTimer = Timer.periodic( + Duration(seconds: fastLocationActiveCadenceSeconds), + (_) { + final position = currentPosition; + if (position == null) return; + _emitFastLocationUpdate(position, reason: 'active_use'); + }, + ); + } + + void _emitFastLocationUpdate(Position position, {required String reason}) { + if (!fastLocationUpdatesEnabled) return; + + final now = DateTime.now(); + final previous = _lastFastLocationSentPosition; + final previousTime = _lastFastLocationSentAt; + if (previous != null && previousTime != null) { + final distance = Geolocator.distanceBetween( + previous.latitude, + previous.longitude, + position.latitude, + position.longitude, + ); + final elapsedMs = now.difference(previousTime).inMilliseconds; + if (distance < 1.0 && elapsedMs < 3000) { + return; + } + } + + _lastFastLocationSentPosition = position; + _lastFastLocationSentAt = now; + onFastLocationUpdate?.call(position, reason); } // ============================================================================ @@ -457,7 +588,9 @@ class LocationTrackingService { await _bleService!.sendSelfAdvert(floodMode: true); debugPrint('βœ… [LocationTracking] Manual broadcast successful'); - debugPrint(' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s'); + debugPrint( + ' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s', + ); onBroadcastSent?.call(position); return true; @@ -480,12 +613,24 @@ class LocationTrackingService { maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0; minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30; gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0; + fastLocationUpdatesEnabled = + prefs.getBool(_prefKeyFastLocationEnabled) ?? false; + fastLocationMovementThresholdMeters = + (prefs.getDouble(_prefKeyFastMovementThreshold) ?? gpsUpdateDistance) + .clamp(1.0, 1000.0); + fastLocationActiveCadenceSeconds = + (prefs.getInt(_prefKeyFastActiveCadence) ?? 10).clamp(5, 60); debugPrint('βœ… [LocationTracking] Settings loaded'); debugPrint(' Min distance: ${minDistanceMeters}m'); debugPrint(' Max distance: ${maxDistanceMeters}m'); debugPrint(' Min time interval: ${minTimeIntervalSeconds}s'); debugPrint(' GPS update distance: ${gpsUpdateDistance}m'); + debugPrint(' Fast updates enabled: $fastLocationUpdatesEnabled'); + debugPrint( + ' Fast movement threshold: ${fastLocationMovementThresholdMeters}m', + ); + debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s'); } /// Save settings to SharedPreferences @@ -497,6 +642,18 @@ class LocationTrackingService { await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds); await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance); await prefs.setBool(_prefKeyEnabled, isTracking); + await prefs.setBool( + _prefKeyFastLocationEnabled, + fastLocationUpdatesEnabled, + ); + await prefs.setDouble( + _prefKeyFastMovementThreshold, + fastLocationMovementThresholdMeters, + ); + await prefs.setInt( + _prefKeyFastActiveCadence, + fastLocationActiveCadenceSeconds, + ); debugPrint('βœ… [LocationTracking] Settings saved'); } @@ -509,6 +666,7 @@ class LocationTrackingService { void dispose() { debugPrint('πŸ—‘οΈ [LocationTracking] Disposing service'); _positionSubscription?.cancel(); + _fastLocationTimer?.cancel(); _positionSubscription = null; _bleService = null; _isInitialized = false; diff --git a/lib/services/mbtiles_service.dart b/lib/services/mbtiles_service.dart deleted file mode 100644 index 7701108..0000000 --- a/lib/services/mbtiles_service.dart +++ /dev/null @@ -1,273 +0,0 @@ -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:mbtiles/mbtiles.dart'; - -/// Metadata information extracted from an MBTiles file -class MbtilesMetadata { - final String name; - final String? description; - final String? version; - final String? attribution; - final String? bounds; // "minLon,minLat,maxLon,maxLat" - final String? center; // "lon,lat,zoom" - final int? minZoom; - final int? maxZoom; - final String? format; // "pbf", "png", "jpg", etc. - final String? type; // "overlay", "baselayer" - final String? json; // Additional metadata JSON - final File file; - final int fileSize; - - const MbtilesMetadata({ - required this.name, - this.description, - this.version, - this.attribution, - this.bounds, - this.center, - this.minZoom, - this.maxZoom, - this.format, - this.type, - this.json, - required this.file, - required this.fileSize, - }); - - /// Check if this is a vector tile MBTiles file - bool get isVector => format == 'pbf' || format == 'mvt'; - - /// Parse bounds string into [minLon, minLat, maxLon, maxLat] - List? get boundsCoordinates { - if (bounds == null) return null; - try { - final parts = bounds!.split(','); - if (parts.length != 4) return null; - return parts.map((s) => double.parse(s.trim())).toList(); - } catch (e) { - debugPrint('Error parsing bounds: $e'); - return null; - } - } - - /// Parse center string into [lon, lat, zoom] - List? get centerCoordinates { - if (center == null) return null; - try { - final parts = center!.split(','); - if (parts.length < 2) return null; - return parts.map((s) => double.parse(s.trim())).toList(); - } catch (e) { - debugPrint('Error parsing center: $e'); - return null; - } - } - - /// Get file size in human-readable format - String get fileSizeFormatted { - if (fileSize < 1024) { - return '$fileSize B'; - } else if (fileSize < 1024 * 1024) { - return '${(fileSize / 1024).toStringAsFixed(1)} KB'; - } else if (fileSize < 1024 * 1024 * 1024) { - return '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB'; - } else { - return '${(fileSize / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; - } - } -} - -/// Service for managing MBTiles files for offline vector maps -class MbtilesService { - static const String _mbtilesDirectory = 'offline_maps'; - - /// Get the directory where MBTiles files are stored - Future getMbtilesDirectory() async { - final appDocDir = await getApplicationDocumentsDirectory(); - final mbtilesDir = Directory('${appDocDir.path}/$_mbtilesDirectory'); - - // Create directory if it doesn't exist - if (!await mbtilesDir.exists()) { - await mbtilesDir.create(recursive: true); - } - - return mbtilesDir; - } - - /// List all MBTiles files in the offline maps directory - Future> listMbtilesFiles() async { - final dir = await getMbtilesDirectory(); - - try { - final files = await dir - .list() - .where((entity) => entity is File && entity.path.endsWith('.mbtiles')) - .map((entity) => entity as File) - .toList(); - - return files; - } catch (e) { - debugPrint('Error listing MBTiles files: $e'); - return []; - } - } - - /// Get metadata from an MBTiles file - Future getMetadata(File file) async { - try { - // Check if file exists - if (!await file.exists()) { - debugPrint('MBTiles file does not exist: ${file.path}'); - return null; - } - - // Get file size - final fileSize = await file.length(); - - // Open MBTiles file - final mbtiles = MbTiles(mbtilesPath: file.path); - - // Get metadata from MBTiles - final metadata = mbtiles.getMetadata(); - - // Convert bounds object to string if available - String? boundsStr; - if (metadata.bounds != null) { - boundsStr = metadata.bounds.toString(); - } - - return MbtilesMetadata( - name: metadata.name, - description: metadata.description, - version: metadata.version?.toString(), - attribution: null, // Not available in new API - bounds: boundsStr, - center: null, // Not available in new API - minZoom: metadata.minZoom?.toInt(), - maxZoom: metadata.maxZoom?.toInt(), - format: metadata.format, - type: metadata.type?.name, - json: null, // Not available in new API - file: file, - fileSize: fileSize, - ); - } catch (e) { - debugPrint('Error reading MBTiles metadata from ${file.path}: $e'); - return null; - } - } - - /// Get metadata for all MBTiles files - Future> getAllMetadata() async { - final files = await listMbtilesFiles(); - final metadataList = []; - - for (final file in files) { - final metadata = await getMetadata(file); - if (metadata != null) { - metadataList.add(metadata); - } - } - - return metadataList; - } - - /// Import an MBTiles file from an external location - Future importMbtilesFile(String sourcePath) async { - try { - final sourceFile = File(sourcePath); - - // Verify source file exists - if (!await sourceFile.exists()) { - debugPrint('Source file does not exist: $sourcePath'); - return null; - } - - // Get destination directory - final destDir = await getMbtilesDirectory(); - final fileName = _getFileName(sourceFile); - final destPath = '${destDir.path}/$fileName'; - - // Copy file to destination - final destFile = await sourceFile.copy(destPath); - debugPrint('Imported MBTiles file to: $destPath'); - - return destFile; - } catch (e) { - debugPrint('Error importing MBTiles file: $e'); - return null; - } - } - - /// Delete an MBTiles file - Future deleteMbtilesFile(File file) async { - try { - if (await file.exists()) { - await file.delete(); - debugPrint('Deleted MBTiles file: ${file.path}'); - return true; - } - return false; - } catch (e) { - debugPrint('Error deleting MBTiles file: $e'); - return false; - } - } - - /// Check if data in MBTiles is gzip compressed - Future isGzipCompressed(File file) async { - try { - // Open MBTiles and check a sample tile - final mbtiles = MbTiles(mbtilesPath: file.path); - - // Try to get metadata to check for compression hints - final metadata = mbtiles.getMetadata(); - final format = metadata.format; - - // For Geofabrik files, format is 'pbf' and data is gzipped - // We can infer this from common patterns, but ideally we'd check actual tile data - if (format == 'pbf') { - // Geofabrik MBTiles are typically gzipped - // Could also check tile data headers, but this is a reasonable heuristic - return true; - } - - return false; - } catch (e) { - debugPrint('Error checking gzip compression: $e'); - return false; - } - } - - /// Determine the vector tile schema from metadata - String? getVectorSchema(MbtilesMetadata metadata) { - // Try to infer schema from metadata - final json = metadata.json; - if (json != null) { - if (json.contains('shortbread')) { - return 'shortbread'; - } else if (json.contains('openmaptiles')) { - return 'openmaptiles'; - } - } - - // Check description - final description = metadata.description?.toLowerCase(); - if (description != null) { - if (description.contains('shortbread')) { - return 'shortbread'; - } else if (description.contains('openmaptiles')) { - return 'openmaptiles'; - } - } - - // Default to unknown - return null; - } - - /// Helper: Get file name from path - String _getFileName(File file) { - return file.path.split(Platform.pathSeparator).last; - } -} diff --git a/lib/services/tile_cache_service.dart b/lib/services/tile_cache_service.dart deleted file mode 100644 index 888abba..0000000 --- a/lib/services/tile_cache_service.dart +++ /dev/null @@ -1,302 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:vector_map_tiles_mbtiles/vector_map_tiles_mbtiles.dart'; -import 'package:mbtiles/mbtiles.dart'; -import '../models/map_layer.dart'; - -class TileCacheService { - static const String _storeName = 'meshcore_sar_tiles'; - - // Global flag to ensure ObjectBox is only initialized once - static bool _objectBoxInitialized = false; - static Future? _objectBoxInitialization; - - Future? _initializeFuture; - FMTCStore? _store; - bool _isInitialized = false; - bool _isDownloading = false; - - Future initialize() async { - if (_isInitialized) return; - _initializeFuture ??= _initializeInternal(); - await _initializeFuture; - } - - Future _initializeInternal() async { - try { - await _ensureObjectBoxInitialized(); - - final store = FMTCStore(_storeName); - try { - await store.manage.create(); - } catch (_) { - // The store may already exist from a prior initialization. - } - - _store = store; - _isInitialized = true; - } catch (_) { - _initializeFuture = null; - rethrow; - } - } - - Future _ensureObjectBoxInitialized() async { - if (_objectBoxInitialized) return; - - _objectBoxInitialization ??= () async { - try { - await FMTCObjectBoxBackend().initialise(); - } catch (_) { - // Treat repeated backend initialization as a no-op. - } finally { - _objectBoxInitialized = true; - } - }(); - - await _objectBoxInitialization; - } - - FMTCStore _requireStore() { - final store = _store; - if (!_isInitialized || store == null) { - throw StateError( - 'TileCacheService not initialized. Call initialize() first.', - ); - } - return store; - } - - TileProvider getTileProvider(MapLayer layer) { - _requireStore(); - return FMTCTileProvider( - stores: {_storeName: BrowseStoreStrategy.readUpdateCreate}, - loadingStrategy: BrowseLoadingStrategy.cacheFirst, - cachedValidDuration: const Duration(days: 30), - ); - } - - /// Get tile provider for WMS layers with caching support - /// WMS layers require special handling because they use WMSTileLayerOptions - TileProvider getTileProviderForWms(MapLayer layer) { - _requireStore(); - if (!layer.isWms) { - throw ArgumentError('Layer must be a WMS layer'); - } - - // Return the same cached tile provider - // The WMS URL construction is handled by flutter_map's WMSTileLayerOptions - return FMTCTileProvider( - stores: {_storeName: BrowseStoreStrategy.readUpdateCreate}, - loadingStrategy: BrowseLoadingStrategy.cacheFirst, - cachedValidDuration: const Duration(days: 30), - ); - } - - Future downloadRegion({ - required MapLayer layer, - required LatLngBounds bounds, - required int minZoom, - required int maxZoom, - Function(double progress)? onProgress, - }) async { - if (!_isInitialized) { - throw StateError( - 'TileCacheService not initialized. Call initialize() first.', - ); - } - - if (_isDownloading) { - throw StateError('A download is already in progress. Cancel it first.'); - } - - _isDownloading = true; - final store = _requireStore(); - - try { - final region = RectangleRegion(bounds); - - final downloadable = region.toDownloadable( - minZoom: minZoom, - maxZoom: maxZoom, - options: TileLayer(urlTemplate: layer.urlTemplate), - ); - - final download = store.download.startForeground(region: downloadable); - - await for (final progress in download.downloadProgress) { - if (onProgress != null && progress.maxTilesCount > 0) { - // Use attemptedTilesCount instead of successfulTilesCount - // attemptedTilesCount includes successful + buffered + skipped tiles - final percentage = progress.percentageProgress; - debugPrint( - 'Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})', - ); - onProgress(percentage); - } - } - } finally { - _isDownloading = false; - } - } - - Future cancelDownload() async { - if (!_isInitialized) return; - await _requireStore().download.cancel(); - } - - Future clearCache() async { - if (!_isInitialized) return; - final store = _requireStore(); - await store.manage.delete(); - await store.manage.create(); - } - - Future getCachedTileCount() async { - if (!_isInitialized) return 0; - final stats = await _requireStore().stats.length; - return stats; - } - - Future getCacheSizeMB() async { - if (!_isInitialized) return 0.0; - final stats = await _requireStore().stats.size; - return stats / (1024 * 1024); - } - - Future> getAvailableStores() async { - if (!_isInitialized) { - throw StateError( - 'TileCacheService not initialized. Call initialize() first.', - ); - } - - final stores = await FMTCRoot.stats.storesAvailable; - return stores.map((store) => store.storeName).toList(); - } - - Future> getStoreStats() async { - if (!_isInitialized) return {}; - - final store = _requireStore(); - final length = await store.stats.length; - final size = await store.stats.all.then((a) => a.size); - - return { - 'tileCount': length, - 'sizeMB': size / 1024, - 'storeName': _storeName, - }; - } - - /// Get vector tile provider for MBTiles layers - MbTilesVectorTileProvider? getVectorTileProvider(MapLayer layer) { - if (!layer.isVector || layer.mbtilesFile == null) { - return null; - } - - try { - final mbtiles = MbTiles( - mbtilesPath: layer.mbtilesFile!.path, - gzip: layer.isGzipped ?? false, - ); - - return MbTilesVectorTileProvider( - mbtiles: mbtiles, - ); - } catch (e) { - debugPrint('Error creating vector tile provider: $e'); - return null; - } - } - - /// Export the current tile cache store to an archive file - /// - /// [outputPath] - Full path where the archive should be saved (e.g., '/path/to/export.fmtc') - /// - /// Returns the number of tiles exported - Future exportStore(String outputPath) async { - if (!_isInitialized) { - throw StateError( - 'TileCacheService not initialized. Call initialize() first.', - ); - } - - try { - final external = FMTCRoot.external(pathToArchive: outputPath); - final result = await external.export(storeNames: [_storeName]); - - debugPrint('Export completed: $result tiles exported to $outputPath'); - return result; - } catch (e) { - debugPrint('Error exporting store: $e'); - rethrow; - } - } - - /// Import a tile cache store from an archive file - /// - /// [filePath] - Path to the .fmtc archive file to import - /// [storeNames] - Optional list of store names to import (null = import all) - /// [strategy] - Conflict resolution strategy (default: merge) - /// - /// Returns a map with import statistics (e.g., tile count, stores imported) - Future> importStore( - String filePath, { - List? storeNames, - ImportConflictStrategy strategy = ImportConflictStrategy.merge, - }) async { - if (!_isInitialized) { - throw StateError( - 'TileCacheService not initialized. Call initialize() first.', - ); - } - - try { - final external = FMTCRoot.external(pathToArchive: filePath); - final result = external.import(storeNames: storeNames, strategy: strategy); - - // Wait for the import to complete and get tile count - final tileCount = await result.complete; - - // Wait for store states - final storesToStates = await result.storesToStates; - - debugPrint('Import completed: $tileCount tiles imported, ${storesToStates.length} stores'); - - // Count successful stores (those that weren't skipped) - final successfulCount = storesToStates.values.where((state) => state.name != null).length; - - return { - 'successfulStores': successfulCount, - 'tileCount': tileCount, - 'storesToStates': storesToStates, - }; - } catch (e) { - debugPrint('Error importing store: $e'); - rethrow; - } - } - - /// List all stores available in an archive file without importing - /// - /// [filePath] - Path to the .fmtc archive file to inspect - /// - /// Returns a list of store names contained in the archive - Future> listArchiveStores(String filePath) async { - try { - final external = FMTCRoot.external(pathToArchive: filePath); - final stores = await external.listStores; - debugPrint('Archive contains ${stores.length} stores: $stores'); - return stores; - } catch (e) { - debugPrint('Error listing archive stores: $e'); - rethrow; - } - } - - void dispose() { - _isInitialized = false; - } -} diff --git a/lib/utils/fast_gps_packet.dart b/lib/utils/fast_gps_packet.dart new file mode 100644 index 0000000..c1c550a --- /dev/null +++ b/lib/utils/fast_gps_packet.dart @@ -0,0 +1,69 @@ +import 'dart:typed_data'; + +class FastGpsPacket { + static const int magic = 0x47; // 'G' + static const int _payloadLength = 19; + // Store coordinates in microdegrees. This preserves sub-meter precision, + // which comfortably satisfies the meter-accuracy requirement. + static const double coordinateScale = 1e6; + + final String senderKey6; + final double latitude; + final double longitude; + final int timestampSeconds; + + const FastGpsPacket({ + required this.senderKey6, + required this.latitude, + required this.longitude, + required this.timestampSeconds, + }); + + static bool isFastGpsBinary(Uint8List payload) => + payload.length == _payloadLength && payload[0] == magic; + + static FastGpsPacket? tryParseBinary(Uint8List payload) { + if (!isFastGpsBinary(payload)) return null; + + final key6 = payload + .sublist(1, 7) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + final data = ByteData.sublistView(payload); + final latitude = data.getInt32(7, Endian.little) / coordinateScale; + final longitude = data.getInt32(11, Endian.little) / coordinateScale; + final timestampSeconds = data.getUint32(15, Endian.little); + + if (!_isValidCoordinate(latitude, longitude)) { + return null; + } + + return FastGpsPacket( + senderKey6: key6, + latitude: latitude, + longitude: longitude, + timestampSeconds: timestampSeconds, + ); + } + + Uint8List encodeBinary() { + final out = Uint8List(_payloadLength); + final data = ByteData.sublistView(out); + out[0] = magic; + for (var i = 0; i < 6; i++) { + out[1 + i] = int.parse(senderKey6.substring(i * 2, i * 2 + 2), radix: 16); + } + data.setInt32(7, (latitude * coordinateScale).round(), Endian.little); + data.setInt32(11, (longitude * coordinateScale).round(), Endian.little); + data.setUint32(15, timestampSeconds, Endian.little); + return out; + } + + static bool _isValidCoordinate(double latitude, double longitude) { + if (!latitude.isFinite || !longitude.isFinite) return false; + return latitude >= -90.0 && + latitude <= 90.0 && + longitude >= -180.0 && + longitude <= 180.0; + } +} diff --git a/lib/widgets/map/download_area_overlay.dart b/lib/widgets/map/download_area_overlay.dart deleted file mode 100644 index 425c34b..0000000 --- a/lib/widgets/map/download_area_overlay.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; - -/// Overlay widget that displays controls for download area selection. -/// The actual polygon should be rendered inside FlutterMap's children. -class DownloadAreaOverlay extends StatelessWidget { - final LatLngBounds bounds; - final VoidCallback onConfirm; - final VoidCallback onCancel; - - const DownloadAreaOverlay({ - super.key, - required this.bounds, - required this.onConfirm, - required this.onCancel, - }); - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - // Control buttons at the top - Positioned( - top: 16, - left: 16, - right: 16, - child: Card( - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Download Area Selection', - style: Theme.of(context).textTheme.titleMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - 'The blue rectangle shows the area to be downloaded. ' - 'To change the area, tap Cancel and select download again.', - style: Theme.of(context).textTheme.bodySmall, - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: onCancel, - icon: const Icon(Icons.close), - label: const Text('Cancel'), - ), - ), - const SizedBox(width: 12), - Expanded( - child: FilledButton.icon( - onPressed: onConfirm, - icon: const Icon(Icons.check), - label: const Text('Confirm'), - ), - ), - ], - ), - ], - ), - ), - ), - ), - - // Area info at the bottom - Positioned( - bottom: 16, - left: 16, - right: 16, - child: Card( - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Area Bounds', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 4), - Text( - 'N: ${bounds.north.toStringAsFixed(4)}Β° ' - 'S: ${bounds.south.toStringAsFixed(4)}Β°', - style: Theme.of(context).textTheme.bodySmall, - ), - Text( - 'E: ${bounds.east.toStringAsFixed(4)}Β° ' - 'W: ${bounds.west.toStringAsFixed(4)}Β°', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - ), - ), - ], - ); - } -} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 71a2375..73a17a5 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,7 +9,6 @@ #include #include #include -#include #include #include @@ -23,9 +22,6 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_avif_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAvifLinuxPlugin"); flutter_avif_linux_plugin_register_with_registrar(flutter_avif_linux_registrar); - g_autoptr(FlPluginRegistrar) objectbox_flutter_libs_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "ObjectboxFlutterLibsPlugin"); - objectbox_flutter_libs_plugin_register_with_registrar(objectbox_flutter_libs_registrar); g_autoptr(FlPluginRegistrar) record_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); record_linux_plugin_register_with_registrar(record_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 4de5e13..d97f3af 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,7 +6,6 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux file_selector_linux flutter_avif_linux - objectbox_flutter_libs record_linux url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a2ed06c..2075a58 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -14,7 +14,6 @@ import flutter_blue_plus_darwin import flutter_local_notifications import geolocator_apple import nsd_macos -import objectbox_flutter_libs import package_info_plus import record_macos import share_plus @@ -32,7 +31,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin")) - ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 54e5bc5..5769659 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -226,14 +226,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.3" - executor_lib: - dependency: transitive - description: - name: executor_lib - sha256: "95ddf2957d9942d9702855b38dd49677f0ee6a8b77d7b16c0e509c7669d17386" - url: "https://pub.dev" - source: hosted - version: "1.1.2" exif: dependency: transitive description: @@ -314,14 +306,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - flat_buffers: - dependency: transitive - description: - name: flat_buffers - sha256: "380bdcba5664a718bfd4ea20a45d39e13684f5318fcd8883066a55e21f37f4c3" - url: "https://pub.dev" - source: hosted - version: "23.5.26" flutter: dependency: "direct main" description: flutter @@ -551,19 +535,12 @@ packages: flutter_map: dependency: "direct main" description: - name: flutter_map - sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8" - url: "https://pub.dev" - source: hosted + path: "." + ref: master + resolved-ref: fdc089aeb4fad05a4f2314f75bbd66c9b7a70668 + url: "https://github.com/fleaflet/flutter_map.git" + source: git version: "8.2.2" - flutter_map_tile_caching: - dependency: "direct main" - description: - name: flutter_map_tile_caching - sha256: "90e097223d8ab74425cf15b449a03adfa4d4c28406dc757e1c396aff0f9beba7" - url: "https://pub.dev" - source: hosted - version: "10.1.1" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -694,14 +671,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - idb_shim: - dependency: transitive - description: - name: idb_shim - sha256: "921301da0a735f336a28fc35c3abdbd4498895cc205fa1ea9f7e785e7d854ceb" - url: "https://pub.dev" - source: hosted - version: "2.8.2+4" image: dependency: transitive description: @@ -830,22 +799,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" - lists: - dependency: transitive - description: - name: lists - sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - logger: - dependency: transitive - description: - name: logger - sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 - url: "https://pub.dev" - source: hosted - version: "2.6.2" logging: dependency: transitive description: @@ -870,14 +823,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" - mbtiles: - dependency: "direct main" - description: - name: mbtiles - sha256: "316af1f8db8ce95888ca70f5dd3f6914906b4e17ceeca8501206d28e78612af8" - url: "https://pub.dev" - source: hosted - version: "0.4.2" meshcore_client: dependency: "direct main" description: @@ -899,10 +844,10 @@ packages: dependency: transitive description: name: mgrs_dart - sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" mime: dependency: transitive description: @@ -975,22 +920,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" - objectbox: - dependency: transitive - description: - name: objectbox - sha256: "3cc186749178a3556e1020c9082d0897d0f9ecbdefcc27320e65c5bc650f0e57" - url: "https://pub.dev" - source: hosted - version: "4.3.1" - objectbox_flutter_libs: - dependency: transitive - description: - name: objectbox_flutter_libs - sha256: cd754766e04229a4f51250f121813d9a3c1a74fc21cd68e48b3c6085cbcd6c85 - url: "https://pub.dev" - source: hosted - version: "4.3.1" objective_c: dependency: transitive description: @@ -1163,10 +1092,10 @@ packages: dependency: "direct main" description: name: proj4dart - sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e + sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "3.0.0" protobuf: dependency: transitive description: @@ -1263,14 +1192,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" - sembast: - dependency: transitive - description: - name: sembast - sha256: "139cf71496105de32e7a08a4e3a1ead0f81c4a616ec9703ed07e8f0d10cdd505" - url: "https://pub.dev" - source: hosted - version: "3.8.6" share_plus: dependency: "direct main" description: @@ -1359,6 +1280,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.4" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" sky_engine: dependency: transitive description: flutter @@ -1420,14 +1349,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" - sqlite3: - dependency: transitive - description: - name: sqlite3 - sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" - url: "https://pub.dev" - source: hosted - version: "2.9.4" stack_trace: dependency: transitive description: @@ -1496,10 +1417,10 @@ packages: dependency: transitive description: name: unicode - sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 url: "https://pub.dev" source: hosted - version: "0.3.1" + version: "1.1.9" url_launcher: dependency: "direct main" description: @@ -1572,23 +1493,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.3" - vector_map_tiles: - dependency: "direct main" - description: - name: vector_map_tiles - sha256: e35f090c428f05e44dd525fa4fedaafd1dbcd28b656cb0ea908528c6ce84a87d - url: "https://pub.dev" - source: hosted - version: "9.0.0-beta.8" - vector_map_tiles_mbtiles: - dependency: "direct main" - description: - path: vector_map_tiles_mbtiles - ref: HEAD - resolved-ref: a09543b7590b373f3ac53f4776e343fee41c7dc6 - url: "https://github.com/josxha/flutter_map_plugins.git" - source: git - version: "1.2.1" vector_math: dependency: transitive description: @@ -1597,30 +1501,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - vector_tile: - dependency: transitive - description: - name: vector_tile - sha256: "7ae290246e3a8734422672dbe791d3f7b8ab631734489fc6d405f1cc2080e38c" - url: "https://pub.dev" - source: hosted - version: "2.0.1" - vector_tile_dem: - dependency: transitive - description: - name: vector_tile_dem - sha256: "81a3568d2213817bd2698f919357e5107c0261491ae1014e821ed4fc3c2bf740" - url: "https://pub.dev" - source: hosted - version: "0.0.2" - vector_tile_renderer: - dependency: "direct main" - description: - name: vector_tile_renderer - sha256: "99530edb073c1cea3c6a4bdb5ca9a5c6779c25ddf1a645678458504708dc221c" - url: "https://pub.dev" - source: hosted - version: "6.0.0" vibration: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index a0c37ce..f25ce82 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -66,24 +66,16 @@ dependencies: provider: ^6.1.0 # Map display - flutter_map: ^8.2.2 + flutter_map: + git: + url: https://github.com/fleaflet/flutter_map.git + ref: master latlong2: ^0.9.0 - # Offline tile caching - flutter_map_tile_caching: ^10.1.1 - - # Vector map tiles - vector_map_tiles: ^9.0.0-beta.8 - vector_map_tiles_mbtiles: - git: - url: https://github.com/josxha/flutter_map_plugins.git - path: vector_map_tiles_mbtiles - vector_tile_renderer: ^6.0.0 - mbtiles: ^0.4.0 http: ^1.2.0 # Coordinate system projections for WMS (EPSG:3794) - proj4dart: ^2.1.0 + proj4dart: ^3.0.0 # Permissions permission_handler: ^12.0.1 diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart index d2c5107..f806e3c 100644 --- a/test/providers/contacts_provider_test.dart +++ b/test/providers/contacts_provider_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/providers/contacts_provider.dart'; import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart'; +import 'package:meshcore_sar_app/utils/fast_gps_packet.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { @@ -347,4 +348,65 @@ void main() { expect(updated.routeSummary, 'Flood/Unknown'); }); }); + + group('ContactsProvider.updateFastGps', () { + late ContactsProvider provider; + late Uint8List publicKey; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + provider = ContactsProvider(); + publicKey = createPublicKey(50); + provider.addOrUpdateContact( + createContact(key: publicKey, type: ContactType.chat, name: 'Fast GPS'), + ); + }); + + test('updates gps while preserving other telemetry fields', () { + final batteryOnly = CayenneLppParser.createBatteryData(3.9); + provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly); + + provider.updateFastGps( + publicKey.sublist(0, 6), + const FastGpsPacket( + senderKey6: '323334353637', + latitude: 44.123456, + longitude: 13.654321, + timestampSeconds: 1700001234, + ), + ); + + final updated = provider.findContactByKey(publicKey)!; + expect(updated.telemetry, isNotNull); + expect(updated.telemetry!.gpsLocation, isNotNull); + expect( + updated.telemetry!.gpsLocation!.latitude, + closeTo(44.123456, 0.000001), + ); + expect( + updated.telemetry!.gpsLocation!.longitude, + closeTo(13.654321, 0.000001), + ); + expect(updated.telemetry!.batteryMilliVolts, isNotNull); + expect(updated.advLat, equals((44.123456 * 1e6).round())); + expect(updated.advLon, equals((13.654321 * 1e6).round())); + expect(updated.lastAdvert, equals(1700001234)); + }); + + test('ignores unknown sender prefix safely', () { + final before = provider.findContactByKey(publicKey)!; + provider.updateFastGps( + Uint8List.fromList([1, 2, 3, 4, 5, 6]), + const FastGpsPacket( + senderKey6: '010203040506', + latitude: 10, + longitude: 20, + timestampSeconds: 99, + ), + ); + final after = provider.findContactByKey(publicKey)!; + expect(after.advLat, equals(before.advLat)); + expect(after.advLon, equals(before.advLon)); + }); + }); } diff --git a/test/utils/fast_gps_packet_test.dart b/test/utils/fast_gps_packet_test.dart new file mode 100644 index 0000000..92bbdaa --- /dev/null +++ b/test/utils/fast_gps_packet_test.dart @@ -0,0 +1,97 @@ +import 'dart:typed_data'; +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/utils/fast_gps_packet.dart'; + +void main() { + group('FastGpsPacket', () { + test('encodes and parses a valid packet', () { + final packet = FastGpsPacket( + senderKey6: 'aabbccddeeff', + latitude: 46.0569, + longitude: 14.5058, + timestampSeconds: 1700000000, + ); + + final encoded = packet.encodeBinary(); + final parsed = FastGpsPacket.tryParseBinary(encoded); + + expect(parsed, isNotNull); + expect(parsed!.senderKey6, equals('aabbccddeeff')); + expect(parsed.latitude, closeTo(46.0569, 0.000001)); + expect(parsed.longitude, closeTo(14.5058, 0.000001)); + expect(parsed.timestampSeconds, equals(1700000000)); + }); + + test('supports negative coordinates', () { + final packet = FastGpsPacket( + senderKey6: '001122334455', + latitude: -33.8688, + longitude: -151.2093, + timestampSeconds: 42, + ); + + final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary()); + expect(parsed, isNotNull); + expect(parsed!.latitude, closeTo(-33.8688, 0.000001)); + expect(parsed.longitude, closeTo(-151.2093, 0.000001)); + }); + + test('rejects malformed payloads', () { + expect( + FastGpsPacket.tryParseBinary(Uint8List.fromList([0x47, 0x01])), + isNull, + ); + expect( + FastGpsPacket.tryParseBinary( + Uint8List.fromList(List.filled(19, 0)..[0] = 0x48), + ), + isNull, + ); + }); + + test('rejects invalid coordinate ranges', () { + final payload = Uint8List(19); + payload[0] = FastGpsPacket.magic; + payload.setRange(1, 7, [0, 1, 2, 3, 4, 5]); + final data = ByteData.sublistView(payload); + data.setInt32( + 7, + (91.0 * FastGpsPacket.coordinateScale).round(), + Endian.little, + ); + data.setInt32( + 11, + (14.5 * FastGpsPacket.coordinateScale).round(), + Endian.little, + ); + data.setUint32(15, 1, Endian.little); + + expect(FastGpsPacket.tryParseBinary(payload), isNull); + }); + + test('preserves at least meter accuracy', () { + const latitude = 46.0569123; + const longitude = 14.5058123; + final packet = FastGpsPacket( + senderKey6: 'aabbccddeeff', + latitude: latitude, + longitude: longitude, + timestampSeconds: 1700000000, + ); + + final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary()); + expect(parsed, isNotNull); + + final latMeters = (parsed!.latitude - latitude).abs() * 111320.0; + final lonMeters = + (parsed.longitude - longitude).abs() * + 111320.0 * + math.cos(latitude * math.pi / 180.0); + + expect(latMeters, lessThan(1.0)); + expect(lonMeters, lessThan(1.0)); + }); + }); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 23d7703..713c73c 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -12,7 +12,6 @@ #include #include #include -#include #include #include #include @@ -31,8 +30,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("GeolocatorWindows")); NsdWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("NsdWindowsPluginCApi")); - ObjectboxFlutterLibsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("ObjectboxFlutterLibsPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); RecordWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 5b38d53..afa41cb 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -9,7 +9,6 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_blue_plus_winrt geolocator_windows nsd_windows - objectbox_flutter_libs permission_handler_windows record_windows share_plus