refactor follow me mode state handling

This commit is contained in:
stopflock
2025-08-29 11:08:50 -05:00
parent 42c03eca7d
commit c4c1505253
7 changed files with 81 additions and 103 deletions

View File

@@ -14,7 +14,7 @@ import 'state/settings_state.dart';
import 'state/upload_queue_state.dart';
// Re-export types
export 'state/settings_state.dart' show UploadMode;
export 'state/settings_state.dart' show UploadMode, FollowMeMode;
export 'state/session_state.dart' show AddCameraSession, EditCameraSession;
// ------------------ AppState ------------------
@@ -68,6 +68,7 @@ class AppState extends ChangeNotifier {
bool get offlineMode => _settingsState.offlineMode;
int get maxCameras => _settingsState.maxCameras;
UploadMode get uploadMode => _settingsState.uploadMode;
FollowMeMode get followMeMode => _settingsState.followMeMode;
// Tile provider state
List<TileProvider> get tileProviders => _settingsState.tileProviders;
@@ -230,7 +231,10 @@ class AppState extends ChangeNotifier {
await _settingsState.deleteTileProvider(providerId);
}
/// Set follow-me mode
Future<void> setFollowMeMode(FollowMeMode mode) async {
await _settingsState.setFollowMeMode(mode);
}
// ---------- Queue Methods ----------
void clearQueue() {

View File

@@ -41,7 +41,6 @@ const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rota
const String kLastMapLatKey = 'last_map_latitude';
const String kLastMapLngKey = 'last_map_longitude';
const String kLastMapZoomKey = 'last_map_zoom';
const String kFollowMeModeKey = 'follow_me_mode';
// Tile/OSM fetch retry parameters (for tunable backoff)
const int kTileFetchMaxAttempts = 3;

View File

@@ -12,12 +12,6 @@ import '../widgets/edit_camera_sheet.dart';
import '../widgets/camera_provider_with_cache.dart';
import '../widgets/download_area_dialog.dart';
enum FollowMeMode {
off, // No following
northUp, // Follow position, keep north up
rotating, // Follow position and rotation
}
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -29,25 +23,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>();
late final AnimatedMapController _mapController;
FollowMeMode _followMeMode = FollowMeMode.northUp;
bool _editSheetShown = false;
@override
void initState() {
super.initState();
_mapController = AnimatedMapController(vsync: this);
// Load saved follow-me mode
_loadFollowMeMode();
}
/// Load the saved follow-me mode
Future<void> _loadFollowMeMode() async {
final savedMode = await MapViewState.loadFollowMeMode();
if (mounted) {
setState(() {
_followMeMode = savedMode;
});
}
}
@override
@@ -56,8 +37,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
super.dispose();
}
String _getFollowMeTooltip() {
switch (_followMeMode) {
String _getFollowMeTooltip(FollowMeMode mode) {
switch (mode) {
case FollowMeMode.off:
return 'Enable follow-me (north up)';
case FollowMeMode.northUp:
@@ -67,8 +48,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}
}
IconData _getFollowMeIcon() {
switch (_followMeMode) {
IconData _getFollowMeIcon(FollowMeMode mode) {
switch (mode) {
case FollowMeMode.off:
return Icons.gps_off;
case FollowMeMode.northUp:
@@ -78,8 +59,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}
}
FollowMeMode _getNextFollowMeMode() {
switch (_followMeMode) {
FollowMeMode _getNextFollowMeMode(FollowMeMode mode) {
switch (mode) {
case FollowMeMode.off:
return FollowMeMode.northUp;
case FollowMeMode.northUp:
@@ -90,12 +71,10 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}
void _openAddCameraSheet() {
// Disable follow-me when adding a camera so the map doesn't jump around
setState(() => _followMeMode = FollowMeMode.off);
// Save the disabled follow-me mode
MapViewState.saveFollowMeMode(_followMeMode);
final appState = context.read<AppState>();
// Disable follow-me when adding a camera so the map doesn't jump around
appState.setFollowMeMode(FollowMeMode.off);
appState.startAddSession();
final session = appState.session!; // guaranteed nonnull now
@@ -105,12 +84,10 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}
void _openEditCameraSheet() {
// Disable follow-me when editing a camera so the map doesn't jump around
setState(() => _followMeMode = FollowMeMode.off);
// Save the disabled follow-me mode
MapViewState.saveFollowMeMode(_followMeMode);
final appState = context.read<AppState>();
// Disable follow-me when editing a camera so the map doesn't jump around
appState.setFollowMeMode(FollowMeMode.off);
final session = appState.editSession!; // should be non-null when this is called
_scaffoldKey.currentState!.showBottomSheet(
@@ -140,18 +117,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
title: const Text('Flock Map'),
actions: [
IconButton(
tooltip: _getFollowMeTooltip(),
icon: Icon(_getFollowMeIcon()),
tooltip: _getFollowMeTooltip(appState.followMeMode),
icon: Icon(_getFollowMeIcon(appState.followMeMode)),
onPressed: () {
setState(() {
final oldMode = _followMeMode;
_followMeMode = _getNextFollowMeMode();
debugPrint('[HomeScreen] Follow mode changed: $oldMode$_followMeMode');
});
// Save the new follow-me mode
MapViewState.saveFollowMeMode(_followMeMode);
final oldMode = appState.followMeMode;
final newMode = _getNextFollowMeMode(oldMode);
debugPrint('[HomeScreen] Follow mode changed: $oldMode$newMode');
appState.setFollowMeMode(newMode);
// If enabling follow-me, retry location init in case permission was granted
if (_followMeMode != FollowMeMode.off) {
if (newMode != FollowMeMode.off) {
_mapViewKey.currentState?.retryLocationInit();
}
},
@@ -167,12 +141,10 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
MapView(
key: _mapViewKey,
controller: _mapController,
followMeMode: _followMeMode,
followMeMode: appState.followMeMode,
onUserGesture: () {
if (_followMeMode != FollowMeMode.off) {
setState(() => _followMeMode = FollowMeMode.off);
// Save the disabled follow-me mode when user interacts with map
MapViewState.saveFollowMeMode(_followMeMode);
if (appState.followMeMode != FollowMeMode.off) {
appState.setFollowMeMode(FollowMeMode.off);
}
},
),

View File

@@ -8,6 +8,13 @@ import '../models/tile_provider.dart';
// Enum for upload mode (Production, OSM Sandbox, Simulate)
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
}
class SettingsState extends ChangeNotifier {
static const String _offlineModePrefsKey = 'offline_mode';
static const String _maxCamerasPrefsKey = 'max_cameras';
@@ -15,10 +22,12 @@ class SettingsState extends ChangeNotifier {
static const String _tileProvidersPrefsKey = 'tile_providers';
static const String _selectedTileTypePrefsKey = 'selected_tile_type';
static const String _legacyTestModePrefsKey = 'test_mode';
static const String _followMeModePrefsKey = 'follow_me_mode';
bool _offlineMode = false;
int _maxCameras = 250;
UploadMode _uploadMode = UploadMode.simulate;
FollowMeMode _followMeMode = FollowMeMode.northUp;
List<TileProvider> _tileProviders = [];
String _selectedTileTypeId = '';
@@ -26,6 +35,7 @@ class SettingsState extends ChangeNotifier {
bool get offlineMode => _offlineMode;
int get maxCameras => _maxCameras;
UploadMode get uploadMode => _uploadMode;
FollowMeMode get followMeMode => _followMeMode;
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
String get selectedTileTypeId => _selectedTileTypeId;
@@ -91,6 +101,14 @@ class SettingsState extends ChangeNotifier {
// Load tile providers (default to built-in providers if none saved)
await _loadTileProviders(prefs);
// Load follow-me mode
if (prefs.containsKey(_followMeModePrefsKey)) {
final modeIndex = prefs.getInt(_followMeModePrefsKey) ?? 0;
if (modeIndex >= 0 && modeIndex < FollowMeMode.values.length) {
_followMeMode = FollowMeMode.values[modeIndex];
}
}
// Load selected tile type (default to first available)
_selectedTileTypeId = prefs.getString(_selectedTileTypePrefsKey) ?? '';
if (_selectedTileTypeId.isEmpty || selectedTileType == null) {
@@ -211,5 +229,14 @@ class SettingsState extends ChangeNotifier {
notifyListeners();
}
/// Set follow-me mode
Future<void> setFollowMeMode(FollowMeMode mode) async {
if (_followMeMode != mode) {
_followMeMode = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_followMeModePrefsKey, mode.index);
notifyListeners();
}
}
}

View File

@@ -5,20 +5,16 @@ import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import '../../dev_config.dart';
import '../../screens/home_screen.dart' show FollowMeMode;
import '../../app_state.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<Position>? _positionSub;
LatLng? _currentLatLng;
FollowMeMode _currentFollowMeMode = FollowMeMode.off;
/// Get the current GPS location (if available)
LatLng? get currentLocation => _currentLatLng;
/// Get the current follow-me mode
FollowMeMode get currentFollowMeMode => _currentFollowMeMode;
/// Initialize GPS location tracking
Future<void> initializeLocation() async {
@@ -48,8 +44,6 @@ class GpsController {
required FollowMeMode oldMode,
required AnimatedMapController controller,
}) {
// Update the stored follow-me mode
_currentFollowMeMode = newMode;
debugPrint('[GpsController] Follow-me mode changed: $oldMode$newMode');
// Only act when follow-me is first enabled and we have a current location
@@ -84,6 +78,7 @@ class GpsController {
/// Process GPS position updates and handle follow-me animations
void processPositionUpdate({
required Position position,
required FollowMeMode followMeMode,
required AnimatedMapController controller,
required VoidCallback onLocationUpdated,
}) {
@@ -93,12 +88,12 @@ class GpsController {
// Notify that location was updated (for setState, etc.)
onLocationUpdated();
// Handle follow-me animations if enabled - use current stored mode, not parameter
if (_currentFollowMeMode != FollowMeMode.off) {
debugPrint('[GpsController] GPS position update: ${latLng.latitude}, ${latLng.longitude}, follow-me: $_currentFollowMeMode');
// Handle follow-me animations if enabled - use current mode from app state
if (followMeMode != FollowMeMode.off) {
debugPrint('[GpsController] GPS position update: ${latLng.latitude}, ${latLng.longitude}, follow-me: $followMeMode');
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
if (_currentFollowMeMode == FollowMeMode.northUp) {
if (followMeMode == FollowMeMode.northUp) {
// Follow position only, keep current rotation
controller.animateTo(
dest: latLng,
@@ -106,7 +101,7 @@ class GpsController {
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
} else if (_currentFollowMeMode == FollowMeMode.rotating) {
} else if (followMeMode == FollowMeMode.rotating) {
// Follow position and rotation based on heading
final heading = position.heading;
final speed = position.speed; // Speed in m/s
@@ -135,10 +130,8 @@ class GpsController {
required FollowMeMode followMeMode,
required AnimatedMapController controller,
required VoidCallback onLocationUpdated,
required FollowMeMode Function() getCurrentFollowMeMode,
}) async {
// Store the initial follow-me mode
_currentFollowMeMode = followMeMode;
final perm = await Geolocator.requestPermission();
if (perm == LocationPermission.denied ||
perm == LocationPermission.deniedForever) {
@@ -147,8 +140,11 @@ class GpsController {
}
_positionSub = Geolocator.getPositionStream().listen((Position position) {
// Get the current follow-me mode from the app state each time
final currentFollowMeMode = getCurrentFollowMeMode();
processPositionUpdate(
position: position,
followMeMode: currentFollowMeMode,
controller: controller,
onLocationUpdated: onLocationUpdated,
);

View File

@@ -4,9 +4,9 @@ import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../dev_config.dart';
import '../../screens/home_screen.dart' show FollowMeMode;
/// Manages map position persistence, initial positioning, and follow-me mode storage.
/// Manages map position persistence and initial positioning.
/// Handles saving/loading last map position and moving to initial locations.
class MapPositionManager {
LatLng? _initialLocation;
@@ -89,33 +89,7 @@ class MapPositionManager {
}
}
/// Save the follow-me mode to persistent storage
static Future<void> saveFollowMeMode(FollowMeMode mode) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(kFollowMeModeKey, mode.index);
debugPrint('[MapPositionManager] Saved follow-me mode: $mode');
} catch (e) {
debugPrint('[MapPositionManager] Failed to save follow-me mode: $e');
}
}
/// Load the follow-me mode from persistent storage
static Future<FollowMeMode> loadFollowMeMode() async {
try {
final prefs = await SharedPreferences.getInstance();
final modeIndex = prefs.getInt(kFollowMeModeKey);
if (modeIndex != null && modeIndex < FollowMeMode.values.length) {
final mode = FollowMeMode.values[modeIndex];
debugPrint('[MapPositionManager] Loaded follow-me mode: $mode');
return mode;
}
} catch (e) {
debugPrint('[MapPositionManager] Failed to load follow-me mode: $e');
}
// Default to northUp if no saved mode
return FollowMeMode.northUp;
}
/// Clear any stored map position (useful for recovery from invalid data)
static Future<void> clearStoredMapPosition() async {

View File

@@ -21,7 +21,7 @@ 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;
import '../app_state.dart' show FollowMeMode;
class MapView extends StatefulWidget {
final AnimatedMapController controller;
@@ -78,6 +78,18 @@ class MapViewState extends State<MapView> {
followMeMode: widget.followMeMode,
controller: _controller,
onLocationUpdated: () => setState(() {}),
getCurrentFollowMeMode: () {
// Use mounted check to avoid calling context when widget is disposed
if (mounted) {
try {
return context.read<AppState>().followMeMode;
} catch (e) {
debugPrint('[MapView] Could not read AppState, defaulting to off: $e');
return FollowMeMode.off;
}
}
return FollowMeMode.off;
},
);
// Fetch initial cameras
@@ -111,12 +123,6 @@ class MapViewState extends State<MapView> {
}
/// Expose static methods from MapPositionManager for external access
static Future<void> saveFollowMeMode(FollowMeMode mode) =>
MapPositionManager.saveFollowMeMode(mode);
static Future<FollowMeMode> loadFollowMeMode() =>
MapPositionManager.loadFollowMeMode();
static Future<void> clearStoredMapPosition() =>
MapPositionManager.clearStoredMapPosition();