fix invalid token and auto resubmit data to server

This commit is contained in:
ALim Aidrus 2025-10-14 09:43:34 +08:00
parent 768047ad18
commit c5453124fd
19 changed files with 950 additions and 314 deletions

View File

@ -6,16 +6,24 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:bcrypt/bcrypt.dart'; // Import bcrypt 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/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/server_config_service.dart';
import 'package:environment_monitoring_app/services/retry_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 { class AuthProvider with ChangeNotifier {
late final ApiService _apiService; late final ApiService _apiService;
late final DatabaseHelper _dbHelper; late final DatabaseHelper _dbHelper;
late final ServerConfigService _serverConfigService; late final ServerConfigService _serverConfigService;
late final RetryService _retryService; late final RetryService _retryService;
final UserPreferencesService _userPreferencesService = UserPreferencesService();
// NEW: Initialize secure storage
final _secureStorage = const FlutterSecureStorage();
static const _passwordStorageKey = 'user_password';
// --- Session & Profile State --- // --- Session & Profile State ---
String? _jwtToken; String? _jwtToken;
@ -25,9 +33,6 @@ class AuthProvider with ChangeNotifier {
String? get userEmail => _userEmail; String? get userEmail => _userEmail;
Map<String, dynamic>? get profileData => _profileData; Map<String, dynamic>? get profileData => _profileData;
// --- ADDED: Temporary password cache for auto-relogin ---
String? _tempOfflinePassword;
// --- App State --- // --- App State ---
bool _isLoading = true; bool _isLoading = true;
bool _isFirstLogin = true; bool _isFirstLogin = true;
@ -36,6 +41,11 @@ class AuthProvider with ChangeNotifier {
bool get isFirstLogin => _isFirstLogin; bool get isFirstLogin => _isFirstLogin;
DateTime? get lastSyncTimestamp => _lastSyncTimestamp; 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 --- // --- Cached Master Data ---
List<Map<String, dynamic>>? _allUsers; List<Map<String, dynamic>>? _allUsers;
List<Map<String, dynamic>>? _tarballStations; List<Map<String, dynamic>>? _tarballStations;
@ -91,6 +101,7 @@ class AuthProvider with ChangeNotifier {
static const String profileDataKey = 'user_profile_data'; static const String profileDataKey = 'user_profile_data';
static const String lastSyncTimestampKey = 'last_sync_timestamp'; static const String lastSyncTimestampKey = 'last_sync_timestamp';
static const String isFirstLoginKey = 'is_first_login'; static const String isFirstLoginKey = 'is_first_login';
static const String lastOnlineLoginKey = 'last_online_login';
AuthProvider({ AuthProvider({
required ApiService apiService, required ApiService apiService,
@ -105,6 +116,11 @@ class AuthProvider with ChangeNotifier {
_loadSessionAndSyncData(); _loadSessionAndSyncData();
} }
Future<bool> isConnected() async {
final connectivityResult = await Connectivity().checkConnectivity();
return !connectivityResult.contains(ConnectivityResult.none);
}
Future<void> _loadSessionAndSyncData() async { Future<void> _loadSessionAndSyncData() async {
_isLoading = true; _isLoading = true;
notifyListeners(); notifyListeners();
@ -139,6 +155,7 @@ class AuthProvider with ChangeNotifier {
if (_jwtToken != null) { if (_jwtToken != null) {
debugPrint('AuthProvider: Session loaded.'); debugPrint('AuthProvider: Session loaded.');
await _userPreferencesService.applyAndSaveDefaultPreferencesIfNeeded();
// Sync logic moved to checkAndTransitionToOnlineSession to handle transitions correctly // Sync logic moved to checkAndTransitionToOnlineSession to handle transitions correctly
} else { } else {
debugPrint('AuthProvider: No active session. App is in offline mode.'); 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. /// Returns true if a successful transition occurred.
Future<bool> checkAndTransitionToOnlineSession() async { Future<bool> checkAndTransitionToOnlineSession() async {
// Condition 1: Check connectivity // Condition 1: Check connectivity
final connectivityResult = await Connectivity().checkConnectivity(); if (!(await isConnected())) {
if (connectivityResult.contains(ConnectivityResult.none)) {
debugPrint("AuthProvider: No internet connection. Skipping transition check."); debugPrint("AuthProvider: No internet connection. Skipping transition check.");
return false; return false;
} }
@ -165,21 +181,27 @@ class AuthProvider with ChangeNotifier {
// If online, trigger a normal sync to ensure data freshness on connection restoration. // If online, trigger a normal sync to ensure data freshness on connection restoration.
if(_jwtToken != null) { if(_jwtToken != null) {
debugPrint("AuthProvider: Session is already online. Triggering standard sync."); 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; return false;
} }
// Condition 3: Check if we have the temporary password to attempt re-login. // FIX: Read password from secure storage instead of temporary variable.
if (_tempOfflinePassword == null || _userEmail == null) { final String? password = await _secureStorage.read(key: _passwordStorageKey);
debugPrint("AuthProvider: In offline session, but no temporary password available for auto-relogin. Manual login required."); if (password == null || _userEmail == null) {
debugPrint("AuthProvider: In offline session, but no password in secure storage for auto-relogin. Manual login required.");
return false; return false;
} }
debugPrint("AuthProvider: Internet detected in offline session. Attempting silent re-login for $_userEmail..."); debugPrint("AuthProvider: Internet detected in offline session. Attempting silent re-login for $_userEmail...");
try { try {
final result = await _apiService.login(_userEmail!, _tempOfflinePassword!); final result = await _apiService.login(_userEmail!, password);
if (result['success'] == true) { if (result['success'] == true) {
debugPrint("AuthProvider: Silent re-login successful. Transitioning to online session."); debugPrint("AuthProvider: Silent re-login successful. Transitioning to online session.");
@ -187,11 +209,8 @@ class AuthProvider with ChangeNotifier {
final Map<String, dynamic> profile = result['data']['profile']; final Map<String, dynamic> profile = result['data']['profile'];
// Use existing login method to set up session and trigger sync. // 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, password);
await login(token, profile, _tempOfflinePassword!);
// Clear temporary password after successful transition
_tempOfflinePassword = null;
notifyListeners(); // Ensure UI updates after state change notifyListeners(); // Ensure UI updates after state change
return true; return true;
} else { } 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<void> 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. /// Attempts to silently re-login to get a new token.
/// This can be called when a 401 Unauthorized error is detected. /// This can be called when a 401 Unauthorized error is detected.
Future<bool> attemptSilentRelogin() async { Future<bool> attemptSilentRelogin() async {
final connectivityResult = await Connectivity().checkConnectivity(); if (!(await isConnected())) {
if (connectivityResult.contains(ConnectivityResult.none)) {
debugPrint("AuthProvider: No internet for silent relogin."); debugPrint("AuthProvider: No internet for silent relogin.");
return false; return false;
} }
if (_tempOfflinePassword == null || _userEmail == null) { // FIX: Read password from secure storage.
debugPrint("AuthProvider: No cached credentials for silent relogin."); 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; return false;
} }
debugPrint("AuthProvider: Session may be expired. Attempting silent re-login for $_userEmail..."); debugPrint("AuthProvider: Session may be expired. Attempting silent re-login for $_userEmail...");
try { try {
final result = await _apiService.login(_userEmail!, _tempOfflinePassword!); final result = await _apiService.login(_userEmail!, password);
if (result['success'] == true) { if (result['success'] == true) {
debugPrint("AuthProvider: Silent re-login successful."); debugPrint("AuthProvider: Silent re-login successful.");
final String token = result['data']['token']; final String token = result['data']['token'];
final Map<String, dynamic> profile = result['data']['profile']; final Map<String, dynamic> 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; return true;
} else { } else {
debugPrint("AuthProvider: Silent re-login failed: ${result['message']}"); debugPrint("AuthProvider: Silent re-login failed: ${result['message']}");
@ -240,46 +300,69 @@ class AuthProvider with ChangeNotifier {
} }
Future<void> syncAllData({bool forceRefresh = false}) async { Future<void> syncAllData({bool forceRefresh = false}) async {
final connectivityResult = await Connectivity().checkConnectivity(); if (!(await isConnected())) {
if (connectivityResult.contains(ConnectivityResult.none)) {
debugPrint("AuthProvider: Device is OFFLINE. Skipping sync."); debugPrint("AuthProvider: Device is OFFLINE. Skipping sync.");
return; 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-")) { if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) {
debugPrint("AuthProvider: Skipping sync, session is offline or null."); debugPrint("AuthProvider: Skipping sync, session is offline or null.");
return; return;
} }
debugPrint("AuthProvider: Device is ONLINE. Starting delta sync."); try {
final prefs = await SharedPreferences.getInstance(); debugPrint("AuthProvider: Device is ONLINE. Starting delta sync.");
final String? lastSync = forceRefresh ? null : prefs.getString(lastSyncTimestampKey); final prefs = await SharedPreferences.getInstance();
final newSyncTimestamp = DateTime.now().toUtc().toIso8601String(); 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']) { if (result['success']) {
debugPrint("AuthProvider: Delta sync successful. Updating last sync timestamp."); debugPrint("AuthProvider: Delta sync successful.");
await prefs.setString(lastSyncTimestampKey, newSyncTimestamp); final newSyncTimestamp = DateTime.now().toUtc().toIso8601String();
_lastSyncTimestamp = DateTime.parse(newSyncTimestamp); await prefs.setString(lastSyncTimestampKey, newSyncTimestamp);
_lastSyncTimestamp = DateTime.parse(newSyncTimestamp);
if (_isFirstLogin) { if (_isFirstLogin) {
await setIsFirstLogin(false); await setIsFirstLogin(false);
debugPrint("AuthProvider: First successful sync complete. isFirstLogin flag set to 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.');
} }
} on SessionExpiredException {
await _loadDataFromCache(); debugPrint("AuthProvider: Session expired during sync. Attempting silent re-login...");
notifyListeners(); final bool reauthenticated = await attemptSilentRelogin();
} else { if (reauthenticated) {
debugPrint("AuthProvider: Delta sync failed. Timestamp not updated."); 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 --- // --- START: NEW METHOD FOR REGISTRATION SCREEN ---
Future<void> syncRegistrationData() async { Future<void> syncRegistrationData() async {
final connectivityResult = await Connectivity().checkConnectivity(); if (!(await isConnected())) {
if (connectivityResult.contains(ConnectivityResult.none)) {
debugPrint("AuthProvider: Device is OFFLINE. Skipping registration data sync."); debugPrint("AuthProvider: Device is OFFLINE. Skipping registration data sync.");
return; return;
} }
@ -298,13 +381,12 @@ class AuthProvider with ChangeNotifier {
// --- END: NEW METHOD FOR REGISTRATION SCREEN --- // --- END: NEW METHOD FOR REGISTRATION SCREEN ---
Future<void> refreshProfile() async { Future<void> refreshProfile() async {
final connectivityResult = await Connectivity().checkConnectivity(); if (!(await isConnected())) {
if (connectivityResult.contains(ConnectivityResult.none)) {
debugPrint("AuthProvider: Device is OFFLINE. Skipping profile refresh."); debugPrint("AuthProvider: Device is OFFLINE. Skipping profile refresh.");
return; return;
} }
if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) { if (_isSessionExpired || _jwtToken == null || _jwtToken!.startsWith("offline-session-")) {
debugPrint("AuthProvider: Skipping profile refresh, session is offline or null."); debugPrint("AuthProvider: Skipping profile refresh, session is offline, expired, or null.");
return; return;
} }
@ -314,6 +396,33 @@ class AuthProvider with ChangeNotifier {
} }
} }
Future<void> 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<void> _loadDataFromCache() async { Future<void> _loadDataFromCache() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final profileJson = prefs.getString(profileDataKey); final profileJson = prefs.getString(profileDataKey);
@ -357,8 +466,9 @@ class AuthProvider with ChangeNotifier {
Future<void> login(String token, Map<String, dynamic> profile, String password) async { Future<void> login(String token, Map<String, dynamic> profile, String password) async {
_jwtToken = token; _jwtToken = token;
_userEmail = profile['email']; _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<String, dynamic> profileWithToken = Map.from(profile); final Map<String, dynamic> profileWithToken = Map.from(profile);
profileWithToken['token'] = token; profileWithToken['token'] = token;
@ -368,6 +478,7 @@ class AuthProvider with ChangeNotifier {
await prefs.setString(tokenKey, token); await prefs.setString(tokenKey, token);
await prefs.setString(userEmailKey, _userEmail!); await prefs.setString(userEmailKey, _userEmail!);
await prefs.setString(profileDataKey, jsonEncode(_profileData)); await prefs.setString(profileDataKey, jsonEncode(_profileData));
await prefs.setString(lastOnlineLoginKey, DateTime.now().toIso8601String());
await _dbHelper.saveProfile(_profileData!); await _dbHelper.saveProfile(_profileData!);
try { try {
@ -383,6 +494,7 @@ class AuthProvider with ChangeNotifier {
} }
debugPrint('AuthProvider: Login successful. Session and profile persisted.'); debugPrint('AuthProvider: Login successful. Session and profile persisted.');
await _userPreferencesService.applyAndSaveDefaultPreferencesIfNeeded();
await syncAllData(forceRefresh: true); await syncAllData(forceRefresh: true);
} }
@ -415,8 +527,8 @@ class AuthProvider with ChangeNotifier {
_jwtToken = "offline-session-${DateTime.now().millisecondsSinceEpoch}"; _jwtToken = "offline-session-${DateTime.now().millisecondsSinceEpoch}";
_userEmail = email; _userEmail = email;
// --- MODIFIED: Cache the password on successful OFFLINE login --- // FIX: Save password to secure storage for future auto-relogin.
_tempOfflinePassword = password; await _secureStorage.write(key: _passwordStorageKey, value: password);
final Map<String, dynamic> profileWithToken = Map.from(cachedProfile); final Map<String, dynamic> profileWithToken = Map.from(cachedProfile);
profileWithToken['token'] = _jwtToken; profileWithToken['token'] = _jwtToken;
@ -467,8 +579,10 @@ class AuthProvider with ChangeNotifier {
_profileData = null; _profileData = null;
_lastSyncTimestamp = null; _lastSyncTimestamp = null;
_isFirstLogin = true; _isFirstLogin = true;
// --- MODIFIED: Clear temp password on logout --- _isSessionExpired = false; // Reset session expired flag on logout
_tempOfflinePassword = null;
// FIX: Clear password from secure storage on logout.
await _secureStorage.delete(key: _passwordStorageKey);
_allUsers = null; _allUsers = null;
_tarballStations = null; _tarballStations = null;
@ -500,6 +614,8 @@ class AuthProvider with ChangeNotifier {
await prefs.remove(userEmailKey); await prefs.remove(userEmailKey);
await prefs.remove(profileDataKey); await prefs.remove(profileDataKey);
await prefs.remove(lastSyncTimestampKey); await prefs.remove(lastSyncTimestampKey);
await prefs.remove(lastOnlineLoginKey);
await prefs.remove('default_preferences_saved');
await prefs.setBool(isFirstLoginKey, true); await prefs.setBool(isFirstLoginKey, true);
debugPrint('AuthProvider: All session and cached data cleared.'); debugPrint('AuthProvider: All session and cached data cleared.');

View File

@ -3,6 +3,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'dart:async'; // Import Timer
import 'package:provider/single_child_widget.dart'; import 'package:provider/single_child_widget.dart';
import 'package:environment_monitoring_app/services/api_service.dart'; import 'package:environment_monitoring_app/services/api_service.dart';
@ -101,31 +102,42 @@ void main() async {
final DatabaseHelper databaseHelper = DatabaseHelper(); final DatabaseHelper databaseHelper = DatabaseHelper();
final TelegramService telegramService = TelegramService(); final TelegramService telegramService = TelegramService();
final ApiService apiService = ApiService(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); 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( runApp(
MultiProvider( MultiProvider(
providers: <SingleChildWidget>[ providers: <SingleChildWidget>[
ChangeNotifierProvider( ChangeNotifierProvider.value(value: authProvider),
create: (context) => AuthProvider(
apiService: apiService,
dbHelper: databaseHelper,
serverConfigService: ServerConfigService(),
retryService: RetryService(),
),
),
// Providers for core services
Provider<ApiService>(create: (_) => apiService), Provider<ApiService>(create: (_) => apiService),
Provider<DatabaseHelper>(create: (_) => databaseHelper), Provider<DatabaseHelper>(create: (_) => databaseHelper),
Provider<TelegramService>(create: (_) => telegramService), Provider<TelegramService>(create: (_) => telegramService),
Provider(create: (_) => LocalStorageService()), Provider(create: (_) => LocalStorageService()),
Provider.value(value: retryService),
Provider(create: (context) => RiverInSituSamplingService(telegramService)), Provider.value(value: marineInSituService),
Provider.value(value: riverInSituService),
Provider(create: (context) => RiverManualTriennialSamplingService(telegramService)), Provider(create: (context) => RiverManualTriennialSamplingService(telegramService)),
Provider(create: (context) => AirSamplingService(databaseHelper, telegramService)), Provider(create: (context) => AirSamplingService(databaseHelper, telegramService)),
Provider(create: (context) => MarineInSituSamplingService(telegramService)),
Provider(create: (context) => MarineTarballSamplingService(telegramService)), Provider(create: (context) => MarineTarballSamplingService(telegramService)),
Provider(create: (context) => MarineNpeReportService(Provider.of<TelegramService>(context, listen: false))), Provider(create: (context) => MarineNpeReportService(Provider.of<TelegramService>(context, listen: false))),
Provider(create: (context) => MarineManualPreDepartureService()), Provider(create: (context) => MarineManualPreDepartureService()),
@ -137,14 +149,22 @@ void main() async {
); );
} }
void setupServices(TelegramService telegramService) { void setupPeriodicServices(TelegramService telegramService, RetryService retryService) {
// Initial alert processing on startup (delayed) // Initial processing on startup (delayed)
Future.delayed(const Duration(seconds: 5), () { Future.delayed(const Duration(seconds: 5), () {
debugPrint("[Main] Performing initial alert queue processing on app start."); debugPrint("[Main] Performing initial alert queue processing on app start.");
telegramService.processAlertQueue(); 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 --- // --- START: MODIFIED RootApp ---
@ -169,7 +189,17 @@ class _RootAppState extends State<RootApp> {
// Wait a moment for providers to be fully available. // Wait a moment for providers to be fully available.
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
if (mounted) { if (mounted) {
Provider.of<AuthProvider>(context, listen: false).checkAndTransitionToOnlineSession(); final authProvider = Provider.of<AuthProvider>(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<RootApp> {
// Access services from provider context // Access services from provider context
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final telegramService = Provider.of<TelegramService>(context, listen: false); final telegramService = Provider.of<TelegramService>(context, listen: false);
final retryService = Provider.of<RetryService>(context, listen: false);
// Attempt to auto-relogin if necessary // When connection is restored, always try to transition/validate the session.
authProvider.checkAndTransitionToOnlineSession(); authProvider.checkAndTransitionToOnlineSession().then((didTransition) {
if (!didTransition) {
authProvider.validateAndRefreshSession();
}
});
// Process alert queue // Process queues
telegramService.processAlertQueue(); telegramService.processAlertQueue();
retryService.processRetryQueue();
} }
} else { } else {
debugPrint("[Main] Internet connection lost."); debugPrint("[Main] Internet connection lost.");
@ -203,7 +239,7 @@ class _RootAppState extends State<RootApp> {
if (auth.isLoading) { if (auth.isLoading) {
homeWidget = const SplashScreen(); homeWidget = const SplashScreen();
} else if (auth.isLoggedIn) { } else if (auth.isLoggedIn) {
homeWidget = const HomePage(); homeWidget = const SessionAwareWrapper(child: HomePage());
} else { } else {
homeWidget = const LoginScreen(); homeWidget = const LoginScreen();
} }
@ -321,6 +357,73 @@ class _RootAppState extends State<RootApp> {
} }
// --- END: MODIFIED RootApp --- // --- END: MODIFIED RootApp ---
class SessionAwareWrapper extends StatefulWidget {
final Widget child;
const SessionAwareWrapper({super.key, required this.child});
@override
State<SessionAwareWrapper> createState() => _SessionAwareWrapperState();
}
class _SessionAwareWrapperState extends State<SessionAwareWrapper> {
bool _isDialogShowing = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final auth = Provider.of<AuthProvider>(context);
if (auth.isSessionExpired && !_isDialogShowing) {
// Use addPostFrameCallback to show dialog after the build phase.
WidgetsBinding.instance.addPostFrameCallback((_) {
_showSessionExpiredDialog();
});
}
}
Future<void> _showSessionExpiredDialog() async {
setState(() => _isDialogShowing = true);
await showDialog(
context: context,
barrierDismissible: false, // User must make a choice
builder: (BuildContext dialogContext) {
final auth = Provider.of<AuthProvider>(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: <Widget>[
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 { class SplashScreen extends StatelessWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});

View File

@ -135,13 +135,102 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
} }
} }
// --- MODIFIED: This function now validates distance and shows a dialog if needed --- // --- START: New function to find and show nearby stations ---
Future<void> _findAndShowNearbyStations() async {
if (_data.currentLatitude == null || _data.currentLatitude!.isEmpty) {
await _getCurrentLocation();
if (!mounted || _data.currentLatitude == null || _data.currentLatitude!.isEmpty) {
return;
}
}
final auth = Provider.of<AuthProvider>(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<Map<String, dynamic>> 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<Map<String, dynamic>>(
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<String, dynamic> station) {
final allStations = Provider.of<AuthProvider>(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<String>()
.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() { void _goToNextStep() {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
_formKey.currentState!.save(); _formKey.currentState!.save();
final distanceInMeters = (_data.distanceDifference ?? 0) * 1000; final distanceInMeters = (_data.distanceDifference ?? 0) * 1000;
if (distanceInMeters > 700) { // START MODIFICATION: Distance reduced to 50m
if (distanceInMeters > 50) {
// END MODIFICATION
_showDistanceRemarkDialog(); _showDistanceRemarkDialog();
} else { } else {
_data.distanceDifferenceRemarks = null; // Clear old remarks if within range _data.distanceDifferenceRemarks = null; // Clear old remarks if within range
@ -153,7 +242,7 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
} }
} }
// --- NEW: This function displays the mandatory remarks dialog --- // NEW: This function displays the mandatory remarks dialog
Future<void> _showDistanceRemarkDialog() async { Future<void> _showDistanceRemarkDialog() async {
final remarkController = TextEditingController(text: _data.distanceDifferenceRemarks); final remarkController = TextEditingController(text: _data.distanceDifferenceRemarks);
final dialogFormKey = GlobalKey<FormState>(); final dialogFormKey = GlobalKey<FormState>();
@ -171,7 +260,9 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: remarkController, controller: remarkController,
@ -335,6 +426,19 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
TextFormField(controller: _stationLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Latitude')), TextFormField(controller: _stationLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Latitude')),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField(controller: _stationLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Longitude')), 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), const SizedBox(height: 24),
Text("Location Verification", style: Theme.of(context).textTheme.titleLarge), Text("Location Verification", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -344,13 +448,14 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
if (_data.distanceDifference != null) if (_data.distanceDifference != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 16.0), 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( child: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
// START MODIFICATION: Distance reduced to 50m
decoration: BoxDecoration( 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), 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( child: RichText(
textAlign: TextAlign.center, textAlign: TextAlign.center,
@ -362,11 +467,12 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
text: '${(_data.distanceDifference! * 1000).toStringAsFixed(0)} meters', text: '${(_data.distanceDifference! * 1000).toStringAsFixed(0)} meters',
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, 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), const SizedBox(height: 16),
@ -387,3 +493,49 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
); );
} }
} }
// --- START: New Dialog Widget for Nearby Stations ---
class _NearbyStationsDialog extends StatelessWidget {
final List<Map<String, dynamic>> 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<String, dynamic>;
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 ---

View File

@ -6,6 +6,7 @@ import 'package:intl/intl.dart';
import 'package:environment_monitoring_app/auth_provider.dart'; import 'package:environment_monitoring_app/auth_provider.dart';
import 'package:environment_monitoring_app/services/settings_service.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/user_preferences_service.dart';
import 'package:environment_monitoring_app/services/api_service.dart'; // Import for DatabaseHelper access
class _ModuleSettings { class _ModuleSettings {
bool isApiEnabled; bool isApiEnabled;
@ -34,6 +35,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _isSyncingData = false; bool _isSyncingData = false;
final UserPreferencesService _preferencesService = UserPreferencesService(); final UserPreferencesService _preferencesService = UserPreferencesService();
final DatabaseHelper _dbHelper = DatabaseHelper(); // Add instance for direct access
bool _isLoadingSettings = true; bool _isLoadingSettings = true;
bool _isSaving = false; bool _isSaving = false;
@ -146,64 +148,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
setState(() => _isLoadingSettings = true); setState(() => _isLoadingSettings = true);
for (var module in _configurableModules) { for (var module in _configurableModules) {
final moduleKey = module['key']!; 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 prefs = await _preferencesService.getModulePreference(moduleKey);
final apiConfigsWithPrefs = await _preferencesService.getAllApiConfigsWithModulePreferences(moduleKey); final apiConfigsWithPrefs = await _preferencesService.getAllApiConfigsWithModulePreferences(moduleKey);
final ftpConfigsWithPrefs = await _preferencesService.getAllFtpConfigsWithModulePreferences(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( _moduleSettings[moduleKey] = _ModuleSettings(
isApiEnabled: prefs['is_api_enabled'], isApiEnabled: prefs?['is_api_enabled'] ?? true, // Fallback to true if null
isFtpEnabled: prefs['is_ftp_enabled'], isFtpEnabled: prefs?['is_ftp_enabled'] ?? true, // Fallback to true if null
apiConfigs: apiConfigsWithPrefs, apiConfigs: apiConfigsWithPrefs,
ftpConfigs: ftpConfigsWithPrefs, ftpConfigs: ftpConfigsWithPrefs,
); );
@ -246,16 +200,39 @@ class _SettingsScreenState extends State<SettingsScreen> {
final auth = Provider.of<AuthProvider>(context, listen: false); final auth = Provider.of<AuthProvider>(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 { try {
// AuthProvider's syncAllData will internally handle token validation and attempt a silent re-login if necessary.
await auth.syncAllData(forceRefresh: true); await auth.syncAllData(forceRefresh: true);
await _loadAllModuleSettings(); await _loadAllModuleSettings(); // Reload settings on successful sync
if (mounted) { if (mounted) {
_showSnackBar('Data synced successfully.', isError: false); _showSnackBar('Data synced successfully.', isError: false);
} }
} catch (e) { } catch (e) {
// 3. Handle failures
if (mounted) { 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 { } finally {
if (mounted) { if (mounted) {
@ -264,6 +241,33 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
} }
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<AuthProvider>(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}) { void _showSnackBar(String message, {bool isError = false}) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(

View File

@ -154,6 +154,15 @@ class ApiService {
return result; return result;
} }
/// Validates the current session token by making a lightweight API call.
/// Throws [SessionExpiredException] if the token is invalid (401).
Future<void> 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 --- // --- REWRITTEN FOR DELTA SYNC ---
/// Helper method to make a delta-sync API call. /// Helper method to make a delta-sync API call.
@ -344,7 +353,8 @@ class ApiService {
return {'success': true, 'message': 'Delta sync successful.'}; return {'success': true, 'message': 'Delta sync successful.'};
} catch (e) { } catch (e) {
debugPrint('ApiService: Delta data sync failed: $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;
} }
} }

View File

@ -10,6 +10,15 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:environment_monitoring_app/auth_provider.dart'; 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. /// A low-level service for making direct HTTP requests.
/// This service is now "dumb" and only sends a request to the specific /// This service is now "dumb" and only sends a request to the specific
/// baseUrl provided. It no longer contains logic for server fallbacks. /// 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()) final response = await http.get(url, headers: await _getJsonHeaders())
.timeout(const Duration(seconds: 60)); .timeout(const Duration(seconds: 60));
return _handleResponse(response); return _handleResponse(response);
} on SessionExpiredException {
rethrow; // CRITICAL FIX: Allow SessionExpiredException to propagate up.
} on SocketException catch (e) { } on SocketException catch (e) {
debugPrint('BaseApiService GET network error: $e'); debugPrint('BaseApiService GET network error: $e');
rethrow; // Re-throw network-related exceptions to be caught by higher-level logic (e.g., login screen) 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), body: jsonEncode(body),
).timeout(const Duration(seconds: 60)); // Note: login.dart applies its own shorter timeout over this. ).timeout(const Duration(seconds: 60)); // Note: login.dart applies its own shorter timeout over this.
return _handleResponse(response); return _handleResponse(response);
} on SessionExpiredException {
rethrow; // CRITICAL FIX: Allow SessionExpiredException to propagate up.
} on SocketException catch (e) { } on SocketException catch (e) {
debugPrint('BaseApiService POST network error: $e'); debugPrint('BaseApiService POST network error: $e');
rethrow; // Re-throw network-related exceptions to be caught by higher-level logic (e.g., login screen) 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(); final responseBody = await streamedResponse.stream.bytesToString();
return _handleResponse(http.Response(responseBody, streamedResponse.statusCode)); return _handleResponse(http.Response(responseBody, streamedResponse.statusCode));
} on SessionExpiredException {
rethrow; // CRITICAL FIX: Allow SessionExpiredException to propagate up.
} on SocketException catch (e) { } on SocketException catch (e) {
debugPrint('BaseApiService Multipart network error: $e'); debugPrint('BaseApiService Multipart network error: $e');
rethrow; // Re-throw network-related exceptions rethrow; // Re-throw network-related exceptions
@ -121,6 +136,12 @@ class BaseApiService {
Map<String, dynamic> _handleResponse(http.Response response) { Map<String, dynamic> _handleResponse(http.Response response) {
debugPrint('Handling response. Status: ${response.statusCode}, Body: ${response.body}'); 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 {
// Try to parse the response body as JSON. // Try to parse the response body as JSON.
final Map<String, dynamic> responseData = jsonDecode(response.body); final Map<String, dynamic> responseData = jsonDecode(response.body);

View File

@ -29,6 +29,7 @@ import 'submission_api_service.dart';
import 'submission_ftp_service.dart'; import 'submission_ftp_service.dart';
import 'telegram_service.dart'; import 'telegram_service.dart';
import 'retry_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. /// A dedicated service to handle all business logic for the Marine In-Situ Sampling feature.
@ -217,6 +218,7 @@ class MarineInSituSamplingService {
bool anyApiSuccess = false; bool anyApiSuccess = false;
Map<String, dynamic> apiDataResult = {}; Map<String, dynamic> apiDataResult = {};
Map<String, dynamic> apiImageResult = {}; Map<String, dynamic> apiImageResult = {};
bool isSessionKnownToBeExpired = false;
try { try {
apiDataResult = await _submissionApiService.submitPost( apiDataResult = await _submissionApiService.submitPost(
@ -225,21 +227,6 @@ class MarineInSituSamplingService {
body: data.toApiFormData(), 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) { if (apiDataResult['success'] == true) {
anyApiSuccess = true; anyApiSuccess = true;
data.reportId = apiDataResult['data']?['man_id']?.toString(); 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.'; 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) { } on SocketException catch (e) {
final errorMessage = "API submission failed with network error: $e"; final errorMessage = "API submission failed with network error: $e";
debugPrint(errorMessage); debugPrint(errorMessage);
@ -305,7 +312,7 @@ class MarineInSituSamplingService {
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.'; finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
finalStatus = 'L4'; finalStatus = 'L4';
} else { } 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'; finalStatus = 'L1';
} }
@ -321,7 +328,7 @@ class MarineInSituSamplingService {
); );
if (overallSuccess) { if (overallSuccess) {
_handleInSituSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty); _handleInSituSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
} }
return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId}; return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId};
@ -336,7 +343,7 @@ class MarineInSituSamplingService {
// Set initial status before first save // Set initial status before first save
data.submissionStatus = 'L1'; 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); 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 = "Submission failed to send and has been queued for later retry.";
const successMessage = "No internet connection. Submission has been saved and queued for upload.";
return {'success': true, 'message': successMessage}; return {'success': true, 'message': successMessage};
} }
@ -428,10 +434,8 @@ class MarineInSituSamplingService {
if (logDirectory != null) { if (logDirectory != null) {
final Map<String, dynamic> updatedLogData = data.toDbJson(); final Map<String, dynamic> updatedLogData = data.toDbJson();
// --- START FIX: Explicitly add final status and message to the update map ---
updatedLogData['submissionStatus'] = status; updatedLogData['submissionStatus'] = status;
updatedLogData['submissionMessage'] = message; updatedLogData['submissionMessage'] = message;
// --- END FIX ---
updatedLogData['logDirectory'] = logDirectory; updatedLogData['logDirectory'] = logDirectory;
updatedLogData['serverConfigName'] = serverName; updatedLogData['serverConfigName'] = serverName;
@ -468,12 +472,17 @@ class MarineInSituSamplingService {
await _dbHelper.saveSubmissionLog(logData); await _dbHelper.saveSubmissionLog(logData);
} }
Future<void> _handleInSituSuccessAlert(InSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async { Future<void> _handleInSituSuccessAlert(InSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
try { try {
final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly); final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly);
final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings); if (isSessionExpired) {
if (!wasSent) { debugPrint("Session is expired; queuing Telegram alert directly.");
await _telegramService.queueMessage('marine_in_situ', message, appSettings); 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) { } catch (e) {
debugPrint("Failed to handle In-Situ Telegram alert: $e"); debugPrint("Failed to handle In-Situ Telegram alert: $e");

View File

@ -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/telegram_service.dart';
import 'package:environment_monitoring_app/services/retry_service.dart'; import 'package:environment_monitoring_app/services/retry_service.dart';
import 'package:environment_monitoring_app/auth_provider.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. /// A dedicated service to handle all business logic for the Marine Tarball Sampling feature.
class MarineTarballSamplingService { class MarineTarballSamplingService {
@ -85,9 +86,8 @@ class MarineTarballSamplingService {
bool anyApiSuccess = false; bool anyApiSuccess = false;
Map<String, dynamic> apiDataResult = {}; Map<String, dynamic> apiDataResult = {};
Map<String, dynamic> apiImageResult = {}; Map<String, dynamic> apiImageResult = {};
bool isSessionKnownToBeExpired = false;
// --- START: MODIFICATION FOR GRANULAR ERROR HANDLING ---
// Step 1: Attempt API Submission in its own try-catch block.
try { try {
apiDataResult = await _submissionApiService.submitPost( apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName, moduleName: moduleName,
@ -95,21 +95,6 @@ class MarineTarballSamplingService {
body: data.toFormData(), 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) { if (apiDataResult['success'] == true) {
anyApiSuccess = true; anyApiSuccess = true;
data.reportId = apiDataResult['data']?['autoid']?.toString(); 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.'; 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) { } on SocketException catch (e) {
final errorMessage = "API submission failed with network error: $e"; final errorMessage = "API submission failed with network error: $e";
debugPrint(errorMessage); debugPrint(errorMessage);
anyApiSuccess = false; anyApiSuccess = false;
apiDataResult = {'success': false, 'message': errorMessage}; 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()); await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData());
if(finalImageFiles.isNotEmpty && data.reportId != null) { if(finalImageFiles.isNotEmpty && data.reportId != null) {
await _retryService.addApiToQueue(endpoint: 'marine/tarball/images', method: 'POST_MULTIPART', fields: {'autoid': data.reportId!}, files: finalImageFiles); 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()); 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<String, dynamic> ftpResults = {'statuses': []}; Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false; bool anyFtpSuccess = false;
try { try {
@ -157,15 +158,11 @@ class MarineTarballSamplingService {
} on SocketException catch (e) { } on SocketException catch (e) {
debugPrint("FTP submission failed with network error: $e"); debugPrint("FTP submission failed with network error: $e");
anyFtpSuccess = false; 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) { } on TimeoutException catch (e) {
debugPrint("FTP submission timed out: $e"); debugPrint("FTP submission timed out: $e");
anyFtpSuccess = false; 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; final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
String finalMessage; String finalMessage;
String finalStatus; String finalStatus;
@ -180,7 +177,7 @@ class MarineTarballSamplingService {
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.'; finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
finalStatus = 'L4'; finalStatus = 'L4';
} else { } 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'; finalStatus = 'L1';
} }
@ -195,7 +192,7 @@ class MarineTarballSamplingService {
); );
if (overallSuccess) { if (overallSuccess) {
_handleTarballSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty); _handleTarballSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
} }
return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId}; 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: {}); await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {});
return {'success': true, 'message': successMessage}; return {'success': true, 'message': successMessage};
@ -276,8 +273,8 @@ class MarineTarballSamplingService {
return { return {
'statuses': <Map<String, dynamic>>[ 'statuses': <Map<String, dynamic>>[
...(ftpDataResult['statuses'] as List), ...?(ftpDataResult['statuses'] as List?),
...(ftpImageResult['statuses'] as List), ...?(ftpImageResult['statuses'] as List?),
], ],
}; };
} }
@ -314,12 +311,17 @@ class MarineTarballSamplingService {
await _dbHelper.saveSubmissionLog(logData); await _dbHelper.saveSubmissionLog(logData);
} }
Future<void> _handleTarballSuccessAlert(TarballSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async { Future<void> _handleTarballSuccessAlert(TarballSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
try { try {
final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly); final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly);
final bool wasSent = await _telegramService.sendAlertImmediately('marine_tarball', message, appSettings); if (isSessionExpired) {
if (!wasSent) { debugPrint("Session is expired; queuing Telegram alert directly.");
await _telegramService.queueMessage('marine_tarball', message, appSettings); 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) { } catch (e) {
debugPrint("Failed to handle Tarball Telegram alert: $e"); debugPrint("Failed to handle Tarball Telegram alert: $e");

View File

@ -21,6 +21,7 @@ class RetryService {
final BaseApiService _baseApiService = BaseApiService(); final BaseApiService _baseApiService = BaseApiService();
final FtpService _ftpService = FtpService(); final FtpService _ftpService = FtpService();
final ServerConfigService _serverConfigService = ServerConfigService(); final ServerConfigService _serverConfigService = ServerConfigService();
bool _isProcessing = false;
// --- START: MODIFICATION FOR HANDLING COMPLEX TASKS --- // --- START: MODIFICATION FOR HANDLING COMPLEX TASKS ---
// These services will be provided after the RetryService is created. // These services will be provided after the RetryService is created.
@ -102,6 +103,37 @@ class RetryService {
return _dbHelper.getPendingRequests(); return _dbHelper.getPendingRequests();
} }
/// Processes the entire queue of pending tasks.
Future<void> 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. /// Attempts to re-execute a single failed task from the queue.
/// Returns `true` on success, `false` on failure. /// Returns `true` on success, `false` on failure.
Future<bool> retryTask(int taskId) async { Future<bool> retryTask(int taskId) async {

View File

@ -30,6 +30,7 @@ import 'submission_api_service.dart';
import 'submission_ftp_service.dart'; import 'submission_ftp_service.dart';
import 'telegram_service.dart'; import 'telegram_service.dart';
import 'retry_service.dart'; import 'retry_service.dart';
import 'base_api_service.dart'; // Import for SessionExpiredException
class RiverInSituSamplingService { class RiverInSituSamplingService {
@ -214,6 +215,7 @@ class RiverInSituSamplingService {
bool anyApiSuccess = false; bool anyApiSuccess = false;
Map<String, dynamic> apiDataResult = {}; Map<String, dynamic> apiDataResult = {};
Map<String, dynamic> apiImageResult = {}; Map<String, dynamic> apiImageResult = {};
bool isSessionKnownToBeExpired = false;
try { try {
apiDataResult = await _submissionApiService.submitPost( apiDataResult = await _submissionApiService.submitPost(
@ -222,21 +224,6 @@ class RiverInSituSamplingService {
body: data.toApiFormData(), 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) { if (apiDataResult['success'] == true) {
anyApiSuccess = true; anyApiSuccess = true;
data.reportId = apiDataResult['data']?['r_man_id']?.toString(); 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.'; 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) { } on SocketException catch (e) {
final errorMessage = "API submission failed with network error: $e"; final errorMessage = "API submission failed with network error: $e";
debugPrint(errorMessage); debugPrint(errorMessage);
@ -302,7 +309,7 @@ class RiverInSituSamplingService {
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.'; finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
finalStatus = 'L4'; finalStatus = 'L4';
} else { } 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'; finalStatus = 'L1';
} }
@ -317,7 +324,7 @@ class RiverInSituSamplingService {
); );
if (overallSuccess) { if (overallSuccess) {
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty); _handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
} }
return { return {
@ -336,7 +343,7 @@ class RiverInSituSamplingService {
final serverName = serverConfig?['config_name'] as String? ?? 'Default'; final serverName = serverConfig?['config_name'] as String? ?? 'Default';
data.submissionStatus = 'L1'; 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); 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}; return {'status': 'Queued', 'success': true, 'message': successMessage};
} }
@ -376,7 +383,7 @@ class RiverInSituSamplingService {
} }
final dataZip = await _zippingService.createDataZip( final dataZip = await _zippingService.createDataZip(
jsonDataMap: {'db.json': data.toDbJson()}, jsonDataMap: {'db.json': jsonEncode(data.toApiFormData())},
baseFileName: baseFileName, baseFileName: baseFileName,
destinationDir: localSubmissionDir, destinationDir: localSubmissionDir,
); );
@ -447,43 +454,52 @@ class RiverInSituSamplingService {
await _dbHelper.saveSubmissionLog(logData); await _dbHelper.saveSubmissionLog(logData);
} }
Future<void> _handleSuccessAlert(RiverInSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async { Future<void> _handleSuccessAlert(RiverInSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
try { try {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final message = await _generateInSituAlertMessage(data, isDataOnly: isDataOnly);
final stationName = data.selectedStation?['sampling_river'] ?? 'N/A'; if (isSessionExpired) {
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'N/A'; debugPrint("Session is expired; queuing Telegram alert directly.");
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) {
await _telegramService.queueMessage('river_in_situ', message, appSettings); 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) { } catch (e) {
debugPrint("Failed to handle River Telegram alert: $e"); debugPrint("Failed to handle River Telegram alert: $e");
} }
} }
Future<String> _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();
}
} }

View File

@ -15,6 +15,7 @@ import 'package:usb_serial/usb_serial.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:provider/provider.dart';
import '../auth_provider.dart'; import '../auth_provider.dart';
import 'location_service.dart'; import 'location_service.dart';
@ -29,6 +30,7 @@ import 'submission_api_service.dart';
import 'submission_ftp_service.dart'; import 'submission_ftp_service.dart';
import 'telegram_service.dart'; import 'telegram_service.dart';
import 'retry_service.dart'; import 'retry_service.dart';
import 'base_api_service.dart'; // Import for SessionExpiredException
class RiverManualTriennialSamplingService { class RiverManualTriennialSamplingService {
@ -213,6 +215,7 @@ class RiverManualTriennialSamplingService {
bool anyApiSuccess = false; bool anyApiSuccess = false;
Map<String, dynamic> apiDataResult = {}; Map<String, dynamic> apiDataResult = {};
Map<String, dynamic> apiImageResult = {}; Map<String, dynamic> apiImageResult = {};
bool isSessionKnownToBeExpired = false;
try { try {
apiDataResult = await _submissionApiService.submitPost( apiDataResult = await _submissionApiService.submitPost(
@ -221,21 +224,6 @@ class RiverManualTriennialSamplingService {
body: data.toApiFormData(), 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) { if (apiDataResult['success'] == true) {
anyApiSuccess = true; anyApiSuccess = true;
data.reportId = apiDataResult['data']?['r_tri_id']?.toString(); 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.'; 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) { } on SocketException catch (e) {
final errorMessage = "API submission failed with network error: $e"; final errorMessage = "API submission failed with network error: $e";
debugPrint(errorMessage); debugPrint(errorMessage);
@ -301,7 +309,7 @@ class RiverManualTriennialSamplingService {
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.'; finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
finalStatus = 'L4'; finalStatus = 'L4';
} else { } 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'; finalStatus = 'L1';
} }
@ -316,7 +324,7 @@ class RiverManualTriennialSamplingService {
); );
if (overallSuccess) { if (overallSuccess) {
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty); _handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
} }
return { return {
@ -335,7 +343,7 @@ class RiverManualTriennialSamplingService {
final serverName = serverConfig?['config_name'] as String? ?? 'Default'; final serverName = serverConfig?['config_name'] as String? ?? 'Default';
data.submissionStatus = 'L1'; 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); 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}; return {'status': 'Queued', 'success': true, 'message': successMessage};
} }
@ -446,43 +454,52 @@ class RiverManualTriennialSamplingService {
await _dbHelper.saveSubmissionLog(logData); await _dbHelper.saveSubmissionLog(logData);
} }
Future<void> _handleSuccessAlert(RiverManualTriennialSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async { Future<void> _handleSuccessAlert(RiverManualTriennialSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
try { try {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final message = await _generateSuccessAlertMessage(data, isDataOnly: isDataOnly);
final stationName = data.selectedStation?['sampling_river'] ?? 'N/A'; if (isSessionExpired) {
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'N/A'; debugPrint("Session is expired; queuing Telegram alert directly.");
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) {
await _telegramService.queueMessage('river_triennial', message, appSettings); 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) { } catch (e) {
debugPrint("Failed to handle River Triennial Telegram alert: $e"); debugPrint("Failed to handle River Triennial Telegram alert: $e");
} }
} }
Future<String> _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();
}
} }

View File

@ -1,27 +1,113 @@
// lib/services/user_preferences_service.dart // lib/services/user_preferences_service.dart
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:environment_monitoring_app/services/api_service.dart'; // Contains DatabaseHelper import 'package:environment_monitoring_app/services/api_service.dart'; // Contains DatabaseHelper
/// A dedicated service to manage the user's local preferences for /// A dedicated service to manage the user's local preferences for
/// module-specific submission destinations. /// module-specific submission destinations.
class UserPreferencesService { class UserPreferencesService {
final DatabaseHelper _dbHelper = DatabaseHelper(); final DatabaseHelper _dbHelper = DatabaseHelper();
static const _defaultPrefsSavedKey = 'default_preferences_saved';
// Moved from settings.dart for central access
final List<Map<String, String>> _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<void> 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. /// Retrieves a module's master submission preferences.
/// If no preference has been saved for this module, it returns a default /// This method now returns null if no preference is found.
/// where both API and FTP are enabled. Future<Map<String, dynamic>?> getModulePreference(String moduleName) async {
Future<Map<String, dynamic>> getModulePreference(String moduleName) async {
final preference = await _dbHelper.getModulePreference(moduleName); final preference = await _dbHelper.getModulePreference(moduleName);
if (preference != null) { if (preference != null) {
return preference; return preference;
} }
// Return a default value if no preference is found in the database. return null;
return {
'module_name': moduleName,
'is_api_enabled': true,
'is_ftp_enabled': true,
};
} }
/// Saves or updates a module's master on/off switches for API and FTP submissions. /// 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. /// destinations to send data to.
Future<List<Map<String, dynamic>>> getEnabledApiConfigsForModule(String moduleName) async { Future<List<Map<String, dynamic>>> getEnabledApiConfigsForModule(String moduleName) async {
// 1. Check the master switch for the module. // 1. Check the master switch for the module.
final pref = await getModulePreference(moduleName); final pref = await _dbHelper.getModulePreference(moduleName); // Use direct DB call
if (!(pref['is_api_enabled'] as bool)) { if (pref == null || !(pref['is_api_enabled'] as bool)) {
debugPrint("API submissions are disabled for module '$moduleName' via master switch."); debugPrint("API submissions are disabled for module '$moduleName'.");
return []; // Return empty list if API is globally disabled for this module. return []; // Return empty list if API is disabled or not set.
} }
// 2. Get all configs with their preference flags. // 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. /// Retrieves only the FTP configurations that are actively enabled for a given module.
Future<List<Map<String, dynamic>>> getEnabledFtpConfigsForModule(String moduleName) async { Future<List<Map<String, dynamic>>> getEnabledFtpConfigsForModule(String moduleName) async {
final pref = await getModulePreference(moduleName); final pref = await _dbHelper.getModulePreference(moduleName); // Use direct DB call
if (!(pref['is_ftp_enabled'] as bool)) { if (pref == null || !(pref['is_ftp_enabled'] as bool)) {
debugPrint("FTP submissions are disabled for module '$moduleName' via master switch."); debugPrint("FTP submissions are disabled for module '$moduleName'.");
return []; return [];
} }

View File

@ -7,12 +7,16 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar); 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 = g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux file_selector_linux
flutter_secure_storage_linux
url_launcher_linux url_launcher_linux
) )

View File

@ -8,6 +8,7 @@ import Foundation
import connectivity_plus import connectivity_plus
import file_picker import file_picker
import file_selector_macos import file_selector_macos
import flutter_secure_storage_macos
import geolocator_apple import geolocator_apple
import path_provider_foundation import path_provider_foundation
import shared_preferences_foundation import shared_preferences_foundation
@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))

View File

@ -271,6 +271,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.28" 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: flutter_svg:
dependency: "direct main" dependency: "direct main"
description: description:
@ -449,6 +497,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.18.1" version: "0.18.1"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.6.7"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:

View File

@ -26,6 +26,7 @@ dependencies:
path_provider: ^2.1.3 path_provider: ^2.1.3
path: ^1.8.3 # Explicitly added for path manipulation path: ^1.8.3 # Explicitly added for path manipulation
connectivity_plus: ^6.0.1 connectivity_plus: ^6.0.1
flutter_secure_storage: ^9.0.0 # ADDED: For securely storing the user's password
# --- UI Components & Utilities --- # --- UI Components & Utilities ---
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8

View File

@ -8,6 +8,7 @@
#include <connectivity_plus/connectivity_plus_windows_plugin.h> #include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <geolocator_windows/geolocator_windows.h> #include <geolocator_windows/geolocator_windows.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h> #include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
GeolocatorWindowsRegisterWithRegistrar( GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows")); registry->GetRegistrarForPlugin("GeolocatorWindows"));
PermissionHandlerWindowsPluginRegisterWithRegistrar( PermissionHandlerWindowsPluginRegisterWithRegistrar(

View File

@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus connectivity_plus
file_selector_windows file_selector_windows
flutter_secure_storage_windows
geolocator_windows geolocator_windows
permission_handler_windows permission_handler_windows
url_launcher_windows url_launcher_windows