smooth transitions

This commit is contained in:
stopflock
2025-08-28 15:07:30 -05:00
parent d7fbfaaaeb
commit 728cef22af
5 changed files with 130 additions and 28 deletions
+8
View File
@@ -33,6 +33,14 @@ const int kCameraMinZoomLevel = 10; // Minimum zoom to show cameras or warning
const Duration kMarkerTapTimeout = Duration(milliseconds: 250); const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500); 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) // Tile/OSM fetch retry parameters (for tunable backoff)
const int kTileFetchMaxAttempts = 3; const int kTileFetchMaxAttempts = 3;
const int kTileFetchInitialDelayMs = 4000; const int kTileFetchInitialDelayMs = 4000;
+16 -3
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../app_state.dart'; import '../app_state.dart';
@@ -24,13 +25,25 @@ class HomeScreen extends StatefulWidget {
State<HomeScreen> createState() => _HomeScreenState(); State<HomeScreen> createState() => _HomeScreenState();
} }
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>(); final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>(); final GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>();
final MapController _mapController = MapController(); late final AnimatedMapController _mapController;
FollowMeMode _followMeMode = FollowMeMode.northUp; FollowMeMode _followMeMode = FollowMeMode.northUp;
bool _editSheetShown = false; bool _editSheetShown = false;
@override
void initState() {
super.initState();
_mapController = AnimatedMapController(vsync: this);
}
@override
void dispose() {
_mapController.dispose();
super.dispose();
}
String _getFollowMeTooltip() { String _getFollowMeTooltip() {
switch (_followMeMode) { switch (_followMeMode) {
case FollowMeMode.off: case FollowMeMode.off:
@@ -179,7 +192,7 @@ class _HomeScreenState extends State<HomeScreen> {
label: Text('Download'), label: Text('Download'),
onPressed: () => showDialog( onPressed: () => showDialog(
context: context, context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController), builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
), ),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48), minimumSize: Size(0, 48),
+95 -22
View File
@@ -1,11 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../app_state.dart'; import '../app_state.dart';
import '../services/offline_area_service.dart'; import '../services/offline_area_service.dart';
@@ -24,7 +26,7 @@ import '../dev_config.dart';
import '../screens/home_screen.dart' show FollowMeMode; import '../screens/home_screen.dart' show FollowMeMode;
class MapView extends StatefulWidget { class MapView extends StatefulWidget {
final MapController controller; final AnimatedMapController controller;
const MapView({ const MapView({
super.key, super.key,
required this.controller, required this.controller,
@@ -40,12 +42,13 @@ class MapView extends StatefulWidget {
} }
class MapViewState extends State<MapView> { class MapViewState extends State<MapView> {
late final MapController _controller; late final AnimatedMapController _controller;
final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh); final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh);
final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150)); final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150));
StreamSubscription<Position>? _positionSub; StreamSubscription<Position>? _positionSub;
LatLng? _currentLatLng; LatLng? _currentLatLng;
LatLng? _initialLocation;
late final CameraProviderWithCache _cameraProvider; late final CameraProviderWithCache _cameraProvider;
late final SimpleTileHttpClient _tileHttpClient; late final SimpleTileHttpClient _tileHttpClient;
@@ -67,6 +70,9 @@ class MapViewState extends State<MapView> {
OfflineAreaService(); OfflineAreaService();
_controller = widget.controller; _controller = widget.controller;
_tileHttpClient = SimpleTileHttpClient(); _tileHttpClient = SimpleTileHttpClient();
// Load last known location before initializing GPS
_loadInitialLocation();
_initLocation(); _initLocation();
// Set up camera overlay caching // Set up camera overlay caching
@@ -79,6 +85,13 @@ class MapViewState extends State<MapView> {
}); });
} }
/// Load the initial location (last known location or default)
Future<void> _loadInitialLocation() async {
_initialLocation = await _loadLastKnownLocation();
}
@override @override
void dispose() { void dispose() {
_positionSub?.cancel(); _positionSub?.cancel();
@@ -99,17 +112,47 @@ class MapViewState extends State<MapView> {
_initLocation(); _initLocation();
} }
/// Save the last known location to persistent storage
Future<void> _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<LatLng?> _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() { void _refreshCamerasFromProvider() {
final appState = context.read<AppState>(); final appState = context.read<AppState>();
LatLngBounds? bounds; LatLngBounds? bounds;
try { try {
bounds = _controller.camera.visibleBounds; bounds = _controller.mapController.camera.visibleBounds;
} catch (_) { } catch (_) {
return; return;
} }
final zoom = _controller.camera.zoom; final zoom = _controller.mapController.camera.zoom;
if (zoom < kCameraMinZoomLevel) { if (zoom < kCameraMinZoomLevel) {
// Show a snackbar-style bubble, if desired // Show a snackbar-style bubble, if desired
if (mounted) { if (mounted) {
@@ -140,12 +183,23 @@ class MapViewState extends State<MapView> {
if (widget.followMeMode != FollowMeMode.off && if (widget.followMeMode != FollowMeMode.off &&
oldWidget.followMeMode == FollowMeMode.off && oldWidget.followMeMode == FollowMeMode.off &&
_currentLatLng != null) { _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) { 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) { } else if (widget.followMeMode == FollowMeMode.rotating) {
// When switching to rotating mode, reset to north-up first // When switching to rotating mode, reset to north-up first - smooth animation
_controller.moveAndRotate(_currentLatLng!, _controller.camera.zoom, 0.0); _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<MapView> {
final latLng = LatLng(position.latitude, position.longitude); final latLng = LatLng(position.latitude, position.longitude);
setState(() => _currentLatLng = latLng); setState(() => _currentLatLng = latLng);
// Save this as the last known location
_saveLastKnownLocation(latLng);
// Back to original pattern - directly check widget parameter // Back to original pattern - directly check widget parameter
if (widget.followMeMode != FollowMeMode.off) { if (widget.followMeMode != FollowMeMode.off) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted) {
try { try {
if (widget.followMeMode == FollowMeMode.northUp) { if (widget.followMeMode == FollowMeMode.northUp) {
// Follow position only, keep current rotation // Follow position only, keep current rotation - smooth animation
_controller.move(latLng, _controller.camera.zoom); _controller.animateTo(
dest: latLng,
zoom: _controller.mapController.camera.zoom,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
} else if (widget.followMeMode == FollowMeMode.rotating) { } 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 heading = position.heading;
final rotation = heading.isNaN ? 0.0 : -heading; // Convert to map rotation final speed = position.speed; // Speed in m/s
_controller.moveAndRotate(latLng, _controller.camera.zoom, rotation);
// 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) { } catch (e) {
debugPrint('MapController not ready yet: $e'); debugPrint('MapController not ready yet: $e');
@@ -185,7 +258,7 @@ class MapViewState extends State<MapView> {
double _safeZoom() { double _safeZoom() {
try { try {
return _controller.camera.zoom; return _controller.mapController.camera.zoom;
} catch (_) { } catch (_) {
return 15.0; return 15.0;
} }
@@ -267,7 +340,7 @@ class MapViewState extends State<MapView> {
// Seed addmode target once, after first controller center is available. // Seed addmode target once, after first controller center is available.
if (session != null && session.target == null) { if (session != null && session.target == null) {
try { try {
final center = _controller.camera.center; final center = _controller.mapController.camera.center;
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback(
(_) => appState.updateSession(target: center), (_) => appState.updateSession(target: center),
); );
@@ -275,11 +348,11 @@ class MapViewState extends State<MapView> {
} }
// For edit sessions, center the map on the camera being edited initially // 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( WidgetsBinding.instance.addPostFrameCallback(
(_) { (_) {
try { try {
_controller.move(editSession.target, _controller.camera.zoom); _controller.mapController.move(editSession.target, _controller.mapController.camera.zoom);
} catch (_) {/* controller not ready yet */} } catch (_) {/* controller not ready yet */}
}, },
); );
@@ -291,7 +364,7 @@ class MapViewState extends State<MapView> {
builder: (context, cameraProvider, child) { builder: (context, cameraProvider, child) {
LatLngBounds? mapBounds; LatLngBounds? mapBounds;
try { try {
mapBounds = _controller.camera.visibleBounds; mapBounds = _controller.mapController.camera.visibleBounds;
} catch (_) { } catch (_) {
mapBounds = null; mapBounds = null;
} }
@@ -301,7 +374,7 @@ class MapViewState extends State<MapView> {
final markers = CameraMarkersBuilder.buildCameraMarkers( final markers = CameraMarkersBuilder.buildCameraMarkers(
cameras: cameras, cameras: cameras,
mapController: _controller, mapController: _controller.mapController,
userLocation: _currentLatLng, userLocation: _currentLatLng,
); );
@@ -329,9 +402,9 @@ class MapViewState extends State<MapView> {
children: [ children: [
FlutterMap( FlutterMap(
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_$_mapRebuildKey'), key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_$_mapRebuildKey'),
mapController: _controller, mapController: _controller.mapController,
options: MapOptions( options: MapOptions(
initialCenter: _currentLatLng ?? LatLng(37.7749, -122.4194), initialCenter: _currentLatLng ?? _initialLocation ?? LatLng(37.7749, -122.4194),
initialZoom: 15, initialZoom: 15,
maxZoom: 19, maxZoom: 19,
onPositionChanged: (pos, gesture) { onPositionChanged: (pos, gesture) {
@@ -382,7 +455,7 @@ class MapViewState extends State<MapView> {
// All map overlays (mode indicator, zoom, attribution, add pin) // All map overlays (mode indicator, zoom, attribution, add pin)
MapOverlays( MapOverlays(
mapController: _controller, mapController: _controller.mapController,
uploadMode: appState.uploadMode, uploadMode: appState.uploadMode,
session: session, session: session,
editSession: editSession, editSession: editSession,
+8
View File
@@ -158,6 +158,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.2.1" 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: flutter_native_splash:
dependency: "direct dev" dependency: "direct dev"
description: description:
+1 -1
View File
@@ -13,7 +13,7 @@ dependencies:
# UI & Map # UI & Map
provider: ^6.1.2 provider: ^6.1.2
flutter_map: ^8.2.1 flutter_map: ^8.2.1
# (removed: using built-in Scalebar from flutter_map >= v6) flutter_map_animations: ^0.9.0
latlong2: ^0.9.0 latlong2: ^0.9.0
geolocator: ^10.1.0 geolocator: ^10.1.0
http: ^1.2.1 http: ^1.2.1