From 6db691dbeb3d30611480138421129534523b6d58 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 28 Aug 2025 19:58:45 -0500 Subject: [PATCH] Break out follow-me / gps stuff from map_view --- lib/widgets/map/gps_controller.dart | 154 ++++++++++++++++++++++++++++ lib/widgets/map_view.dart | 105 ++++--------------- 2 files changed, 174 insertions(+), 85 deletions(-) create mode 100644 lib/widgets/map/gps_controller.dart diff --git a/lib/widgets/map/gps_controller.dart b/lib/widgets/map/gps_controller.dart new file mode 100644 index 0000000..a8df4ac --- /dev/null +++ b/lib/widgets/map/gps_controller.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_map_animations/flutter_map_animations.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; + +import '../../dev_config.dart'; +import '../../screens/home_screen.dart' show FollowMeMode; + +/// Manages GPS location tracking, follow-me modes, and location-based map animations. +/// Handles GPS permissions, position streams, and follow-me behavior. +class GpsController { + StreamSubscription? _positionSub; + LatLng? _currentLatLng; + + /// Get the current GPS location (if available) + LatLng? get currentLocation => _currentLatLng; + + /// Initialize GPS location tracking + Future initializeLocation() async { + final perm = await Geolocator.requestPermission(); + if (perm == LocationPermission.denied || + perm == LocationPermission.deniedForever) { + debugPrint('[GpsController] Location permission denied'); + return; + } + + _positionSub = Geolocator.getPositionStream().listen((Position position) { + final latLng = LatLng(position.latitude, position.longitude); + _currentLatLng = latLng; + debugPrint('[GpsController] GPS position updated: ${latLng.latitude}, ${latLng.longitude}'); + }); + } + + /// Retry location initialization (e.g., after permission granted) + Future retryLocationInit() async { + debugPrint('[GpsController] Retrying location initialization'); + await initializeLocation(); + } + + /// Handle follow-me mode changes and animate map accordingly + void handleFollowMeModeChange({ + required FollowMeMode newMode, + required FollowMeMode oldMode, + required AnimatedMapController controller, + }) { + // Only act when follow-me is first enabled and we have a current location + if (newMode != FollowMeMode.off && + oldMode == FollowMeMode.off && + _currentLatLng != null) { + + try { + if (newMode == FollowMeMode.northUp) { + controller.animateTo( + dest: _currentLatLng!, + zoom: controller.mapController.camera.zoom, + duration: kFollowMeAnimationDuration, + curve: Curves.easeOut, + ); + } else if (newMode == FollowMeMode.rotating) { + // When switching to rotating mode, reset to north-up first + controller.animateTo( + dest: _currentLatLng!, + zoom: controller.mapController.camera.zoom, + rotation: 0.0, + duration: kFollowMeAnimationDuration, + curve: Curves.easeOut, + ); + } + } catch (e) { + debugPrint('[GpsController] MapController not ready for follow-me change: $e'); + } + } + } + + /// Process GPS position updates and handle follow-me animations + void processPositionUpdate({ + required Position position, + required FollowMeMode followMeMode, + required AnimatedMapController controller, + required VoidCallback onLocationUpdated, + }) { + final latLng = LatLng(position.latitude, position.longitude); + _currentLatLng = latLng; + + // Notify that location was updated (for setState, etc.) + onLocationUpdated(); + + // Handle follow-me animations if enabled + if (followMeMode != FollowMeMode.off) { + WidgetsBinding.instance.addPostFrameCallback((_) { + try { + if (followMeMode == FollowMeMode.northUp) { + // Follow position only, keep current rotation + controller.animateTo( + dest: latLng, + zoom: controller.mapController.camera.zoom, + duration: kFollowMeAnimationDuration, + curve: Curves.easeOut, + ); + } else if (followMeMode == FollowMeMode.rotating) { + // Follow position and rotation based on heading + final heading = position.heading; + final speed = position.speed; // Speed in m/s + + // Only apply rotation if moving fast enough to avoid wild spinning when stationary + final shouldRotate = !speed.isNaN && speed >= kMinSpeedForRotationMps && !heading.isNaN; + final rotation = shouldRotate ? -heading : controller.mapController.camera.rotation; + + controller.animateTo( + dest: latLng, + zoom: controller.mapController.camera.zoom, + rotation: rotation, + duration: kFollowMeAnimationDuration, + curve: Curves.easeOut, + ); + } + } catch (e) { + debugPrint('[GpsController] MapController not ready for position animation: $e'); + } + }); + } + } + + /// Initialize GPS with custom position processing callback + Future initializeWithCallback({ + required FollowMeMode followMeMode, + required AnimatedMapController controller, + required VoidCallback onLocationUpdated, + }) async { + final perm = await Geolocator.requestPermission(); + if (perm == LocationPermission.denied || + perm == LocationPermission.deniedForever) { + debugPrint('[GpsController] Location permission denied'); + return; + } + + _positionSub = Geolocator.getPositionStream().listen((Position position) { + processPositionUpdate( + position: position, + followMeMode: followMeMode, + controller: controller, + onLocationUpdated: onLocationUpdated, + ); + }); + } + + /// Dispose of GPS resources + void dispose() { + _positionSub?.cancel(); + _positionSub = null; + debugPrint('[GpsController] GPS controller disposed'); + } +} \ No newline at end of file diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 4438a2e..3e02157 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -1,12 +1,8 @@ -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_animations/flutter_map_animations.dart'; import 'package:latlong2/latlong.dart'; -import 'package:geolocator/geolocator.dart'; import 'package:provider/provider.dart'; -import 'package:http/http.dart' as http; -import 'package:collection/collection.dart'; import '../app_state.dart'; import '../services/offline_area_service.dart'; @@ -22,6 +18,7 @@ import 'map/map_overlays.dart'; import 'map/map_position_manager.dart'; import 'map/tile_layer_manager.dart'; import 'map/camera_refresh_controller.dart'; +import 'map/gps_controller.dart'; import 'network_status_indicator.dart'; import '../dev_config.dart'; import '../screens/home_screen.dart' show FollowMeMode; @@ -48,12 +45,10 @@ class MapViewState extends State { final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150)); final Debouncer _mapPositionDebounce = Debouncer(const Duration(milliseconds: 1000)); - StreamSubscription? _positionSub; - LatLng? _currentLatLng; - late final MapPositionManager _positionManager; late final TileLayerManager _tileManager; late final CameraRefreshController _cameraController; + late final GpsController _gpsController; // Track zoom to clear queue on zoom changes double? _lastZoom; @@ -68,6 +63,7 @@ class MapViewState extends State { _tileManager.initialize(); _cameraController = CameraRefreshController(); _cameraController.initialize(onCamerasUpdated: _onCamerasUpdated); + _gpsController = GpsController(); // Load last map position before initializing GPS _positionManager.loadLastMapPosition().then((_) { @@ -76,7 +72,13 @@ class MapViewState extends State { _positionManager.moveToInitialLocationIfNeeded(_controller); }); }); - _initLocation(); + + // Initialize GPS with callback for position updates and follow-me + _gpsController.initializeWithCallback( + followMeMode: widget.followMeMode, + controller: _controller, + onLocationUpdated: () => setState(() {}), + ); // Fetch initial cameras WidgetsBinding.instance.addPostFrameCallback((_) { @@ -90,12 +92,12 @@ class MapViewState extends State { @override void dispose() { - _positionSub?.cancel(); _cameraDebounce.dispose(); _tileDebounce.dispose(); _mapPositionDebounce.dispose(); _cameraController.dispose(); _tileManager.dispose(); + _gpsController.dispose(); super.dispose(); } @@ -105,8 +107,7 @@ class MapViewState extends State { /// Public method to retry location initialization (e.g., after permission granted) void retryLocationInit() { - debugPrint('[MapView] Retrying location initialization'); - _initLocation(); + _gpsController.retryLocationInit(); } /// Expose static methods from MapPositionManager for external access @@ -138,78 +139,12 @@ class MapViewState extends State { @override void didUpdateWidget(covariant MapView oldWidget) { super.didUpdateWidget(oldWidget); - // Back to original pattern - simple check - if (widget.followMeMode != FollowMeMode.off && - oldWidget.followMeMode == FollowMeMode.off && - _currentLatLng != null) { - // Move to current location when follow me is first enabled - smooth animation - if (widget.followMeMode == FollowMeMode.northUp) { - _controller.animateTo( - dest: _currentLatLng!, - zoom: _controller.mapController.camera.zoom, - duration: kFollowMeAnimationDuration, - curve: Curves.easeOut, - ); - } else if (widget.followMeMode == FollowMeMode.rotating) { - // When switching to rotating mode, reset to north-up first - smooth animation - _controller.animateTo( - dest: _currentLatLng!, - zoom: _controller.mapController.camera.zoom, - rotation: 0.0, - duration: kFollowMeAnimationDuration, - curve: Curves.easeOut, - ); - } - } - } - - Future _initLocation() async { - final perm = await Geolocator.requestPermission(); - if (perm == LocationPermission.denied || - perm == LocationPermission.deniedForever) return; - - _positionSub = - Geolocator.getPositionStream().listen((Position position) { - final latLng = LatLng(position.latitude, position.longitude); - setState(() => _currentLatLng = latLng); - - // Back to original pattern - directly check widget parameter - if (widget.followMeMode != FollowMeMode.off) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - try { - if (widget.followMeMode == FollowMeMode.northUp) { - // Follow position only, keep current rotation - smooth animation - _controller.animateTo( - dest: latLng, - zoom: _controller.mapController.camera.zoom, - duration: kFollowMeAnimationDuration, - curve: Curves.easeOut, - ); - } else if (widget.followMeMode == FollowMeMode.rotating) { - // Follow position and rotation based on heading - smooth animation - final heading = position.heading; - final speed = position.speed; // Speed in m/s - - // Only apply rotation if moving fast enough to avoid wild spinning when stationary - final shouldRotate = !speed.isNaN && speed >= kMinSpeedForRotationMps && !heading.isNaN; - final rotation = shouldRotate ? -heading : _controller.mapController.camera.rotation; - - _controller.animateTo( - dest: latLng, - zoom: _controller.mapController.camera.zoom, - rotation: rotation, - duration: kFollowMeAnimationDuration, - curve: Curves.easeOut, - ); - } - } catch (e) { - debugPrint('MapController not ready yet: $e'); - } - } - }); - } - }); + // Handle follow-me mode changes + _gpsController.handleFollowMeModeChange( + newMode: widget.followMeMode, + oldMode: oldWidget.followMeMode, + controller: _controller, + ); } double _safeZoom() { @@ -288,7 +223,7 @@ class MapViewState extends State { final markers = CameraMarkersBuilder.buildCameraMarkers( cameras: cameras, mapController: _controller.mapController, - userLocation: _currentLatLng, + userLocation: _gpsController.currentLocation, ); final overlays = DirectionConesBuilder.buildDirectionCones( @@ -317,7 +252,7 @@ class MapViewState extends State { key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_${_tileManager.mapRebuildKey}'), mapController: _controller.mapController, options: MapOptions( - initialCenter: _currentLatLng ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194), + initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194), initialZoom: _positionManager.initialZoom ?? 15, maxZoom: 19, onPositionChanged: (pos, gesture) {