mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-08-15 23:50:49 +02:00
smooth transitions
This commit is contained in:
+9
-1
@@ -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;
|
||||||
@@ -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 kCameraRingColorMock = Color(0xC4FFFFFF); // Add camera mock point - white
|
||||||
const Color kCameraRingColorPending = Color(0xC49C27B0); // Submitted/pending cameras - purple
|
const Color kCameraRingColorPending = Color(0xC49C27B0); // Submitted/pending cameras - purple
|
||||||
const Color kCameraRingColorEditing = Color(0xC4FF9800); // Camera being edited - orange
|
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
|
||||||
@@ -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
@@ -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 add‑mode target once, after first controller center is available.
|
// Seed add‑mode 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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
@@ -49,4 +49,4 @@ flutter_icons:
|
|||||||
android: true
|
android: true
|
||||||
ios: true
|
ios: true
|
||||||
image_path: "assets/app_icon.png"
|
image_path: "assets/app_icon.png"
|
||||||
min_sdk_android: 21
|
min_sdk_android: 21
|
||||||
|
|||||||
Reference in New Issue
Block a user