From cd2ab000429f7b4d5a028f63830e8bcd19a17678 Mon Sep 17 00:00:00 2001 From: stopflock Date: Wed, 22 Oct 2025 15:27:28 -0500 Subject: [PATCH] North-up compass and rotation lock --- DEVELOPER.md | 46 +++++++- README.md | 2 +- assets/changelog.json | 5 +- lib/app_state.dart | 5 + lib/dev_config.dart | 3 + lib/localizations/de.json | 4 +- lib/localizations/en.json | 4 +- lib/localizations/es.json | 4 +- lib/localizations/fr.json | 4 +- lib/localizations/it.json | 4 +- lib/localizations/pt.json | 4 +- lib/localizations/zh.json | 4 +- lib/screens/home_screen.dart | 12 +-- lib/state/settings_state.dart | 30 +++++- lib/widgets/compass_indicator.dart | 161 ++++++++++++++++++++++++++++ lib/widgets/map/gps_controller.dart | 13 ++- lib/widgets/map/map_overlays.dart | 19 ++-- lib/widgets/map_view.dart | 56 +++++++++- scripts/validate_localizations.dart | 0 19 files changed, 340 insertions(+), 40 deletions(-) create mode 100644 lib/widgets/compass_indicator.dart create mode 100644 scripts/validate_localizations.dart diff --git a/DEVELOPER.md b/DEVELOPER.md index a2ce7ab..8fe8c80 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -242,7 +242,31 @@ Local cache contains production data. Showing production nodes in sandbox mode w - Simple RecentAlert tracking prevents duplicate notifications - Visual callback system for in-app alerts when app is active -### 8. Suspected Locations +### 8. Compass Indicator & North Lock + +**Purpose**: Visual compass showing map orientation with optional north-lock functionality + +**Design decisions:** +- **Separate from follow mode**: North lock is independent of GPS following behavior +- **Smart rotation detection**: Distinguishes intentional rotation (>5°) from zoom gestures +- **Visual feedback**: Clear skeumorphic compass design with red north indicator +- **Mode awareness**: Disabled during follow+rotate mode (incompatible) + +**Key behaviors:** +- **North indicator**: Red arrow always points toward true north regardless of map rotation +- **Tap to toggle**: Enable/disable north lock with visual animation to north +- **Auto-disable**: North lock turns off when switching to follow+rotate mode +- **Gesture intelligence**: Only disables on significant rotation changes, ignores zoom artifacts + +**Visual states:** +- **Normal**: White background, grey border, red north arrow +- **North locked**: White background, blue border, bright red north arrow +- **Disabled**: Grey background, muted colors (during follow+rotate mode) + +**Why separate from follow mode:** +Users often want to follow their location while keeping the map oriented north. Previous "north up" follow mode was confusing because it didn't actually keep north up. This separation provides clear, predictable behavior. + +### 9. Suspected Locations **Data pipeline:** - **CSV ingestion**: Downloads utility permit data from alprwatch.org @@ -253,7 +277,7 @@ Local cache contains production data. Showing production nodes in sandbox mode w **Why utility permits:** Utility companies often must file permits when installing surveillance infrastructure. This creates a paper trail that can indicate potential surveillance sites before devices are confirmed through direct observation. -### 9. Upload Mode Simplification +### 10. Upload Mode Simplification **Release vs Debug builds:** - **Release builds**: Production OSM only (simplified UX) @@ -266,7 +290,7 @@ Most users should contribute to production; testing modes add complexity bool get showUploadModeSelector => kDebugMode; ``` -### 10. Navigation & Routing (Implemented, Awaiting Integration) +### 11. Navigation & Routing (Implemented, Awaiting Integration) **Current state:** - **Search functionality**: Fully implemented and active @@ -341,6 +365,22 @@ bool get showUploadModeSelector => kDebugMode; - **Battery life**: Excessive network requests drain battery - **Clear feedback**: Users understand why nodes aren't showing +### 6. Why Separate Compass Indicator from Follow Mode? + +**Alternative**: Combined "follow with north up" mode + +**Why separate controls:** +- **Clear user mental model**: "Follow me" vs "lock to north" are distinct concepts +- **Flexible combinations**: Users can follow without north lock, or vice versa +- **Avoid mode conflicts**: Follow+rotate is incompatible with north lock +- **Reduced confusion**: Previous "north up" mode didn't actually keep north up + +**Design benefits:** +- **Brutalist approach**: Two simple, independent features instead of complex mode combinations +- **Visual feedback**: Compass shows exact map orientation regardless of follow state +- **Smart gesture detection**: Differentiates intentional rotation from zoom artifacts +- **Predictable behavior**: Each control does exactly what it says + --- ## Development Guidelines diff --git a/README.md b/README.md index 39c94d0..d220ad5 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with ### Map & Navigation - **Multi-source tiles**: Switch between OpenStreetMap, USGS imagery, Esri imagery, Mapbox, OpenTopoMap, and any custom providers - **Offline-first design**: Download a region for complete offline operation -- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, and gesture-friendly interactions +- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, compass indicator with north-lock, and gesture-friendly interactions - **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), pending edits (grey), devices being edited (orange), and pending deletions (red) ### Device Management diff --git a/assets/changelog.json b/assets/changelog.json index 14799bf..9392063 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,7 +1,8 @@ { "1.2.5": { - "content": "• Smart area caching: Loads 3x larger areas and refreshes data every 60 seconds for much faster browsing\n• Enhanced tile loading: Increased retry attempts with faster delays - tiles load much more reliably\n• Improved error handling: Overpass queries now automatically split on timeouts and node limits\n• Better network status: Streamlined loading indicator that works with all data refresh types\n• Instant node display: Surveillance devices now appear immediately when data finishes loading\n• Node limit alerts: Get notified when too many devices are found (increase limit in settings to see more)" - }, + "content": "• Smart area caching: Loads 3x larger areas and refreshes data every 60 seconds for much faster browsing\n• Enhanced tile loading: Increased retry attempts with faster delays - tiles load much more reliably\n• Improved error handling: Overpass queries now automatically split on timeouts and node limits\n• Better network status: Streamlined loading indicator that works with all data refresh types\n• Instant node display: Surveillance devices now appear immediately when data finishes loading\n• Node limit alerts: Get notified when too many devices are found (increase limit in settings to see more)", + "content": "• NEW: Compass indicator shows map orientation and enables north-lock mode\n• NEW: North-lock keeps map pointing north while following your location\n• IMPROVED: Follow-me mode renamed for clarity (was confusingly called 'north up')\n• IMPROVED: Smart rotation detection ignores zoom gestures but responds to intentional map rotation\n• Smart area caching: Loads 3x larger areas and refreshes data every 60 seconds for much faster browsing\n• Enhanced tile loading: Increased retry attempts with faster delays - tiles load much more reliably\n• Improved error handling: Overpass queries now automatically split on timeouts and node limits\n• Better network status: Streamlined loading indicator that works with all data refresh types\n• Instant node display: Surveillance devices now appear immediately when data finishes loading\n• Node limit alerts: Get notified when too many devices are found (increase limit in settings to see more)" +}, "1.2.4": { "content": "• New welcome popup for first-time users with essential privacy information\n• Automatic changelog display when app updates (like this one!)\n• Added Release Notes viewer in Settings > About\n• Enhanced user onboarding and transparency about data handling\n• Improved documentation for contributors" }, diff --git a/lib/app_state.dart b/lib/app_state.dart index 1a992eb..ba916e0 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -130,6 +130,7 @@ class AppState extends ChangeNotifier { int get maxCameras => _settingsState.maxCameras; UploadMode get uploadMode => _settingsState.uploadMode; FollowMeMode get followMeMode => _settingsState.followMeMode; + bool get northLockEnabled => _settingsState.northLockEnabled; bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled; int get proximityAlertDistance => _settingsState.proximityAlertDistance; bool get networkStatusIndicatorEnabled => _settingsState.networkStatusIndicatorEnabled; @@ -409,6 +410,10 @@ class AppState extends ChangeNotifier { Future setFollowMeMode(FollowMeMode mode) async { await _settingsState.setFollowMeMode(mode); } + + Future setNorthLockEnabled(bool enabled) async { + await _settingsState.setNorthLockEnabled(enabled); + } /// Set proximity alerts enabled/disabled Future setProximityAlertsEnabled(bool enabled) async { diff --git a/lib/dev_config.dart b/lib/dev_config.dart index a2fc346..7decabe 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -68,6 +68,9 @@ const Duration kDebounceCameraRefresh = Duration(milliseconds: 500); const double kPreFetchAreaExpansionMultiplier = 3.0; // Expand visible bounds by this factor for pre-fetching const int kPreFetchZoomLevel = 10; // Always pre-fetch at this zoom level for consistent area sizes const int kMaxPreFetchSplitDepth = 3; // Maximum recursive splits when hitting Overpass node limit + +// North lock configuration +const double kNorthLockDisableThresholdDegrees = 10.0; // Rotation threshold to disable north lock (degrees) const int kDataRefreshIntervalSeconds = 60; // Refresh cached data after this many seconds // Follow-me mode smooth transitions diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 7798a04..24cc0f3 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -19,8 +19,8 @@ "clear": "Löschen" }, "followMe": { - "off": "Verfolgung aktivieren (Norden oben)", - "northUp": "Verfolgung aktivieren (Rotation)", + "off": "Verfolgung aktivieren", + "follow": "Verfolgung aktivieren (Rotation)", "rotating": "Verfolgung deaktivieren" }, "settings": { diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 4c622ef..967e658 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -36,8 +36,8 @@ "clear": "Clear" }, "followMe": { - "off": "Enable follow-me (north up)", - "northUp": "Enable follow-me (rotating)", + "off": "Enable follow-me", + "follow": "Enable follow-me (rotating)", "rotating": "Disable follow-me" }, "settings": { diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 40397bb..28f186d 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -36,8 +36,8 @@ "clear": "Limpiar" }, "followMe": { - "off": "Activar seguimiento (norte arriba)", - "northUp": "Activar seguimiento (rotación)", + "off": "Activar seguimiento", + "follow": "Activar seguimiento (rotación)", "rotating": "Desactivar seguimiento" }, "settings": { diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 71bbcda..3f07d98 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -36,8 +36,8 @@ "clear": "Effacer" }, "followMe": { - "off": "Activer le suivi (nord en haut)", - "northUp": "Activer le suivi (rotation)", + "off": "Activer le suivi", + "follow": "Activer le suivi (rotation)", "rotating": "Désactiver le suivi" }, "settings": { diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 6a1811c..1b2e4c2 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -36,8 +36,8 @@ "clear": "Pulisci" }, "followMe": { - "off": "Attiva seguimi (nord in alto)", - "northUp": "Attiva seguimi (rotazione)", + "off": "Attiva seguimi", + "follow": "Attiva seguimi (rotazione)", "rotating": "Disattiva seguimi" }, "settings": { diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index a94204c..6620a1a 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -36,8 +36,8 @@ "clear": "Limpar" }, "followMe": { - "off": "Ativar seguir-me (norte para cima)", - "northUp": "Ativar seguir-me (rotação)", + "off": "Ativar seguir-me", + "follow": "Ativar seguir-me (rotação)", "rotating": "Desativar seguir-me" }, "settings": { diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 452480e..282fbd9 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -36,8 +36,8 @@ "clear": "清空" }, "followMe": { - "off": "启用跟随模式(北向上)", - "northUp": "启用跟随模式(旋转)", + "off": "启用跟随模式", + "follow": "启用跟随模式(旋转)", "rotating": "禁用跟随模式" }, "settings": { diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 919e692..1fd9927 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -72,8 +72,8 @@ class _HomeScreenState extends State with TickerProviderStateMixin { switch (mode) { case FollowMeMode.off: return locService.t('followMe.off'); - case FollowMeMode.northUp: - return locService.t('followMe.northUp'); + case FollowMeMode.follow: + return locService.t('followMe.follow'); case FollowMeMode.rotating: return locService.t('followMe.rotating'); } @@ -83,7 +83,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { switch (mode) { case FollowMeMode.off: return Icons.gps_off; - case FollowMeMode.northUp: + case FollowMeMode.follow: return Icons.gps_fixed; case FollowMeMode.rotating: return Icons.navigation; @@ -93,8 +93,8 @@ class _HomeScreenState extends State with TickerProviderStateMixin { FollowMeMode _getNextFollowMeMode(FollowMeMode mode) { switch (mode) { case FollowMeMode.off: - return FollowMeMode.northUp; - case FollowMeMode.northUp: + return FollowMeMode.follow; + case FollowMeMode.follow: return FollowMeMode.rotating; case FollowMeMode.rotating: return FollowMeMode.off; @@ -296,7 +296,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { userLocation = _mapViewKey.currentState?.getUserLocation(); if (userLocation != null && appState.shouldAutoEnableFollowMe(userLocation)) { debugPrint('[HomeScreen] Auto-enabling follow-me mode - user within 1km of start'); - appState.setFollowMeMode(FollowMeMode.northUp); + appState.setFollowMeMode(FollowMeMode.follow); enableFollowMe = true; } } catch (e) { diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index 0f69f9f..cbf1eec 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -12,8 +12,8 @@ enum UploadMode { production, sandbox, simulate } // Enum for follow-me mode (moved from HomeScreen to centralized state) enum FollowMeMode { off, // No following - northUp, // Follow position, keep north up - rotating, // Follow position and rotation + follow, // Follow position, preserve current rotation + rotating, // Follow position and rotation based on heading } class SettingsState extends ChangeNotifier { @@ -24,6 +24,7 @@ class SettingsState extends ChangeNotifier { static const String _selectedTileTypePrefsKey = 'selected_tile_type'; static const String _legacyTestModePrefsKey = 'test_mode'; static const String _followMeModePrefsKey = 'follow_me_mode'; + static const String _northLockEnabledPrefsKey = 'north_lock_enabled'; static const String _proximityAlertsEnabledPrefsKey = 'proximity_alerts_enabled'; static const String _proximityAlertDistancePrefsKey = 'proximity_alert_distance'; static const String _networkStatusIndicatorEnabledPrefsKey = 'network_status_indicator_enabled'; @@ -32,7 +33,8 @@ class SettingsState extends ChangeNotifier { bool _offlineMode = false; int _maxCameras = 250; UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production; - FollowMeMode _followMeMode = FollowMeMode.northUp; + FollowMeMode _followMeMode = FollowMeMode.follow; + bool _northLockEnabled = false; bool _proximityAlertsEnabled = false; int _proximityAlertDistance = kProximityAlertDefaultDistance; bool _networkStatusIndicatorEnabled = false; @@ -45,6 +47,7 @@ class SettingsState extends ChangeNotifier { int get maxCameras => _maxCameras; UploadMode get uploadMode => _uploadMode; FollowMeMode get followMeMode => _followMeMode; + bool get northLockEnabled => _northLockEnabled; bool get proximityAlertsEnabled => _proximityAlertsEnabled; int get proximityAlertDistance => _proximityAlertDistance; bool get networkStatusIndicatorEnabled => _networkStatusIndicatorEnabled; @@ -97,6 +100,9 @@ class SettingsState extends ChangeNotifier { _maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250; } + // Load north lock enabled setting + _northLockEnabled = prefs.getBool(_northLockEnabledPrefsKey) ?? false; + // Load proximity alerts settings _proximityAlertsEnabled = prefs.getBool(_proximityAlertsEnabledPrefsKey) ?? false; _proximityAlertDistance = prefs.getInt(_proximityAlertDistancePrefsKey) ?? kProximityAlertDefaultDistance; @@ -291,11 +297,29 @@ class SettingsState extends ChangeNotifier { Future setFollowMeMode(FollowMeMode mode) async { if (_followMeMode != mode) { _followMeMode = mode; + + // Disable north lock when switching to rotating mode (incompatible) + if (mode == FollowMeMode.rotating && _northLockEnabled) { + _northLockEnabled = false; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_northLockEnabledPrefsKey, false); + } + final prefs = await SharedPreferences.getInstance(); await prefs.setInt(_followMeModePrefsKey, mode.index); notifyListeners(); } } + + /// Set north lock enabled/disabled + Future setNorthLockEnabled(bool enabled) async { + if (_northLockEnabled != enabled) { + _northLockEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_northLockEnabledPrefsKey, enabled); + notifyListeners(); + } + } /// Set proximity alerts enabled/disabled Future setProximityAlertsEnabled(bool enabled) async { diff --git a/lib/widgets/compass_indicator.dart b/lib/widgets/compass_indicator.dart new file mode 100644 index 0000000..81e0074 --- /dev/null +++ b/lib/widgets/compass_indicator.dart @@ -0,0 +1,161 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_animations/flutter_map_animations.dart'; +import 'package:provider/provider.dart'; + +import '../app_state.dart'; + +/// A compass indicator widget that shows the current map rotation and allows tapping to enable/disable north lock. +/// The compass appears in the top-right corner of the map and is disabled (non-interactive) when in follow+rotate mode. +class CompassIndicator extends StatefulWidget { + final AnimatedMapController mapController; + + const CompassIndicator({ + super.key, + required this.mapController, + }); + + @override + State createState() => _CompassIndicatorState(); +} + +class _CompassIndicatorState extends State { + double _lastRotation = 0.0; + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, appState, child) { + // Get current map rotation in degrees + double rotationDegrees = 0.0; + try { + rotationDegrees = widget.mapController.mapController.camera.rotation; + } catch (_) { + // Map controller not ready yet + } + + // Convert degrees to radians for Transform.rotate (flutter_map uses degrees) + final rotationRadians = rotationDegrees * (pi / 180); + + // Check if we're in follow+rotate mode (compass should be disabled) + final isDisabled = appState.followMeMode == FollowMeMode.rotating; + final northLockEnabled = appState.northLockEnabled; + + // Force rebuild when north lock changes by comparing rotation + if (northLockEnabled && rotationDegrees != _lastRotation) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() {}); + }); + } + _lastRotation = rotationDegrees; + + return Positioned( + top: (appState.uploadMode == UploadMode.sandbox || appState.uploadMode == UploadMode.simulate) ? 60 : 18, + right: 16, + child: GestureDetector( + onTap: isDisabled ? null : () { + // Toggle north lock (but not when in follow+rotate mode) + final newNorthLockEnabled = !northLockEnabled; + appState.setNorthLockEnabled(newNorthLockEnabled); + + // If enabling north lock, animate to north-up orientation + if (newNorthLockEnabled) { + try { + widget.mapController.animateTo( + dest: widget.mapController.mapController.camera.center, + zoom: widget.mapController.mapController.camera.zoom, + rotation: 0.0, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOut, + ); + } catch (_) { + // Controller not ready, ignore + } + } + }, + child: Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: isDisabled + ? Colors.grey.withOpacity(0.8) + : Colors.white.withOpacity(0.95), + shape: BoxShape.circle, + border: Border.all( + color: isDisabled + ? Colors.grey.shade400 + : (northLockEnabled + ? Theme.of(context).colorScheme.primary + : Colors.grey.shade300), + width: northLockEnabled ? 3.0 : 2.0, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.25), + blurRadius: 6, + offset: const Offset(0, 3), + ), + ], + ), + child: Stack( + children: [ + // Compass face with cardinal directions + Center( + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isDisabled + ? Colors.grey.shade200 + : Colors.grey.shade50, + ), + ), + ), + // North indicator that rotates with map rotation + Transform.rotate( + angle: rotationRadians, // Rotate same direction as map rotation to counter-act it + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // North arrow (red triangle pointing up) + Container( + margin: const EdgeInsets.only(top: 6), + child: Icon( + Icons.keyboard_arrow_up, + size: 20, + color: isDisabled + ? Colors.grey.shade600 + : (northLockEnabled + ? Colors.red.shade700 + : Colors.red.shade600), + ), + ), + // Small 'N' label + Text( + 'N', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: isDisabled + ? Colors.grey.shade600 + : (northLockEnabled + ? Colors.red.shade700 + : Colors.red.shade600), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/widgets/map/gps_controller.dart b/lib/widgets/map/gps_controller.dart index 570debd..8017a99 100644 --- a/lib/widgets/map/gps_controller.dart +++ b/lib/widgets/map/gps_controller.dart @@ -55,7 +55,7 @@ class GpsController { _currentLatLng != null) { try { - if (newMode == FollowMeMode.northUp) { + if (newMode == FollowMeMode.follow) { controller.animateTo( dest: _currentLatLng!, zoom: controller.mapController.camera.zoom, @@ -89,6 +89,8 @@ class GpsController { int proximityAlertDistance = 200, List nearbyNodes = const [], List enabledProfiles = const [], + // Optional parameter for north lock functionality + bool northLockEnabled = false, }) { final latLng = LatLng(position.latitude, position.longitude); _currentLatLng = latLng; @@ -111,11 +113,13 @@ class GpsController { debugPrint('[GpsController] GPS position update: ${latLng.latitude}, ${latLng.longitude}, follow-me: $followMeMode'); WidgetsBinding.instance.addPostFrameCallback((_) { try { - if (followMeMode == FollowMeMode.northUp) { - // Follow position only, keep current rotation + if (followMeMode == FollowMeMode.follow) { + // Follow position only, keep current rotation (unless north lock is enabled) + final rotation = northLockEnabled ? 0.0 : controller.mapController.camera.rotation; controller.animateTo( dest: latLng, zoom: controller.mapController.camera.zoom, + rotation: rotation, duration: kFollowMeAnimationDuration, curve: Curves.easeOut, ); @@ -153,6 +157,7 @@ class GpsController { required int Function() getProximityAlertDistance, required List Function() getNearbyNodes, required List Function() getEnabledProfiles, + required bool Function() getNorthLockEnabled, }) async { final perm = await Geolocator.requestPermission(); if (perm == LocationPermission.denied || @@ -168,6 +173,7 @@ class GpsController { final proximityAlertDistance = getProximityAlertDistance(); final nearbyNodes = getNearbyNodes(); final enabledProfiles = getEnabledProfiles(); + final northLockEnabled = getNorthLockEnabled(); processPositionUpdate( position: position, @@ -178,6 +184,7 @@ class GpsController { proximityAlertDistance: proximityAlertDistance, nearbyNodes: nearbyNodes, enabledProfiles: enabledProfiles, + northLockEnabled: northLockEnabled, ); }); } diff --git a/lib/widgets/map/map_overlays.dart b/lib/widgets/map/map_overlays.dart index db5dc64..ac62f54 100644 --- a/lib/widgets/map/map_overlays.dart +++ b/lib/widgets/map/map_overlays.dart @@ -1,16 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_animations/flutter_map_animations.dart'; import 'package:provider/provider.dart'; import '../../app_state.dart'; import '../../dev_config.dart'; import '../../services/localization_service.dart'; import '../camera_icon.dart'; +import '../compass_indicator.dart'; import 'layer_selector_button.dart'; /// Widget that renders all map overlay UI elements class MapOverlays extends StatelessWidget { - final MapController mapController; + final AnimatedMapController mapController; final UploadMode uploadMode; final AddNodeSession? session; final EditNodeSession? editSession; @@ -82,6 +84,11 @@ class MapOverlays extends StatelessWidget { ), ), + // Compass indicator (top-right, below mode indicator) + CompassIndicator( + mapController: mapController, + ), + // Zoom indicator, positioned relative to button bar Positioned( left: 10, @@ -96,7 +103,7 @@ class MapOverlays extends StatelessWidget { builder: (context) { double zoom = 15.0; // fallback try { - zoom = mapController.camera.zoom; + zoom = mapController.mapController.camera.zoom; } catch (_) { // Map controller not ready yet } @@ -173,8 +180,8 @@ class MapOverlays extends StatelessWidget { heroTag: "zoom_in", onPressed: () { try { - final zoom = mapController.camera.zoom; - mapController.move(mapController.camera.center, zoom + 1); + final zoom = mapController.mapController.camera.zoom; + mapController.mapController.move(mapController.mapController.camera.center, zoom + 1); } catch (_) { // Map controller not ready yet } @@ -188,8 +195,8 @@ class MapOverlays extends StatelessWidget { heroTag: "zoom_out", onPressed: () { try { - final zoom = mapController.camera.zoom; - mapController.move(mapController.camera.center, zoom - 1); + final zoom = mapController.mapController.camera.zoom; + mapController.mapController.move(mapController.mapController.camera.center, zoom - 1); } catch (_) { // Map controller not ready yet } diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index b8ce682..bd0bf6e 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -74,6 +74,9 @@ class MapViewState extends State { // Track map center to clear queue on significant panning LatLng? _lastCenter; + // Track rotation to detect intentional vs accidental rotation + double? _lastRotation; + // State for proximity alert banner bool _showProximityBanner = false; @@ -178,6 +181,17 @@ class MapViewState extends State { } return []; }, + getNorthLockEnabled: () { + if (mounted) { + try { + return context.read().northLockEnabled; + } catch (e) { + debugPrint('[MapView] Could not read north lock enabled: $e'); + return false; + } + } + return false; + }, ); // Fetch initial cameras @@ -544,7 +558,45 @@ class MapViewState extends State { maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(), onPositionChanged: (pos, gesture) { setState(() {}); // Instant UI update for zoom, etc. - if (gesture) widget.onUserGesture(); + if (gesture) { + widget.onUserGesture(); + + // Handle north lock: prevent rotation or disable lock if user rotates significantly + if (appState.northLockEnabled) { + try { + final currentRotation = pos.rotation; + if (_lastRotation != null) { + // Calculate rotation change since last gesture + final rotationChange = (currentRotation - _lastRotation!).abs(); + // If user tries to rotate significantly, disable north lock and allow it + if (rotationChange > kNorthLockDisableThresholdDegrees) { + appState.setNorthLockEnabled(false); + // Allow this rotation to proceed + } else { + // Small rotation or zoom gesture - force map back to north (0°) + if (currentRotation.abs() > 0.1) { // Only correct if actually rotated + WidgetsBinding.instance.addPostFrameCallback((_) { + try { + _controller.animateTo( + dest: pos.center, + zoom: pos.zoom, + rotation: 0.0, + duration: const Duration(milliseconds: 100), // Quick snap back + curve: Curves.easeOut, + ); + } catch (_) { + // Controller not ready, ignore + } + }); + } + } + } + _lastRotation = currentRotation; + } catch (_) { + // Controller not ready, ignore + } + } + } if (session != null) { appState.updateSession(target: pos.center); @@ -622,7 +674,7 @@ class MapViewState extends State { // All map overlays (mode indicator, zoom, attribution, add pin) MapOverlays( - mapController: _controller.mapController, + mapController: _controller, uploadMode: appState.uploadMode, session: session, editSession: editSession, diff --git a/scripts/validate_localizations.dart b/scripts/validate_localizations.dart new file mode 100644 index 0000000..e69de29