From df5e26f78d7025a167d9ea6c7dc6745b76947fa8 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 21 Aug 2025 18:39:09 -0500 Subject: [PATCH] breakup app_state --- lib/app_state.dart | 538 ++++-------------- lib/services/auth_service.dart | 3 +- .../map_data_submodules/tiles_from_local.dart | 30 +- lib/services/offline_area_service.dart | 81 ++- lib/state/auth_state.dart | 140 +++++ lib/state/profile_state.dart | 87 +++ lib/state/session_state.dart | 61 ++ lib/state/settings_state.dart | 80 +++ lib/state/upload_queue_state.dart | 169 ++++++ 9 files changed, 767 insertions(+), 422 deletions(-) create mode 100644 lib/state/auth_state.dart create mode 100644 lib/state/profile_state.dart create mode 100644 lib/state/session_state.dart create mode 100644 lib/state/settings_state.dart create mode 100644 lib/state/upload_queue_state.dart diff --git a/lib/app_state.dart b/lib/app_state.dart index 12a8b4b..cfa9c1d 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -1,327 +1,133 @@ -import 'dart:convert'; -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'models/camera_profile.dart'; import 'models/pending_upload.dart'; -import 'models/osm_camera_node.dart'; -import 'services/auth_service.dart'; -import 'services/uploader.dart'; -import 'services/profile_service.dart'; -import 'services/camera_cache.dart'; -import 'widgets/tile_provider_with_cache.dart'; -import 'widgets/camera_provider_with_cache.dart'; - -// Enum for upload mode (Production, OSM Sandbox, Simulate) -enum UploadMode { production, sandbox, simulate } - -// ------------------ AddCameraSession ------------------ -class AddCameraSession { - AddCameraSession({required this.profile, this.directionDegrees = 0}); - CameraProfile profile; - double directionDegrees; - LatLng? target; -} +import 'services/offline_area_service.dart'; +import 'state/auth_state.dart'; +import 'state/profile_state.dart'; +import 'state/session_state.dart'; +import 'state/settings_state.dart'; +import 'state/upload_queue_state.dart'; +// Re-export types for backward compatibility +export 'state/settings_state.dart' show UploadMode; +export 'state/session_state.dart' show AddCameraSession; // ------------------ AppState ------------------ class AppState extends ChangeNotifier { static late AppState instance; + + // State modules + late final AuthState _authState; + late final ProfileState _profileState; + late final SessionState _sessionState; + late final SettingsState _settingsState; + late final UploadQueueState _uploadQueueState; + + bool _isInitialized = false; + AppState() { instance = this; + _authState = AuthState(); + _profileState = ProfileState(); + _sessionState = SessionState(); + _settingsState = SettingsState(); + _uploadQueueState = UploadQueueState(); + + // Set up state change listeners + _authState.addListener(_onStateChanged); + _profileState.addListener(_onStateChanged); + _sessionState.addListener(_onStateChanged); + _settingsState.addListener(_onStateChanged); + _uploadQueueState.addListener(_onStateChanged); + _init(); } - // ------------------- Offline Mode ------------------- - static const String _offlineModePrefsKey = 'offline_mode'; - bool _offlineMode = false; - bool get offlineMode => _offlineMode; - Future setOfflineMode(bool enabled) async { - final wasOffline = _offlineMode; - _offlineMode = enabled; - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_offlineModePrefsKey, enabled); - if (wasOffline && !enabled) { - // Transitioning from offline to online: clear tile cache! - TileProviderWithCache.clearCache(); - _startUploader(); // Resume upload queue processing as we leave offline mode - } - notifyListeners(); - } - - final _auth = AuthService(); - String? _username; - - bool _isInitialized = false; + // Getters that delegate to individual state modules bool get isInitialized => _isInitialized; + + // Auth state + bool get isLoggedIn => _authState.isLoggedIn; + String get username => _authState.username; + + // Profile state + List get profiles => _profileState.profiles; + List get enabledProfiles => _profileState.enabledProfiles; + bool isEnabled(CameraProfile p) => _profileState.isEnabled(p); + + // Session state + AddCameraSession? get session => _sessionState.session; + + // Settings state + bool get offlineMode => _settingsState.offlineMode; + int get maxCameras => _settingsState.maxCameras; + UploadMode get uploadMode => _settingsState.uploadMode; + + // Upload queue state + int get pendingCount => _uploadQueueState.pendingCount; + List get pendingUploads => _uploadQueueState.pendingUploads; - final List _profiles = []; - final Set _enabled = {}; - static const String _enabledPrefsKey = 'enabled_profiles'; - static const String _maxCamerasPrefsKey = 'max_cameras'; - - // Maximum number of cameras fetched/drawn - int _maxCameras = 250; - int get maxCameras => _maxCameras; - set maxCameras(int n) { - if (n < 10) n = 10; // minimum - _maxCameras = n; - SharedPreferences.getInstance().then((prefs) { - prefs.setInt(_maxCamerasPrefsKey, n); - }); + void _onStateChanged() { notifyListeners(); } - // Upload mode: production, sandbox, or simulate (in-memory, no uploads) - UploadMode _uploadMode = UploadMode.simulate; - static const String _uploadModePrefsKey = 'upload_mode'; - UploadMode get uploadMode => _uploadMode; - Future setUploadMode(UploadMode mode) async { - _uploadMode = mode; - // Update AuthService to match new mode - _auth.setUploadMode(mode); - // Refresh user display for active mode, validating token - try { - if (await _auth.isLoggedIn()) { - print('AppState: Switching mode, token exists; validating...'); - final isValid = await validateToken(); - if (isValid) { - print("AppState: Switching mode; fetching username for $mode..."); - _username = await _auth.login(); - if (_username != null) { - print("AppState: Switched mode, now logged in as $_username"); - } else { - print('AppState: Switched mode but failed to retrieve username'); - } - } else { - print('AppState: Switching mode, token invalid—auto-logout.'); - await logout(); // This clears _username also. - } - } else { - _username = null; - print("AppState: Mode change: not logged in in $mode"); - } - } catch (e) { - _username = null; - print("AppState: Mode change user restoration error: $e"); - } - final prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_uploadModePrefsKey, mode.index); - print("AppState: Upload mode set to $mode"); - notifyListeners(); - } - - // For legacy bool test mode - static const String _legacyTestModePrefsKey = 'test_mode'; - - AddCameraSession? _session; - AddCameraSession? get session => _session; - final List _queue = []; - Timer? _uploadTimer; - - bool get isLoggedIn => _username != null; - String get username => _username ?? ''; - // ---------- Init ---------- Future _init() async { - // Initialize profiles: built-in + custom - _profiles.add(CameraProfile.alpr()); - _profiles.addAll(await ProfileService().load()); - - // Load enabled profile IDs and upload/test mode from prefs - final prefs = await SharedPreferences.getInstance(); - final enabledIds = prefs.getStringList(_enabledPrefsKey); - if (enabledIds != null && enabledIds.isNotEmpty) { - // Restore enabled profiles by id - _enabled.addAll(_profiles.where((p) => enabledIds.contains(p.id))); - } else { - // By default, all are enabled - _enabled.addAll(_profiles); - } - // Upload mode loading (including migration from old test_mode bool) - if (prefs.containsKey(_uploadModePrefsKey)) { - final idx = prefs.getInt(_uploadModePrefsKey) ?? 0; - if (idx >= 0 && idx < UploadMode.values.length) { - _uploadMode = UploadMode.values[idx]; - } - } else if (prefs.containsKey(_legacyTestModePrefsKey)) { - // migrate legacy test_mode (true->simulate, false->prod) - final legacy = prefs.getBool(_legacyTestModePrefsKey) ?? false; - _uploadMode = legacy ? UploadMode.simulate : UploadMode.production; - await prefs.remove(_legacyTestModePrefsKey); - await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); - } - // Max cameras - if (prefs.containsKey(_maxCamerasPrefsKey)) { - _maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250; - } - // Offline mode loading - if (prefs.containsKey(_offlineModePrefsKey)) { - _offlineMode = prefs.getBool(_offlineModePrefsKey) ?? false; - } - // Ensure AuthService follows loaded mode - _auth.setUploadMode(_uploadMode); - print('AppState: AuthService mode now updated to $_uploadMode'); - - await _loadQueue(); + // Initialize all state modules + await _settingsState.init(); + await _profileState.init(); + await _uploadQueueState.init(); + await _authState.init(_settingsState.uploadMode); - // Check if we're already logged in and get username - try { - if (await _auth.isLoggedIn()) { - print('AppState: User appears to be logged in, fetching username...'); - _username = await _auth.login(); - if (_username != null) { - print("AppState: Successfully retrieved username: $_username"); - } else { - print('AppState: Failed to retrieve username despite being logged in'); - } - } else { - print('AppState: User is not logged in'); - } - } catch (e) { - print("AppState: Error during auth initialization: $e"); - } + // Initialize OfflineAreaService to ensure offline areas are loaded + await OfflineAreaService().ensureInitialized(); + // Start uploader if conditions are met _startUploader(); + _isInitialized = true; notifyListeners(); } - // ---------- Auth ---------- + // ---------- Auth Methods ---------- Future login() async { - try { - print('AppState: Starting login process...'); - _username = await _auth.login(); - if (_username != null) { - print("AppState: Login successful for user: $_username"); - } else { - print('AppState: Login failed - no username returned'); - } - } catch (e) { - print("AppState: Login error: $e"); - _username = null; - } - notifyListeners(); + await _authState.login(); } Future logout() async { - await _auth.logout(); - _username = null; - notifyListeners(); + await _authState.logout(); } - // Add method to refresh auth state Future refreshAuthState() async { - try { - print('AppState: Refreshing auth state...'); - if (await _auth.isLoggedIn()) { - print('AppState: Token exists, fetching username...'); - _username = await _auth.login(); - if (_username != null) { - print("AppState: Auth refresh successful: $_username"); - } else { - print('AppState: Auth refresh failed - no username'); - } - } else { - print('AppState: No valid token found'); - _username = null; - } - } catch (e) { - print("AppState: Auth refresh error: $e"); - _username = null; - } - notifyListeners(); + await _authState.refreshAuthState(); } - // Force a completely fresh login (clears stored tokens) Future forceLogin() async { - try { - print('AppState: Starting forced fresh login...'); - _username = await _auth.forceLogin(); - if (_username != null) { - print("AppState: Forced login successful: $_username"); - } else { - print('AppState: Forced login failed - no username returned'); - } - } catch (e) { - print("AppState: Forced login error: $e"); - _username = null; - } - notifyListeners(); + await _authState.forceLogin(); } - // Validate current token/credentials Future validateToken() async { - try { - return await _auth.isLoggedIn(); - } catch (e) { - print("AppState: Token validation error: $e"); - return false; - } + return await _authState.validateToken(); } - // ---------- Profiles ---------- - List get profiles => List.unmodifiable(_profiles); - bool isEnabled(CameraProfile p) => _enabled.contains(p); - List get enabledProfiles => - _profiles.where(isEnabled).toList(growable: false); + // ---------- Profile Methods ---------- void toggleProfile(CameraProfile p, bool e) { - if (e) { - _enabled.add(p); - } else { - _enabled.remove(p); - // Safety: Always have at least one enabled profile - if (_enabled.isEmpty) { - final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first); - _enabled.add(builtIn); - } - } - _saveEnabledProfiles(); - notifyListeners(); + _profileState.toggleProfile(p, e); } void addOrUpdateProfile(CameraProfile p) { - final idx = _profiles.indexWhere((x) => x.id == p.id); - if (idx >= 0) { - _profiles[idx] = p; - } else { - _profiles.add(p); - _enabled.add(p); - _saveEnabledProfiles(); - } - ProfileService().save(_profiles); - notifyListeners(); + _profileState.addOrUpdateProfile(p); } void deleteProfile(CameraProfile p) { - if (p.builtin) return; - _enabled.remove(p); - _profiles.removeWhere((x) => x.id == p.id); - // Safety: Always have at least one enabled profile - if (_enabled.isEmpty) { - final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first); - _enabled.add(builtIn); - } - _saveEnabledProfiles(); - ProfileService().save(_profiles); - notifyListeners(); + _profileState.deleteProfile(p); } - // Save enabled profile IDs to disk - Future _saveEnabledProfiles() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setStringList( - _enabledPrefsKey, - _enabled.map((p) => p.id).toList(), - ); - } - - // ---------- Add‑camera session ---------- + // ---------- Session Methods ---------- void startAddSession() { - _session = AddCameraSession(profile: enabledProfiles.first); - notifyListeners(); + _sessionState.startAddSession(enabledProfiles); } void updateSession({ @@ -329,169 +135,77 @@ class AppState extends ChangeNotifier { CameraProfile? profile, LatLng? target, }) { - if (_session == null) return; - - bool dirty = false; - if (directionDeg != null && directionDeg != _session!.directionDegrees) { - _session!.directionDegrees = directionDeg; - dirty = true; - } - if (profile != null && profile != _session!.profile) { - _session!.profile = profile; - dirty = true; - } - if (target != null) { - _session!.target = target; - dirty = true; - } - if (dirty) notifyListeners(); // <-- slider & map update + _sessionState.updateSession( + directionDeg: directionDeg, + profile: profile, + target: target, + ); } void cancelSession() { - _session = null; - notifyListeners(); + _sessionState.cancelSession(); } void commitSession() { - if (_session?.target == null) return; - - // Create the pending upload - final upload = PendingUpload( - coord: _session!.target!, - direction: _session!.directionDegrees, - profile: _session!.profile, - ); - - _queue.add(upload); - _saveQueue(); - - // Add to camera cache immediately so it shows on the map - // Create a temporary node with a negative ID (to distinguish from real OSM nodes) - // Using timestamp as negative ID to ensure uniqueness - final tempId = -DateTime.now().millisecondsSinceEpoch; - final tags = Map.from(upload.profile.tags); - tags['direction'] = upload.direction.toStringAsFixed(0); - tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction - - final tempNode = OsmCameraNode( - id: tempId, - coord: upload.coord, - tags: tags, - ); - - CameraCache.instance.addOrUpdate([tempNode]); - // Notify camera provider to update the map - CameraProviderWithCache.instance.notifyListeners(); - - _session = null; - - // Restart uploader when new items are added - _startUploader(); - - notifyListeners(); + final session = _sessionState.commitSession(); + if (session != null) { + _uploadQueueState.addFromSession(session); + _startUploader(); + } } - // ---------- Queue persistence ---------- - Future _saveQueue() async { - final prefs = await SharedPreferences.getInstance(); - final jsonList = _queue.map((e) => e.toJson()).toList(); - await prefs.setString('queue', jsonEncode(jsonList)); + // ---------- Settings Methods ---------- + Future setOfflineMode(bool enabled) async { + await _settingsState.setOfflineMode(enabled); + if (!enabled) { + _startUploader(); // Resume upload queue processing as we leave offline mode + } else { + _uploadQueueState.stopUploader(); // Stop uploader in offline mode + } } - Future _loadQueue() async { - final prefs = await SharedPreferences.getInstance(); - final jsonStr = prefs.getString('queue'); - if (jsonStr == null) return; - final list = jsonDecode(jsonStr) as List; - _queue - ..clear() - ..addAll(list.map((e) => PendingUpload.fromJson(e))); + set maxCameras(int n) { + _settingsState.maxCameras = n; } - // ---------- Uploader ---------- - void _startUploader() { - _uploadTimer?.cancel(); - - // No uploads without auth or queue, or if offline mode is enabled. - if (_queue.isEmpty || _offlineMode) return; - - _uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async { - if (_queue.isEmpty || _offlineMode) { - _uploadTimer?.cancel(); - return; - } - - // Find the first queue item that is NOT in error state and act on that - final item = _queue.where((pu) => !pu.error).cast().firstOrNull; - if (item == null) return; - - // Retrieve access after every tick (accounts for re-login) - final access = await _auth.getAccessToken(); - if (access == null) return; // not logged in - - bool ok; - if (_uploadMode == UploadMode.simulate) { - // Simulate successful upload without calling real API - print("AppState: UploadMode.simulate - simulating upload for ${item.coord}"); - await Future.delayed(const Duration(seconds: 1)); // Simulate network delay - ok = true; - print('AppState: Simulated upload successful'); - } else { - // Real upload -- pass uploadMode so uploader can switch between prod and sandbox - final up = Uploader(access, () { - _queue.remove(item); - _saveQueue(); - notifyListeners(); - }, uploadMode: _uploadMode); - ok = await up.upload(item); - } - - if (ok && _uploadMode == UploadMode.simulate) { - // Remove manually for simulate mode - _queue.remove(item); - _saveQueue(); - notifyListeners(); - } - if (!ok) { - item.attempts++; - if (item.attempts >= 3) { - // Mark as error and stop the uploader. User can manually retry. - item.error = true; - _saveQueue(); - notifyListeners(); - _uploadTimer?.cancel(); - } else { - await Future.delayed(const Duration(seconds: 20)); - } - } - }); + Future setUploadMode(UploadMode mode) async { + await _settingsState.setUploadMode(mode); + await _authState.onUploadModeChanged(mode); + _startUploader(); // Restart uploader with new mode } - // ---------- Exposed getters ---------- - int get pendingCount => _queue.length; - List get pendingUploads => List.unmodifiable(_queue); - - // ---------- Queue management ---------- + // ---------- Queue Methods ---------- void clearQueue() { - print("AppState: Clearing upload queue (${_queue.length} items)"); - _queue.clear(); - _saveQueue(); - notifyListeners(); + _uploadQueueState.clearQueue(); } void removeFromQueue(PendingUpload upload) { - print("AppState: Removing upload from queue: ${upload.coord}"); - _queue.remove(upload); - _saveQueue(); - notifyListeners(); + _uploadQueueState.removeFromQueue(upload); } - // Retry a failed upload (clear error and attempts, then try uploading again) void retryUpload(PendingUpload upload) { - upload.error = false; - upload.attempts = 0; - _saveQueue(); - notifyListeners(); + _uploadQueueState.retryUpload(upload); _startUploader(); // resume uploader if not busy } + + // ---------- Private Methods ---------- + void _startUploader() { + _uploadQueueState.startUploader( + offlineMode: offlineMode, + uploadMode: uploadMode, + getAccessToken: _authState.getAccessToken, + ); + } + + @override + void dispose() { + _authState.removeListener(_onStateChanged); + _profileState.removeListener(_onStateChanged); + _sessionState.removeListener(_onStateChanged); + _settingsState.removeListener(_onStateChanged); + _uploadQueueState.removeListener(_onStateChanged); + + _uploadQueueState.dispose(); + super.dispose(); + } } diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 30f5898..eabeb5a 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -8,9 +8,8 @@ import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; /// Handles PKCE OAuth login with OpenStreetMap. -import '../app_state.dart'; - import '../keys.dart'; +import '../app_state.dart' show UploadMode; class AuthService { // Both client IDs from keys.dart diff --git a/lib/services/map_data_submodules/tiles_from_local.dart b/lib/services/map_data_submodules/tiles_from_local.dart index 16d78f9..196f56a 100644 --- a/lib/services/map_data_submodules/tiles_from_local.dart +++ b/lib/services/map_data_submodules/tiles_from_local.dart @@ -6,25 +6,47 @@ import '../offline_areas/offline_tile_utils.dart'; /// Fetch a tile from the newest offline area that plausibly contains it, or throw if not found. Future> fetchLocalTile({required int z, required int x, required int y}) async { - final areas = OfflineAreaService().offlineAreas; + final offlineService = OfflineAreaService(); + await offlineService.ensureInitialized(); + final areas = offlineService.offlineAreas; final List<_AreaTileMatch> candidates = []; + print('[fetchLocalTile] Looking for tile $z/$x/$y in ${areas.length} offline areas'); + for (final area in areas) { - if (area.status != OfflineAreaStatus.complete) continue; - if (z < area.minZoom || z > area.maxZoom) continue; + print('[fetchLocalTile] Checking area: ${area.id}, status: ${area.status}, zoom: ${area.minZoom}-${area.maxZoom}'); + + if (area.status != OfflineAreaStatus.complete) { + print('[fetchLocalTile] Skipping area ${area.id} - status not complete: ${area.status}'); + continue; + } + + if (z < area.minZoom || z > area.maxZoom) { + print('[fetchLocalTile] Skipping area ${area.id} - zoom $z outside range ${area.minZoom}-${area.maxZoom}'); + continue; + } // Get tile coverage for area at this zoom only final coveredTiles = computeTileList(area.bounds, z, z); final hasTile = coveredTiles.any((tile) => tile[0] == z && tile[1] == x && tile[2] == y); + print('[fetchLocalTile] Area ${area.id} covers ${coveredTiles.length} tiles at zoom $z, contains target tile: $hasTile'); + if (hasTile) { final tilePath = _tilePath(area.directory, z, x, y); final file = File(tilePath); - if (await file.exists()) { + final exists = await file.exists(); + print('[fetchLocalTile] Tile file path: $tilePath, exists: $exists'); + + if (exists) { final stat = await file.stat(); candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified)); + print('[fetchLocalTile] Added candidate from area ${area.id}'); } } } + + print('[fetchLocalTile] Found ${candidates.length} candidates for tile $z/$x/$y'); + if (candidates.isEmpty) { throw Exception('Tile $z/$x/$y not found in any offline area'); } diff --git a/lib/services/offline_area_service.dart b/lib/services/offline_area_service.dart index 8064722..626e938 100644 --- a/lib/services/offline_area_service.dart +++ b/lib/services/offline_area_service.dart @@ -17,12 +17,31 @@ import 'package:flock_map_app/dev_config.dart'; class OfflineAreaService { static final OfflineAreaService _instance = OfflineAreaService._(); factory OfflineAreaService() => _instance; - OfflineAreaService._() { - _loadAreasFromDisk().then((_) => _ensureAndAutoDownloadWorldArea()); - } + + bool _initialized = false; + Future? _initializationFuture; + + OfflineAreaService._(); final List _areas = []; List get offlineAreas => List.unmodifiable(_areas); + + /// Ensure the service is initialized (areas loaded from disk) + Future ensureInitialized() async { + if (_initialized) return; + + _initializationFuture ??= _initialize(); + await _initializationFuture; + } + + Future _initialize() async { + if (_initialized) return; + + await _loadAreasFromDisk(); + await _ensureAndAutoDownloadWorldArea(); + _initialized = true; + print('OfflineAreaService: Initialization complete. Found ${_areas.length} offline areas.'); + } Future getOfflineAreaDir() async { final dir = await getApplicationDocumentsDirectory(); @@ -39,6 +58,7 @@ class OfflineAreaService { } Future getAreaSizeBytes(OfflineArea area) async { + print('[getAreaSizeBytes] Starting for area ${area.id}, status: ${area.status}'); int total = 0; final dir = Directory(area.directory); if (await dir.exists()) { @@ -49,14 +69,30 @@ class OfflineAreaService { } } area.sizeBytes = total; + print('[getAreaSizeBytes] Before saveAreasToDisk, area ${area.id} status: ${area.status}'); await saveAreasToDisk(); + print('[getAreaSizeBytes] After saveAreasToDisk, area ${area.id} status: ${area.status}'); return total; } Future saveAreasToDisk() async { try { final file = await _getMetadataPath(); - final content = jsonEncode(_areas.map((a) => a.toJson()).toList()); + final offlineDir = await getOfflineAreaDir(); + + // Convert areas to JSON with relative paths for portability + final areaJsonList = _areas.map((area) { + final json = area.toJson(); + // Convert absolute path to relative path for storage + if (json['directory'].toString().startsWith(offlineDir.path)) { + final relativePath = json['directory'].toString().replaceFirst('${offlineDir.path}/', ''); + json['directory'] = relativePath; + print('[OfflineAreaService] Saving area ${area.id} with relative path: $relativePath'); + } + return json; + }).toList(); + + final content = jsonEncode(areaJsonList); await file.writeAsString(content); } catch (e) { debugPrint('Failed to save offline areas: $e'); @@ -77,12 +113,49 @@ class OfflineAreaService { return; } _areas.clear(); + for (final areaJson in data) { + // Migrate stored directory paths to be relative for portability + String storedDir = areaJson['directory']; + String relativePath = storedDir; + + // If it's an absolute path, extract just the folder name + if (storedDir.startsWith('/')) { + if (storedDir.contains('/offline_areas/')) { + final parts = storedDir.split('/offline_areas/'); + if (parts.length == 2) { + relativePath = parts[1]; // Just the folder name (e.g., "world" or "2025-08-19...") + } + } + } + + // Always construct absolute path at runtime + final offlineDir = await getOfflineAreaDir(); + final fullPath = '${offlineDir.path}/$relativePath'; + + print('[OfflineAreaService] Area ${areaJson['id']}: stored="$storedDir", relative="$relativePath", full="$fullPath"'); + + // Update the JSON to use the full path for this session + areaJson['directory'] = fullPath; + final area = OfflineArea.fromJson(areaJson); + if (!Directory(area.directory).existsSync()) { + print('[OfflineAreaService] Directory does not exist: ${area.directory}'); area.status = OfflineAreaStatus.error; } else { + print('[OfflineAreaService] Directory exists, getting size...'); + + // Reset error status if directory now exists (fixes areas that were previously broken due to path issues) + if (area.status == OfflineAreaStatus.error) { + print('[OfflineAreaService] Resetting error status to complete for area ${area.id} since directory now exists'); + area.status = OfflineAreaStatus.complete; + } + + print('[OfflineAreaService] Status before getAreaSizeBytes: ${area.status}'); getAreaSizeBytes(area); + print('[OfflineAreaService] Status after getAreaSizeBytes: ${area.status}'); + print('[OfflineAreaService] Area ${area.id} loaded successfully'); } _areas.add(area); } diff --git a/lib/state/auth_state.dart b/lib/state/auth_state.dart new file mode 100644 index 0000000..076c260 --- /dev/null +++ b/lib/state/auth_state.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; + +import '../services/auth_service.dart'; +import 'settings_state.dart'; + +class AuthState extends ChangeNotifier { + final AuthService _auth = AuthService(); + String? _username; + + // Getters + bool get isLoggedIn => _username != null; + String get username => _username ?? ''; + AuthService get authService => _auth; + + // Initialize auth state and check existing login + Future init(UploadMode uploadMode) async { + _auth.setUploadMode(uploadMode); + + try { + if (await _auth.isLoggedIn()) { + print('AuthState: User appears to be logged in, fetching username...'); + _username = await _auth.login(); + if (_username != null) { + print("AuthState: Successfully retrieved username: $_username"); + } else { + print('AuthState: Failed to retrieve username despite being logged in'); + } + } else { + print('AuthState: User is not logged in'); + } + } catch (e) { + print("AuthState: Error during auth initialization: $e"); + } + } + + Future login() async { + try { + print('AuthState: Starting login process...'); + _username = await _auth.login(); + if (_username != null) { + print("AuthState: Login successful for user: $_username"); + } else { + print('AuthState: Login failed - no username returned'); + } + } catch (e) { + print("AuthState: Login error: $e"); + _username = null; + } + notifyListeners(); + } + + Future logout() async { + await _auth.logout(); + _username = null; + notifyListeners(); + } + + Future refreshAuthState() async { + try { + print('AuthState: Refreshing auth state...'); + if (await _auth.isLoggedIn()) { + print('AuthState: Token exists, fetching username...'); + _username = await _auth.login(); + if (_username != null) { + print("AuthState: Auth refresh successful: $_username"); + } else { + print('AuthState: Auth refresh failed - no username'); + } + } else { + print('AuthState: No valid token found'); + _username = null; + } + } catch (e) { + print("AuthState: Auth refresh error: $e"); + _username = null; + } + notifyListeners(); + } + + Future forceLogin() async { + try { + print('AuthState: Starting forced fresh login...'); + _username = await _auth.forceLogin(); + if (_username != null) { + print("AuthState: Forced login successful: $_username"); + } else { + print('AuthState: Forced login failed - no username returned'); + } + } catch (e) { + print("AuthState: Forced login error: $e"); + _username = null; + } + notifyListeners(); + } + + Future validateToken() async { + try { + return await _auth.isLoggedIn(); + } catch (e) { + print("AuthState: Token validation error: $e"); + return false; + } + } + + // Handle upload mode changes + Future onUploadModeChanged(UploadMode mode) async { + _auth.setUploadMode(mode); + + // Refresh user display for active mode, validating token + try { + if (await _auth.isLoggedIn()) { + print('AuthState: Switching mode, token exists; validating...'); + final isValid = await validateToken(); + if (isValid) { + print("AuthState: Switching mode; fetching username for $mode..."); + _username = await _auth.login(); + if (_username != null) { + print("AuthState: Switched mode, now logged in as $_username"); + } else { + print('AuthState: Switched mode but failed to retrieve username'); + } + } else { + print('AuthState: Switching mode, token invalid—auto-logout.'); + await logout(); // This clears _username also. + } + } else { + _username = null; + print("AuthState: Mode change: not logged in in $mode"); + } + } catch (e) { + _username = null; + print("AuthState: Mode change user restoration error: $e"); + } + notifyListeners(); + } + + Future getAccessToken() async { + return await _auth.getAccessToken(); + } +} \ No newline at end of file diff --git a/lib/state/profile_state.dart b/lib/state/profile_state.dart new file mode 100644 index 0000000..c847446 --- /dev/null +++ b/lib/state/profile_state.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/camera_profile.dart'; +import '../services/profile_service.dart'; + +class ProfileState extends ChangeNotifier { + static const String _enabledPrefsKey = 'enabled_profiles'; + + final List _profiles = []; + final Set _enabled = {}; + + // Getters + List get profiles => List.unmodifiable(_profiles); + bool isEnabled(CameraProfile p) => _enabled.contains(p); + List get enabledProfiles => + _profiles.where(isEnabled).toList(growable: false); + + // Initialize profiles from built-in and custom sources + Future init() async { + // Initialize profiles: built-in + custom + _profiles.add(CameraProfile.alpr()); + _profiles.addAll(await ProfileService().load()); + + // Load enabled profile IDs from prefs + final prefs = await SharedPreferences.getInstance(); + final enabledIds = prefs.getStringList(_enabledPrefsKey); + if (enabledIds != null && enabledIds.isNotEmpty) { + // Restore enabled profiles by id + _enabled.addAll(_profiles.where((p) => enabledIds.contains(p.id))); + } else { + // By default, all are enabled + _enabled.addAll(_profiles); + } + } + + void toggleProfile(CameraProfile p, bool e) { + if (e) { + _enabled.add(p); + } else { + _enabled.remove(p); + // Safety: Always have at least one enabled profile + if (_enabled.isEmpty) { + final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first); + _enabled.add(builtIn); + } + } + _saveEnabledProfiles(); + notifyListeners(); + } + + void addOrUpdateProfile(CameraProfile p) { + final idx = _profiles.indexWhere((x) => x.id == p.id); + if (idx >= 0) { + _profiles[idx] = p; + } else { + _profiles.add(p); + _enabled.add(p); + _saveEnabledProfiles(); + } + ProfileService().save(_profiles); + notifyListeners(); + } + + void deleteProfile(CameraProfile p) { + if (p.builtin) return; + _enabled.remove(p); + _profiles.removeWhere((x) => x.id == p.id); + // Safety: Always have at least one enabled profile + if (_enabled.isEmpty) { + final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first); + _enabled.add(builtIn); + } + _saveEnabledProfiles(); + ProfileService().save(_profiles); + notifyListeners(); + } + + // Save enabled profile IDs to disk + Future _saveEnabledProfiles() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + _enabledPrefsKey, + _enabled.map((p) => p.id).toList(), + ); + } +} \ No newline at end of file diff --git a/lib/state/session_state.dart b/lib/state/session_state.dart new file mode 100644 index 0000000..3c25c85 --- /dev/null +++ b/lib/state/session_state.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; + +import '../models/camera_profile.dart'; + +// ------------------ AddCameraSession ------------------ +class AddCameraSession { + AddCameraSession({required this.profile, this.directionDegrees = 0}); + CameraProfile profile; + double directionDegrees; + LatLng? target; +} + +class SessionState extends ChangeNotifier { + AddCameraSession? _session; + + // Getter + AddCameraSession? get session => _session; + + void startAddSession(List enabledProfiles) { + _session = AddCameraSession(profile: enabledProfiles.first); + notifyListeners(); + } + + void updateSession({ + double? directionDeg, + CameraProfile? profile, + LatLng? target, + }) { + if (_session == null) return; + + bool dirty = false; + if (directionDeg != null && directionDeg != _session!.directionDegrees) { + _session!.directionDegrees = directionDeg; + dirty = true; + } + if (profile != null && profile != _session!.profile) { + _session!.profile = profile; + dirty = true; + } + if (target != null) { + _session!.target = target; + dirty = true; + } + if (dirty) notifyListeners(); + } + + void cancelSession() { + _session = null; + notifyListeners(); + } + + AddCameraSession? commitSession() { + if (_session?.target == null) return null; + + final session = _session!; + _session = null; + notifyListeners(); + return session; + } +} \ No newline at end of file diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart new file mode 100644 index 0000000..67b24e4 --- /dev/null +++ b/lib/state/settings_state.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../widgets/tile_provider_with_cache.dart'; + +// Enum for upload mode (Production, OSM Sandbox, Simulate) +enum UploadMode { production, sandbox, simulate } + +class SettingsState extends ChangeNotifier { + static const String _offlineModePrefsKey = 'offline_mode'; + static const String _maxCamerasPrefsKey = 'max_cameras'; + static const String _uploadModePrefsKey = 'upload_mode'; + static const String _legacyTestModePrefsKey = 'test_mode'; + + bool _offlineMode = false; + int _maxCameras = 250; + UploadMode _uploadMode = UploadMode.simulate; + + // Getters + bool get offlineMode => _offlineMode; + int get maxCameras => _maxCameras; + UploadMode get uploadMode => _uploadMode; + + // Initialize settings from preferences + Future init() async { + final prefs = await SharedPreferences.getInstance(); + + // Load offline mode + _offlineMode = prefs.getBool(_offlineModePrefsKey) ?? false; + + // Load max cameras + if (prefs.containsKey(_maxCamerasPrefsKey)) { + _maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250; + } + + // Load upload mode (including migration from old test_mode bool) + if (prefs.containsKey(_uploadModePrefsKey)) { + final idx = prefs.getInt(_uploadModePrefsKey) ?? 0; + if (idx >= 0 && idx < UploadMode.values.length) { + _uploadMode = UploadMode.values[idx]; + } + } else if (prefs.containsKey(_legacyTestModePrefsKey)) { + // migrate legacy test_mode (true->simulate, false->prod) + final legacy = prefs.getBool(_legacyTestModePrefsKey) ?? false; + _uploadMode = legacy ? UploadMode.simulate : UploadMode.production; + await prefs.remove(_legacyTestModePrefsKey); + await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); + } + } + + Future setOfflineMode(bool enabled) async { + final wasOffline = _offlineMode; + _offlineMode = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_offlineModePrefsKey, enabled); + + if (wasOffline && !enabled) { + // Transitioning from offline to online: clear tile cache! + TileProviderWithCache.clearCache(); + } + + notifyListeners(); + } + + set maxCameras(int n) { + if (n < 10) n = 10; // minimum + _maxCameras = n; + SharedPreferences.getInstance().then((prefs) { + prefs.setInt(_maxCamerasPrefsKey, n); + }); + notifyListeners(); + } + + Future setUploadMode(UploadMode mode) async { + _uploadMode = mode; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_uploadModePrefsKey, mode.index); + notifyListeners(); + } +} \ No newline at end of file diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart new file mode 100644 index 0000000..9b67e11 --- /dev/null +++ b/lib/state/upload_queue_state.dart @@ -0,0 +1,169 @@ +import 'dart:convert'; +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/pending_upload.dart'; +import '../models/osm_camera_node.dart'; +import '../services/camera_cache.dart'; +import '../services/uploader.dart'; +import '../widgets/camera_provider_with_cache.dart'; +import 'settings_state.dart'; +import 'session_state.dart'; + +class UploadQueueState extends ChangeNotifier { + final List _queue = []; + Timer? _uploadTimer; + + // Getters + int get pendingCount => _queue.length; + List get pendingUploads => List.unmodifiable(_queue); + + // Initialize by loading queue from storage + Future init() async { + await _loadQueue(); + } + + // Add a completed session to the upload queue + void addFromSession(AddCameraSession session) { + final upload = PendingUpload( + coord: session.target!, + direction: session.directionDegrees, + profile: session.profile, + ); + + _queue.add(upload); + _saveQueue(); + + // Add to camera cache immediately so it shows on the map + // Create a temporary node with a negative ID (to distinguish from real OSM nodes) + // Using timestamp as negative ID to ensure uniqueness + final tempId = -DateTime.now().millisecondsSinceEpoch; + final tags = Map.from(upload.profile.tags); + tags['direction'] = upload.direction.toStringAsFixed(0); + tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction + + final tempNode = OsmCameraNode( + id: tempId, + coord: upload.coord, + tags: tags, + ); + + CameraCache.instance.addOrUpdate([tempNode]); + // Notify camera provider to update the map + CameraProviderWithCache.instance.notifyListeners(); + + notifyListeners(); + } + + void clearQueue() { + print("UploadQueueState: Clearing upload queue (${_queue.length} items)"); + _queue.clear(); + _saveQueue(); + notifyListeners(); + } + + void removeFromQueue(PendingUpload upload) { + print("UploadQueueState: Removing upload from queue: ${upload.coord}"); + _queue.remove(upload); + _saveQueue(); + notifyListeners(); + } + + void retryUpload(PendingUpload upload) { + upload.error = false; + upload.attempts = 0; + _saveQueue(); + notifyListeners(); + } + + // Start the upload processing loop + void startUploader({ + required bool offlineMode, + required UploadMode uploadMode, + required Future Function() getAccessToken, + }) { + _uploadTimer?.cancel(); + + // No uploads without queue, or if offline mode is enabled. + if (_queue.isEmpty || offlineMode) return; + + _uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async { + if (_queue.isEmpty || offlineMode) { + _uploadTimer?.cancel(); + return; + } + + // Find the first queue item that is NOT in error state and act on that + final item = _queue.where((pu) => !pu.error).cast().firstOrNull; + if (item == null) return; + + // Retrieve access after every tick (accounts for re-login) + final access = await getAccessToken(); + if (access == null) return; // not logged in + + bool ok; + if (uploadMode == UploadMode.simulate) { + // Simulate successful upload without calling real API + print("UploadQueueState: UploadMode.simulate - simulating upload for ${item.coord}"); + await Future.delayed(const Duration(seconds: 1)); // Simulate network delay + ok = true; + print('UploadQueueState: Simulated upload successful'); + } else { + // Real upload -- pass uploadMode so uploader can switch between prod and sandbox + final up = Uploader(access, () { + _queue.remove(item); + _saveQueue(); + notifyListeners(); + }, uploadMode: uploadMode); + ok = await up.upload(item); + } + + if (ok && uploadMode == UploadMode.simulate) { + // Remove manually for simulate mode + _queue.remove(item); + _saveQueue(); + notifyListeners(); + } + if (!ok) { + item.attempts++; + if (item.attempts >= 3) { + // Mark as error and stop the uploader. User can manually retry. + item.error = true; + _saveQueue(); + notifyListeners(); + _uploadTimer?.cancel(); + } else { + await Future.delayed(const Duration(seconds: 20)); + } + } + }); + } + + void stopUploader() { + _uploadTimer?.cancel(); + } + + // ---------- Queue persistence ---------- + Future _saveQueue() async { + final prefs = await SharedPreferences.getInstance(); + final jsonList = _queue.map((e) => e.toJson()).toList(); + await prefs.setString('queue', jsonEncode(jsonList)); + } + + Future _loadQueue() async { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = prefs.getString('queue'); + if (jsonStr == null) return; + final list = jsonDecode(jsonStr) as List; + _queue + ..clear() + ..addAll(list.map((e) => PendingUpload.fromJson(e))); + } + + @override + void dispose() { + _uploadTimer?.cancel(); + super.dispose(); + } +} \ No newline at end of file