diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b7b1ad2..50aab68 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -337,7 +337,7 @@ Key packages used in this project: - `flutter_blue_plus`: Mobile Bluetooth (Android/iOS) - `flutter_web_bluetooth`: Web Bluetooth (Chrome/Edge) - `geolocator`: GPS/Location -- `flutter_map`: Map rendering +- `maplibre_gl`: Map rendering (MapLibre GL vector tiles via OpenFreeMap) - `hive`: Local storage - `provider`: State management - `http`: API requests diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3468904..d7c6a8d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -42,7 +42,7 @@ android { applicationId = "net.meshmapper.app" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = flutter.minSdkVersion // MapLibre GL requires 23+ targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index a11d769..aa77ae3 100644 --- a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -55,6 +55,11 @@ public static void registerWith(@NonNull FlutterEngine flutterEngine) { } catch (Exception e) { Log.e(TAG, "Error registering plugin just_audio, com.ryanheise.just_audio.JustAudioPlugin", e); } + try { + flutterEngine.getPlugins().add(new org.maplibre.maplibregl.MapLibreMapsPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin maplibre_gl, org.maplibre.maplibregl.MapLibreMapsPlugin", e); + } try { flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin()); } catch (Exception e) { diff --git a/ios/Podfile b/ios/Podfile index c84fc8a..620e46e 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -39,27 +39,5 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) - - target.build_configurations.each do |config| - config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ - '$(inherited)', - 'PERMISSION_EVENTS=0', - 'PERMISSION_EVENTS_FULL_ACCESS=0', - 'PERMISSION_REMINDERS=0', - 'PERMISSION_CONTACTS=0', - 'PERMISSION_CAMERA=0', - 'PERMISSION_MICROPHONE=0', - 'PERMISSION_SPEECH_RECOGNIZER=0', - 'PERMISSION_PHOTOS=0', - 'PERMISSION_LOCATION=1', - 'PERMISSION_NOTIFICATIONS=1', - 'PERMISSION_MEDIA_LIBRARY=0', - 'PERMISSION_SENSORS=0', - 'PERMISSION_BLUETOOTH=0', - 'PERMISSION_APP_TRACKING_TRANSPARENCY=0', - 'PERMISSION_CRITICAL_ALERTS=0', - 'PERMISSION_ASSISTANT=0', - ] - end end end diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4d7193e..a28342f 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -27,6 +27,15 @@ "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" } }, + { + "identity" : "maplibre-gl-native-distribution", + "kind" : "remoteSourceControl", + "location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git", + "state" : { + "revision" : "1051e9dfa3546bece1a6eaf33a5ac85ac35d6bda", + "version" : "6.19.1" + } + }, { "identity" : "sdwebimage", "kind" : "remoteSourceControl", diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4d7193e..a28342f 100644 --- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -27,6 +27,15 @@ "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" } }, + { + "identity" : "maplibre-gl-native-distribution", + "kind" : "remoteSourceControl", + "location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git", + "state" : { + "revision" : "1051e9dfa3546bece1a6eaf33a5ac85ac35d6bda", + "version" : "6.19.1" + } + }, { "identity" : "sdwebimage", "kind" : "remoteSourceControl", diff --git a/lib/main.dart b/lib/main.dart index 985fb78..22d67ee 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'services/bluetooth/mobile_bluetooth.dart'; import 'services/bluetooth/web_bluetooth.dart'; import 'services/background_service.dart'; import 'services/debug_file_logger.dart'; +import 'services/offline_map_service.dart'; import 'utils/debug_logger_io.dart'; void main() async { @@ -69,6 +70,11 @@ void main() async { await BackgroundServiceManager.cleanupOrphanedService(); } + // Clean up any stale offline map download notification + if (!kIsWeb) { + await OfflineMapService().cleanupOrphanedNotification(); + } + runApp(MeshMapperApp(initialThemeMode: initialThemeMode)); } @@ -219,6 +225,9 @@ class MeshMapperApp extends StatelessWidget { ChangeNotifierProvider( create: (_) => AppStateProvider(bluetoothService: bluetoothService), ), + ChangeNotifierProvider( + create: (_) => OfflineMapService()..initialize(), + ), ], child: _ThemedApp(initialThemeMode: initialThemeMode), ); diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 11c04a7..84e4acc 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -97,6 +97,11 @@ class UserPreferences { /// Download map tiles (base map + coverage overlay). When false, no tile network requests are made to save mobile data. final bool mapTilesEnabled; + /// MeshMapper coverage raster overlay opacity (0.0 = fully transparent, + /// 1.0 = fully opaque). Applied to the `meshmapper-overlay-layer` raster + /// layer so users can see the base map underneath the coverage squares. + final double coverageOverlayOpacity; + /// Disconnect alert: play audible alert when pinging stops unexpectedly (BLE disconnect, idle timeout, maintenance) final bool disconnectAlertEnabled; @@ -129,7 +134,7 @@ class UserPreferences { this.iataCode, this.backgroundModeEnabled = false, this.developerModeEnabled = false, - this.mapStyle = 'dark', + this.mapStyle = 'liberty', this.closeAppAfterDisconnect = false, this.themeMode = 'dark', this.unitSystem = 'metric', @@ -148,6 +153,7 @@ class UserPreferences { this.gpsMarkerStyle = 'arrow', this.colorVisionType = 'none', this.mapTilesEnabled = true, + this.coverageOverlayOpacity = 0.7, this.disconnectAlertEnabled = false, this.customApiEnabled = false, this.customApiUrl, @@ -172,7 +178,7 @@ class UserPreferences { iataCode: json['iataCode'] as String?, backgroundModeEnabled: (json['backgroundModeEnabled'] as bool?) ?? false, developerModeEnabled: (json['developerModeEnabled'] as bool?) ?? false, - mapStyle: (json['mapStyle'] as String?) ?? 'dark', + mapStyle: (json['mapStyle'] as String?) ?? 'liberty', closeAppAfterDisconnect: (json['closeAppAfterDisconnect'] as bool?) ?? false, themeMode: (json['themeMode'] as String?) ?? 'dark', @@ -193,6 +199,8 @@ class UserPreferences { gpsMarkerStyle: _migrateGpsMarkerStyle(json['gpsMarkerStyle'] as String?), colorVisionType: (json['colorVisionType'] as String?) ?? 'none', mapTilesEnabled: (json['mapTilesEnabled'] as bool?) ?? true, + coverageOverlayOpacity: + (json['coverageOverlayOpacity'] as num?)?.toDouble() ?? 0.7, disconnectAlertEnabled: (json['disconnectAlertEnabled'] as bool?) ?? false, customApiEnabled: (json['customApiEnabled'] as bool?) ?? false, @@ -247,6 +255,7 @@ class UserPreferences { 'gpsMarkerStyle': gpsMarkerStyle, 'colorVisionType': colorVisionType, 'mapTilesEnabled': mapTilesEnabled, + 'coverageOverlayOpacity': coverageOverlayOpacity, 'disconnectAlertEnabled': disconnectAlertEnabled, 'customApiEnabled': customApiEnabled, 'customApiUrl': customApiUrl, @@ -290,6 +299,7 @@ class UserPreferences { String? gpsMarkerStyle, String? colorVisionType, bool? mapTilesEnabled, + double? coverageOverlayOpacity, bool? disconnectAlertEnabled, bool? customApiEnabled, String? customApiUrl, @@ -334,6 +344,8 @@ class UserPreferences { gpsMarkerStyle: gpsMarkerStyle ?? this.gpsMarkerStyle, colorVisionType: colorVisionType ?? this.colorVisionType, mapTilesEnabled: mapTilesEnabled ?? this.mapTilesEnabled, + coverageOverlayOpacity: + coverageOverlayOpacity ?? this.coverageOverlayOpacity, disconnectAlertEnabled: disconnectAlertEnabled ?? this.disconnectAlertEnabled, customApiEnabled: customApiEnabled ?? this.customApiEnabled, @@ -406,6 +418,7 @@ class UserPreferences { other.gpsMarkerStyle == gpsMarkerStyle && other.colorVisionType == colorVisionType && other.mapTilesEnabled == mapTilesEnabled && + other.coverageOverlayOpacity == coverageOverlayOpacity && other.disconnectAlertEnabled == disconnectAlertEnabled && other.customApiEnabled == customApiEnabled && other.customApiUrl == customApiUrl && @@ -448,6 +461,7 @@ class UserPreferences { gpsMarkerStyle, colorVisionType, mapTilesEnabled, + coverageOverlayOpacity, disconnectAlertEnabled, customApiEnabled, customApiUrl, diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 4a7db8a..4ecc88f 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -731,9 +731,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { '[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); notifyListeners(); - // Schedule overlay tile refresh after server has time to regenerate tiles - // Cache buster change + notifyListeners triggers flutter_map's reloadImages() - // which updates tile URLs in-place and refetches cleanly + // Schedule overlay tile refresh after server has time to regenerate tiles. + // The MapWidget watches _overlayCacheBust and calls _refreshCoverageOverlay() + // (remove + re-add raster source with new URL) when it changes. _tileRefreshTimer?.cancel(); _tileRefreshTimer = Timer(const Duration(seconds: 5), () { _overlayCacheBust = DateTime.now().millisecondsSinceEpoch; @@ -4192,6 +4192,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _savePreferences(); } + /// Set coverage overlay opacity (0.3–1.0) and persist. + /// MapWidget watches `preferences.coverageOverlayOpacity` and applies the + /// new value to the raster layer at runtime via setLayerProperties, so the + /// overlay fades live as the slider moves. Lower bound of 0.3 prevents the + /// overlay from disappearing entirely. + void setCoverageOverlayOpacity(double opacity) { + final clamped = opacity.clamp(0.3, 1.0); + _preferences = _preferences.copyWith(coverageOverlayOpacity: clamped); + debugLog( + '[MAP] Coverage overlay opacity set to ${clamped.toStringAsFixed(2)}'); + notifyListeners(); + _savePreferences(); + } + /// Set app theme mode (dark/light) and persist void setThemeMode(String mode) { _preferences = _preferences.copyWith(themeMode: mode); @@ -4720,14 +4734,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (reason == 'gps_inaccurate') { logError('GPS Accuracy Error\n$message', autoSwitch: false); - _zoneCheckError = message; - _zoneCheckErrorReason = 'gps_inaccurate'; - notifyListeners(); + // Schedule a retry so we don't depend solely on the GPS stream firing + // again — on first launch the stream may stall on a low-accuracy fix + // and the coverage tile overlay would never load. + _scheduleZoneCheckRetry( + seconds: 10, error: message, reason: 'gps_inaccurate'); } else if (reason == 'gps_stale') { logError('GPS Stale Error\n$message', autoSwitch: false); - _zoneCheckError = message; - _zoneCheckErrorReason = 'gps_stale'; - notifyListeners(); + _scheduleZoneCheckRetry( + seconds: 10, error: message, reason: 'gps_stale'); } else if (reason == 'zone_disabled') { final errorMsg = _getErrorMessage(reason, message); logError(errorMsg); @@ -5348,24 +5363,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await disconnect(); } - /// Force a repeater refetch when the cached list is empty (e.g. popup open, - /// offline session, startup race). Uses the current zone if known, otherwise - /// falls back to the user-configured IATA. No-op if neither is available or - /// if repeaters are already loaded. - Future refetchRepeatersIfPossible() async { - if (_repeaters.isNotEmpty) return; - final iata = (zoneCode?.isNotEmpty == true) - ? zoneCode - : _preferences.iataCode; - if (iata == null || iata.isEmpty) { - debugLog('[MAP] refetchRepeatersIfPossible: no IATA available, skipping'); - return; - } - _repeatersLoaded = false; - _repeatersLoadedForIata = null; - await _fetchRepeatersForZone(iata); - } - /// Fetch repeaters for a zone (called when zone is discovered) /// Only fetches once per IATA code to avoid redundant network requests Future _fetchRepeatersForZone(String iata) async { diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index e7bd7f7..425a0a1 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -367,9 +367,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { case PingLogType.disc: final disc = entry.asDisc; for (final node in disc.discoveredNodes) { - if (node.repeaterId.toLowerCase().startsWith(query)) { - return true; - } + if (node.repeaterId.toLowerCase().startsWith(query)) return true; if (node.pubkeyHex != null && node.pubkeyHex!.toLowerCase().startsWith(query)) { return true; diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart new file mode 100644 index 0000000..65619bb --- /dev/null +++ b/lib/screens/offline_maps_screen.dart @@ -0,0 +1,1135 @@ +import 'dart:math' show Point; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:provider/provider.dart'; + +import '../providers/app_state_provider.dart'; +import '../services/offline_map_service.dart'; +import '../widgets/app_toast.dart'; + +/// Available map styles for offline download. +/// Satellite uses inline raster JSON which doesn't work well with the offline +/// region downloader, so we only offer the vector tile styles. +const _downloadStyles = { + 'Liberty': 'https://tiles.openfreemap.org/styles/liberty', + 'Dark': 'https://tiles.openfreemap.org/styles/dark', + 'Light': 'https://tiles.openfreemap.org/styles/bright', +}; + +/// Screen for managing offline map tile downloads. +/// +/// Accessible from the Settings screen. The underlying [OfflineMapService] +/// lives at the app level (via Provider), so downloads continue even after +/// navigating away from this screen. A system notification shows progress. +class OfflineMapsScreen extends StatefulWidget { + const OfflineMapsScreen({super.key}); + + @override + State createState() => _OfflineMapsScreenState(); +} + +class _OfflineMapsScreenState extends State { + @override + void initState() { + super.initState(); + // Listen for background download completions to show a toast. + final service = context.read(); + service.addListener(_onServiceUpdate); + } + + @override + void dispose() { + // Use try-catch in case the provider is already disposed during app teardown. + try { + context.read().removeListener(_onServiceUpdate); + } catch (_) {} + super.dispose(); + } + + void _onServiceUpdate() { + if (!mounted) return; + final service = context.read(); + final completed = service.consumeLastCompletedName(); + if (completed != null) { + AppToast.success(context, '"$completed" downloaded'); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final service = context.watch(); + + return Scaffold( + appBar: AppBar( + toolbarHeight: 40, + title: const Text('Offline Maps', style: TextStyle(fontSize: 18)), + ), + body: !service.initialized + ? const Center(child: CircularProgressIndicator()) + : kIsWeb + ? _buildWebUnsupported(theme) + : ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), + children: [ + _buildStorageCard(context, service, theme, isDark), + const SizedBox(height: 8), + _buildDownloadedRegionsCard( + context, service, theme, isDark), + const SizedBox(height: 8), + if (service.isDownloading) + _buildDownloadProgressCard( + context, service, theme, isDark), + ], + ), + floatingActionButton: + (service.initialized && !kIsWeb && !service.isDownloading) + ? FloatingActionButton.extended( + onPressed: () => _showDownloadDialog(context), + icon: const Icon(Icons.download), + label: const Text('Download Region'), + ) + : null, + ); + } + + Widget _buildWebUnsupported(ThemeData theme) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_off, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + 'Offline maps are not available on web', + style: theme.textTheme.titleMedium + ?.copyWith(color: Colors.grey.shade500), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Use the mobile app to download map regions for offline use', + style: theme.textTheme.bodySmall + ?.copyWith(color: Colors.grey.shade400), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + // ────────────────────────────────────────────── + // Storage usage card + // ────────────────────────────────────────────── + + Widget _buildStorageCard(BuildContext context, OfflineMapService service, + ThemeData theme, bool isDark) { + final usageRatio = service.usageRatio; + final barColor = usageRatio > 0.9 + ? Colors.red + : usageRatio > 0.7 + ? Colors.orange + : theme.colorScheme.primary; + + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Storage', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: () => _showStorageLimitDialog(context, service), + icon: const Icon(Icons.tune, size: 16), + label: const Text('Limit'), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Usage bar + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: usageRatio, + minHeight: 20, + backgroundColor: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.grey.shade200, + color: barColor, + ), + ), + const SizedBox(height: 8), + + // Usage text + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${service.totalUsedDisplay} used', + style: theme.textTheme.bodySmall?.copyWith( + color: barColor, + fontWeight: FontWeight.w600, + ), + ), + Text( + '${service.storageLimitDisplay} limit', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + '${service.regions.length} region${service.regions.length == 1 ? '' : 's'} downloaded', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey.shade500, + fontSize: 11, + ), + ), + ], + ), + ), + ); + } + + // ────────────────────────────────────────────── + // Downloaded regions list + // ────────────────────────────────────────────── + + Widget _buildDownloadedRegionsCard(BuildContext context, + OfflineMapService service, ThemeData theme, bool isDark) { + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 0), + child: Row( + children: [ + Text( + 'Downloaded Regions', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + if (service.regions.isNotEmpty) + IconButton( + icon: const Icon(Icons.refresh, size: 20), + onPressed: () => service.refreshRegions(), + tooltip: 'Refresh', + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + if (service.regions.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + children: [ + Icon(Icons.map_outlined, + size: 48, color: Colors.grey.shade400), + const SizedBox(height: 8), + Text( + 'No offline regions downloaded', + style: TextStyle(color: Colors.grey.shade500), + ), + const SizedBox(height: 4), + Text( + 'Tap "Download Region" to save map tiles for offline use', + style: TextStyle(fontSize: 12, color: Colors.grey.shade400), + textAlign: TextAlign.center, + ), + ], + ), + ) + else + ...service.regions.map( + (region) => _RegionTile( + region: region, + onDelete: () => _confirmDeleteRegion(context, service, region), + ), + ), + if (service.regions.length > 1) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: OutlinedButton.icon( + onPressed: () => _confirmDeleteAll(context, service), + icon: const Icon(Icons.delete_sweep, size: 18), + label: const Text('Delete All'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide(color: Colors.red, width: 0.5), + minimumSize: const Size.fromHeight(36), + visualDensity: VisualDensity.compact, + ), + ), + ), + const SizedBox(height: 4), + ], + ), + ); + } + + // ────────────────────────────────────────────── + // Download progress card + // ────────────────────────────────────────────── + + Widget _buildDownloadProgressCard(BuildContext context, + OfflineMapService service, ThemeData theme, bool isDark) { + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Downloading', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + service.downloadingRegionName ?? 'Region', + style: theme.textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: service.downloadProgress ?? 0, + minHeight: 8, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Text( + '${((service.downloadProgress ?? 0) * 100).round()}%', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Download continues in the background if you leave this screen', + style: TextStyle(fontSize: 11, color: Colors.grey.shade500), + ), + ], + ), + ), + ); + } + + // ────────────────────────────────────────────── + // Storage limit dialog + // ────────────────────────────────────────────── + + Future _showStorageLimitDialog( + BuildContext context, OfflineMapService service) async { + int currentLimit = service.storageLimitMb; + + final result = await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setDialogState) { + return AlertDialog( + title: const Text('Storage Limit'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$currentLimit MB', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 16), + Slider( + value: currentLimit.toDouble(), + min: OfflineMapService.minStorageLimitMb.toDouble(), + max: OfflineMapService.maxStorageLimitMb.toDouble(), + divisions: (OfflineMapService.maxStorageLimitMb - + OfflineMapService.minStorageLimitMb) ~/ + 50, + label: '$currentLimit MB', + onChanged: (value) { + setDialogState(() => currentLimit = value.round()); + }, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${OfflineMapService.minStorageLimitMb} MB', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + '${OfflineMapService.maxStorageLimitMb} MB', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Currently using ${service.totalUsedDisplay}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, currentLimit), + child: const Text('Save'), + ), + ], + ); + }, + ); + }, + ); + + if (result != null) { + await service.setStorageLimit(result); + if (context.mounted) { + AppToast.success(context, 'Storage limit set to $result MB'); + } + } + } + + // ────────────────────────────────────────────── + // Download new region dialog + // ────────────────────────────────────────────── + + Future _showDownloadDialog(BuildContext context) async { + final started = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const _DownloadRegionPage(), + ), + ); + + // Toast is handled by the _onServiceUpdate listener when the download + // completes (which may happen long after this page returns). + if (started == true && context.mounted) { + AppToast.simple( + context, 'Download started — check notifications for progress'); + } + } + + // ────────────────────────────────────────────── + // Delete confirmations + // ────────────────────────────────────────────── + + Future _confirmDeleteRegion(BuildContext context, + OfflineMapService service, OfflineMapRegion region) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete Region?'), + content: Text( + 'Delete "${region.name}"? This will free approximately ' + '${region.sizeDisplay} of storage.\n\n' + 'Note: shared tiles used by other regions may not be freed immediately.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true) { + final success = await service.deleteRegion(region.id); + if (context.mounted) { + if (success) { + AppToast.success(context, '"${region.name}" deleted'); + } else { + AppToast.error( + context, service.lastError ?? 'Failed to delete region'); + } + } + } + } + + Future _confirmDeleteAll( + BuildContext context, OfflineMapService service) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete All Regions?'), + content: Text( + 'Delete all ${service.regions.length} downloaded regions? ' + 'This will free approximately ${service.totalUsedDisplay}.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Delete All'), + ), + ], + ), + ); + + if (confirmed == true) { + await service.deleteAllRegions(); + if (context.mounted) { + AppToast.success(context, 'All regions deleted'); + } + } + } +} + +// ═══════════════════════════════════════════════ +// Region list tile +// ═══════════════════════════════════════════════ + +class _RegionTile extends StatelessWidget { + final OfflineMapRegion region; + final VoidCallback onDelete; + + const _RegionTile({required this.region, required this.onDelete}); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: _styleIcon(region.styleName), + title: Text( + region.name, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${region.styleName} · z${region.minZoom.round()}-${region.maxZoom.round()} · ${region.sizeDisplay}\n' + '${region.boundsDisplay}', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + isThreeLine: true, + trailing: IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20), + onPressed: onDelete, + tooltip: 'Delete', + ), + ); + } + + Widget _styleIcon(String styleName) { + switch (styleName.toLowerCase()) { + case 'dark': + return const Icon(Icons.dark_mode); + case 'light': + return const Icon(Icons.light_mode); + case 'satellite': + return const Icon(Icons.satellite_alt); + case 'liberty': + default: + return const Icon(Icons.map); + } + } +} + +// ═══════════════════════════════════════════════ +// Download region flow (full-page) +// ═══════════════════════════════════════════════ + +class _DownloadRegionPage extends StatefulWidget { + const _DownloadRegionPage(); + + @override + State<_DownloadRegionPage> createState() => _DownloadRegionPageState(); +} + +class _DownloadRegionPageState extends State<_DownloadRegionPage> { + final _nameController = TextEditingController(); + String _selectedStyle = 'Liberty'; + double _minZoom = 6; + double _maxZoom = 15; + bool _submitting = false; + String? _error; + + // Default center (Ottawa) + static const LatLng _defaultCenter = LatLng(45.4215, -75.6972); + + // Bounds selection via interactive map + MapLibreMapController? _mapController; + LatLng? _boundsNE; + LatLng? _boundsSW; + int _tapCount = 0; + Line? _boundsLine; + Fill? _boundsFill; + + // Existing region overlays + final List _existingFills = []; + final List _existingLines = []; + bool _showExisting = true; + + @override + void initState() { + super.initState(); + // grab user's current map style to start + final pref = context.read().preferences.mapStyle; + final mapped = pref.substring(0, 1).toUpperCase() + pref.substring(1); + if (_downloadStyles.containsKey(mapped)) { + _selectedStyle = mapped; + } + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + LatLngBounds? get _selectedBounds { + if (_boundsNE == null || _boundsSW == null) return null; + return LatLngBounds(southwest: _boundsSW!, northeast: _boundsNE!); + } + + bool get _canSubmit => + _nameController.text.trim().isNotEmpty && + _selectedBounds != null && + !_submitting; + + int get _estimatedTiles { + final bounds = _selectedBounds; + if (bounds == null) return 0; + return OfflineMapService.estimateTileCount(bounds, _minZoom, _maxZoom); + } + + String get _estimatedSize { + final bytes = OfflineMapService.estimateSizeBytes(_estimatedTiles); + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(0)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + // Determine map center - prefer current GPS, fallback to last known, then Ottawa + LatLng center = _defaultCenter; + if (appState.currentPosition != null) { + center = LatLng( + appState.currentPosition!.latitude, + appState.currentPosition!.longitude, + ); + } else if (appState.lastKnownPosition != null) { + center = LatLng( + appState.lastKnownPosition!.lat, + appState.lastKnownPosition!.lon, + ); + } + + return Scaffold( + appBar: AppBar( + toolbarHeight: 40, + title: const Text('Download Region', style: TextStyle(fontSize: 18)), + ), + body: Column( + children: [ + // Map for bounds selection + Expanded( + flex: 3, + child: Stack( + children: [ + MapLibreMap( + styleString: _downloadStyles[_selectedStyle]!, + initialCameraPosition: CameraPosition( + target: center, // Vancouver default + zoom: 10, + ), + onMapCreated: (controller) { + _mapController = controller; + }, + onStyleLoadedCallback: () { + if (_showExisting) _drawExistingRegions(); + }, + onMapClick: _onMapTap, + rotateGesturesEnabled: false, + tiltGesturesEnabled: false, + ), + // Bounds instruction overlay + Positioned( + top: 8, + left: 8, + right: 8, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: (isDark ? Colors.black : Colors.white) + .withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _selectedBounds != null + ? 'Region selected · ~$_estimatedTiles tiles · $_estimatedSize' + : _tapCount == 1 + ? 'Tap the opposite corner to complete the region' + : 'Tap two corners on the map to select a region', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ), + // Reset bounds button + if (_selectedBounds != null) + Positioned( + top: 8, + right: 8, + child: Material( + type: MaterialType.circle, + color: theme.colorScheme.primaryContainer, + elevation: 2, + child: InkWell( + customBorder: const CircleBorder(), + onTap: _resetBounds, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(Icons.restart_alt, + size: 20, + color: theme.colorScheme.onPrimaryContainer), + ), + ), + ), + ), + // Existing regions toggle + Positioned( + bottom: 8, + left: 8, + child: Material( + borderRadius: BorderRadius.circular(16), + color: (isDark ? Colors.black : Colors.white) + .withValues(alpha: 0.85), + elevation: 2, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: _toggleExistingRegions, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _showExisting + ? Icons.visibility + : Icons.visibility_off, + size: 14, + color: const Color(0xFFF59E0B), + ), + const SizedBox(width: 4), + Text( + '${context.read().regions.length} existing', + style: theme.textTheme.bodySmall?.copyWith( + fontSize: 11, + color: _showExisting + ? const Color(0xFFF59E0B) + : Colors.grey, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + + // Configuration panel + Expanded( + flex: 2, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Region name + TextField( + controller: _nameController, + decoration: InputDecoration( + labelText: 'Region Name', + hintText: 'e.g. Downtown Vancouver', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8)), + isDense: true, + prefixIcon: const Icon(Icons.label_outline, size: 20), + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + + // Style selector + Row( + children: [ + const Text('Style: ', + style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(width: 8), + Expanded( + child: SegmentedButton( + segments: _downloadStyles.keys + .map((s) => ButtonSegment( + value: s, + label: Text(s, + style: const TextStyle(fontSize: 12)), + )) + .toList(), + selected: {_selectedStyle}, + onSelectionChanged: (selected) { + setState(() => _selectedStyle = selected.first); + }, + style: SegmentedButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Zoom range + Row( + children: [ + Text( + 'Zoom: ${_minZoom.round()} – ${_maxZoom.round()}', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + const Spacer(), + Text( + '~$_estimatedTiles tiles', + style: TextStyle( + fontSize: 12, color: Colors.grey.shade500), + ), + ], + ), + RangeSlider( + values: RangeValues(_minZoom, _maxZoom), + min: 0, + max: 18, + divisions: 18, + labels: RangeLabels( + _minZoom.round().toString(), + _maxZoom.round().toString(), + ), + onChanged: (values) { + setState(() { + _minZoom = values.start; + _maxZoom = values.end; + }); + }, + ), + + if (_error != null) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.red.withValues(alpha: 0.3)), + ), + child: Text( + _error!, + style: const TextStyle(color: Colors.red, fontSize: 12), + ), + ), + ], + + const SizedBox(height: 12), + + // Download button + FilledButton.icon( + onPressed: _canSubmit ? _startDownload : null, + icon: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.download), + label: + Text(_submitting ? 'Starting...' : 'Download Region'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(44), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + void _onMapTap(Point point, LatLng coordinates) { + setState(() { + if (_tapCount == 0) { + _boundsSW = coordinates; + _boundsNE = null; + _tapCount = 1; + _clearBoundsOverlay(); + } else if (_tapCount == 1) { + // Ensure SW is actually southwest and NE is northeast + final lat1 = _boundsSW!.latitude; + final lng1 = _boundsSW!.longitude; + final lat2 = coordinates.latitude; + final lng2 = coordinates.longitude; + + _boundsSW = LatLng( + lat1 < lat2 ? lat1 : lat2, + lng1 < lng2 ? lng1 : lng2, + ); + _boundsNE = LatLng( + lat1 > lat2 ? lat1 : lat2, + lng1 > lng2 ? lng1 : lng2, + ); + _tapCount = 2; + _drawBoundsOverlay(); + } + }); + } + + void _resetBounds() { + _clearBoundsOverlay(); + setState(() { + _boundsSW = null; + _boundsNE = null; + _tapCount = 0; + }); + } + + /// Draw outlines for all previously downloaded regions so the user + /// can see existing coverage while selecting a new area. + Future _drawExistingRegions() async { + if (_mapController == null) return; + final service = context.read(); + for (final region in service.regions) { + try { + final sw = region.bounds.southwest; + final ne = region.bounds.northeast; + final nw = LatLng(ne.latitude, sw.longitude); + final se = LatLng(sw.latitude, ne.longitude); + final ring = [sw, se, ne, nw, sw]; + + final fill = await _mapController!.addFill(FillOptions( + geometry: [ring], + fillColor: '#F59E0B', // amber-500 + fillOpacity: 0.10, + )); + final line = await _mapController!.addLine(LineOptions( + geometry: ring, + lineColor: '#F59E0B', + lineWidth: 1.5, + lineOpacity: 0.6, + )); + _existingFills.add(fill); + _existingLines.add(line); + } catch (e) { + debugPrint( + '[OFFLINE_MAP] Failed to draw existing region ${region.name}: $e'); + } + } + } + + Future _clearExistingRegions() async { + if (_mapController == null) return; + for (final f in _existingFills) { + try { + await _mapController!.removeFill(f); + } catch (_) {} + } + for (final l in _existingLines) { + try { + await _mapController!.removeLine(l); + } catch (_) {} + } + _existingFills.clear(); + _existingLines.clear(); + } + + void _toggleExistingRegions() { + setState(() => _showExisting = !_showExisting); + if (_showExisting) { + _drawExistingRegions(); + } else { + _clearExistingRegions(); + } + } + + Future _drawBoundsOverlay() async { + if (_mapController == null || _boundsSW == null || _boundsNE == null) { + return; + } + + final sw = _boundsSW!; + final ne = _boundsNE!; + final nw = LatLng(ne.latitude, sw.longitude); + final se = LatLng(sw.latitude, ne.longitude); + final ring = [sw, se, ne, nw, sw]; + + try { + _boundsFill = await _mapController!.addFill(FillOptions( + geometry: [ring], + fillColor: '#4A90D9', + fillOpacity: 0.15, + )); + _boundsLine = await _mapController!.addLine(LineOptions( + geometry: ring, + lineColor: '#4A90D9', + lineWidth: 2.0, + lineOpacity: 0.8, + )); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to draw bounds overlay: $e'); + } + } + + Future _clearBoundsOverlay() async { + if (_mapController == null) return; + try { + if (_boundsFill != null) { + await _mapController!.removeFill(_boundsFill!); + _boundsFill = null; + } + if (_boundsLine != null) { + await _mapController!.removeLine(_boundsLine!); + _boundsLine = null; + } + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to clear bounds overlay: $e'); + } + } + + Future _startDownload() async { + final bounds = _selectedBounds; + if (bounds == null) return; + + final name = _nameController.text.trim(); + if (name.isEmpty) return; + + final service = context.read(); + final styleUrl = _downloadStyles[_selectedStyle]!; + + // Check storage limit + final estBytes = OfflineMapService.estimateSizeBytes(_estimatedTiles); + if (service.wouldExceedLimit(estBytes)) { + setState(() { + _error = 'This download would exceed your storage limit. ' + 'Free up space or increase the limit in storage settings.'; + }); + return; + } + + setState(() { + _submitting = true; + _error = null; + }); + + // Fire-and-forget: the service runs the download in the background + // and shows a system notification for progress. We just kick it off + // and return to the management screen. + service.downloadRegion( + name: name, + bounds: bounds, + styleUrl: styleUrl, + styleName: _selectedStyle, + minZoom: _minZoom, + maxZoom: _maxZoom, + ); + + // Give the service a tick to validate and start + await Future.delayed(const Duration(milliseconds: 100)); + + if (!mounted) return; + + if (service.lastError != null && !service.isDownloading) { + setState(() { + _submitting = false; + _error = service.lastError; + }); + } else { + // Download is queued — return to the management screen + Navigator.pop(context, true); + } + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 5f269d7..29ecb17 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -30,6 +30,7 @@ import '../widgets/bug_report_dialog.dart'; import '../widgets/upload_logs_dialog.dart'; import 'package:intl/intl.dart'; import '../widgets/app_toast.dart'; +import 'offline_maps_screen.dart'; /// Settings screen for user preferences and API configuration class SettingsScreen extends StatefulWidget { @@ -160,13 +161,44 @@ class _SettingsScreenState extends State { title: const Text('Disable Map Tiles'), subtitle: Text(prefs.mapTilesEnabled ? 'Map and coverage tiles load normally' - : 'Disabled to save mobile data'), + : 'Network tiles disabled · downloaded regions still visible'), value: !prefs.mapTilesEnabled, onChanged: (value) { appState .updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); }, ), + if (!kIsWeb) + ListTile( + leading: const Icon(Icons.download_for_offline), + title: const Text('Offline Maps'), + subtitle: const Text('Download map tiles for offline use'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const OfflineMapsScreen(), + ), + ); + }, + ), + if (prefs.mapTilesEnabled) + ListTile( + leading: const Icon(Icons.opacity), + title: const Text('Coverage Overlay Opacity'), + subtitle: Slider( + value: prefs.coverageOverlayOpacity.clamp(0.3, 1.0), + min: 0.3, + max: 1.0, + divisions: 7, + label: '${(prefs.coverageOverlayOpacity * 100).round()}%', + onChanged: (value) => + appState.setCoverageOverlayOpacity(value), + ), + trailing: + Text('${(prefs.coverageOverlayOpacity * 100).round()}%'), + ), ListTile( leading: const Icon(Icons.visibility), title: const Text('Color Vision'), diff --git a/lib/services/offline_map_service.dart b/lib/services/offline_map_service.dart new file mode 100644 index 0000000..8040866 --- /dev/null +++ b/lib/services/offline_map_service.dart @@ -0,0 +1,529 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Metadata keys stored with each offline region. +class _MetaKeys { + _MetaKeys._(); + static const name = 'name'; + static const styleName = 'styleName'; + static const createdAt = 'createdAt'; + + /// Estimated size in bytes (rough heuristic based on tile count). + static const estimatedBytes = 'estimatedBytes'; +} + +/// A user-friendly wrapper around a raw [OfflineRegion]. +class OfflineMapRegion { + final int id; + final String name; + final String styleName; + final LatLngBounds bounds; + final double minZoom; + final double maxZoom; + final DateTime createdAt; + final int estimatedBytes; + + const OfflineMapRegion({ + required this.id, + required this.name, + required this.styleName, + required this.bounds, + required this.minZoom, + required this.maxZoom, + required this.createdAt, + required this.estimatedBytes, + }); + + factory OfflineMapRegion.fromOfflineRegion(OfflineRegion region) { + final meta = region.metadata; + return OfflineMapRegion( + id: region.id, + name: (meta[_MetaKeys.name] as String?) ?? 'Region ${region.id}', + styleName: (meta[_MetaKeys.styleName] as String?) ?? 'Unknown', + bounds: region.definition.bounds, + minZoom: region.definition.minZoom, + maxZoom: region.definition.maxZoom, + createdAt: + DateTime.tryParse((meta[_MetaKeys.createdAt] as String?) ?? '') ?? + DateTime.now(), + // Platform channel JSON round-trip can return int as num/double. + estimatedBytes: (meta[_MetaKeys.estimatedBytes] as num?)?.toInt() ?? 0, + ); + } + + /// Human-readable size string. + String get sizeDisplay { + if (estimatedBytes < 1024) return '$estimatedBytes B'; + if (estimatedBytes < 1024 * 1024) { + return '${(estimatedBytes / 1024).toStringAsFixed(1)} KB'; + } + return '${(estimatedBytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + /// Short bounds description (e.g. "49.2°N, 123.1°W"). + String get boundsDisplay { + final sw = bounds.southwest; + final ne = bounds.northeast; + final latCenter = (sw.latitude + ne.latitude) / 2; + final lngCenter = (sw.longitude + ne.longitude) / 2; + final latDir = latCenter >= 0 ? 'N' : 'S'; + final lngDir = lngCenter >= 0 ? 'E' : 'W'; + return '${latCenter.abs().toStringAsFixed(2)}°$latDir, ' + '${lngCenter.abs().toStringAsFixed(2)}°$lngDir'; + } +} + +/// Manages offline map tile downloads, listing, deletion, and storage limits. +/// +/// Lives at the app level (provided via [ChangeNotifierProvider] in main.dart) +/// so downloads continue when the user navigates away from the Offline Maps +/// screen. A system notification shows real-time progress. +/// +/// Not available on web (maplibre_gl offline APIs are mobile-only). +class OfflineMapService extends ChangeNotifier { + static const _storageLimitKey = 'offline_map_storage_limit_mb'; + static const int defaultStorageLimitMb = 500; + static const int minStorageLimitMb = 50; + static const int maxStorageLimitMb = 5000; + + // ── Notification constants ── + static const String _notifChannelId = 'meshmapper_offline_maps'; + static const String _notifChannelName = 'Offline Map Downloads'; + static const int _progressNotifId = 889; + static const int _completeNotifId = 890; + + final FlutterLocalNotificationsPlugin _notifPlugin = + FlutterLocalNotificationsPlugin(); + bool _notifInitialized = false; + + // ── Region state ── + List _regions = []; + List get regions => List.unmodifiable(_regions); + + int _storageLimitMb = defaultStorageLimitMb; + int get storageLimitMb => _storageLimitMb; + int get storageLimitBytes => _storageLimitMb * 1024 * 1024; + + /// Total estimated bytes across all downloaded regions. + int get totalUsedBytes => + _regions.fold(0, (sum, r) => sum + r.estimatedBytes); + + double get usageRatio { + if (storageLimitBytes == 0) return 0; + return (totalUsedBytes / storageLimitBytes).clamp(0.0, 1.0); + } + + String get totalUsedDisplay => _formatBytes(totalUsedBytes); + String get storageLimitDisplay => '$_storageLimitMb MB'; + + // ── Download state ── + + /// Currently active download progress (null if idle). + double? _downloadProgress; + double? get downloadProgress => _downloadProgress; + + String? _downloadingRegionName; + String? get downloadingRegionName => _downloadingRegionName; + + bool get isDownloading => _downloadProgress != null; + + String? _lastError; + String? get lastError => _lastError; + + /// Name of the most recently completed download (for one-shot UI toast). + /// Call [consumeLastCompletedName] to read and clear. + String? _lastCompletedName; + String? consumeLastCompletedName() { + final name = _lastCompletedName; + _lastCompletedName = null; + return name; + } + + bool _initialized = false; + bool get initialized => _initialized; + + // ── Initialization ── + + /// Initialize: create notification channel, load storage limit, refresh list. + /// Safe to call multiple times — subsequent calls are no-ops. + Future initialize() async { + if (kIsWeb) return; + if (_initialized) return; + try { + await _initNotifications(); + final prefs = await SharedPreferences.getInstance(); + _storageLimitMb = prefs.getInt(_storageLimitKey) ?? defaultStorageLimitMb; + await refreshRegions(); + _initialized = true; + notifyListeners(); + } catch (e) { + debugPrint('[OFFLINE_MAP] Init error: $e'); + notifyListeners(); + } + } + + /// Set up the Android notification channel for download progress. + Future _initNotifications() async { + if (_notifInitialized) return; + try { + const AndroidNotificationChannel channel = AndroidNotificationChannel( + _notifChannelId, + _notifChannelName, + description: 'Shows progress when downloading offline map tiles', + importance: Importance.low, // No sound/vibration + showBadge: false, + ); + + await _notifPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.createNotificationChannel(channel); + + // Initialize the plugin (required before showing notifications). + const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher'); + const iosInit = DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ); + await _notifPlugin.initialize( + const InitializationSettings(android: androidInit, iOS: iosInit), + ); + _notifInitialized = true; + } catch (e) { + debugPrint('[OFFLINE_MAP] Notification init error: $e'); + } + } + + // ── Notifications ── + + Future _showProgressNotification(String regionName, int percent) async { + if (!_notifInitialized) return; + try { + final androidDetails = AndroidNotificationDetails( + _notifChannelId, + _notifChannelName, + channelDescription: 'Shows progress when downloading offline map tiles', + importance: Importance.low, + priority: Priority.low, + showProgress: true, + maxProgress: 100, + progress: percent, + ongoing: true, // Non-dismissable while downloading + autoCancel: false, + onlyAlertOnce: true, // Don't buzz on every update + icon: '@mipmap/ic_launcher', + ); + + await _notifPlugin.show( + _progressNotifId, + 'Downloading "$regionName"', + '$percent% complete', + NotificationDetails(android: androidDetails), + ); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to show progress notification: $e'); + } + } + + Future _showCompleteNotification(String regionName) async { + await _dismissProgressNotification(); + if (!_notifInitialized) return; + try { + const androidDetails = AndroidNotificationDetails( + _notifChannelId, + _notifChannelName, + channelDescription: 'Shows progress when downloading offline map tiles', + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + icon: '@mipmap/ic_launcher', + ); + + await _notifPlugin.show( + _completeNotifId, + 'Download Complete', + '"$regionName" is ready for offline use', + const NotificationDetails(android: androidDetails), + ); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to show complete notification: $e'); + } + } + + Future _showErrorNotification(String regionName) async { + await _dismissProgressNotification(); + if (!_notifInitialized) return; + try { + const androidDetails = AndroidNotificationDetails( + _notifChannelId, + _notifChannelName, + channelDescription: 'Shows progress when downloading offline map tiles', + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + icon: '@mipmap/ic_launcher', + ); + + await _notifPlugin.show( + _completeNotifId, + 'Download Failed', + 'Failed to download "$regionName"', + const NotificationDetails(android: androidDetails), + ); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to show error notification: $e'); + } + } + + Future _dismissProgressNotification() async { + try { + await _notifPlugin.cancel(_progressNotifId); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to dismiss notification: $e'); + } + } + + // ── Region queries ── + + /// Refresh the list of downloaded regions from MapLibre native storage. + Future refreshRegions() async { + if (kIsWeb) return; + try { + final rawRegions = await getListOfRegions(); + final parsed = []; + for (final r in rawRegions) { + try { + parsed.add(OfflineMapRegion.fromOfflineRegion(r)); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to parse region ${r.id}: $e'); + } + } + _regions = parsed; + _regions.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + notifyListeners(); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to list regions: $e'); + } + } + + // ── Storage limit ── + + /// Update the storage limit (in MB) and persist it. + Future setStorageLimit(int limitMb) async { + _storageLimitMb = limitMb.clamp(minStorageLimitMb, maxStorageLimitMb); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_storageLimitKey, _storageLimitMb); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to save storage limit: $e'); + } + notifyListeners(); + } + + // ── Tile estimation ── + + /// Estimate tile count for a region (rough heuristic). + /// Uses the standard 2^z tile count formula for each zoom level. + static int estimateTileCount( + LatLngBounds bounds, double minZoom, double maxZoom) { + int total = 0; + for (int z = minZoom.floor(); z <= maxZoom.ceil(); z++) { + final tilesPerSide = 1 << z; // 2^z + final lonFraction = + (bounds.northeast.longitude - bounds.southwest.longitude).abs() / + 360.0; + final latFraction = + (bounds.northeast.latitude - bounds.southwest.latitude).abs() / 180.0; + final xTiles = (lonFraction * tilesPerSide).ceil().clamp(1, tilesPerSide); + final yTiles = (latFraction * tilesPerSide).ceil().clamp(1, tilesPerSide); + total += xTiles * yTiles; + } + return total; + } + + /// Rough estimate of download size in bytes from tile count. + /// Vector tiles average ~15-25 KB each; raster tiles ~20-40 KB. + /// We use 20 KB as a middle estimate. + static int estimateSizeBytes(int tileCount) => tileCount * 20 * 1024; + + /// Check if downloading a region of [estimatedBytes] would exceed the limit. + bool wouldExceedLimit(int estimatedBytes) => + (totalUsedBytes + estimatedBytes) > storageLimitBytes; + + // ── Download ── + + /// Download an offline region. + /// + /// The download runs in MapLibre's native layer, so it survives Flutter + /// screen navigation. This service (kept alive by the app-level Provider) + /// receives progress callbacks and forwards them to both [notifyListeners] + /// and a system notification. + /// + /// Returns the new [OfflineMapRegion] on success, null on failure. + Future downloadRegion({ + required String name, + required LatLngBounds bounds, + required String styleUrl, + required String styleName, + double minZoom = 0, + double maxZoom = 14, + }) async { + if (kIsWeb) return null; + if (isDownloading) { + _lastError = 'A download is already in progress'; + notifyListeners(); + return null; + } + + final tileCount = estimateTileCount(bounds, minZoom, maxZoom); + final estBytes = estimateSizeBytes(tileCount); + + if (wouldExceedLimit(estBytes)) { + _lastError = + 'Download would exceed storage limit (${_formatBytes(estBytes)} needed, ' + '${_formatBytes(storageLimitBytes - totalUsedBytes)} remaining)'; + notifyListeners(); + return null; + } + + _downloadProgress = 0; + _downloadingRegionName = name; + _lastError = null; + _lastCompletedName = null; + notifyListeners(); + _showProgressNotification(name, 0); + + try { + final definition = OfflineRegionDefinition( + bounds: bounds, + mapStyleUrl: styleUrl, + minZoom: minZoom, + maxZoom: maxZoom, + ); + + final metadata = { + _MetaKeys.name: name, + _MetaKeys.styleName: styleName, + _MetaKeys.createdAt: DateTime.now().toIso8601String(), + _MetaKeys.estimatedBytes: estBytes, + }; + + final region = await downloadOfflineRegion( + definition, + metadata: metadata, + onEvent: _onDownloadEvent, + ); + + // downloadOfflineRegion resolves once the native download is queued, + // not necessarily when it finishes. The _onDownloadEvent callback + // handles completion. But if progress is already null (Success fired + // synchronously), the download completed inline. + if (_downloadProgress != null) { + // Still in progress — the event callback will finalize. + return null; + } + + // Completed synchronously (small region / cached tiles) + _downloadProgress = null; + _downloadingRegionName = null; + _lastCompletedName = name; + await refreshRegions(); + _showCompleteNotification(name); + return _regions.firstWhere((r) => r.id == region.id, + orElse: () => OfflineMapRegion.fromOfflineRegion(region)); + } catch (e) { + _downloadProgress = null; + _downloadingRegionName = null; + _lastError = 'Download failed: $e'; + notifyListeners(); + _showErrorNotification(name); + return null; + } + } + + void _onDownloadEvent(DownloadRegionStatus status) { + if (status is Success) { + final name = _downloadingRegionName ?? 'Region'; + _downloadProgress = null; + _downloadingRegionName = null; + _lastCompletedName = name; + notifyListeners(); // Immediately clear progress state + _showCompleteNotification(name); + // Small delay lets the native DB commit before we query it. + Future.delayed(const Duration(milliseconds: 500), () { + refreshRegions(); + }); + } else if (status is InProgress) { + _downloadProgress = status.progress / 100.0; + notifyListeners(); + // Throttle notification updates to every 2% to avoid flooding + final percent = status.progress.round(); + if (percent % 2 == 0) { + _showProgressNotification(_downloadingRegionName ?? 'Region', percent); + } + } else { + // Error status + final name = _downloadingRegionName ?? 'Region'; + _downloadProgress = null; + _downloadingRegionName = null; + _lastError = 'Download error occurred'; + _showErrorNotification(name); + notifyListeners(); + } + } + + // ── Deletion ── + + /// Delete a downloaded region by ID. + Future deleteRegion(int regionId) async { + if (kIsWeb) return false; + try { + await deleteOfflineRegion(regionId); + await refreshRegions(); + return true; + } catch (e) { + debugPrint('[OFFLINE_MAP] Delete failed: $e'); + _lastError = 'Failed to delete region: $e'; + notifyListeners(); + return false; + } + } + + /// Delete all downloaded regions. + Future deleteAllRegions() async { + if (kIsWeb) return; + final ids = _regions.map((r) => r.id).toList(); + for (final id in ids) { + try { + await deleteOfflineRegion(id); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to delete region $id: $e'); + } + } + await refreshRegions(); + } + + // ── Cleanup ── + + /// Cancel any stale progress notification from a previous session. + /// Called at app startup (mirrors BackgroundServiceManager.cleanupOrphanedService). + Future cleanupOrphanedNotification() async { + if (kIsWeb) return; + try { + await _initNotifications(); + await _notifPlugin.cancel(_progressNotifId); + } catch (e) { + debugPrint('[OFFLINE_MAP] Failed to cleanup orphaned notification: $e'); + } + } + + static String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index ab35995..d9d2ca7 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1,11 +1,11 @@ +import 'dart:async'; import 'dart:math' as math; +import 'dart:typed_data'; import 'dart:ui' as ui; -import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_cancellable_tile_provider/flutter_map_cancellable_tile_provider.dart'; -import 'package:latlong2/latlong.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:provider/provider.dart'; import '../models/log_entry.dart'; @@ -18,24 +18,198 @@ import '../utils/distance_formatter.dart'; import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; -/// Map style options +/// Satellite style as inline MapLibre style JSON (ArcGIS raster source). +/// The `glyphs` URL is required because our native symbol layers +/// (repeater cluster count, individual repeater hex IDs, distance labels) +/// use `textField`, and MapLibre iOS wedges its resource loader with +/// NSURLError -1002 if it tries to resolve glyphs against a style that +/// doesn't declare a glyphs URL. +const _satelliteStyleJson = + '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{"satellite":{"type":"raster","tiles":["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"],"tileSize":256,"maxzoom":17}},"layers":[{"id":"satellite-layer","type":"raster","source":"satellite"}]}'; + +/// Blank style with dark background — used when mapTilesEnabled is false +/// (saves mobile data while still showing markers and overlays). +/// Includes a `glyphs` URL so native annotations using textField (repeater +/// hex IDs, distance labels) can render their text even when tiles are off. +// const _blankStyleJson = +// '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{},"layers":[{"id":"background","type":"background","paint":{"background-color":"#0F172A"}}]}'; + +/// Default font stack used for all native text labels (textField property). +/// Available in OpenFreeMap glyph sets (Liberty, Bright, Dark, Positron). +const _defaultFontStack = ['Noto Sans Regular']; + +/// Image-name constants for the marker bitmaps registered via +/// `controller.addImage()` and referenced by `SymbolOptions.iconImage`. +/// +/// Repeater shapes have one bitmap per (status color × hop_byte shape) — 12 +/// total. Coverage markers have one bitmap per (ping type × success state) for +/// the user's currently-selected style — 8 total per style preference. GPS +/// marker has one bitmap per style — 6 total. +class _MapImages { + _MapImages._(); + + // Repeater shape bitmaps: status × hop_bytes + // Names: rep_active_1, rep_dead_2, rep_dup_3, etc. + static String repeater(String status, int hopBytes) => + 'rep_${status}_$hopBytes'; + + static const repeaterStatuses = ['active', 'dead', 'new', 'dup']; + static const repeaterHopBytes = [1, 2, 3]; + + // Coverage marker bitmaps: type × success state + // Names: cov_tx_ok, cov_disc_fail, etc. + static String coverage(String type, bool success) => + 'cov_${type}_${success ? "ok" : "fail"}'; + + static const coverageTypes = ['tx', 'rx', 'disc', 'trace']; + + // GPS marker bitmaps: one per style + // Names: gps_arrow, gps_car, etc. The list of styles lives in + // _registerMapImages where we map each style key to its CustomPainter. + static String gps(String style) => 'gps_$style'; +} + +/// Renders a [CustomPainter] into a PNG byte buffer using `dart:ui`. +/// +/// This is the bridge between our existing Flutter `CustomPainter` marker +/// rendering code and MapLibre's native annotation system. The bytes returned +/// here can be passed to `controller.addImage(name, bytes)` and then referenced +/// by `SymbolOptions.iconImage: name`. The native engine renders the symbol +/// in the same pass as the map tiles, eliminating the Flutter platform-view +/// sync lag that affects widget overlays. +/// +/// [size] is the logical size in pixels — the output bitmap is upscaled by +/// [devicePixelRatio] for crispness on high-DPI screens. Default 3.0 covers +/// Renders a distance-label pill: white text on a semi-transparent rounded +/// rectangle background. Returns the PNG bytes and the logical size (width/ +/// height in logical pixels, NOT device pixels) so the caller can use it for +/// screen-space collision tests. +/// +/// Sized dynamically to the text — the pill grows with longer labels. Uses +/// devicePixelRatio=3.0 to match the other bitmap markers on this map. +Future<({Uint8List bytes, Size size})> _renderDistanceLabelPng( + String text, { + double devicePixelRatio = 3.0, +}) async { + const fontSize = 11.0; + const horizontalPad = 6.0; + const verticalPad = 3.0; + const cornerRadius = 6.0; + + // Measure the text first so we can size the pill to fit. + final textPainter = TextPainter( + text: TextSpan( + text: text, + style: const TextStyle( + fontSize: fontSize, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + + final logicalWidth = textPainter.width + horizontalPad * 2; + final logicalHeight = textPainter.height + verticalPad * 2; + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.scale(devicePixelRatio); + + // Background pill. + final bgPaint = Paint()..color = Colors.black.withValues(alpha: 0.72); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, logicalWidth, logicalHeight), + const Radius.circular(cornerRadius), + ), + bgPaint, + ); + + // Subtle light border for separation from dark map backgrounds. + final borderPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.25) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(0.5, 0.5, logicalWidth - 1, logicalHeight - 1), + const Radius.circular(cornerRadius), + ), + borderPaint, + ); + + textPainter.paint(canvas, const Offset(horizontalPad, verticalPad)); + + final picture = recorder.endRecording(); + final image = await picture.toImage( + (logicalWidth * devicePixelRatio).round(), + (logicalHeight * devicePixelRatio).round(), + ); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + picture.dispose(); + image.dispose(); + if (byteData == null) { + throw StateError('Failed to encode distance label to PNG bytes'); + } + return ( + bytes: byteData.buffer.asUint8List(), + size: Size(logicalWidth, logicalHeight), + ); +} + +/// most modern phones (typical DPR is 2.0–3.5). +Future _renderPainterToPng( + CustomPainter painter, + Size size, { + double devicePixelRatio = 3.0, +}) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + // Scale the canvas so the painter still draws at logical size, but the + // resulting bitmap has more actual pixels. + canvas.scale(devicePixelRatio); + painter.paint(canvas, size); + final picture = recorder.endRecording(); + final image = await picture.toImage( + (size.width * devicePixelRatio).round(), + (size.height * devicePixelRatio).round(), + ); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + picture.dispose(); + image.dispose(); + if (byteData == null) { + throw StateError('Failed to encode CustomPainter to PNG bytes'); + } + return byteData.buffer.asUint8List(); +} + +/// Map style options. +/// +/// Declaration order matters: it determines the cycle order when the user +/// taps the "switch style" button (see `_cycleMapStyle`). Liberty is first +/// because it's the default for new users. enum MapStyle { + liberty, dark, light, satellite, } extension MapStyleExtension on MapStyle { - /// Convert from stored string preference to MapStyle enum + /// Convert from stored string preference to MapStyle enum. + /// Defaults to Liberty for unknown / unset preferences. static MapStyle fromString(String value) { switch (value) { + case 'dark': + return MapStyle.dark; case 'light': return MapStyle.light; case 'satellite': return MapStyle.satellite; - case 'dark': + case 'liberty': default: - return MapStyle.dark; + return MapStyle.liberty; } } @@ -45,6 +219,8 @@ extension MapStyleExtension on MapStyle { return 'Dark'; case MapStyle.light: return 'Light'; + case MapStyle.liberty: + return 'Liberty'; case MapStyle.satellite: return 'Satellite'; } @@ -56,60 +232,28 @@ extension MapStyleExtension on MapStyle { return Icons.dark_mode; case MapStyle.light: return Icons.light_mode; + case MapStyle.liberty: + return Icons.map; case MapStyle.satellite: return Icons.satellite_alt; } } - String get urlTemplate { + /// MapLibre style URL (or inline JSON for satellite) + String get styleUrl { switch (this) { case MapStyle.dark: - return 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; + return 'https://tiles.openfreemap.org/styles/dark'; case MapStyle.light: - return 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + return 'https://tiles.openfreemap.org/styles/bright'; + case MapStyle.liberty: + return 'https://tiles.openfreemap.org/styles/liberty'; case MapStyle.satellite: - return 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; - } - } - - List? get subdomains { - switch (this) { - case MapStyle.dark: - return ['a', 'b', 'c', 'd']; - case MapStyle.light: - return null; // OSM doesn't use subdomains anymore - case MapStyle.satellite: - return null; // ArcGIS doesn't use subdomains - } - } - - /// Whether this style supports retina tiles via {r} placeholder - bool get supportsRetina { - switch (this) { - case MapStyle.dark: - return true; // Carto supports @2x via {r} - case MapStyle.light: - return false; // OSM has no retina support - case MapStyle.satellite: - return false; // ArcGIS has no retina support + return _satelliteStyleJson; } } } -/// Custom tile provider that silently handles HTTP errors (404, 503, etc.) -/// instead of flooding the console with exceptions -final class SilentCancellableNetworkTileProvider - extends CancellableNetworkTileProvider { - SilentCancellableNetworkTileProvider() - : super( - dioClient: Dio( - BaseOptions( - validateStatus: (status) => true, // Accept all status codes - ), - ), - ); -} - /// Resolved repeater with SNR and ambiguity info for ping focus mode. /// Line color is based on [snr] (green/yellow/red). When a short hex ID /// matches multiple repeaters, [ambiguous] is true and the line gets a @@ -122,7 +266,7 @@ class _ResolvedRepeater { } /// Map widget with TX/RX markers -/// Uses flutter_map with OpenStreetMap tiles +/// Uses MapLibre GL with OpenFreeMap vector tiles class MapWidget extends StatefulWidget { /// Bottom padding in pixels to account for overlays (e.g., control panel in portrait) /// The map will offset its center point upward by half this value @@ -151,8 +295,8 @@ class MapWidget extends StatefulWidget { State createState() => _MapWidgetState(); } -class _MapWidgetState extends State with TickerProviderStateMixin { - final MapController _mapController = MapController(); +class _MapWidgetState extends State { + MapLibreMapController? _mapController; // Auto-follow GPS like a navigation app bool _autoFollow = false; // Disabled by default - users often zoom out first @@ -169,6 +313,23 @@ class _MapWidgetState extends State with TickerProviderStateMixin { true; // true = north always up, false = rotate with heading double? _lastHeading; // Track last heading for smooth rotation + // Desired camera zoom while auto-follow is active. Set when the user taps + // "center on position" and updated when the user pinch-zooms. Each auto- + // follow GPS tick uses this as the animation target zoom — otherwise a tick + // that arrives during the initial zoom animation cancels it (animateCamera + // replaces in-flight animations), leaving the camera stuck at an + // intermediate zoom and the marker off-center. + double? _autoFollowDesiredZoom; + + // Bearing derivation state. geolocator's Position.heading is only reliable + // at speed — on both Android (Location.getBearing() requires hasBearing() + // and speed > 0) and iOS (CLLocation.course == -1 when invalid) it's + // effectively 0 or -1 when stationary or walking slowly. We keep our own + // anchor-to-current bearing as a fallback so the arrow/walk marker and + // heading-mode map rotation behave correctly at low speeds. + LatLng? _bearingAnchor; // last fix used as the bearing origin + double? _computedHeading; // last known-good bearing in degrees 0..360 + // MeshMapper overlay toggle (on by default) bool _showMeshMapperOverlay = true; @@ -190,17 +351,99 @@ class _MapWidgetState extends State with TickerProviderStateMixin { bool _wasAutoFollowBeforeFocus = false; bool _wasRotatingBeforeFocus = false; // true if heading mode was active - // Smooth animation for map movement - AnimationController? _animationController; - Animation? _animation; - LatLng? _animationStartPosition; - LatLng? _animationEndPosition; - - // Smooth animation for map rotation - AnimationController? _rotationAnimationController; - Animation? _rotationAnimation; - double? _rotationStartAngle; - double? _rotationEndAngle; + // MapLibre style and overlay tracking + int _lastCacheBust = 0; + // Tracks the zone code we last rendered the coverage overlay for. When the + // zone check succeeds after the style has already loaded (e.g. first check + // failed with gps_inaccurate and a later retry succeeded), _addCoverageOverlay + // would otherwise never re-run and the raster layer would stay missing. + String? _lastOverlayZoneCode; + // Last coverage overlay opacity we pushed into MapLibre. Compared against + // the current preference in _buildMap to detect slider changes and apply + // them live via _applyCoverageOverlayOpacity (no layer rebuild needed). + double? _lastAppliedCoverageOpacity; + // Guard flag that coalesces multiple overlay-refresh triggers (cache bust + // and zone change) in the same frame into a single post-frame callback. + // Without this, two watchers can schedule concurrent _refreshCoverageOverlay + // runs whose remove/add calls interleave and produce "Source already exists" + // errors in the native log. + bool _coverageRefreshScheduled = false; + bool _styleLoaded = false; + bool _hasStyleLoadedOnce = + false; // True after first onStyleLoadedCallback (prevents re-centering on style switch) + + // Tracks the last marker data version we synced to native annotations. + // The build() method computes a version hash from app state and only triggers + // _syncAllAnnotations when the hash changes (avoiding unnecessary diff work). + int _lastMarkerDataVersion = -1; + // Serializes concurrent _syncAllAnnotations runs. Without this, a second + // build() can fire a sync while the previous one is still awaiting platform + // calls — both would mutate _coverageSymbols / _distanceLabelSymbols, and + // the older sync's cleanup loop would remove symbols the newer sync just + // added. The flag causes re-entrant post-frame callbacks to bail; after the + // in-flight sync finishes, the finally block checks if the data version + // advanced during the run and triggers a rebuild if so. + bool _syncInFlight = false; + + // Tile load failure detection — shows a banner if map tiles haven't loaded + // within a timeout after style load. Cleared when onMapIdle fires. + bool _tileLoadFailed = false; + + /// Tracks the last-applied mapTilesEnabled value so we can detect changes + /// in _buildMap and call setOffline() without a full style reload. + bool? _lastMapTilesEnabled; + Timer? _tileLoadTimeoutTimer; + static const _tileLoadTimeoutSeconds = 8; + + // Re-entrance guard for _onStyleLoaded. The iOS plugin can fire + // onStyleLoadedCallback multiple times during a single style switch, + // which causes the sync logic to race against itself. This flag bails + // any nested call. + bool _styleLoadInProgress = false; + + // True only after _setupRepeaterClusterLayers has finished creating the + // cluster GeoJSON source AND all 3 layers. Set to false at the start of + // each style load. Used as an additional guard for build()-triggered post- + // frame syncs so they don't race ahead of source creation and try to call + // setGeoJsonSource on a source that doesn't exist yet (which produces the + // "Failed to update repeater source: sourceNotFound" error at startup). + bool _clusterLayersReady = false; + + // Native annotation tracking — populated by sync methods. + // Maps from app-state IDs to MapLibre Symbol/Line objects so we can diff + // (add new, update existing, remove deleted) on each data version change. + // NOTE: repeaters do NOT use the annotation manager — they live in a custom + // cluster-enabled GeoJSON source so MapLibre can group nearby markers into + // count bubbles at low zoom. See _setupRepeaterClusterLayers(). + final Map _coverageSymbols = {}; // key: "{type}_{ts.ms}" + final Map _distanceLabelSymbols = + {}; // key: focused repeater id + // Per focused-repeater metadata used by the collision-avoidance reflow: + // the image size (for hit-box overlap tests) and the repeater lat/lon (so + // we can slide the label along the ping→repeater line at a new parameter t). + final Map _distanceLabelImageSize = {}; + final Map _distanceLabelRepeaterPos = {}; + // Tracks distance-label image names we've registered via addImage, so the + // style-reload path can drop stale names from the map's image cache if ever + // needed. Right now we just re-addImage on each sync (idempotent). + final Set _registeredDistanceLabelImages = {}; + Symbol? _gpsSymbol; // single GPS marker + + // Repeater cluster source/layer IDs (custom GeoJSON layer with cluster: true) + static const _repeaterSourceId = 'repeaters-source'; + static const _repeaterIndividualLayerId = 'repeaters-individual'; + static const _repeaterClusterBubbleLayerId = 'repeaters-cluster-bubble'; + static const _repeaterClusterCountLayerId = 'repeaters-cluster-count'; + + // Tracks which marker style preference the coverage images are currently + // registered for. When the user changes their preference, we re-register. + String? _registeredCoverageStyle; + + // True after _registerMapImages() finishes — gates symbol creation. + bool _imagesRegistered = false; + + // Last bearing seen by camera listener (for non-rotating GPS counter-rotation) + double _lastBearing = 0; // Default center (Ottawa) static const LatLng _defaultCenter = LatLng(45.4215, -75.6972); @@ -208,11 +451,43 @@ class _MapWidgetState extends State with TickerProviderStateMixin { @override void dispose() { - _animationController?.dispose(); - _rotationAnimationController?.dispose(); + _tileLoadTimeoutTimer?.cancel(); + final controller = _mapController; + if (controller != null) { + controller.removeListener(_onCameraChanged); + // Symbol/feature tap listeners are registered in _onMapCreated onto + // separate callback collections that ChangeNotifier.dispose() does NOT + // clear. Remove them explicitly so an in-flight tap that gets queued + // before the platform channel is torn down can't reach into a disposed + // State. try/catch swallows the edge case where _onMapCreated never ran. + try { + controller.onSymbolTapped.remove(_handleSymbolTap); + } catch (_) {} + try { + controller.onFeatureTapped.remove(_handleFeatureTap); + } catch (_) {} + } super.dispose(); } + /// Camera change listener — fires every frame during pan/zoom (because + /// trackCameraPosition: true is set on MapLibreMap). With native annotations, + /// the markers themselves don't need a per-frame rebuild — they're rendered + /// by the native map engine and stay in sync automatically. The only thing + /// we still need to do here is update the GPS marker's iconRotate when the + /// camera bearing changes, because for rotating styles (arrow/walk/chomper) + /// iconRotate = heading - bearing and the bearing animates continuously in + /// heading mode. Throttled by a small bearing delta to avoid spamming + /// updateSymbol. + void _onCameraChanged() { + if (!mounted || _mapController == null) return; + final pos = _mapController!.cameraPosition; + if (pos == null) return; + if ((pos.bearing - _lastBearing).abs() < 0.5) return; + _lastBearing = pos.bearing; + _updateGpsSymbolRotation(); + } + @override void didUpdateWidget(MapWidget oldWidget) { super.didUpdateWidget(oldWidget); @@ -224,133 +499,69 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _lastGpsPosition != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _autoFollow && _lastGpsPosition != null) { + final double targetBearing = + (!_alwaysNorth && _computedHeading != null) + ? _computedHeading! + : 0.0; + final double targetZoom = _autoFollowDesiredZoom ?? + _mapController?.cameraPosition?.zoom ?? + _defaultZoom; final adjustedPosition = _offsetPositionForPadding( _lastGpsPosition!, widget.bottomPaddingPixels, widget.rightPaddingPixels, + targetZoom, + targetBearing, + ); + _animateAutoFollowCamera( + target: adjustedPosition, + zoom: targetZoom, + bearing: targetBearing, ); - _animateToPosition(adjustedPosition); } }); } } - /// Smoothly animate the map to a new position - void _animateToPosition(LatLng target) { - if (!_isMapReady || !mounted) return; - - // Get current position - final currentCenter = _mapController.camera.center; - - // Skip if already at target (within small threshold) - final distance = - const Distance().as(LengthUnit.Meter, currentCenter, target); - if (distance < 1) return; // Less than 1 meter, don't animate - - // Cancel any running animation - _animationController?.stop(); - _animationController?.dispose(); - - // Create new animation controller - // Duration based on distance - shorter for small movements, longer for big jumps - final duration = Duration(milliseconds: distance < 100 ? 200 : 300); - - _animationController = AnimationController( - duration: duration, - vsync: this, - ); - - _animation = CurvedAnimation( - parent: _animationController!, - curve: Curves.easeOutCubic, // Smooth deceleration - ); - - _animationStartPosition = currentCenter; - _animationEndPosition = target; - - _animation!.addListener(() { - if (!mounted || - _animationStartPosition == null || - _animationEndPosition == null) { - return; - } - - // Interpolate between start and end positions - final t = _animation!.value; - final lat = _animationStartPosition!.latitude + - ((_animationEndPosition!.latitude - - _animationStartPosition!.latitude) * - t); - final lng = _animationStartPosition!.longitude + - ((_animationEndPosition!.longitude - - _animationStartPosition!.longitude) * - t); - - _mapController.move(LatLng(lat, lng), _mapController.camera.zoom); - }); - - _animationController!.forward(); - } - /// Smoothly animate the map to a new position with zoom void _animateToPositionWithZoom(LatLng target, double targetZoom) { - if (!_isMapReady || !mounted) return; - - // Get current position and zoom - final currentCenter = _mapController.camera.center; - final currentZoom = _mapController.camera.zoom; - - // Cancel any running animation - _animationController?.stop(); - _animationController?.dispose(); - - // Create new animation controller - const duration = Duration(milliseconds: 500); // Smooth zoom + pan - - _animationController = AnimationController( - duration: duration, - vsync: this, + if (_mapController == null || !_isMapReady || !mounted) return; + _mapController!.animateCamera( + CameraUpdate.newLatLngZoom(target, targetZoom), + duration: const Duration(milliseconds: 500), ); + } - _animation = CurvedAnimation( - parent: _animationController!, - curve: Curves.easeInOutCubic, // Smooth acceleration and deceleration + /// Atomic auto-follow camera update: animates target, zoom, and bearing + /// together in a single animateCamera call. + /// + /// Using separate animateCamera calls for position and rotation races — + /// the second call cancels the first, so each GPS tick in heading mode + /// lost either the pan or the rotation. Bundling everything into one + /// newCameraPosition update avoids the race entirely and also keeps the + /// initial zoom animation from being cancelled by the first auto-follow + /// tick. + void _animateAutoFollowCamera({ + required LatLng target, + required double zoom, + required double bearing, + int durationMs = 300, + }) { + if (_mapController == null || !_isMapReady || !mounted) return; + _mapController!.animateCamera( + CameraUpdate.newCameraPosition(CameraPosition( + target: target, + zoom: zoom, + bearing: bearing, + )), + duration: Duration(milliseconds: durationMs), ); - - _animationStartPosition = currentCenter; - _animationEndPosition = target; - - _animation!.addListener(() { - if (!mounted || - _animationStartPosition == null || - _animationEndPosition == null) { - return; - } - - // Interpolate between start and end positions - final t = _animation!.value; - final lat = _animationStartPosition!.latitude + - ((_animationEndPosition!.latitude - - _animationStartPosition!.latitude) * - t); - final lng = _animationStartPosition!.longitude + - ((_animationEndPosition!.longitude - - _animationStartPosition!.longitude) * - t); - - // Interpolate zoom - final zoom = currentZoom + ((targetZoom - currentZoom) * t); - - _mapController.move(LatLng(lat, lng), zoom); - }); - - _animationController!.forward(); } /// Zoom to fit a focused ping and its connected repeaters on screen void _zoomToFocusBounds( LatLng pingLocation, List<_ResolvedRepeater> repeaters) { - if (!_isMapReady || !mounted) return; + if (_mapController == null || !_isMapReady || !mounted) return; final points = [ pingLocation, @@ -358,38 +569,39 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ]; if (points.length < 2) return; - final fitted = CameraFit.coordinates( - coordinates: points, - padding: EdgeInsets.fromLTRB( - 60, 60, 60, MediaQuery.of(context).size.height * 0.4), - maxZoom: 15, - ).fit(_mapController.camera); + // Build bounding box from all points + double minLat = points[0].latitude, maxLat = points[0].latitude; + double minLon = points[0].longitude, maxLon = points[0].longitude; + for (final p in points) { + if (p.latitude < minLat) minLat = p.latitude; + if (p.latitude > maxLat) maxLat = p.latitude; + if (p.longitude < minLon) minLon = p.longitude; + if (p.longitude > maxLon) maxLon = p.longitude; + } + final bounds = LatLngBounds( + southwest: LatLng(minLat, minLon), + northeast: LatLng(maxLat, maxLon), + ); - _animateToPositionWithZoom(fitted.center, fitted.zoom); + final bottomPad = MediaQuery.of(context).size.height * 0.4; + _mapController!.animateCamera( + CameraUpdate.newLatLngBounds(bounds, + left: 60, top: 60, right: 60, bottom: bottomPad), + duration: const Duration(milliseconds: 500), + ); } /// Smoothly animate the map rotation to match heading + /// MapLibre bearing is clockwise from north (same as GPS heading) void _animateToRotation(double targetHeading) { - if (!_isMapReady || !mounted || _alwaysNorth) return; - - // Get current rotation (in degrees) - final currentRotation = _mapController.camera.rotation; - - // Normalize target heading to -180 to 180 range for smooth rotation - // Map heading is counter-clockwise from north, GPS heading is clockwise - // So we need to negate it: -targetHeading - double targetRotation = -targetHeading; - - // Normalize angles to -180 to 180 range - while (targetRotation > 180) { - targetRotation -= 360; - } - while (targetRotation < -180) { - targetRotation += 360; + if (_mapController == null || !_isMapReady || !mounted || _alwaysNorth) { + return; } + final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; + // Calculate shortest rotation path - double delta = targetRotation - currentRotation; + double delta = targetHeading - currentBearing; while (delta > 180) { delta -= 360; } @@ -400,84 +612,117 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Skip if rotation change is very small (less than 2 degrees) if (delta.abs() < 2) return; - // Cancel any running rotation animation - _rotationAnimationController?.stop(); - _rotationAnimationController?.dispose(); - - // Create new rotation animation controller - // Faster rotation for small changes, slower for large changes - final duration = Duration(milliseconds: delta.abs() < 45 ? 300 : 500); - - _rotationAnimationController = AnimationController( - duration: duration, - vsync: this, - ); - - _rotationAnimation = CurvedAnimation( - parent: _rotationAnimationController!, - curve: Curves.easeInOutCubic, // Smooth acceleration and deceleration + _mapController!.animateCamera( + CameraUpdate.bearingTo(targetHeading), + duration: Duration(milliseconds: delta.abs() < 45 ? 300 : 500), ); + } - _rotationStartAngle = currentRotation; - _rotationEndAngle = currentRotation + delta; + /// Produce a reliable heading in degrees (0..360) from successive GPS fixes. + /// + /// Prefers `Position.heading` when the device is moving fast enough for the + /// hardware bearing to be trustworthy; otherwise derives the bearing from + /// the delta between the last anchor fix and the current one. Returns the + /// last known-good value (possibly null) when we don't have enough motion + /// yet. This exists because geolocator reports heading=0 (Android) or + /// -1 (iOS) at rest and during slow/stop-and-go movement, which would + /// otherwise leave the arrow/walk marker stuck pointing north. + double? _computeHeading(Position p) { + final here = LatLng(p.latitude, p.longitude); + + // Fast path: trust the GPS chip when it's actually moving. + // geolocator reports speed in m/s. 1 m/s ≈ 3.6 km/h — slower than that, + // the hardware bearing is either stale or not computed. + final gpsHeading = p.heading; + if (p.speed >= 1.0 && gpsHeading >= 0 && gpsHeading <= 360) { + _bearingAnchor = here; + _computedHeading = gpsHeading; + return _computedHeading; + } - _rotationAnimation!.addListener(() { - if (!mounted || - _rotationStartAngle == null || - _rotationEndAngle == null) { - return; + // Slow/stationary path: compute our own bearing once we have enough travel. + if (_bearingAnchor == null) { + _bearingAnchor = here; + } else { + final moved = Geolocator.distanceBetween( + _bearingAnchor!.latitude, + _bearingAnchor!.longitude, + here.latitude, + here.longitude, + ); + if (moved >= 5.0) { + final bearing = Geolocator.bearingBetween( + _bearingAnchor!.latitude, + _bearingAnchor!.longitude, + here.latitude, + here.longitude, + ); + // bearingBetween returns -180..180; normalize to 0..360. + _computedHeading = (bearing + 360) % 360; + _bearingAnchor = here; } + } - // Interpolate between start and end angles - final t = _rotationAnimation!.value; - final rotation = _rotationStartAngle! + - ((_rotationEndAngle! - _rotationStartAngle!) * t); - - _mapController.rotate(rotation); - }); - - _rotationAnimationController!.forward(); + return _computedHeading; // may be null until first meaningful motion } - /// Offset a lat/lon position by screen pixels (to account for UI overlays) - /// Shifts the map center to keep the GPS marker centered in the visible map area - /// - bottomPadding: shifts center down (portrait mode with bottom panel) - /// - rightPadding: shifts center left (landscape mode with side panel) - LatLng _offsetPositionForPadding(LatLng position, double bottomPadding, - [double rightPadding = 0, double? atZoom]) { - if (!_isMapReady) return position; + /// Offset a lat/lon position by screen pixels (to account for UI overlays). + /// Shifts the camera target so the GPS marker sits in the visible (unpadded) + /// part of the map: + /// - bottomPadding > 0: camera shifts "screen-down" so marker appears toward + /// the top half (portrait with bottom panel open). + /// - rightPadding > 0: camera shifts "screen-right" so marker appears toward + /// the left half (landscape with side panel open on the right). + /// + /// [atZoom] and [atBearing] override the current camera values. Callers that + /// are *about* to animate the camera to a new zoom/bearing must pass the + /// target values — otherwise the offset gets computed at an interpolated + /// mid-animation value and the marker settles off-center. + LatLng _offsetPositionForPadding( + LatLng position, + double bottomPadding, [ + double rightPadding = 0, + double? atZoom, + double? atBearing, + ]) { + if (_mapController == null || !_isMapReady) return position; if (bottomPadding <= 0 && rightPadding <= 0) return position; - // Get meters per pixel at current zoom (or at a specific zoom if provided) + // Get meters per pixel at the target zoom (or current camera zoom). // Approx: 40075km / (256 * 2^zoom) at equator, adjusted by cos(lat) - final zoom = atZoom ?? _mapController.camera.zoom; + final zoom = atZoom ?? _mapController!.cameraPosition?.zoom ?? _defaultZoom; final metersPerPixel = 40075000 / (256 * math.pow(2, zoom)) * math.cos(position.latitude * math.pi / 180); + // Start with the offset expressed as if the map were north-up + // (bearing = 0): bottom padding shifts the target geographic-south, + // right padding shifts the target geographic-west. double latOffset = 0; double lonOffset = 0; - - // Bottom padding: shift center south (map moves up, marker appears centered) if (bottomPadding > 0) { final meterOffset = (bottomPadding / 2) * metersPerPixel; latOffset = -(meterOffset / 111000); // ~111km per degree latitude } - - // Right padding: shift center west (map moves right, marker appears centered) if (rightPadding > 0) { final meterOffset = (rightPadding / 2) * metersPerPixel; - // Longitude degrees per meter varies with latitude lonOffset = -(meterOffset / (111000 * math.cos(position.latitude * math.pi / 180))); } - // When the map is rotated (heading mode), geographic "south" no longer maps - // to "screen down". Rotate the offset vector by the camera rotation so the - // shift always points in the correct screen direction. - final rotationDeg = _mapController.camera.rotation; - if (rotationDeg.abs() > 0.1) { - final rotationRad = -rotationDeg * math.pi / 180; + // When the map is rotated, "screen-down" no longer points geographic + // south — it points wherever bearing + 180° aims. Rotate the offset + // vector so the shift still lands in the correct screen direction. + // + // MapLibre bearing is clockwise from north (heading east => bearing 90, + // screen-down => world-west). To send a south-pointing input vector to + // the world direction that corresponds to screen-down at the given + // bearing, we rotate it clockwise by `bearing` — i.e. by +bearing, not + // -bearing as the previous implementation did. + final bearingDeg = + atBearing ?? _mapController!.cameraPosition?.bearing ?? 0; + if (bearingDeg.abs() > 0.1) { + final rotationRad = bearingDeg * math.pi / 180; final cosR = math.cos(rotationRad); final sinR = math.sin(rotationRad); final rotatedLat = latOffset * cosR - lonOffset * sinR; @@ -536,6 +781,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } if (appState.currentPosition != null) { + // Recompute our derived heading for this frame. _computedHeading is + // updated as a side effect; use it below instead of reading + // currentPosition.heading directly (which is unreliable at low speeds). + _computeHeading(appState.currentPosition!); + // One-time initial zoom to GPS when we first get a position // This happens even with auto-follow disabled so user sees their location // Don't apply panel offset - center directly on GPS so pin is in middle of screen @@ -564,30 +814,58 @@ class _MapWidgetState extends State with TickerProviderStateMixin { }); } - // Auto-follow GPS position when enabled - use smooth animation + // Auto-follow GPS position when enabled. When auto-follow is on we + // bundle pan, zoom, and bearing into a single animateCamera call so + // the three don't race each other. _autoFollowDesiredZoom is the + // zoom the camera is animating toward — using it instead of the + // (potentially interpolated) current zoom prevents drift during the + // initial zoom animation after tapping center-on-position. if (_autoFollow && _isMapReady) { final newPosition = center; - // Only animate if position has actually changed if (_lastGpsPosition == null || _lastGpsPosition!.latitude != newPosition.latitude || _lastGpsPosition!.longitude != newPosition.longitude) { _lastGpsPosition = newPosition; - // Use post frame callback to avoid build-during-build issues + final double targetBearing = + (!_alwaysNorth && _computedHeading != null) + ? _computedHeading! + : 0.0; + final double targetZoom = _autoFollowDesiredZoom ?? + _mapController?.cameraPosition?.zoom ?? + _defaultZoom; + // Track _lastHeading here too so the separate rotation block + // below (which runs when auto-follow is off) doesn't fire a + // redundant rotation animation on the next frame. + if (!_alwaysNorth && _computedHeading != null) { + _lastHeading = _computedHeading; + } WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _autoFollow) { - // Apply offset for bottom padding when control panel is open - final adjustedPosition = _offsetPositionForPadding(newPosition, - widget.bottomPaddingPixels, widget.rightPaddingPixels); - _animateToPosition( - adjustedPosition); // Smooth animation instead of jump + final adjustedPosition = _offsetPositionForPadding( + newPosition, + widget.bottomPaddingPixels, + widget.rightPaddingPixels, + targetZoom, + targetBearing, + ); + _animateAutoFollowCamera( + target: adjustedPosition, + zoom: targetZoom, + bearing: targetBearing, + ); } }); } } - // Handle map rotation based on heading (when not in Always North mode) - if (!_alwaysNorth && _isMapReady) { - final heading = appState.currentPosition!.heading; + // Handle map rotation based on heading when NOT auto-following. + // When auto-follow is on, rotation is bundled into the combined + // camera update above so we don't race two animateCamera calls. + if (!_autoFollow && + !_alwaysNorth && + _isMapReady && + _computedHeading != null) { + final heading = _computedHeading!; if (_lastHeading == null) { // First heading after startup — store without rotating so the // initial zoom animation can settle at rotation 0 (where the @@ -598,14 +876,18 @@ class _MapWidgetState extends State with TickerProviderStateMixin { '[MAP] First heading after startup (${heading.toStringAsFixed(1)}°) — stored without rotating'); } else if ((heading - _lastHeading!).abs() > 2) { _lastHeading = heading; - // Use post frame callback to avoid build-during-build issues WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && !_alwaysNorth) { + if (mounted && !_alwaysNorth && !_autoFollow) { _animateToRotation(heading); } }); } } + } else { + // GPS lock lost — clear bearing state so reacquisition starts fresh + // instead of snapping the marker/map to a stale direction. + _bearingAnchor = null; + _computedHeading = null; } // Handle navigation trigger from log screen or graph @@ -617,20 +899,23 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (target != null) { // Reset map controls to default state _autoFollow = false; // Disable center on GPS + _autoFollowDesiredZoom = null; _alwaysNorth = true; // Set to north-up mode _rotationLocked = false; // Unlock rotation _lastHeading = null; // Reset heading tracking + _bearingAnchor = null; // Reset derived-heading anchor + _computedHeading = null; // Navigate to the coordinates with close zoom (18 = street level view) // Center directly on target without offset - we want the pin in the middle WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { + if (mounted && _mapController != null) { final targetPosition = LatLng(target.lat, target.lon); // Rotate map back to north (0 degrees) first - final currentRotation = _mapController.camera.rotation; - if (currentRotation.abs() > 2) { - _mapController.rotate(0); + final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; + if (currentBearing.abs() > 2) { + _mapController!.animateCamera(CameraUpdate.bearingTo(0)); } // Animate to the exact target position (no offset) @@ -640,6 +925,44 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } } + // Sync native annotations whenever marker data changes (provider triggers + // a rebuild). The version hash detects changes to ping/repeater counts, + // GPS position, focus state, prefs, etc. Native annotations stay in sync + // with the camera automatically — we only need to push data updates. + // + // _clusterLayersReady is the critical guard here: it ensures the cluster + // GeoJSON source actually exists before any sync attempts to push data + // into it. Without this, a Provider data update arriving in the brief + // window between _registerMapImages and _setupRepeaterClusterLayers + // (inside _onStyleLoaded) would race ahead and call setGeoJsonSource on + // a not-yet-created source, throwing "sourceNotFound". + if (_isMapReady && + _styleLoaded && + _imagesRegistered && + _clusterLayersReady) { + final dataVersion = _computeMarkerDataVersion(appState); + if (dataVersion != _lastMarkerDataVersion) { + _lastMarkerDataVersion = dataVersion; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + // Guard against concurrent build()-triggered syncs stepping on each + // other. _syncAllAnnotations awaits multiple native platform calls + // and can take ~100ms+; during auto-ping bursts multiple rebuilds + // would otherwise schedule overlapping runs whose cleanup loops + // would remove symbols the other sync just added. + if (_syncInFlight) return; + _syncInFlight = true; + try { + await _syncAllAnnotations(appState); + } catch (e) { + debugError('[MAP] _syncAllAnnotations failed: $e'); + } finally { + _syncInFlight = false; + } + }); + } + } + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; // Get safe area padding for dynamic island/notch in landscape @@ -649,8 +972,16 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return Stack( children: [ - // Map - _buildMap(appState, center), + // Map — wait for Hive-loaded preferences before constructing + // MapLibreMap, otherwise the default mapStyle ('liberty') would + // render first and then swap to the user's saved style. + if (appState.preferencesLoaded) + _buildMap(appState, center) + else + const ColoredBox( + color: Color(0xFF1A1A1A), + child: SizedBox.expand(), + ), // GPS Info + Top Repeaters overlay (top-left, respects dynamic island in landscape) Positioned( @@ -674,10 +1005,52 @@ class _MapWidgetState extends State with TickerProviderStateMixin { right: 8, child: _buildCollapsibleMapControls(appState), ), + + // Tile load failure banner — appears if base tiles haven't finished + // loading within ${_tileLoadTimeoutSeconds}s after style load. + if (_tileLoadFailed) + Positioned( + top: topPadding, + left: 0, + right: 0, + child: Center( + child: _buildTileLoadFailedBanner(), + ), + ), ], ); } + /// Banner shown when map tiles fail to load within the timeout window. + Widget _buildTileLoadFailedBanner() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 80), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.orange.shade900.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.shade700, width: 1), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_off, color: Colors.white, size: 16), + SizedBox(width: 8), + Flexible( + child: Text( + 'Map tiles unavailable — check connection', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + /// Collapsible map controls (toggle at top, expands downward) Widget _buildCollapsibleMapControls(AppStateProvider appState) { // Use external state if provided, otherwise use internal state @@ -685,198 +1058,1606 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final onToggle = widget.onMapControlsToggle ?? () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Toggle button (always visible) - at top - GestureDetector( - onTap: onToggle, - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: isExpanded - ? const BorderRadius.vertical(top: Radius.circular(8)) - : BorderRadius.circular(8), - ), - child: Icon( - isExpanded ? Icons.expand_less : Icons.expand_more, - color: Colors.white, - size: 22, - ), - ), - ), - // Map controls (only when expanded) - below the toggle button - if (isExpanded) _buildMapControls(appState), - ], - ); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Toggle button (always visible) - at top + GestureDetector( + onTap: onToggle, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: isExpanded + ? const BorderRadius.vertical(top: Radius.circular(8)) + : BorderRadius.circular(8), + ), + child: Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + color: Colors.white, + size: 22, + ), + ), + ), + // Map controls (only when expanded) - below the toggle button + if (isExpanded) _buildMapControls(appState), + ], + ); + } + + Widget _buildMap(AppStateProvider appState, LatLng center) { + final mapStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); + // Always use the real style so downloaded offline tiles can render from + // cache. Network access is controlled via setOffline() instead. + final newStyleUrl = mapStyle.styleUrl; + + // Detect mapTilesEnabled toggle changes and switch MapLibre between + // online (network tiles) and offline (cache-only) mode. This avoids + // a full style reload — the same style stays loaded but MapLibre stops + // or starts making network requests for tiles. + final tilesEnabled = appState.preferences.mapTilesEnabled; + if (_lastMapTilesEnabled != tilesEnabled && _isMapReady) { + _lastMapTilesEnabled = tilesEnabled; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setOffline(!tilesEnabled); + debugPrint( + '[MAP] setOffline(${!tilesEnabled}) — tiles ${tilesEnabled ? "enabled" : "disabled (cache only)"}'); + }); + } + + // Style changes flow through MapLibreMap.styleString — the plugin's + // didUpdateWidget detects the new value and fires a native setStyle. + // onStyleLoadedCallback → _onStyleLoaded re-registers images, rebuilds + // cluster layers, re-adds the coverage overlay, and re-syncs annotations. + + // Detect cache bust or zoneCode change → schedule a SINGLE coalesced + // refresh. Previously each watcher scheduled its own post-frame callback, + // which could race when both changed in the same frame (e.g. a zone + // transition that also rotates cache bust). The _coverageRefreshScheduled + // flag ensures at most one refresh is queued per frame. + // + // The zoneCode watcher is needed because _addCoverageOverlay only runs + // during _onStyleLoaded — if the first zone check failed with + // gps_inaccurate, the style loads with zoneCode=null and the overlay is + // skipped. When a later retry sets the zone, nothing else would trigger + // the raster layer. + final cacheBustChanged = appState.overlayCacheBust != _lastCacheBust && + _isMapReady && + _styleLoaded; + final zoneChanged = appState.zoneCode != _lastOverlayZoneCode && + _isMapReady && + _styleLoaded; + if (cacheBustChanged || zoneChanged) { + if (cacheBustChanged) _lastCacheBust = appState.overlayCacheBust; + if (zoneChanged) _lastOverlayZoneCode = appState.zoneCode; + if (!_coverageRefreshScheduled) { + _coverageRefreshScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) async { + _coverageRefreshScheduled = false; + if (!mounted) return; + await _refreshCoverageOverlay(appState); + }); + } + } + + // Detect coverage overlay opacity change (user dragged the slider in + // Settings → General) and push it to the live raster layer without + // rebuilding the whole overlay. Skipped while ping focus mode is active — + // focus forces opacity to 0 and _dismissPingFocus restores the preference + // value directly. + final wantedOpacity = appState.preferences.coverageOverlayOpacity; + if (_isMapReady && + _styleLoaded && + _focusedPingLocation == null && + _lastAppliedCoverageOpacity != null && + _lastAppliedCoverageOpacity != wantedOpacity) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _applyCoverageOverlayOpacity(wantedOpacity); + }); + } + + return Stack( + children: [ + // MapLibre GL map (base tiles via style; coverage overlay added programmatically) + MapLibreMap( + styleString: newStyleUrl, + initialCameraPosition: CameraPosition( + target: center, + zoom: _defaultZoom, + ), + minMaxZoomPreference: const MinMaxZoomPreference(3, 17), + rotateGesturesEnabled: !_rotationLocked, + scrollGesturesEnabled: true, + zoomGesturesEnabled: true, + tiltGesturesEnabled: false, // 2D wardriving map + compassEnabled: false, // We have our own controls + // CRITICAL: must be true so the controller's `cameraPosition` getter + // stays synced with the platform side. Without this, the Dart-side + // _cameraPosition is set once at construction and never updated, which + // breaks our sync projection (markers project to stale positions and + // get filtered out by viewport bounds). Also enables camera-move events + // during gestures so _onCameraChanged fires every frame for live + // marker overlay updates. + trackCameraPosition: true, + onMapCreated: _onMapCreated, + onStyleLoadedCallback: () => _onStyleLoaded(appState), + onMapIdle: _onMapIdle, + onCameraIdle: _onCameraIdle, + // NOTE: we do NOT pass onMapClick here. The iOS plugin's + // handleMapTap fires `feature#onTap` when a tap hits any + // interactive layer (including our cluster source layers) and + // does NOT fire `map#onMapClick` in that case. We register a + // listener on `controller.onFeatureTapped` in _onMapCreated + // instead — that fires for taps on custom layer features. + ), + // No widget marker overlay — markers are now native MapLibre + // annotations rendered by the platform view itself. + ], + ); + } + + void _onMapCreated(MapLibreMapController controller) { + _mapController = controller; + // Wire up native annotation tap callbacks. These streams fire when the + // user taps on a symbol/line that the platform-side hit-test matches. + // Since the controller is created exactly once, this listener registration + // happens exactly once too — no need to remove and re-add on style switch. + controller.onSymbolTapped.add(_handleSymbolTap); + // Generic feature tap handler — fires for ANY interactive style layer, + // including our custom repeater cluster + individual layers (which are + // NOT managed by the annotation manager). We dispatch in _handleFeatureTap + // based on the layerId. + controller.onFeatureTapped.add(_handleFeatureTap); + } + + /// Routes a native symbol tap to the appropriate detail sheet. + /// The tap event carries the [Symbol] object, which has the metadata Map we + /// attached when calling addSymbol() in the various sync methods. We use the + /// `kind` and `id` keys to look up the original ping/repeater object from + /// app state and call the existing `_show*Details()` method (which expects + /// the full object, not just an ID). + void _handleSymbolTap(Symbol symbol) { + if (!mounted) return; + final data = symbol.data; + if (data == null) return; + final kind = data['kind'] as String?; + final id = data['id']; + final appState = context.read(); + + switch (kind) { + // 'repeater' is no longer handled here — repeaters are in a custom + // cluster GeoJSON layer (not the annotation manager) and dispatch + // through _handleMapClick + queryRenderedFeatures instead. + case 'tx': + final ping = appState.txPings + .where((p) => p.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (ping != null) _showTxPingDetails(ping); + break; + case 'rx': + final ping = appState.rxPings + .where((p) => p.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (ping != null) _showRxPingDetails(ping); + break; + case 'disc': + final entry = appState.discLogEntries + .where((e) => e.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (entry != null) _showDiscPingDetails(entry); + break; + case 'trace': + final entry = appState.traceLogEntries + .where((e) => e.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (entry != null) _showTraceDetails(entry); + break; + // gps, distance-label: not tappable in original — no action + } + } + + /// Handles taps on custom layer features (repeater cluster bubbles and + /// individual repeaters). Wired in [_onMapCreated] via + /// `controller.onFeatureTapped.add(_handleFeatureTap)`. + /// + /// The iOS/Android tap dispatcher calls this for ANY tap that hits an + /// interactive style layer, BEFORE falling back to `onMapClick`. Since our + /// cluster source layers are interactive, taps on repeaters/clusters always + /// route here (not through onMapClick). + /// + /// We dispatch by [layerId]: + /// - cluster bubble layer → zoom in 2 levels around the tap point + /// - individual repeater layer → look up the Repeater by id and open the + /// existing detail sheet + /// + /// [id] is the GeoJSON Feature `id` (which we set to `repeater.id` for + /// individual repeaters; MapLibre auto-generates one for cluster features). + /// [annotation] is always null here since these layers aren't managed by + /// the annotation manager. + void _handleFeatureTap( + math.Point point, + LatLng coordinates, + String id, + String layerId, + Annotation? annotation, + ) { + if (!mounted) return; + + // Cluster tap: just zoom in. We accept hits on EITHER the bubble circle + // layer OR the count-text symbol layer that sits on top of it. The + // platform-side hit-test iterates layers top-down and returns the first + // feature it finds; for cluster taps, the centered count text usually + // gets hit before the underlying bubble, so we have to recognise both + // layer IDs as "user tapped a cluster". Either way the action is the + // same: animate-zoom in 2 levels around the tap point. + // + // The explicit 200ms duration is important for perceived responsiveness. + // Without it, iOS uses setCamera(animated: true) which has a slow ease-in + // start (~150ms before any noticeable motion). Passing a duration switches + // the native code path to fly(to:withDuration:) which ramps in faster and + // finishes in 200ms, making the tap feel "instant" rather than delayed. + if (layerId == _repeaterClusterBubbleLayerId || + layerId == _repeaterClusterCountLayerId) { + final currentZoom = _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, 17.0); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + return; + } + + // Individual repeater: look up by id (which is repeater.id) and open the + // existing detail sheet. We recompute isDuplicate and hopOverride from + // app state rather than carrying them in feature properties — the values + // are cheap to derive and always reflect the latest data. + if (layerId == _repeaterIndividualLayerId) { + _showRepeaterDetailsById(id); + return; + } + + // GPS marker tap: the GPS marker is a non-interactive symbol on the + // annotation manager layer (which sits ON TOP of all custom layers in + // paint order). Without intervention, taps on the GPS marker hit the + // annotation layer first and stop the iOS dispatcher from checking the + // cluster layers underneath. Detect that case here and re-query the + // cluster layers at the same screen point so the user can still tap + // a cluster/repeater that the GPS marker happens to be sitting on top of. + if (annotation is Symbol) { + final kind = annotation.data?['kind'] as String?; + if (kind == 'gps') { + _fallThroughToRepeaterAt(point, coordinates); + return; + } + } + } + + /// When a tap hits the GPS marker (which has no detail sheet), try to find + /// any repeater cluster or individual repeater under the same point and + /// dispatch THAT instead. We use [queryRenderedFeatures] explicitly scoped + /// to the cluster source's layers, since the iOS native tap dispatcher + /// already short-circuited at the GPS marker layer above. + Future _fallThroughToRepeaterAt( + math.Point point, + LatLng coordinates, + ) async { + if (_mapController == null) return; + try { + final features = await _mapController!.queryRenderedFeatures( + point, + const [ + _repeaterClusterCountLayerId, + _repeaterClusterBubbleLayerId, + _repeaterIndividualLayerId, + ], + null, + ); + if (features.isEmpty || !mounted) return; + + // The Dart-side wrapper jsonDecodes each feature into a Map for us + // (see method_channel_maplibre_gl.dart::queryRenderedFeatures). So we + // can read properties directly without parsing JSON. + final feature = features.first as Map; + final properties = (feature['properties'] as Map?) ?? {}; + + // Cluster (auto-tagged by MapLibre when cluster: true is set on source). + // Same explicit 200ms duration as the direct cluster path in + // _handleFeatureTap so both tap routes feel identical. + if (properties['cluster'] == true) { + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, 17.0); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + return; + } + + // Individual repeater. The feature `id` field is the repeater.id we set + // in _buildRepeaterFeatureCollection (or fall back to the property). + final repeaterId = + (feature['id'] ?? properties['repeaterId'])?.toString(); + if (repeaterId != null) { + _showRepeaterDetailsById(repeaterId); + } + } catch (e) { + debugError('[MAP] queryRenderedFeatures fall-through failed: $e'); + } + } + + /// Open the repeater detail sheet for a given [repeaterId]. Looks up the + /// Repeater object from app state and recomputes the duplicate/hopOverride + /// flags. Used by both direct tap dispatch and the GPS fall-through path. + void _showRepeaterDetailsById(String repeaterId) { + if (!mounted) return; + final appState = context.read(); + final repeater = + appState.repeaters.where((r) => r.id == repeaterId).firstOrNull; + if (repeater == null) return; + + final duplicates = _getDuplicateRepeaterIds(appState.repeaters); + final isDuplicate = duplicates.contains(repeater.id); + final hopOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; + + _showRepeaterDetails( + repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: hopOverride, + ); + } + + Future _onStyleLoaded(AppStateProvider appState) async { + // Re-entrance guard. iOS plugin sometimes fires onStyleLoadedCallback + // multiple times during a single setStyle. The race causes "Layer not + // found" errors during the symbol manager's _rebuildLayers and + // double-registers images. Bail any nested call so the first invocation + // runs to completion uninterrupted. + if (_styleLoadInProgress) { + debugLog( + '[MAP] _onStyleLoaded re-entered while already running, skipping'); + return; + } + _styleLoadInProgress = true; + try { + _styleLoaded = true; + _isMapReady = true; + + // CRITICAL: clear stale Symbol references from any previous style load. + // Style reloads cause maplibre_gl to construct a brand-new SymbolManager + // with empty internal _idToAnnotation maps. Our _gpsSymbol / + // _coverageSymbols / _distanceLabelSymbols still reference the OLD + // Symbol objects whose IDs are not in the new manager — calling + // updateSymbol on them throws "you can only set existing annotations". + // Clearing them now means the next sync will call addSymbol (which + // creates fresh symbols in the new manager) instead of updateSymbol. + _gpsSymbol = null; + _coverageSymbols.clear(); + _distanceLabelSymbols.clear(); + // Distance-label companions: the native side wipes registered images on + // style reload, so the "already registered" cache must be cleared too or + // the next focus mode will skip addImage() and reference a non-existent + // image. The size/repeater-position maps are cleared for consistency. + _distanceLabelImageSize.clear(); + _distanceLabelRepeaterPos.clear(); + _registeredDistanceLabelImages.clear(); + // Mark cluster layers as not-ready until _setupRepeaterClusterLayers + // creates them on the new style. This gates build()-driven post-frame + // syncs from racing ahead of source creation. + _clusterLayersReady = false; + + // Disable symbol decluttering on the annotation manager. By default, + // MapLibre symbol layers hide overlapping icons/labels at lower zoom to + // reduce visual clutter — but for wardriving we want every coverage + // marker visible regardless of density. (Repeaters are now in their own + // cluster-enabled GeoJSON layer with its own per-layer overlap settings.) + await _configureSymbolDecluttering(); + + // Pre-render and register all marker bitmaps for native annotations. + // Style reloads (e.g., user switches dark→liberty) wipe registered images, + // so we always re-register on every style load. Awaited so the cluster + // layer (which references icon image names) sees them when it's created. + _imagesRegistered = false; + await _registerMapImages(appState); + + // Set up the repeater cluster source + 3 layers. Must run AFTER images + // are registered, since the individual symbol layer's iconImage expression + // looks up names registered by _registerMapImages. + await _setupRepeaterClusterLayers(); + + // Re-add coverage overlay AFTER cluster layers exist so _addCoverageOverlay + // can target the bottom repeater layer as its belowLayerId reference. This + // keeps the insertion point consistent with the zoneCode watcher path — + // both end up with raster at the bottom of the repeater stack, not above it. + await _refreshCoverageOverlay(appState); + _lastOverlayZoneCode = appState.zoneCode; + + // Start tile-load timeout. If onMapIdle doesn't fire within N seconds, + // we assume tiles are failing to load (network down, server error, etc.) + // and surface a banner. Cleared as soon as onMapIdle fires. + // When tiles are disabled (cache-only mode), suppress the warning — cached + // tiles load instantly or not at all; a timeout would be misleading. + _tileLoadTimeoutTimer?.cancel(); + final tilesEnabled = appState.preferences.mapTilesEnabled; + _lastMapTilesEnabled = tilesEnabled; + // Ensure MapLibre offline mode matches the user's preference. + setOffline(!tilesEnabled); + if (tilesEnabled) { + _tileLoadFailed = false; + _tileLoadTimeoutTimer = + Timer(const Duration(seconds: _tileLoadTimeoutSeconds), () { + if (mounted && !_tileLoadFailed) { + debugWarn( + '[MAP] Tile load timeout — tiles did not finish loading within ${_tileLoadTimeoutSeconds}s'); + setState(() => _tileLoadFailed = true); + } + }); + } else { + // Cache-only mode — never show the tile-load warning + _tileLoadFailed = false; + } + + // First-load-only setup: center on GPS and register camera listener. + // On subsequent style switches, preserve the user's pan position. + if (!_hasStyleLoadedOnce) { + _hasStyleLoadedOnce = true; + + // Center on GPS if available (initial centering) + if (appState.currentPosition != null) { + final center = LatLng( + appState.currentPosition!.latitude, + appState.currentPosition!.longitude, + ); + _mapController!.animateCamera( + CameraUpdate.newLatLngZoom(center, _defaultZoom), + ); + } + + // Register camera listener ONCE so marker overlay positions update on pan/zoom + _mapController!.addListener(_onCameraChanged); + } + + // Force an initial annotation sync now that images are registered AND the + // cluster source/layers exist. This pushes the current app state into the + // newly-created native annotations on first style load (and again whenever + // the style is reloaded, since style reloads wipe everything). + if (mounted) { + await _syncAllAnnotations(appState); + // Update the data version to match what we just synced. Without this, + // the build()-driven post-frame sync would fire AGAIN with the same + // data because _lastMarkerDataVersion still holds the previous value + // — that double-sync was racing the first sync's symbol refs and + // throwing "you can only set existing annotations" errors twice. + _lastMarkerDataVersion = _computeMarkerDataVersion(appState); + if (mounted) setState(() {}); + } + } finally { + _styleLoadInProgress = false; + } + } + + /// Fires when the map finishes loading visible tiles and the camera is idle. + /// We use this as the "tiles loaded successfully" signal — clears the failure + /// timer and hides any tile-load warning banner. + void _onMapIdle() { + _tileLoadTimeoutTimer?.cancel(); + if (_tileLoadFailed && mounted) { + debugLog('[MAP] Tiles recovered after earlier load failure'); + setState(() => _tileLoadFailed = false); + } + } + + /// Fires when the camera stops moving — after both gestures and + /// programmatic animations. While auto-follow is on, we use this as the + /// point to sync our tracked target zoom with whatever zoom the camera + /// actually settled at (e.g. after the user pinch-zoomed). That keeps the + /// next auto-follow GPS tick from snapping the camera back to a stale + /// target zoom. + void _onCameraIdle() { + if (!_autoFollow || _mapController == null) return; + final currentZoom = _mapController!.cameraPosition?.zoom; + if (currentZoom != null) { + _autoFollowDesiredZoom = currentZoom; + } + } + + /// Add MeshMapper coverage raster overlay as a MapLibre source+layer + Future _addCoverageOverlay(AppStateProvider appState) async { + if (_mapController == null || !_showMeshMapperOverlay) return; + if (!appState.preferences.mapTilesEnabled) return; + if (appState.zoneCode == null || appState.zoneCode!.isEmpty) return; + + final cvdParam = appState.preferences.colorVisionType != 'none' + ? '&cvd=${appState.preferences.colorVisionType}' + : ''; + final url = + 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}$cvdParam'; + + try { + await _mapController!.addSource( + 'meshmapper-overlay', + RasterSourceProperties(tiles: [url], tileSize: 256, maxzoom: 17), + ); + // Target the bottom of the repeater cluster stack when it exists, so the + // raster lands beneath ALL marker layers (repeater clusters + symbol + // annotations). During the initial style load, _setupRepeaterClusterLayers + // runs before this — so _clusterLayersReady is true and we use the + // individual repeater layer as the reference. The zoneCode watcher also + // fires after cluster setup, so both paths converge to the same stack. + // Fallback to the symbol annotation layer only if cluster layers haven't + // been created yet (shouldn't happen in practice, but keeps the raster + // underneath markers either way). + final belowLayer = _clusterLayersReady + ? _repeaterIndividualLayerId + : _symbolAnnotationLayerId(); + // While ping focus mode is active, force the newly added raster layer + // to opacity 0 so a cache-bust tile refresh (fires 5s after every API + // upload success — see AppStateProvider._tileRefreshTimer) doesn't + // make the overlay pop back into view in the middle of focus mode. + // Dismissing focus restores the preference value via + // _applyCoverageOverlayOpacity in _dismissPingFocus. + final opacity = _focusedPingLocation != null + ? 0.0 + : appState.preferences.coverageOverlayOpacity; + await _mapController!.addRasterLayer( + 'meshmapper-overlay', + 'meshmapper-overlay-layer', + RasterLayerProperties(rasterOpacity: opacity), + belowLayerId: belowLayer, + ); + _lastAppliedCoverageOpacity = opacity; + debugLog( + '[MAP] Coverage overlay added (below ${belowLayer ?? "top"}, opacity ${opacity.toStringAsFixed(2)})'); + } catch (e) { + debugLog('[MAP] Failed to add coverage overlay: $e'); + } + } + + /// Apply a new coverage overlay opacity to the live raster layer without + /// removing/re-adding it. No-op if the layer doesn't exist yet. + Future _applyCoverageOverlayOpacity(double opacity) async { + if (_mapController == null) return; + try { + await _mapController!.setLayerProperties( + 'meshmapper-overlay-layer', + RasterLayerProperties(rasterOpacity: opacity), + ); + _lastAppliedCoverageOpacity = opacity; + debugLog( + '[MAP] Coverage overlay opacity updated to ${opacity.toStringAsFixed(2)}'); + } catch (e) { + // Layer may not exist yet (e.g. before first style load or when the + // overlay is hidden). Safe to ignore — next _addCoverageOverlay call + // will pick up the current preference value. + debugLog('[MAP] Coverage overlay opacity update skipped: $e'); + } + } + + /// Returns the layer ID of the symbol annotation manager's first (and only) + /// layer, or `null` if the manager isn't initialized yet. Used as a + /// `belowLayerId` reference to insert other layers (coverage overlay, focus + /// lines) BENEATH the marker symbols so markers always render on top. + String? _symbolAnnotationLayerId() { + final manager = _mapController?.symbolManager; + if (manager == null) return null; + return '${manager.id}_0'; + } + + /// Disables MapLibre's default symbol-collision behavior for our marker + /// annotations. Without this, repeater markers fade out as you zoom out + /// because the symbol layer hides overlapping icons + labels to reduce + /// visual clutter — undesirable for a wardriving app where every marker + /// matters. Called once per style load, before any symbols are added. + Future _configureSymbolDecluttering() async { + if (_mapController == null) return; + try { + await _mapController!.setSymbolIconAllowOverlap(true); + await _mapController!.setSymbolIconIgnorePlacement(true); + await _mapController!.setSymbolTextAllowOverlap(true); + await _mapController!.setSymbolTextIgnorePlacement(true); + } catch (e) { + debugError('[MAP] Failed to configure symbol decluttering: $e'); + } + } + + /// Remove coverage overlay source and layer + Future _removeCoverageOverlay() async { + if (_mapController == null) return; + try { + await _mapController!.removeLayer('meshmapper-overlay-layer'); + await _mapController!.removeSource('meshmapper-overlay'); + } catch (_) {} + } + + /// Refresh coverage overlay (remove and re-add with new URL) + Future _refreshCoverageOverlay(AppStateProvider appState) async { + await _removeCoverageOverlay(); + await _addCoverageOverlay(appState); + } + + /// Returns the fill color for a repeater status keyword. + /// Mirrors the priority logic in [_getRepeaterMarkerColor]. + Color _repeaterStatusColor(String status) { + switch (status) { + case 'dup': + return PingColors.repeaterDuplicate; + case 'dead': + return PingColors.repeaterDead; + case 'new': + return PingColors.repeaterNew; + case 'active': + default: + return PingColors.repeaterActive; + } + } + + /// Returns the color for a coverage marker (TX/RX/DISC/Trace × success/fail). + Color _coverageStatusColor(String type, bool success) { + switch (type) { + case 'tx': + return success ? PingColors.txSuccess : PingColors.txFail; + case 'rx': + return PingColors.rx; + case 'disc': + return success ? PingColors.discSuccess : PingColors.discFail; + case 'trace': + return success ? Colors.cyan : Colors.grey; + default: + return Colors.grey; + } + } + + /// Returns the borderRadius value for a repeater shape based on hop_bytes. + /// Mirrors the values in the original `_buildRepeaterMarkers` (lines ~2390). + double _repeaterBorderRadius(int hopBytes) { + if (hopBytes >= 3) return 8; + if (hopBytes == 2) return 6; + return 4; + } + + /// Pre-renders and registers all marker bitmaps that the native MapLibre + /// symbols reference via `iconImage`. Called from [_onStyleLoaded] after the + /// style is ready (so addImage can succeed). Idempotent — safe to call again + /// if a style reload happens; addImage replaces existing entries by name. + /// + /// Generates: + /// - 12 repeater shape bitmaps (4 status colors × 3 hop_byte radii) — fixed + /// width 48px, the widest case (6-char hex IDs); shorter text is centered + /// by MapLibre's textField rendering. + /// - 8 coverage marker bitmaps for the user's currently-selected style. + /// - 6 GPS marker bitmaps (one per style). + /// + /// Marker style preference changes are handled separately by + /// [_reregisterCoverageImages] which only re-runs the coverage section. + Future _registerMapImages(AppStateProvider appState) async { + if (_mapController == null) return; + + try { + // 1. Repeater shapes — 12 variants + const repeaterSize = Size(48, 28); + for (final status in _MapImages.repeaterStatuses) { + final color = _repeaterStatusColor(status); + for (final hopBytes in _MapImages.repeaterHopBytes) { + final painter = _RepeaterShapePainter( + fillColor: color, + borderRadius: _repeaterBorderRadius(hopBytes), + ); + final bytes = await _renderPainterToPng(painter, repeaterSize); + await _mapController!.addImage( + _MapImages.repeater(status, hopBytes), + bytes, + ); + } + } + + // 2. Coverage markers — 8 variants for current style + await _registerCoverageImages(appState.preferences.markerStyle); + + // 3. GPS marker variants — 6 styles + const gpsSize = Size(48, 48); + final gpsPainters = { + 'arrow': const _ArrowPainter(), + 'car': const _CarMarkerPainter(), + 'bike': const _BikeMarkerPainter(), + 'boat': const _BoatMarkerPainter(), + 'walk': const _WalkMarkerPainter(), + 'chomper': const _ChomperMarkerPainter(), + }; + for (final entry in gpsPainters.entries) { + final bytes = await _renderPainterToPng(entry.value, gpsSize); + await _mapController!.addImage(_MapImages.gps(entry.key), bytes); + } + + _imagesRegistered = true; + debugLog( + '[MAP] Registered ${_MapImages.repeaterStatuses.length * _MapImages.repeaterHopBytes.length} repeater + 8 coverage + ${gpsPainters.length} GPS marker images'); + // NOTE: do NOT trigger _syncAllAnnotations here. The repeater cluster + // source/layers haven't been created yet — _onStyleLoaded calls + // _setupRepeaterClusterLayers AFTER us, then triggers the initial sync + // once everything is in place. + } catch (e) { + debugError('[MAP] Failed to register marker images: $e'); + } + } + + /// Generates and registers the 8 coverage marker bitmaps for [styleName]. + /// Called from [_registerMapImages] on initial setup, and from the + /// preference-change handler when the user picks a different marker shape. + Future _registerCoverageImages(String styleName) async { + if (_mapController == null) return; + // 40×40 canvas with the 24×24 glyph centered inside it — the transparent + // padding enlarges the native symbol hit target (~40×40 px) without + // changing the visual marker size. Fixes finicky taps on small markers. + const coverageSize = Size(40, 40); + for (final type in _MapImages.coverageTypes) { + for (final success in [true, false]) { + final painter = _CoverageMarkerPainter( + style: styleName, + color: _coverageStatusColor(type, success), + ); + final bytes = await _renderPainterToPng(painter, coverageSize); + await _mapController!.addImage( + _MapImages.coverage(type, success), + bytes, + ); + } + } + _registeredCoverageStyle = styleName; + } + + /// Returns the status keyword used as the iconImage suffix for a repeater. + /// Mirrors the priority logic in [_getRepeaterMarkerColor]: duplicate > dead + /// > new > active. + String _repeaterStatusKey(Repeater repeater, bool isDuplicate) { + if (isDuplicate) return 'dup'; + if (repeater.isDead) return 'dead'; + if (repeater.isNew) return 'new'; + return 'active'; + } + + /// Converts a Flutter [Color] to a `#RRGGBB` (or `#RRGGBBAA`) hex string + /// for MapLibre symbol/line properties (which take CSS-style color strings). + String _colorToHex(Color color, {bool includeAlpha = false}) { + final argb = color.toARGB32() & 0xFFFFFFFF; + final rr = ((argb >> 16) & 0xFF).toRadixString(16).padLeft(2, '0'); + final gg = ((argb >> 8) & 0xFF).toRadixString(16).padLeft(2, '0'); + final bb = (argb & 0xFF).toRadixString(16).padLeft(2, '0'); + if (includeAlpha) { + final aa = ((argb >> 24) & 0xFF).toRadixString(16).padLeft(2, '0'); + return '#$rr$gg$bb$aa'; + } + return '#$rr$gg$bb'; + } + + /// Builds a GeoJSON FeatureCollection of all repeaters in app state, with + /// per-feature properties used by the data-driven symbol layer expressions + /// (iconImage, color, opacity, hex). Re-pushed to the cluster source whenever + /// the marker data version changes — MapLibre handles re-clustering natively. + Map _buildRepeaterFeatureCollection( + AppStateProvider appState) { + final duplicates = _getDuplicateRepeaterIds(appState.repeaters); + final hopOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; + final focusActive = _focusedPingLocation != null; + + final features = >[]; + for (final repeater in appState.repeaters) { + final isDuplicate = duplicates.contains(repeater.id); + final statusKey = _repeaterStatusKey(repeater, isDuplicate); + final isConnected = focusActive && + _focusedRepeaters.any((r) => r.repeater.id == repeater.id); + // In focus mode, hide repeaters not involved in the focused ping entirely + // (skip the feature) rather than dimming — cleaner focus view and prevents + // them from contributing to clusters. + if (focusActive && !isConnected) continue; + final effectiveBytes = hopOverride ?? repeater.hopBytes; + // Clamp to the 1/2/3 hop_byte image variants we registered + final shapeBytes = effectiveBytes >= 3 + ? 3 + : effectiveBytes == 2 + ? 2 + : 1; + final iconImage = _MapImages.repeater(statusKey, shapeBytes); + final hex = repeater.displayHexId(overrideHopBytes: hopOverride); + final colorHex = _colorToHex(_repeaterStatusColor(statusKey)); + + features.add({ + 'type': 'Feature', + 'id': repeater.id, + 'properties': { + 'repeaterId': repeater.id, + 'iconImage': iconImage, + 'color': colorHex, + 'hex': hex, + 'isDuplicate': isDuplicate, + if (hopOverride != null) 'hopOverride': hopOverride, + }, + 'geometry': { + 'type': 'Point', + // GeoJSON convention: [longitude, latitude] + 'coordinates': [repeater.lon, repeater.lat], + }, + }); + } + + return {'type': 'FeatureCollection', 'features': features}; + } + + /// Creates the cluster-enabled GeoJSON source and three rendering layers + /// (individual symbols, cluster bubble circles, cluster count text). Called + /// once per style load AFTER images are registered (the individual symbol + /// layer references the registered icon names via a data-driven expression). + Future _setupRepeaterClusterLayers() async { + if (_mapController == null) return; + + // Idempotent: tear down any existing source/layers from a previous style load + for (final layerId in [ + _repeaterClusterCountLayerId, + _repeaterClusterBubbleLayerId, + _repeaterIndividualLayerId, + ]) { + try { + await _mapController!.removeLayer(layerId); + } catch (_) {} + } + try { + await _mapController!.removeSource(_repeaterSourceId); + } catch (_) {} + + // Empty source with cluster enabled. We'll push real data via setGeoJsonSource + // from _syncRepeaterSymbols whenever the marker data version changes. + // + // IMPORTANT: pass `data` as a Dart Map (NOT jsonEncode-d string). The iOS + // plugin's `buildShapeSource` assumes that if `data` is a String, it must be + // a URL — and crashes via JSONSerialization.data() if a non-URL string is + // passed and the URL parse fails. Maps are handled correctly. + try { + await _mapController!.addSource( + _repeaterSourceId, + const GeojsonSourceProperties( + data: { + 'type': 'FeatureCollection', + 'features': [] + }, + cluster: true, + clusterRadius: 50, + clusterMaxZoom: 14, + ), + ); + + // Place all three layers BELOW the symbol annotation manager so coverage + // markers / GPS / distance labels still render on top of repeater clusters. + final belowLayer = _symbolAnnotationLayerId(); + + // Layer 1: individual repeater markers (when not part of a cluster). + // Data-driven properties read from each feature's `properties` map. + await _mapController!.addSymbolLayer( + _repeaterSourceId, + _repeaterIndividualLayerId, + const SymbolLayerProperties( + iconImage: ['get', 'iconImage'], + iconColor: ['get', 'color'], + iconSize: 1.4, + iconAllowOverlap: true, + iconIgnorePlacement: true, + textField: ['get', 'hex'], + textColor: '#FFFFFF', + textHaloColor: '#000000', + textHaloWidth: 1.5, + textSize: 13, + textAllowOverlap: true, + textIgnorePlacement: true, + textFont: _defaultFontStack, + ), + filter: [ + '!', + ['has', 'point_count'] + ], + belowLayerId: belowLayer, + ); + + // Layer 2: cluster bubble (circle, sized by point_count). + // The 'step' expression makes the bubble grow as more repeaters merge: + // - default radius 18px (clusters of 2-9) + // - 22px for clusters of 10+ + // - 26px for clusters of 50+ + await _mapController!.addCircleLayer( + _repeaterSourceId, + _repeaterClusterBubbleLayerId, + CircleLayerProperties( + circleColor: _colorToHex(PingColors.repeaterActive), + circleRadius: const [ + 'step', + ['get', 'point_count'], + 18, + 10, + 22, + 50, + 26, + ], + circleStrokeColor: '#FFFFFF', + circleStrokeWidth: 2, + circleOpacity: 0.9, + ), + filter: ['has', 'point_count'], + belowLayerId: belowLayer, + ); + + // Layer 3: cluster count text (uses MapLibre's built-in + // 'point_count_abbreviated' property — automatically formatted as + // "1.2k" for large counts). + await _mapController!.addSymbolLayer( + _repeaterSourceId, + _repeaterClusterCountLayerId, + const SymbolLayerProperties( + textField: ['get', 'point_count_abbreviated'], + textColor: '#FFFFFF', + textSize: 14, + textHaloColor: '#000000', + textHaloWidth: 1, + textAllowOverlap: true, + textIgnorePlacement: true, + textFont: _defaultFontStack, + ), + filter: ['has', 'point_count'], + belowLayerId: belowLayer, + ); + + // All 3 layers + source created successfully — mark ready so the + // build()-triggered post-frame sync can run, and so _syncRepeaterSymbols + // is allowed to push data via setGeoJsonSource. + _clusterLayersReady = true; + } catch (e) { + debugError('[MAP] Failed to set up repeater cluster layers: $e'); + } + } + + /// Pushes the current repeater state into the cluster source. MapLibre + /// re-clusters natively whenever the source data changes. Replaces the + /// previous per-symbol addSymbol/updateSymbol/removeSymbol diff loop. + Future _syncRepeaterSymbols(AppStateProvider appState) async { + if (_mapController == null || + !_styleLoaded || + !_imagesRegistered || + !_clusterLayersReady) { + return; + } + try { + final geojson = _buildRepeaterFeatureCollection(appState); + await _mapController!.setGeoJsonSource(_repeaterSourceId, geojson); + } catch (e) { + debugError('[MAP] Failed to update repeater source: $e'); + } + } + + /// Composite key for a coverage marker symbol — kind + timestamp ms + lat/lon. + /// Used as the map key in [_coverageSymbols] and to detect updates/removals. + /// Lat/lon at 5-decimal precision (~1.1m) is included so two distinct pings + /// that happen to land in the same millisecond (possible under heavy RX + /// traffic) don't collide on a shared key. + String _coverageKey(String type, DateTime ts, double lat, double lon) => + '${type}_${ts.millisecondsSinceEpoch}_' + '${lat.toStringAsFixed(5)}_${lon.toStringAsFixed(5)}'; + + /// Diff-syncs native coverage symbols (TX/RX/DISC/Trace) against app state. + /// One symbol per ping, image varies by type/success state, opacity reflects + /// focus mode (faded if focus active and this isn't the focused ping). + /// + /// Marker style preference changes are NOT handled here — when the user + /// switches between circle/pin/diamond/dot, the caller must first call + /// [_handleMarkerStyleChange] to re-register the bitmap variants. + Future _syncCoverageSymbols(AppStateProvider appState) async { + if (_mapController == null || !_styleLoaded || !_imagesRegistered) return; + + // Re-register coverage images if the user changed their style preference + final currentStyle = appState.preferences.markerStyle; + if (_registeredCoverageStyle != currentStyle) { + await _registerCoverageImages(currentStyle); + // After re-registering, all existing coverage symbols still reference + // the same image names — but the underlying bitmaps have changed shape. + // The native side picks up the new bitmaps automatically. No need to + // update each symbol. + } + + final wantedKeys = {}; + final focusActive = _focusedPingLocation != null; + + Future syncOne({ + required String type, + required double lat, + required double lon, + required DateTime ts, + required bool success, + required int idForMetadata, + }) async { + final key = _coverageKey(type, ts, lat, lon); + final isFocused = _isFocusedPing(lat, lon, ts); + // In focus mode, hide every coverage marker except the focused ping. + // Skipping wantedKeys lets the cleanup loop remove them entirely so the + // map is uncluttered. Dismissing focus re-syncs and restores them. + if (focusActive && !isFocused) return; + wantedKeys.add(key); + + final options = SymbolOptions( + geometry: LatLng(lat, lon), + iconImage: _MapImages.coverage(type, success), + iconSize: isFocused ? 1.2 : 1.0, + ); + + final existing = _coverageSymbols[key]; + if (existing == null) { + try { + final symbol = await _mapController!.addSymbol( + options, + {'kind': type, 'id': idForMetadata}, + ); + _coverageSymbols[key] = symbol; + } catch (e) { + debugError('[MAP] addSymbol($type) failed at $ts: $e'); + } + } else { + try { + await _mapController!.updateSymbol(existing, options); + } catch (e) { + debugError('[MAP] updateSymbol($type) failed at $ts: $e'); + } + } + } + + // TX pings + for (final ping in appState.txPings) { + await syncOne( + type: 'tx', + lat: ping.latitude, + lon: ping.longitude, + ts: ping.timestamp, + success: ping.heardRepeaters.isNotEmpty, + idForMetadata: ping.timestamp.millisecondsSinceEpoch, + ); + } + + // RX pings + for (final ping in appState.rxPings) { + await syncOne( + type: 'rx', + lat: ping.latitude, + lon: ping.longitude, + ts: ping.timestamp, + success: true, // RX has no fail state — always uses the rx color + idForMetadata: ping.timestamp.millisecondsSinceEpoch, + ); + } + + // DISC entries (success = received node responses; drop = treat as TX fail) + for (final entry in appState.discLogEntries) { + final received = entry.nodeCount > 0; + // When discDropEnabled, "no response" should look like a TX fail color. + // We model that by using the 'tx' image variant for failed DISCs: + final type = (!received && appState.discDropEnabled) ? 'tx' : 'disc'; + await syncOne( + type: type, + lat: entry.latitude, + lon: entry.longitude, + ts: entry.timestamp, + success: received, + idForMetadata: entry.timestamp.millisecondsSinceEpoch, + ); + } + + // Trace entries + for (final entry in appState.traceLogEntries) { + await syncOne( + type: 'trace', + lat: entry.latitude, + lon: entry.longitude, + ts: entry.timestamp, + success: entry.success, + idForMetadata: entry.timestamp.millisecondsSinceEpoch, + ); + } + + // Remove symbols for pings that no longer exist (e.g., user cleared markers) + final toRemove = + _coverageSymbols.keys.where((k) => !wantedKeys.contains(k)).toList(); + for (final key in toRemove) { + final sym = _coverageSymbols.remove(key); + if (sym != null) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + } + } + + /// Returns true if the given GPS marker style should rotate to face the + /// user's heading (vs staying screen-aligned). Arrow/walk/pacman face the + /// heading; car/bike/boat icons stay upright on a rotated map. + bool _gpsStyleFacesHeading(String style) => + style == 'arrow' || style == 'walk' || style == 'chomper'; + + /// Computes the iconRotate value for the GPS marker. + /// + /// MapLibre annotation symbols use the default `icon-rotation-alignment: auto` + /// which resolves to `viewport` for point symbols — meaning iconRotate is + /// applied in screen space, not map space. That has two consequences: + /// + /// - Rotating styles (arrow/walk/chomper) must point in the direction of + /// travel both in always-north mode (where bearing = 0, so iconRotate + /// = heading) AND in heading mode (where the map is rotated so that + /// direction-of-travel is screen-up — so iconRotate should be 0). + /// The single formula that works for both is `heading - bearing`. + /// + /// - Non-rotating styles (car/bike/boat) should always be drawn upright + /// on screen. With viewport alignment that's iconRotate = 0 regardless + /// of bearing; the icon is already screen-aligned by default. + double _gpsIconRotate(String style, double heading) { + final bearing = _mapController?.cameraPosition?.bearing ?? 0; + if (_gpsStyleFacesHeading(style)) { + final rotated = heading - bearing; + // Normalize to 0..360 so MapLibre doesn't take the "long way around" + // when iconRotate crosses the ±180° seam during interpolation. + return (rotated % 360 + 360) % 360; + } + return 0; + } + + /// Adds, updates, or removes the single GPS position symbol to match + /// [appState.currentPosition]. Called from the post-frame sync trigger. + Future _syncGpsSymbol(AppStateProvider appState) async { + if (_mapController == null || !_styleLoaded || !_imagesRegistered) return; + + final pos = appState.currentPosition; + if (pos == null) { + // No GPS lock — remove existing GPS symbol if present + if (_gpsSymbol != null) { + try { + await _mapController!.removeSymbol(_gpsSymbol!); + } catch (_) {} + _gpsSymbol = null; + } + return; + } + + final style = appState.preferences.gpsMarkerStyle; + // Use the derived heading (updated by _computeHeading in build()) so the + // arrow/walk/chomper markers actually point in the direction of travel + // even when pos.heading is stale or unset. + final iconRotate = _gpsIconRotate(style, _computedHeading ?? 0); + + final options = SymbolOptions( + geometry: LatLng(pos.latitude, pos.longitude), + iconImage: _MapImages.gps(style), + iconRotate: iconRotate, + ); + + if (_gpsSymbol == null) { + try { + _gpsSymbol = await _mapController!.addSymbol(options, {'kind': 'gps'}); + } catch (e) { + debugError('[MAP] addSymbol(gps) failed: $e'); + } + } else { + try { + await _mapController!.updateSymbol(_gpsSymbol!, options); + } catch (e) { + debugError('[MAP] updateSymbol(gps) failed: $e'); + } + } + } + + /// Updates only the GPS symbol's iconRotate. Called from the camera-change + /// listener when the bearing changes — under viewport alignment, rotating + /// styles (arrow/walk/chomper) are the ones whose iconRotate depends on the + /// bearing (iconRotate = heading - bearing), so they need refreshing as the + /// bearing animates. Non-rotating styles use iconRotate = 0 and don't care. + /// Cheaper than calling [_syncGpsSymbol] which also updates position. + Future _updateGpsSymbolRotation() async { + if (_gpsSymbol == null || _mapController == null) return; + final appState = context.read(); + final pos = appState.currentPosition; + if (pos == null) return; + final style = appState.preferences.gpsMarkerStyle; + if (!_gpsStyleFacesHeading(style)) return; + try { + await _mapController!.updateSymbol( + _gpsSymbol!, + SymbolOptions(iconRotate: _gpsIconRotate(style, _computedHeading ?? 0)), + ); + } catch (_) {} + } + + // Source/layer ID constants for the focus-mode dotted lines + static const _focusLinesSourceId = 'focus-lines-source'; + static const _focusLinesLayerId = 'focus-lines-layer'; + static const _focusLinesAmbiguousLayerId = 'focus-lines-ambiguous-border'; + + /// Builds and applies the focus-mode dotted polylines that visually connect + /// a focused ping to each repeater that heard it. Color-coded by SNR; + /// ambiguous matches get a wider white outline drawn underneath. + /// + /// Implementation uses a GeoJSON source + line layer (rather than the + /// annotation-level addLine API) because LineOptions does not expose + /// `lineDasharray`, but LineLayerProperties does. + /// + /// Idempotent: removes any existing source/layers first, then re-adds with + /// the latest focus state. + Future _updateFocusLines() async { + if (_mapController == null || !_styleLoaded) return; + + // Always remove existing layers/source first (silently ignore if absent). + // Order matters: remove the layers BEFORE the source they reference. + try { + await _mapController!.removeLayer(_focusLinesLayerId); + } catch (_) {} + try { + await _mapController!.removeLayer(_focusLinesAmbiguousLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_focusLinesSourceId); + } catch (_) {} + + if (_focusedPingLocation == null || _focusedRepeaters.isEmpty) return; + + // Build a FeatureCollection with one LineString per connected repeater. + // Per-feature properties carry the line color (data-driven styling) and + // ambiguous flag (used as a layer filter for the border line). + final features = >[]; + for (final r in _focusedRepeaters) { + final color = r.snr != null ? PingColors.snrColor(r.snr!) : Colors.grey; + features.add({ + 'type': 'Feature', + 'properties': { + 'color': _colorToHex(color), + 'ambiguous': r.ambiguous, + }, + 'geometry': { + 'type': 'LineString', + 'coordinates': [ + [_focusedPingLocation!.longitude, _focusedPingLocation!.latitude], + [r.repeater.lon, r.repeater.lat], + ], + }, + }); + } + + // Pass the FeatureCollection as a Dart Map (NOT a jsonEncode-d string). + // The iOS plugin's buildShapeSource crashes if `data` is a string that's + // not a URL — see fix in _setupRepeaterClusterLayers for the same gotcha. + final geojson = { + 'type': 'FeatureCollection', + 'features': features, + }; + + try { + await _mapController!.addSource( + _focusLinesSourceId, + GeojsonSourceProperties(data: geojson), + ); + + // Insert focus line layers BELOW the individual repeater layer so + // repeater boxes (and the cluster bubbles/count text above them, plus + // the symbol annotation markers on top of those) all render on top of + // the connecting lines. This is especially important at the repeater + // end of each line, where the dotted stroke would otherwise draw over + // the repeater box. + const belowLayer = _repeaterIndividualLayerId; + + // Border line (white, wider, only for ambiguous matches) — added FIRST + // so it renders BENEATH the colored line on top. + await _mapController!.addLineLayer( + _focusLinesSourceId, + _focusLinesAmbiguousLayerId, + const LineLayerProperties( + lineColor: '#FFFFFF', + lineOpacity: 0.6, + lineWidth: 6.5, + lineDasharray: [2, 4], + lineCap: 'round', + ), + filter: [ + '==', + ['get', 'ambiguous'], + true + ], + belowLayerId: belowLayer, + ); + + // Main colored line (color from feature property via data-driven expression) + await _mapController!.addLineLayer( + _focusLinesSourceId, + _focusLinesLayerId, + const LineLayerProperties( + lineColor: ['get', 'color'], + lineOpacity: 0.9, + lineWidth: 3.5, + lineDasharray: [2, 4], + lineCap: 'round', + ), + belowLayerId: belowLayer, + ); + } catch (e) { + debugError('[MAP] Failed to add focus lines: $e'); + } + } + + /// Diff-syncs the distance label symbols shown in focus mode. Each label is + /// a bitmap pill (white text on a dark rounded rectangle background, baked + /// into an addImage icon) placed at the midpoint of the ping→repeater line. + /// + /// A later pass ([_reflowDistanceLabelsForCollisions]) may slide individual + /// labels along their lines after the zoom-to-fit animation settles, to + /// prevent them from overlapping on screen. + Future _syncDistanceLabels(AppStateProvider appState) async { + if (_mapController == null || !_styleLoaded) return; + + // No focus → remove all existing labels and wipe the tracking maps. + // + // Order matters here: snapshot the symbols to remove and clear the + // tracking maps SYNCHRONOUSLY before awaiting any removeSymbol call. + // + // Why: removeSymbol is async. If we cleared after the await loop, a + // concurrent _syncDistanceLabels call (triggered by e.g. the user + // tapping a new ping and its focus activating during the yield) would + // see the old tracking data — populate new symbols for the new focus + // into the still-populated map — and then our late `.clear()` would + // wipe the new-focus entries from tracking, leaving orphaned native + // symbols on the map and causing the NEXT sync to double-add them. + // By clearing first, any concurrent sync starts from a clean slate. + if (_focusedPingLocation == null || _focusedRepeaters.isEmpty) { + final toRemove = List.of(_distanceLabelSymbols.values); + _distanceLabelSymbols.clear(); + _distanceLabelImageSize.clear(); + _distanceLabelRepeaterPos.clear(); + for (final sym in toRemove) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + return; + } + + final isImperial = appState.preferences.isImperial; + final ping = _focusedPingLocation!; + final wantedKeys = {}; + + for (final r in _focusedRepeaters) { + final key = r.repeater.id; + wantedKeys.add(key); + final midLat = (ping.latitude + r.repeater.lat) / 2; + final midLon = (ping.longitude + r.repeater.lon) / 2; + final meters = GpsService.distanceBetween( + ping.latitude, + ping.longitude, + r.repeater.lat, + r.repeater.lon, + ); + final labelText = meters < 1000 + ? formatMeters(meters, isImperial: isImperial) + : formatKilometers(meters / 1000, isImperial: isImperial); + + // Dedup the bitmap image by label text — identical distances reuse one + // registered image. addImage is idempotent by name, so re-registering + // the same name is a no-op on subsequent calls. + final imageName = 'distance-label-${labelText.hashCode}'; + Size? imageSize; + if (!_registeredDistanceLabelImages.contains(imageName)) { + try { + final rendered = await _renderDistanceLabelPng(labelText); + await _mapController!.addImage(imageName, rendered.bytes); + _registeredDistanceLabelImages.add(imageName); + imageSize = rendered.size; + } catch (e) { + debugError('[MAP] render/addImage(distance label) failed: $e'); + } + } + // If we didn't just render (reuse case) we still need the size for + // collision tests. Re-render for measurement; this is cheap and rare. + if (imageSize == null) { + try { + final rendered = await _renderDistanceLabelPng(labelText); + imageSize = rendered.size; + } catch (_) { + imageSize = const Size(60, 18); + } + } + _distanceLabelImageSize[key] = imageSize; + _distanceLabelRepeaterPos[key] = LatLng(r.repeater.lat, r.repeater.lon); + + final options = SymbolOptions( + geometry: LatLng(midLat, midLon), + iconImage: imageName, + iconSize: 1.0, + iconAnchor: 'center', + ); + + final existing = _distanceLabelSymbols[key]; + if (existing == null) { + try { + _distanceLabelSymbols[key] = await _mapController!.addSymbol( + options, + {'kind': 'distance'}, + ); + } catch (e) { + debugError('[MAP] addSymbol(distance) failed: $e'); + } + } else { + try { + await _mapController!.updateSymbol(existing, options); + } catch (e) { + debugError('[MAP] updateSymbol(distance) failed: $e'); + } + } + } + + // Remove labels for repeaters no longer in focus + final toRemove = _distanceLabelSymbols.keys + .where((k) => !wantedKeys.contains(k)) + .toList(); + for (final key in toRemove) { + final sym = _distanceLabelSymbols.remove(key); + _distanceLabelImageSize.remove(key); + _distanceLabelRepeaterPos.remove(key); + if (sym != null) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + } } - Widget _buildMap(AppStateProvider appState, LatLng center) { - return Builder( - builder: (context) => FlutterMap( - mapController: _mapController, - options: MapOptions( - initialCenter: center, - initialZoom: _defaultZoom, - minZoom: 3, - maxZoom: 17, - interactionOptions: InteractionOptions( - flags: _rotationLocked - ? InteractiveFlag.all & ~InteractiveFlag.rotate - : InteractiveFlag.all, - ), - onMapReady: () { - _isMapReady = true; - // Initial center on GPS if available - if (appState.currentPosition != null) { - _mapController.move(center, _defaultZoom); - } - }, - ), - children: [ - // Tile layer (dynamic based on selected style from preferences) - // Skipped entirely when map tiles are disabled to save mobile data - if (appState.preferences.mapTilesEnabled) - Builder( - builder: (context) { - final mapStyle = - MapStyleExtension.fromString(appState.preferences.mapStyle); - return TileLayer( - urlTemplate: mapStyle.urlTemplate, - subdomains: mapStyle.subdomains ?? const [], - userAgentPackageName: 'com.meshmapper.app', - maxZoom: 17, - retinaMode: mapStyle.supportsRetina && - RetinaMode.isHighDensity(context), - tileDisplay: const TileDisplay.fadeIn( - reloadStartOpacity: 1.0, - ), - tileProvider: SilentCancellableNetworkTileProvider(), - ); - }, - ), + /// After the focus zoom-to-fit animation settles, walks the placed distance + /// labels and slides any that overlap on screen to a different position + /// along their ping→repeater line. Uses toScreenLocationBatch to sample + /// candidate t values (0.5, 0.4, 0.6, 0.3, 0.7, 0.25, 0.75) for each label + /// and greedily picks the first non-colliding slot. + Future _reflowDistanceLabelsForCollisions() async { + if (_mapController == null || !mounted) return; + if (_focusedPingLocation == null) return; + if (_distanceLabelSymbols.isEmpty) return; - // MeshMapper coverage overlay (only when zone code available, overlay enabled, and tiles enabled) - if (appState.preferences.mapTilesEnabled && - appState.zoneCode != null && - _showMeshMapperOverlay) - TileLayer( - urlTemplate: - 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}${appState.preferences.colorVisionType != 'none' ? '&cvd=${appState.preferences.colorVisionType}' : ''}', - userAgentPackageName: 'com.meshmapper.app', - minZoom: 3, - maxZoom: 17, - tileDisplay: const TileDisplay.fadeIn( - reloadStartOpacity: 1.0, - ), - tileProvider: SilentCancellableNetworkTileProvider(), - ), + final ping = _focusedPingLocation!; + // Deterministic order: iterate focused repeaters in the list order we got + // them in (SNR-ranked upstream), so the "primary" label wins t=0.5. + final orderedIds = _focusedRepeaters + .map((r) => r.repeater.id) + .where(_distanceLabelSymbols.containsKey) + .toList(); + if (orderedIds.isEmpty) return; + + // Candidate t values to try, in preference order. + const candidateTs = [0.5, 0.4, 0.6, 0.3, 0.7, 0.25, 0.75]; + + // Step 1: compute all candidate LatLngs for every label so we can batch + // the toScreenLocation calls (one round-trip instead of N×T). + final candidateLatLngs = []; + for (final id in orderedIds) { + final repeaterPos = _distanceLabelRepeaterPos[id]; + if (repeaterPos == null) continue; + for (final t in candidateTs) { + candidateLatLngs.add(LatLng( + ping.latitude + (repeaterPos.latitude - ping.latitude) * t, + ping.longitude + (repeaterPos.longitude - ping.longitude) * t, + )); + } + } - // Coverage markers (TX, RX, DISC, Trace) — sorted by timestamp, newest on top - // During focus mode, the focused marker is excluded and rendered in its own top layer - MarkerLayer( - markers: _buildCoverageMarkers( - txPings: appState.txPings, - rxPings: appState.rxPings, - discEntries: appState.discLogEntries, - discDropEnabled: appState.discDropEnabled, - traceEntries: appState.traceLogEntries, - excludeFocused: _focusedPingLocation != null, - ), - ), + List> screenPoints; + try { + screenPoints = + await _mapController!.toScreenLocationBatch(candidateLatLngs); + } catch (e) { + debugError('[MAP] toScreenLocationBatch(distance labels) failed: $e'); + return; + } + if (!mounted || _focusedPingLocation == null) return; + + // Step 2: greedily place each label at the first candidate t whose + // screen rect doesn't overlap any already-placed label rect. + const gap = 4.0; // extra spacing between pills in logical pixels + final placedRects = []; + var cursor = 0; + for (final id in orderedIds) { + final repeaterPos = _distanceLabelRepeaterPos[id]; + final labelSize = _distanceLabelImageSize[id] ?? const Size(60, 18); + if (repeaterPos == null) { + cursor += candidateTs.length; + continue; + } - // Focus mode: polylines from focused ping to each connected repeater - // Line color = SNR (green/yellow/red). Ambiguous matches get a white border. - if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) - PolylineLayer( - polylines: _focusedRepeaters.map((r) { - final lineColor = - r.snr != null ? PingColors.snrColor(r.snr!) : Colors.grey; - return Polyline( - points: [ - _focusedPingLocation!, - LatLng(r.repeater.lat, r.repeater.lon) - ], - color: lineColor.withValues(alpha: 0.9), - strokeWidth: 3.5, - isDotted: true, - borderStrokeWidth: r.ambiguous ? 1.5 : 0, - borderColor: - r.ambiguous ? Colors.white.withValues(alpha: 0.6) : null, - ); - }).toList(), - ), + int bestIdx = 0; + Rect? bestRect; + for (var i = 0; i < candidateTs.length; i++) { + final sp = screenPoints[cursor + i]; + final rect = Rect.fromCenter( + center: Offset(sp.x.toDouble(), sp.y.toDouble()), + width: labelSize.width + gap, + height: labelSize.height + gap, + ); + final collides = placedRects.any((r) => r.overlaps(rect)); + if (!collides) { + bestIdx = i; + bestRect = rect; + break; + } + // Fallback: keep the first candidate rect so we still place somewhere + // if every slot collides. + bestRect ??= rect; + } - // Repeater markers (magenta with ID, rotate with map) - // During focus mode, split into two layers: faded repeaters below, connected on top - if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) ...[ - // Faded non-connected repeaters (below) - MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, - onlyFaded: true, - ), - ), - // Distance labels (middle) - MarkerLayer( - rotate: true, - markers: _buildFocusDistanceLabels(appState), - ), - // Connected repeaters (on top) - MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, - onlyConnected: true, - ), - ), - // Focused ping marker (above everything except GPS) - MarkerLayer( - markers: _buildFocusedPingMarker( - txPings: appState.txPings, - rxPings: appState.rxPings, - discEntries: appState.discLogEntries, - discDropEnabled: appState.discDropEnabled, - traceEntries: appState.traceLogEntries, - ), - ), - ] else - // Normal mode: single layer with all repeaters - MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, - ), - ), + final tChosen = candidateTs[bestIdx]; + final targetLatLng = LatLng( + ping.latitude + (repeaterPos.latitude - ping.latitude) * tChosen, + ping.longitude + (repeaterPos.longitude - ping.longitude) * tChosen, + ); + placedRects.add(bestRect!); + + final symbol = _distanceLabelSymbols[id]; + if (symbol != null) { + try { + await _mapController!.updateSymbol( + symbol, + SymbolOptions(geometry: targetLatLng), + ); + } catch (e) { + debugError('[MAP] updateSymbol(distance reflow) failed: $e'); + } + } - // Current position marker - if (appState.currentPosition != null) - MarkerLayer( - // Vehicle/boat icons stay upright by counter-rotating against map rotation; - // arrow, walk, and chomper rotate with heading (handled by Transform.rotate in the painter) - rotate: appState.preferences.gpsMarkerStyle != 'arrow' && - appState.preferences.gpsMarkerStyle != 'walk' && - appState.preferences.gpsMarkerStyle != 'chomper', - markers: [ - Marker( - point: LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, - ), - width: 48, - height: 48, - child: _buildCurrentPositionMarker( - appState.currentPosition!.heading), - ), - ], - ), - ], - ), + cursor += candidateTs.length; + } + } + + /// Single entry point that syncs all native annotations against current + /// app state. Called from the post-frame callback in [build] when the + /// marker data version changes (so we don't sync on every camera tick). + Future _syncAllAnnotations(AppStateProvider appState) async { + await _syncRepeaterSymbols(appState); + await _syncCoverageSymbols(appState); + await _syncGpsSymbol(appState); + await _updateFocusLines(); + await _syncDistanceLabels(appState); + } + + /// Compute a version hash of all data that affects the marker list. + /// When this changes, the cached marker list is rebuilt; otherwise it's reused + /// across camera-change rebuilds (which happen at ~60Hz during pan/zoom). + /// + /// Captures **in-place** mutations too: TX pings grow `heardRepeaters` during + /// the 7s echo window, and DISC entries grow `discoveredNodes` as late + /// responses land. Summing counts makes the hash sensitive to these additions + /// even though the parent list length doesn't change. + int _computeMarkerDataVersion(AppStateProvider appState) { + int txEchoTotal = 0; + for (final p in appState.txPings) { + txEchoTotal += p.heardRepeaters.length; + } + int discNodeTotal = 0; + for (final e in appState.discLogEntries) { + discNodeTotal += e.discoveredNodes.length; + } + + return Object.hash( + appState.txPings.length, + appState.rxPings.length, + appState.discLogEntries.length, + appState.traceLogEntries.length, + appState.repeaters.length, + appState.discDropEnabled, + appState.enforceHopBytes, + appState.effectiveHopBytes, + _focusedPingLocation, + _focusedPingTimestamp, + _focusedRepeaters.length, + appState.preferences.gpsMarkerStyle, + appState.preferences.markerStyle, + appState.currentPosition?.latitude, + appState.currentPosition?.longitude, + _computedHeading, + txEchoTotal, + discNodeTotal, ); } @@ -1184,6 +2965,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (_autoFollow) { setState(() { _autoFollow = false; + _autoFollowDesiredZoom = null; }); appState.setMapAutoFollow(false); return; @@ -1195,16 +2977,30 @@ class _MapWidgetState extends State with TickerProviderStateMixin { appState.currentPosition!.latitude, appState.currentPosition!.longitude, ); + const targetZoom = 17.0; // Street level zoom when enabling follow setState(() { _autoFollow = true; _lastGpsPosition = targetPosition; + _autoFollowDesiredZoom = targetZoom; }); appState.setMapAutoFollow(true); - // Apply offset for bottom padding when control panel is open - final adjustedPosition = _offsetPositionForPadding(targetPosition, - widget.bottomPaddingPixels, widget.rightPaddingPixels); - _animateToPositionWithZoom( - adjustedPosition, 17.0); // Street level zoom when enabling follow + // Bundle target + zoom + bearing into one animation so the + // initial centering can't be half-cancelled by a racing GPS tick. + final double targetBearing = + (!_alwaysNorth && _computedHeading != null) ? _computedHeading! : 0.0; + final adjustedPosition = _offsetPositionForPadding( + targetPosition, + widget.bottomPaddingPixels, + widget.rightPaddingPixels, + targetZoom, + targetBearing, + ); + _animateAutoFollowCamera( + target: adjustedPosition, + zoom: targetZoom, + bearing: targetBearing, + durationMs: 500, + ); } } @@ -1212,6 +3008,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { setState(() { _showMeshMapperOverlay = !_showMeshMapperOverlay; }); + if (_showMeshMapperOverlay) { + _addCoverageOverlay(context.read()); + } else { + _removeCoverageOverlay(); + } } void _toggleNorthMode() { @@ -1220,53 +3021,25 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _alwaysNorth = !_alwaysNorth; // If switching to Always North mode, smoothly rotate map back to north - if (_alwaysNorth && _isMapReady) { - // Reset heading tracking + if (_alwaysNorth && _isMapReady && _mapController != null) { _lastHeading = null; - // Smoothly rotate back to north (0 degrees) - final currentRotation = _mapController.camera.rotation; - if (currentRotation.abs() > 2) { - // Cancel any running rotation animation - _rotationAnimationController?.stop(); - _rotationAnimationController?.dispose(); - - // Create animation to rotate back to north - const duration = Duration(milliseconds: 500); - _rotationAnimationController = AnimationController( - duration: duration, - vsync: this, - ); - - _rotationAnimation = CurvedAnimation( - parent: _rotationAnimationController!, - curve: Curves.easeInOutCubic, + final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; + if (currentBearing.abs() > 2) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 500), ); - - _rotationStartAngle = currentRotation; - _rotationEndAngle = 0.0; // North - - _rotationAnimation!.addListener(() { - if (!mounted || - _rotationStartAngle == null || - _rotationEndAngle == null) { - return; - } - - final t = _rotationAnimation!.value; - final rotation = _rotationStartAngle! + - ((_rotationEndAngle! - _rotationStartAngle!) * t); - - _mapController.rotate(rotation); - }); - - _rotationAnimationController!.forward(); } } else if (!_alwaysNorth && appState.currentPosition != null) { // If switching to heading mode, immediately start rotating to current heading _lastHeading = null; // Force initial rotation + // Prefer our derived heading; fall back to whatever GPS reports (may + // be 0 if we haven't moved yet — better than no rotation at all). + final initialHeading = + _computedHeading ?? appState.currentPosition!.heading; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && !_alwaysNorth && appState.currentPosition != null) { - _animateToRotation(appState.currentPosition!.heading); + _animateToRotation(initialHeading); } }); } @@ -1280,44 +3053,16 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _rotationLocked = !_rotationLocked; // When enabling lock in "Always North" mode, rotate back to north - // When in "Rotate with Heading" mode, keep current rotation - if (_rotationLocked && _isMapReady && _alwaysNorth) { - final currentRotation = _mapController.camera.rotation; - if (currentRotation.abs() > 2) { - // Cancel any running rotation animation - _rotationAnimationController?.stop(); - _rotationAnimationController?.dispose(); - - // Create animation to rotate back to north - const duration = Duration(milliseconds: 500); - _rotationAnimationController = AnimationController( - duration: duration, - vsync: this, + if (_rotationLocked && + _isMapReady && + _alwaysNorth && + _mapController != null) { + final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; + if (currentBearing.abs() > 2) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 500), ); - - _rotationAnimation = CurvedAnimation( - parent: _rotationAnimationController!, - curve: Curves.easeInOutCubic, - ); - - _rotationStartAngle = currentRotation; - _rotationEndAngle = 0.0; // North - - _rotationAnimation!.addListener(() { - if (!mounted || - _rotationStartAngle == null || - _rotationEndAngle == null) { - return; - } - - final t = _rotationAnimation!.value; - final rotation = _rotationStartAngle! + - ((_rotationEndAngle! - _rotationStartAngle!) * t); - - _mapController.rotate(rotation); - }); - - _rotationAnimationController!.forward(); } } }); @@ -1965,115 +3710,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } /// Build a coverage marker child widget based on the user's marker style preference. - Widget _buildCoverageMarkerChild(Color color) { - final style = context.read().preferences.markerStyle; - switch (style) { - case 'circle': - return Container( - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2.0), - boxShadow: const [ - BoxShadow( - color: Colors.black12, blurRadius: 2, offset: Offset(0, 1)) - ], - ), - ); - case 'pin': - return CustomPaint( - size: const Size(20, 20), - painter: _PinMarkerPainter(color), - ); - case 'diamond': - return CustomPaint( - size: const Size(20, 20), - painter: _DiamondMarkerPainter(color), - ); - case 'dot': - default: - return Container( - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all( - color: Colors.white.withValues(alpha: 0.6), width: 1.5), - boxShadow: const [ - BoxShadow( - color: Colors.black12, blurRadius: 2, offset: Offset(0, 1)) - ], - ), - ); - } - } - - /// Build all coverage dot markers sorted by timestamp (oldest first = drawn underneath). - /// Newer pings always render on top regardless of type. - List _buildCoverageMarkers({ - required List txPings, - required List rxPings, - required List discEntries, - required bool discDropEnabled, - required List traceEntries, - bool excludeFocused = false, - }) { - final timestamped = <(DateTime, Marker)>[ - for (final ping in txPings) - if (!excludeFocused || - !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) - (ping.timestamp, _buildTxMarker(ping)), - for (final ping in rxPings) - if (!excludeFocused || - !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) - (ping.timestamp, _buildRxMarker(ping)), - for (final entry in discEntries) - if (!excludeFocused || - !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) - (entry.timestamp, _buildDiscMarker(entry, discDropEnabled)), - for (final entry in traceEntries) - if (!excludeFocused || - !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) - (entry.timestamp, _buildTraceMarker(entry)), - ]; - - timestamped.sort((a, b) => a.$1.compareTo(b.$1)); - return timestamped.map((e) => e.$2).toList(); - } - - /// Build just the focused ping marker for rendering in its own top layer. - List _buildFocusedPingMarker({ - required List txPings, - required List rxPings, - required List discEntries, - required bool discDropEnabled, - required List traceEntries, - }) { - if (_focusedPingLocation == null) return []; - - for (final ping in txPings) { - if (_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) { - return [_buildTxMarker(ping)]; - } - } - for (final ping in rxPings) { - if (_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) { - return [_buildRxMarker(ping)]; - } - } - for (final entry in discEntries) { - if (_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) { - return [_buildDiscMarker(entry, discDropEnabled)]; - } - } - for (final entry in traceEntries) { - if (_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) { - return [_buildTraceMarker(entry)]; - } - } - return []; - } - /// Check if a ping at given lat/lon/timestamp is the currently focused ping. + /// Used by the native annotation sync to apply focus-mode styling (size, + /// opacity) to the focused ping vs other pings. bool _isFocusedPing(double lat, double lon, DateTime timestamp) { return _focusedPingLocation != null && _focusedPingTimestamp == timestamp && @@ -2081,80 +3720,6 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _focusedPingLocation!.longitude == lon; } - /// Apply focus fade to a marker color. Returns dimmed color if focus is active - /// and this marker is not the focused one. - Color _applyFocusFade(Color color, bool isFocused) { - if (_focusedPingLocation == null || isFocused) return color; - return color.withValues(alpha: 0.15); - } - - Marker _buildTxMarker(TxPing ping) { - final isFocused = - _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); - final color = - ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess; - final size = isFocused ? 24.0 : 20.0; - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: size, - height: size, - child: GestureDetector( - onTap: () => _showTxPingDetails(ping), - child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), - ), - ); - } - - Marker _buildRxMarker(RxPing ping) { - final isFocused = - _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); - final size = isFocused ? 24.0 : 20.0; - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: size, - height: size, - child: GestureDetector( - onTap: () => _showRxPingDetails(ping), - child: _buildCoverageMarkerChild( - _applyFocusFade(PingColors.rx, isFocused)), - ), - ); - } - - Marker _buildDiscMarker(DiscLogEntry entry, bool discDropEnabled) { - final isFocused = - _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); - final color = entry.nodeCount == 0 - ? (discDropEnabled ? PingColors.txFail : PingColors.discFail) - : _discMarkerColor; - final size = isFocused ? 24.0 : 20.0; - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: size, - height: size, - child: GestureDetector( - onTap: () => _showDiscPingDetails(entry), - child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), - ), - ); - } - - Marker _buildTraceMarker(TraceLogEntry entry) { - final isFocused = - _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); - final color = entry.success ? Colors.cyan : Colors.grey; - final size = isFocused ? 24.0 : 20.0; - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: size, - height: size, - child: GestureDetector( - onTap: () => _showTraceDetails(entry), - child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), - ), - ); - } - void _showTraceDetails(TraceLogEntry entry) { // Activate focus mode for successful traces with a known repeater if (entry.success) { @@ -2172,6 +3737,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { context: context, useSafeArea: true, isScrollControlled: true, + // Transparent barrier so the map stays fully bright during focus mode — + // the default Colors.black54 scrim would defeat the purpose of focus. + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), @@ -2434,53 +4002,6 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ).whenComplete(() => _dismissPingFocus()); } - /// Build distance label markers at the midpoint of each focus line. - List _buildFocusDistanceLabels(AppStateProvider appState) { - if (_focusedPingLocation == null) return []; - final isImperial = appState.preferences.isImperial; - final ping = _focusedPingLocation!; - - return _focusedRepeaters.map((r) { - final repeaterPos = LatLng(r.repeater.lat, r.repeater.lon); - // Midpoint of the line - final midLat = (ping.latitude + repeaterPos.latitude) / 2; - final midLon = (ping.longitude + repeaterPos.longitude) / 2; - // Distance in meters — use GpsService for consistency with repeater popup - final meters = GpsService.distanceBetween( - ping.latitude, - ping.longitude, - repeaterPos.latitude, - repeaterPos.longitude, - ); - final label = meters < 1000 - ? formatMeters(meters, isImperial: isImperial) - : formatKilometers(meters / 1000, isImperial: isImperial); - - return Marker( - point: LatLng(midLat, midLon), - width: 70, - height: 22, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - label, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Colors.white, - ), - ), - ), - ); - }).toList(); - } - /// DISC marker color (delegates to active palette) static Color get _discMarkerColor => PingColors.discSuccess; @@ -2526,8 +4047,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Activate ping focus mode — draw lines, fade markers, zoom to fit. void _activatePingFocus(LatLng pingLocation, DateTime timestamp, List<_ResolvedRepeater> repeaters) { - _preFocusCenter = _mapController.camera.center; - _preFocusZoom = _mapController.camera.zoom; + final pos = _mapController?.cameraPosition; + _preFocusCenter = pos?.target; + _preFocusZoom = pos?.zoom; _wasAutoFollowBeforeFocus = _autoFollow; _wasRotatingBeforeFocus = !_alwaysNorth; @@ -2538,10 +4060,12 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Lock to north-up during focus so the zoom-to-fit view is stable if (!_alwaysNorth) { _alwaysNorth = true; - _animateToRotation(0); // Won't fire because _alwaysNorth is now true - // Snap rotation to 0 directly - if (_isMapReady) { - _mapController.rotate(0); + // Snap rotation to north (instant — avoids wobble before zoom-to-fit animation) + if (_isMapReady && _mapController != null) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 1), + ); } } @@ -2551,11 +4075,25 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _focusedRepeaters = repeaters; }); + // Hide the MeshMapper coverage raster overlay for a clean focus view. + // Uses opacity=0 rather than removing the layer to avoid a tile refetch + // on dismiss. No-ops gracefully if the layer isn't present. + _applyCoverageOverlayOpacity(0.0); + WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _focusedPingLocation != null) { _zoomToFocusBounds(pingLocation, repeaters); } }); + + // Once the 500ms zoom-to-fit animation settles, re-flow the distance + // labels so any that collide on screen slide along their lines to a + // non-overlapping slot. 600ms gives the camera a bit of buffer beyond + // the animation duration. + Future.delayed(const Duration(milliseconds: 600), () { + if (!mounted || _focusedPingLocation == null) return; + _reflowDistanceLabelsForCollisions(); + }); } /// Dismiss ping focus mode — restore map state. @@ -2576,6 +4114,12 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _focusedRepeaters = []; }); + // Restore the MeshMapper coverage raster overlay opacity. Safe if the + // layer was hidden via the toggle during focus — setLayerProperties is + // wrapped in try/catch inside the helper. + final appState = context.read(); + _applyCoverageOverlayOpacity(appState.preferences.coverageOverlayOpacity); + if (center != null && zoom != null) { _animateToPositionWithZoom(center, zoom); @@ -2619,136 +4163,6 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return _repeaterMarkerColor; // Active (default) } - List _buildRepeaterMarkers( - List repeaters, - int? regionHopBytesOverride, { - bool onlyFaded = false, - bool onlyConnected = false, - }) { - final duplicateIds = _getDuplicateRepeaterIds(repeaters); - final hasFocus = _focusedPingLocation != null; - - return repeaters.where((repeater) { - if (!hasFocus) return true; // No focus — include all - final isConnected = - _focusedRepeaters.any((r) => r.repeater.id == repeater.id); - if (onlyConnected) return isConnected; - if (onlyFaded) return !isConnected; - return true; - }).map((repeater) { - final isDuplicate = duplicateIds.contains(repeater.id); - final markerColor = _getRepeaterMarkerColor(repeater, isDuplicate); - - // During focus mode, fade repeaters not connected to the focused ping - final isConnected = hasFocus && - _focusedRepeaters.any((r) => r.repeater.id == repeater.id); - final effectiveColor = (hasFocus && !isConnected) - ? markerColor.withValues(alpha: 0.15) - : markerColor; - final effectiveBorderColor = (hasFocus && !isConnected) - ? Colors.white.withValues(alpha: 0.15) - : Colors.white; - final effectiveTextColor = (hasFocus && !isConnected) - ? Colors.white.withValues(alpha: 0.15) - : Colors.white; - - // Display hex ID based on per-repeater hop_bytes (or regional admin override) - final displayId = - repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); - final effectiveBytes = regionHopBytesOverride ?? repeater.hopBytes; - final isLongId = displayId.length > 2; - final markerWidth = displayId.length > 4 - ? 48.0 - : isLongId - ? 40.0 - : 28.0; - - // Shape varies by hop bytes: 1=square, 2=rounded rect, 3=more rounded - final borderRadius = effectiveBytes >= 3 - ? BorderRadius.circular(8) - : effectiveBytes == 2 - ? BorderRadius.circular(6) - : BorderRadius.circular(4); - - return Marker( - point: LatLng(repeater.lat, repeater.lon), - width: markerWidth, - height: 28, - child: GestureDetector( - onTap: () => _showRepeaterDetails(repeater, - isDuplicate: isDuplicate, - regionHopBytesOverride: regionHopBytesOverride), - child: Container( - padding: isLongId - ? const EdgeInsets.symmetric(horizontal: 4) - : EdgeInsets.zero, - decoration: BoxDecoration( - color: effectiveColor, - borderRadius: borderRadius, - border: Border.all(color: effectiveBorderColor, width: 2), - boxShadow: (hasFocus && !isConnected) - ? null - : const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - alignment: Alignment.center, - child: Text( - displayId, - style: TextStyle( - fontSize: displayId.length > 4 - ? 8 - : isLongId - ? 9 - : 10, - fontWeight: FontWeight.bold, - color: effectiveTextColor, - fontFamily: 'monospace', - ), - ), - ), - ), - ); - }).toList(); - } - - Widget _buildCurrentPositionMarker(double heading) { - // Convert heading from degrees to radians - // heading is 0-360 degrees, 0 = North, 90 = East - final headingRadians = heading * (math.pi / 180); - final style = context.read().preferences.gpsMarkerStyle; - - // Arrow, walk, and chomper rotate with heading; vehicle/boat icons don't (they face up) - final shouldRotate = - style == 'arrow' || style == 'walk' || style == 'chomper'; - - final CustomPainter painter; - switch (style) { - case 'car': - painter = const _CarMarkerPainter(); - case 'bike': - painter = const _BikeMarkerPainter(); - case 'boat': - painter = const _BoatMarkerPainter(); - case 'walk': - painter = const _WalkMarkerPainter(); - case 'chomper': - painter = const _ChomperMarkerPainter(); - case 'arrow': - default: - painter = const _ArrowPainter(); - } - - final child = CustomPaint(size: const Size(24, 24), painter: painter); - return shouldRotate - ? Transform.rotate(angle: headingRadians, child: child) - : child; - } - /// Compute node column width based on hop byte count. /// [extraPadding] adds space for additional content (e.g. nodeTypeLabel in DISC popup). double _nodeColumnWidth({double extraPadding = 0}) { @@ -2787,6 +4201,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { context: context, useSafeArea: true, isScrollControlled: true, + // Transparent barrier so the map stays fully bright during focus mode — + // the default Colors.black54 scrim would defeat the purpose of focus. + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), @@ -3049,6 +4466,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { showModalBottomSheet( context: context, useSafeArea: true, + // Transparent barrier so the map stays fully bright during focus mode — + // the default Colors.black54 scrim would defeat the purpose of focus. + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), @@ -3279,6 +4699,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { context: context, useSafeArea: true, isScrollControlled: true, + // Transparent barrier so the map stays fully bright during focus mode — + // the default Colors.black54 scrim would defeat the purpose of focus. + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), @@ -4211,6 +5634,133 @@ class _DiamondMarkerPainter extends CustomPainter { oldDelegate.color != color; } +/// Paints a repeater marker shape (filled colored rounded box with white border +/// and drop shadow). Used at startup to generate bitmap variants for native +/// MapLibre symbols. The text (hex ID) is rendered separately by the symbol's +/// `textField` property at runtime — this painter only draws the box itself. +class _RepeaterShapePainter extends CustomPainter { + final Color fillColor; + final double borderRadius; + + const _RepeaterShapePainter({ + required this.fillColor, + required this.borderRadius, + }); + + @override + void paint(Canvas canvas, Size size) { + // Inset the box by the shadow blur amount so the shadow has room to draw + const shadowBlur = 4.0; + final boxRect = Rect.fromLTWH( + shadowBlur, + shadowBlur, + size.width - 2 * shadowBlur, + size.height - 2 * shadowBlur, + ); + + // Drop shadow (positioned 2px below the box) + final shadowPaint = Paint() + ..color = Colors.black26 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, shadowBlur); + canvas.drawRRect( + RRect.fromRectAndRadius( + boxRect.shift(const Offset(0, 2)), + Radius.circular(borderRadius), + ), + shadowPaint, + ); + + // Filled colored box + final fillPaint = Paint()..color = fillColor; + canvas.drawRRect( + RRect.fromRectAndRadius(boxRect, Radius.circular(borderRadius)), + fillPaint, + ); + + // White border (2px wide, drawn inside the box edge) + final borderPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0; + final innerRect = boxRect.deflate(1); + canvas.drawRRect( + RRect.fromRectAndRadius(innerRect, Radius.circular(borderRadius - 1)), + borderPaint, + ); + } + + @override + bool shouldRepaint(covariant _RepeaterShapePainter old) => + old.fillColor != fillColor || old.borderRadius != borderRadius; +} + +/// Paints a coverage ping marker (TX/RX/DISC/Trace) in one of the four user +/// styles. Used at startup to generate bitmap variants for native MapLibre +/// symbols. Reuses _PinMarkerPainter and _DiamondMarkerPainter for those styles. +class _CoverageMarkerPainter extends CustomPainter { + final String style; // 'circle' / 'pin' / 'diamond' / 'dot' + final Color color; + + const _CoverageMarkerPainter({required this.style, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + // Visible glyph area — the canvas is typically larger (40×40) so the + // surrounding pixels stay transparent, giving MapLibre a bigger native + // tap hit target without enlarging the actual marker visual. + const innerSize = Size(24, 24); + final dx = (size.width - innerSize.width) / 2; + final dy = (size.height - innerSize.height) / 2; + canvas.save(); + canvas.translate(dx, dy); + switch (style) { + case 'pin': + _PinMarkerPainter(color).paint(canvas, innerSize); + break; + case 'diamond': + _DiamondMarkerPainter(color).paint(canvas, innerSize); + break; + case 'circle': + _paintCircle(canvas, innerSize, borderAlpha: 1.0, borderWidth: 2.0); + break; + case 'dot': + default: + _paintCircle(canvas, innerSize, borderAlpha: 0.6, borderWidth: 1.5); + break; + } + canvas.restore(); + } + + void _paintCircle(Canvas canvas, Size size, + {required double borderAlpha, required double borderWidth}) { + final center = Offset(size.width / 2, size.height / 2); + final radius = math.min(size.width, size.height) / 2 - 2; + + // Drop shadow (slightly below) + final shadowPaint = Paint() + ..color = Colors.black12 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2); + canvas.drawCircle(center.translate(0, 1), radius, shadowPaint); + + // Filled circle + canvas.drawCircle(center, radius, Paint()..color = color); + + // White border + canvas.drawCircle( + center, + radius, + Paint() + ..color = Colors.white.withValues(alpha: borderAlpha) + ..style = PaintingStyle.stroke + ..strokeWidth = borderWidth, + ); + } + + @override + bool shouldRepaint(covariant _CoverageMarkerPainter old) => + old.style != style || old.color != color; +} + /// A stateful widget for sound item with play button visual feedback class _SoundItemWidget extends StatefulWidget { final IconData icon; diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 90296d0..2144f04 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -85,18 +85,86 @@ class RepeaterIdChip extends StatelessWidget { /// (e.g. the ping's GPS location) instead of the user's current position. static void showRepeaterPopup(BuildContext context, String repeaterId, {String? fullHexId, ({double lat, double lon})? fromLatLng}) { + final appState = Provider.of(context, listen: false); + final repeaters = appState.repeaters; + + final Widget content; + + if (repeaters.isEmpty) { + content = Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'Repeater data not available', + style: TextStyle( + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 13, + ), + ), + ); + } else { + // DISC pings provide the full public key — match first 8 hex chars + // (4 bytes) against repeater hexId for exact identification. + // TX/RX pings only have 1-byte IDs so fall back to prefix matching. + final matchKey = fullHexId != null && fullHexId.length >= 8 + ? fullHexId.substring(0, 8) + : repeaterId; + final matches = repeaters + .where( + (r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) + .toList(); + + if (matches.isEmpty) { + content = Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'Unknown repeater', + style: TextStyle( + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 13, + ), + ), + ); + } else { + // Use explicit origin point if provided, otherwise fall back to current GPS + final position = appState.currentPosition; + final refLat = fromLatLng?.lat ?? position?.latitude; + final refLon = fromLatLng?.lon ?? position?.longitude; + + // Sort by distance (closest first) when a reference point is available + if (refLat != null && refLon != null) { + matches.sort((a, b) { + final distA = + GpsService.distanceBetween(refLat, refLon, a.lat, a.lon); + final distB = + GpsService.distanceBetween(refLat, refLon, b.lat, b.lon); + return distA.compareTo(distB); + }); + } + + final regionOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; + content = Column( + mainAxisSize: MainAxisSize.min, + children: matches + .map((r) => _buildRepeaterRow(context, r, + refLat: refLat, + refLon: refLon, + regionHopBytesOverride: regionOverride)) + .toList(), + ); + } + } + showDialog( context: context, builder: (dialogContext) => Dialog( - backgroundColor: - Theme.of(dialogContext).colorScheme.surfaceContainerHighest, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide( - color: Theme.of(dialogContext) - .colorScheme - .outline - .withValues(alpha: 0.3), + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), ), ), child: ConstrainedBox( @@ -113,7 +181,7 @@ class RepeaterIdChip extends StatelessWidget { Icon( Icons.cell_tower, size: 18, - color: Theme.of(dialogContext).colorScheme.primary, + color: Theme.of(context).colorScheme.primary, ), const SizedBox(width: 8), Text( @@ -122,21 +190,16 @@ class RepeaterIdChip extends StatelessWidget { fontSize: 15, fontWeight: FontWeight.w600, fontFamily: 'monospace', - color: Theme.of(dialogContext).colorScheme.onSurface, + color: Theme.of(context).colorScheme.onSurface, ), ), ], ), const SizedBox(height: 12), - Divider( - height: 1, color: Theme.of(dialogContext).dividerColor), + Divider(height: 1, color: Theme.of(context).dividerColor), const SizedBox(height: 12), - // Content (lazy-fetches repeaters if cache is empty) - _RepeaterPopupContent( - repeaterId: repeaterId, - fullHexId: fullHexId, - fromLatLng: fromLatLng, - ), + // Content + content, ], ), ), @@ -261,23 +324,6 @@ class RepeaterIdChip extends StatelessWidget { ); } - /// Caption-style italic message used inside the popup (e.g. "Unknown - /// repeater", "Repeater data not available"). Lifted out of the static - /// builder so `_RepeaterPopupContent` can reuse it. - static Widget _buildCaptionMessage(BuildContext context, String text) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text( - text, - style: TextStyle( - fontStyle: FontStyle.italic, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 13, - ), - ), - ); - } - /// Build a hex ID badge — circle for 2-char, pill for longer IDs static Widget _buildHexBadge(String displayId, Color color) { final isLong = displayId.length > 2; @@ -304,179 +350,3 @@ class RepeaterIdChip extends StatelessWidget { ); } } - -/// Stateful popup body that lazily re-fetches the repeater list if the cache -/// is empty when the user opens the dialog (e.g. offline session, startup -/// race, or a transient fetch failure). See -/// `AppStateProvider.refetchRepeatersIfPossible` for the trigger. -class _RepeaterPopupContent extends StatefulWidget { - final String repeaterId; - final String? fullHexId; - final ({double lat, double lon})? fromLatLng; - - const _RepeaterPopupContent({ - required this.repeaterId, - this.fullHexId, - this.fromLatLng, - }); - - @override - State<_RepeaterPopupContent> createState() => _RepeaterPopupContentState(); -} - -class _RepeaterPopupContentState extends State<_RepeaterPopupContent> { - bool _loading = false; - bool _hasTriedRefetch = false; - - @override - void initState() { - super.initState(); - final appState = Provider.of(context, listen: false); - if (appState.repeaters.isEmpty && _hasIataAvailable(appState)) { - _kickOffRefetch(appState); - } - } - - bool _hasIataAvailable(AppStateProvider appState) { - if (appState.zoneCode?.isNotEmpty == true) return true; - final prefIata = appState.preferences.iataCode; - return prefIata != null && prefIata.isNotEmpty; - } - - void _kickOffRefetch(AppStateProvider appState) { - final iata = (appState.zoneCode?.isNotEmpty == true) - ? appState.zoneCode - : appState.preferences.iataCode; - debugLog( - '[MAP] Repeater popup opened with empty cache, refetching (iata=$iata)'); - setState(() { - _loading = true; - _hasTriedRefetch = true; - }); - appState.refetchRepeatersIfPossible().whenComplete(() { - if (!mounted) return; - setState(() => _loading = false); - }); - } - - @override - Widget build(BuildContext context) { - // Watch so the dialog rebuilds automatically when the fetch populates - // _repeaters (refetchRepeatersIfPossible calls notifyListeners on success). - final appState = context.watch(); - final repeaters = appState.repeaters; - - if (repeaters.isNotEmpty) { - return _buildMatches(context, appState, repeaters); - } - - if (_loading) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: 10), - Text( - 'Loading repeaters…', - style: TextStyle( - fontStyle: FontStyle.italic, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ); - } - - // Not loading and no data. If we tried and got nothing, offer retry. - // Otherwise we have no IATA at all — show the terminal "not available". - if (_hasTriedRefetch) { - return InkWell( - onTap: () { - final appState = - Provider.of(context, listen: false); - _kickOffRefetch(appState); - }, - borderRadius: BorderRadius.circular(6), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.refresh, - size: 14, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 6), - Flexible( - child: Text( - "Couldn't load repeaters — tap to retry", - style: TextStyle( - fontStyle: FontStyle.italic, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 13, - ), - ), - ), - ], - ), - ), - ); - } - - return RepeaterIdChip._buildCaptionMessage( - context, 'Repeater data not available'); - } - - Widget _buildMatches( - BuildContext context, AppStateProvider appState, List repeaters) { - // DISC pings provide the full public key — match first 8 hex chars - // (4 bytes) against repeater hexId for exact identification. TX/RX pings - // only have 1-byte IDs so fall back to prefix matching. - final matchKey = widget.fullHexId != null && widget.fullHexId!.length >= 8 - ? widget.fullHexId!.substring(0, 8) - : widget.repeaterId; - final matches = repeaters - .where((r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) - .toList(); - - if (matches.isEmpty) { - return RepeaterIdChip._buildCaptionMessage(context, 'Unknown repeater'); - } - - final position = appState.currentPosition; - final refLat = widget.fromLatLng?.lat ?? position?.latitude; - final refLon = widget.fromLatLng?.lon ?? position?.longitude; - - if (refLat != null && refLon != null) { - matches.sort((a, b) { - final distA = GpsService.distanceBetween(refLat, refLon, a.lat, a.lon); - final distB = GpsService.distanceBetween(refLat, refLon, b.lat, b.lon); - return distA.compareTo(distB); - }); - } - - final regionOverride = - appState.enforceHopBytes ? appState.effectiveHopBytes : null; - return Column( - mainAxisSize: MainAxisSize.min, - children: matches - .map((r) => RepeaterIdChip._buildRepeaterRow( - context, - r, - refLat: refLat, - refLon: refLon, - regionHopBytesOverride: regionOverride, - )) - .toList(), - ); - } -} diff --git a/pubspec.lock b/pubspec.lock index 5b88216..17e8082 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -430,22 +430,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.2.0" - flutter_map: - dependency: "direct main" - description: - name: flutter_map - sha256: "87cc8349b8fa5dccda5af50018c7374b6645334a0d680931c1fe11bce88fa5bb" - url: "https://pub.dev" - source: hosted - version: "6.2.1" - flutter_map_cancellable_tile_provider: - dependency: "direct main" - description: - name: flutter_map_cancellable_tile_provider - sha256: ae18dd59faf74f3eca1d28f83e59b47741bbff962e123bbebe9335c04d432f44 - url: "https://pub.dev" - source: hosted - version: "2.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -672,14 +656,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.16" - latlong2: - dependency: "direct main" - description: - name: latlong2 - sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" - url: "https://pub.dev" - source: hosted - version: "0.9.1" leak_tracker: dependency: transitive description: @@ -712,46 +688,54 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - lists: + logging: dependency: transitive description: - name: lists - sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.0.1" - logger: + version: "1.3.0" + maplibre_gl: + dependency: "direct main" + description: + name: maplibre_gl + sha256: d9773555ae4ebab94bbc3ae2176b077cfda486ec729eefe01e1613f164cb8410 + url: "https://pub.dev" + source: hosted + version: "0.25.0" + maplibre_gl_platform_interface: dependency: transitive description: - name: logger - sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + name: maplibre_gl_platform_interface + sha256: bd7de401dea24dd7e8a6f2fa736ddee7dbbee3e24a9027f0afdd619994702047 url: "https://pub.dev" source: hosted - version: "2.6.2" - logging: + version: "0.25.0" + maplibre_gl_web: dependency: transitive description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + name: maplibre_gl_web + sha256: af0e48bf96e8dd99f8b958a1953126971eb8a0527b9735441d4f24df3913f5a2 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "0.25.0" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -760,14 +744,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" - mgrs_dart: - dependency: transitive - description: - name: mgrs_dart - sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 - url: "https://pub.dev" - source: hosted - version: "2.0.0" mime: dependency: transitive description: @@ -960,14 +936,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" - polylabel: - dependency: transitive - description: - name: polylabel - sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" - url: "https://pub.dev" - source: hosted - version: "1.0.1" pool: dependency: transitive description: @@ -984,14 +952,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" - proj4dart: - dependency: transitive - description: - name: proj4dart - sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e - url: "https://pub.dev" - source: hosted - version: "2.1.0" provider: dependency: "direct main" description: @@ -1193,10 +1153,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: transitive description: @@ -1221,14 +1181,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - unicode: - dependency: transitive - description: - name: unicode - sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" - url: "https://pub.dev" - source: hosted - version: "0.3.1" url_launcher: dependency: "direct main" description: @@ -1373,14 +1325,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" - wkt_parser: - dependency: transitive - description: - name: wkt_parser - sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" - url: "https://pub.dev" - source: hosted - version: "2.0.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 60b10a0..a0dcd0c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,9 +13,7 @@ dependencies: flutter_blue_plus: ^1.32.0 flutter_web_bluetooth: ^0.2.3 geolocator: ^11.0.0 - flutter_map: ^6.1.0 - flutter_map_cancellable_tile_provider: ^2.0.0 - latlong2: ^0.9.0 + maplibre_gl: ^0.25.0 http: ^1.2.0 shared_preferences: ^2.2.0 hive: ^2.2.3 diff --git a/web/index.html b/web/index.html index e0c14e7..4af36e5 100644 --- a/web/index.html +++ b/web/index.html @@ -27,6 +27,10 @@ // The value below is injected by flutter build, do not touch. const serviceWorkerVersion = null; + + + +