mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
breakup app_state
This commit is contained in:
@@ -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<void> 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<CameraProfile> get profiles => _profileState.profiles;
|
||||
List<CameraProfile> 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<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
|
||||
|
||||
final List<CameraProfile> _profiles = [];
|
||||
final Set<CameraProfile> _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<void> 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<PendingUpload> _queue = [];
|
||||
Timer? _uploadTimer;
|
||||
|
||||
bool get isLoggedIn => _username != null;
|
||||
String get username => _username ?? '';
|
||||
|
||||
// ---------- Init ----------
|
||||
Future<void> _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<void> 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<void> logout() async {
|
||||
await _auth.logout();
|
||||
_username = null;
|
||||
notifyListeners();
|
||||
await _authState.logout();
|
||||
}
|
||||
|
||||
// Add method to refresh auth state
|
||||
Future<void> 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<void> 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<bool> validateToken() async {
|
||||
try {
|
||||
return await _auth.isLoggedIn();
|
||||
} catch (e) {
|
||||
print("AppState: Token validation error: $e");
|
||||
return false;
|
||||
}
|
||||
return await _authState.validateToken();
|
||||
}
|
||||
|
||||
// ---------- Profiles ----------
|
||||
List<CameraProfile> get profiles => List.unmodifiable(_profiles);
|
||||
bool isEnabled(CameraProfile p) => _enabled.contains(p);
|
||||
List<CameraProfile> 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<void> _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<String, String>.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<void> _saveQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = _queue.map((e) => e.toJson()).toList();
|
||||
await prefs.setString('queue', jsonEncode(jsonList));
|
||||
// ---------- Settings Methods ----------
|
||||
Future<void> 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<void> _loadQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString('queue');
|
||||
if (jsonStr == null) return;
|
||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||
_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<PendingUpload?>().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<void> 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<PendingUpload> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<List<int>> 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');
|
||||
}
|
||||
|
||||
@@ -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<void>? _initializationFuture;
|
||||
|
||||
OfflineAreaService._();
|
||||
|
||||
final List<OfflineArea> _areas = [];
|
||||
List<OfflineArea> get offlineAreas => List.unmodifiable(_areas);
|
||||
|
||||
/// Ensure the service is initialized (areas loaded from disk)
|
||||
Future<void> ensureInitialized() async {
|
||||
if (_initialized) return;
|
||||
|
||||
_initializationFuture ??= _initialize();
|
||||
await _initializationFuture;
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
await _loadAreasFromDisk();
|
||||
await _ensureAndAutoDownloadWorldArea();
|
||||
_initialized = true;
|
||||
print('OfflineAreaService: Initialization complete. Found ${_areas.length} offline areas.');
|
||||
}
|
||||
|
||||
Future<Directory> getOfflineAreaDir() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
@@ -39,6 +58,7 @@ class OfflineAreaService {
|
||||
}
|
||||
|
||||
Future<int> 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<void> 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);
|
||||
}
|
||||
|
||||
140
lib/state/auth_state.dart
Normal file
140
lib/state/auth_state.dart
Normal file
@@ -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<void> 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<void> 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<void> logout() async {
|
||||
await _auth.logout();
|
||||
_username = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> 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<void> 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<bool> validateToken() async {
|
||||
try {
|
||||
return await _auth.isLoggedIn();
|
||||
} catch (e) {
|
||||
print("AuthState: Token validation error: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle upload mode changes
|
||||
Future<void> 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<String?> getAccessToken() async {
|
||||
return await _auth.getAccessToken();
|
||||
}
|
||||
}
|
||||
87
lib/state/profile_state.dart
Normal file
87
lib/state/profile_state.dart
Normal file
@@ -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<CameraProfile> _profiles = [];
|
||||
final Set<CameraProfile> _enabled = {};
|
||||
|
||||
// Getters
|
||||
List<CameraProfile> get profiles => List.unmodifiable(_profiles);
|
||||
bool isEnabled(CameraProfile p) => _enabled.contains(p);
|
||||
List<CameraProfile> get enabledProfiles =>
|
||||
_profiles.where(isEnabled).toList(growable: false);
|
||||
|
||||
// Initialize profiles from built-in and custom sources
|
||||
Future<void> 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<void> _saveEnabledProfiles() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_enabledPrefsKey,
|
||||
_enabled.map((p) => p.id).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
61
lib/state/session_state.dart
Normal file
61
lib/state/session_state.dart
Normal file
@@ -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<CameraProfile> 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;
|
||||
}
|
||||
}
|
||||
80
lib/state/settings_state.dart
Normal file
80
lib/state/settings_state.dart
Normal file
@@ -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<void> 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<void> 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<void> setUploadMode(UploadMode mode) async {
|
||||
_uploadMode = mode;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_uploadModePrefsKey, mode.index);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
169
lib/state/upload_queue_state.dart
Normal file
169
lib/state/upload_queue_state.dart
Normal file
@@ -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<PendingUpload> _queue = [];
|
||||
Timer? _uploadTimer;
|
||||
|
||||
// Getters
|
||||
int get pendingCount => _queue.length;
|
||||
List<PendingUpload> get pendingUploads => List.unmodifiable(_queue);
|
||||
|
||||
// Initialize by loading queue from storage
|
||||
Future<void> 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<String, String>.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<String?> 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<PendingUpload?>().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<void> _saveQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = _queue.map((e) => e.toJson()).toList();
|
||||
await prefs.setString('queue', jsonEncode(jsonList));
|
||||
}
|
||||
|
||||
Future<void> _loadQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString('queue');
|
||||
if (jsonStr == null) return;
|
||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||
_queue
|
||||
..clear()
|
||||
..addAll(list.map((e) => PendingUpload.fromJson(e)));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_uploadTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user