fix invalid token and auto resubmit data to server
This commit is contained in:
parent
768047ad18
commit
c5453124fd
@ -6,16 +6,24 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:bcrypt/bcrypt.dart'; // Import bcrypt
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; // NEW: Import secure storage
|
||||
|
||||
import 'package:environment_monitoring_app/services/api_service.dart';
|
||||
import 'package:environment_monitoring_app/services/base_api_service.dart';
|
||||
import 'package:environment_monitoring_app/services/server_config_service.dart';
|
||||
import 'package:environment_monitoring_app/services/retry_service.dart';
|
||||
import 'package:environment_monitoring_app/services/user_preferences_service.dart';
|
||||
|
||||
class AuthProvider with ChangeNotifier {
|
||||
late final ApiService _apiService;
|
||||
late final DatabaseHelper _dbHelper;
|
||||
late final ServerConfigService _serverConfigService;
|
||||
late final RetryService _retryService;
|
||||
final UserPreferencesService _userPreferencesService = UserPreferencesService();
|
||||
|
||||
// NEW: Initialize secure storage
|
||||
final _secureStorage = const FlutterSecureStorage();
|
||||
static const _passwordStorageKey = 'user_password';
|
||||
|
||||
// --- Session & Profile State ---
|
||||
String? _jwtToken;
|
||||
@ -25,9 +33,6 @@ class AuthProvider with ChangeNotifier {
|
||||
String? get userEmail => _userEmail;
|
||||
Map<String, dynamic>? get profileData => _profileData;
|
||||
|
||||
// --- ADDED: Temporary password cache for auto-relogin ---
|
||||
String? _tempOfflinePassword;
|
||||
|
||||
// --- App State ---
|
||||
bool _isLoading = true;
|
||||
bool _isFirstLogin = true;
|
||||
@ -36,6 +41,11 @@ class AuthProvider with ChangeNotifier {
|
||||
bool get isFirstLogin => _isFirstLogin;
|
||||
DateTime? get lastSyncTimestamp => _lastSyncTimestamp;
|
||||
|
||||
/// This flag indicates the session is confirmed expired and auto-relogin failed.
|
||||
/// The app should operate in offline mode until the user manually logs in again.
|
||||
bool _isSessionExpired = false;
|
||||
bool get isSessionExpired => _isSessionExpired;
|
||||
|
||||
// --- Cached Master Data ---
|
||||
List<Map<String, dynamic>>? _allUsers;
|
||||
List<Map<String, dynamic>>? _tarballStations;
|
||||
@ -91,6 +101,7 @@ class AuthProvider with ChangeNotifier {
|
||||
static const String profileDataKey = 'user_profile_data';
|
||||
static const String lastSyncTimestampKey = 'last_sync_timestamp';
|
||||
static const String isFirstLoginKey = 'is_first_login';
|
||||
static const String lastOnlineLoginKey = 'last_online_login';
|
||||
|
||||
AuthProvider({
|
||||
required ApiService apiService,
|
||||
@ -105,6 +116,11 @@ class AuthProvider with ChangeNotifier {
|
||||
_loadSessionAndSyncData();
|
||||
}
|
||||
|
||||
Future<bool> isConnected() async {
|
||||
final connectivityResult = await Connectivity().checkConnectivity();
|
||||
return !connectivityResult.contains(ConnectivityResult.none);
|
||||
}
|
||||
|
||||
Future<void> _loadSessionAndSyncData() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
@ -139,6 +155,7 @@ class AuthProvider with ChangeNotifier {
|
||||
|
||||
if (_jwtToken != null) {
|
||||
debugPrint('AuthProvider: Session loaded.');
|
||||
await _userPreferencesService.applyAndSaveDefaultPreferencesIfNeeded();
|
||||
// Sync logic moved to checkAndTransitionToOnlineSession to handle transitions correctly
|
||||
} else {
|
||||
debugPrint('AuthProvider: No active session. App is in offline mode.');
|
||||
@ -152,8 +169,7 @@ class AuthProvider with ChangeNotifier {
|
||||
/// Returns true if a successful transition occurred.
|
||||
Future<bool> checkAndTransitionToOnlineSession() async {
|
||||
// Condition 1: Check connectivity
|
||||
final connectivityResult = await Connectivity().checkConnectivity();
|
||||
if (connectivityResult.contains(ConnectivityResult.none)) {
|
||||
if (!(await isConnected())) {
|
||||
debugPrint("AuthProvider: No internet connection. Skipping transition check.");
|
||||
return false;
|
||||
}
|
||||
@ -165,21 +181,27 @@ class AuthProvider with ChangeNotifier {
|
||||
// If online, trigger a normal sync to ensure data freshness on connection restoration.
|
||||
if(_jwtToken != null) {
|
||||
debugPrint("AuthProvider: Session is already online. Triggering standard sync.");
|
||||
syncAllData();
|
||||
// FIX: Add try-catch to prevent unhandled exceptions from crashing the app during background syncs.
|
||||
try {
|
||||
await syncAllData();
|
||||
} catch (e) {
|
||||
debugPrint("AuthProvider: Background sync failed silently on transition check: $e");
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Condition 3: Check if we have the temporary password to attempt re-login.
|
||||
if (_tempOfflinePassword == null || _userEmail == null) {
|
||||
debugPrint("AuthProvider: In offline session, but no temporary password available for auto-relogin. Manual login required.");
|
||||
// FIX: Read password from secure storage instead of temporary variable.
|
||||
final String? password = await _secureStorage.read(key: _passwordStorageKey);
|
||||
if (password == null || _userEmail == null) {
|
||||
debugPrint("AuthProvider: In offline session, but no password in secure storage for auto-relogin. Manual login required.");
|
||||
return false;
|
||||
}
|
||||
|
||||
debugPrint("AuthProvider: Internet detected in offline session. Attempting silent re-login for $_userEmail...");
|
||||
|
||||
try {
|
||||
final result = await _apiService.login(_userEmail!, _tempOfflinePassword!);
|
||||
final result = await _apiService.login(_userEmail!, password);
|
||||
|
||||
if (result['success'] == true) {
|
||||
debugPrint("AuthProvider: Silent re-login successful. Transitioning to online session.");
|
||||
@ -187,11 +209,8 @@ class AuthProvider with ChangeNotifier {
|
||||
final Map<String, dynamic> profile = result['data']['profile'];
|
||||
|
||||
// Use existing login method to set up session and trigger sync.
|
||||
// Re-pass the password to ensure credentials are fully cached after transition.
|
||||
await login(token, profile, _tempOfflinePassword!);
|
||||
await login(token, profile, password);
|
||||
|
||||
// Clear temporary password after successful transition
|
||||
_tempOfflinePassword = null;
|
||||
notifyListeners(); // Ensure UI updates after state change
|
||||
return true;
|
||||
} else {
|
||||
@ -206,28 +225,69 @@ class AuthProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚡️ Orchestrates session validation and silent re-authentication.
|
||||
/// This should be called whenever the app gains an internet connection.
|
||||
Future<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.
|
||||
/// This can be called when a 401 Unauthorized error is detected.
|
||||
Future<bool> attemptSilentRelogin() async {
|
||||
final connectivityResult = await Connectivity().checkConnectivity();
|
||||
if (connectivityResult.contains(ConnectivityResult.none)) {
|
||||
if (!(await isConnected())) {
|
||||
debugPrint("AuthProvider: No internet for silent relogin.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_tempOfflinePassword == null || _userEmail == null) {
|
||||
debugPrint("AuthProvider: No cached credentials for silent relogin.");
|
||||
// FIX: Read password from secure storage.
|
||||
final String? password = await _secureStorage.read(key: _passwordStorageKey);
|
||||
if (password == null || _userEmail == null) {
|
||||
debugPrint("AuthProvider: No cached credentials in secure storage for silent relogin.");
|
||||
return false;
|
||||
}
|
||||
|
||||
debugPrint("AuthProvider: Session may be expired. Attempting silent re-login for $_userEmail...");
|
||||
try {
|
||||
final result = await _apiService.login(_userEmail!, _tempOfflinePassword!);
|
||||
final result = await _apiService.login(_userEmail!, password);
|
||||
if (result['success'] == true) {
|
||||
debugPrint("AuthProvider: Silent re-login successful.");
|
||||
final String token = result['data']['token'];
|
||||
final Map<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;
|
||||
} else {
|
||||
debugPrint("AuthProvider: Silent re-login failed: ${result['message']}");
|
||||
@ -240,46 +300,69 @@ class AuthProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> syncAllData({bool forceRefresh = false}) async {
|
||||
final connectivityResult = await Connectivity().checkConnectivity();
|
||||
if (connectivityResult.contains(ConnectivityResult.none)) {
|
||||
if (!(await isConnected())) {
|
||||
debugPrint("AuthProvider: Device is OFFLINE. Skipping sync.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent sync attempts if token is an offline placeholder
|
||||
// Proactively check if session is already marked as expired
|
||||
if (_isSessionExpired) {
|
||||
debugPrint("AuthProvider: Skipping sync, session is expired. Manual login required.");
|
||||
throw Exception('Session expired. Please log in again to sync.');
|
||||
}
|
||||
|
||||
if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) {
|
||||
debugPrint("AuthProvider: Skipping sync, session is offline or null.");
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint("AuthProvider: Device is ONLINE. Starting delta sync.");
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final String? lastSync = forceRefresh ? null : prefs.getString(lastSyncTimestampKey);
|
||||
final newSyncTimestamp = DateTime.now().toUtc().toIso8601String();
|
||||
try {
|
||||
debugPrint("AuthProvider: Device is ONLINE. Starting delta sync.");
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final String? lastSync = forceRefresh ? null : prefs.getString(lastSyncTimestampKey);
|
||||
|
||||
final result = await _apiService.syncAllData(lastSyncTimestamp: lastSync);
|
||||
final result = await _apiService.syncAllData(lastSyncTimestamp: lastSync);
|
||||
|
||||
if (result['success']) {
|
||||
debugPrint("AuthProvider: Delta sync successful. Updating last sync timestamp.");
|
||||
await prefs.setString(lastSyncTimestampKey, newSyncTimestamp);
|
||||
_lastSyncTimestamp = DateTime.parse(newSyncTimestamp);
|
||||
if (result['success']) {
|
||||
debugPrint("AuthProvider: Delta sync successful.");
|
||||
final newSyncTimestamp = DateTime.now().toUtc().toIso8601String();
|
||||
await prefs.setString(lastSyncTimestampKey, newSyncTimestamp);
|
||||
_lastSyncTimestamp = DateTime.parse(newSyncTimestamp);
|
||||
|
||||
if (_isFirstLogin) {
|
||||
await setIsFirstLogin(false);
|
||||
debugPrint("AuthProvider: First successful sync complete. isFirstLogin flag set to false.");
|
||||
if (_isFirstLogin) {
|
||||
await setIsFirstLogin(false);
|
||||
debugPrint("AuthProvider: First successful sync complete. isFirstLogin flag set to false.");
|
||||
}
|
||||
|
||||
await _loadDataFromCache();
|
||||
notifyListeners();
|
||||
} else {
|
||||
debugPrint("AuthProvider: Delta sync failed logically. Message: ${result['message']}");
|
||||
// We throw an exception here so the UI can report a failure.
|
||||
throw Exception('Data sync failed. Please check the logs.');
|
||||
}
|
||||
|
||||
await _loadDataFromCache();
|
||||
notifyListeners();
|
||||
} else {
|
||||
debugPrint("AuthProvider: Delta sync failed. Timestamp not updated.");
|
||||
} on SessionExpiredException {
|
||||
debugPrint("AuthProvider: Session expired during sync. Attempting silent re-login...");
|
||||
final bool reauthenticated = await attemptSilentRelogin();
|
||||
if (reauthenticated) {
|
||||
debugPrint("AuthProvider: Re-login successful. Retrying sync...");
|
||||
await syncAllData(forceRefresh: forceRefresh); // Retry the sync
|
||||
} else {
|
||||
debugPrint("AuthProvider: Re-login failed after session expired during sync. Switching to offline mode.");
|
||||
_isSessionExpired = true;
|
||||
notifyListeners();
|
||||
throw Exception('Session expired. App is now in offline mode.');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("AuthProvider: A general error occurred during sync: $e");
|
||||
// Re-throw the exception so the UI can display it.
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// --- START: NEW METHOD FOR REGISTRATION SCREEN ---
|
||||
Future<void> syncRegistrationData() async {
|
||||
final connectivityResult = await Connectivity().checkConnectivity();
|
||||
if (connectivityResult.contains(ConnectivityResult.none)) {
|
||||
if (!(await isConnected())) {
|
||||
debugPrint("AuthProvider: Device is OFFLINE. Skipping registration data sync.");
|
||||
return;
|
||||
}
|
||||
@ -298,13 +381,12 @@ class AuthProvider with ChangeNotifier {
|
||||
// --- END: NEW METHOD FOR REGISTRATION SCREEN ---
|
||||
|
||||
Future<void> refreshProfile() async {
|
||||
final connectivityResult = await Connectivity().checkConnectivity();
|
||||
if (connectivityResult.contains(ConnectivityResult.none)) {
|
||||
if (!(await isConnected())) {
|
||||
debugPrint("AuthProvider: Device is OFFLINE. Skipping profile refresh.");
|
||||
return;
|
||||
}
|
||||
if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) {
|
||||
debugPrint("AuthProvider: Skipping profile refresh, session is offline or null.");
|
||||
if (_isSessionExpired || _jwtToken == null || _jwtToken!.startsWith("offline-session-")) {
|
||||
debugPrint("AuthProvider: Skipping profile refresh, session is offline, expired, or null.");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -314,6 +396,33 @@ class AuthProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<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 {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
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 {
|
||||
_jwtToken = token;
|
||||
_userEmail = profile['email'];
|
||||
// --- MODIFIED: Cache password on successful ONLINE login ---
|
||||
_tempOfflinePassword = password;
|
||||
|
||||
// FIX: Save password to secure storage instead of in-memory variable.
|
||||
await _secureStorage.write(key: _passwordStorageKey, value: password);
|
||||
|
||||
final Map<String, dynamic> profileWithToken = Map.from(profile);
|
||||
profileWithToken['token'] = token;
|
||||
@ -368,6 +478,7 @@ class AuthProvider with ChangeNotifier {
|
||||
await prefs.setString(tokenKey, token);
|
||||
await prefs.setString(userEmailKey, _userEmail!);
|
||||
await prefs.setString(profileDataKey, jsonEncode(_profileData));
|
||||
await prefs.setString(lastOnlineLoginKey, DateTime.now().toIso8601String());
|
||||
await _dbHelper.saveProfile(_profileData!);
|
||||
|
||||
try {
|
||||
@ -383,6 +494,7 @@ class AuthProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
debugPrint('AuthProvider: Login successful. Session and profile persisted.');
|
||||
await _userPreferencesService.applyAndSaveDefaultPreferencesIfNeeded();
|
||||
await syncAllData(forceRefresh: true);
|
||||
}
|
||||
|
||||
@ -415,8 +527,8 @@ class AuthProvider with ChangeNotifier {
|
||||
_jwtToken = "offline-session-${DateTime.now().millisecondsSinceEpoch}";
|
||||
_userEmail = email;
|
||||
|
||||
// --- MODIFIED: Cache the password on successful OFFLINE login ---
|
||||
_tempOfflinePassword = password;
|
||||
// FIX: Save password to secure storage for future auto-relogin.
|
||||
await _secureStorage.write(key: _passwordStorageKey, value: password);
|
||||
|
||||
final Map<String, dynamic> profileWithToken = Map.from(cachedProfile);
|
||||
profileWithToken['token'] = _jwtToken;
|
||||
@ -467,8 +579,10 @@ class AuthProvider with ChangeNotifier {
|
||||
_profileData = null;
|
||||
_lastSyncTimestamp = null;
|
||||
_isFirstLogin = true;
|
||||
// --- MODIFIED: Clear temp password on logout ---
|
||||
_tempOfflinePassword = null;
|
||||
_isSessionExpired = false; // Reset session expired flag on logout
|
||||
|
||||
// FIX: Clear password from secure storage on logout.
|
||||
await _secureStorage.delete(key: _passwordStorageKey);
|
||||
|
||||
_allUsers = null;
|
||||
_tarballStations = null;
|
||||
@ -500,6 +614,8 @@ class AuthProvider with ChangeNotifier {
|
||||
await prefs.remove(userEmailKey);
|
||||
await prefs.remove(profileDataKey);
|
||||
await prefs.remove(lastSyncTimestampKey);
|
||||
await prefs.remove(lastOnlineLoginKey);
|
||||
await prefs.remove('default_preferences_saved');
|
||||
await prefs.setBool(isFirstLoginKey, true);
|
||||
|
||||
debugPrint('AuthProvider: All session and cached data cleared.');
|
||||
|
||||
145
lib/main.dart
145
lib/main.dart
@ -3,6 +3,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'dart:async'; // Import Timer
|
||||
|
||||
import 'package:provider/single_child_widget.dart';
|
||||
import 'package:environment_monitoring_app/services/api_service.dart';
|
||||
@ -101,31 +102,42 @@ void main() async {
|
||||
final DatabaseHelper databaseHelper = DatabaseHelper();
|
||||
final TelegramService telegramService = TelegramService();
|
||||
final ApiService apiService = ApiService(telegramService: telegramService);
|
||||
final RetryService retryService = RetryService();
|
||||
final MarineInSituSamplingService marineInSituService = MarineInSituSamplingService(telegramService);
|
||||
final RiverInSituSamplingService riverInSituService = RiverInSituSamplingService(telegramService);
|
||||
|
||||
telegramService.setApiService(apiService);
|
||||
|
||||
setupServices(telegramService);
|
||||
// The AuthProvider needs to be created here so it can be passed to the retry service.
|
||||
final authProvider = AuthProvider(
|
||||
apiService: apiService,
|
||||
dbHelper: databaseHelper,
|
||||
serverConfigService: ServerConfigService(),
|
||||
retryService: retryService,
|
||||
);
|
||||
|
||||
// Initialize the retry service with all its dependencies.
|
||||
retryService.initialize(
|
||||
marineInSituService: marineInSituService,
|
||||
riverInSituService: riverInSituService,
|
||||
authProvider: authProvider,
|
||||
);
|
||||
|
||||
setupPeriodicServices(telegramService, retryService);
|
||||
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: <SingleChildWidget>[
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => AuthProvider(
|
||||
apiService: apiService,
|
||||
dbHelper: databaseHelper,
|
||||
serverConfigService: ServerConfigService(),
|
||||
retryService: RetryService(),
|
||||
),
|
||||
),
|
||||
// Providers for core services
|
||||
ChangeNotifierProvider.value(value: authProvider),
|
||||
Provider<ApiService>(create: (_) => apiService),
|
||||
Provider<DatabaseHelper>(create: (_) => databaseHelper),
|
||||
Provider<TelegramService>(create: (_) => telegramService),
|
||||
Provider(create: (_) => LocalStorageService()),
|
||||
|
||||
Provider(create: (context) => RiverInSituSamplingService(telegramService)),
|
||||
Provider.value(value: retryService),
|
||||
Provider.value(value: marineInSituService),
|
||||
Provider.value(value: riverInSituService),
|
||||
Provider(create: (context) => RiverManualTriennialSamplingService(telegramService)),
|
||||
Provider(create: (context) => AirSamplingService(databaseHelper, telegramService)),
|
||||
Provider(create: (context) => MarineInSituSamplingService(telegramService)),
|
||||
Provider(create: (context) => MarineTarballSamplingService(telegramService)),
|
||||
Provider(create: (context) => MarineNpeReportService(Provider.of<TelegramService>(context, listen: false))),
|
||||
Provider(create: (context) => MarineManualPreDepartureService()),
|
||||
@ -137,14 +149,22 @@ void main() async {
|
||||
);
|
||||
}
|
||||
|
||||
void setupServices(TelegramService telegramService) {
|
||||
// Initial alert processing on startup (delayed)
|
||||
void setupPeriodicServices(TelegramService telegramService, RetryService retryService) {
|
||||
// Initial processing on startup (delayed)
|
||||
Future.delayed(const Duration(seconds: 5), () {
|
||||
debugPrint("[Main] Performing initial alert queue processing on app start.");
|
||||
telegramService.processAlertQueue();
|
||||
debugPrint("[Main] Performing initial retry queue processing on app start.");
|
||||
retryService.processRetryQueue();
|
||||
});
|
||||
|
||||
// Connectivity listener moved to RootApp to access AuthProvider context.
|
||||
// Start recurring timers to process both queues every 5 minutes.
|
||||
Timer.periodic(const Duration(minutes: 5), (timer) {
|
||||
debugPrint("[Main] Periodic check: Processing Telegram alert queue...");
|
||||
telegramService.processAlertQueue();
|
||||
debugPrint("[Main] Periodic check: Processing main retry queue...");
|
||||
retryService.processRetryQueue();
|
||||
});
|
||||
}
|
||||
|
||||
// --- START: MODIFIED RootApp ---
|
||||
@ -169,7 +189,17 @@ class _RootAppState extends State<RootApp> {
|
||||
// Wait a moment for providers to be fully available.
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
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
|
||||
final authProvider = Provider.of<AuthProvider>(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
|
||||
authProvider.checkAndTransitionToOnlineSession();
|
||||
// When connection is restored, always try to transition/validate the session.
|
||||
authProvider.checkAndTransitionToOnlineSession().then((didTransition) {
|
||||
if (!didTransition) {
|
||||
authProvider.validateAndRefreshSession();
|
||||
}
|
||||
});
|
||||
|
||||
// Process alert queue
|
||||
// Process queues
|
||||
telegramService.processAlertQueue();
|
||||
retryService.processRetryQueue();
|
||||
}
|
||||
} else {
|
||||
debugPrint("[Main] Internet connection lost.");
|
||||
@ -203,7 +239,7 @@ class _RootAppState extends State<RootApp> {
|
||||
if (auth.isLoading) {
|
||||
homeWidget = const SplashScreen();
|
||||
} else if (auth.isLoggedIn) {
|
||||
homeWidget = const HomePage();
|
||||
homeWidget = const SessionAwareWrapper(child: HomePage());
|
||||
} else {
|
||||
homeWidget = const LoginScreen();
|
||||
}
|
||||
@ -321,6 +357,73 @@ class _RootAppState extends State<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 {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
|
||||
@ -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() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
_formKey.currentState!.save();
|
||||
final distanceInMeters = (_data.distanceDifference ?? 0) * 1000;
|
||||
|
||||
if (distanceInMeters > 700) {
|
||||
// START MODIFICATION: Distance reduced to 50m
|
||||
if (distanceInMeters > 50) {
|
||||
// END MODIFICATION
|
||||
_showDistanceRemarkDialog();
|
||||
} else {
|
||||
_data.distanceDifferenceRemarks = null; // Clear old remarks if within range
|
||||
@ -153,7 +242,7 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- NEW: This function displays the mandatory remarks dialog ---
|
||||
// NEW: This function displays the mandatory remarks dialog
|
||||
Future<void> _showDistanceRemarkDialog() async {
|
||||
final remarkController = TextEditingController(text: _data.distanceDifferenceRemarks);
|
||||
final dialogFormKey = GlobalKey<FormState>();
|
||||
@ -171,7 +260,9 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Your current location is more than 700m away from the station.'),
|
||||
// START MODIFICATION: Text updated for 50m
|
||||
const Text('Your current location is more than 50m away from the station.'),
|
||||
// END MODIFICATION
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: remarkController,
|
||||
@ -335,6 +426,19 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
|
||||
TextFormField(controller: _stationLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Latitude')),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(controller: _stationLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Longitude')),
|
||||
|
||||
// --- START: Added Nearby Station Button ---
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.explore_outlined),
|
||||
label: const Text("NEARBY STATION"),
|
||||
onPressed: _isLoading ? null : _findAndShowNearbyStations,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
// --- END: Added Nearby Station Button ---
|
||||
|
||||
const SizedBox(height: 24),
|
||||
Text("Location Verification", style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
@ -344,13 +448,14 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
|
||||
if (_data.distanceDifference != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
// --- MODIFIED: This UI now better reflects the warning/ok status ---
|
||||
// MODIFIED: This UI now better reflects the warning/ok status
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
// START MODIFICATION: Distance reduced to 50m
|
||||
decoration: BoxDecoration(
|
||||
color: ((_data.distanceDifference ?? 0) * 1000) > 700 ? Colors.red.withOpacity(0.1) : Colors.green.withOpacity(0.1),
|
||||
color: ((_data.distanceDifference ?? 0) * 1000) > 50 ? Colors.red.withOpacity(0.1) : Colors.green.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: ((_data.distanceDifference ?? 0) * 1000) > 700 ? Colors.red : Colors.green),
|
||||
border: Border.all(color: ((_data.distanceDifference ?? 0) * 1000) > 50 ? Colors.red : Colors.green),
|
||||
),
|
||||
child: RichText(
|
||||
textAlign: TextAlign.center,
|
||||
@ -362,11 +467,12 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
|
||||
text: '${(_data.distanceDifference! * 1000).toStringAsFixed(0)} meters',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: ((_data.distanceDifference ?? 0) * 1000) > 700 ? Colors.red : Colors.green),
|
||||
color: ((_data.distanceDifference ?? 0) * 1000) > 50 ? Colors.red : Colors.green),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// END MODIFICATION
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@ -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 ---
|
||||
@ -6,6 +6,7 @@ import 'package:intl/intl.dart';
|
||||
import 'package:environment_monitoring_app/auth_provider.dart';
|
||||
import 'package:environment_monitoring_app/services/settings_service.dart';
|
||||
import 'package:environment_monitoring_app/services/user_preferences_service.dart';
|
||||
import 'package:environment_monitoring_app/services/api_service.dart'; // Import for DatabaseHelper access
|
||||
|
||||
class _ModuleSettings {
|
||||
bool isApiEnabled;
|
||||
@ -34,6 +35,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _isSyncingData = false;
|
||||
|
||||
final UserPreferencesService _preferencesService = UserPreferencesService();
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper(); // Add instance for direct access
|
||||
bool _isLoadingSettings = true;
|
||||
bool _isSaving = false;
|
||||
|
||||
@ -146,64 +148,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
setState(() => _isLoadingSettings = true);
|
||||
for (var module in _configurableModules) {
|
||||
final moduleKey = module['key']!;
|
||||
|
||||
// This method now simply loads whatever preferences are saved in the database.
|
||||
// The auto-saving of defaults is handled by AuthProvider upon login/app start.
|
||||
final prefs = await _preferencesService.getModulePreference(moduleKey);
|
||||
final apiConfigsWithPrefs = await _preferencesService.getAllApiConfigsWithModulePreferences(moduleKey);
|
||||
final ftpConfigsWithPrefs = await _preferencesService.getAllFtpConfigsWithModulePreferences(moduleKey);
|
||||
|
||||
final bool isAnyApiConfigEnabled = apiConfigsWithPrefs.any((c) => c['is_enabled'] == true);
|
||||
|
||||
if (prefs['is_api_enabled'] == true && !isAnyApiConfigEnabled) {
|
||||
final pstwHqApi = apiConfigsWithPrefs.firstWhere((c) => c['config_name'] == 'PSTW_HQ', orElse: () => {});
|
||||
if (pstwHqApi.isNotEmpty) {
|
||||
pstwHqApi['is_enabled'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
final bool isAnyFtpConfigEnabled = ftpConfigsWithPrefs.any((c) => c['is_enabled'] == true);
|
||||
|
||||
if (prefs['is_ftp_enabled'] == true && !isAnyFtpConfigEnabled) {
|
||||
switch (moduleKey) {
|
||||
case 'marine_tarball':
|
||||
for (var config in ftpConfigsWithPrefs) {
|
||||
if (config['config_name'] == 'pstw_marine_tarball' || config['config_name'] == 'tes_marine_tarball') {
|
||||
config['is_enabled'] = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'marine_in_situ':
|
||||
for (var config in ftpConfigsWithPrefs) {
|
||||
if (config['config_name'] == 'pstw_marine_manual' || config['config_name'] == 'tes_marine_manual') {
|
||||
config['is_enabled'] = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'river_in_situ':
|
||||
for (var config in ftpConfigsWithPrefs) {
|
||||
if (config['config_name'] == 'pstw_river_manual' || config['config_name'] == 'tes_river_manual') {
|
||||
config['is_enabled'] = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'air_collection':
|
||||
for (var config in ftpConfigsWithPrefs) {
|
||||
if (config['config_name'] == 'pstw_air_collect' || config['config_name'] == 'tes_air_collect') {
|
||||
config['is_enabled'] = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'air_installation':
|
||||
for (var config in ftpConfigsWithPrefs) {
|
||||
if (config['config_name'] == 'pstw_air_install' || config['config_name'] == 'tes_air_install') {
|
||||
config['is_enabled'] = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_moduleSettings[moduleKey] = _ModuleSettings(
|
||||
isApiEnabled: prefs['is_api_enabled'],
|
||||
isFtpEnabled: prefs['is_ftp_enabled'],
|
||||
isApiEnabled: prefs?['is_api_enabled'] ?? true, // Fallback to true if null
|
||||
isFtpEnabled: prefs?['is_ftp_enabled'] ?? true, // Fallback to true if null
|
||||
apiConfigs: apiConfigsWithPrefs,
|
||||
ftpConfigs: ftpConfigsWithPrefs,
|
||||
);
|
||||
@ -246,16 +200,39 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
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 {
|
||||
// AuthProvider's syncAllData will internally handle token validation and attempt a silent re-login if necessary.
|
||||
await auth.syncAllData(forceRefresh: true);
|
||||
await _loadAllModuleSettings();
|
||||
await _loadAllModuleSettings(); // Reload settings on successful sync
|
||||
|
||||
if (mounted) {
|
||||
_showSnackBar('Data synced successfully.', isError: false);
|
||||
}
|
||||
} catch (e) {
|
||||
// 3. Handle failures
|
||||
if (mounted) {
|
||||
_showSnackBar('Data sync failed. Please check your connection.', isError: true);
|
||||
// If the sync failed, check if the session is now marked as expired.
|
||||
// This indicates that the silent re-login attempt during the sync also failed.
|
||||
if (auth.isSessionExpired) {
|
||||
_showSessionExpiredDialog();
|
||||
} else {
|
||||
// A different error occurred (e.g., server down, network issue during sync)
|
||||
_showSnackBar('Data sync failed: ${e.toString()}', isError: true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
@ -264,6 +241,33 @@ class _SettingsScreenState extends State<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}) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@ -154,6 +154,15 @@ class ApiService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Validates the current session token by making a lightweight API call.
|
||||
/// Throws [SessionExpiredException] if the token is invalid (401).
|
||||
Future<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 ---
|
||||
|
||||
/// Helper method to make a delta-sync API call.
|
||||
@ -344,7 +353,8 @@ class ApiService {
|
||||
return {'success': true, 'message': 'Delta sync successful.'};
|
||||
} catch (e) {
|
||||
debugPrint('ApiService: Delta data sync failed: $e');
|
||||
return {'success': false, 'message': 'Data sync failed: $e'};
|
||||
// Re-throw the original exception so AuthProvider can catch specific types like SessionExpiredException
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,15 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:environment_monitoring_app/auth_provider.dart';
|
||||
|
||||
/// Custom exception thrown when the API returns a 401 Unauthorized status.
|
||||
class SessionExpiredException implements Exception {
|
||||
final String message;
|
||||
SessionExpiredException([this.message = "Your session has expired. Please log in again."]);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// A low-level service for making direct HTTP requests.
|
||||
/// This service is now "dumb" and only sends a request to the specific
|
||||
/// baseUrl provided. It no longer contains logic for server fallbacks.
|
||||
@ -36,6 +45,8 @@ class BaseApiService {
|
||||
final response = await http.get(url, headers: await _getJsonHeaders())
|
||||
.timeout(const Duration(seconds: 60));
|
||||
return _handleResponse(response);
|
||||
} on SessionExpiredException {
|
||||
rethrow; // CRITICAL FIX: Allow SessionExpiredException to propagate up.
|
||||
} on SocketException catch (e) {
|
||||
debugPrint('BaseApiService GET network error: $e');
|
||||
rethrow; // Re-throw network-related exceptions to be caught by higher-level logic (e.g., login screen)
|
||||
@ -59,6 +70,8 @@ class BaseApiService {
|
||||
body: jsonEncode(body),
|
||||
).timeout(const Duration(seconds: 60)); // Note: login.dart applies its own shorter timeout over this.
|
||||
return _handleResponse(response);
|
||||
} on SessionExpiredException {
|
||||
rethrow; // CRITICAL FIX: Allow SessionExpiredException to propagate up.
|
||||
} on SocketException catch (e) {
|
||||
debugPrint('BaseApiService POST network error: $e');
|
||||
rethrow; // Re-throw network-related exceptions to be caught by higher-level logic (e.g., login screen)
|
||||
@ -106,6 +119,8 @@ class BaseApiService {
|
||||
final responseBody = await streamedResponse.stream.bytesToString();
|
||||
return _handleResponse(http.Response(responseBody, streamedResponse.statusCode));
|
||||
|
||||
} on SessionExpiredException {
|
||||
rethrow; // CRITICAL FIX: Allow SessionExpiredException to propagate up.
|
||||
} on SocketException catch (e) {
|
||||
debugPrint('BaseApiService Multipart network error: $e');
|
||||
rethrow; // Re-throw network-related exceptions
|
||||
@ -121,6 +136,12 @@ class BaseApiService {
|
||||
|
||||
Map<String, dynamic> _handleResponse(http.Response response) {
|
||||
debugPrint('Handling response. Status: ${response.statusCode}, Body: ${response.body}');
|
||||
|
||||
// Check for 401 Unauthorized and throw the specific exception.
|
||||
if (response.statusCode == 401) {
|
||||
throw SessionExpiredException();
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to parse the response body as JSON.
|
||||
final Map<String, dynamic> responseData = jsonDecode(response.body);
|
||||
|
||||
@ -29,6 +29,7 @@ import 'submission_api_service.dart';
|
||||
import 'submission_ftp_service.dart';
|
||||
import 'telegram_service.dart';
|
||||
import 'retry_service.dart';
|
||||
import 'base_api_service.dart'; // Import for SessionExpiredException
|
||||
|
||||
|
||||
/// A dedicated service to handle all business logic for the Marine In-Situ Sampling feature.
|
||||
@ -217,6 +218,7 @@ class MarineInSituSamplingService {
|
||||
bool anyApiSuccess = false;
|
||||
Map<String, dynamic> apiDataResult = {};
|
||||
Map<String, dynamic> apiImageResult = {};
|
||||
bool isSessionKnownToBeExpired = false;
|
||||
|
||||
try {
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
@ -225,21 +227,6 @@ class MarineInSituSamplingService {
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
|
||||
if (apiDataResult['success'] == false &&
|
||||
(apiDataResult['message'] as String?)?.contains('Unauthorized') == true) {
|
||||
debugPrint("API submission failed with Unauthorized. Attempting silent relogin...");
|
||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
||||
|
||||
if (reloginSuccess) {
|
||||
debugPrint("Silent relogin successful. Retrying data submission...");
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine/manual/sample',
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (apiDataResult['success'] == true) {
|
||||
anyApiSuccess = true;
|
||||
data.reportId = apiDataResult['data']?['man_id']?.toString();
|
||||
@ -261,6 +248,26 @@ class MarineInSituSamplingService {
|
||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
||||
}
|
||||
}
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin...");
|
||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
||||
|
||||
if (reloginSuccess) {
|
||||
debugPrint("Silent relogin successful. Retrying entire online submission process...");
|
||||
return await _performOnlineSubmission(
|
||||
data: data,
|
||||
appSettings: appSettings,
|
||||
moduleName: moduleName,
|
||||
authProvider: authProvider,
|
||||
logDirectory: logDirectory,
|
||||
);
|
||||
} else {
|
||||
debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP.");
|
||||
isSessionKnownToBeExpired = true;
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/manual/sample', method: 'POST', body: data.toApiFormData());
|
||||
}
|
||||
} on SocketException catch (e) {
|
||||
final errorMessage = "API submission failed with network error: $e";
|
||||
debugPrint(errorMessage);
|
||||
@ -305,7 +312,7 @@ class MarineInSituSamplingService {
|
||||
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
|
||||
finalStatus = 'L4';
|
||||
} else {
|
||||
finalMessage = 'All submission attempts failed and have been queued for retry.';
|
||||
finalMessage = apiDataResult['message'] ?? 'All submission attempts failed and have been queued for retry.';
|
||||
finalStatus = 'L1';
|
||||
}
|
||||
|
||||
@ -321,7 +328,7 @@ class MarineInSituSamplingService {
|
||||
);
|
||||
|
||||
if (overallSuccess) {
|
||||
_handleInSituSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty);
|
||||
_handleInSituSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
||||
}
|
||||
|
||||
return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId};
|
||||
@ -336,7 +343,7 @@ class MarineInSituSamplingService {
|
||||
|
||||
// Set initial status before first save
|
||||
data.submissionStatus = 'L1';
|
||||
data.submissionMessage = 'Submission queued due to being offline.';
|
||||
data.submissionMessage = 'Submission queued for later retry.';
|
||||
|
||||
final String? localLogPath = await _localStorageService.saveInSituSamplingData(data, serverName: serverName);
|
||||
|
||||
@ -355,8 +362,7 @@ class MarineInSituSamplingService {
|
||||
},
|
||||
);
|
||||
|
||||
// No need to save again, initial save already has the L1 status.
|
||||
const successMessage = "No internet connection. Submission has been saved and queued for upload.";
|
||||
const successMessage = "Submission failed to send and has been queued for later retry.";
|
||||
return {'success': true, 'message': successMessage};
|
||||
}
|
||||
|
||||
@ -428,10 +434,8 @@ class MarineInSituSamplingService {
|
||||
if (logDirectory != null) {
|
||||
final Map<String, dynamic> updatedLogData = data.toDbJson();
|
||||
|
||||
// --- START FIX: Explicitly add final status and message to the update map ---
|
||||
updatedLogData['submissionStatus'] = status;
|
||||
updatedLogData['submissionMessage'] = message;
|
||||
// --- END FIX ---
|
||||
|
||||
updatedLogData['logDirectory'] = logDirectory;
|
||||
updatedLogData['serverConfigName'] = serverName;
|
||||
@ -468,12 +472,17 @@ class MarineInSituSamplingService {
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
}
|
||||
|
||||
Future<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 {
|
||||
final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly);
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings);
|
||||
if (!wasSent) {
|
||||
if (isSessionExpired) {
|
||||
debugPrint("Session is expired; queuing Telegram alert directly.");
|
||||
await _telegramService.queueMessage('marine_in_situ', message, appSettings);
|
||||
} else {
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings);
|
||||
if (!wasSent) {
|
||||
await _telegramService.queueMessage('marine_in_situ', message, appSettings);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Failed to handle In-Situ Telegram alert: $e");
|
||||
|
||||
@ -19,6 +19,7 @@ import 'package:environment_monitoring_app/services/submission_ftp_service.dart'
|
||||
import 'package:environment_monitoring_app/services/telegram_service.dart';
|
||||
import 'package:environment_monitoring_app/services/retry_service.dart';
|
||||
import 'package:environment_monitoring_app/auth_provider.dart';
|
||||
import 'package:environment_monitoring_app/services/base_api_service.dart'; // Import for SessionExpiredException
|
||||
|
||||
/// A dedicated service to handle all business logic for the Marine Tarball Sampling feature.
|
||||
class MarineTarballSamplingService {
|
||||
@ -85,9 +86,8 @@ class MarineTarballSamplingService {
|
||||
bool anyApiSuccess = false;
|
||||
Map<String, dynamic> apiDataResult = {};
|
||||
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 {
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
@ -95,21 +95,6 @@ class MarineTarballSamplingService {
|
||||
body: data.toFormData(),
|
||||
);
|
||||
|
||||
if (apiDataResult['success'] == false &&
|
||||
(apiDataResult['message'] as String?)?.contains('Unauthorized') == true) {
|
||||
debugPrint("API submission failed with Unauthorized. Attempting silent relogin...");
|
||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
||||
|
||||
if (reloginSuccess) {
|
||||
debugPrint("Silent relogin successful. Retrying data submission...");
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine/tarball/sample',
|
||||
body: data.toFormData(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (apiDataResult['success'] == true) {
|
||||
anyApiSuccess = true;
|
||||
data.reportId = apiDataResult['data']?['autoid']?.toString();
|
||||
@ -129,12 +114,30 @@ class MarineTarballSamplingService {
|
||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
||||
}
|
||||
}
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin...");
|
||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
||||
|
||||
if (reloginSuccess) {
|
||||
debugPrint("Silent relogin successful. Retrying entire online submission process...");
|
||||
return await _performOnlineSubmission(
|
||||
data: data,
|
||||
appSettings: appSettings,
|
||||
moduleName: moduleName,
|
||||
authProvider: authProvider,
|
||||
);
|
||||
} else {
|
||||
debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP.");
|
||||
isSessionKnownToBeExpired = true;
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData());
|
||||
}
|
||||
} on SocketException catch (e) {
|
||||
final errorMessage = "API submission failed with network error: $e";
|
||||
debugPrint(errorMessage);
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': errorMessage};
|
||||
// Manually queue the failed API tasks since the service might not have been able to
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData());
|
||||
if(finalImageFiles.isNotEmpty && data.reportId != null) {
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/tarball/images', method: 'POST_MULTIPART', fields: {'autoid': data.reportId!}, files: finalImageFiles);
|
||||
@ -147,8 +150,6 @@ class MarineTarballSamplingService {
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData());
|
||||
}
|
||||
|
||||
// Step 2: Attempt FTP Submission in its own try-catch block.
|
||||
// This code will now run even if the API submission above failed.
|
||||
Map<String, dynamic> ftpResults = {'statuses': []};
|
||||
bool anyFtpSuccess = false;
|
||||
try {
|
||||
@ -157,15 +158,11 @@ class MarineTarballSamplingService {
|
||||
} on SocketException catch (e) {
|
||||
debugPrint("FTP submission failed with network error: $e");
|
||||
anyFtpSuccess = false;
|
||||
// Note: The underlying SubmissionFtpService already queues failed uploads,
|
||||
// so we just need to catch the error to prevent a crash.
|
||||
} on TimeoutException catch (e) {
|
||||
debugPrint("FTP submission timed out: $e");
|
||||
anyFtpSuccess = false;
|
||||
}
|
||||
// --- END: MODIFICATION FOR GRANULAR ERROR HANDLING ---
|
||||
|
||||
// Step 3: Determine final status based on the outcomes of the independent steps.
|
||||
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
|
||||
String finalMessage;
|
||||
String finalStatus;
|
||||
@ -180,7 +177,7 @@ class MarineTarballSamplingService {
|
||||
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
|
||||
finalStatus = 'L4';
|
||||
} else {
|
||||
finalMessage = 'All submission attempts failed and have been queued for retry.';
|
||||
finalMessage = apiDataResult['message'] ?? 'All submission attempts failed and have been queued for retry.';
|
||||
finalStatus = 'L1';
|
||||
}
|
||||
|
||||
@ -195,7 +192,7 @@ class MarineTarballSamplingService {
|
||||
);
|
||||
|
||||
if (overallSuccess) {
|
||||
_handleTarballSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty);
|
||||
_handleTarballSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
||||
}
|
||||
|
||||
return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId};
|
||||
@ -225,7 +222,7 @@ class MarineTarballSamplingService {
|
||||
},
|
||||
);
|
||||
|
||||
const successMessage = "No internet connection. Submission has been saved and queued for upload.";
|
||||
const successMessage = "Submission failed to send and has been queued for later retry.";
|
||||
await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {});
|
||||
|
||||
return {'success': true, 'message': successMessage};
|
||||
@ -276,8 +273,8 @@ class MarineTarballSamplingService {
|
||||
|
||||
return {
|
||||
'statuses': <Map<String, dynamic>>[
|
||||
...(ftpDataResult['statuses'] as List),
|
||||
...(ftpImageResult['statuses'] as List),
|
||||
...?(ftpDataResult['statuses'] as List?),
|
||||
...?(ftpImageResult['statuses'] as List?),
|
||||
],
|
||||
};
|
||||
}
|
||||
@ -314,12 +311,17 @@ class MarineTarballSamplingService {
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
}
|
||||
|
||||
Future<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 {
|
||||
final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly);
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('marine_tarball', message, appSettings);
|
||||
if (!wasSent) {
|
||||
if (isSessionExpired) {
|
||||
debugPrint("Session is expired; queuing Telegram alert directly.");
|
||||
await _telegramService.queueMessage('marine_tarball', message, appSettings);
|
||||
} else {
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('marine_tarball', message, appSettings);
|
||||
if (!wasSent) {
|
||||
await _telegramService.queueMessage('marine_tarball', message, appSettings);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Failed to handle Tarball Telegram alert: $e");
|
||||
|
||||
@ -21,6 +21,7 @@ class RetryService {
|
||||
final BaseApiService _baseApiService = BaseApiService();
|
||||
final FtpService _ftpService = FtpService();
|
||||
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||
bool _isProcessing = false;
|
||||
|
||||
// --- START: MODIFICATION FOR HANDLING COMPLEX TASKS ---
|
||||
// These services will be provided after the RetryService is created.
|
||||
@ -102,6 +103,37 @@ class RetryService {
|
||||
return _dbHelper.getPendingRequests();
|
||||
}
|
||||
|
||||
/// Processes the entire queue of pending tasks.
|
||||
Future<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.
|
||||
/// Returns `true` on success, `false` on failure.
|
||||
Future<bool> retryTask(int taskId) async {
|
||||
|
||||
@ -30,6 +30,7 @@ import 'submission_api_service.dart';
|
||||
import 'submission_ftp_service.dart';
|
||||
import 'telegram_service.dart';
|
||||
import 'retry_service.dart';
|
||||
import 'base_api_service.dart'; // Import for SessionExpiredException
|
||||
|
||||
|
||||
class RiverInSituSamplingService {
|
||||
@ -214,6 +215,7 @@ class RiverInSituSamplingService {
|
||||
bool anyApiSuccess = false;
|
||||
Map<String, dynamic> apiDataResult = {};
|
||||
Map<String, dynamic> apiImageResult = {};
|
||||
bool isSessionKnownToBeExpired = false;
|
||||
|
||||
try {
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
@ -222,21 +224,6 @@ class RiverInSituSamplingService {
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
|
||||
if (apiDataResult['success'] == false &&
|
||||
(apiDataResult['message'] as String?)?.contains('Unauthorized') == true) {
|
||||
debugPrint("API submission failed with Unauthorized. Attempting silent relogin...");
|
||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
||||
|
||||
if (reloginSuccess) {
|
||||
debugPrint("Silent relogin successful. Retrying data submission...");
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'river/manual/sample',
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (apiDataResult['success'] == true) {
|
||||
anyApiSuccess = true;
|
||||
data.reportId = apiDataResult['data']?['r_man_id']?.toString();
|
||||
@ -258,6 +245,26 @@ class RiverInSituSamplingService {
|
||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
||||
}
|
||||
}
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin...");
|
||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
||||
|
||||
if (reloginSuccess) {
|
||||
debugPrint("Silent relogin successful. Retrying entire online submission process...");
|
||||
return await _performOnlineSubmission(
|
||||
data: data,
|
||||
appSettings: appSettings,
|
||||
moduleName: moduleName,
|
||||
authProvider: authProvider,
|
||||
logDirectory: logDirectory,
|
||||
);
|
||||
} else {
|
||||
debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP.");
|
||||
isSessionKnownToBeExpired = true;
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
||||
await _retryService.addApiToQueue(endpoint: 'river/manual/sample', method: 'POST', body: data.toApiFormData());
|
||||
}
|
||||
} on SocketException catch (e) {
|
||||
final errorMessage = "API submission failed with network error: $e";
|
||||
debugPrint(errorMessage);
|
||||
@ -302,7 +309,7 @@ class RiverInSituSamplingService {
|
||||
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
|
||||
finalStatus = 'L4';
|
||||
} else {
|
||||
finalMessage = 'All submission attempts failed and have been queued for retry.';
|
||||
finalMessage = apiDataResult['message'] ?? 'All submission attempts failed and have been queued for retry.';
|
||||
finalStatus = 'L1';
|
||||
}
|
||||
|
||||
@ -317,7 +324,7 @@ class RiverInSituSamplingService {
|
||||
);
|
||||
|
||||
if (overallSuccess) {
|
||||
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty);
|
||||
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
||||
}
|
||||
|
||||
return {
|
||||
@ -336,7 +343,7 @@ class RiverInSituSamplingService {
|
||||
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
|
||||
|
||||
data.submissionStatus = 'L1';
|
||||
data.submissionMessage = 'Submission queued due to being offline.';
|
||||
data.submissionMessage = 'Submission queued for later retry.';
|
||||
|
||||
final String? localLogPath = await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName);
|
||||
|
||||
@ -355,7 +362,7 @@ class RiverInSituSamplingService {
|
||||
},
|
||||
);
|
||||
|
||||
const successMessage = "No internet connection. Submission has been saved and queued for upload.";
|
||||
const successMessage = "Submission failed to send and has been queued for later retry.";
|
||||
return {'status': 'Queued', 'success': true, 'message': successMessage};
|
||||
}
|
||||
|
||||
@ -376,7 +383,7 @@ class RiverInSituSamplingService {
|
||||
}
|
||||
|
||||
final dataZip = await _zippingService.createDataZip(
|
||||
jsonDataMap: {'db.json': data.toDbJson()},
|
||||
jsonDataMap: {'db.json': jsonEncode(data.toApiFormData())},
|
||||
baseFileName: baseFileName,
|
||||
destinationDir: localSubmissionDir,
|
||||
);
|
||||
@ -447,43 +454,52 @@ class RiverInSituSamplingService {
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
}
|
||||
|
||||
Future<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 {
|
||||
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||
final stationName = data.selectedStation?['sampling_river'] ?? 'N/A';
|
||||
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'N/A';
|
||||
final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
final submitter = data.firstSamplerName ?? 'N/A';
|
||||
final sondeID = data.sondeId ?? 'N/A';
|
||||
final distanceKm = data.distanceDifferenceInKm ?? 0;
|
||||
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
|
||||
final distanceRemarks = data.distanceDifferenceRemarks ?? 'N/A';
|
||||
|
||||
final buffer = StringBuffer()
|
||||
..writeln('✅ *River In-Situ Sample ${submissionType} Submitted:*')
|
||||
..writeln()
|
||||
..writeln('*Station Name & Code:* $stationName ($stationCode)')
|
||||
..writeln('*Date of Submitted:* $submissionDate')
|
||||
..writeln('*Submitted by User:* $submitter')
|
||||
..writeln('*Sonde ID:* $sondeID')
|
||||
..writeln('*Status of Submission:* Successful');
|
||||
|
||||
if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) {
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('🔔 *Alert:*')
|
||||
..writeln('*Distance from station:* $distanceMeters meters');
|
||||
if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
|
||||
buffer.writeln('*Remarks for distance:* $distanceRemarks');
|
||||
}
|
||||
}
|
||||
final String message = buffer.toString();
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings);
|
||||
if (!wasSent) {
|
||||
final message = await _generateInSituAlertMessage(data, isDataOnly: isDataOnly);
|
||||
if (isSessionExpired) {
|
||||
debugPrint("Session is expired; queuing Telegram alert directly.");
|
||||
await _telegramService.queueMessage('river_in_situ', message, appSettings);
|
||||
} else {
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings);
|
||||
if (!wasSent) {
|
||||
await _telegramService.queueMessage('river_in_situ', message, appSettings);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Failed to handle River Telegram alert: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<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();
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@ import 'package:usb_serial/usb_serial.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../auth_provider.dart';
|
||||
import 'location_service.dart';
|
||||
@ -29,6 +30,7 @@ import 'submission_api_service.dart';
|
||||
import 'submission_ftp_service.dart';
|
||||
import 'telegram_service.dart';
|
||||
import 'retry_service.dart';
|
||||
import 'base_api_service.dart'; // Import for SessionExpiredException
|
||||
|
||||
|
||||
class RiverManualTriennialSamplingService {
|
||||
@ -213,6 +215,7 @@ class RiverManualTriennialSamplingService {
|
||||
bool anyApiSuccess = false;
|
||||
Map<String, dynamic> apiDataResult = {};
|
||||
Map<String, dynamic> apiImageResult = {};
|
||||
bool isSessionKnownToBeExpired = false;
|
||||
|
||||
try {
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
@ -221,21 +224,6 @@ class RiverManualTriennialSamplingService {
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
|
||||
if (apiDataResult['success'] == false &&
|
||||
(apiDataResult['message'] as String?)?.contains('Unauthorized') == true) {
|
||||
debugPrint("API submission failed with Unauthorized. Attempting silent relogin...");
|
||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
||||
|
||||
if (reloginSuccess) {
|
||||
debugPrint("Silent relogin successful. Retrying data submission...");
|
||||
apiDataResult = await _submissionApiService.submitPost(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'river/triennial/sample',
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (apiDataResult['success'] == true) {
|
||||
anyApiSuccess = true;
|
||||
data.reportId = apiDataResult['data']?['r_tri_id']?.toString();
|
||||
@ -257,6 +245,26 @@ class RiverManualTriennialSamplingService {
|
||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
||||
}
|
||||
}
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin...");
|
||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
||||
|
||||
if (reloginSuccess) {
|
||||
debugPrint("Silent relogin successful. Retrying entire online submission process...");
|
||||
return await _performOnlineSubmission(
|
||||
data: data,
|
||||
appSettings: appSettings,
|
||||
moduleName: moduleName,
|
||||
authProvider: authProvider,
|
||||
logDirectory: logDirectory,
|
||||
);
|
||||
} else {
|
||||
debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP.");
|
||||
isSessionKnownToBeExpired = true;
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
||||
await _retryService.addApiToQueue(endpoint: 'river/triennial/sample', method: 'POST', body: data.toApiFormData());
|
||||
}
|
||||
} on SocketException catch (e) {
|
||||
final errorMessage = "API submission failed with network error: $e";
|
||||
debugPrint(errorMessage);
|
||||
@ -301,7 +309,7 @@ class RiverManualTriennialSamplingService {
|
||||
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
|
||||
finalStatus = 'L4';
|
||||
} else {
|
||||
finalMessage = 'All submission attempts failed and have been queued for retry.';
|
||||
finalMessage = apiDataResult['message'] ?? 'All submission attempts failed and have been queued for retry.';
|
||||
finalStatus = 'L1';
|
||||
}
|
||||
|
||||
@ -316,7 +324,7 @@ class RiverManualTriennialSamplingService {
|
||||
);
|
||||
|
||||
if (overallSuccess) {
|
||||
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty);
|
||||
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
||||
}
|
||||
|
||||
return {
|
||||
@ -335,7 +343,7 @@ class RiverManualTriennialSamplingService {
|
||||
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
|
||||
|
||||
data.submissionStatus = 'L1';
|
||||
data.submissionMessage = 'Submission queued due to being offline.';
|
||||
data.submissionMessage = 'Submission queued for later retry.';
|
||||
|
||||
final String? localLogPath = await _localStorageService.saveRiverManualTriennialSamplingData(data, serverName: serverName);
|
||||
|
||||
@ -354,7 +362,7 @@ class RiverManualTriennialSamplingService {
|
||||
},
|
||||
);
|
||||
|
||||
const successMessage = "No internet connection. Submission has been saved and queued for upload.";
|
||||
const successMessage = "Submission failed to send and has been queued for later retry.";
|
||||
return {'status': 'Queued', 'success': true, 'message': successMessage};
|
||||
}
|
||||
|
||||
@ -446,43 +454,52 @@ class RiverManualTriennialSamplingService {
|
||||
await _dbHelper.saveSubmissionLog(logData);
|
||||
}
|
||||
|
||||
Future<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 {
|
||||
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||
final stationName = data.selectedStation?['sampling_river'] ?? 'N/A';
|
||||
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'N/A';
|
||||
final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
final submitter = data.firstSamplerName ?? 'N/A';
|
||||
final sondeID = data.sondeId ?? 'N/A';
|
||||
final distanceKm = data.distanceDifferenceInKm ?? 0;
|
||||
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
|
||||
final distanceRemarks = data.distanceDifferenceRemarks ?? 'N/A';
|
||||
|
||||
final buffer = StringBuffer()
|
||||
..writeln('✅ *River Triennial Sample ${submissionType} Submitted:*')
|
||||
..writeln()
|
||||
..writeln('*Station Name & Code:* $stationName ($stationCode)')
|
||||
..writeln('*Date of Submitted:* $submissionDate')
|
||||
..writeln('*Submitted by User:* $submitter')
|
||||
..writeln('*Sonde ID:* $sondeID')
|
||||
..writeln('*Status of Submission:* Successful');
|
||||
|
||||
if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) {
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('🔔 *Alert:*')
|
||||
..writeln('*Distance from station:* $distanceMeters meters');
|
||||
if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
|
||||
buffer.writeln('*Remarks for distance:* $distanceRemarks');
|
||||
}
|
||||
}
|
||||
final String message = buffer.toString();
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('river_triennial', message, appSettings);
|
||||
if (!wasSent) {
|
||||
final message = await _generateSuccessAlertMessage(data, isDataOnly: isDataOnly);
|
||||
if (isSessionExpired) {
|
||||
debugPrint("Session is expired; queuing Telegram alert directly.");
|
||||
await _telegramService.queueMessage('river_triennial', message, appSettings);
|
||||
} else {
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('river_triennial', message, appSettings);
|
||||
if (!wasSent) {
|
||||
await _telegramService.queueMessage('river_triennial', message, appSettings);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Failed to handle River Triennial Telegram alert: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<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();
|
||||
}
|
||||
}
|
||||
@ -1,27 +1,113 @@
|
||||
// lib/services/user_preferences_service.dart
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:environment_monitoring_app/services/api_service.dart'; // Contains DatabaseHelper
|
||||
|
||||
/// A dedicated service to manage the user's local preferences for
|
||||
/// module-specific submission destinations.
|
||||
class UserPreferencesService {
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
static const _defaultPrefsSavedKey = 'default_preferences_saved';
|
||||
|
||||
// Moved from settings.dart for central access
|
||||
final List<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.
|
||||
/// If no preference has been saved for this module, it returns a default
|
||||
/// where both API and FTP are enabled.
|
||||
Future<Map<String, dynamic>> getModulePreference(String moduleName) async {
|
||||
/// This method now returns null if no preference is found.
|
||||
Future<Map<String, dynamic>?> getModulePreference(String moduleName) async {
|
||||
final preference = await _dbHelper.getModulePreference(moduleName);
|
||||
if (preference != null) {
|
||||
return preference;
|
||||
}
|
||||
// Return a default value if no preference is found in the database.
|
||||
return {
|
||||
'module_name': moduleName,
|
||||
'is_api_enabled': true,
|
||||
'is_ftp_enabled': true,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Saves or updates a module's master on/off switches for API and FTP submissions.
|
||||
@ -119,10 +205,10 @@ class UserPreferencesService {
|
||||
/// destinations to send data to.
|
||||
Future<List<Map<String, dynamic>>> getEnabledApiConfigsForModule(String moduleName) async {
|
||||
// 1. Check the master switch for the module.
|
||||
final pref = await getModulePreference(moduleName);
|
||||
if (!(pref['is_api_enabled'] as bool)) {
|
||||
debugPrint("API submissions are disabled for module '$moduleName' via master switch.");
|
||||
return []; // Return empty list if API is globally disabled for this module.
|
||||
final pref = await _dbHelper.getModulePreference(moduleName); // Use direct DB call
|
||||
if (pref == null || !(pref['is_api_enabled'] as bool)) {
|
||||
debugPrint("API submissions are disabled for module '$moduleName'.");
|
||||
return []; // Return empty list if API is disabled or not set.
|
||||
}
|
||||
|
||||
// 2. Get all configs with their preference flags.
|
||||
@ -137,9 +223,9 @@ class UserPreferencesService {
|
||||
|
||||
/// Retrieves only the FTP configurations that are actively enabled for a given module.
|
||||
Future<List<Map<String, dynamic>>> getEnabledFtpConfigsForModule(String moduleName) async {
|
||||
final pref = await getModulePreference(moduleName);
|
||||
if (!(pref['is_ftp_enabled'] as bool)) {
|
||||
debugPrint("FTP submissions are disabled for module '$moduleName' via master switch.");
|
||||
final pref = await _dbHelper.getModulePreference(moduleName); // Use direct DB call
|
||||
if (pref == null || !(pref['is_ftp_enabled'] as bool)) {
|
||||
debugPrint("FTP submissions are disabled for module '$moduleName'.");
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@ -7,12 +7,16 @@
|
||||
#include "generated_plugin_registrant.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>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
flutter_secure_storage_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import Foundation
|
||||
import connectivity_plus
|
||||
import file_picker
|
||||
import file_selector_macos
|
||||
import flutter_secure_storage_macos
|
||||
import geolocator_apple
|
||||
import path_provider_foundation
|
||||
import shared_preferences_foundation
|
||||
@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
||||
56
pubspec.lock
56
pubspec.lock
@ -271,6 +271,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.28"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -449,6 +497,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.18.1"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.7"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -26,6 +26,7 @@ dependencies:
|
||||
path_provider: ^2.1.3
|
||||
path: ^1.8.3 # Explicitly added for path manipulation
|
||||
connectivity_plus: ^6.0.1
|
||||
flutter_secure_storage: ^9.0.0 # ADDED: For securely storing the user's password
|
||||
|
||||
# --- UI Components & Utilities ---
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
|
||||
#include <connectivity_plus/connectivity_plus_windows_plugin.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 <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
GeolocatorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
connectivity_plus
|
||||
file_selector_windows
|
||||
flutter_secure_storage_windows
|
||||
geolocator_windows
|
||||
permission_handler_windows
|
||||
url_launcher_windows
|
||||
|
||||
Loading…
Reference in New Issue
Block a user