diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 22b8017..9e52c66 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -33,6 +33,14 @@ const int kCameraMinZoomLevel = 10; // Minimum zoom to show cameras or warning const Duration kMarkerTapTimeout = Duration(milliseconds: 250); const Duration kDebounceCameraRefresh = Duration(milliseconds: 500); +// Follow-me mode smooth transitions +const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600); +const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation + +// Last known location storage +const String kLastKnownLatKey = 'last_known_latitude'; +const String kLastKnownLngKey = 'last_known_longitude'; + // Tile/OSM fetch retry parameters (for tunable backoff) const int kTileFetchMaxAttempts = 3; const int kTileFetchInitialDelayMs = 4000; @@ -57,4 +65,4 @@ const Color kCameraRingColorReal = Color(0xC43F55F3); // Real cameras from OSM - const Color kCameraRingColorMock = Color(0xC4FFFFFF); // Add camera mock point - white const Color kCameraRingColorPending = Color(0xC49C27B0); // Submitted/pending cameras - purple const Color kCameraRingColorEditing = Color(0xC4FF9800); // Camera being edited - orange -const Color kCameraRingColorPendingEdit = Color(0xC4757575); // Original camera with pending edit - grey +const Color kCameraRingColorPendingEdit = Color(0xC4757575); // Original camera with pending edit - grey \ No newline at end of file diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 0ef368f..02a6ba3 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,5 +1,6 @@ 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'; @@ -24,13 +25,25 @@ class HomeScreen extends StatefulWidget { State createState() => _HomeScreenState(); } -class _HomeScreenState extends State { +class _HomeScreenState extends State with TickerProviderStateMixin { final GlobalKey _scaffoldKey = GlobalKey(); final GlobalKey _mapViewKey = GlobalKey(); - final MapController _mapController = MapController(); + late final AnimatedMapController _mapController; FollowMeMode _followMeMode = FollowMeMode.northUp; bool _editSheetShown = false; + @override + void initState() { + super.initState(); + _mapController = AnimatedMapController(vsync: this); + } + + @override + void dispose() { + _mapController.dispose(); + super.dispose(); + } + String _getFollowMeTooltip() { switch (_followMeMode) { case FollowMeMode.off: @@ -179,7 +192,7 @@ class _HomeScreenState extends State { label: Text('Download'), onPressed: () => showDialog( context: context, - builder: (ctx) => DownloadAreaDialog(controller: _mapController), + builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController), ), style: ElevatedButton.styleFrom( minimumSize: Size(0, 48), diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 89f7d30..b9d829e 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -1,11 +1,13 @@ 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 'package:shared_preferences/shared_preferences.dart'; import '../app_state.dart'; import '../services/offline_area_service.dart'; @@ -24,7 +26,7 @@ import '../dev_config.dart'; import '../screens/home_screen.dart' show FollowMeMode; class MapView extends StatefulWidget { - final MapController controller; + final AnimatedMapController controller; const MapView({ super.key, required this.controller, @@ -40,12 +42,13 @@ class MapView extends StatefulWidget { } class MapViewState extends State { - late final MapController _controller; + late final AnimatedMapController _controller; final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh); final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150)); StreamSubscription? _positionSub; LatLng? _currentLatLng; + LatLng? _initialLocation; late final CameraProviderWithCache _cameraProvider; late final SimpleTileHttpClient _tileHttpClient; @@ -67,6 +70,9 @@ class MapViewState extends State { OfflineAreaService(); _controller = widget.controller; _tileHttpClient = SimpleTileHttpClient(); + + // Load last known location before initializing GPS + _loadInitialLocation(); _initLocation(); // Set up camera overlay caching @@ -79,6 +85,13 @@ class MapViewState extends State { }); } + /// Load the initial location (last known location or default) + Future _loadInitialLocation() async { + _initialLocation = await _loadLastKnownLocation(); + } + + + @override void dispose() { _positionSub?.cancel(); @@ -99,17 +112,47 @@ class MapViewState extends State { _initLocation(); } + /// Save the last known location to persistent storage + Future _saveLastKnownLocation(LatLng location) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(kLastKnownLatKey, location.latitude); + await prefs.setDouble(kLastKnownLngKey, location.longitude); + debugPrint('[MapView] Saved last known location: ${location.latitude}, ${location.longitude}'); + } catch (e) { + debugPrint('[MapView] Failed to save last known location: $e'); + } + } + + /// Load the last known location from persistent storage + Future _loadLastKnownLocation() async { + try { + final prefs = await SharedPreferences.getInstance(); + final lat = prefs.getDouble(kLastKnownLatKey); + final lng = prefs.getDouble(kLastKnownLngKey); + + if (lat != null && lng != null) { + final location = LatLng(lat, lng); + debugPrint('[MapView] Loaded last known location: ${location.latitude}, ${location.longitude}'); + return location; + } + } catch (e) { + debugPrint('[MapView] Failed to load last known location: $e'); + } + return null; + } + void _refreshCamerasFromProvider() { final appState = context.read(); LatLngBounds? bounds; try { - bounds = _controller.camera.visibleBounds; + bounds = _controller.mapController.camera.visibleBounds; } catch (_) { return; } - final zoom = _controller.camera.zoom; + final zoom = _controller.mapController.camera.zoom; if (zoom < kCameraMinZoomLevel) { // Show a snackbar-style bubble, if desired if (mounted) { @@ -140,12 +183,23 @@ class MapViewState extends State { if (widget.followMeMode != FollowMeMode.off && oldWidget.followMeMode == FollowMeMode.off && _currentLatLng != null) { - // Move to current location when follow me is first enabled + // Move to current location when follow me is first enabled - smooth animation if (widget.followMeMode == FollowMeMode.northUp) { - _controller.move(_currentLatLng!, _controller.camera.zoom); + _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 - _controller.moveAndRotate(_currentLatLng!, _controller.camera.zoom, 0.0); + // 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, + ); } } } @@ -160,19 +214,38 @@ class MapViewState extends State { final latLng = LatLng(position.latitude, position.longitude); setState(() => _currentLatLng = latLng); + // Save this as the last known location + _saveLastKnownLocation(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 - _controller.move(latLng, _controller.camera.zoom); + // 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 + // Follow position and rotation based on heading - smooth animation final heading = position.heading; - final rotation = heading.isNaN ? 0.0 : -heading; // Convert to map rotation - _controller.moveAndRotate(latLng, _controller.camera.zoom, rotation); + 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'); @@ -185,7 +258,7 @@ class MapViewState extends State { double _safeZoom() { try { - return _controller.camera.zoom; + return _controller.mapController.camera.zoom; } catch (_) { return 15.0; } @@ -267,7 +340,7 @@ class MapViewState extends State { // Seed add‑mode target once, after first controller center is available. if (session != null && session.target == null) { try { - final center = _controller.camera.center; + final center = _controller.mapController.camera.center; WidgetsBinding.instance.addPostFrameCallback( (_) => appState.updateSession(target: center), ); @@ -275,11 +348,11 @@ class MapViewState extends State { } // For edit sessions, center the map on the camera being edited initially - if (editSession != null && _controller.camera.center != editSession.target) { + if (editSession != null && _controller.mapController.camera.center != editSession.target) { WidgetsBinding.instance.addPostFrameCallback( (_) { try { - _controller.move(editSession.target, _controller.camera.zoom); + _controller.mapController.move(editSession.target, _controller.mapController.camera.zoom); } catch (_) {/* controller not ready yet */} }, ); @@ -291,7 +364,7 @@ class MapViewState extends State { builder: (context, cameraProvider, child) { LatLngBounds? mapBounds; try { - mapBounds = _controller.camera.visibleBounds; + mapBounds = _controller.mapController.camera.visibleBounds; } catch (_) { mapBounds = null; } @@ -301,7 +374,7 @@ class MapViewState extends State { final markers = CameraMarkersBuilder.buildCameraMarkers( cameras: cameras, - mapController: _controller, + mapController: _controller.mapController, userLocation: _currentLatLng, ); @@ -329,9 +402,9 @@ class MapViewState extends State { children: [ FlutterMap( key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_$_mapRebuildKey'), - mapController: _controller, + mapController: _controller.mapController, options: MapOptions( - initialCenter: _currentLatLng ?? LatLng(37.7749, -122.4194), + initialCenter: _currentLatLng ?? _initialLocation ?? LatLng(37.7749, -122.4194), initialZoom: 15, maxZoom: 19, onPositionChanged: (pos, gesture) { @@ -382,7 +455,7 @@ class MapViewState extends State { // All map overlays (mode indicator, zoom, attribution, add pin) MapOverlays( - mapController: _controller, + mapController: _controller.mapController, uploadMode: appState.uploadMode, session: session, editSession: editSession, diff --git a/pubspec.lock b/pubspec.lock index 29982b5..051d2a1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -158,6 +158,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.2.1" + flutter_map_animations: + dependency: "direct main" + description: + name: flutter_map_animations + sha256: bf583863561861aaaf4854ae7ed8940d79bea7d32918bf7a85d309b25235a09e + url: "https://pub.dev" + source: hosted + version: "0.9.0" flutter_native_splash: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 7da3e55..23e3bcd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: # UI & Map provider: ^6.1.2 flutter_map: ^8.2.1 - # (removed: using built-in Scalebar from flutter_map >= v6) + flutter_map_animations: ^0.9.0 latlong2: ^0.9.0 geolocator: ^10.1.0 http: ^1.2.1 @@ -49,4 +49,4 @@ flutter_icons: android: true ios: true image_path: "assets/app_icon.png" - min_sdk_android: 21 \ No newline at end of file + min_sdk_android: 21