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