From c5453124fd2a11e577e0f6b3600e45da6abd1125 Mon Sep 17 00:00:00 2001 From: ALim Aidrus Date: Tue, 14 Oct 2025 09:43:34 +0800 Subject: [PATCH] fix invalid token and auto resubmit data to server --- lib/auth_provider.dart | 220 +++++++++++++----- lib/main.dart | 145 ++++++++++-- .../marine/manual/tarball_sampling_step1.dart | 170 +++++++++++++- lib/screens/settings.dart | 114 ++++----- lib/services/api_service.dart | 12 +- lib/services/base_api_service.dart | 21 ++ .../marine_in_situ_sampling_service.dart | 59 +++-- .../marine_tarball_sampling_service.dart | 66 +++--- lib/services/retry_service.dart | 32 +++ .../river_in_situ_sampling_service.dart | 120 +++++----- ...ver_manual_triennial_sampling_service.dart | 119 ++++++---- lib/services/user_preferences_service.dart | 118 ++++++++-- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 56 +++++ pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 19 files changed, 950 insertions(+), 314 deletions(-) diff --git a/lib/auth_provider.dart b/lib/auth_provider.dart index 7163974..3adbe9d 100644 --- a/lib/auth_provider.dart +++ b/lib/auth_provider.dart @@ -6,16 +6,24 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'dart:convert'; import 'package:bcrypt/bcrypt.dart'; // Import bcrypt +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; // NEW: Import secure storage import 'package:environment_monitoring_app/services/api_service.dart'; +import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart'; import 'package:environment_monitoring_app/services/retry_service.dart'; +import 'package:environment_monitoring_app/services/user_preferences_service.dart'; class AuthProvider with ChangeNotifier { late final ApiService _apiService; late final DatabaseHelper _dbHelper; late final ServerConfigService _serverConfigService; late final RetryService _retryService; + final UserPreferencesService _userPreferencesService = UserPreferencesService(); + + // NEW: Initialize secure storage + final _secureStorage = const FlutterSecureStorage(); + static const _passwordStorageKey = 'user_password'; // --- Session & Profile State --- String? _jwtToken; @@ -25,9 +33,6 @@ class AuthProvider with ChangeNotifier { String? get userEmail => _userEmail; Map? get profileData => _profileData; - // --- ADDED: Temporary password cache for auto-relogin --- - String? _tempOfflinePassword; - // --- App State --- bool _isLoading = true; bool _isFirstLogin = true; @@ -36,6 +41,11 @@ class AuthProvider with ChangeNotifier { bool get isFirstLogin => _isFirstLogin; DateTime? get lastSyncTimestamp => _lastSyncTimestamp; + /// This flag indicates the session is confirmed expired and auto-relogin failed. + /// The app should operate in offline mode until the user manually logs in again. + bool _isSessionExpired = false; + bool get isSessionExpired => _isSessionExpired; + // --- Cached Master Data --- List>? _allUsers; List>? _tarballStations; @@ -91,6 +101,7 @@ class AuthProvider with ChangeNotifier { static const String profileDataKey = 'user_profile_data'; static const String lastSyncTimestampKey = 'last_sync_timestamp'; static const String isFirstLoginKey = 'is_first_login'; + static const String lastOnlineLoginKey = 'last_online_login'; AuthProvider({ required ApiService apiService, @@ -105,6 +116,11 @@ class AuthProvider with ChangeNotifier { _loadSessionAndSyncData(); } + Future isConnected() async { + final connectivityResult = await Connectivity().checkConnectivity(); + return !connectivityResult.contains(ConnectivityResult.none); + } + Future _loadSessionAndSyncData() async { _isLoading = true; notifyListeners(); @@ -139,6 +155,7 @@ class AuthProvider with ChangeNotifier { if (_jwtToken != null) { debugPrint('AuthProvider: Session loaded.'); + await _userPreferencesService.applyAndSaveDefaultPreferencesIfNeeded(); // Sync logic moved to checkAndTransitionToOnlineSession to handle transitions correctly } else { debugPrint('AuthProvider: No active session. App is in offline mode.'); @@ -152,8 +169,7 @@ class AuthProvider with ChangeNotifier { /// Returns true if a successful transition occurred. Future checkAndTransitionToOnlineSession() async { // Condition 1: Check connectivity - final connectivityResult = await Connectivity().checkConnectivity(); - if (connectivityResult.contains(ConnectivityResult.none)) { + if (!(await isConnected())) { debugPrint("AuthProvider: No internet connection. Skipping transition check."); return false; } @@ -165,21 +181,27 @@ class AuthProvider with ChangeNotifier { // If online, trigger a normal sync to ensure data freshness on connection restoration. if(_jwtToken != null) { debugPrint("AuthProvider: Session is already online. Triggering standard sync."); - syncAllData(); + // FIX: Add try-catch to prevent unhandled exceptions from crashing the app during background syncs. + try { + await syncAllData(); + } catch (e) { + debugPrint("AuthProvider: Background sync failed silently on transition check: $e"); + } } return false; } - // Condition 3: Check if we have the temporary password to attempt re-login. - if (_tempOfflinePassword == null || _userEmail == null) { - debugPrint("AuthProvider: In offline session, but no temporary password available for auto-relogin. Manual login required."); + // FIX: Read password from secure storage instead of temporary variable. + final String? password = await _secureStorage.read(key: _passwordStorageKey); + if (password == null || _userEmail == null) { + debugPrint("AuthProvider: In offline session, but no password in secure storage for auto-relogin. Manual login required."); return false; } debugPrint("AuthProvider: Internet detected in offline session. Attempting silent re-login for $_userEmail..."); try { - final result = await _apiService.login(_userEmail!, _tempOfflinePassword!); + final result = await _apiService.login(_userEmail!, password); if (result['success'] == true) { debugPrint("AuthProvider: Silent re-login successful. Transitioning to online session."); @@ -187,11 +209,8 @@ class AuthProvider with ChangeNotifier { final Map profile = result['data']['profile']; // Use existing login method to set up session and trigger sync. - // Re-pass the password to ensure credentials are fully cached after transition. - await login(token, profile, _tempOfflinePassword!); + await login(token, profile, password); - // Clear temporary password after successful transition - _tempOfflinePassword = null; notifyListeners(); // Ensure UI updates after state change return true; } else { @@ -206,28 +225,69 @@ class AuthProvider with ChangeNotifier { } } + /// ⚡️ Orchestrates session validation and silent re-authentication. + /// This should be called whenever the app gains an internet connection. + Future validateAndRefreshSession() async { + if (!(await isConnected())) { + debugPrint('AuthProvider: No connection, skipping session validation.'); + return; + } + + if (_isSessionExpired) { + debugPrint('AuthProvider: Session is marked as expired, manual login required.'); + return; + } + + // Don't attempt validation if the user is not logged in or is in a temporary offline session. + if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) { + return; + } + + try { + await _apiService.validateToken(); + debugPrint('AuthProvider: Session token is valid.'); + } on SessionExpiredException { + debugPrint('AuthProvider: Session validation failed (token expired). Attempting silent re-login.'); + final bool reauthenticated = await attemptSilentRelogin(); + + if (!reauthenticated) { + debugPrint('AuthProvider: Silent re-login failed. Switching to session-expired offline mode.'); + _isSessionExpired = true; + notifyListeners(); + // You can optionally show a one-time notification here. + } else { + debugPrint('AuthProvider: Silent re-login successful. Session restored.'); + } + } catch (e) { + debugPrint('AuthProvider: An error occurred during session validation: $e'); + } + } + /// Attempts to silently re-login to get a new token. /// This can be called when a 401 Unauthorized error is detected. Future attemptSilentRelogin() async { - final connectivityResult = await Connectivity().checkConnectivity(); - if (connectivityResult.contains(ConnectivityResult.none)) { + if (!(await isConnected())) { debugPrint("AuthProvider: No internet for silent relogin."); return false; } - if (_tempOfflinePassword == null || _userEmail == null) { - debugPrint("AuthProvider: No cached credentials for silent relogin."); + // FIX: Read password from secure storage. + final String? password = await _secureStorage.read(key: _passwordStorageKey); + if (password == null || _userEmail == null) { + debugPrint("AuthProvider: No cached credentials in secure storage for silent relogin."); return false; } debugPrint("AuthProvider: Session may be expired. Attempting silent re-login for $_userEmail..."); try { - final result = await _apiService.login(_userEmail!, _tempOfflinePassword!); + final result = await _apiService.login(_userEmail!, password); if (result['success'] == true) { debugPrint("AuthProvider: Silent re-login successful."); final String token = result['data']['token']; final Map profile = result['data']['profile']; - await login(token, profile, _tempOfflinePassword!); + await login(token, profile, password); + _isSessionExpired = false; // Explicitly mark session as valid again. + notifyListeners(); return true; } else { debugPrint("AuthProvider: Silent re-login failed: ${result['message']}"); @@ -240,46 +300,69 @@ class AuthProvider with ChangeNotifier { } Future syncAllData({bool forceRefresh = false}) async { - final connectivityResult = await Connectivity().checkConnectivity(); - if (connectivityResult.contains(ConnectivityResult.none)) { + if (!(await isConnected())) { debugPrint("AuthProvider: Device is OFFLINE. Skipping sync."); return; } - // Prevent sync attempts if token is an offline placeholder + // Proactively check if session is already marked as expired + if (_isSessionExpired) { + debugPrint("AuthProvider: Skipping sync, session is expired. Manual login required."); + throw Exception('Session expired. Please log in again to sync.'); + } + if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) { debugPrint("AuthProvider: Skipping sync, session is offline or null."); return; } - debugPrint("AuthProvider: Device is ONLINE. Starting delta sync."); - final prefs = await SharedPreferences.getInstance(); - final String? lastSync = forceRefresh ? null : prefs.getString(lastSyncTimestampKey); - final newSyncTimestamp = DateTime.now().toUtc().toIso8601String(); + try { + debugPrint("AuthProvider: Device is ONLINE. Starting delta sync."); + final prefs = await SharedPreferences.getInstance(); + final String? lastSync = forceRefresh ? null : prefs.getString(lastSyncTimestampKey); - final result = await _apiService.syncAllData(lastSyncTimestamp: lastSync); + final result = await _apiService.syncAllData(lastSyncTimestamp: lastSync); - if (result['success']) { - debugPrint("AuthProvider: Delta sync successful. Updating last sync timestamp."); - await prefs.setString(lastSyncTimestampKey, newSyncTimestamp); - _lastSyncTimestamp = DateTime.parse(newSyncTimestamp); + if (result['success']) { + debugPrint("AuthProvider: Delta sync successful."); + final newSyncTimestamp = DateTime.now().toUtc().toIso8601String(); + await prefs.setString(lastSyncTimestampKey, newSyncTimestamp); + _lastSyncTimestamp = DateTime.parse(newSyncTimestamp); - if (_isFirstLogin) { - await setIsFirstLogin(false); - debugPrint("AuthProvider: First successful sync complete. isFirstLogin flag set to false."); + if (_isFirstLogin) { + await setIsFirstLogin(false); + debugPrint("AuthProvider: First successful sync complete. isFirstLogin flag set to false."); + } + + await _loadDataFromCache(); + notifyListeners(); + } else { + debugPrint("AuthProvider: Delta sync failed logically. Message: ${result['message']}"); + // We throw an exception here so the UI can report a failure. + throw Exception('Data sync failed. Please check the logs.'); } - - await _loadDataFromCache(); - notifyListeners(); - } else { - debugPrint("AuthProvider: Delta sync failed. Timestamp not updated."); + } on SessionExpiredException { + debugPrint("AuthProvider: Session expired during sync. Attempting silent re-login..."); + final bool reauthenticated = await attemptSilentRelogin(); + if (reauthenticated) { + debugPrint("AuthProvider: Re-login successful. Retrying sync..."); + await syncAllData(forceRefresh: forceRefresh); // Retry the sync + } else { + debugPrint("AuthProvider: Re-login failed after session expired during sync. Switching to offline mode."); + _isSessionExpired = true; + notifyListeners(); + throw Exception('Session expired. App is now in offline mode.'); + } + } catch (e) { + debugPrint("AuthProvider: A general error occurred during sync: $e"); + // Re-throw the exception so the UI can display it. + rethrow; } } // --- START: NEW METHOD FOR REGISTRATION SCREEN --- Future syncRegistrationData() async { - final connectivityResult = await Connectivity().checkConnectivity(); - if (connectivityResult.contains(ConnectivityResult.none)) { + if (!(await isConnected())) { debugPrint("AuthProvider: Device is OFFLINE. Skipping registration data sync."); return; } @@ -298,13 +381,12 @@ class AuthProvider with ChangeNotifier { // --- END: NEW METHOD FOR REGISTRATION SCREEN --- Future refreshProfile() async { - final connectivityResult = await Connectivity().checkConnectivity(); - if (connectivityResult.contains(ConnectivityResult.none)) { + if (!(await isConnected())) { debugPrint("AuthProvider: Device is OFFLINE. Skipping profile refresh."); return; } - if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) { - debugPrint("AuthProvider: Skipping profile refresh, session is offline or null."); + if (_isSessionExpired || _jwtToken == null || _jwtToken!.startsWith("offline-session-")) { + debugPrint("AuthProvider: Skipping profile refresh, session is offline, expired, or null."); return; } @@ -314,6 +396,33 @@ class AuthProvider with ChangeNotifier { } } + Future proactiveTokenRefresh() async { + if (!(await isConnected())) { + debugPrint('AuthProvider: No connection, skipping proactive token refresh.'); + return; + } + + final prefs = await SharedPreferences.getInstance(); + final lastOnlineLoginString = prefs.getString(lastOnlineLoginKey); + + if (lastOnlineLoginString == null) { + debugPrint('AuthProvider: No last online login timestamp found, skipping proactive refresh.'); + return; // Never logged in online, nothing to refresh. + } + + try { + final lastOnlineLogin = DateTime.parse(lastOnlineLoginString); + if (DateTime.now().difference(lastOnlineLogin).inHours >= 24) { + debugPrint('AuthProvider: Session is older than 24 hours. Attempting proactive silent re-login.'); + await attemptSilentRelogin(); + } else { + debugPrint('AuthProvider: Session is fresh (< 24 hours old). No proactive refresh needed.'); + } + } catch (e) { + debugPrint('AuthProvider: Error during proactive token refresh check: $e'); + } + } + Future _loadDataFromCache() async { final prefs = await SharedPreferences.getInstance(); final profileJson = prefs.getString(profileDataKey); @@ -357,8 +466,9 @@ class AuthProvider with ChangeNotifier { Future login(String token, Map profile, String password) async { _jwtToken = token; _userEmail = profile['email']; - // --- MODIFIED: Cache password on successful ONLINE login --- - _tempOfflinePassword = password; + + // FIX: Save password to secure storage instead of in-memory variable. + await _secureStorage.write(key: _passwordStorageKey, value: password); final Map profileWithToken = Map.from(profile); profileWithToken['token'] = token; @@ -368,6 +478,7 @@ class AuthProvider with ChangeNotifier { await prefs.setString(tokenKey, token); await prefs.setString(userEmailKey, _userEmail!); await prefs.setString(profileDataKey, jsonEncode(_profileData)); + await prefs.setString(lastOnlineLoginKey, DateTime.now().toIso8601String()); await _dbHelper.saveProfile(_profileData!); try { @@ -383,6 +494,7 @@ class AuthProvider with ChangeNotifier { } debugPrint('AuthProvider: Login successful. Session and profile persisted.'); + await _userPreferencesService.applyAndSaveDefaultPreferencesIfNeeded(); await syncAllData(forceRefresh: true); } @@ -415,8 +527,8 @@ class AuthProvider with ChangeNotifier { _jwtToken = "offline-session-${DateTime.now().millisecondsSinceEpoch}"; _userEmail = email; - // --- MODIFIED: Cache the password on successful OFFLINE login --- - _tempOfflinePassword = password; + // FIX: Save password to secure storage for future auto-relogin. + await _secureStorage.write(key: _passwordStorageKey, value: password); final Map profileWithToken = Map.from(cachedProfile); profileWithToken['token'] = _jwtToken; @@ -467,8 +579,10 @@ class AuthProvider with ChangeNotifier { _profileData = null; _lastSyncTimestamp = null; _isFirstLogin = true; - // --- MODIFIED: Clear temp password on logout --- - _tempOfflinePassword = null; + _isSessionExpired = false; // Reset session expired flag on logout + + // FIX: Clear password from secure storage on logout. + await _secureStorage.delete(key: _passwordStorageKey); _allUsers = null; _tarballStations = null; @@ -500,6 +614,8 @@ class AuthProvider with ChangeNotifier { await prefs.remove(userEmailKey); await prefs.remove(profileDataKey); await prefs.remove(lastSyncTimestampKey); + await prefs.remove(lastOnlineLoginKey); + await prefs.remove('default_preferences_saved'); await prefs.setBool(isFirstLoginKey, true); debugPrint('AuthProvider: All session and cached data cleared.'); diff --git a/lib/main.dart b/lib/main.dart index d8ce94f..eb55f50 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; +import 'dart:async'; // Import Timer import 'package:provider/single_child_widget.dart'; import 'package:environment_monitoring_app/services/api_service.dart'; @@ -101,31 +102,42 @@ void main() async { final DatabaseHelper databaseHelper = DatabaseHelper(); final TelegramService telegramService = TelegramService(); final ApiService apiService = ApiService(telegramService: telegramService); + final RetryService retryService = RetryService(); + final MarineInSituSamplingService marineInSituService = MarineInSituSamplingService(telegramService); + final RiverInSituSamplingService riverInSituService = RiverInSituSamplingService(telegramService); + telegramService.setApiService(apiService); - setupServices(telegramService); + // The AuthProvider needs to be created here so it can be passed to the retry service. + final authProvider = AuthProvider( + apiService: apiService, + dbHelper: databaseHelper, + serverConfigService: ServerConfigService(), + retryService: retryService, + ); + + // Initialize the retry service with all its dependencies. + retryService.initialize( + marineInSituService: marineInSituService, + riverInSituService: riverInSituService, + authProvider: authProvider, + ); + + setupPeriodicServices(telegramService, retryService); runApp( MultiProvider( providers: [ - ChangeNotifierProvider( - create: (context) => AuthProvider( - apiService: apiService, - dbHelper: databaseHelper, - serverConfigService: ServerConfigService(), - retryService: RetryService(), - ), - ), - // Providers for core services + ChangeNotifierProvider.value(value: authProvider), Provider(create: (_) => apiService), Provider(create: (_) => databaseHelper), Provider(create: (_) => telegramService), Provider(create: (_) => LocalStorageService()), - - Provider(create: (context) => RiverInSituSamplingService(telegramService)), + Provider.value(value: retryService), + Provider.value(value: marineInSituService), + Provider.value(value: riverInSituService), Provider(create: (context) => RiverManualTriennialSamplingService(telegramService)), Provider(create: (context) => AirSamplingService(databaseHelper, telegramService)), - Provider(create: (context) => MarineInSituSamplingService(telegramService)), Provider(create: (context) => MarineTarballSamplingService(telegramService)), Provider(create: (context) => MarineNpeReportService(Provider.of(context, listen: false))), Provider(create: (context) => MarineManualPreDepartureService()), @@ -137,14 +149,22 @@ void main() async { ); } -void setupServices(TelegramService telegramService) { - // Initial alert processing on startup (delayed) +void setupPeriodicServices(TelegramService telegramService, RetryService retryService) { + // Initial processing on startup (delayed) Future.delayed(const Duration(seconds: 5), () { debugPrint("[Main] Performing initial alert queue processing on app start."); telegramService.processAlertQueue(); + debugPrint("[Main] Performing initial retry queue processing on app start."); + retryService.processRetryQueue(); }); - // Connectivity listener moved to RootApp to access AuthProvider context. + // Start recurring timers to process both queues every 5 minutes. + Timer.periodic(const Duration(minutes: 5), (timer) { + debugPrint("[Main] Periodic check: Processing Telegram alert queue..."); + telegramService.processAlertQueue(); + debugPrint("[Main] Periodic check: Processing main retry queue..."); + retryService.processRetryQueue(); + }); } // --- START: MODIFIED RootApp --- @@ -169,7 +189,17 @@ class _RootAppState extends State { // Wait a moment for providers to be fully available. await Future.delayed(const Duration(milliseconds: 100)); if (mounted) { - Provider.of(context, listen: false).checkAndTransitionToOnlineSession(); + final authProvider = Provider.of(context, listen: false); + + // Perform proactive token refresh on app start + await authProvider.proactiveTokenRefresh(); + + // First, try to transition from an offline placeholder token to an online one. + final didTransition = await authProvider.checkAndTransitionToOnlineSession(); + // If no transition happened (i.e., we were already supposed to be online), validate the session. + if (!didTransition) { + authProvider.validateAndRefreshSession(); + } } } @@ -182,12 +212,18 @@ class _RootAppState extends State { // Access services from provider context final authProvider = Provider.of(context, listen: false); final telegramService = Provider.of(context, listen: false); + final retryService = Provider.of(context, listen: false); - // Attempt to auto-relogin if necessary - authProvider.checkAndTransitionToOnlineSession(); + // When connection is restored, always try to transition/validate the session. + authProvider.checkAndTransitionToOnlineSession().then((didTransition) { + if (!didTransition) { + authProvider.validateAndRefreshSession(); + } + }); - // Process alert queue + // Process queues telegramService.processAlertQueue(); + retryService.processRetryQueue(); } } else { debugPrint("[Main] Internet connection lost."); @@ -203,7 +239,7 @@ class _RootAppState extends State { if (auth.isLoading) { homeWidget = const SplashScreen(); } else if (auth.isLoggedIn) { - homeWidget = const HomePage(); + homeWidget = const SessionAwareWrapper(child: HomePage()); } else { homeWidget = const LoginScreen(); } @@ -321,6 +357,73 @@ class _RootAppState extends State { } // --- END: MODIFIED RootApp --- +class SessionAwareWrapper extends StatefulWidget { + final Widget child; + const SessionAwareWrapper({super.key, required this.child}); + + @override + State createState() => _SessionAwareWrapperState(); +} + +class _SessionAwareWrapperState extends State { + bool _isDialogShowing = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final auth = Provider.of(context); + + if (auth.isSessionExpired && !_isDialogShowing) { + // Use addPostFrameCallback to show dialog after the build phase. + WidgetsBinding.instance.addPostFrameCallback((_) { + _showSessionExpiredDialog(); + }); + } + } + + Future _showSessionExpiredDialog() async { + setState(() => _isDialogShowing = true); + await showDialog( + context: context, + barrierDismissible: false, // User must make a choice + builder: (BuildContext dialogContext) { + final auth = Provider.of(context, listen: false); + return AlertDialog( + title: const Text("Session Expired"), + content: const Text( + "Your online session has expired. You can continue working offline, but you will not be able to sync data until you log in again."), + actions: [ + TextButton( + child: const Text("Continue Offline"), + onPressed: () { + Navigator.of(dialogContext).pop(); // Just close the dialog + }, + ), + ElevatedButton( + child: const Text("Login Now"), + onPressed: () { + // Logout clears all state and pushes to login screen via the RootApp builder + auth.logout(); + Navigator.of(dialogContext).pop(); + }, + ), + ], + ); + }, + ); + // Once the dialog is dismissed, reset the flag. + if (mounted) { + setState(() => _isDialogShowing = false); + } + } + + @override + Widget build(BuildContext context) { + // This widget just returns its child, its only job is to show the dialog. + return widget.child; + } +} + class SplashScreen extends StatelessWidget { const SplashScreen({super.key}); diff --git a/lib/screens/marine/manual/tarball_sampling_step1.dart b/lib/screens/marine/manual/tarball_sampling_step1.dart index 0454ca6..5941df8 100644 --- a/lib/screens/marine/manual/tarball_sampling_step1.dart +++ b/lib/screens/marine/manual/tarball_sampling_step1.dart @@ -135,13 +135,102 @@ class _TarballSamplingStep1State extends State { } } - // --- MODIFIED: This function now validates distance and shows a dialog if needed --- + // --- START: New function to find and show nearby stations --- + Future _findAndShowNearbyStations() async { + if (_data.currentLatitude == null || _data.currentLatitude!.isEmpty) { + await _getCurrentLocation(); + if (!mounted || _data.currentLatitude == null || _data.currentLatitude!.isEmpty) { + return; + } + } + + final auth = Provider.of(context, listen: false); + final allStations = auth.tarballStations ?? []; + if (allStations.isEmpty) { + _showSnackBar("Station list is not available."); + return; + } + + final currentLat = double.parse(_data.currentLatitude!); + final currentLon = double.parse(_data.currentLongitude!); + final List> nearbyStations = []; + + for (var station in allStations) { + final stationLat = station['tbl_latitude']; + final stationLon = station['tbl_longitude']; + + if (stationLat is num && stationLon is num) { + final distanceInMeters = Geolocator.distanceBetween(currentLat, currentLon, stationLat.toDouble(), stationLon.toDouble()); + if (distanceInMeters <= 5000.0) { // 5km radius + nearbyStations.add({'station': station, 'distance': distanceInMeters}); + } + } + } + + nearbyStations.sort((a, b) => a['distance'].compareTo(b['distance'])); + + if (!mounted) return; + + final selectedStation = await showDialog>( + context: context, + builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations), + ); + + if (selectedStation != null) { + _updateFormWithSelectedStation(selectedStation); + } + } + // --- END: New function --- + + // --- START: New helper to update form after selection --- + void _updateFormWithSelectedStation(Map station) { + final allStations = Provider.of(context, listen: false).tarballStations ?? []; + setState(() { + // Update State + _data.selectedStateName = station['state_name']; + + // Update Category List based on new State + final categories = allStations + .where((s) => s['state_name'] == _data.selectedStateName) + .map((s) => s['category_name'] as String?) + .whereType() + .toSet() + .toList(); + categories.sort(); + _categoriesForState = categories; + + // Update Category + _data.selectedCategoryName = station['category_name']; + + // Update Station List based on new State and Category + _stationsForCategory = allStations + .where((s) => + s['state_name'] == _data.selectedStateName && + s['category_name'] == _data.selectedCategoryName) + .toList(); + + // Update Selected Station and its coordinates + _data.selectedStation = station; + _data.stationLatitude = station['tbl_latitude']?.toString(); + _data.stationLongitude = station['tbl_longitude']?.toString(); + _stationLatController.text = _data.stationLatitude ?? ''; + _stationLonController.text = _data.stationLongitude ?? ''; + + // Recalculate distance + _calculateDistance(); + }); + } + // --- END: New helper --- + + // MODIFIED: This function now validates distance and shows a dialog if needed void _goToNextStep() { if (_formKey.currentState!.validate()) { _formKey.currentState!.save(); final distanceInMeters = (_data.distanceDifference ?? 0) * 1000; - if (distanceInMeters > 700) { + // START MODIFICATION: Distance reduced to 50m + if (distanceInMeters > 50) { + // END MODIFICATION _showDistanceRemarkDialog(); } else { _data.distanceDifferenceRemarks = null; // Clear old remarks if within range @@ -153,7 +242,7 @@ class _TarballSamplingStep1State extends State { } } - // --- NEW: This function displays the mandatory remarks dialog --- + // NEW: This function displays the mandatory remarks dialog Future _showDistanceRemarkDialog() async { final remarkController = TextEditingController(text: _data.distanceDifferenceRemarks); final dialogFormKey = GlobalKey(); @@ -171,7 +260,9 @@ class _TarballSamplingStep1State extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Your current location is more than 700m away from the station.'), + // START MODIFICATION: Text updated for 50m + const Text('Your current location is more than 50m away from the station.'), + // END MODIFICATION const SizedBox(height: 16), TextFormField( controller: remarkController, @@ -335,6 +426,19 @@ class _TarballSamplingStep1State extends State { TextFormField(controller: _stationLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Latitude')), const SizedBox(height: 16), TextFormField(controller: _stationLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Longitude')), + + // --- START: Added Nearby Station Button --- + const SizedBox(height: 16), + ElevatedButton.icon( + icon: const Icon(Icons.explore_outlined), + label: const Text("NEARBY STATION"), + onPressed: _isLoading ? null : _findAndShowNearbyStations, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + // --- END: Added Nearby Station Button --- + const SizedBox(height: 24), Text("Location Verification", style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 16), @@ -344,13 +448,14 @@ class _TarballSamplingStep1State extends State { if (_data.distanceDifference != null) Padding( padding: const EdgeInsets.only(top: 16.0), - // --- MODIFIED: This UI now better reflects the warning/ok status --- + // MODIFIED: This UI now better reflects the warning/ok status child: Container( padding: const EdgeInsets.all(12), + // START MODIFICATION: Distance reduced to 50m decoration: BoxDecoration( - color: ((_data.distanceDifference ?? 0) * 1000) > 700 ? Colors.red.withOpacity(0.1) : Colors.green.withOpacity(0.1), + color: ((_data.distanceDifference ?? 0) * 1000) > 50 ? Colors.red.withOpacity(0.1) : Colors.green.withOpacity(0.1), borderRadius: BorderRadius.circular(8), - border: Border.all(color: ((_data.distanceDifference ?? 0) * 1000) > 700 ? Colors.red : Colors.green), + border: Border.all(color: ((_data.distanceDifference ?? 0) * 1000) > 50 ? Colors.red : Colors.green), ), child: RichText( textAlign: TextAlign.center, @@ -362,11 +467,12 @@ class _TarballSamplingStep1State extends State { text: '${(_data.distanceDifference! * 1000).toStringAsFixed(0)} meters', style: TextStyle( fontWeight: FontWeight.bold, - color: ((_data.distanceDifference ?? 0) * 1000) > 700 ? Colors.red : Colors.green), + color: ((_data.distanceDifference ?? 0) * 1000) > 50 ? Colors.red : Colors.green), ), ], ), ), + // END MODIFICATION ), ), const SizedBox(height: 16), @@ -386,4 +492,50 @@ class _TarballSamplingStep1State extends State { ), ); } -} \ No newline at end of file +} + +// --- START: New Dialog Widget for Nearby Stations --- +class _NearbyStationsDialog extends StatelessWidget { + final List> nearbyStations; + + const _NearbyStationsDialog({required this.nearbyStations}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Nearby Stations (within 5km)'), + content: SizedBox( + width: double.maxFinite, + child: nearbyStations.isEmpty + ? const Center(child: Text('No stations found.')) + : ListView.builder( + shrinkWrap: true, + itemCount: nearbyStations.length, + itemBuilder: (context, index) { + final item = nearbyStations[index]; + final station = item['station'] as Map; + final distanceInMeters = item['distance'] as double; + + return Card( + child: ListTile( + title: Text("${station['tbl_station_code'] ?? 'N/A'}"), + subtitle: Text("${station['tbl_station_name'] ?? 'N/A'}"), + trailing: Text("${distanceInMeters.toStringAsFixed(0)} m"), + onTap: () { + Navigator.of(context).pop(station); + }, + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ], + ); + } +} +// --- END: New Dialog Widget --- \ No newline at end of file diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 7c6c724..bc716bb 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -6,6 +6,7 @@ import 'package:intl/intl.dart'; import 'package:environment_monitoring_app/auth_provider.dart'; import 'package:environment_monitoring_app/services/settings_service.dart'; import 'package:environment_monitoring_app/services/user_preferences_service.dart'; +import 'package:environment_monitoring_app/services/api_service.dart'; // Import for DatabaseHelper access class _ModuleSettings { bool isApiEnabled; @@ -34,6 +35,7 @@ class _SettingsScreenState extends State { bool _isSyncingData = false; final UserPreferencesService _preferencesService = UserPreferencesService(); + final DatabaseHelper _dbHelper = DatabaseHelper(); // Add instance for direct access bool _isLoadingSettings = true; bool _isSaving = false; @@ -146,64 +148,16 @@ class _SettingsScreenState extends State { setState(() => _isLoadingSettings = true); for (var module in _configurableModules) { final moduleKey = module['key']!; + + // This method now simply loads whatever preferences are saved in the database. + // The auto-saving of defaults is handled by AuthProvider upon login/app start. final prefs = await _preferencesService.getModulePreference(moduleKey); final apiConfigsWithPrefs = await _preferencesService.getAllApiConfigsWithModulePreferences(moduleKey); final ftpConfigsWithPrefs = await _preferencesService.getAllFtpConfigsWithModulePreferences(moduleKey); - final bool isAnyApiConfigEnabled = apiConfigsWithPrefs.any((c) => c['is_enabled'] == true); - - if (prefs['is_api_enabled'] == true && !isAnyApiConfigEnabled) { - final pstwHqApi = apiConfigsWithPrefs.firstWhere((c) => c['config_name'] == 'PSTW_HQ', orElse: () => {}); - if (pstwHqApi.isNotEmpty) { - pstwHqApi['is_enabled'] = true; - } - } - - final bool isAnyFtpConfigEnabled = ftpConfigsWithPrefs.any((c) => c['is_enabled'] == true); - - if (prefs['is_ftp_enabled'] == true && !isAnyFtpConfigEnabled) { - switch (moduleKey) { - case 'marine_tarball': - for (var config in ftpConfigsWithPrefs) { - if (config['config_name'] == 'pstw_marine_tarball' || config['config_name'] == 'tes_marine_tarball') { - config['is_enabled'] = true; - } - } - break; - case 'marine_in_situ': - for (var config in ftpConfigsWithPrefs) { - if (config['config_name'] == 'pstw_marine_manual' || config['config_name'] == 'tes_marine_manual') { - config['is_enabled'] = true; - } - } - break; - case 'river_in_situ': - for (var config in ftpConfigsWithPrefs) { - if (config['config_name'] == 'pstw_river_manual' || config['config_name'] == 'tes_river_manual') { - config['is_enabled'] = true; - } - } - break; - case 'air_collection': - for (var config in ftpConfigsWithPrefs) { - if (config['config_name'] == 'pstw_air_collect' || config['config_name'] == 'tes_air_collect') { - config['is_enabled'] = true; - } - } - break; - case 'air_installation': - for (var config in ftpConfigsWithPrefs) { - if (config['config_name'] == 'pstw_air_install' || config['config_name'] == 'tes_air_install') { - config['is_enabled'] = true; - } - } - break; - } - } - _moduleSettings[moduleKey] = _ModuleSettings( - isApiEnabled: prefs['is_api_enabled'], - isFtpEnabled: prefs['is_ftp_enabled'], + isApiEnabled: prefs?['is_api_enabled'] ?? true, // Fallback to true if null + isFtpEnabled: prefs?['is_ftp_enabled'] ?? true, // Fallback to true if null apiConfigs: apiConfigsWithPrefs, ftpConfigs: ftpConfigsWithPrefs, ); @@ -246,16 +200,39 @@ class _SettingsScreenState extends State { final auth = Provider.of(context, listen: false); + // 1. Pre-sync checks + if (!await auth.isConnected()) { + _showSnackBar('Sync failed: No internet connection.', isError: true); + setState(() => _isSyncingData = false); + return; + } + + if (auth.isSessionExpired) { + _showSessionExpiredDialog(); + setState(() => _isSyncingData = false); + return; + } + + // 2. Attempt the sync operation try { + // AuthProvider's syncAllData will internally handle token validation and attempt a silent re-login if necessary. await auth.syncAllData(forceRefresh: true); - await _loadAllModuleSettings(); + await _loadAllModuleSettings(); // Reload settings on successful sync if (mounted) { _showSnackBar('Data synced successfully.', isError: false); } } catch (e) { + // 3. Handle failures if (mounted) { - _showSnackBar('Data sync failed. Please check your connection.', isError: true); + // If the sync failed, check if the session is now marked as expired. + // This indicates that the silent re-login attempt during the sync also failed. + if (auth.isSessionExpired) { + _showSessionExpiredDialog(); + } else { + // A different error occurred (e.g., server down, network issue during sync) + _showSnackBar('Data sync failed: ${e.toString()}', isError: true); + } } } finally { if (mounted) { @@ -264,6 +241,33 @@ class _SettingsScreenState extends State { } } + void _showSessionExpiredDialog() { + if (!mounted) return; + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text("Session Expired"), + content: const Text("Your session has expired and automatic re-login failed. Please log out and log in again to sync data."), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text("OK"), + ), + ElevatedButton( + onPressed: () { + final auth = Provider.of(context, listen: false); + Navigator.pop(dialogContext); + auth.logout(); + // Navigate to the root, which will then redirect to the login screen. + Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false); + }, + child: const Text("Logout"), + ), + ], + ), + ); + } + void _showSnackBar(String message, {bool isError = false}) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index a4cca1a..66d3691 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -154,6 +154,15 @@ class ApiService { return result; } + /// Validates the current session token by making a lightweight API call. + /// Throws [SessionExpiredException] if the token is invalid (401). + Future validateToken() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + // A simple GET request to an authenticated endpoint like /profile is perfect for validation. + // The underlying _handleResponse in BaseApiService will automatically throw the exception on 401. + await _baseService.get(baseUrl, 'profile'); + } + // --- REWRITTEN FOR DELTA SYNC --- /// Helper method to make a delta-sync API call. @@ -344,7 +353,8 @@ class ApiService { return {'success': true, 'message': 'Delta sync successful.'}; } catch (e) { debugPrint('ApiService: Delta data sync failed: $e'); - return {'success': false, 'message': 'Data sync failed: $e'}; + // Re-throw the original exception so AuthProvider can catch specific types like SessionExpiredException + rethrow; } } diff --git a/lib/services/base_api_service.dart b/lib/services/base_api_service.dart index ea8bcec..0fdcddc 100644 --- a/lib/services/base_api_service.dart +++ b/lib/services/base_api_service.dart @@ -10,6 +10,15 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:path/path.dart' as path; import 'package:environment_monitoring_app/auth_provider.dart'; +/// Custom exception thrown when the API returns a 401 Unauthorized status. +class SessionExpiredException implements Exception { + final String message; + SessionExpiredException([this.message = "Your session has expired. Please log in again."]); + + @override + String toString() => message; +} + /// A low-level service for making direct HTTP requests. /// This service is now "dumb" and only sends a request to the specific /// baseUrl provided. It no longer contains logic for server fallbacks. @@ -36,6 +45,8 @@ class BaseApiService { final response = await http.get(url, headers: await _getJsonHeaders()) .timeout(const Duration(seconds: 60)); return _handleResponse(response); + } on SessionExpiredException { + rethrow; // CRITICAL FIX: Allow SessionExpiredException to propagate up. } on SocketException catch (e) { debugPrint('BaseApiService GET network error: $e'); rethrow; // Re-throw network-related exceptions to be caught by higher-level logic (e.g., login screen) @@ -59,6 +70,8 @@ class BaseApiService { body: jsonEncode(body), ).timeout(const Duration(seconds: 60)); // Note: login.dart applies its own shorter timeout over this. return _handleResponse(response); + } on SessionExpiredException { + rethrow; // CRITICAL FIX: Allow SessionExpiredException to propagate up. } on SocketException catch (e) { debugPrint('BaseApiService POST network error: $e'); rethrow; // Re-throw network-related exceptions to be caught by higher-level logic (e.g., login screen) @@ -106,6 +119,8 @@ class BaseApiService { final responseBody = await streamedResponse.stream.bytesToString(); return _handleResponse(http.Response(responseBody, streamedResponse.statusCode)); + } on SessionExpiredException { + rethrow; // CRITICAL FIX: Allow SessionExpiredException to propagate up. } on SocketException catch (e) { debugPrint('BaseApiService Multipart network error: $e'); rethrow; // Re-throw network-related exceptions @@ -121,6 +136,12 @@ class BaseApiService { Map _handleResponse(http.Response response) { debugPrint('Handling response. Status: ${response.statusCode}, Body: ${response.body}'); + + // Check for 401 Unauthorized and throw the specific exception. + if (response.statusCode == 401) { + throw SessionExpiredException(); + } + try { // Try to parse the response body as JSON. final Map responseData = jsonDecode(response.body); diff --git a/lib/services/marine_in_situ_sampling_service.dart b/lib/services/marine_in_situ_sampling_service.dart index f0431f2..bcfc002 100644 --- a/lib/services/marine_in_situ_sampling_service.dart +++ b/lib/services/marine_in_situ_sampling_service.dart @@ -29,6 +29,7 @@ import 'submission_api_service.dart'; import 'submission_ftp_service.dart'; import 'telegram_service.dart'; import 'retry_service.dart'; +import 'base_api_service.dart'; // Import for SessionExpiredException /// A dedicated service to handle all business logic for the Marine In-Situ Sampling feature. @@ -217,6 +218,7 @@ class MarineInSituSamplingService { bool anyApiSuccess = false; Map apiDataResult = {}; Map apiImageResult = {}; + bool isSessionKnownToBeExpired = false; try { apiDataResult = await _submissionApiService.submitPost( @@ -225,21 +227,6 @@ class MarineInSituSamplingService { body: data.toApiFormData(), ); - if (apiDataResult['success'] == false && - (apiDataResult['message'] as String?)?.contains('Unauthorized') == true) { - debugPrint("API submission failed with Unauthorized. Attempting silent relogin..."); - final bool reloginSuccess = await authProvider.attemptSilentRelogin(); - - if (reloginSuccess) { - debugPrint("Silent relogin successful. Retrying data submission..."); - apiDataResult = await _submissionApiService.submitPost( - moduleName: moduleName, - endpoint: 'marine/manual/sample', - body: data.toApiFormData(), - ); - } - } - if (apiDataResult['success'] == true) { anyApiSuccess = true; data.reportId = apiDataResult['data']?['man_id']?.toString(); @@ -261,6 +248,26 @@ class MarineInSituSamplingService { apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.'; } } + } on SessionExpiredException catch (_) { + debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin..."); + final bool reloginSuccess = await authProvider.attemptSilentRelogin(); + + if (reloginSuccess) { + debugPrint("Silent relogin successful. Retrying entire online submission process..."); + return await _performOnlineSubmission( + data: data, + appSettings: appSettings, + moduleName: moduleName, + authProvider: authProvider, + logDirectory: logDirectory, + ); + } else { + debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP."); + isSessionKnownToBeExpired = true; + anyApiSuccess = false; + apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'}; + await _retryService.addApiToQueue(endpoint: 'marine/manual/sample', method: 'POST', body: data.toApiFormData()); + } } on SocketException catch (e) { final errorMessage = "API submission failed with network error: $e"; debugPrint(errorMessage); @@ -305,7 +312,7 @@ class MarineInSituSamplingService { finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.'; finalStatus = 'L4'; } else { - finalMessage = 'All submission attempts failed and have been queued for retry.'; + finalMessage = apiDataResult['message'] ?? 'All submission attempts failed and have been queued for retry.'; finalStatus = 'L1'; } @@ -321,7 +328,7 @@ class MarineInSituSamplingService { ); if (overallSuccess) { - _handleInSituSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty); + _handleInSituSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired); } return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId}; @@ -336,7 +343,7 @@ class MarineInSituSamplingService { // Set initial status before first save data.submissionStatus = 'L1'; - data.submissionMessage = 'Submission queued due to being offline.'; + data.submissionMessage = 'Submission queued for later retry.'; final String? localLogPath = await _localStorageService.saveInSituSamplingData(data, serverName: serverName); @@ -355,8 +362,7 @@ class MarineInSituSamplingService { }, ); - // No need to save again, initial save already has the L1 status. - const successMessage = "No internet connection. Submission has been saved and queued for upload."; + const successMessage = "Submission failed to send and has been queued for later retry."; return {'success': true, 'message': successMessage}; } @@ -428,10 +434,8 @@ class MarineInSituSamplingService { if (logDirectory != null) { final Map updatedLogData = data.toDbJson(); - // --- START FIX: Explicitly add final status and message to the update map --- updatedLogData['submissionStatus'] = status; updatedLogData['submissionMessage'] = message; - // --- END FIX --- updatedLogData['logDirectory'] = logDirectory; updatedLogData['serverConfigName'] = serverName; @@ -468,12 +472,17 @@ class MarineInSituSamplingService { await _dbHelper.saveSubmissionLog(logData); } - Future _handleInSituSuccessAlert(InSituSamplingData data, List>? appSettings, {required bool isDataOnly}) async { + Future _handleInSituSuccessAlert(InSituSamplingData data, List>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async { try { final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly); - final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings); - if (!wasSent) { + if (isSessionExpired) { + debugPrint("Session is expired; queuing Telegram alert directly."); await _telegramService.queueMessage('marine_in_situ', message, appSettings); + } else { + final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings); + if (!wasSent) { + await _telegramService.queueMessage('marine_in_situ', message, appSettings); + } } } catch (e) { debugPrint("Failed to handle In-Situ Telegram alert: $e"); diff --git a/lib/services/marine_tarball_sampling_service.dart b/lib/services/marine_tarball_sampling_service.dart index 82d682f..2a62b4c 100644 --- a/lib/services/marine_tarball_sampling_service.dart +++ b/lib/services/marine_tarball_sampling_service.dart @@ -19,6 +19,7 @@ import 'package:environment_monitoring_app/services/submission_ftp_service.dart' import 'package:environment_monitoring_app/services/telegram_service.dart'; import 'package:environment_monitoring_app/services/retry_service.dart'; import 'package:environment_monitoring_app/auth_provider.dart'; +import 'package:environment_monitoring_app/services/base_api_service.dart'; // Import for SessionExpiredException /// A dedicated service to handle all business logic for the Marine Tarball Sampling feature. class MarineTarballSamplingService { @@ -85,9 +86,8 @@ class MarineTarballSamplingService { bool anyApiSuccess = false; Map apiDataResult = {}; Map apiImageResult = {}; + bool isSessionKnownToBeExpired = false; - // --- START: MODIFICATION FOR GRANULAR ERROR HANDLING --- - // Step 1: Attempt API Submission in its own try-catch block. try { apiDataResult = await _submissionApiService.submitPost( moduleName: moduleName, @@ -95,21 +95,6 @@ class MarineTarballSamplingService { body: data.toFormData(), ); - if (apiDataResult['success'] == false && - (apiDataResult['message'] as String?)?.contains('Unauthorized') == true) { - debugPrint("API submission failed with Unauthorized. Attempting silent relogin..."); - final bool reloginSuccess = await authProvider.attemptSilentRelogin(); - - if (reloginSuccess) { - debugPrint("Silent relogin successful. Retrying data submission..."); - apiDataResult = await _submissionApiService.submitPost( - moduleName: moduleName, - endpoint: 'marine/tarball/sample', - body: data.toFormData(), - ); - } - } - if (apiDataResult['success'] == true) { anyApiSuccess = true; data.reportId = apiDataResult['data']?['autoid']?.toString(); @@ -129,12 +114,30 @@ class MarineTarballSamplingService { apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.'; } } + } on SessionExpiredException catch (_) { + debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin..."); + final bool reloginSuccess = await authProvider.attemptSilentRelogin(); + + if (reloginSuccess) { + debugPrint("Silent relogin successful. Retrying entire online submission process..."); + return await _performOnlineSubmission( + data: data, + appSettings: appSettings, + moduleName: moduleName, + authProvider: authProvider, + ); + } else { + debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP."); + isSessionKnownToBeExpired = true; + anyApiSuccess = false; + apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'}; + await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData()); + } } on SocketException catch (e) { final errorMessage = "API submission failed with network error: $e"; debugPrint(errorMessage); anyApiSuccess = false; apiDataResult = {'success': false, 'message': errorMessage}; - // Manually queue the failed API tasks since the service might not have been able to await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData()); if(finalImageFiles.isNotEmpty && data.reportId != null) { await _retryService.addApiToQueue(endpoint: 'marine/tarball/images', method: 'POST_MULTIPART', fields: {'autoid': data.reportId!}, files: finalImageFiles); @@ -147,8 +150,6 @@ class MarineTarballSamplingService { await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData()); } - // Step 2: Attempt FTP Submission in its own try-catch block. - // This code will now run even if the API submission above failed. Map ftpResults = {'statuses': []}; bool anyFtpSuccess = false; try { @@ -157,15 +158,11 @@ class MarineTarballSamplingService { } on SocketException catch (e) { debugPrint("FTP submission failed with network error: $e"); anyFtpSuccess = false; - // Note: The underlying SubmissionFtpService already queues failed uploads, - // so we just need to catch the error to prevent a crash. } on TimeoutException catch (e) { debugPrint("FTP submission timed out: $e"); anyFtpSuccess = false; } - // --- END: MODIFICATION FOR GRANULAR ERROR HANDLING --- - // Step 3: Determine final status based on the outcomes of the independent steps. final bool overallSuccess = anyApiSuccess || anyFtpSuccess; String finalMessage; String finalStatus; @@ -180,7 +177,7 @@ class MarineTarballSamplingService { finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.'; finalStatus = 'L4'; } else { - finalMessage = 'All submission attempts failed and have been queued for retry.'; + finalMessage = apiDataResult['message'] ?? 'All submission attempts failed and have been queued for retry.'; finalStatus = 'L1'; } @@ -195,7 +192,7 @@ class MarineTarballSamplingService { ); if (overallSuccess) { - _handleTarballSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty); + _handleTarballSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired); } return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId}; @@ -225,7 +222,7 @@ class MarineTarballSamplingService { }, ); - const successMessage = "No internet connection. Submission has been saved and queued for upload."; + const successMessage = "Submission failed to send and has been queued for later retry."; await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}); return {'success': true, 'message': successMessage}; @@ -276,8 +273,8 @@ class MarineTarballSamplingService { return { 'statuses': >[ - ...(ftpDataResult['statuses'] as List), - ...(ftpImageResult['statuses'] as List), + ...?(ftpDataResult['statuses'] as List?), + ...?(ftpImageResult['statuses'] as List?), ], }; } @@ -314,12 +311,17 @@ class MarineTarballSamplingService { await _dbHelper.saveSubmissionLog(logData); } - Future _handleTarballSuccessAlert(TarballSamplingData data, List>? appSettings, {required bool isDataOnly}) async { + Future _handleTarballSuccessAlert(TarballSamplingData data, List>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async { try { final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly); - final bool wasSent = await _telegramService.sendAlertImmediately('marine_tarball', message, appSettings); - if (!wasSent) { + if (isSessionExpired) { + debugPrint("Session is expired; queuing Telegram alert directly."); await _telegramService.queueMessage('marine_tarball', message, appSettings); + } else { + final bool wasSent = await _telegramService.sendAlertImmediately('marine_tarball', message, appSettings); + if (!wasSent) { + await _telegramService.queueMessage('marine_tarball', message, appSettings); + } } } catch (e) { debugPrint("Failed to handle Tarball Telegram alert: $e"); diff --git a/lib/services/retry_service.dart b/lib/services/retry_service.dart index 1d14b00..d086267 100644 --- a/lib/services/retry_service.dart +++ b/lib/services/retry_service.dart @@ -21,6 +21,7 @@ class RetryService { final BaseApiService _baseApiService = BaseApiService(); final FtpService _ftpService = FtpService(); final ServerConfigService _serverConfigService = ServerConfigService(); + bool _isProcessing = false; // --- START: MODIFICATION FOR HANDLING COMPLEX TASKS --- // These services will be provided after the RetryService is created. @@ -102,6 +103,37 @@ class RetryService { return _dbHelper.getPendingRequests(); } + /// Processes the entire queue of pending tasks. + Future processRetryQueue() async { + if (_isProcessing) { + debugPrint("[RetryService] ⏳ Queue is already being processed. Skipping."); + return; + } + _isProcessing = true; + debugPrint("[RetryService] ▶️ Starting to process main retry queue..."); + + final pendingTasks = await getPendingTasks(); + if (pendingTasks.isEmpty) { + debugPrint("[RetryService] ⏹️ Queue is empty. Nothing to process."); + _isProcessing = false; + return; + } + + if (_authProvider == null || !await _authProvider!.isConnected()) { + debugPrint("[RetryService] ❌ No internet connection. Aborting queue processing."); + _isProcessing = false; + return; + } + + debugPrint("[RetryService] 🔎 Found ${pendingTasks.length} pending tasks."); + for (final task in pendingTasks) { + await retryTask(task['id'] as int); + } + + debugPrint("[RetryService] ⏹️ Finished processing retry queue."); + _isProcessing = false; + } + /// Attempts to re-execute a single failed task from the queue. /// Returns `true` on success, `false` on failure. Future retryTask(int taskId) async { diff --git a/lib/services/river_in_situ_sampling_service.dart b/lib/services/river_in_situ_sampling_service.dart index 7f346a9..fad12f9 100644 --- a/lib/services/river_in_situ_sampling_service.dart +++ b/lib/services/river_in_situ_sampling_service.dart @@ -30,6 +30,7 @@ import 'submission_api_service.dart'; import 'submission_ftp_service.dart'; import 'telegram_service.dart'; import 'retry_service.dart'; +import 'base_api_service.dart'; // Import for SessionExpiredException class RiverInSituSamplingService { @@ -214,6 +215,7 @@ class RiverInSituSamplingService { bool anyApiSuccess = false; Map apiDataResult = {}; Map apiImageResult = {}; + bool isSessionKnownToBeExpired = false; try { apiDataResult = await _submissionApiService.submitPost( @@ -222,21 +224,6 @@ class RiverInSituSamplingService { body: data.toApiFormData(), ); - if (apiDataResult['success'] == false && - (apiDataResult['message'] as String?)?.contains('Unauthorized') == true) { - debugPrint("API submission failed with Unauthorized. Attempting silent relogin..."); - final bool reloginSuccess = await authProvider.attemptSilentRelogin(); - - if (reloginSuccess) { - debugPrint("Silent relogin successful. Retrying data submission..."); - apiDataResult = await _submissionApiService.submitPost( - moduleName: moduleName, - endpoint: 'river/manual/sample', - body: data.toApiFormData(), - ); - } - } - if (apiDataResult['success'] == true) { anyApiSuccess = true; data.reportId = apiDataResult['data']?['r_man_id']?.toString(); @@ -258,6 +245,26 @@ class RiverInSituSamplingService { apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.'; } } + } on SessionExpiredException catch (_) { + debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin..."); + final bool reloginSuccess = await authProvider.attemptSilentRelogin(); + + if (reloginSuccess) { + debugPrint("Silent relogin successful. Retrying entire online submission process..."); + return await _performOnlineSubmission( + data: data, + appSettings: appSettings, + moduleName: moduleName, + authProvider: authProvider, + logDirectory: logDirectory, + ); + } else { + debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP."); + isSessionKnownToBeExpired = true; + anyApiSuccess = false; + apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'}; + await _retryService.addApiToQueue(endpoint: 'river/manual/sample', method: 'POST', body: data.toApiFormData()); + } } on SocketException catch (e) { final errorMessage = "API submission failed with network error: $e"; debugPrint(errorMessage); @@ -302,7 +309,7 @@ class RiverInSituSamplingService { finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.'; finalStatus = 'L4'; } else { - finalMessage = 'All submission attempts failed and have been queued for retry.'; + finalMessage = apiDataResult['message'] ?? 'All submission attempts failed and have been queued for retry.'; finalStatus = 'L1'; } @@ -317,7 +324,7 @@ class RiverInSituSamplingService { ); if (overallSuccess) { - _handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty); + _handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired); } return { @@ -336,7 +343,7 @@ class RiverInSituSamplingService { final serverName = serverConfig?['config_name'] as String? ?? 'Default'; data.submissionStatus = 'L1'; - data.submissionMessage = 'Submission queued due to being offline.'; + data.submissionMessage = 'Submission queued for later retry.'; final String? localLogPath = await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName); @@ -355,7 +362,7 @@ class RiverInSituSamplingService { }, ); - const successMessage = "No internet connection. Submission has been saved and queued for upload."; + const successMessage = "Submission failed to send and has been queued for later retry."; return {'status': 'Queued', 'success': true, 'message': successMessage}; } @@ -376,7 +383,7 @@ class RiverInSituSamplingService { } final dataZip = await _zippingService.createDataZip( - jsonDataMap: {'db.json': data.toDbJson()}, + jsonDataMap: {'db.json': jsonEncode(data.toApiFormData())}, baseFileName: baseFileName, destinationDir: localSubmissionDir, ); @@ -447,43 +454,52 @@ class RiverInSituSamplingService { await _dbHelper.saveSubmissionLog(logData); } - Future _handleSuccessAlert(RiverInSituSamplingData data, List>? appSettings, {required bool isDataOnly}) async { + Future _handleSuccessAlert(RiverInSituSamplingData data, List>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async { try { - final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; - final stationName = data.selectedStation?['sampling_river'] ?? 'N/A'; - final stationCode = data.selectedStation?['sampling_station_code'] ?? 'N/A'; - final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now()); - final submitter = data.firstSamplerName ?? 'N/A'; - final sondeID = data.sondeId ?? 'N/A'; - final distanceKm = data.distanceDifferenceInKm ?? 0; - final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); - final distanceRemarks = data.distanceDifferenceRemarks ?? 'N/A'; - - final buffer = StringBuffer() - ..writeln('✅ *River In-Situ Sample ${submissionType} Submitted:*') - ..writeln() - ..writeln('*Station Name & Code:* $stationName ($stationCode)') - ..writeln('*Date of Submitted:* $submissionDate') - ..writeln('*Submitted by User:* $submitter') - ..writeln('*Sonde ID:* $sondeID') - ..writeln('*Status of Submission:* Successful'); - - if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { - buffer - ..writeln() - ..writeln('🔔 *Alert:*') - ..writeln('*Distance from station:* $distanceMeters meters'); - if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') { - buffer.writeln('*Remarks for distance:* $distanceRemarks'); - } - } - final String message = buffer.toString(); - final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings); - if (!wasSent) { + final message = await _generateInSituAlertMessage(data, isDataOnly: isDataOnly); + if (isSessionExpired) { + debugPrint("Session is expired; queuing Telegram alert directly."); await _telegramService.queueMessage('river_in_situ', message, appSettings); + } else { + final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings); + if (!wasSent) { + await _telegramService.queueMessage('river_in_situ', message, appSettings); + } } } catch (e) { debugPrint("Failed to handle River Telegram alert: $e"); } } + + Future _generateInSituAlertMessage(RiverInSituSamplingData data, {required bool isDataOnly}) async { + final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; + final stationName = data.selectedStation?['sampling_river'] ?? 'N/A'; + final stationCode = data.selectedStation?['sampling_station_code'] ?? 'N/A'; + final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now()); + final submitter = data.firstSamplerName ?? 'N/A'; + final sondeID = data.sondeId ?? 'N/A'; + final distanceKm = data.distanceDifferenceInKm ?? 0; + final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); + final distanceRemarks = data.distanceDifferenceRemarks ?? 'N/A'; + + final buffer = StringBuffer() + ..writeln('✅ *River In-Situ Sample ${submissionType} Submitted:*') + ..writeln() + ..writeln('*Station Name & Code:* $stationName ($stationCode)') + ..writeln('*Date of Submitted:* $submissionDate') + ..writeln('*Submitted by User:* $submitter') + ..writeln('*Sonde ID:* $sondeID') + ..writeln('*Status of Submission:* Successful'); + + if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { + buffer + ..writeln() + ..writeln('🔔 *Distance Alert:*') + ..writeln('*Distance from station:* $distanceMeters meters'); + if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') { + buffer.writeln('*Remarks for distance:* $distanceRemarks'); + } + } + return buffer.toString(); + } } \ No newline at end of file diff --git a/lib/services/river_manual_triennial_sampling_service.dart b/lib/services/river_manual_triennial_sampling_service.dart index d0b7e82..2b10b22 100644 --- a/lib/services/river_manual_triennial_sampling_service.dart +++ b/lib/services/river_manual_triennial_sampling_service.dart @@ -15,6 +15,7 @@ import 'package:usb_serial/usb_serial.dart'; import 'dart:convert'; import 'package:intl/intl.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:provider/provider.dart'; import '../auth_provider.dart'; import 'location_service.dart'; @@ -29,6 +30,7 @@ import 'submission_api_service.dart'; import 'submission_ftp_service.dart'; import 'telegram_service.dart'; import 'retry_service.dart'; +import 'base_api_service.dart'; // Import for SessionExpiredException class RiverManualTriennialSamplingService { @@ -213,6 +215,7 @@ class RiverManualTriennialSamplingService { bool anyApiSuccess = false; Map apiDataResult = {}; Map apiImageResult = {}; + bool isSessionKnownToBeExpired = false; try { apiDataResult = await _submissionApiService.submitPost( @@ -221,21 +224,6 @@ class RiverManualTriennialSamplingService { body: data.toApiFormData(), ); - if (apiDataResult['success'] == false && - (apiDataResult['message'] as String?)?.contains('Unauthorized') == true) { - debugPrint("API submission failed with Unauthorized. Attempting silent relogin..."); - final bool reloginSuccess = await authProvider.attemptSilentRelogin(); - - if (reloginSuccess) { - debugPrint("Silent relogin successful. Retrying data submission..."); - apiDataResult = await _submissionApiService.submitPost( - moduleName: moduleName, - endpoint: 'river/triennial/sample', - body: data.toApiFormData(), - ); - } - } - if (apiDataResult['success'] == true) { anyApiSuccess = true; data.reportId = apiDataResult['data']?['r_tri_id']?.toString(); @@ -257,6 +245,26 @@ class RiverManualTriennialSamplingService { apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.'; } } + } on SessionExpiredException catch (_) { + debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin..."); + final bool reloginSuccess = await authProvider.attemptSilentRelogin(); + + if (reloginSuccess) { + debugPrint("Silent relogin successful. Retrying entire online submission process..."); + return await _performOnlineSubmission( + data: data, + appSettings: appSettings, + moduleName: moduleName, + authProvider: authProvider, + logDirectory: logDirectory, + ); + } else { + debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP."); + isSessionKnownToBeExpired = true; + anyApiSuccess = false; + apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'}; + await _retryService.addApiToQueue(endpoint: 'river/triennial/sample', method: 'POST', body: data.toApiFormData()); + } } on SocketException catch (e) { final errorMessage = "API submission failed with network error: $e"; debugPrint(errorMessage); @@ -301,7 +309,7 @@ class RiverManualTriennialSamplingService { finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.'; finalStatus = 'L4'; } else { - finalMessage = 'All submission attempts failed and have been queued for retry.'; + finalMessage = apiDataResult['message'] ?? 'All submission attempts failed and have been queued for retry.'; finalStatus = 'L1'; } @@ -316,7 +324,7 @@ class RiverManualTriennialSamplingService { ); if (overallSuccess) { - _handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty); + _handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired); } return { @@ -335,7 +343,7 @@ class RiverManualTriennialSamplingService { final serverName = serverConfig?['config_name'] as String? ?? 'Default'; data.submissionStatus = 'L1'; - data.submissionMessage = 'Submission queued due to being offline.'; + data.submissionMessage = 'Submission queued for later retry.'; final String? localLogPath = await _localStorageService.saveRiverManualTriennialSamplingData(data, serverName: serverName); @@ -354,7 +362,7 @@ class RiverManualTriennialSamplingService { }, ); - const successMessage = "No internet connection. Submission has been saved and queued for upload."; + const successMessage = "Submission failed to send and has been queued for later retry."; return {'status': 'Queued', 'success': true, 'message': successMessage}; } @@ -446,43 +454,52 @@ class RiverManualTriennialSamplingService { await _dbHelper.saveSubmissionLog(logData); } - Future _handleSuccessAlert(RiverManualTriennialSamplingData data, List>? appSettings, {required bool isDataOnly}) async { + Future _handleSuccessAlert(RiverManualTriennialSamplingData data, List>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async { try { - final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; - final stationName = data.selectedStation?['sampling_river'] ?? 'N/A'; - final stationCode = data.selectedStation?['sampling_station_code'] ?? 'N/A'; - final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now()); - final submitter = data.firstSamplerName ?? 'N/A'; - final sondeID = data.sondeId ?? 'N/A'; - final distanceKm = data.distanceDifferenceInKm ?? 0; - final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); - final distanceRemarks = data.distanceDifferenceRemarks ?? 'N/A'; - - final buffer = StringBuffer() - ..writeln('✅ *River Triennial Sample ${submissionType} Submitted:*') - ..writeln() - ..writeln('*Station Name & Code:* $stationName ($stationCode)') - ..writeln('*Date of Submitted:* $submissionDate') - ..writeln('*Submitted by User:* $submitter') - ..writeln('*Sonde ID:* $sondeID') - ..writeln('*Status of Submission:* Successful'); - - if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { - buffer - ..writeln() - ..writeln('🔔 *Alert:*') - ..writeln('*Distance from station:* $distanceMeters meters'); - if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') { - buffer.writeln('*Remarks for distance:* $distanceRemarks'); - } - } - final String message = buffer.toString(); - final bool wasSent = await _telegramService.sendAlertImmediately('river_triennial', message, appSettings); - if (!wasSent) { + final message = await _generateSuccessAlertMessage(data, isDataOnly: isDataOnly); + if (isSessionExpired) { + debugPrint("Session is expired; queuing Telegram alert directly."); await _telegramService.queueMessage('river_triennial', message, appSettings); + } else { + final bool wasSent = await _telegramService.sendAlertImmediately('river_triennial', message, appSettings); + if (!wasSent) { + await _telegramService.queueMessage('river_triennial', message, appSettings); + } } } catch (e) { debugPrint("Failed to handle River Triennial Telegram alert: $e"); } } + + Future _generateSuccessAlertMessage(RiverManualTriennialSamplingData data, {required bool isDataOnly}) async { + final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; + final stationName = data.selectedStation?['sampling_river'] ?? 'N/A'; + final stationCode = data.selectedStation?['sampling_station_code'] ?? 'N/A'; + final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now()); + final submitter = data.firstSamplerName ?? 'N/A'; + final sondeID = data.sondeId ?? 'N/A'; + final distanceKm = data.distanceDifferenceInKm ?? 0; + final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); + final distanceRemarks = data.distanceDifferenceRemarks ?? 'N/A'; + + final buffer = StringBuffer() + ..writeln('✅ *River Triennial Sample ${submissionType} Submitted:*') + ..writeln() + ..writeln('*Station Name & Code:* $stationName ($stationCode)') + ..writeln('*Date of Submitted:* $submissionDate') + ..writeln('*Submitted by User:* $submitter') + ..writeln('*Sonde ID:* $sondeID') + ..writeln('*Status of Submission:* Successful'); + + if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { + buffer + ..writeln() + ..writeln('🔔 *Alert:*') + ..writeln('*Distance from station:* $distanceMeters meters'); + if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') { + buffer.writeln('*Remarks for distance:* $distanceRemarks'); + } + } + return buffer.toString(); + } } \ No newline at end of file diff --git a/lib/services/user_preferences_service.dart b/lib/services/user_preferences_service.dart index 2854c1f..fad2f53 100644 --- a/lib/services/user_preferences_service.dart +++ b/lib/services/user_preferences_service.dart @@ -1,27 +1,113 @@ // lib/services/user_preferences_service.dart import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:environment_monitoring_app/services/api_service.dart'; // Contains DatabaseHelper /// A dedicated service to manage the user's local preferences for /// module-specific submission destinations. class UserPreferencesService { final DatabaseHelper _dbHelper = DatabaseHelper(); + static const _defaultPrefsSavedKey = 'default_preferences_saved'; + + // Moved from settings.dart for central access + final List> _configurableModules = [ + {'key': 'marine_tarball', 'name': 'Marine Tarball'}, + {'key': 'marine_in_situ', 'name': 'Marine In-Situ'}, + {'key': 'river_in_situ', 'name': 'River In-Situ'}, + {'key': 'air_installation', 'name': 'Air Installation'}, + {'key': 'air_collection', 'name': 'Air Collection'}, + ]; + + /// Checks if default preferences have been set. If not, it applies + /// and saves the default submission destinations for all modules. + /// This ensures the app is ready for submissions immediately after the first login. + Future applyAndSaveDefaultPreferencesIfNeeded() async { + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(_defaultPrefsSavedKey) ?? false) { + // Defaults have already been saved for this session, do nothing. + return; + } + + debugPrint("Applying and auto-saving default submission preferences for the first time."); + + try { + // Get all possible configs from the database just once + final allApiConfigs = await _dbHelper.loadApiConfigs() ?? []; + final allFtpConfigs = await _dbHelper.loadFtpConfigs() ?? []; + + for (var module in _configurableModules) { + final moduleKey = module['key']!; + + // 1. Save master switches to enable API and FTP for the module. + await saveModulePreference( + moduleName: moduleKey, + isApiEnabled: true, + isFtpEnabled: true, + ); + + // 2. Determine default API links + final defaultApiLinks = allApiConfigs.map((config) { + bool isEnabled = config['config_name'] == 'PSTW_HQ'; + return {...config, 'is_enabled': isEnabled}; + }).toList(); + + // 3. Determine default FTP links + final defaultFtpLinks = allFtpConfigs.map((config) { + bool isEnabled = false; // Disable all by default + switch (moduleKey) { + case 'marine_tarball': + if (config['config_name'] == 'pstw_marine_tarball' || config['config_name'] == 'tes_marine_tarball') { + isEnabled = true; + } + break; + case 'marine_in_situ': + if (config['config_name'] == 'pstw_marine_manual' || config['config_name'] == 'tes_marine_manual') { + isEnabled = true; + } + break; + case 'river_in_situ': + if (config['config_name'] == 'pstw_river_manual' || config['config_name'] == 'tes_river_manual') { + isEnabled = true; + } + break; + case 'air_collection': + if (config['config_name'] == 'pstw_air_collect' || config['config_name'] == 'tes_air_collect') { + isEnabled = true; + } + break; + case 'air_installation': + if (config['config_name'] == 'pstw_air_install' || config['config_name'] == 'tes_air_install') { + isEnabled = true; + } + break; + } + return {...config, 'is_enabled': isEnabled}; + }).toList(); + + // 4. Save the default links to the database. + await saveApiLinksForModule(moduleKey, defaultApiLinks); + await saveFtpLinksForModule(moduleKey, defaultFtpLinks); + } + + // 5. Set the flag to prevent this from running again until next login. + await prefs.setBool(_defaultPrefsSavedKey, true); + debugPrint("Default submission preferences have been auto-saved."); + + } catch (e) { + debugPrint("Error auto-saving default preferences: $e"); + } + } + /// Retrieves a module's master submission preferences. - /// If no preference has been saved for this module, it returns a default - /// where both API and FTP are enabled. - Future> getModulePreference(String moduleName) async { + /// This method now returns null if no preference is found. + Future?> getModulePreference(String moduleName) async { final preference = await _dbHelper.getModulePreference(moduleName); if (preference != null) { return preference; } - // Return a default value if no preference is found in the database. - return { - 'module_name': moduleName, - 'is_api_enabled': true, - 'is_ftp_enabled': true, - }; + return null; } /// Saves or updates a module's master on/off switches for API and FTP submissions. @@ -119,10 +205,10 @@ class UserPreferencesService { /// destinations to send data to. Future>> getEnabledApiConfigsForModule(String moduleName) async { // 1. Check the master switch for the module. - final pref = await getModulePreference(moduleName); - if (!(pref['is_api_enabled'] as bool)) { - debugPrint("API submissions are disabled for module '$moduleName' via master switch."); - return []; // Return empty list if API is globally disabled for this module. + final pref = await _dbHelper.getModulePreference(moduleName); // Use direct DB call + if (pref == null || !(pref['is_api_enabled'] as bool)) { + debugPrint("API submissions are disabled for module '$moduleName'."); + return []; // Return empty list if API is disabled or not set. } // 2. Get all configs with their preference flags. @@ -137,9 +223,9 @@ class UserPreferencesService { /// Retrieves only the FTP configurations that are actively enabled for a given module. Future>> getEnabledFtpConfigsForModule(String moduleName) async { - final pref = await getModulePreference(moduleName); - if (!(pref['is_ftp_enabled'] as bool)) { - debugPrint("FTP submissions are disabled for module '$moduleName' via master switch."); + final pref = await _dbHelper.getModulePreference(moduleName); // Use direct DB call + if (pref == null || !(pref['is_ftp_enabled'] as bool)) { + debugPrint("FTP submissions are disabled for module '$moduleName'."); return []; } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 7299b5c..3ccd551 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,12 +7,16 @@ #include "generated_plugin_registrant.h" #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 786ff5c..9ce94c4 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux + flutter_secure_storage_linux url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 35373a0..9bfa37e 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,7 @@ import Foundation import connectivity_plus import file_picker import file_selector_macos +import flutter_secure_storage_macos import geolocator_apple import path_provider_foundation import shared_preferences_foundation @@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 7d6f478..f1b0669 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -271,6 +271,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.28" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_svg: dependency: "direct main" description: @@ -449,6 +497,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.18.1" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" json_annotation: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 540497d..ffe8351 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: path_provider: ^2.1.3 path: ^1.8.3 # Explicitly added for path manipulation connectivity_plus: ^6.0.1 + flutter_secure_storage: ^9.0.0 # ADDED: For securely storing the user's password # --- UI Components & Utilities --- cupertino_icons: ^1.0.8 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index edb244d..88102b6 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); GeolocatorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("GeolocatorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index d0234a0..208be0d 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST connectivity_plus file_selector_windows + flutter_secure_storage_windows geolocator_windows permission_handler_windows url_launcher_windows