diff --git a/lib/auth_provider.dart b/lib/auth_provider.dart index 73575ba..1c0247b 100644 --- a/lib/auth_provider.dart +++ b/lib/auth_provider.dart @@ -1,27 +1,22 @@ +// lib/auth_provider.dart + +import 'package:flutter/foundation.dart'; // Import for compute function import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'dart:convert'; +import 'package:bcrypt/bcrypt.dart'; // Import bcrypt import 'package:environment_monitoring_app/services/api_service.dart'; -// --- ADDED: Import for the service that manages active server configurations --- import 'package:environment_monitoring_app/services/server_config_service.dart'; -// --- ADDED: Import for the new service that manages the retry queue --- import 'package:environment_monitoring_app/services/retry_service.dart'; - -/// A comprehensive provider to manage user authentication, session state, -/// and cached master data for offline use. class AuthProvider with ChangeNotifier { - // FIX: Change to late final and remove direct instantiation. late final ApiService _apiService; late final DatabaseHelper _dbHelper; - // --- ADDED: Instance of the ServerConfigService to set the initial URL --- late final ServerConfigService _serverConfigService; - // --- ADDED: Instance of the RetryService to manage pending tasks --- late final RetryService _retryService; - // --- Session & Profile State --- String? _jwtToken; String? _userEmail; @@ -30,6 +25,9 @@ class AuthProvider with ChangeNotifier { String? get userEmail => _userEmail; Map? get profileData => _profileData; + // --- ADDED: Temporary password cache for auto-relogin --- + String? _tempOfflinePassword; + // --- App State --- bool _isLoading = true; bool _isFirstLogin = true; @@ -55,12 +53,9 @@ class AuthProvider with ChangeNotifier { List>? _parameterLimits; List>? _apiConfigs; List>? _ftpConfigs; - // --- ADDED: State variable for the list of documents --- List>? _documents; - // --- ADDED: State variable for the list of tasks pending manual retry --- List>? _pendingRetries; - // --- Getters for UI access --- List>? get allUsers => _allUsers; List>? get tarballStations => _tarballStations; @@ -78,12 +73,9 @@ class AuthProvider with ChangeNotifier { List>? get parameterLimits => _parameterLimits; List>? get apiConfigs => _apiConfigs; List>? get ftpConfigs => _ftpConfigs; - // --- ADDED: Getter for the list of documents --- List>? get documents => _documents; - // --- ADDED: Getter for the list of tasks pending manual retry --- List>? get pendingRetries => _pendingRetries; - // --- SharedPreferences Keys --- static const String tokenKey = 'jwt_token'; static const String userEmailKey = 'user_email'; @@ -91,7 +83,6 @@ class AuthProvider with ChangeNotifier { static const String lastSyncTimestampKey = 'last_sync_timestamp'; static const String isFirstLoginKey = 'is_first_login'; - // FIX: Constructor now accepts dependencies. AuthProvider({ required ApiService apiService, required DatabaseHelper dbHelper, @@ -105,7 +96,6 @@ class AuthProvider with ChangeNotifier { _loadSessionAndSyncData(); } - /// Loads the user session from storage and then triggers a data sync. Future _loadSessionAndSyncData() async { _isLoading = true; notifyListeners(); @@ -115,36 +105,32 @@ class AuthProvider with ChangeNotifier { _userEmail = prefs.getString(userEmailKey); _isFirstLogin = prefs.getBool(isFirstLoginKey) ?? true; - // --- MODIFIED: Logic to insert a default URL on the first ever run --- - // Check if there is an active API configuration. final activeApiConfig = await _serverConfigService.getActiveApiConfig(); if (activeApiConfig == null) { - // If no config is set (which will be true on the very first launch), - // we programmatically create and set a default one. debugPrint("AuthProvider: No active API config found. Setting default bootstrap URL."); final initialConfig = { 'api_config_id': 0, 'config_name': 'Default Server', - 'api_url': 'https://dev14.pstw.com.my/v1', + 'api_url': 'https://mms-apiv4.pstw.com.my/v1', }; - // Save this default config as the active one. await _serverConfigService.setActiveApiConfig(initialConfig); } - // --- END OF MODIFICATION --- - // MODIFIED: Switched to getting a string for the ISO8601 timestamp final lastSyncString = prefs.getString(lastSyncTimestampKey); if (lastSyncString != null) { - _lastSyncTimestamp = DateTime.parse(lastSyncString); + try { + _lastSyncTimestamp = DateTime.parse(lastSyncString); + } catch (e) { + debugPrint("Error parsing last sync timestamp: $e"); + prefs.remove(lastSyncTimestampKey); + } } - // Always load from local DB first for instant startup await _loadDataFromCache(); if (_jwtToken != null) { - debugPrint('AuthProvider: Session loaded. Triggering online sync.'); - // Don't await here to allow the UI to build instantly with cached data - syncAllData(); + debugPrint('AuthProvider: Session loaded.'); + // Sync logic moved to checkAndTransitionToOnlineSession to handle transitions correctly } else { debugPrint('AuthProvider: No active session. App is in offline mode.'); } @@ -153,40 +139,127 @@ class AuthProvider with ChangeNotifier { notifyListeners(); } - /// The main function to sync all app data using the delta-sync strategy. + /// Checks if the session is offline and attempts to transition to an online session by performing a silent re-login. + /// Returns true if a successful transition occurred. + Future checkAndTransitionToOnlineSession() async { + // Condition 1: Check connectivity + final connectivityResult = await Connectivity().checkConnectivity(); + if (connectivityResult.contains(ConnectivityResult.none)) { + debugPrint("AuthProvider: No internet connection. Skipping transition check."); + return false; + } + + // Condition 2: Check if currently in an offline session state + final bool inOfflineSession = _jwtToken != null && _jwtToken!.startsWith("offline-session-"); + if (!inOfflineSession) { + // Already online or logged out, no transition needed. + // If online, trigger a normal sync to ensure data freshness on connection restoration. + if(_jwtToken != null) { + debugPrint("AuthProvider: Session is already online. Triggering standard sync."); + syncAllData(); + } + return false; + } + + // Condition 3: Check if we have the temporary password to attempt re-login. + if (_tempOfflinePassword == null || _userEmail == null) { + debugPrint("AuthProvider: In offline session, but no temporary password available for auto-relogin. Manual login required."); + return false; + } + + debugPrint("AuthProvider: Internet detected in offline session. Attempting silent re-login for $_userEmail..."); + + try { + final result = await _apiService.login(_userEmail!, _tempOfflinePassword!); + + if (result['success'] == true) { + debugPrint("AuthProvider: Silent re-login successful. Transitioning to online session."); + final String token = result['data']['token']; + final Map profile = result['data']['profile']; + + // Use existing login method to set up session and trigger sync. + // Re-pass the password to ensure credentials are fully cached after transition. + await login(token, profile, _tempOfflinePassword!); + + // Clear temporary password after successful transition + _tempOfflinePassword = null; + notifyListeners(); // Ensure UI updates after state change + return true; + } else { + // Silent login failed (e.g., password changed on another device). + // Keep user in offline mode for now. They will need to log out and log back in manually. + debugPrint("AuthProvider: Silent re-login failed: ${result['message']}"); + return false; + } + } catch (e) { + debugPrint("AuthProvider: Silent re-login exception: $e"); + return false; + } + } + + /// Attempts to silently re-login to get a new token. + /// This can be called when a 401 Unauthorized error is detected. + Future attemptSilentRelogin() async { + final connectivityResult = await Connectivity().checkConnectivity(); + if (connectivityResult.contains(ConnectivityResult.none)) { + debugPrint("AuthProvider: No internet for silent relogin."); + return false; + } + + if (_tempOfflinePassword == null || _userEmail == null) { + debugPrint("AuthProvider: No cached credentials for silent relogin."); + return false; + } + + debugPrint("AuthProvider: Session may be expired. Attempting silent re-login for $_userEmail..."); + try { + final result = await _apiService.login(_userEmail!, _tempOfflinePassword!); + if (result['success'] == true) { + debugPrint("AuthProvider: Silent re-login successful."); + final String token = result['data']['token']; + final Map profile = result['data']['profile']; + await login(token, profile, _tempOfflinePassword!); + return true; + } else { + debugPrint("AuthProvider: Silent re-login failed: ${result['message']}"); + return false; + } + } catch (e) { + debugPrint("AuthProvider: Silent re-login exception: $e"); + return false; + } + } + Future syncAllData({bool forceRefresh = false}) async { final connectivityResult = await Connectivity().checkConnectivity(); - if (connectivityResult == ConnectivityResult.none) { + if (connectivityResult.contains(ConnectivityResult.none)) { debugPrint("AuthProvider: Device is OFFLINE. Skipping sync."); return; } + // Prevent sync attempts if token is an offline placeholder + if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) { + debugPrint("AuthProvider: Skipping sync, session is offline or null."); + return; + } + debugPrint("AuthProvider: Device is ONLINE. Starting delta sync."); final prefs = await SharedPreferences.getInstance(); - - // If 'forceRefresh' is true, sync all data by passing a null timestamp. final String? lastSync = forceRefresh ? null : prefs.getString(lastSyncTimestampKey); - - // Record the time BEFORE the sync starts. This will be our new timestamp on success. - // Use UTC for consistency across timezones. final newSyncTimestamp = DateTime.now().toUtc().toIso8601String(); final result = await _apiService.syncAllData(lastSyncTimestamp: lastSync); if (result['success']) { debugPrint("AuthProvider: Delta sync successful. Updating last sync timestamp."); - // On success, save the new timestamp for the next run. await prefs.setString(lastSyncTimestampKey, newSyncTimestamp); _lastSyncTimestamp = DateTime.parse(newSyncTimestamp); - // --- ADDED: After the first successful sync, set isFirstLogin to false --- if (_isFirstLogin) { await setIsFirstLogin(false); debugPrint("AuthProvider: First successful sync complete. isFirstLogin flag set to false."); } - // --- END --- - // After updating the DB, reload data from the cache into memory to update the UI. await _loadDataFromCache(); notifyListeners(); } else { @@ -194,22 +267,31 @@ class AuthProvider with ChangeNotifier { } } - /// A dedicated method to refresh only the profile. Future refreshProfile() async { + final connectivityResult = await Connectivity().checkConnectivity(); + if (connectivityResult.contains(ConnectivityResult.none)) { + debugPrint("AuthProvider: Device is OFFLINE. Skipping profile refresh."); + return; + } + if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) { + debugPrint("AuthProvider: Skipping profile refresh, session is offline or null."); + return; + } + final result = await _apiService.refreshProfile(); if (result['success']) { - _profileData = result['data']; - // Persist the updated profile data - await _dbHelper.saveProfile(_profileData!); - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(profileDataKey, jsonEncode(_profileData)); - notifyListeners(); + await setProfileData(result['data']); } } - /// Loads all master data from the local cache using DatabaseHelper. Future _loadDataFromCache() async { - _profileData = await _dbHelper.loadProfile(); + final prefs = await SharedPreferences.getInstance(); + final profileJson = prefs.getString(profileDataKey); + if (profileJson != null) { + _profileData = jsonDecode(profileJson); + } else { + _profileData = await _dbHelper.loadProfile(); + } _allUsers = await _dbHelper.loadUsers(); _tarballStations = await _dbHelper.loadTarballStations(); _manualStations = await _dbHelper.loadManualStations(); @@ -222,49 +304,116 @@ class AuthProvider with ChangeNotifier { _airClients = await _dbHelper.loadAirClients(); _airManualStations = await _dbHelper.loadAirManualStations(); _states = await _dbHelper.loadStates(); - // ADDED: Load new data types from the local database _appSettings = await _dbHelper.loadAppSettings(); _parameterLimits = await _dbHelper.loadParameterLimits(); - // --- ADDED: Load documents from the local database cache --- _documents = await _dbHelper.loadDocuments(); _apiConfigs = await _dbHelper.loadApiConfigs(); _ftpConfigs = await _dbHelper.loadFtpConfigs(); - // --- ADDED: Load pending retry tasks from the database --- _pendingRetries = await _retryService.getPendingTasks(); debugPrint("AuthProvider: All master data loaded from local DB cache."); } - // --- ADDED: A public method to allow the UI to refresh the pending tasks list --- - /// Refreshes the list of pending retry tasks from the local database. Future refreshPendingTasks() async { _pendingRetries = await _retryService.getPendingTasks(); notifyListeners(); } - // --- Methods for UI interaction --- - - /// Handles the login process, saving session data and triggering a full data sync. - Future login(String token, Map profile) async { + Future login(String token, Map profile, String password) async { _jwtToken = token; _userEmail = profile['email']; - _profileData = profile; + // --- MODIFIED: Cache password on successful ONLINE login --- + _tempOfflinePassword = password; + + final Map profileWithToken = Map.from(profile); + profileWithToken['token'] = token; + _profileData = profileWithToken; final prefs = await SharedPreferences.getInstance(); await prefs.setString(tokenKey, token); await prefs.setString(userEmailKey, _userEmail!); - await prefs.setString(profileDataKey, jsonEncode(profile)); - await _dbHelper.saveProfile(profile); + await prefs.setString(profileDataKey, jsonEncode(_profileData)); + await _dbHelper.saveProfile(_profileData!); + + try { + debugPrint("AuthProvider: Hashing and caching password for offline login."); + final String hashedPassword = await compute(hashPassword, password); + await _dbHelper.upsertUserWithCredentials( + profile: profile, + passwordHash: hashedPassword, + ); + debugPrint("AuthProvider: Credentials cached successfully."); + } catch (e) { + debugPrint("AuthProvider: Failed to cache password hash: $e"); + } debugPrint('AuthProvider: Login successful. Session and profile persisted.'); - // Perform a full refresh on login to ensure data is pristine. await syncAllData(forceRefresh: true); } + Future loginOffline(String email, String password) async { + debugPrint("AuthProvider: Attempting offline login for user $email."); + + try { + // 1. Retrieve stored hash from the local database based on email. + final String? storedHash = await _dbHelper.getUserPasswordHashByEmail(email); + + if (storedHash == null || storedHash.isEmpty) { + debugPrint("AuthProvider DEBUG: Offline login failed for $email because NO HASH was found in local storage."); + return false; + } + + // 2. Verify the provided password against the stored hash. + debugPrint("AuthProvider: Verifying password against stored hash..."); + final bool passwordMatches = await compute(verifyPassword, CheckPasswordParams(password, storedHash)); + + if (passwordMatches) { + debugPrint("AuthProvider: Offline password verification successful."); + // 3. Load profile data from local storage. + final Map? cachedProfile = await _dbHelper.loadProfileByEmail(email); + if (cachedProfile == null) { + debugPrint("AuthProvider DEBUG: Offline login failed because profile data was missing, even though password matched."); + return false; + } + + // 4. Initialize session state from cached profile data. + _jwtToken = "offline-session-${DateTime.now().millisecondsSinceEpoch}"; + _userEmail = email; + + // --- MODIFIED: Cache the password on successful OFFLINE login --- + _tempOfflinePassword = password; + + final Map profileWithToken = Map.from(cachedProfile); + profileWithToken['token'] = _jwtToken; + _profileData = profileWithToken; + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(tokenKey, _jwtToken!); + await prefs.setString(userEmailKey, _userEmail!); + await prefs.setString(profileDataKey, jsonEncode(_profileData)); + + await _loadDataFromCache(); + notifyListeners(); + return true; + } else { + debugPrint("AuthProvider DEBUG: Offline login failed because password did not match stored hash (Hash Mismatch)."); + return false; + } + } catch (e) { + debugPrint("AuthProvider DEBUG: An unexpected error occurred during offline login process: $e"); + return false; + } + } + Future setProfileData(Map data) async { + final String? existingToken = _profileData?['token']; _profileData = data; + if (_profileData != null && existingToken != null) { + _profileData!['token'] = existingToken; + } + final prefs = await SharedPreferences.getInstance(); - await prefs.setString(profileDataKey, jsonEncode(data)); - await _dbHelper.saveProfile(data); // Also save to local DB + await prefs.setString(profileDataKey, jsonEncode(_profileData)); + await _dbHelper.saveProfile(_profileData!); // Also save to local DB notifyListeners(); } @@ -282,6 +431,9 @@ class AuthProvider with ChangeNotifier { _profileData = null; _lastSyncTimestamp = null; _isFirstLogin = true; + // --- MODIFIED: Clear temp password on logout --- + _tempOfflinePassword = null; + _allUsers = null; _tarballStations = null; _manualStations = null; @@ -294,18 +446,14 @@ class AuthProvider with ChangeNotifier { _airClients = null; _airManualStations = null; _states = null; - // ADDED: Clear new data on logout _appSettings = null; _parameterLimits = null; - // --- ADDED: Clear documents list on logout --- _documents = null; _apiConfigs = null; _ftpConfigs = null; - // --- ADDED: Clear pending retry tasks on logout --- _pendingRetries = null; final prefs = await SharedPreferences.getInstance(); - // MODIFIED: Removed keys individually for safer logout await prefs.remove(tokenKey); await prefs.remove(userEmailKey); await prefs.remove(profileDataKey); @@ -319,4 +467,18 @@ class AuthProvider with ChangeNotifier { Future> resetPassword(String email) { return _apiService.post('auth/forgot-password', {'email': email}); } +} + +class CheckPasswordParams { + final String password; + final String hash; + CheckPasswordParams(this.password, this.hash); +} + +String hashPassword(String password) { + return BCrypt.hashpw(password, BCrypt.gensalt()); +} + +bool verifyPassword(CheckPasswordParams params) { + return BCrypt.checkpw(params.password, params.hash); } \ No newline at end of file diff --git a/lib/collapsible_sidebar.dart b/lib/collapsible_sidebar.dart index 1296def..9827e35 100644 --- a/lib/collapsible_sidebar.dart +++ b/lib/collapsible_sidebar.dart @@ -43,117 +43,91 @@ class _CollapsibleSidebarState extends State { @override void initState() { super.initState(); + // --- MODIFIED: Menu items now match the home pages --- _menuItems = [ + // ====== AIR ====== SidebarItem( - // Reverted to IconData for the top-level 'Air' category icon: Icons.cloud, label: "Air", isParent: true, children: [ - // Added Manual sub-category for Air SidebarItem( - icon: Icons.handshake, // Example icon for Manual + icon: Icons.handshake, label: "Manual", isParent: true, children: [ - SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/manual/dashboard'), - SidebarItem(icon: Icons.edit_note, label: "Manual Sampling", route: '/air/manual/manual-sampling'), - SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/manual/report'), + SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/air/manual/info'), + SidebarItem(icon: Icons.construction, label: "Installation", route: '/air/manual/installation'), + SidebarItem(icon: Icons.inventory_2, label: "Collection", route: '/air/manual/collection'), SidebarItem(icon: Icons.article, label: "Data Log", route: '/air/manual/data-log'), - SidebarItem(icon: Icons.image, label: "Image Request", route: '/air/manual/image-request'), ], ), SidebarItem( icon: Icons.trending_up, label: "Continuous", isParent: true, children: [ - SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/continuous/dashboard'), - SidebarItem(icon: Icons.info, label: "Overview", route: '/air/continuous/overview'), - SidebarItem(icon: Icons.input, label: "Entry", route: '/air/continuous/entry'), - SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/continuous/report'), + SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/air/continuous/info'), ]), SidebarItem( icon: Icons.search, label: "Investigative", isParent: true, children: [ - SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/investigative/dashboard'), - SidebarItem(icon: Icons.info, label: "Overview", route: '/air/investigative/overview'), - SidebarItem(icon: Icons.input, label: "Entry", route: '/air/investigative/entry'), - SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/investigative/report'), + SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/air/investigative/info'), ]), ], ), + // ====== RIVER ====== SidebarItem( - // Reverted to IconData for the top-level 'River' category icon: Icons.water, label: "River", isParent: true, children: [ - // Added Manual sub-category for River SidebarItem( - icon: Icons.handshake, // Example icon for Manual + icon: Icons.handshake, label: "Manual", isParent: true, children: [ - SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/river/manual/dashboard'), + SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/river/manual/info'), SidebarItem(icon: Icons.pin_drop, label: "In-Situ Sampling", route: '/river/manual/in-situ'), - SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/manual/report'), SidebarItem(icon: Icons.date_range, label: "Triennial Sampling", route: '/river/manual/triennial'), SidebarItem(icon: Icons.article, label: "Data Log", route: '/river/manual/data-log'), - SidebarItem(icon: Icons.image, label: "Image Request", route: '/river/manual/image-request'), ], ), SidebarItem( icon: Icons.trending_up, label: "Continuous", isParent: true, children: [ - SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/river/continuous/dashboard'), - SidebarItem(icon: Icons.info, label: "Overview", route: '/river/continuous/overview'), - SidebarItem(icon: Icons.input, label: "Entry", route: '/river/continuous/entry'), - SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/continuous/report'), + SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/river/continuous/info'), ]), SidebarItem( icon: Icons.search, label: "Investigative", isParent: true, children: [ - SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/river/investigative/dashboard'), - SidebarItem(icon: Icons.info, label: "Overview", route: '/river/investigative/overview'), - SidebarItem(icon: Icons.input, label: "Entry", route: '/river/investigative/entry'), - SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/investigative/report'), + SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/river/investigative/info'), ]), ], ), + // ====== MARINE ====== SidebarItem( - // Reverted to IconData for the top-level 'Marine' category icon: Icons.sailing, label: "Marine", isParent: true, children: [ - // Added Manual sub-category for Marine SidebarItem( - icon: Icons.handshake, // Example icon for Manual + icon: Icons.handshake, label: "Manual", isParent: true, children: [ - SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/marine/manual/dashboard'), SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/manual/info'), SidebarItem(icon: Icons.assignment, label: "Pre-Sampling", route: '/marine/manual/pre-sampling'), SidebarItem(icon: Icons.pin_drop, label: "In-Situ Sampling", route: '/marine/manual/in-situ'), SidebarItem(icon: Icons.waves, label: "Tarball Sampling", route: '/marine/manual/tarball'), - SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/manual/report'), SidebarItem(icon: Icons.article, label: "Data Log", route: '/marine/manual/data-log'), - SidebarItem(icon: Icons.image, label: "Image Request", route: '/marine/manual/image-request'), ], ), SidebarItem( icon: Icons.trending_up, label: "Continuous", isParent: true, children: [ - SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/marine/continuous/dashboard'), - SidebarItem(icon: Icons.info, label: "Overview", route: '/marine/continuous/overview'), - SidebarItem(icon: Icons.input, label: "Entry", route: '/marine/continuous/entry'), - SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/continuous/report'), + SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/continuous/info'), ]), SidebarItem( icon: Icons.search, label: "Investigative", isParent: true, children: [ - SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/marine/investigative/dashboard'), - SidebarItem(icon: Icons.info, label: "Overview", route: '/marine/investigative/overview'), - SidebarItem(icon: Icons.input, label: "Entry", route: '/marine/investigative/entry'), - SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/investigative/report'), + SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/investigative/info'), ]), ], ), - // Added Settings menu item + // ====== SETTINGS ====== SidebarItem(icon: Icons.settings, label: "Settings", route: '/settings'), ]; } @@ -202,8 +176,7 @@ class _CollapsibleSidebarState extends State { Widget _buildExpandableNavItem(SidebarItem item) { if (item.children == null || item.children!.isEmpty) { // This case handles a top-level item that is NOT a parent, - // but if you have such an item, it should probably go through _buildNavItem directly. - // For this structure, all top-level items are parents. + // like the "Settings" item. return _buildNavItem(item.icon, item.label, item.route ?? '', isTopLevel: true, imagePath: item.imagePath); } @@ -298,4 +271,4 @@ class _CollapsibleSidebarState extends State { ), ); } -} +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 8fc6e00..0bac92e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -124,24 +124,63 @@ void main() async { } void setupServices(TelegramService telegramService) { + // Initial alert processing on startup (delayed) Future.delayed(const Duration(seconds: 5), () { debugPrint("[Main] Performing initial alert queue processing on app start."); telegramService.processAlertQueue(); }); - Connectivity().onConnectivityChanged.listen((List results) { - if (results.contains(ConnectivityResult.mobile) || results.contains(ConnectivityResult.wifi)) { - debugPrint("[Main] Internet connection detected. Triggering alert queue processing."); - telegramService.processAlertQueue(); - } else { - debugPrint("[Main] Internet connection lost."); - } - }); + // Connectivity listener moved to RootApp to access AuthProvider context. } -class RootApp extends StatelessWidget { +// --- START: MODIFIED RootApp --- +class RootApp extends StatefulWidget { const RootApp({super.key}); + @override + State createState() => _RootAppState(); +} + +class _RootAppState extends State { + + @override + void initState() { + super.initState(); + _initializeConnectivityListener(); + _performInitialSessionCheck(); + } + + /// Initial check when app loads to see if we need to transition from offline to online. + void _performInitialSessionCheck() async { + // Wait a moment for providers to be fully available. + await Future.delayed(const Duration(milliseconds: 100)); + if (mounted) { + Provider.of(context, listen: false).checkAndTransitionToOnlineSession(); + } + } + + /// Listens for connectivity changes to trigger auto-relogin or queue processing. + void _initializeConnectivityListener() { + Connectivity().onConnectivityChanged.listen((List results) { + if (!results.contains(ConnectivityResult.none)) { + debugPrint("[Main] Internet connection detected."); + if (mounted) { + // Access services from provider context + final authProvider = Provider.of(context, listen: false); + final telegramService = Provider.of(context, listen: false); + + // Attempt to auto-relogin if necessary + authProvider.checkAndTransitionToOnlineSession(); + + // Process alert queue + telegramService.processAlertQueue(); + } + } else { + debugPrint("[Main] Internet connection lost."); + } + }); + } + @override Widget build(BuildContext context) { return Consumer( @@ -241,7 +280,7 @@ class RootApp extends StatelessWidget { '/marine/manual/in-situ': (context) => marineManualInSituSampling.MarineInSituSampling(), '/marine/manual/tarball': (context) => const TarballSamplingStep1(), '/marine/manual/report': (context) => marineManualReport.MarineManualReport(), - '/marine/manual/data-log': (context) => marineManualDataStatusLog.MarineManualDataStatusLog(), + //'/marine/manual/data-log': (context) => marineManualDataStatusLog.MarineManualDataStatusLog(), // This is handled in onGenerateRoute '/marine/manual/image-request': (context) => marineManualImageRequest.MarineManualImageRequest(), // Marine Continuous @@ -261,6 +300,7 @@ class RootApp extends StatelessWidget { ); } } +// --- END: MODIFIED RootApp --- class SplashScreen extends StatelessWidget { const SplashScreen({super.key}); @@ -273,7 +313,7 @@ class SplashScreen extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Image.asset( - 'assets/icon4.png', + 'assets/icon4.png', // Ensure this asset exists height: 360, width: 360, ), diff --git a/lib/models/in_situ_sampling_data.dart b/lib/models/in_situ_sampling_data.dart index 6ffd109..44c3b47 100644 --- a/lib/models/in_situ_sampling_data.dart +++ b/lib/models/in_situ_sampling_data.dart @@ -68,13 +68,14 @@ class InSituSamplingData { String? submissionMessage; String? reportId; - // REPAIRED: Added a constructor to accept initial values. InSituSamplingData({ this.samplingDate, this.samplingTime, }); - // --- ADDED: Factory constructor to create a new instance from a map --- + /// Creates an InSituSamplingData object from a JSON map. + /// This is critical for the offline retry mechanism. The keys used here MUST perfectly + /// match the keys used in the `toDbJson()` method to ensure data integrity. factory InSituSamplingData.fromJson(Map json) { double? doubleFromJson(dynamic value) { if (value is num) return value.toDouble(); @@ -88,62 +89,70 @@ class InSituSamplingData { return null; } - // Helper to create a File object from a path string File? fileFromPath(dynamic path) { return (path is String && path.isNotEmpty) ? File(path) : null; } - return InSituSamplingData() - ..firstSamplerName = json['first_sampler_name'] - ..firstSamplerUserId = intFromJson(json['first_sampler_user_id']) - ..secondSampler = json['secondSampler'] - ..samplingDate = json['man_date'] - ..samplingTime = json['man_time'] - ..samplingType = json['man_type'] - ..sampleIdCode = json['man_sample_id_code'] - ..selectedStateName = json['selectedStateName'] - ..selectedCategoryName = json['selectedCategoryName'] - ..selectedStation = json['selectedStation'] - ..stationLatitude = json['stationLatitude'] - ..stationLongitude = json['stationLongitude'] - ..currentLatitude = json['man_current_latitude']?.toString() - ..currentLongitude = json['man_current_longitude']?.toString() - ..distanceDifferenceInKm = doubleFromJson(json['man_distance_difference']) - ..distanceDifferenceRemarks = json['man_distance_difference_remarks'] - ..weather = json['man_weather'] - ..tideLevel = json['man_tide_level'] - ..seaCondition = json['man_sea_condition'] - ..eventRemarks = json['man_event_remark'] - ..labRemarks = json['man_lab_remark'] - ..sondeId = json['man_sondeID'] - ..dataCaptureDate = json['data_capture_date'] - ..dataCaptureTime = json['data_capture_time'] - ..oxygenConcentration = doubleFromJson(json['man_oxygen_conc']) - ..oxygenSaturation = doubleFromJson(json['man_oxygen_sat']) - ..ph = doubleFromJson(json['man_ph']) - ..salinity = doubleFromJson(json['man_salinity']) - ..electricalConductivity = doubleFromJson(json['man_conductivity']) - ..temperature = doubleFromJson(json['man_temperature']) - ..tds = doubleFromJson(json['man_tds']) - ..turbidity = doubleFromJson(json['man_turbidity']) - ..tss = doubleFromJson(json['man_tss']) - ..batteryVoltage = doubleFromJson(json['man_battery_volt']) - ..optionalRemark1 = json['man_optional_photo_01_remarks'] - ..optionalRemark2 = json['man_optional_photo_02_remarks'] - ..optionalRemark3 = json['man_optional_photo_03_remarks'] - ..optionalRemark4 = json['man_optional_photo_04_remarks'] - ..leftLandViewImage = fileFromPath(json['man_left_side_land_view']) - ..rightLandViewImage = fileFromPath(json['man_right_side_land_view']) - ..waterFillingImage = fileFromPath(json['man_filling_water_into_sample_bottle']) - ..seawaterColorImage = fileFromPath(json['man_seawater_in_clear_glass_bottle']) - ..phPaperImage = fileFromPath(json['man_examine_preservative_ph_paper']) - ..optionalImage1 = fileFromPath(json['man_optional_photo_01']) - ..optionalImage2 = fileFromPath(json['man_optional_photo_02']) - ..optionalImage3 = fileFromPath(json['man_optional_photo_03']) - ..optionalImage4 = fileFromPath(json['man_optional_photo_04']); + final data = InSituSamplingData(); + + // START FIX: Aligned all keys to perfectly match the toDbJson() method and added backward compatibility. + data.firstSamplerName = json['first_sampler_name']; + data.firstSamplerUserId = intFromJson(json['first_sampler_user_id']); + data.secondSampler = json['secondSampler'] ?? json['second_sampler']; + data.samplingDate = json['sampling_date'] ?? json['man_date']; + data.samplingTime = json['sampling_time'] ?? json['man_time']; + data.samplingType = json['sampling_type']; + data.sampleIdCode = json['sample_id_code']; + data.selectedStateName = json['selected_state_name']; + data.selectedCategoryName = json['selected_category_name']; + data.selectedStation = json['selectedStation']; + data.stationLatitude = json['station_latitude']; + data.stationLongitude = json['station_longitude']; + data.currentLatitude = json['current_latitude']?.toString(); + data.currentLongitude = json['current_longitude']?.toString(); + data.distanceDifferenceInKm = doubleFromJson(json['distance_difference_in_km']); + data.distanceDifferenceRemarks = json['distance_difference_remarks']; + data.weather = json['weather']; + data.tideLevel = json['tide_level']; + data.seaCondition = json['sea_condition']; + data.eventRemarks = json['event_remarks']; + data.labRemarks = json['lab_remarks']; + data.optionalRemark1 = json['man_optional_photo_01_remarks']; + data.optionalRemark2 = json['man_optional_photo_02_remarks']; + data.optionalRemark3 = json['man_optional_photo_03_remarks']; + data.optionalRemark4 = json['man_optional_photo_04_remarks']; + data.sondeId = json['sonde_id']; + data.dataCaptureDate = json['data_capture_date']; + data.dataCaptureTime = json['data_capture_time']; + data.oxygenConcentration = doubleFromJson(json['oxygen_concentration']); + data.oxygenSaturation = doubleFromJson(json['oxygen_saturation']); + data.ph = doubleFromJson(json['ph']); + data.salinity = doubleFromJson(json['salinity']); + data.electricalConductivity = doubleFromJson(json['electrical_conductivity']); + data.temperature = doubleFromJson(json['temperature']); + data.tds = doubleFromJson(json['tds']); + data.turbidity = doubleFromJson(json['turbidity']); + data.tss = doubleFromJson(json['tss']); + data.batteryVoltage = doubleFromJson(json['battery_voltage']); + data.submissionStatus = json['submission_status']; + data.submissionMessage = json['submission_message']; + data.reportId = json['report_id']?.toString(); + + // Image paths are added by LocalStorageService, not toDbJson, so they are read separately. + data.leftLandViewImage = fileFromPath(json['man_left_side_land_view']); + data.rightLandViewImage = fileFromPath(json['man_right_side_land_view']); + data.waterFillingImage = fileFromPath(json['man_filling_water_into_sample_bottle']); + data.seawaterColorImage = fileFromPath(json['man_seawater_in_clear_glass_bottle']); + data.phPaperImage = fileFromPath(json['man_examine_preservative_ph_paper']); + data.optionalImage1 = fileFromPath(json['man_optional_photo_01']); + data.optionalImage2 = fileFromPath(json['man_optional_photo_02']); + data.optionalImage3 = fileFromPath(json['man_optional_photo_03']); + data.optionalImage4 = fileFromPath(json['man_optional_photo_04']); + // END FIX + + return data; } - /// Generates a formatted Telegram alert message for successful submissions. String generateTelegramAlertMessage({required bool isDataOnly}) { final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final stationName = selectedStation?['man_station_name'] ?? 'N/A'; @@ -172,24 +181,32 @@ class InSituSamplingData { return buffer.toString(); } - /// Converts the data model into a Map for the API form data. Map toApiFormData() { final Map map = {}; - // Helper to add non-null values to the map void add(String key, dynamic value) { - if (value != null && value.toString().isNotEmpty) { - map[key] = value.toString(); + if (value != null) { + String stringValue; + if (value is double) { + if (value == -999.0) { + stringValue = '-999'; + } else { + stringValue = value.toStringAsFixed(5); + } + } else { + stringValue = value.toString(); + } + + if (stringValue.isNotEmpty) { + map[key] = stringValue; + } } } - // --- Required fields that were missing or incorrect --- add('station_id', selectedStation?['station_id']); add('man_date', samplingDate); add('man_time', samplingTime); add('first_sampler_user_id', firstSamplerUserId); - - // --- Other Step 1 Data --- add('man_second_sampler_id', secondSampler?['user_id']); add('man_type', samplingType); add('man_sample_id_code', sampleIdCode); @@ -197,8 +214,6 @@ class InSituSamplingData { add('man_current_longitude', currentLongitude); add('man_distance_difference', distanceDifferenceInKm); add('man_distance_difference_remarks', distanceDifferenceRemarks); - - // --- Step 2 Data --- add('man_weather', weather); add('man_tide_level', tideLevel); add('man_sea_condition', seaCondition); @@ -208,8 +223,6 @@ class InSituSamplingData { add('man_optional_photo_02_remarks', optionalRemark2); add('man_optional_photo_03_remarks', optionalRemark3); add('man_optional_photo_04_remarks', optionalRemark4); - - // --- Step 3 Data --- add('man_sondeID', sondeId); add('data_capture_date', dataCaptureDate); add('data_capture_time', dataCaptureTime); @@ -223,8 +236,6 @@ class InSituSamplingData { add('man_turbidity', turbidity); add('man_tss', tss); add('man_battery_volt', batteryVoltage); - - // --- Human-readable fields for server-side alerts --- add('first_sampler_name', firstSamplerName); add('man_station_code', selectedStation?['man_station_code']); add('man_station_name', selectedStation?['man_station_name']); @@ -232,7 +243,6 @@ class InSituSamplingData { return map; } - /// Converts the image properties into a Map for the multipart API request. Map toApiImageFiles() { return { 'man_left_side_land_view': leftLandViewImage, @@ -247,19 +257,20 @@ class InSituSamplingData { }; } - /// Creates a single JSON object with all submission data, mimicking 'db.json'. + /// Creates a single JSON object with all submission data for offline storage. + /// The keys here are the single source of truth for the offline data format. Map toDbJson() { return { 'first_sampler_name': firstSamplerName, 'first_sampler_user_id': firstSamplerUserId, - 'second_sampler': secondSampler, + 'secondSampler': secondSampler, 'sampling_date': samplingDate, 'sampling_time': samplingTime, 'sampling_type': samplingType, 'sample_id_code': sampleIdCode, 'selected_state_name': selectedStateName, 'selected_category_name': selectedCategoryName, - 'selected_station': selectedStation, + 'selectedStation': selectedStation, 'station_latitude': stationLatitude, 'station_longitude': stationLongitude, 'current_latitude': currentLatitude, @@ -271,6 +282,10 @@ class InSituSamplingData { 'sea_condition': seaCondition, 'event_remarks': eventRemarks, 'lab_remarks': labRemarks, + 'man_optional_photo_01_remarks': optionalRemark1, + 'man_optional_photo_02_remarks': optionalRemark2, + 'man_optional_photo_03_remarks': optionalRemark3, + 'man_optional_photo_04_remarks': optionalRemark4, 'sonde_id': sondeId, 'data_capture_date': dataCaptureDate, 'data_capture_time': dataCaptureTime, @@ -289,48 +304,4 @@ class InSituSamplingData { 'report_id': reportId, }; } - - /// Creates a JSON object for basic form info, mimicking 'basic_form.json'. - Map toBasicFormJson() { - return { - 'tech_name': firstSamplerName, - 'sampler_2ndname': secondSampler?['user_name'], - 'sample_date': samplingDate, - 'sample_time': samplingTime, - 'sampling_type': samplingType, - 'sample_state': selectedStateName, - 'station_id': selectedStation?['man_station_code'], - 'station_latitude': stationLatitude, - 'station_longitude': stationLongitude, - 'latitude': currentLatitude, - 'longitude': currentLongitude, - 'sample_id': sampleIdCode, - }; - } - - /// Creates a JSON object for sensor readings, mimicking 'reading.json'. - Map toReadingJson() { - return { - 'do_mgl': oxygenConcentration, - 'do_sat': oxygenSaturation, - 'ph': ph, - 'salinity': salinity, - 'temperature': temperature, - 'turbidity': turbidity, - 'tds': tds, - 'electric_conductivity': electricalConductivity, - 'flowrate': null, // This is not collected in marine in-situ - 'date_sampling_reading': dataCaptureDate, - 'time_sampling_reading': dataCaptureTime, - }; - } - - /// Creates a JSON object for manual info, mimicking 'manual_info.json'. - Map toManualInfoJson() { - return { - 'weather': weather, - 'remarks_event': eventRemarks, - 'remarks_lab': labRemarks, - }; - } } \ No newline at end of file diff --git a/lib/models/river_in_situ_sampling_data.dart b/lib/models/river_in_situ_sampling_data.dart index 12ee4c9..6010b00 100644 --- a/lib/models/river_in_situ_sampling_data.dart +++ b/lib/models/river_in_situ_sampling_data.dart @@ -86,7 +86,6 @@ class RiverInSituSamplingData { return (path is String && path.isNotEmpty) ? File(path) : null; } - // FIX: Robust helper functions for parsing numerical values double? doubleFromJson(dynamic value) { if (value is num) return value.toDouble(); if (value is String) return double.tryParse(value); @@ -99,62 +98,63 @@ class RiverInSituSamplingData { return null; } + // --- START: MODIFIED FOR CONSISTENT SERIALIZATION --- + // Keys now match toMap() for reliability, with fallback to old API keys for backward compatibility. return RiverInSituSamplingData() - ..firstSamplerName = json['first_sampler_name'] - // MODIFIED THIS LINE TO USE THE HELPER FUNCTION - ..firstSamplerUserId = intFromJson(json['first_sampler_user_id']) + ..firstSamplerName = json['firstSamplerName'] ?? json['first_sampler_name'] + ..firstSamplerUserId = intFromJson(json['firstSamplerUserId'] ?? json['first_sampler_user_id']) ..secondSampler = json['secondSampler'] - ..samplingDate = json['r_man_date'] - ..samplingTime = json['r_man_time'] - ..samplingType = json['r_man_type'] - ..sampleIdCode = json['r_man_sample_id_code'] + ..samplingDate = json['samplingDate'] ?? json['r_man_date'] + ..samplingTime = json['samplingTime'] ?? json['r_man_time'] + ..samplingType = json['samplingType'] ?? json['r_man_type'] + ..sampleIdCode = json['sampleIdCode'] ?? json['r_man_sample_id_code'] ..selectedStateName = json['selectedStateName'] ..selectedCategoryName = json['selectedCategoryName'] ..selectedStation = json['selectedStation'] ..stationLatitude = json['stationLatitude'] ..stationLongitude = json['stationLongitude'] - ..currentLatitude = json['r_man_current_latitude']?.toString() - ..currentLongitude = json['r_man_current_longitude']?.toString() - ..distanceDifferenceInKm = doubleFromJson(json['r_man_distance_difference']) - ..distanceDifferenceRemarks = json['r_man_distance_difference_remarks'] - ..weather = json['r_man_weather'] - ..eventRemarks = json['r_man_event_remark'] - ..labRemarks = json['r_man_lab_remark'] - ..sondeId = json['r_man_sondeID'] - ..dataCaptureDate = json['data_capture_date'] - ..dataCaptureTime = json['data_capture_time'] - // FIX: Apply doubleFromJson helper to all numerical fields - ..oxygenConcentration = doubleFromJson(json['r_man_oxygen_conc']) - ..oxygenSaturation = doubleFromJson(json['r_man_oxygen_sat']) - ..ph = doubleFromJson(json['r_man_ph']) - ..salinity = doubleFromJson(json['r_man_salinity']) - ..electricalConductivity = doubleFromJson(json['r_man_conductivity']) - ..temperature = doubleFromJson(json['r_man_temperature']) - ..tds = doubleFromJson(json['r_man_tds']) - ..turbidity = doubleFromJson(json['r_man_turbidity']) - ..ammonia = doubleFromJson(json['r_man_ammonia']) // MODIFIED: Replaced tss with ammonia - ..batteryVoltage = doubleFromJson(json['r_man_battery_volt']) - // END FIX - ..optionalRemark1 = json['r_man_optional_photo_01_remarks'] - ..optionalRemark2 = json['r_man_optional_photo_02_remarks'] - ..optionalRemark3 = json['r_man_optional_photo_03_remarks'] - ..optionalRemark4 = json['r_man_optional_photo_04_remarks'] - ..backgroundStationImage = fileFromJson(json['r_man_background_station']) - ..upstreamRiverImage = fileFromJson(json['r_man_upstream_river']) - ..downstreamRiverImage = fileFromJson(json['r_man_downstream_river']) - ..sampleTurbidityImage = fileFromJson(json['r_man_sample_turbidity']) - ..optionalImage1 = fileFromJson(json['r_man_optional_photo_01']) - ..optionalImage2 = fileFromJson(json['r_man_optional_photo_02']) - ..optionalImage3 = fileFromJson(json['r_man_optional_photo_03']) - ..optionalImage4 = fileFromJson(json['r_man_optional_photo_04']) - // ADDED: Flowrate fields from JSON - ..flowrateMethod = json['r_man_flowrate_method'] - // FIX: Apply doubleFromJson helper to all new numerical flowrate fields - ..flowrateSurfaceDrifterHeight = doubleFromJson(json['r_man_flowrate_sd_height']) - ..flowrateSurfaceDrifterDistance = doubleFromJson(json['r_man_flowrate_sd_distance']) - ..flowrateSurfaceDrifterTimeFirst = json['r_man_flowrate_sd_time_first'] - ..flowrateSurfaceDrifterTimeLast = json['r_man_flowrate_sd_time_last'] - ..flowrateValue = doubleFromJson(json['r_man_flowrate_value']); + ..currentLatitude = (json['currentLatitude'] ?? json['r_man_current_latitude'])?.toString() + ..currentLongitude = (json['currentLongitude'] ?? json['r_man_current_longitude'])?.toString() + ..distanceDifferenceInKm = doubleFromJson(json['distanceDifferenceInKm'] ?? json['r_man_distance_difference']) + ..distanceDifferenceRemarks = json['distanceDifferenceRemarks'] ?? json['r_man_distance_difference_remarks'] + ..weather = json['weather'] ?? json['r_man_weather'] + ..eventRemarks = json['eventRemarks'] ?? json['r_man_event_remark'] + ..labRemarks = json['labRemarks'] ?? json['r_man_lab_remark'] + ..sondeId = json['sondeId'] ?? json['r_man_sondeID'] + ..dataCaptureDate = json['dataCaptureDate'] ?? json['data_capture_date'] + ..dataCaptureTime = json['dataCaptureTime'] ?? json['data_capture_time'] + ..oxygenConcentration = doubleFromJson(json['oxygenConcentration'] ?? json['r_man_oxygen_conc']) + ..oxygenSaturation = doubleFromJson(json['oxygenSaturation'] ?? json['r_man_oxygen_sat']) + ..ph = doubleFromJson(json['ph'] ?? json['r_man_ph']) + ..salinity = doubleFromJson(json['salinity'] ?? json['r_man_salinity']) + ..electricalConductivity = doubleFromJson(json['electricalConductivity'] ?? json['r_man_conductivity']) + ..temperature = doubleFromJson(json['temperature'] ?? json['r_man_temperature']) + ..tds = doubleFromJson(json['tds'] ?? json['r_man_tds']) + ..turbidity = doubleFromJson(json['turbidity'] ?? json['r_man_turbidity']) + ..ammonia = doubleFromJson(json['ammonia'] ?? json['r_man_ammonia']) + ..batteryVoltage = doubleFromJson(json['batteryVoltage'] ?? json['r_man_battery_volt']) + ..optionalRemark1 = json['optionalRemark1'] ?? json['r_man_optional_photo_01_remarks'] + ..optionalRemark2 = json['optionalRemark2'] ?? json['r_man_optional_photo_02_remarks'] + ..optionalRemark3 = json['optionalRemark3'] ?? json['r_man_optional_photo_03_remarks'] + ..optionalRemark4 = json['optionalRemark4'] ?? json['r_man_optional_photo_04_remarks'] + ..backgroundStationImage = fileFromJson(json['backgroundStationImage'] ?? json['r_man_background_station']) + ..upstreamRiverImage = fileFromJson(json['upstreamRiverImage'] ?? json['r_man_upstream_river']) + ..downstreamRiverImage = fileFromJson(json['downstreamRiverImage'] ?? json['r_man_downstream_river']) + ..sampleTurbidityImage = fileFromJson(json['sampleTurbidityImage'] ?? json['r_man_sample_turbidity']) + ..optionalImage1 = fileFromJson(json['optionalImage1'] ?? json['r_man_optional_photo_01']) + ..optionalImage2 = fileFromJson(json['optionalImage2'] ?? json['r_man_optional_photo_02']) + ..optionalImage3 = fileFromJson(json['optionalImage3'] ?? json['r_man_optional_photo_03']) + ..optionalImage4 = fileFromJson(json['optionalImage4'] ?? json['r_man_optional_photo_04']) + ..flowrateMethod = json['flowrateMethod'] ?? json['r_man_flowrate_method'] + ..flowrateSurfaceDrifterHeight = doubleFromJson(json['flowrateSurfaceDrifterHeight'] ?? json['r_man_flowrate_sd_height']) + ..flowrateSurfaceDrifterDistance = doubleFromJson(json['flowrateSurfaceDrifterDistance'] ?? json['r_man_flowrate_sd_distance']) + ..flowrateSurfaceDrifterTimeFirst = json['flowrateSurfaceDrifterTimeFirst'] ?? json['r_man_flowrate_sd_time_first'] + ..flowrateSurfaceDrifterTimeLast = json['flowrateSurfaceDrifterTimeLast'] ?? json['r_man_flowrate_sd_time_last'] + ..flowrateValue = doubleFromJson(json['flowrateValue'] ?? json['r_man_flowrate_value']) + ..submissionStatus = json['submissionStatus'] + ..submissionMessage = json['submissionMessage'] + ..reportId = json['reportId']?.toString(); + // --- END: MODIFIED FOR CONSISTENT SERIALIZATION --- } diff --git a/lib/screens/login.dart b/lib/screens/login.dart index fad1776..c919485 100644 --- a/lib/screens/login.dart +++ b/lib/screens/login.dart @@ -1,6 +1,10 @@ +// lib/screens/login.dart + +import 'dart:async'; // Import for TimeoutException + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; // Keep for potential future use, though not strictly necessary for the new logic import 'package:environment_monitoring_app/services/api_service.dart'; import 'package:environment_monitoring_app/auth_provider.dart'; @@ -17,7 +21,6 @@ class _LoginScreenState extends State { final _formKey = GlobalKey(); final TextEditingController _emailController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); - // FIX: Removed direct instantiation of ApiService bool _isLoading = false; String _errorMessage = ''; @@ -39,60 +42,75 @@ class _LoginScreenState extends State { }); final auth = Provider.of(context, listen: false); - // FIX: Retrieve ApiService from the Provider tree final apiService = Provider.of(context, listen: false); + final String email = _emailController.text.trim(); + final String password = _passwordController.text.trim(); + // --- START: MODIFIED Internet-First Strategy with Improved Fallback --- + try { + // --- Attempt 1: Online Login --- + debugPrint("Login attempt: Trying online authentication..."); + final Map result = await apiService + .login(email, password) + .timeout(const Duration(seconds: 10)); + + if (result['success'] == true) { + // --- Online Success --- + final String token = result['data']['token']; + final Map profile = result['data']['profile']; + await auth.login(token, profile, password); + + if (auth.isFirstLogin) { + await auth.setIsFirstLogin(false); + } - // --- Offline Check for First Login --- - if (auth.isFirstLogin) { - final connectivityResult = await Connectivity().checkConnectivity(); - if (connectivityResult == ConnectivityResult.none) { if (!mounted) return; + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const HomePage()), + ); + } else { + // --- Online Failure (API Error) --- setState(() { - _isLoading = false; - _errorMessage = 'An internet connection is required for the first login to sync initial data.'; + _errorMessage = result['message'] ?? 'Invalid email or password.'; }); _showSnackBar(_errorMessage, isError: true); - return; + } + } on TimeoutException catch (_) { + // --- Online Failure (Timeout) --- + debugPrint("Login attempt: Online request timed out after 10 seconds. Triggering offline fallback."); + _showSnackBar("Slow connection detected. Trying offline login...", isError: true); + await _attemptOfflineLogin(auth, email, password); + } catch (e) { + // --- Online Failure (Other Network Error, e.g., SocketException) --- + debugPrint("Login attempt: Network error ($e). Triggering offline fallback immediately."); + _showSnackBar("Connection failed. Trying offline login...", isError: true); + // FIX: Removed the unreliable connectivity check. Treat all exceptions here as a reason to try offline. + await _attemptOfflineLogin(auth, email, password); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); } } + // --- END: MODIFIED Internet-First Strategy --- + } - // --- API Call --- - final Map result = await apiService.login( // FIX: Use retrieved instance - _emailController.text.trim(), - _passwordController.text.trim(), - ); + /// Helper function to perform offline validation and update UI. + Future _attemptOfflineLogin(AuthProvider auth, String email, String password) async { + final bool offlineSuccess = await auth.loginOffline(email, password); - if (!mounted) return; - - if (result['success'] == true) { - // --- Update AuthProvider --- - final String token = result['data']['token']; - // CORRECTED: The API now returns a 'profile' object on login, not 'user'. - final Map profile = result['data']['profile']; - - // The login method in AuthProvider now handles setting the token, profile, - // and triggering the full data sync. - await auth.login(token, profile); - - if (auth.isFirstLogin) { - await auth.setIsFirstLogin(false); + if (mounted) { + if (offlineSuccess) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const HomePage()), + ); + } else { + setState(() { + _errorMessage = "Offline login failed. Check credentials or connect to internet to sync."; + }); + _showSnackBar(_errorMessage, isError: true); } - - if (!mounted) return; - - // Navigate to the home screen - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (context) => const HomePage()), - ); - - } else { - // Login failed, show error message - setState(() { - _isLoading = false; - _errorMessage = result['message'] ?? 'An unknown error occurred.'; - }); - _showSnackBar(_errorMessage, isError: true); } } diff --git a/lib/screens/marine/manual/data_status_log.dart b/lib/screens/marine/manual/data_status_log.dart index 28b1156..8fa4afc 100644 --- a/lib/screens/marine/manual/data_status_log.dart +++ b/lib/screens/marine/manual/data_status_log.dart @@ -107,14 +107,22 @@ class _MarineManualDataStatusLogState extends State { final List tempTarball = []; for (var log in inSituLogs) { - final String dateStr = log['samplingDate'] ?? ''; - final String timeStr = log['samplingTime'] ?? ''; + // START FIX: Use backward-compatible keys to read the timestamp + final String dateStr = log['sampling_date'] ?? log['man_date'] ?? ''; + final String timeStr = log['sampling_time'] ?? log['man_time'] ?? ''; + // END FIX + + // --- START FIX: Prevent fallback to DateTime.now() to make errors visible --- + final dt = DateTime.tryParse('$dateStr $timeStr'); + // --- END FIX --- tempManual.add(SubmissionLogEntry( type: 'Manual Sampling', title: log['selectedStation']?['man_station_name'] ?? 'Unknown Station', stationCode: log['selectedStation']?['man_station_code'] ?? 'N/A', - submissionDateTime: DateTime.tryParse('$dateStr $timeStr') ?? DateTime.now(), + // --- START FIX: Use the parsed date or a placeholder for invalid entries --- + submissionDateTime: dt ?? DateTime.fromMillisecondsSinceEpoch(0), + // --- END FIX --- reportId: log['reportId']?.toString(), status: log['submissionStatus'] ?? 'L1', message: log['submissionMessage'] ?? 'No status message.', @@ -126,11 +134,14 @@ class _MarineManualDataStatusLogState extends State { } for (var log in tarballLogs) { + final dateStr = log['sampling_date'] ?? ''; + final timeStr = log['sampling_time'] ?? ''; + tempTarball.add(SubmissionLogEntry( type: 'Tarball Sampling', title: log['selectedStation']?['tbl_station_name'] ?? 'Unknown Station', stationCode: log['selectedStation']?['tbl_station_code'] ?? 'N/A', - submissionDateTime: DateTime.tryParse('${log['sampling_date']} ${log['sampling_time']}') ?? DateTime.now(), + submissionDateTime: DateTime.tryParse('$dateStr $timeStr') ?? DateTime.fromMillisecondsSinceEpoch(0), reportId: log['reportId']?.toString(), status: log['submissionStatus'] ?? 'L1', message: log['submissionMessage'] ?? 'No status message.', @@ -172,6 +183,13 @@ class _MarineManualDataStatusLogState extends State { (log.reportId?.toLowerCase() ?? '').contains(query); } + File? _createFileFromPath(String? path) { + if (path != null && path.isNotEmpty) { + return File(path); + } + return null; + } + Future _resubmitData(SubmissionLogEntry log) async { final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); if (mounted) { @@ -187,66 +205,55 @@ class _MarineManualDataStatusLogState extends State { Map result = {}; if (log.type == 'Manual Sampling') { - // START CHANGE: Reconstruct data object and call the new service + // --- START FIX: Rely on the now-robust fromJson factory for cleaner reconstruction --- final dataToResubmit = InSituSamplingData.fromJson(log.rawData); - // Re-attach File objects from paths - dataToResubmit.leftLandViewImage = File(log.rawData['man_left_side_land_view'] ?? ''); - dataToResubmit.rightLandViewImage = File(log.rawData['man_right_side_land_view'] ?? ''); - dataToResubmit.waterFillingImage = File(log.rawData['man_filling_water_into_sample_bottle'] ?? ''); - dataToResubmit.seawaterColorImage = File(log.rawData['man_seawater_in_clear_glass_bottle'] ?? ''); - dataToResubmit.phPaperImage = File(log.rawData['man_examine_preservative_ph_paper'] ?? ''); - dataToResubmit.optionalImage1 = File(log.rawData['man_optional_photo_01'] ?? ''); - dataToResubmit.optionalImage2 = File(log.rawData['man_optional_photo_02'] ?? ''); - dataToResubmit.optionalImage3 = File(log.rawData['man_optional_photo_03'] ?? ''); - dataToResubmit.optionalImage4 = File(log.rawData['man_optional_photo_04'] ?? ''); + // --- END FIX --- result = await _marineInSituService.submitInSituSample( data: dataToResubmit, appSettings: appSettings, + context: context, + authProvider: authProvider, + logDirectory: log.rawData['logDirectory'] as String?, ); - // END CHANGE } else if (log.type == 'Tarball Sampling') { - // START CHANGE: Reconstruct data object and call the new service - final dataToResubmit = TarballSamplingData(); // Create a fresh instance + final dataToResubmit = TarballSamplingData(); final logData = log.rawData; - // Manually map fields from the raw log data to the new object - dataToResubmit.firstSampler = logData['firstSampler']; - dataToResubmit.firstSamplerUserId = logData['firstSamplerUserId']; + dataToResubmit.firstSampler = logData['first_sampler_name']; + dataToResubmit.firstSamplerUserId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? ''); dataToResubmit.secondSampler = logData['secondSampler']; - dataToResubmit.samplingDate = logData['samplingDate']; - dataToResubmit.samplingTime = logData['samplingTime']; - dataToResubmit.selectedStateName = logData['selectedStateName']; - dataToResubmit.selectedCategoryName = logData['selectedCategoryName']; + dataToResubmit.samplingDate = logData['sampling_date']; + dataToResubmit.samplingTime = logData['sampling_time']; + dataToResubmit.selectedStateName = logData['selectedStation']?['state_name']; + dataToResubmit.selectedCategoryName = logData['selectedStation']?['category_name']; dataToResubmit.selectedStation = logData['selectedStation']; - dataToResubmit.stationLatitude = logData['stationLatitude']; - dataToResubmit.stationLongitude = logData['stationLongitude']; - dataToResubmit.currentLatitude = logData['currentLatitude']; - dataToResubmit.currentLongitude = logData['currentLongitude']; - dataToResubmit.distanceDifference = logData['distanceDifference']; - dataToResubmit.distanceDifferenceRemarks = logData['distanceDifferenceRemarks']; - dataToResubmit.classificationId = logData['classificationId']; + dataToResubmit.stationLatitude = logData['selectedStation']?['tbl_latitude']?.toString(); + dataToResubmit.stationLongitude = logData['selectedStation']?['tbl_longitude']?.toString(); + dataToResubmit.currentLatitude = logData['current_latitude']; + dataToResubmit.currentLongitude = logData['current_longitude']; + dataToResubmit.distanceDifference = double.tryParse(logData['distance_difference']?.toString() ?? ''); + dataToResubmit.distanceDifferenceRemarks = logData['distance_remarks']; + dataToResubmit.classificationId = int.tryParse(logData['classification_id']?.toString() ?? ''); dataToResubmit.selectedClassification = logData['selectedClassification']; - dataToResubmit.optionalRemark1 = logData['optionalRemark1']; - dataToResubmit.optionalRemark2 = logData['optionalRemark2']; - dataToResubmit.optionalRemark3 = logData['optionalRemark3']; - dataToResubmit.optionalRemark4 = logData['optionalRemark4']; - - // Re-attach File objects from paths - dataToResubmit.leftCoastalViewImage = File(logData['left_side_coastal_view'] ?? ''); - dataToResubmit.rightCoastalViewImage = File(logData['right_side_coastal_view'] ?? ''); - dataToResubmit.verticalLinesImage = File(logData['drawing_vertical_lines'] ?? ''); - dataToResubmit.horizontalLineImage = File(logData['drawing_horizontal_line'] ?? ''); - dataToResubmit.optionalImage1 = File(logData['optional_photo_01'] ?? ''); - dataToResubmit.optionalImage2 = File(logData['optional_photo_02'] ?? ''); - dataToResubmit.optionalImage3 = File(logData['optional_photo_03'] ?? ''); - dataToResubmit.optionalImage4 = File(logData['optional_photo_04'] ?? ''); + dataToResubmit.optionalRemark1 = logData['optional_photo_remark_01']; + dataToResubmit.optionalRemark2 = logData['optional_photo_remark_02']; + dataToResubmit.optionalRemark3 = logData['optional_photo_remark_03']; + dataToResubmit.optionalRemark4 = logData['optional_photo_remark_04']; + dataToResubmit.leftCoastalViewImage = _createFileFromPath(logData['left_side_coastal_view']); + dataToResubmit.rightCoastalViewImage = _createFileFromPath(logData['right_side_coastal_view']); + dataToResubmit.verticalLinesImage = _createFileFromPath(logData['drawing_vertical_lines']); + dataToResubmit.horizontalLineImage = _createFileFromPath(logData['drawing_horizontal_line']); + dataToResubmit.optionalImage1 = _createFileFromPath(logData['optional_photo_01']); + dataToResubmit.optionalImage2 = _createFileFromPath(logData['optional_photo_02']); + dataToResubmit.optionalImage3 = _createFileFromPath(logData['optional_photo_03']); + dataToResubmit.optionalImage4 = _createFileFromPath(logData['optional_photo_04']); result = await _marineTarballService.submitTarballSample( data: dataToResubmit, appSettings: appSettings, + context: context, ); - // END CHANGE } if (mounted) { @@ -341,10 +348,31 @@ class _MarineManualDataStatusLogState extends State { } Widget _buildLogListItem(SubmissionLogEntry log) { - final isFailed = !log.status.startsWith('S') && !log.status.startsWith('L4'); final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); final isResubmitting = _isResubmitting[logKey] ?? false; + // --- START: MODIFICATION FOR GRANULAR STATUS ICONS --- + // Define the different states based on the detailed status code. + final bool isFullSuccess = log.status == 'S4'; + final bool isPartialSuccess = log.status == 'S3' || log.status == 'L4'; + final bool canResubmit = !isFullSuccess; // Allow resubmission for partial success or failure. + + // Determine the icon and color based on the state. + IconData statusIcon; + Color statusColor; + + if (isFullSuccess) { + statusIcon = Icons.check_circle_outline; + statusColor = Colors.green; + } else if (isPartialSuccess) { + statusIcon = Icons.warning_amber_rounded; + statusColor = Colors.orange; + } else { + statusIcon = Icons.error_outline; + statusColor = Colors.red; + } + // --- END: MODIFICATION FOR GRANULAR STATUS ICONS --- + final titleWidget = RichText( text: TextSpan( style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), @@ -357,17 +385,18 @@ class _MarineManualDataStatusLogState extends State { ], ), ); - final subtitle = '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}'; + + final bool isDateValid = !log.submissionDateTime.isAtSameMomentAs(DateTime.fromMillisecondsSinceEpoch(0)); + final subtitle = isDateValid + ? '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}' + : '${log.serverName} - Invalid Date'; return ExpansionTile( key: PageStorageKey(logKey), - leading: Icon( - isFailed ? Icons.error_outline : Icons.check_circle_outline, - color: isFailed ? Colors.red : Colors.green, - ), + leading: Icon(statusIcon, color: statusColor), title: titleWidget, subtitle: Text(subtitle), - trailing: isFailed + trailing: canResubmit ? (isResubmitting ? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 3)) : IconButton(icon: const Icon(Icons.sync, color: Colors.blue), tooltip: 'Resubmit', onPressed: () => _resubmitData(log))) @@ -382,9 +411,6 @@ class _MarineManualDataStatusLogState extends State { _buildDetailRow('Server:', log.serverName), _buildDetailRow('Report ID:', log.reportId ?? 'N/A'), _buildDetailRow('Submission Type:', log.type), - const Divider(height: 10), - _buildGranularStatus('API', log.apiStatusRaw), - _buildGranularStatus('FTP', log.ftpStatusRaw), ], ), ) @@ -392,52 +418,6 @@ class _MarineManualDataStatusLogState extends State { ); } - Widget _buildGranularStatus(String type, String? jsonStatus) { - if (jsonStatus == null || jsonStatus.isEmpty) { - return Container(); - } - - List statuses; - try { - statuses = jsonDecode(jsonStatus); - } catch (_) { - return _buildDetailRow('$type Status:', jsonStatus); - } - - if (statuses.isEmpty) { - return Container(); - } - - return Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('$type Status:', style: const TextStyle(fontWeight: FontWeight.bold)), - ...statuses.map((s) { - final serverName = s['server_name'] ?? s['config_name'] ?? 'Server N/A'; - final status = s['status'] ?? 'N/A'; - final bool isSuccess = status.toLowerCase().contains('success') || status.toLowerCase().contains('queued') || status.toLowerCase().contains('not_configured') || status.toLowerCase().contains('not_applicable') || status.toLowerCase().contains('not_required'); - final IconData icon = isSuccess ? Icons.check_circle_outline : (status.toLowerCase().contains('failed') ? Icons.error_outline : Icons.sync); - final Color color = isSuccess ? Colors.green : (status.toLowerCase().contains('failed') ? Colors.red : Colors.grey); - String detailLabel = (s['type'] != null) ? '(${s['type']})' : ''; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 8.0), - child: Row( - children: [ - Icon(icon, size: 16, color: color), - const SizedBox(width: 5), - Expanded(child: Text('$serverName $detailLabel: $status')), - ], - ), - ); - }).toList(), - ], - ), - ); - } - Widget _buildDetailRow(String label, String value) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), diff --git a/lib/screens/marine/manual/in_situ_sampling.dart b/lib/screens/marine/manual/in_situ_sampling.dart index be683a8..06d9dfe 100644 --- a/lib/screens/marine/manual/in_situ_sampling.dart +++ b/lib/screens/marine/manual/in_situ_sampling.dart @@ -6,17 +6,12 @@ import 'package:provider/provider.dart'; import 'package:environment_monitoring_app/auth_provider.dart'; import '../../../models/in_situ_sampling_data.dart'; -// START CHANGE: Import the new, consolidated MarineInSituSamplingService import '../../../services/marine_in_situ_sampling_service.dart'; -// END CHANGE import 'widgets/in_situ_step_1_sampling_info.dart'; import 'widgets/in_situ_step_2_site_info.dart'; import 'widgets/in_situ_step_3_data_capture.dart'; import 'widgets/in_situ_step_4_summary.dart'; -/// The main screen for the In-Situ Sampling feature. -/// This stateful widget orchestrates the multi-step process using a PageView. -/// It manages the overall data model and the service layer for the entire workflow. class MarineInSituSampling extends StatefulWidget { const MarineInSituSampling({super.key}); @@ -26,11 +21,7 @@ class MarineInSituSampling extends StatefulWidget { class _MarineInSituSamplingState extends State { final PageController _pageController = PageController(); - late InSituSamplingData _data; - - // MODIFIED: Declare the service variable but do not instantiate it here. - // It will be initialized from the Provider. late MarineInSituSamplingService _samplingService; int _currentPage = 0; @@ -45,28 +36,24 @@ class _MarineInSituSamplingState extends State { ); } - // ADDED: didChangeDependencies to safely get the service from the Provider. - // This is the correct lifecycle method to access inherited widgets like Provider. @override void didChangeDependencies() { super.didChangeDependencies(); - // Fetch the single, global instance of the service from the Provider tree. _samplingService = Provider.of(context); } @override void dispose() { _pageController.dispose(); - // START FIX - // REMOVED: _samplingService.dispose(); - // The service is managed by a higher-level Provider and should not be disposed of - // here, as other widgets might still be listening to it. This prevents the - // "ValueNotifier was used after being disposed" error. + // START FIX: Do not dispose the service here. + // The service is managed by the Provider at a higher level in the app. Disposing it + // here can cause the "deactivated widget's ancestor" error if other widgets + // are still listening to it during screen transitions. + // _samplingService.dispose(); // END FIX super.dispose(); } - /// Navigates to the next page in the form. void _nextPage() { if (_currentPage < 3) { _pageController.nextPage( @@ -76,7 +63,6 @@ class _MarineInSituSamplingState extends State { } } - /// Navigates to the previous page in the form. void _previousPage() { if (_currentPage > 0) { _pageController.previousPage( @@ -86,25 +72,23 @@ class _MarineInSituSamplingState extends State { } } - // START CHANGE: The _submitForm method is now greatly simplified. Future _submitForm() async { setState(() => _isLoading = true); final authProvider = Provider.of(context, listen: false); final appSettings = authProvider.appSettings; - // Delegate the entire submission process to the new dedicated service. - // The service handles API calls, zipping, FTP queuing, logging, and alerts. final result = await _samplingService.submitInSituSample( data: _data, appSettings: appSettings, + context: context, + authProvider: authProvider, ); if (!mounted) return; setState(() => _isLoading = false); - // Display the final result to the user final message = result['message'] ?? 'An unknown error occurred.'; final color = (result['success'] == true) ? Colors.green : Colors.red; ScaffoldMessenger.of(context).showSnackBar( @@ -113,17 +97,12 @@ class _MarineInSituSamplingState extends State { Navigator.of(context).popUntil((route) => route.isFirst); } - // END CHANGE @override Widget build(BuildContext context) { - // START CHANGE: Provide the new MarineInSituSamplingService to all child widgets. - // The child widgets (Step 1, 2, 3) can now access all its methods (for location, - // image picking, and device connection) via Provider. return Provider.value( value: _samplingService, - // END CHANGE child: Scaffold( appBar: AppBar( title: Text('In-Situ Sampling (${_currentPage + 1}/4)'), @@ -143,7 +122,6 @@ class _MarineInSituSamplingState extends State { }); }, children: [ - // Each step is a separate widget, receiving the data model and navigation callbacks. InSituStep1SamplingInfo(data: _data, onNext: _nextPage), InSituStep2SiteInfo(data: _data, onNext: _nextPage), InSituStep3DataCapture(data: _data, onNext: _nextPage), diff --git a/lib/screens/marine/manual/tarball_sampling.dart b/lib/screens/marine/manual/tarball_sampling.dart index 06fbacd..a3f8b53 100644 --- a/lib/screens/marine/manual/tarball_sampling.dart +++ b/lib/screens/marine/manual/tarball_sampling.dart @@ -100,12 +100,13 @@ class _MarineTarballSamplingState extends State { // ADDED: Fetch the global service instance from Provider. final tarballService = Provider.of(context, listen: false); - // START CHANGE: Call the method on the new dedicated service + // START FIX: Pass the required `context` argument to the function call. final result = await tarballService.submitTarballSample( data: _data, appSettings: appSettings, + context: context, ); - // END CHANGE + // END FIX if (!mounted) return; setState(() => _isLoading = false); diff --git a/lib/screens/marine/manual/tarball_sampling_step2.dart b/lib/screens/marine/manual/tarball_sampling_step2.dart index 2d73b3a..4e9de08 100644 --- a/lib/screens/marine/manual/tarball_sampling_step2.dart +++ b/lib/screens/marine/manual/tarball_sampling_step2.dart @@ -1,3 +1,5 @@ +// lib/screens/marine/manual/tarball_sampling_step2.dart + import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -152,7 +154,13 @@ class _TarballSamplingStep2State extends State { final tempDir = await getTemporaryDirectory(); final filePath = path.join(tempDir.path, newFileName); - final processedFile = await File(filePath).writeAsBytes(img.encodeJpg(originalImage)); + + // --- START: MODIFICATION TO FIX RACE CONDITION --- + // Changed from asynchronous `writeAsBytes` to synchronous `writeAsBytesSync`. + // This guarantees the file is fully written to disk before the function returns, + // preventing a 0-byte file from being copied during a fast offline save. + final File processedFile = File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); + // --- END: MODIFICATION TO FIX RACE CONDITION --- setState(() => _isPickingImage = false); return processedFile; diff --git a/lib/screens/marine/manual/tarball_sampling_step3_summary.dart b/lib/screens/marine/manual/tarball_sampling_step3_summary.dart index 7068934..7a0daa4 100644 --- a/lib/screens/marine/manual/tarball_sampling_step3_summary.dart +++ b/lib/screens/marine/manual/tarball_sampling_step3_summary.dart @@ -40,6 +40,7 @@ class _TarballSamplingStep3SummaryState extends State with Wi bool _isAutoReading = false; StreamSubscription? _dataSubscription; + // --- START FIX: Declare service variable --- + late final MarineInSituSamplingService _samplingService; + // --- END FIX --- + Map? _previousReadingsForComparison; + Set _outOfBoundsKeys = {}; /// Maps the app's internal parameter keys to the names used in the /// 'param_parameter_list' column from the server. @@ -70,6 +75,9 @@ class _InSituStep3DataCaptureState extends State with Wi @override void initState() { super.initState(); + // --- START FIX: Initialize service variable safely --- + _samplingService = Provider.of(context, listen: false); + // --- END FIX --- _initializeControllers(); WidgetsBinding.instance.addObserver(this); } @@ -77,6 +85,16 @@ class _InSituStep3DataCaptureState extends State with Wi @override void dispose() { _dataSubscription?.cancel(); + + // --- START FIX: Use the pre-fetched service instance --- + if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { + _samplingService.disconnectFromBluetooth(); + } + if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) { + _samplingService.disconnectFromSerial(); + } + // --- END FIX --- + _disposeControllers(); WidgetsBinding.instance.removeObserver(this); super.dispose(); @@ -201,13 +219,38 @@ class _InSituStep3DataCaptureState extends State with Wi } } } catch (e) { - if (mounted) _showSnackBar('Connection failed: $e', isError: true); + debugPrint("Connection failed: $e"); + if (mounted) _showConnectionFailedDialog(); } finally { if (mounted) setState(() => _isLoading = false); } return success; } + Future _showConnectionFailedDialog() async { + if (!mounted) return; + return showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Connection Failed'), + content: const SingleChildScrollView( + child: Text('Could not connect to the device. Please check that the device is turned on, within range, and not connected to another application.'), + ), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } + void _toggleAutoReading(String activeType) { final service = context.read(); setState(() { @@ -222,16 +265,26 @@ class _InSituStep3DataCaptureState extends State with Wi void _disconnect(String type) { final service = context.read(); - if (type == 'bluetooth') service.disconnectFromBluetooth(); else service.disconnectFromSerial(); + if (type == 'bluetooth') { + service.disconnectFromBluetooth(); + } else { + service.disconnectFromSerial(); + } _dataSubscription?.cancel(); _dataSubscription = null; - if (mounted) setState(() => _isAutoReading = false); + if (mounted) { + setState(() => _isAutoReading = false); + } } void _disconnectFromAll() { final service = context.read(); - if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) _disconnect('bluetooth'); - if (service.serialConnectionState.value != SerialConnectionState.disconnected) _disconnect('serial'); + if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { + _disconnect('bluetooth'); + } + if (service.serialConnectionState.value != SerialConnectionState.disconnected) { + _disconnect('serial'); + } } void _updateTextFields(Map readings) { @@ -251,38 +304,29 @@ class _InSituStep3DataCaptureState extends State with Wi } void _validateAndProceed() { - debugPrint("--- Parameter Validation Triggered ---"); - if (_isAutoReading) { _showStopReadingDialog(); return; } + if (!_formKey.currentState!.validate()) { + return; + } _formKey.currentState!.save(); final currentReadings = _captureReadingsToMap(); - debugPrint("Current Readings Captured: $currentReadings"); - final authProvider = Provider.of(context, listen: false); - final allLimits = authProvider.parameterLimits ?? []; - debugPrint("Total parameter limits loaded from AuthProvider: ${allLimits.length} rules."); - if (allLimits.isNotEmpty) { - debugPrint("Sample limit rule from AuthProvider: ${allLimits.first}"); - } - - final marineLimits = allLimits.where((limit) => limit['department_id'] == 4).toList(); - debugPrint("Found ${marineLimits.length} rules after filtering for Marine department (ID 4)."); - + final marineLimits = (authProvider.parameterLimits ?? []).where((limit) => limit['department_id'] == 4).toList(); final outOfBoundsParams = _validateParameters(currentReadings, marineLimits); - debugPrint("Validation check complete. Found ${outOfBoundsParams.length} out-of-bounds parameters."); + + setState(() { + _outOfBoundsKeys = outOfBoundsParams.map((p) => _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet(); + }); if (outOfBoundsParams.isNotEmpty) { - debugPrint("Action: Displaying parameter limit warning dialog for: $outOfBoundsParams"); _showParameterLimitDialog(outOfBoundsParams, currentReadings); } else { - debugPrint("Action: Validation passed or no applicable limits found. Proceeding to the next step."); _saveDataAndMoveOn(currentReadings); } - debugPrint("--- Parameter Validation Finished ---"); } Map _captureReadingsToMap() { @@ -352,11 +396,12 @@ class _InSituStep3DataCaptureState extends State with Wi return; } - if (_previousReadingsForComparison != null) { - setState(() { + setState(() { + _outOfBoundsKeys.clear(); + if (_previousReadingsForComparison != null) { _previousReadingsForComparison = null; - }); - } + } + }); widget.onNext(); } @@ -431,10 +476,12 @@ class _InSituStep3DataCaptureState extends State with Wi if (activeConnection != null) _buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']), const SizedBox(height: 24), + // START FIX: Restored ValueListenableBuilder to listen for Sonde ID updates. ValueListenableBuilder( valueListenable: service.sondeId, builder: (context, sondeId, child) { final newSondeId = sondeId ?? ''; + // Use a post-frame callback to safely update the controller after the build. WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _sondeIdController.text != newSondeId) { _sondeIdController.text = newSondeId; @@ -444,12 +491,13 @@ class _InSituStep3DataCaptureState extends State with Wi return TextFormField( controller: _sondeIdController, decoration: const InputDecoration(labelText: 'Sonde ID *', hintText: 'Connect device or enter manually'), - validator: (v) => v!.isEmpty ? 'Sonde ID is required' : null, + validator: (v) => v == null || v.isEmpty ? 'Sonde ID is required' : null, onChanged: (value) => widget.data.sondeId = value, onSaved: (v) => widget.data.sondeId = v, ); }, ), + // END FIX const SizedBox(height: 16), Row( children: [ @@ -470,6 +518,7 @@ class _InSituStep3DataCaptureState extends State with Wi label: param['label'] as String, unit: param['unit'] as String, controller: param['controller'] as TextEditingController, + isOutOfBounds: _outOfBoundsKeys.contains(param['key']), ); }).toList(), ), @@ -490,10 +539,10 @@ class _InSituStep3DataCaptureState extends State with Wi return Card( margin: const EdgeInsets.only(top: 24.0), - color: Theme.of(context).cardColor, // Adapts to theme's card color + color: Theme.of(context).cardColor, child: Padding( padding: const EdgeInsets.all(16.0), - child: DefaultTextStyle( // Ensure text adapts to theme + child: DefaultTextStyle( style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -529,6 +578,7 @@ class _InSituStep3DataCaptureState extends State with Wi final label = param['label'] as String; final controller = param['controller'] as TextEditingController; final previousValue = previousReadings[key]; + final bool isCurrentValueOutOfBounds = _outOfBoundsKeys.contains(key); return TableRow( children: [ @@ -536,15 +586,20 @@ class _InSituStep3DataCaptureState extends State with Wi Padding( padding: const EdgeInsets.all(8.0), child: Text( - previousValue == -999.0 ? '-.--' : previousValue!.toStringAsFixed(2), + previousValue == -999.0 ? '-.--' : previousValue!.toStringAsFixed(5), style: TextStyle(color: isDarkTheme ? Colors.orange.shade200 : Colors.orange.shade700), ), ), Padding( padding: const EdgeInsets.all(8.0), child: Text( - controller.text.contains('-999') ? '-.--' : (double.tryParse(controller.text) ?? 0).toStringAsFixed(2), - style: TextStyle(color: isDarkTheme ? Colors.green.shade200 : Colors.green.shade700, fontWeight: FontWeight.bold), + controller.text.contains('-999') ? '-.--' : (double.tryParse(controller.text) ?? 0).toStringAsFixed(5), + style: TextStyle( + color: isCurrentValueOutOfBounds + ? Colors.red + : (isDarkTheme ? Colors.green.shade200 : Colors.green.shade700), + fontWeight: FontWeight.bold + ), ), ), ], @@ -569,7 +624,7 @@ class _InSituStep3DataCaptureState extends State with Wi title: const Text('Parameter Limit Warning'), content: SingleChildScrollView( child: DefaultTextStyle( - style: TextStyle(color: Theme.of(context).textTheme.bodyMedium?.color), // Make sure text is visible + style: TextStyle(color: Theme.of(context).textTheme.bodyMedium?.color), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -600,12 +655,12 @@ class _InSituStep3DataCaptureState extends State with Wi ...invalidParams.map((p) => TableRow( children: [ Padding(padding: const EdgeInsets.all(6.0), child: Text(p['label'])), - Padding(padding: const EdgeInsets.all(6.0), child: Text('${p['lower_limit']?.toStringAsFixed(1) ?? 'N/A'} - ${p['upper_limit']?.toStringAsFixed(1) ?? 'N/A'}')), + Padding(padding: const EdgeInsets.all(6.0), child: Text('${p['lower_limit']?.toStringAsFixed(5) ?? 'N/A'} - ${p['upper_limit']?.toStringAsFixed(5) ?? 'N/A'}')), Padding( padding: const EdgeInsets.all(6.0), child: Text( - p['value'].toStringAsFixed(2), - style: const TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold), // Highlight the out-of-bounds value + p['value'].toStringAsFixed(5), + style: const TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold), ), ), ], @@ -641,10 +696,15 @@ class _InSituStep3DataCaptureState extends State with Wi ); } - Widget _buildParameterListItem({required IconData icon, required String label, required String unit, required TextEditingController controller}) { + Widget _buildParameterListItem({required IconData icon, required String label, required String unit, required TextEditingController controller, bool isOutOfBounds = false}) { final bool isMissing = controller.text.isEmpty || controller.text.contains('-999'); final String displayValue = isMissing ? '-.--' : controller.text; final String displayLabel = unit.isEmpty ? label : '$label ($unit)'; + + final Color valueColor = isOutOfBounds + ? Colors.red + : (isMissing ? Colors.grey : Theme.of(context).colorScheme.primary); + return Card( margin: const EdgeInsets.symmetric(vertical: 4.0), child: ListTile( @@ -654,7 +714,7 @@ class _InSituStep3DataCaptureState extends State with Wi displayValue, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, - color: isMissing ? Colors.grey : Theme.of(context).colorScheme.primary), + color: valueColor), ), ), ); diff --git a/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart b/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart index 671cebd..fbffc91 100644 --- a/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart +++ b/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart @@ -2,7 +2,9 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../../auth_provider.dart'; import '../../../../models/in_situ_sampling_data.dart'; class InSituStep4Summary extends StatelessWidget { @@ -17,8 +19,72 @@ class InSituStep4Summary extends StatelessWidget { required this.isLoading, }); + // --- START: MODIFICATION FOR HIGHLIGHTING --- + // Added helper logic to re-validate parameters on the summary screen. + + /// Maps the app's internal parameter keys to the names used in the database. + static const Map _parameterKeyToLimitName = const { + 'oxygenConcentration': 'Oxygen Conc', + 'oxygenSaturation': 'Oxygen Sat', + 'ph': 'pH', + 'salinity': 'Salinity', + 'electricalConductivity': 'Conductivity', + 'temperature': 'Temperature', + 'tds': 'TDS', + 'turbidity': 'Turbidity', + 'tss': 'TSS', + 'batteryVoltage': 'Battery', + }; + + /// Re-validates the final parameters against the defined limits. + Set _getOutOfBoundsKeys(BuildContext context) { + final authProvider = Provider.of(context, listen: false); + final marineLimits = (authProvider.parameterLimits ?? []).where((limit) => limit['department_id'] == 4).toList(); + final Set invalidKeys = {}; + + final readings = { + 'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation, + 'ph': data.ph, 'salinity': data.salinity, 'electricalConductivity': data.electricalConductivity, + 'temperature': data.temperature, 'tds': data.tds, 'turbidity': data.turbidity, + 'tss': data.tss, 'batteryVoltage': data.batteryVoltage, + }; + + double? parseLimitValue(dynamic value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; + } + + readings.forEach((key, value) { + if (value == null || value == -999.0) return; + + final limitName = _parameterKeyToLimitName[key]; + if (limitName == null) return; + + final limitData = marineLimits.firstWhere((l) => l['param_parameter_list'] == limitName, orElse: () => {}); + + if (limitData.isNotEmpty) { + final lowerLimit = parseLimitValue(limitData['param_lower_limit']); + final upperLimit = parseLimitValue(limitData['param_upper_limit']); + + if ((lowerLimit != null && value < lowerLimit) || (upperLimit != null && value > upperLimit)) { + invalidKeys.add(key); + } + } + }); + + return invalidKeys; + } + // --- END: MODIFICATION FOR HIGHLIGHTING --- + @override Widget build(BuildContext context) { + // --- START: MODIFICATION FOR HIGHLIGHTING --- + // Get the set of out-of-bounds keys before building the list. + final outOfBoundsKeys = _getOutOfBoundsKeys(context); + // --- END: MODIFICATION FOR HIGHLIGHTING --- + return ListView( padding: const EdgeInsets.all(16.0), children: [ @@ -91,16 +157,18 @@ class InSituStep4Summary extends StatelessWidget { _buildDetailRow("Sonde ID:", data.sondeId), _buildDetailRow("Capture Time:", "${data.dataCaptureDate} ${data.dataCaptureTime}"), const Divider(height: 20), - _buildParameterListItem(context, icon: Icons.air, label: "Oxygen Conc.", unit: "mg/L", value: data.oxygenConcentration?.toStringAsFixed(2)), - _buildParameterListItem(context, icon: Icons.percent, label: "Oxygen Sat.", unit: "%", value: data.oxygenSaturation?.toStringAsFixed(2)), - _buildParameterListItem(context, icon: Icons.science_outlined, label: "pH", unit: "", value: data.ph?.toStringAsFixed(2)), - _buildParameterListItem(context, icon: Icons.waves, label: "Salinity", unit: "ppt", value: data.salinity?.toStringAsFixed(2)), - _buildParameterListItem(context, icon: Icons.flash_on, label: "Conductivity", unit: "µS/cm", value: data.electricalConductivity?.toStringAsFixed(0)), - _buildParameterListItem(context, icon: Icons.thermostat, label: "Temperature", unit: "°C", value: data.temperature?.toStringAsFixed(2)), - _buildParameterListItem(context, icon: Icons.grain, label: "TDS", unit: "mg/L", value: data.tds?.toStringAsFixed(2)), - _buildParameterListItem(context, icon: Icons.opacity, label: "Turbidity", unit: "NTU", value: data.turbidity?.toStringAsFixed(2)), - _buildParameterListItem(context, icon: Icons.filter_alt_outlined, label: "TSS", unit: "mg/L", value: data.tss?.toStringAsFixed(2)), - _buildParameterListItem(context, icon: Icons.battery_charging_full, label: "Battery", unit: "V", value: data.batteryVoltage?.toStringAsFixed(2)), + // --- START: MODIFICATION FOR 5 DECIMALS & HIGHLIGHTING --- + _buildParameterListItem(context, icon: Icons.air, label: "Oxygen Conc.", unit: "mg/L", value: data.oxygenConcentration, isOutOfBounds: outOfBoundsKeys.contains('oxygenConcentration')), + _buildParameterListItem(context, icon: Icons.percent, label: "Oxygen Sat.", unit: "%", value: data.oxygenSaturation, isOutOfBounds: outOfBoundsKeys.contains('oxygenSaturation')), + _buildParameterListItem(context, icon: Icons.science_outlined, label: "pH", unit: "", value: data.ph, isOutOfBounds: outOfBoundsKeys.contains('ph')), + _buildParameterListItem(context, icon: Icons.waves, label: "Salinity", unit: "ppt", value: data.salinity, isOutOfBounds: outOfBoundsKeys.contains('salinity')), + _buildParameterListItem(context, icon: Icons.flash_on, label: "Conductivity", unit: "µS/cm", value: data.electricalConductivity, isOutOfBounds: outOfBoundsKeys.contains('electricalConductivity')), + _buildParameterListItem(context, icon: Icons.thermostat, label: "Temperature", unit: "°C", value: data.temperature, isOutOfBounds: outOfBoundsKeys.contains('temperature')), + _buildParameterListItem(context, icon: Icons.grain, label: "TDS", unit: "mg/L", value: data.tds, isOutOfBounds: outOfBoundsKeys.contains('tds')), + _buildParameterListItem(context, icon: Icons.opacity, label: "Turbidity", unit: "NTU", value: data.turbidity, isOutOfBounds: outOfBoundsKeys.contains('turbidity')), + _buildParameterListItem(context, icon: Icons.filter_alt_outlined, label: "TSS", unit: "mg/L", value: data.tss, isOutOfBounds: outOfBoundsKeys.contains('tss')), + _buildParameterListItem(context, icon: Icons.battery_charging_full, label: "Battery", unit: "V", value: data.batteryVoltage, isOutOfBounds: outOfBoundsKeys.contains('batteryVoltage')), + // --- END: MODIFICATION --- ], ), @@ -169,11 +237,19 @@ class InSituStep4Summary extends StatelessWidget { ); } - Widget _buildParameterListItem(BuildContext context, {required IconData icon, required String label, required String unit, required String? value}) { - // REPAIRED: Only check if the value is null. It will now display numerical - // values like -999.00 instead of converting them to 'N/A'. - final bool isMissing = value == null; - final String displayValue = isMissing ? 'N/A' : '$value ${unit}'.trim(); + // --- START: MODIFICATION FOR 5 DECIMALS & HIGHLIGHTING --- + Widget _buildParameterListItem(BuildContext context, {required IconData icon, required String label, required String unit, required double? value, bool isOutOfBounds = false}) { + final bool isMissing = value == null || value == -999.0; + // Format the value to 5 decimal places if it's a valid number. + final String displayValue = isMissing ? 'N/A' : '${value.toStringAsFixed(5)} ${unit}'.trim(); + + // START CHANGE: Use theme-aware color for normal values + // Determine the color for the value based on theme and status. + final Color? defaultTextColor = Theme.of(context).textTheme.bodyLarge?.color; + final Color valueColor = isOutOfBounds + ? Colors.red + : (isMissing ? Colors.grey : defaultTextColor ?? Colors.black); + // END CHANGE return ListTile( dense: true, @@ -183,12 +259,13 @@ class InSituStep4Summary extends StatelessWidget { trailing: Text( displayValue, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: isMissing ? Colors.grey : null, - fontWeight: isMissing ? null : FontWeight.bold, + color: valueColor, + fontWeight: isOutOfBounds ? FontWeight.bold : null, ), ), ); } + // --- END: MODIFICATION --- /// A reusable widget to display an attached image or a placeholder. Widget _buildImageCard(String title, File? image, {String? remark}) { diff --git a/lib/screens/river/manual/data_status_log.dart b/lib/screens/river/manual/data_status_log.dart index 3f4c1b3..8ca64b6 100644 --- a/lib/screens/river/manual/data_status_log.dart +++ b/lib/screens/river/manual/data_status_log.dart @@ -80,12 +80,10 @@ class _RiverManualDataStatusLogState extends State { final riverLogs = await _localStorageService.getAllRiverInSituLogs(); final List tempLogs = []; - if (riverLogs != null) { - for (var log in riverLogs) { - final entry = _createLogEntry(log); - if (entry != null) { - tempLogs.add(entry); - } + for (var log in riverLogs) { + final entry = _createLogEntry(log); + if (entry != null) { + tempLogs.add(entry); } } @@ -99,33 +97,25 @@ class _RiverManualDataStatusLogState extends State { } } + // --- START: MODIFIED TO FIX NULL SAFETY ERRORS --- SubmissionLogEntry? _createLogEntry(Map log) { - String? type; - String? title; - String? stationCode; + final String type = log['samplingType'] ?? 'In-Situ Sampling'; + final String title = log['selectedStation']?['sampling_river'] ?? 'Unknown River'; + final String stationCode = log['selectedStation']?['sampling_station_code'] ?? 'N/A'; DateTime submissionDateTime = DateTime.now(); - String? dateStr; - String? timeStr; + final String? dateStr = log['samplingDate'] ?? log['r_man_date']; + final String? timeStr = log['samplingTime'] ?? log['r_man_time']; - if (log.containsKey('samplingType')) { - type = log['samplingType'] ?? 'In-Situ Sampling'; - title = log['selectedStation']?['sampling_river'] ?? 'Unknown River'; - stationCode = log['selectedStation']?['sampling_station_code'] ?? 'N/A'; - dateStr = log['r_man_date'] ?? log['samplingDate'] ?? ''; - timeStr = log['r_man_time'] ?? log['samplingTime'] ?? ''; - } else { - return null; - } - - // FIX: Safely parse date and time by providing default values try { - final String fullDateString = '$dateStr ${timeStr!.length == 5 ? timeStr + ':00' : timeStr}'; - submissionDateTime = DateTime.tryParse(fullDateString) ?? DateTime.now(); + if (dateStr != null && timeStr != null && dateStr.isNotEmpty && timeStr.isNotEmpty) { + final String fullDateString = '$dateStr ${timeStr.length == 5 ? "$timeStr:00" : timeStr}'; + submissionDateTime = DateTime.tryParse(fullDateString) ?? DateTime.now(); + } } catch (_) { submissionDateTime = DateTime.now(); } + // --- END: MODIFIED TO FIX NULL SAFETY ERRORS --- - // FIX: Safely handle apiStatusRaw and ftpStatusRaw to prevent null access String? apiStatusRaw; if (log['api_status'] != null) { apiStatusRaw = log['api_status'] is String ? log['api_status'] : jsonEncode(log['api_status']); @@ -137,9 +127,9 @@ class _RiverManualDataStatusLogState extends State { } return SubmissionLogEntry( - type: type!, - title: title!, - stationCode: stationCode!, + type: type, + title: title, + stationCode: stationCode, submissionDateTime: submissionDateTime, reportId: log['reportId']?.toString(), status: log['submissionStatus'] ?? 'L1', @@ -178,34 +168,24 @@ class _RiverManualDataStatusLogState extends State { try { final authProvider = Provider.of(context, listen: false); final appSettings = authProvider.appSettings; - final logData = log.rawData; - final dataToResubmit = RiverInSituSamplingData.fromJson(logData); - final Map imageFiles = {}; - dataToResubmit.toApiImageFiles().keys.forEach((key) { - final imagePath = logData[key]; - if (imagePath is String && imagePath.isNotEmpty) { - imageFiles[key] = File(imagePath); - } - }); - final result = await _apiService.river.submitInSituSample( - formData: dataToResubmit.toApiFormData(), - imageFiles: imageFiles, + final dataToResubmit = RiverInSituSamplingData.fromJson(log.rawData); + + final result = await _riverInSituService.submitData( + data: dataToResubmit, appSettings: appSettings, + authProvider: authProvider, + logDirectory: log.rawData['logDirectory'], // Pass the log directory for updating ); - final updatedLogData = log.rawData; - updatedLogData['submissionStatus'] = result['status']; - updatedLogData['submissionMessage'] = result['message']; - updatedLogData['reportId'] = result['reportId']?.toString() ?? updatedLogData['reportId']; - updatedLogData['api_status'] = jsonEncode(result['api_status']); - updatedLogData['ftp_status'] = jsonEncode(result['ftp_status']); - - await _localStorageService.updateRiverInSituLog(updatedLogData); - if (mounted) { + final message = result['message'] ?? 'Resubmission process completed.'; + final isSuccess = result['success'] as bool? ?? false; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Resubmission successful!')), + SnackBar( + content: Text(message), + backgroundColor: isSuccess ? Colors.green : Colors.orange, + ), ); } } catch (e) { @@ -371,7 +351,7 @@ class _RiverManualDataStatusLogState extends State { try { statuses = jsonDecode(jsonStatus); } catch (_) { - return _buildDetailRow('$type Status:', jsonStatus!); + return _buildDetailRow('$type Status:', jsonStatus); } if (statuses.isEmpty) { @@ -385,11 +365,11 @@ class _RiverManualDataStatusLogState extends State { children: [ Text('$type Status:', style: const TextStyle(fontWeight: FontWeight.bold)), ...statuses.map((s) { - final serverName = s['server_name'] ?? 'Server N/A'; - final status = s['status'] ?? 'N/A'; - final bool isSuccess = status.toLowerCase().contains('success') || status.toLowerCase().contains('queued') || status.toLowerCase().contains('not_configured') || status.toLowerCase().contains('not_applicable') || status.toLowerCase().contains('not_required'); - final IconData icon = isSuccess ? Icons.check_circle_outline : (status.toLowerCase().contains('failed') ? Icons.error_outline : Icons.sync); - final Color color = isSuccess ? Colors.green : (status.toLowerCase().contains('failed') ? Colors.red : Colors.grey); + final serverName = s['server_name'] ?? s['config_name'] ?? 'Server N/A'; + final status = s['message'] ?? 'N/A'; + final bool isSuccess = s['success'] as bool? ?? false; + final IconData icon = isSuccess ? Icons.check_circle_outline : Icons.error_outline; + final Color color = isSuccess ? Colors.green : Colors.red; String detailLabel = (s['type'] != null) ? '(${s['type']})' : ''; return Padding( @@ -421,4 +401,4 @@ class _RiverManualDataStatusLogState extends State { ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/river/manual/in_situ_sampling.dart b/lib/screens/river/manual/in_situ_sampling.dart index 638a742..62c045d 100644 --- a/lib/screens/river/manual/in_situ_sampling.dart +++ b/lib/screens/river/manual/in_situ_sampling.dart @@ -43,9 +43,6 @@ class _RiverInSituSamplingScreenState extends State { int _currentPage = 0; bool _isLoading = false; - // FIX: Removed the late variable, it will be fetched from context directly. - // late RiverInSituSamplingService _samplingService; - @override void initState() { super.initState(); @@ -53,7 +50,6 @@ class _RiverInSituSamplingScreenState extends State { samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()), samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()), ); - // FIX: Removed the problematic post-frame callback initialization. } @override @@ -83,30 +79,26 @@ class _RiverInSituSamplingScreenState extends State { Future _submitForm() async { setState(() => _isLoading = true); - // FIX: Get the sampling service directly from the context here. final samplingService = Provider.of(context, listen: false); final authProvider = Provider.of(context, listen: false); final appSettings = authProvider.appSettings; - final result = await samplingService.submitData(_data, appSettings); - - _data.submissionStatus = result['status']; - _data.submissionMessage = result['message']; - _data.reportId = result['reportId']?.toString(); - - final activeApiConfig = await _serverConfigService.getActiveApiConfig(); - final serverName = activeApiConfig?['config_name'] as String? ?? 'Default'; - await _localStorageService.saveRiverInSituSamplingData(_data, serverName: serverName); + // --- START: MODIFIED SUBMISSION LOGIC --- + // The service call is updated to the new signature (no context). + // The service now handles all local saving, so the manual save call is removed. + final result = await samplingService.submitData( + data: _data, + appSettings: appSettings, + authProvider: authProvider, + ); if (!mounted) return; setState(() => _isLoading = false); - final message = _data.submissionMessage ?? 'An unknown error occurred.'; + final message = result['message'] ?? 'An unknown error occurred.'; final highLevelStatus = result['status'] as String? ?? 'L1'; - - final bool isSuccess = highLevelStatus.startsWith('S') || highLevelStatus.startsWith('L4'); - + final bool isSuccess = highLevelStatus.startsWith('S') || highLevelStatus.startsWith('L4') || highLevelStatus == 'Queued'; final color = isSuccess ? Colors.green : Colors.red; ScaffoldMessenger.of(context).showSnackBar( @@ -114,13 +106,11 @@ class _RiverInSituSamplingScreenState extends State { ); Navigator.of(context).popUntil((route) => route.isFirst); + // --- END: MODIFIED SUBMISSION LOGIC --- } @override Widget build(BuildContext context) { - // FIX: The Provider.value wrapper is removed. The service is already - // available in the widget tree from a higher-level provider, and child - // widgets can access it using Provider.of or context.read. return Scaffold( appBar: AppBar( title: Text('In-Situ Sampling (${_currentPage + 1}/5)'), diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 5a68189..3f792fa 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -136,6 +136,67 @@ class _SettingsScreenState extends State { final apiConfigsWithPrefs = await _preferencesService.getAllApiConfigsWithModulePreferences(moduleKey); final ftpConfigsWithPrefs = await _preferencesService.getAllFtpConfigsWithModulePreferences(moduleKey); + // START MODIFICATION: Apply default settings for submission preferences + // This logic checks if the main toggle for a submission type is on but no + // specific destination is checked. If so, it applies a default selection. + // This ensures a default configuration without overriding saved user choices. + + // Check if any API config is already enabled from preferences. + final bool isAnyApiConfigEnabled = apiConfigsWithPrefs.any((c) => c['is_enabled'] == true); + + // If the main API toggle is on but no specific API is selected, apply the default. + 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; + } + } + + // Check if any FTP config is already enabled from preferences. + final bool isAnyFtpConfigEnabled = ftpConfigsWithPrefs.any((c) => c['is_enabled'] == true); + + // If the main FTP toggle is on but no specific FTP is selected, apply the defaults for the module. + 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; + } + } + // END MODIFICATION + _moduleSettings[moduleKey] = _ModuleSettings( isApiEnabled: prefs['is_api_enabled'], isFtpEnabled: prefs['is_ftp_enabled'], diff --git a/lib/services/air_sampling_service.dart b/lib/services/air_sampling_service.dart index 452bd1c..538da3c 100644 --- a/lib/services/air_sampling_service.dart +++ b/lib/services/air_sampling_service.dart @@ -1,6 +1,7 @@ // lib/services/air_sampling_service.dart import 'dart:io'; +import 'dart:async'; // Added for TimeoutException import 'package:flutter/foundation.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; @@ -16,15 +17,12 @@ import 'local_storage_service.dart'; import 'telegram_service.dart'; import 'server_config_service.dart'; import 'zipping_service.dart'; -// START CHANGE: Import the new common submission services import 'submission_api_service.dart'; import 'submission_ftp_service.dart'; -// END CHANGE - +import 'retry_service.dart'; // Added for queuing failed tasks /// A dedicated service for handling all business logic for the Air Manual Sampling feature. class AirSamplingService { - // START CHANGE: Instantiate new services and remove ApiService final DatabaseHelper _dbHelper; final TelegramService _telegramService; final SubmissionApiService _submissionApiService = SubmissionApiService(); @@ -32,9 +30,8 @@ class AirSamplingService { final ServerConfigService _serverConfigService = ServerConfigService(); final ZippingService _zippingService = ZippingService(); final LocalStorageService _localStorageService = LocalStorageService(); - // END CHANGE + final RetryService _retryService = RetryService(); // Added - // MODIFIED: Constructor no longer needs ApiService AirSamplingService(this._dbHelper, this._telegramService); // This helper method remains unchanged as it's for local saving logic @@ -118,149 +115,199 @@ class AirSamplingService { return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); } - // --- REFACTORED submitInstallation method --- + // --- REFACTORED submitInstallation method with granular error handling --- Future> submitInstallation(AirInstallationData data, List>? appSettings) async { const String moduleName = 'air_installation'; final activeConfig = await _serverConfigService.getActiveApiConfig(); final serverName = activeConfig?['config_name'] as String? ?? 'Default'; - // --- 1. API SUBMISSION (DATA) --- - debugPrint("Step 1: Delegating Installation Data submission to SubmissionApiService..."); - final dataResult = await _submissionApiService.submitPost( - moduleName: moduleName, - endpoint: 'air/manual/installation', - body: data.toJsonForApi(), - ); - - if (dataResult['success'] != true) { - await _logAndSave(data: data, status: 'L1', message: dataResult['message']!, apiResults: [dataResult], ftpStatuses: [], serverName: serverName, type: 'Installation'); - return {'status': 'L1', 'message': dataResult['message']}; - } - - final recordId = dataResult['data']?['air_man_id']?.toString(); - if (recordId == null) { - await _logAndSave(data: data, status: 'L1', message: 'API Error: Missing record ID.', apiResults: [dataResult], ftpStatuses: [], serverName: serverName, type: 'Installation'); - return {'status': 'L1', 'message': 'API Error: Missing record ID.'}; - } - data.airManId = int.tryParse(recordId); - - // --- 2. API SUBMISSION (IMAGES) --- - debugPrint("Step 2: Delegating Installation Image submission to SubmissionApiService..."); + bool anyApiSuccess = false; + Map apiDataResult = {}; + Map apiImageResult = {}; final imageFiles = data.getImagesForUpload(); - final imageResult = await _submissionApiService.submitMultipart( - moduleName: moduleName, - endpoint: 'air/manual/installation-images', - fields: {'air_man_id': recordId}, - files: imageFiles, - ); - final bool apiImagesSuccess = imageResult['success'] == true; - // --- 3. FTP SUBMISSION --- - debugPrint("Step 3: Delegating Installation FTP submission to SubmissionFtpService..."); - final stationCode = data.stationID ?? 'UNKNOWN'; - final samplingDateTime = "${data.installationDate}_${data.installationTime}".replaceAll(':', '-').replaceAll(' ', '_'); - final baseFileName = "${stationCode}_INSTALLATION_${samplingDateTime}"; + // Step 1: Attempt API Submission + try { + apiDataResult = await _submissionApiService.submitPost( + moduleName: moduleName, + endpoint: 'air/manual/installation', + body: data.toJsonForApi(), + ); - // Zip and submit data - final dataZip = await _zippingService.createDataZip(jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, baseFileName: baseFileName); + if (apiDataResult['success'] == true) { + final recordId = apiDataResult['data']?['air_man_id']?.toString(); + if (recordId != null) { + data.airManId = int.tryParse(recordId); + apiImageResult = await _submissionApiService.submitMultipart( + moduleName: moduleName, + endpoint: 'air/manual/installation-images', + fields: {'air_man_id': recordId}, + files: imageFiles, + ); + anyApiSuccess = apiImageResult['success'] == true; + } else { + anyApiSuccess = false; + apiDataResult['message'] = 'API Error: Missing record ID.'; + } + } + } on SocketException catch (e) { + final errorMessage = "API submission failed with network error: $e"; + debugPrint(errorMessage); + anyApiSuccess = false; + apiDataResult = {'success': false, 'message': errorMessage}; + await _retryService.addApiToQueue(endpoint: 'air/manual/installation', method: 'POST', body: data.toJsonForApi()); + } on TimeoutException catch (e) { + final errorMessage = "API submission timed out: $e"; + debugPrint(errorMessage); + anyApiSuccess = false; + apiDataResult = {'success': false, 'message': errorMessage}; + await _retryService.addApiToQueue(endpoint: 'air/manual/installation', method: 'POST', body: data.toJsonForApi()); + } + + // Step 2: Attempt FTP Submission Map ftpDataResult = {'statuses': []}; - if (dataZip != null) { - ftpDataResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: dataZip, remotePath: '/air/data/${path.basename(dataZip.path)}'); - } - - // Zip and submit images - final imageZip = await _zippingService.createImageZip(imageFiles: imageFiles.values.toList(), baseFileName: baseFileName); Map ftpImageResult = {'statuses': []}; - if (imageZip != null) { - ftpImageResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: imageZip, remotePath: '/air/images/${path.basename(imageZip.path)}'); + bool anyFtpSuccess = false; + try { + final stationCode = data.stationID ?? 'UNKNOWN'; + final samplingDateTime = "${data.installationDate}_${data.installationTime}".replaceAll(':', '-').replaceAll(' ', '_'); + final baseFileName = "${stationCode}_INSTALLATION_${samplingDateTime}"; + + final dataZip = await _zippingService.createDataZip(jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, baseFileName: baseFileName); + if (dataZip != null) { + ftpDataResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: dataZip, remotePath: '/air/data/${path.basename(dataZip.path)}'); + } + + final imageZip = await _zippingService.createImageZip(imageFiles: imageFiles.values.toList(), baseFileName: baseFileName); + if (imageZip != null) { + ftpImageResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: imageZip, remotePath: '/air/images/${path.basename(imageZip.path)}'); + } + anyFtpSuccess = !(ftpDataResult['statuses'] as List).any((s) => s['success'] == false) && !(ftpImageResult['statuses'] as List).any((s) => s['success'] == false); + } on SocketException catch (e) { + debugPrint("FTP submission failed with network error: $e"); + anyFtpSuccess = false; + } on TimeoutException catch (e) { + debugPrint("FTP submission timed out: $e"); + anyFtpSuccess = false; } - final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true); - - // --- 4. DETERMINE FINAL STATUS, LOG, AND ALERT --- + // Step 3: Determine Final Status String finalStatus; String finalMessage; - if (apiImagesSuccess) { - finalStatus = ftpSuccess ? 'S4' : 'S2'; - finalMessage = ftpSuccess ? 'Data and files submitted successfully.' : 'Data submitted to API. FTP upload failed or was queued.'; + if (anyApiSuccess && anyFtpSuccess) { + finalStatus = 'S4'; + finalMessage = 'Data and files submitted successfully.'; + } else if (anyApiSuccess && !anyFtpSuccess) { + finalStatus = 'S3'; + finalMessage = 'Data submitted to API, but FTP upload failed and was queued.'; + } else if (!anyApiSuccess && anyFtpSuccess) { + finalStatus = 'L4'; + finalMessage = 'API submission failed, but files were sent via FTP.'; } else { - finalStatus = ftpSuccess ? 'L2_FTP_ONLY' : 'L2_PENDING_IMAGES'; - finalMessage = ftpSuccess ? 'API image upload failed, but files were sent via FTP.' : 'Data submitted, but API image and FTP uploads failed.'; + finalStatus = 'L1'; + finalMessage = 'Both API and FTP submissions failed and were queued.'; } - await _logAndSave(data: data, status: finalStatus, message: finalMessage, apiResults: [dataResult, imageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Installation'); - _handleInstallationSuccessAlert(data, appSettings, isDataOnly: !apiImagesSuccess); + await _logAndSave(data: data, status: finalStatus, message: finalMessage, apiResults: [apiDataResult, apiImageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Installation'); + if (anyApiSuccess || anyFtpSuccess) { + _handleInstallationSuccessAlert(data, appSettings, isDataOnly: imageFiles.isEmpty); + } return {'status': finalStatus, 'message': finalMessage}; } - // --- REFACTORED submitCollection method --- + // --- REFACTORED submitCollection method with granular error handling --- Future> submitCollection(AirCollectionData data, AirInstallationData installationData, List>? appSettings) async { const String moduleName = 'air_collection'; final activeConfig = await _serverConfigService.getActiveApiConfig(); final serverName = activeConfig?['config_name'] as String? ?? 'Default'; - // --- 1. API SUBMISSION (DATA) --- - debugPrint("Step 1: Delegating Collection Data submission to SubmissionApiService..."); - data.airManId = installationData.airManId; // Ensure collection is linked to installation - final dataResult = await _submissionApiService.submitPost( - moduleName: moduleName, - endpoint: 'air/manual/collection', - body: data.toJson(), - ); - - if (dataResult['success'] != true) { - await _logAndSave(data: data, installationData: installationData, status: 'L3', message: dataResult['message']!, apiResults: [dataResult], ftpStatuses: [], serverName: serverName, type: 'Collection'); - return {'status': 'L3', 'message': dataResult['message']}; - } - - // --- 2. API SUBMISSION (IMAGES) --- - debugPrint("Step 2: Delegating Collection Image submission to SubmissionApiService..."); + bool anyApiSuccess = false; + Map apiDataResult = {}; + Map apiImageResult = {}; final imageFiles = data.getImagesForUpload(); - final imageResult = await _submissionApiService.submitMultipart( - moduleName: moduleName, - endpoint: 'air/manual/collection-images', - fields: {'air_man_id': data.airManId.toString()}, - files: imageFiles, - ); - final bool apiImagesSuccess = imageResult['success'] == true; - // --- 3. FTP SUBMISSION --- - debugPrint("Step 3: Delegating Collection FTP submission to SubmissionFtpService..."); - final stationCode = installationData.stationID ?? 'UNKNOWN'; - final samplingDateTime = "${data.collectionDate}_${data.collectionTime}".replaceAll(':', '-').replaceAll(' ', '_'); - final baseFileName = "${stationCode}_COLLECTION_${samplingDateTime}"; + // Step 1: Attempt API Submission + try { + data.airManId = installationData.airManId; + apiDataResult = await _submissionApiService.submitPost( + moduleName: moduleName, + endpoint: 'air/manual/collection', + body: data.toJson(), + ); - // Zip and submit data (includes both installation and collection data) - final combinedJson = jsonEncode({"installation": installationData.toDbJson(), "collection": data.toMap()}); - final dataZip = await _zippingService.createDataZip(jsonDataMap: {'db.json': combinedJson}, baseFileName: baseFileName); + if (apiDataResult['success'] == true) { + apiImageResult = await _submissionApiService.submitMultipart( + moduleName: moduleName, + endpoint: 'air/manual/collection-images', + fields: {'air_man_id': data.airManId.toString()}, + files: imageFiles, + ); + anyApiSuccess = apiImageResult['success'] == true; + } + } on SocketException catch (e) { + final errorMessage = "API submission failed with network error: $e"; + debugPrint(errorMessage); + anyApiSuccess = false; + apiDataResult = {'success': false, 'message': errorMessage}; + await _retryService.addApiToQueue(endpoint: 'air/manual/collection', method: 'POST', body: data.toJson()); + } on TimeoutException catch (e) { + final errorMessage = "API submission timed out: $e"; + debugPrint(errorMessage); + anyApiSuccess = false; + apiDataResult = {'success': false, 'message': errorMessage}; + await _retryService.addApiToQueue(endpoint: 'air/manual/collection', method: 'POST', body: data.toJson()); + } + + // Step 2: Attempt FTP Submission Map ftpDataResult = {'statuses': []}; - if (dataZip != null) { - ftpDataResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: dataZip, remotePath: '/air/data/${path.basename(dataZip.path)}'); - } - - // Zip and submit images - final imageZip = await _zippingService.createImageZip(imageFiles: imageFiles.values.toList(), baseFileName: baseFileName); Map ftpImageResult = {'statuses': []}; - if (imageZip != null) { - ftpImageResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: imageZip, remotePath: '/air/images/${path.basename(imageZip.path)}'); + bool anyFtpSuccess = false; + try { + final stationCode = installationData.stationID ?? 'UNKNOWN'; + final samplingDateTime = "${data.collectionDate}_${data.collectionTime}".replaceAll(':', '-').replaceAll(' ', '_'); + final baseFileName = "${stationCode}_COLLECTION_${samplingDateTime}"; + + final combinedJson = jsonEncode({"installation": installationData.toDbJson(), "collection": data.toMap()}); + final dataZip = await _zippingService.createDataZip(jsonDataMap: {'db.json': combinedJson}, baseFileName: baseFileName); + if (dataZip != null) { + ftpDataResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: dataZip, remotePath: '/air/data/${path.basename(dataZip.path)}'); + } + + final imageZip = await _zippingService.createImageZip(imageFiles: imageFiles.values.toList(), baseFileName: baseFileName); + if (imageZip != null) { + ftpImageResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: imageZip, remotePath: '/air/images/${path.basename(imageZip.path)}'); + } + anyFtpSuccess = !(ftpDataResult['statuses'] as List).any((s) => s['success'] == false) && !(ftpImageResult['statuses'] as List).any((s) => s['success'] == false); + } on SocketException catch (e) { + debugPrint("FTP submission failed with network error: $e"); + anyFtpSuccess = false; + } on TimeoutException catch (e) { + debugPrint("FTP submission timed out: $e"); + anyFtpSuccess = false; } - final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true); - - // --- 4. DETERMINE FINAL STATUS, LOG, AND ALERT --- + // Step 3: Determine Final Status String finalStatus; String finalMessage; - if (apiImagesSuccess) { - finalStatus = ftpSuccess ? 'S4_API_FTP' : 'S3'; - finalMessage = ftpSuccess ? 'Data and files submitted successfully.' : 'Data submitted to API. FTP upload failed or was queued.'; + if (anyApiSuccess && anyFtpSuccess) { + finalStatus = 'S4'; + finalMessage = 'Data and files submitted successfully.'; + } else if (anyApiSuccess && !anyFtpSuccess) { + finalStatus = 'S3'; + finalMessage = 'Data submitted to API, but FTP upload failed and was queued.'; + } else if (!anyApiSuccess && anyFtpSuccess) { + finalStatus = 'L4'; + finalMessage = 'API submission failed, but files were sent via FTP.'; } else { - finalStatus = ftpSuccess ? 'L4_FTP_ONLY' : 'L4_PENDING_IMAGES'; - finalMessage = ftpSuccess ? 'API image upload failed, but files were sent via FTP.' : 'Data submitted, but API image and FTP uploads failed.'; + finalStatus = 'L1'; + finalMessage = 'Both API and FTP submissions failed and were queued.'; } - await _logAndSave(data: data, installationData: installationData, status: finalStatus, message: finalMessage, apiResults: [dataResult, imageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Collection'); - _handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: !apiImagesSuccess); + await _logAndSave(data: data, installationData: installationData, status: finalStatus, message: finalMessage, apiResults: [apiDataResult, apiImageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Collection'); + if(anyApiSuccess || anyFtpSuccess) { + _handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: imageFiles.isEmpty); + } return {'status': finalStatus, 'message': finalMessage}; } @@ -303,7 +350,7 @@ class AirSamplingService { 'status': status, 'message': message, 'report_id': (data.airManId ?? installationData?.airManId)?.toString(), - 'created_at': DateTime.now(), + 'created_at': DateTime.now().toIso8601String(), 'form_data': jsonEncode(formData), 'image_data': jsonEncode(imagePaths), 'server_name': serverName, diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 191289b..080d9ed 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -16,41 +16,31 @@ import 'package:environment_monitoring_app/models/tarball_data.dart'; import 'package:environment_monitoring_app/models/air_collection_data.dart'; import 'package:environment_monitoring_app/models/air_installation_data.dart'; import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.dart'; -// START CHANGE: Added import for ServerConfigService to get the base URL import 'package:environment_monitoring_app/services/server_config_service.dart'; -// END CHANGE // ======================================================================= // Part 1: Unified API Service // ======================================================================= -/// A unified service that consolidates all API interactions for the application. -// ... (ApiService class definition remains the same) - class ApiService { final BaseApiService _baseService = BaseApiService(); final DatabaseHelper dbHelper = DatabaseHelper(); - // START CHANGE: Added ServerConfigService to provide the base URL for API calls final ServerConfigService _serverConfigService = ServerConfigService(); - // END CHANGE late final MarineApiService marine; late final RiverApiService river; late final AirApiService air; - static const String imageBaseUrl = 'https://dev14.pstw.com.my/'; + static const String imageBaseUrl = 'https://mms-apiv4.pstw.com.my/'; ApiService({required TelegramService telegramService}) { - // START CHANGE: Pass the ServerConfigService to the sub-services marine = MarineApiService(_baseService, telegramService, _serverConfigService); river = RiverApiService(_baseService, telegramService, _serverConfigService); air = AirApiService(_baseService, telegramService, _serverConfigService); - // END CHANGE } - // --- Core API Methods (Unchanged) --- + // --- Core API Methods --- - // START CHANGE: Update all calls to _baseService to pass the required baseUrl Future> login(String email, String password) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.post(baseUrl, 'auth/login', {'email': email, 'password': password}); @@ -92,6 +82,7 @@ class ApiService { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'profile'); } + Future> getAllUsers() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'users'); @@ -101,20 +92,22 @@ class ApiService { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'departments'); } + Future> getAllCompanies() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'companies'); } + Future> getAllPositions() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'positions'); } + Future> getAllStates() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'states'); } - Future> sendTelegramAlert({ required String chatId, required String message, @@ -147,10 +140,8 @@ class ApiService { baseUrl: baseUrl, endpoint: 'profile/upload-picture', fields: {}, - files: {'profile_picture': imageFile} - ); + files: {'profile_picture': imageFile}); } - // END CHANGE Future> refreshProfile() async { debugPrint('ApiService: Refreshing profile data from server...'); @@ -166,47 +157,151 @@ class ApiService { /// Helper method to make a delta-sync API call. Future> _fetchDelta(String endpoint, String? lastSyncTimestamp) async { - // START CHANGE: Get baseUrl and pass it to the get method final baseUrl = await _serverConfigService.getActiveApiUrl(); String url = endpoint; if (lastSyncTimestamp != null) { - // Append the 'since' parameter to the URL for delta requests url += '?since=$lastSyncTimestamp'; } return _baseService.get(baseUrl, url); - // END CHANGE } /// Orchestrates a full DELTA sync from the server to the local database. Future> syncAllData({String? lastSyncTimestamp}) async { debugPrint('ApiService: Starting DELTA data sync. Since: $lastSyncTimestamp'); try { - // Defines all data types to sync, their endpoints, and their DB handlers. final syncTasks = { - 'profile': {'endpoint': 'profile', 'handler': (d, id) async { if (d.isNotEmpty) await dbHelper.saveProfile(d.first); }}, - 'allUsers': {'endpoint': 'users', 'handler': (d, id) async { await dbHelper.upsertUsers(d); await dbHelper.deleteUsers(id); }}, - // --- ADDED: New sync task for documents --- - 'documents': {'endpoint': 'documents', 'handler': (d, id) async { await dbHelper.upsertDocuments(d); await dbHelper.deleteDocuments(id); }}, - // --- END ADDED --- - 'tarballStations': {'endpoint': 'marine/tarball/stations', 'handler': (d, id) async { await dbHelper.upsertTarballStations(d); await dbHelper.deleteTarballStations(id); }}, - 'manualStations': {'endpoint': 'marine/manual/stations', 'handler': (d, id) async { await dbHelper.upsertManualStations(d); await dbHelper.deleteManualStations(id); }}, - 'tarballClassifications': {'endpoint': 'marine/tarball/classifications', 'handler': (d, id) async { await dbHelper.upsertTarballClassifications(d); await dbHelper.deleteTarballClassifications(id); }}, - 'riverManualStations': {'endpoint': 'river/manual-stations', 'handler': (d, id) async { await dbHelper.upsertRiverManualStations(d); await dbHelper.deleteRiverManualStations(id); }}, - 'riverTriennialStations': {'endpoint': 'river/triennial-stations', 'handler': (d, id) async { await dbHelper.upsertRiverTriennialStations(d); await dbHelper.deleteRiverTriennialStations(id); }}, - 'departments': {'endpoint': 'departments', 'handler': (d, id) async { await dbHelper.upsertDepartments(d); await dbHelper.deleteDepartments(id); }}, - 'companies': {'endpoint': 'companies', 'handler': (d, id) async { await dbHelper.upsertCompanies(d); await dbHelper.deleteCompanies(id); }}, - 'positions': {'endpoint': 'positions', 'handler': (d, id) async { await dbHelper.upsertPositions(d); await dbHelper.deletePositions(id); }}, - 'airManualStations': {'endpoint': 'air/manual-stations', 'handler': (d, id) async { await dbHelper.upsertAirManualStations(d); await dbHelper.deleteAirManualStations(id); }}, - 'airClients': {'endpoint': 'air/clients', 'handler': (d, id) async { await dbHelper.upsertAirClients(d); await dbHelper.deleteAirClients(id); }}, - 'states': {'endpoint': 'states', 'handler': (d, id) async { await dbHelper.upsertStates(d); await dbHelper.deleteStates(id); }}, - 'appSettings': {'endpoint': 'settings', 'handler': (d, id) async { await dbHelper.upsertAppSettings(d); await dbHelper.deleteAppSettings(id); }}, - 'parameterLimits': {'endpoint': 'parameter-limits', 'handler': (d, id) async { await dbHelper.upsertParameterLimits(d); await dbHelper.deleteParameterLimits(id); }}, - 'apiConfigs': {'endpoint': 'api-configs', 'handler': (d, id) async { await dbHelper.upsertApiConfigs(d); await dbHelper.deleteApiConfigs(id); }}, - 'ftpConfigs': {'endpoint': 'ftp-configs', 'handler': (d, id) async { await dbHelper.upsertFtpConfigs(d); await dbHelper.deleteFtpConfigs(id); }}, + 'profile': { + 'endpoint': 'profile', + 'handler': (d, id) async { + if (d.isNotEmpty) await dbHelper.saveProfile(d.first); + } + }, + 'allUsers': { + 'endpoint': 'users', + 'handler': (d, id) async { + // START CHANGE: Use custom upsert method for users + await dbHelper.upsertUsers(d); + await dbHelper.deleteUsers(id); + // END CHANGE + } + }, + 'documents': { + 'endpoint': 'documents', + 'handler': (d, id) async { + await dbHelper.upsertDocuments(d); + await dbHelper.deleteDocuments(id); + } + }, + 'tarballStations': { + 'endpoint': 'marine/tarball/stations', + 'handler': (d, id) async { + await dbHelper.upsertTarballStations(d); + await dbHelper.deleteTarballStations(id); + } + }, + 'manualStations': { + 'endpoint': 'marine/manual/stations', + 'handler': (d, id) async { + await dbHelper.upsertManualStations(d); + await dbHelper.deleteManualStations(id); + } + }, + 'tarballClassifications': { + 'endpoint': 'marine/tarball/classifications', + 'handler': (d, id) async { + await dbHelper.upsertTarballClassifications(d); + await dbHelper.deleteTarballClassifications(id); + } + }, + 'riverManualStations': { + 'endpoint': 'river/manual-stations', + 'handler': (d, id) async { + await dbHelper.upsertRiverManualStations(d); + await dbHelper.deleteRiverManualStations(id); + } + }, + 'riverTriennialStations': { + 'endpoint': 'river/triennial-stations', + 'handler': (d, id) async { + await dbHelper.upsertRiverTriennialStations(d); + await dbHelper.deleteRiverTriennialStations(id); + } + }, + 'departments': { + 'endpoint': 'departments', + 'handler': (d, id) async { + await dbHelper.upsertDepartments(d); + await dbHelper.deleteDepartments(id); + } + }, + 'companies': { + 'endpoint': 'companies', + 'handler': (d, id) async { + await dbHelper.upsertCompanies(d); + await dbHelper.deleteCompanies(id); + } + }, + 'positions': { + 'endpoint': 'positions', + 'handler': (d, id) async { + await dbHelper.upsertPositions(d); + await dbHelper.deletePositions(id); + } + }, + 'airManualStations': { + 'endpoint': 'air/manual-stations', + 'handler': (d, id) async { + await dbHelper.upsertAirManualStations(d); + await dbHelper.deleteAirManualStations(id); + } + }, + 'airClients': { + 'endpoint': 'air/clients', + 'handler': (d, id) async { + await dbHelper.upsertAirClients(d); + await dbHelper.deleteAirClients(id); + } + }, + 'states': { + 'endpoint': 'states', + 'handler': (d, id) async { + await dbHelper.upsertStates(d); + await dbHelper.deleteStates(id); + } + }, + 'appSettings': { + 'endpoint': 'settings', + 'handler': (d, id) async { + await dbHelper.upsertAppSettings(d); + await dbHelper.deleteAppSettings(id); + } + }, + 'parameterLimits': { + 'endpoint': 'parameter-limits', + 'handler': (d, id) async { + await dbHelper.upsertParameterLimits(d); + await dbHelper.deleteParameterLimits(id); + } + }, + 'apiConfigs': { + 'endpoint': 'api-configs', + 'handler': (d, id) async { + await dbHelper.upsertApiConfigs(d); + await dbHelper.deleteApiConfigs(id); + } + }, + 'ftpConfigs': { + 'endpoint': 'ftp-configs', + 'handler': (d, id) async { + await dbHelper.upsertFtpConfigs(d); + await dbHelper.deleteFtpConfigs(id); + } + }, }; // Fetch all deltas in parallel - final fetchFutures = syncTasks.map((key, value) => MapEntry(key, _fetchDelta(value['endpoint'] as String, lastSyncTimestamp))); + final fetchFutures = syncTasks.map((key, value) => + MapEntry(key, _fetchDelta(value['endpoint'] as String, lastSyncTimestamp))); final results = await Future.wait(fetchFutures.values); final resultData = Map.fromIterables(fetchFutures.keys, results); @@ -216,7 +311,6 @@ class ApiService { final result = entry.value; if (result['success'] == true && result['data'] != null) { - // The profile endpoint has a different structure, handle it separately. if (key == 'profile') { await (syncTasks[key]!['handler'] as Function)([result['data']], []); } else { @@ -231,7 +325,6 @@ class ApiService { debugPrint('ApiService: Delta sync complete.'); return {'success': true, 'message': 'Delta sync successful.'}; - } catch (e) { debugPrint('ApiService: Delta data sync failed: $e'); return {'success': false, 'message': 'Data sync failed: $e'}; @@ -246,16 +339,15 @@ class ApiService { class AirApiService { final BaseApiService _baseService; final TelegramService? _telegramService; - // START CHANGE: Add ServerConfigService dependency final ServerConfigService _serverConfigService; - AirApiService(this._baseService, this._telegramService, this._serverConfigService); - // END CHANGE - // START CHANGE: Update all calls to _baseService to pass the required baseUrl + AirApiService(this._baseService, this._telegramService, this._serverConfigService); + Future> getManualStations() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'air/manual-stations'); } + Future> getClients() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'air/clients'); @@ -296,27 +388,25 @@ class AirApiService { files: files, ); } -// END CHANGE } - class MarineApiService { final BaseApiService _baseService; final TelegramService _telegramService; - // START CHANGE: Add ServerConfigService dependency final ServerConfigService _serverConfigService; - MarineApiService(this._baseService, this._telegramService, this._serverConfigService); - // END CHANGE - // START CHANGE: Update all calls to _baseService to pass the required baseUrl + MarineApiService(this._baseService, this._telegramService, this._serverConfigService); + Future> getTarballStations() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'marine/tarball/stations'); } + Future> getManualStations() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'marine/manual/stations'); } + Future> getTarballClassifications() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'marine/tarball/classifications'); @@ -398,7 +488,8 @@ class MarineApiService { }; } - Future _handleInSituSuccessAlert(InSituSamplingData data, List>? appSettings, {required bool isDataOnly}) async { + Future _handleInSituSuccessAlert(InSituSamplingData data, + List>? appSettings, {required bool isDataOnly}) async { try { final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly); final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings); @@ -417,31 +508,40 @@ class MarineApiService { }) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); final dataResult = await _baseService.post(baseUrl, 'marine/tarball/sample', formData); - if (dataResult['success'] != true) return {'status': 'L1', 'success': false, 'message': 'Failed to submit data: ${dataResult['message']}'}; + if (dataResult['success'] != true) + return {'status': 'L1', 'success': false, 'message': 'Failed to submit data: ${dataResult['message']}'}; final recordId = dataResult['data']?['autoid']; if (recordId == null) return {'status': 'L2', 'success': false, 'message': 'Data submitted, but failed to get a record ID.'}; final filesToUpload = {}; - imageFiles.forEach((key, value) { if (value != null) filesToUpload[key] = value; }); + imageFiles.forEach((key, value) { + if (value != null) filesToUpload[key] = value; + }); if (filesToUpload.isEmpty) { _handleTarballSuccessAlert(formData, appSettings, isDataOnly: true); return {'status': 'L3', 'success': true, 'message': 'Data submitted successfully.', 'reportId': recordId}; } - final imageResult = await _baseService.postMultipart(baseUrl: baseUrl, endpoint: 'marine/tarball/images', fields: {'autoid': recordId.toString()}, files: filesToUpload); + final imageResult = await _baseService.postMultipart( + baseUrl: baseUrl, endpoint: 'marine/tarball/images', fields: {'autoid': recordId.toString()}, files: filesToUpload); if (imageResult['success'] != true) { _handleTarballSuccessAlert(formData, appSettings, isDataOnly: true); - return {'status': 'L2', 'success': false, 'message': 'Data submitted, but image upload failed: ${imageResult['message']}', 'reportId': recordId}; + return { + 'status': 'L2', + 'success': false, + 'message': 'Data submitted, but image upload failed: ${imageResult['message']}', + 'reportId': recordId + }; } _handleTarballSuccessAlert(formData, appSettings, isDataOnly: false); return {'status': 'L3', 'success': true, 'message': 'Data and images submitted successfully.', 'reportId': recordId}; } - // END CHANGE - Future _handleTarballSuccessAlert(Map formData, List>? appSettings, {required bool isDataOnly}) async { + Future _handleTarballSuccessAlert( + Map formData, List>? appSettings, {required bool isDataOnly}) async { debugPrint("Triggering Telegram alert logic..."); try { final message = _generateTarballAlertMessage(formData, isDataOnly: isDataOnly); @@ -489,16 +589,15 @@ class MarineApiService { class RiverApiService { final BaseApiService _baseService; final TelegramService _telegramService; - // START CHANGE: Add ServerConfigService dependency final ServerConfigService _serverConfigService; - RiverApiService(this._baseService, this._telegramService, this._serverConfigService); - // END CHANGE - // START CHANGE: Update all calls to _baseService to pass the required baseUrl + RiverApiService(this._baseService, this._telegramService, this._serverConfigService); + Future> getManualStations() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'river/manual-stations'); } + Future> getTriennialStations() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'river/triennial-stations'); @@ -570,9 +669,9 @@ class RiverApiService { 'reportId': recordId.toString() }; } - // END CHANGE - Future _handleInSituSuccessAlert(Map formData, List>? appSettings, {required bool isDataOnly}) async { + Future _handleInSituSuccessAlert( + Map formData, List>? appSettings, {required bool isDataOnly}) async { try { final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final stationName = formData['r_man_station_name'] ?? 'N/A'; @@ -622,8 +721,7 @@ class RiverApiService { class DatabaseHelper { static Database? _database; static const String _dbName = 'app_data.db'; - // --- ADDED: Incremented DB version for the new table --- - static const int _dbVersion = 20; + static const int _dbVersion = 21; static const String _profileTable = 'user_profile'; static const String _usersTable = 'all_users'; @@ -645,14 +743,12 @@ class DatabaseHelper { static const String _ftpConfigsTable = 'ftp_configurations'; static const String _retryQueueTable = 'retry_queue'; static const String _submissionLogTable = 'submission_log'; - // --- ADDED: New table name for documents --- static const String _documentsTable = 'documents'; static const String _modulePreferencesTable = 'module_preferences'; static const String _moduleApiLinksTable = 'module_api_links'; static const String _moduleFtpLinksTable = 'module_ftp_links'; - Future get database async { if (_database != null) return _database!; _database = await _initDB(); @@ -666,7 +762,14 @@ class DatabaseHelper { Future _onCreate(Database db, int version) async { await db.execute('CREATE TABLE $_profileTable(user_id INTEGER PRIMARY KEY, profile_json TEXT)'); - await db.execute('CREATE TABLE $_usersTable(user_id INTEGER PRIMARY KEY, user_json TEXT)'); + await db.execute(''' + CREATE TABLE $_usersTable( + user_id INTEGER PRIMARY KEY, + email TEXT UNIQUE, + password_hash TEXT, + user_json TEXT + ) + '''); await db.execute('CREATE TABLE $_tarballStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); await db.execute('CREATE TABLE $_manualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); await db.execute('CREATE TABLE $_riverManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); @@ -693,7 +796,6 @@ class DatabaseHelper { status TEXT NOT NULL ) '''); - // FIX: Updated CREATE TABLE statement for _submissionLogTable to include api_status and ftp_status await db.execute(''' CREATE TABLE $_submissionLogTable ( submission_id TEXT PRIMARY KEY, @@ -710,8 +812,6 @@ class DatabaseHelper { ftp_status TEXT ) '''); - - // START CHANGE: Added CREATE TABLE statements for the new tables. await db.execute(''' CREATE TABLE $_modulePreferencesTable ( module_name TEXT PRIMARY KEY, @@ -735,9 +835,6 @@ class DatabaseHelper { is_enabled INTEGER NOT NULL DEFAULT 1 ) '''); - // END CHANGE - - // --- ADDED: Create the documents table on initial creation --- await db.execute('CREATE TABLE $_documentsTable(id INTEGER PRIMARY KEY, document_json TEXT)'); } @@ -770,7 +867,6 @@ class DatabaseHelper { '''); } if (oldVersion < 18) { - // FIX: Updated UPGRADE TABLE statement for _submissionLogTable to include api_status and ftp_status await db.execute(''' CREATE TABLE IF NOT EXISTS $_submissionLogTable ( submission_id TEXT PRIMARY KEY, @@ -786,18 +882,11 @@ class DatabaseHelper { ) '''); } - - // Add columns if upgrading from < 18 or if columns were manually dropped (for testing) - // NOTE: In a real migration, you'd check if the columns exist first. if (oldVersion < 19) { try { await db.execute("ALTER TABLE $_submissionLogTable ADD COLUMN api_status TEXT"); await db.execute("ALTER TABLE $_submissionLogTable ADD COLUMN ftp_status TEXT"); - } catch (_) { - // Ignore if columns already exist during a complex migration path - } - - // START CHANGE: Add upgrade path for the new preference tables. + } catch (_) {} await db.execute(''' CREATE TABLE IF NOT EXISTS $_modulePreferencesTable ( module_name TEXT PRIMARY KEY, @@ -821,12 +910,23 @@ class DatabaseHelper { is_enabled INTEGER NOT NULL DEFAULT 1 ) '''); - // END CHANGE } - // --- ADDED: Upgrade path for the new documents table --- if (oldVersion < 20) { await db.execute('CREATE TABLE IF NOT EXISTS $_documentsTable(id INTEGER PRIMARY KEY, document_json TEXT)'); } + + if (oldVersion < 21) { + try { + await db.execute("ALTER TABLE $_usersTable ADD COLUMN email TEXT"); + } catch (e) { + debugPrint("Upgrade warning: Failed to add email column to users table (may already exist): $e"); + } + try { + await db.execute("ALTER TABLE $_usersTable ADD COLUMN password_hash TEXT"); + } catch (e) { + debugPrint("Upgrade warning: Failed to add password_hash column to users table (may already exist): $e"); + } + } } /// Performs an "upsert": inserts new records or replaces existing ones. @@ -869,43 +969,140 @@ class DatabaseHelper { Future saveProfile(Map profile) async { final db = await database; - await db.insert(_profileTable, {'user_id': profile['user_id'], 'profile_json': jsonEncode(profile)}, conflictAlgorithm: ConflictAlgorithm.replace); + await db.insert(_profileTable, {'user_id': profile['user_id'], 'profile_json': jsonEncode(profile)}, + conflictAlgorithm: ConflictAlgorithm.replace); } + Future?> loadProfile() async { final db = await database; final List> maps = await db.query(_profileTable); - if(maps.isNotEmpty) return jsonDecode(maps.first['profile_json']); + if (maps.isNotEmpty) return jsonDecode(maps.first['profile_json']); return null; } - // --- Upsert/Delete/Load methods for all data types --- + // --- START: Offline Authentication and User Upsert Methods --- + + /// Retrieves a user's profile JSON from the users table by email. + Future?> loadProfileByEmail(String email) async { + final db = await database; + final List> maps = await db.query( + _usersTable, + columns: ['user_json'], + where: 'email = ?', + whereArgs: [email], + ); + if (maps.isNotEmpty) { + try { + return jsonDecode(maps.first['user_json']) as Map; + } catch (e) { + debugPrint("Error decoding profile for email $email: $e"); + return null; + } + } + return null; + } + + /// Inserts or replaces a user's profile and credentials. + /// This ensures the record exists when caching credentials during login. + Future upsertUserWithCredentials({ + required Map profile, + required String passwordHash, + }) async { + final db = await database; + await db.insert( + _usersTable, + { + 'user_id': profile['user_id'], + 'email': profile['email'], + 'password_hash': passwordHash, + 'user_json': jsonEncode(profile) + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + debugPrint("Upserted user credentials for ${profile['email']}"); + } + + /// Retrieves the stored password hash for a user by email. + Future getUserPasswordHashByEmail(String email) async { + final db = await database; + final List> result = await db.query( + _usersTable, + columns: ['password_hash'], + where: 'email = ?', + whereArgs: [email], + ); + if (result.isNotEmpty && result.first['password_hash'] != null) { + return result.first['password_hash'] as String; + } + return null; + } + + // --- START: Custom upsert method for user sync to prevent hash overwrite --- + /// Upserts user data from sync without overwriting the local password hash. + Future upsertUsers(List> data) async { + if (data.isEmpty) return; + final db = await database; + for (var item in data) { + final updateData = { + //'email': item['email'], + 'user_json': jsonEncode(item), + }; + + // Try to update existing record first, preserving other columns like password_hash + int count = await db.update( + _usersTable, + updateData, + where: 'user_id = ?', + whereArgs: [item['user_id']], + ); + + // If no record was updated (count == 0), insert a new record. + if (count == 0) { + await db.insert( + _usersTable, + { + 'user_id': item['user_id'], + 'email': item['email'], + 'user_json': jsonEncode(item), + // password_hash will be null for a new user until they log in on this device. + }, + conflictAlgorithm: ConflictAlgorithm.ignore, + ); + } + } + debugPrint("Upserted ${data.length} user items in custom upsert method."); + } + // --- END: Custom upsert method --- - Future upsertUsers(List> data) => _upsertData(_usersTable, 'user_id', data, 'user'); Future deleteUsers(List ids) => _deleteData(_usersTable, 'user_id', ids); Future>?> loadUsers() => _loadData(_usersTable, 'user'); - // --- ADDED: Handlers for the new documents table --- Future upsertDocuments(List> data) => _upsertData(_documentsTable, 'id', data, 'document'); Future deleteDocuments(List ids) => _deleteData(_documentsTable, 'id', ids); Future>?> loadDocuments() => _loadData(_documentsTable, 'document'); - Future upsertTarballStations(List> data) => _upsertData(_tarballStationsTable, 'station_id', data, 'station'); + Future upsertTarballStations(List> data) => + _upsertData(_tarballStationsTable, 'station_id', data, 'station'); Future deleteTarballStations(List ids) => _deleteData(_tarballStationsTable, 'station_id', ids); Future>?> loadTarballStations() => _loadData(_tarballStationsTable, 'station'); - Future upsertManualStations(List> data) => _upsertData(_manualStationsTable, 'station_id', data, 'station'); + Future upsertManualStations(List> data) => + _upsertData(_manualStationsTable, 'station_id', data, 'station'); Future deleteManualStations(List ids) => _deleteData(_manualStationsTable, 'station_id', ids); Future>?> loadManualStations() => _loadData(_manualStationsTable, 'station'); - Future upsertRiverManualStations(List> data) => _upsertData(_riverManualStationsTable, 'station_id', data, 'station'); + Future upsertRiverManualStations(List> data) => + _upsertData(_riverManualStationsTable, 'station_id', data, 'station'); Future deleteRiverManualStations(List ids) => _deleteData(_riverManualStationsTable, 'station_id', ids); Future>?> loadRiverManualStations() => _loadData(_riverManualStationsTable, 'station'); - Future upsertRiverTriennialStations(List> data) => _upsertData(_riverTriennialStationsTable, 'station_id', data, 'station'); + Future upsertRiverTriennialStations(List> data) => + _upsertData(_riverTriennialStationsTable, 'station_id', data, 'station'); Future deleteRiverTriennialStations(List ids) => _deleteData(_riverTriennialStationsTable, 'station_id', ids); Future>?> loadRiverTriennialStations() => _loadData(_riverTriennialStationsTable, 'station'); - Future upsertTarballClassifications(List> data) => _upsertData(_tarballClassificationsTable, 'classification_id', data, 'classification'); + Future upsertTarballClassifications(List> data) => + _upsertData(_tarballClassificationsTable, 'classification_id', data, 'classification'); Future deleteTarballClassifications(List ids) => _deleteData(_tarballClassificationsTable, 'classification_id', ids); Future>?> loadTarballClassifications() => _loadData(_tarballClassificationsTable, 'classification'); @@ -921,7 +1118,8 @@ class DatabaseHelper { Future deletePositions(List ids) => _deleteData(_positionsTable, 'position_id', ids); Future>?> loadPositions() => _loadData(_positionsTable, 'position'); - Future upsertAirManualStations(List> data) => _upsertData(_airManualStationsTable, 'station_id', data, 'station'); + Future upsertAirManualStations(List> data) => + _upsertData(_airManualStationsTable, 'station_id', data, 'station'); Future deleteAirManualStations(List ids) => _deleteData(_airManualStationsTable, 'station_id', ids); Future>?> loadAirManualStations() => _loadData(_airManualStationsTable, 'station'); @@ -941,7 +1139,6 @@ class DatabaseHelper { Future deleteParameterLimits(List ids) => _deleteData(_parameterLimitsTable, 'param_autoid', ids); Future>?> loadParameterLimits() => _loadData(_parameterLimitsTable, 'limit'); - // --- ADDED: Methods for independent API and FTP configurations --- Future upsertApiConfigs(List> data) => _upsertData(_apiConfigsTable, 'api_config_id', data, 'config'); Future deleteApiConfigs(List ids) => _deleteData(_apiConfigsTable, 'api_config_id', ids); Future>?> loadApiConfigs() => _loadData(_apiConfigsTable, 'config'); @@ -950,7 +1147,6 @@ class DatabaseHelper { Future deleteFtpConfigs(List ids) => _deleteData(_ftpConfigsTable, 'ftp_config_id', ids); Future>?> loadFtpConfigs() => _loadData(_ftpConfigsTable, 'config'); - // --- ADDED: Methods for the new retry queue --- Future queueFailedRequest(Map data) async { final db = await database; return await db.insert(_retryQueueTable, data, conflictAlgorithm: ConflictAlgorithm.replace); @@ -972,10 +1168,6 @@ class DatabaseHelper { await db.delete(_retryQueueTable, where: 'id = ?', whereArgs: [id]); } - // --- ADDED: Methods for the centralized submission log --- - - /// Saves a new submission log entry to the central database table. - // FIX: Updated signature to accept api_status and ftp_status Future saveSubmissionLog(Map data) async { final db = await database; await db.insert( @@ -985,7 +1177,6 @@ class DatabaseHelper { ); } - /// Retrieves all submission log entries, optionally filtered by module. Future>?> loadSubmissionLogs({String? module}) async { final db = await database; List> maps; @@ -1008,9 +1199,6 @@ class DatabaseHelper { return null; } - // START CHANGE: Added helper methods for the new preference tables. - - /// Saves or updates a module's master submission preferences. Future saveModulePreference({ required String moduleName, required bool isApiEnabled, @@ -1028,7 +1216,6 @@ class DatabaseHelper { ); } - /// Retrieves a module's master submission preferences. Future?> getModulePreference(String moduleName) async { final db = await database; final result = await db.query( @@ -1044,16 +1231,13 @@ class DatabaseHelper { 'is_ftp_enabled': (row['is_ftp_enabled'] as int) == 1, }; } - return null; // Return null if no specific preference is set + return null; } - /// Saves the complete set of API links for a specific module, replacing any old ones. Future saveApiLinksForModule(String moduleName, List> links) async { final db = await database; await db.transaction((txn) async { - // First, delete all existing links for this module await txn.delete(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); - // Then, insert all the new links for (final link in links) { await txn.insert(_moduleApiLinksTable, { 'module_name': moduleName, @@ -1064,7 +1248,6 @@ class DatabaseHelper { }); } - /// Saves the complete set of FTP links for a specific module, replacing any old ones. Future saveFtpLinksForModule(String moduleName, List> links) async { final db = await database; await db.transaction((txn) async { @@ -1079,7 +1262,6 @@ class DatabaseHelper { }); } - /// Retrieves all API links for a specific module, regardless of enabled status. Future>> getAllApiLinksForModule(String moduleName) async { final db = await database; final result = await db.query(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); @@ -1089,7 +1271,6 @@ class DatabaseHelper { }).toList(); } - /// Retrieves all FTP links for a specific module, regardless of enabled status. Future>> getAllFtpLinksForModule(String moduleName) async { final db = await database; final result = await db.query(_moduleFtpLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); @@ -1098,6 +1279,4 @@ class DatabaseHelper { 'is_enabled': (row['is_enabled'] as int) == 1, }).toList(); } - -// END CHANGE } \ No newline at end of file diff --git a/lib/services/base_api_service.dart b/lib/services/base_api_service.dart index 4077e3c..ea8bcec 100644 --- a/lib/services/base_api_service.dart +++ b/lib/services/base_api_service.dart @@ -1,7 +1,9 @@ // lib/services/base_api_service.dart import 'dart:convert'; -import 'dart:io'; +import 'dart:io'; // Import for SocketException check +import 'dart:async'; // Import for TimeoutException check + import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; @@ -34,9 +36,15 @@ class BaseApiService { final response = await http.get(url, headers: await _getJsonHeaders()) .timeout(const Duration(seconds: 60)); return _handleResponse(response); + } on SocketException catch (e) { + debugPrint('BaseApiService GET network error: $e'); + rethrow; // Re-throw network-related exceptions to be caught by higher-level logic (e.g., login screen) + } on TimeoutException catch (e) { + debugPrint('BaseApiService GET timeout error: $e'); + rethrow; // Re-throw timeout exceptions } catch (e) { - debugPrint('GET request to $baseUrl failed: $e'); - return {'success': false, 'message': 'Network error or timeout: $e'}; + debugPrint('GET request to $baseUrl failed with general error: $e'); + return {'success': false, 'message': 'An unexpected error occurred: $e'}; } } @@ -49,10 +57,16 @@ class BaseApiService { url, headers: await _getJsonHeaders(), body: jsonEncode(body), - ).timeout(const Duration(seconds: 60)); + ).timeout(const Duration(seconds: 60)); // Note: login.dart applies its own shorter timeout over this. return _handleResponse(response); + } on SocketException catch (e) { + debugPrint('BaseApiService POST network error: $e'); + rethrow; // Re-throw network-related exceptions to be caught by higher-level logic (e.g., login screen) + } on TimeoutException catch (e) { + debugPrint('BaseApiService POST timeout error: $e'); + rethrow; // Re-throw timeout exceptions } catch (e) { - debugPrint('POST to $baseUrl failed. Error: $e'); + debugPrint('POST to $baseUrl failed with general error: $e'); return {'success': false, 'message': 'API connection failed: $e'}; } } @@ -92,6 +106,12 @@ class BaseApiService { final responseBody = await streamedResponse.stream.bytesToString(); return _handleResponse(http.Response(responseBody, streamedResponse.statusCode)); + } on SocketException catch (e) { + debugPrint('BaseApiService Multipart network error: $e'); + rethrow; // Re-throw network-related exceptions + } on TimeoutException catch (e) { + debugPrint('BaseApiService Multipart timeout error: $e'); + rethrow; // Re-throw timeout exceptions } catch (e, s) { debugPrint('Multipart upload to $baseUrl failed. Error: $e'); debugPrint('Stack trace: $s'); @@ -102,14 +122,26 @@ class BaseApiService { Map _handleResponse(http.Response response) { debugPrint('Handling response. Status: ${response.statusCode}, Body: ${response.body}'); try { + // Try to parse the response body as JSON. final Map responseData = jsonDecode(response.body); + if (response.statusCode >= 200 && response.statusCode < 300) { - if (responseData['status'] == 'success' || responseData['success'] == true) { - return {'success': true, 'data': responseData['data'], 'message': responseData['message']}; - } else { - return {'success': false, 'message': responseData['message'] ?? 'An unknown API error occurred.'}; + // Successful API call (2xx status code) + // Check application-level success flag if one exists in your API standard. + // Assuming your API returns {'status': 'success', 'data': ...} or {'success': true, 'data': ...} + if (responseData.containsKey('success') && responseData['success'] == false) { + return {'success': false, 'message': responseData['message'] ?? 'API indicated failure.'}; } + // If no explicit failure flag, or if success=true, return success. + // Adjust logic based on your API's specific response structure. + return { + 'success': true, + 'data': responseData['data'], // Assumes data is nested under 'data' key + 'message': responseData['message'] ?? 'Success' + }; } else { + // API returned an error code (4xx, 5xx). + // Return the error message provided by the server. return {'success': false, 'message': responseData['message'] ?? 'Server error: ${response.statusCode}'}; } } catch (e) { diff --git a/lib/services/local_storage_service.dart b/lib/services/local_storage_service.dart index ed68806..89c6e2d 100644 --- a/lib/services/local_storage_service.dart +++ b/lib/services/local_storage_service.dart @@ -120,11 +120,6 @@ class LocalStorageService { if (serializableData.containsKey(key) && serializableData[key] is File) { final newPath = await copyImageToLocal(serializableData[key]); serializableData['${key}Path'] = newPath; // Creates 'imageFrontPath', etc. - // Note: DO NOT remove the original key here if it holds a File object. - // The copy is needed for local storage/DB logging, but the File object must stay - // on the key if other process calls `toMap()` again. - // However, based on the previous logic, we rely on the caller passing a map - // that separates File objects from paths for DB logging. } } @@ -142,8 +137,6 @@ class LocalStorageService { serializableData['collectionData'] = collectionMap; } - // CRITICAL FIX: Ensure the JSON data only contains serializable (non-File) objects - // We must strip the File objects before encoding, as they have now been copied. final Map finalData = Map.from(serializableData); // Recursive helper to remove File objects before JSON encoding @@ -154,12 +147,7 @@ class LocalStorageService { }); } - // Since the caller (_toMapForLocalSave) only passes a map with File objects on the image keys, - // and paths on the *Path keys*, simply removing the File keys and encoding is sufficient. - finalData.removeWhere((key, value) => value is File); - if (finalData.containsKey('collectionData') && finalData['collectionData'] is Map) { - cleanMap(finalData['collectionData'] as Map); - } + cleanMap(finalData); final jsonFile = File(p.join(eventDir.path, 'data.json')); @@ -243,13 +231,25 @@ class LocalStorageService { jsonData['serverConfigName'] = serverName; jsonData['selectedStation'] = data.selectedStation; + jsonData['selectedClassification'] = data.selectedClassification; + jsonData['secondSampler'] = data.secondSampler; + final imageFiles = data.toImageFiles(); for (var entry in imageFiles.entries) { final File? imageFile = entry.value; - if (imageFile != null) { - final String originalFileName = p.basename(imageFile.path); - final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName)); - jsonData[entry.key] = newFile.path; + if (imageFile != null && imageFile.path.isNotEmpty) { + try { + if (p.dirname(imageFile.path) == eventDir.path) { + jsonData[entry.key] = imageFile.path; + } else { + final String originalFileName = p.basename(imageFile.path); + final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName)); + jsonData[entry.key] = newFile.path; + } + } catch (e) { + debugPrint("Error processing image file ${imageFile.path}: $e"); + jsonData[entry.key] = null; + } } } @@ -319,7 +319,6 @@ class LocalStorageService { // Part 4: Marine In-Situ Specific Methods (LOGGING RESTORED) // ======================================================================= - // --- MODIFIED: Removed leading underscore to make the method public --- Future getInSituBaseDir({required String serverName}) async { final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName); if (mmsv4Dir == null) return null; @@ -348,17 +347,31 @@ class LocalStorageService { await eventDir.create(recursive: true); } - final Map jsonData = { ...data.toApiFormData(), 'submissionStatus': data.submissionStatus, 'submissionMessage': data.submissionMessage, 'reportId': data.reportId }; + // --- START FIX: Explicitly include the final status and message --- + // This ensures the status calculated in the service layer is saved correctly. + final Map jsonData = data.toDbJson(); + jsonData['submissionStatus'] = data.submissionStatus; + jsonData['submissionMessage'] = data.submissionMessage; + // --- END FIX --- + jsonData['serverConfigName'] = serverName; - jsonData['selectedStation'] = data.selectedStation; final imageFiles = data.toApiImageFiles(); for (var entry in imageFiles.entries) { final File? imageFile = entry.value; - if (imageFile != null) { - final String originalFileName = p.basename(imageFile.path); - final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName)); - jsonData[entry.key] = newFile.path; + if (imageFile != null && imageFile.path.isNotEmpty) { + try { + if (p.dirname(imageFile.path) == eventDir.path) { + jsonData[entry.key] = imageFile.path; + } else { + final String originalFileName = p.basename(imageFile.path); + final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName)); + jsonData[entry.key] = newFile.path; + } + } catch (e) { + debugPrint("Error processing In-Situ image file ${imageFile.path}: $e"); + jsonData[entry.key] = null; + } } } @@ -462,19 +475,26 @@ class LocalStorageService { await eventDir.create(recursive: true); } - final Map jsonData = { ...data.toApiFormData(), 'submissionStatus': data.submissionStatus, 'submissionMessage': data.submissionMessage, 'reportId': data.reportId }; + // --- START: MODIFIED TO USE toMap() FOR COMPLETE DATA SERIALIZATION --- + final Map jsonData = data.toMap(); jsonData['serverConfigName'] = serverName; - jsonData['selectedStation'] = data.selectedStation; final imageFiles = data.toApiImageFiles(); for (var entry in imageFiles.entries) { final File? imageFile = entry.value; if (imageFile != null) { final String originalFileName = p.basename(imageFile.path); - final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName)); - jsonData[entry.key] = newFile.path; + if (p.dirname(imageFile.path) == eventDir.path) { + // If file is already in the correct directory, just store the path + jsonData[entry.key] = imageFile.path; + } else { + // Otherwise, copy it to the permanent directory + final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName)); + jsonData[entry.key] = newFile.path; + } } } + // --- END: MODIFIED TO USE toMap() FOR COMPLETE DATA SERIALIZATION --- final jsonFile = File(p.join(eventDir.path, 'data.json')); await jsonFile.writeAsString(jsonEncode(jsonData)); diff --git a/lib/services/marine_in_situ_sampling_service.dart b/lib/services/marine_in_situ_sampling_service.dart index 3009c87..89c27b4 100644 --- a/lib/services/marine_in_situ_sampling_service.dart +++ b/lib/services/marine_in_situ_sampling_service.dart @@ -2,18 +2,21 @@ import 'dart:async'; import 'dart:io'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:path/path.dart' as path; +import 'package:path/path.dart' as p; import 'package:image/image.dart' as img; import 'package:geolocator/geolocator.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:usb_serial/usb_serial.dart'; -import 'dart:convert'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:provider/provider.dart'; +import '../auth_provider.dart'; import 'location_service.dart'; import '../models/in_situ_sampling_data.dart'; import '../bluetooth/bluetooth_manager.dart'; @@ -25,6 +28,7 @@ import 'api_service.dart'; import 'submission_api_service.dart'; import 'submission_ftp_service.dart'; import 'telegram_service.dart'; +import 'retry_service.dart'; /// A dedicated service to handle all business logic for the Marine In-Situ Sampling feature. @@ -42,10 +46,9 @@ class MarineInSituSamplingService { final LocalStorageService _localStorageService = LocalStorageService(); final ServerConfigService _serverConfigService = ServerConfigService(); final DatabaseHelper _dbHelper = DatabaseHelper(); - // MODIFIED: Declare the service, but do not initialize it here. + final RetryService _retryService = RetryService(); final TelegramService _telegramService; - // ADDED: A constructor to accept the global TelegramService instance. MarineInSituSamplingService(this._telegramService); static const platform = MethodChannel('com.example.environment_monitoring_app/usb'); @@ -83,7 +86,7 @@ class MarineInSituSamplingService { final stationCode = data.selectedStation?['man_station_code'] ?? 'NA'; final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-'); final newFileName = "${stationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg"; - final filePath = path.join(tempDir.path, newFileName); + final filePath = p.join(tempDir.path, newFileName); return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); } @@ -158,59 +161,206 @@ class MarineInSituSamplingService { _serialManager.dispose(); } - // --- Data Submission --- Future> submitInSituSample({ required InSituSamplingData data, required List>? appSettings, + required AuthProvider authProvider, + BuildContext? context, + String? logDirectory, }) async { const String moduleName = 'marine_in_situ'; - final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default'; + final connectivityResult = await Connectivity().checkConnectivity(); + bool isOnline = connectivityResult != ConnectivityResult.none; + bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false); + + if (isOnline && isOfflineSession) { + debugPrint("In-Situ submission online during offline session. Attempting auto-relogin..."); + final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession(); + if (transitionSuccess) { + isOfflineSession = false; + } else { + isOnline = false; + } + } + + if (isOnline && !isOfflineSession) { + debugPrint("Proceeding with direct ONLINE In-Situ submission..."); + return await _performOnlineSubmission( + data: data, + appSettings: appSettings, + moduleName: moduleName, + authProvider: authProvider, + logDirectory: logDirectory, + ); + } else { + debugPrint("Proceeding with OFFLINE In-Situ queuing mechanism..."); + return await _performOfflineQueuing( + data: data, + moduleName: moduleName, + ); + } + } + + Future> _performOnlineSubmission({ + required InSituSamplingData data, + required List>? appSettings, + required String moduleName, + required AuthProvider authProvider, + String? logDirectory, + }) async { + final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default'; final imageFilesWithNulls = data.toApiImageFiles(); imageFilesWithNulls.removeWhere((key, value) => value == null); final Map finalImageFiles = imageFilesWithNulls.cast(); - // START CHANGE: Implement the correct two-step submission process. - // Step 1A: Submit form data as JSON. - debugPrint("Step 1A: Submitting In-Situ form data..."); - final apiDataResult = await _submissionApiService.submitPost( - moduleName: moduleName, - endpoint: 'marine/manual/sample', - body: data.toApiFormData(), + bool anyApiSuccess = false; + Map apiDataResult = {}; + Map apiImageResult = {}; + + try { + apiDataResult = await _submissionApiService.submitPost( + moduleName: moduleName, + endpoint: 'marine/manual/sample', + body: data.toApiFormData(), + ); + + if (apiDataResult['success'] == false && + (apiDataResult['message'] as String?)?.contains('Unauthorized') == true) { + debugPrint("API submission failed with Unauthorized. Attempting silent relogin..."); + final bool reloginSuccess = await authProvider.attemptSilentRelogin(); + + if (reloginSuccess) { + debugPrint("Silent relogin successful. Retrying data submission..."); + apiDataResult = await _submissionApiService.submitPost( + moduleName: moduleName, + endpoint: 'marine/manual/sample', + body: data.toApiFormData(), + ); + } + } + + if (apiDataResult['success'] == true) { + anyApiSuccess = true; + data.reportId = apiDataResult['data']?['man_id']?.toString(); + + if (data.reportId != null) { + if (finalImageFiles.isNotEmpty) { + apiImageResult = await _submissionApiService.submitMultipart( + moduleName: moduleName, + endpoint: 'marine/manual/images', + fields: {'man_id': data.reportId!}, + files: finalImageFiles, + ); + if (apiImageResult['success'] != true) { + anyApiSuccess = false; + } + } + } else { + anyApiSuccess = false; + apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.'; + } + } + } on SocketException catch (e) { + final errorMessage = "API submission failed with network error: $e"; + debugPrint(errorMessage); + anyApiSuccess = false; + apiDataResult = {'success': false, 'message': errorMessage}; + await _retryService.addApiToQueue(endpoint: 'marine/manual/sample', method: 'POST', body: data.toApiFormData()); + if (finalImageFiles.isNotEmpty && data.reportId != null) { + await _retryService.addApiToQueue(endpoint: 'marine/manual/images', method: 'POST_MULTIPART', fields: {'man_id': data.reportId!}, files: finalImageFiles); + } + } on TimeoutException catch (e) { + final errorMessage = "API submission timed out: $e"; + debugPrint(errorMessage); + anyApiSuccess = false; + apiDataResult = {'success': false, 'message': errorMessage}; + await _retryService.addApiToQueue(endpoint: 'marine/manual/sample', method: 'POST', body: data.toApiFormData()); + } + + Map ftpResults = {'statuses': []}; + bool anyFtpSuccess = false; + try { + ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName); + anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured'); + } on SocketException catch (e) { + debugPrint("FTP submission failed with network error: $e"); + anyFtpSuccess = false; + } on TimeoutException catch (e) { + debugPrint("FTP submission timed out: $e"); + anyFtpSuccess = false; + } + + final bool overallSuccess = anyApiSuccess || anyFtpSuccess; + String finalMessage; + String finalStatus; + + if (anyApiSuccess && anyFtpSuccess) { + finalMessage = 'Data submitted successfully to all destinations.'; + finalStatus = 'S4'; + } else if (anyApiSuccess && !anyFtpSuccess) { + finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.'; + finalStatus = 'S3'; + } else if (!anyApiSuccess && anyFtpSuccess) { + finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.'; + finalStatus = 'L4'; + } else { + finalMessage = 'All submission attempts failed and have been queued for retry.'; + finalStatus = 'L1'; + } + + await _logAndSave( + data: data, + status: finalStatus, + message: finalMessage, + apiResults: [apiDataResult, apiImageResult], + ftpStatuses: ftpResults['statuses'], + serverName: serverName, + finalImageFiles: finalImageFiles, + logDirectory: logDirectory, ); - // If the initial data submission fails, log and exit early. - if (apiDataResult['success'] != true) { - data.submissionStatus = 'L1'; - data.submissionMessage = apiDataResult['message'] ?? 'Failed to submit form data.'; - await _logAndSave(data: data, apiResults: [apiDataResult], ftpStatuses: [], serverName: serverName, finalImageFiles: finalImageFiles); - return {'success': false, 'message': data.submissionMessage}; + if (overallSuccess) { + _handleInSituSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty); } - final reportId = apiDataResult['data']?['man_id']?.toString(); - if (reportId == null) { - data.submissionStatus = 'L1'; - data.submissionMessage = 'API Error: Missing man_id in response.'; - await _logAndSave(data: data, apiResults: [apiDataResult], ftpStatuses: [], serverName: serverName, finalImageFiles: finalImageFiles); - return {'success': false, 'message': data.submissionMessage}; - } - data.reportId = reportId; + return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId}; + } - // Step 1B: Submit images as multipart/form-data. - debugPrint("Step 1B: Submitting In-Situ images..."); - Map apiImageResult = {'success': true, 'message': 'No images to upload.'}; - if (finalImageFiles.isNotEmpty) { - apiImageResult = await _submissionApiService.submitMultipart( - moduleName: moduleName, - endpoint: 'marine/manual/images', // Assumed endpoint for uploadManualImages - fields: {'man_id': reportId}, - files: finalImageFiles, - ); - } - final bool apiSuccess = apiImageResult['success'] == true; - // END CHANGE + Future> _performOfflineQueuing({ + required InSituSamplingData data, + required String moduleName, + }) async { + final serverConfig = await _serverConfigService.getActiveApiConfig(); + final serverName = serverConfig?['config_name'] as String? ?? 'Default'; - // Step 2: FTP Submission + // Set initial status before first save + data.submissionStatus = 'L1'; + data.submissionMessage = 'Submission queued due to being offline.'; + + final String? localLogPath = await _localStorageService.saveInSituSamplingData(data, serverName: serverName); + + if (localLogPath == null) { + const message = "Failed to save submission to local device storage."; + await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}); + return {'success': false, 'message': message}; + } + + await _retryService.queueTask( + type: 'insitu_submission', + payload: { + 'module': moduleName, + 'localLogPath': localLogPath, + 'serverConfig': serverConfig, + }, + ); + + // No need to save again, initial save already has the L1 status. + const successMessage = "No internet connection. Submission has been saved and queued for upload."; + return {'success': true, 'message': successMessage}; + } + + Future> _generateAndUploadFtpFiles(InSituSamplingData data, Map imageFiles, String serverName, String moduleName) async { final stationCode = data.selectedStation?['man_station_code'] ?? 'NA'; final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); final baseFileName = '${stationCode}_$fileTimestamp'; @@ -220,77 +370,86 @@ class MarineInSituSamplingService { module: 'marine', subModule: 'marine_in_situ_sampling', ); - - final Directory? localSubmissionDir = logDirectory != null ? Directory(path.join(logDirectory.path, data.reportId ?? baseFileName)) : null; + final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null; if (localSubmissionDir != null && !await localSubmissionDir.exists()) { await localSubmissionDir.create(recursive: true); } final dataZip = await _zippingService.createDataZip( - jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, - baseFileName: baseFileName, - destinationDir: localSubmissionDir); + jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, + baseFileName: baseFileName, + destinationDir: localSubmissionDir, + ); Map ftpDataResult = {'success': true, 'statuses': []}; if (dataZip != null) { ftpDataResult = await _submissionFtpService.submit( - moduleName: moduleName, - fileToUpload: dataZip, - remotePath: '/${path.basename(dataZip.path)}'); + moduleName: moduleName, + fileToUpload: dataZip, + remotePath: '/${p.basename(dataZip.path)}', + ); } final imageZip = await _zippingService.createImageZip( - imageFiles: finalImageFiles.values.toList(), - baseFileName: baseFileName, - destinationDir: localSubmissionDir); + imageFiles: imageFiles.values.toList(), + baseFileName: baseFileName, + destinationDir: localSubmissionDir, + ); Map ftpImageResult = {'success': true, 'statuses': []}; if (imageZip != null) { ftpImageResult = await _submissionFtpService.submit( - moduleName: moduleName, - fileToUpload: imageZip, - remotePath: '/${path.basename(imageZip.path)}'); - } - final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true); - - // Step 3: Finalize and Log - String finalStatus; - String finalMessage; - if (apiSuccess) { - finalStatus = ftpSuccess ? 'S4' : 'S3'; - finalMessage = ftpSuccess ? 'Data submitted successfully.' : 'Data sent to API. FTP upload failed/queued.'; - } else { - finalStatus = ftpSuccess ? 'L4' : 'L1'; - finalMessage = ftpSuccess ? 'API failed, but files sent to FTP.' : 'All submission attempts failed.'; + moduleName: moduleName, + fileToUpload: imageZip, + remotePath: '/${p.basename(imageZip.path)}', + ); } - data.submissionStatus = finalStatus; - data.submissionMessage = finalMessage; - - await _logAndSave( - data: data, - apiResults: [apiDataResult, apiImageResult], // Log both API steps - ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], - serverName: serverName, - finalImageFiles: finalImageFiles, - ); - - if (apiSuccess || ftpSuccess) { - _handleInSituSuccessAlert(data, appSettings, isDataOnly: !apiSuccess); - } - - return {'success': apiSuccess || ftpSuccess, 'message': finalMessage}; + return { + 'statuses': >[ + ...(ftpDataResult['statuses'] as List), + ...(ftpImageResult['statuses'] as List), + ], + }; } - // Helper function to centralize logging and local saving. Future _logAndSave({ required InSituSamplingData data, + required String status, + required String message, required List> apiResults, required List> ftpStatuses, required String serverName, required Map finalImageFiles, + String? logDirectory, }) async { + data.submissionStatus = status; + data.submissionMessage = message; final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); - await _localStorageService.saveInSituSamplingData(data, serverName: serverName); + if (logDirectory != null) { + final Map updatedLogData = data.toDbJson(); + + // --- START FIX: Explicitly add final status and message to the update map --- + updatedLogData['submissionStatus'] = status; + updatedLogData['submissionMessage'] = message; + // --- END FIX --- + + updatedLogData['logDirectory'] = logDirectory; + updatedLogData['serverConfigName'] = serverName; + updatedLogData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList()); + updatedLogData['ftp_status'] = jsonEncode(ftpStatuses); + + final imageFilePaths = data.toApiImageFiles(); + imageFilePaths.forEach((key, file) { + if (file != null) { + updatedLogData[key] = file.path; + } + }); + + await _localStorageService.updateInSituLog(updatedLogData); + } else { + await _localStorageService.saveInSituSamplingData(data, serverName: serverName); + } + final logData = { 'submission_id': data.reportId ?? fileTimestamp, diff --git a/lib/services/marine_tarball_sampling_service.dart b/lib/services/marine_tarball_sampling_service.dart index 095a78e..82d682f 100644 --- a/lib/services/marine_tarball_sampling_service.dart +++ b/lib/services/marine_tarball_sampling_service.dart @@ -2,8 +2,12 @@ import 'dart:io'; import 'dart:convert'; +import 'dart:async'; // Added for TimeoutException import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter/material.dart'; import 'package:environment_monitoring_app/models/tarball_data.dart'; import 'package:environment_monitoring_app/services/local_storage_service.dart'; @@ -13,6 +17,8 @@ import 'package:environment_monitoring_app/services/api_service.dart'; import 'package:environment_monitoring_app/services/submission_api_service.dart'; import 'package:environment_monitoring_app/services/submission_ftp_service.dart'; import 'package:environment_monitoring_app/services/telegram_service.dart'; +import 'package:environment_monitoring_app/services/retry_service.dart'; +import 'package:environment_monitoring_app/auth_provider.dart'; /// A dedicated service to handle all business logic for the Marine Tarball Sampling feature. class MarineTarballSamplingService { @@ -22,57 +28,210 @@ class MarineTarballSamplingService { final LocalStorageService _localStorageService = LocalStorageService(); final ServerConfigService _serverConfigService = ServerConfigService(); final DatabaseHelper _dbHelper = DatabaseHelper(); - // MODIFIED: Declare the service, but do not initialize it here. + final RetryService _retryService = RetryService(); final TelegramService _telegramService; - // ADDED: A constructor to accept the global TelegramService instance. MarineTarballSamplingService(this._telegramService); Future> submitTarballSample({ required TarballSamplingData data, required List>? appSettings, + required BuildContext context, }) async { const String moduleName = 'marine_tarball'; + final authProvider = Provider.of(context, listen: false); + + final connectivityResult = await Connectivity().checkConnectivity(); + bool isOnline = connectivityResult != ConnectivityResult.none; + bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false); + + if (isOnline && isOfflineSession) { + debugPrint("Submission initiated online during an offline session. Attempting auto-relogin..."); + final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession(); + if (transitionSuccess) { + isOfflineSession = false; + } else { + isOnline = false; + } + } + + if (isOnline && !isOfflineSession) { + debugPrint("Proceeding with direct ONLINE submission..."); + return await _performOnlineSubmission( + data: data, + appSettings: appSettings, + moduleName: moduleName, + authProvider: authProvider, + ); + } else { + debugPrint("Proceeding with OFFLINE queuing mechanism..."); + return await _performOfflineQueuing( + data: data, + moduleName: moduleName, + ); + } + } + + Future> _performOnlineSubmission({ + required TarballSamplingData data, + required List>? appSettings, + required String moduleName, + required AuthProvider authProvider, + }) async { final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default'; + final imageFiles = data.toImageFiles()..removeWhere((key, value) => value == null); + final finalImageFiles = imageFiles.cast(); - final imageFilesWithNulls = data.toImageFiles(); - imageFilesWithNulls.removeWhere((key, value) => value == null); - final Map finalImageFiles = imageFilesWithNulls.cast(); + bool anyApiSuccess = false; + Map apiDataResult = {}; + Map apiImageResult = {}; - // START CHANGE: Revert to the correct two-step API submission process - // --- Step 1A: API Data Submission --- - debugPrint("Step 1A: Submitting Tarball form data..."); - final apiDataResult = await _submissionApiService.submitPost( - moduleName: moduleName, - endpoint: 'marine/tarball/sample', - body: data.toFormData(), - ); + // --- START: MODIFICATION FOR GRANULAR ERROR HANDLING --- + // Step 1: Attempt API Submission in its own try-catch block. + try { + apiDataResult = await _submissionApiService.submitPost( + moduleName: moduleName, + endpoint: 'marine/tarball/sample', + body: data.toFormData(), + ); - if (apiDataResult['success'] != true) { - // If the initial data submission fails, log and exit early. - await _logAndSave(data: data, status: 'L1', message: apiDataResult['message']!, apiResults: [apiDataResult], ftpStatuses: [], serverName: serverName, finalImageFiles: finalImageFiles); - return {'success': false, 'message': apiDataResult['message']}; + if (apiDataResult['success'] == false && + (apiDataResult['message'] as String?)?.contains('Unauthorized') == true) { + debugPrint("API submission failed with Unauthorized. Attempting silent relogin..."); + final bool reloginSuccess = await authProvider.attemptSilentRelogin(); + + if (reloginSuccess) { + debugPrint("Silent relogin successful. Retrying data submission..."); + apiDataResult = await _submissionApiService.submitPost( + moduleName: moduleName, + endpoint: 'marine/tarball/sample', + body: data.toFormData(), + ); + } + } + + if (apiDataResult['success'] == true) { + anyApiSuccess = true; + data.reportId = apiDataResult['data']?['autoid']?.toString(); + + if (data.reportId != null) { + apiImageResult = await _submissionApiService.submitMultipart( + moduleName: moduleName, + endpoint: 'marine/tarball/images', + fields: {'autoid': data.reportId!}, + files: finalImageFiles, + ); + if (apiImageResult['success'] != true) { + anyApiSuccess = false; // Downgrade success if images fail + } + } else { + anyApiSuccess = false; + apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.'; + } + } + } on SocketException catch (e) { + final errorMessage = "API submission failed with network error: $e"; + debugPrint(errorMessage); + anyApiSuccess = false; + apiDataResult = {'success': false, 'message': errorMessage}; + // Manually queue the failed API tasks since the service might not have been able to + await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData()); + if(finalImageFiles.isNotEmpty && data.reportId != null) { + await _retryService.addApiToQueue(endpoint: 'marine/tarball/images', method: 'POST_MULTIPART', fields: {'autoid': data.reportId!}, files: finalImageFiles); + } + } on TimeoutException catch (e) { + final errorMessage = "API submission timed out: $e"; + debugPrint(errorMessage); + anyApiSuccess = false; + apiDataResult = {'success': false, 'message': errorMessage}; + await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData()); } - final recordId = apiDataResult['data']?['autoid']?.toString(); - if (recordId == null) { - await _logAndSave(data: data, status: 'L1', message: 'API Error: Missing record ID.', apiResults: [apiDataResult], ftpStatuses: [], serverName: serverName, finalImageFiles: finalImageFiles); - return {'success': false, 'message': 'API Error: Missing record ID.'}; + // Step 2: Attempt FTP Submission in its own try-catch block. + // This code will now run even if the API submission above failed. + Map ftpResults = {'statuses': []}; + bool anyFtpSuccess = false; + try { + ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName); + anyFtpSuccess = !ftpResults['statuses'].any((status) => status['success'] == false); + } on SocketException catch (e) { + debugPrint("FTP submission failed with network error: $e"); + anyFtpSuccess = false; + // Note: The underlying SubmissionFtpService already queues failed uploads, + // so we just need to catch the error to prevent a crash. + } on TimeoutException catch (e) { + debugPrint("FTP submission timed out: $e"); + anyFtpSuccess = false; } - data.reportId = recordId; + // --- END: MODIFICATION FOR GRANULAR ERROR HANDLING --- - // --- Step 1B: API Image Submission --- - debugPrint("Step 1B: Submitting Tarball images..."); - final apiImageResult = await _submissionApiService.submitMultipart( - moduleName: moduleName, - endpoint: 'marine/tarball/images', - fields: {'autoid': recordId}, - files: finalImageFiles, + // Step 3: Determine final status based on the outcomes of the independent steps. + final bool overallSuccess = anyApiSuccess || anyFtpSuccess; + String finalMessage; + String finalStatus; + + if (anyApiSuccess && anyFtpSuccess) { + finalMessage = 'Data submitted successfully to all destinations.'; + finalStatus = 'S4'; + } else if (anyApiSuccess && !anyFtpSuccess) { + finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.'; + finalStatus = 'S3'; + } else if (!anyApiSuccess && anyFtpSuccess) { + finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.'; + finalStatus = 'L4'; + } else { + finalMessage = 'All submission attempts failed and have been queued for retry.'; + finalStatus = 'L1'; + } + + await _logAndSave( + data: data, + status: finalStatus, + message: finalMessage, + apiResults: [apiDataResult, apiImageResult], + ftpStatuses: ftpResults['statuses'], + serverName: serverName, + finalImageFiles: finalImageFiles, ); - final bool apiSuccess = apiImageResult['success'] == true; - // END CHANGE - // --- Step 2: FTP Submission --- + if (overallSuccess) { + _handleTarballSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty); + } + + return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId}; + } + + Future> _performOfflineQueuing({ + required TarballSamplingData data, + required String moduleName, + }) async { + final serverConfig = await _serverConfigService.getActiveApiConfig(); + final serverName = serverConfig?['config_name'] as String? ?? 'Default'; + + final String? localLogPath = await _localStorageService.saveTarballSamplingData(data, serverName: serverName); + + if (localLogPath == null) { + const message = "Failed to save submission to local device storage."; + await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}); + return {'success': false, 'message': message}; + } + + await _retryService.queueTask( + type: 'tarball_submission', + payload: { + 'module': moduleName, + 'localLogPath': localLogPath, + 'serverConfig': serverConfig, + }, + ); + + const successMessage = "No internet connection. Submission has been saved and queued for upload."; + await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}); + + return {'success': true, 'message': successMessage}; + } + + Future> _generateAndUploadFtpFiles(TarballSamplingData data, Map imageFiles, String serverName, String moduleName) async { final stationCode = data.selectedStation?['tbl_station_code'] ?? 'NA'; final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); final baseFileName = '${stationCode}_$fileTimestamp'; @@ -82,7 +241,6 @@ class MarineTarballSamplingService { module: 'marine', subModule: 'marine_tarball_sampling', ); - final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null; if (localSubmissionDir != null && !await localSubmissionDir.exists()) { await localSubmissionDir.create(recursive: true); @@ -93,7 +251,6 @@ class MarineTarballSamplingService { baseFileName: baseFileName, destinationDir: localSubmissionDir, ); - Map ftpDataResult = {'success': true, 'statuses': []}; if (dataZip != null) { ftpDataResult = await _submissionFtpService.submit( @@ -104,11 +261,10 @@ class MarineTarballSamplingService { } final imageZip = await _zippingService.createImageZip( - imageFiles: finalImageFiles.values.toList(), + imageFiles: imageFiles.values.toList(), baseFileName: baseFileName, destinationDir: localSubmissionDir, ); - Map ftpImageResult = {'success': true, 'statuses': []}; if (imageZip != null) { ftpImageResult = await _submissionFtpService.submit( @@ -117,37 +273,15 @@ class MarineTarballSamplingService { remotePath: '/${p.basename(imageZip.path)}', ); } - final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true); - // --- Step 3: Finalize and Log --- - String finalStatus; - String finalMessage; - if (apiSuccess) { - finalStatus = ftpSuccess ? 'S4' : 'S3'; - finalMessage = ftpSuccess ? 'Data submitted successfully.' : 'Data sent to API. FTP upload failed/queued.'; - } else { - finalStatus = ftpSuccess ? 'L4' : 'L1'; - finalMessage = ftpSuccess ? 'API failed, but files sent to FTP.' : 'All submission attempts failed.'; - } - - await _logAndSave( - data: data, - status: finalStatus, - message: finalMessage, - apiResults: [apiDataResult, apiImageResult], // Log results from both API steps - ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], - serverName: serverName, - finalImageFiles: finalImageFiles - ); - - if (apiSuccess || ftpSuccess) { - _handleTarballSuccessAlert(data, appSettings, isDataOnly: !apiSuccess); - } - - return {'success': apiSuccess || ftpSuccess, 'message': finalMessage, 'reportId': data.reportId}; + return { + 'statuses': >[ + ...(ftpDataResult['statuses'] as List), + ...(ftpImageResult['statuses'] as List), + ], + }; } - // Added a helper to reduce code duplication in the main submit method Future _logAndSave({ required TarballSamplingData data, required String status, diff --git a/lib/services/retry_service.dart b/lib/services/retry_service.dart index 05c2c79..1d14b00 100644 --- a/lib/services/retry_service.dart +++ b/lib/services/retry_service.dart @@ -3,42 +3,74 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; + +import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart'; +import 'package:environment_monitoring_app/services/marine_in_situ_sampling_service.dart'; +import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.dart'; // ADDED +import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart'; // ADDED 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/ftp_service.dart'; -// START CHANGE: Added imports to get server configurations import 'package:environment_monitoring_app/services/server_config_service.dart'; -// END CHANGE +import 'package:environment_monitoring_app/auth_provider.dart'; -/// A dedicated service to manage the queue of failed API and FTP requests -/// for manual resubmission. +/// A dedicated service to manage the queue of failed API, FTP, and complex submission tasks. class RetryService { - // Use singleton instances to avoid re-creating services unnecessarily. final DatabaseHelper _dbHelper = DatabaseHelper(); final BaseApiService _baseApiService = BaseApiService(); final FtpService _ftpService = FtpService(); - // START CHANGE: Add instance of ServerConfigService final ServerConfigService _serverConfigService = ServerConfigService(); - // END CHANGE + + // --- START: MODIFICATION FOR HANDLING COMPLEX TASKS --- + // These services will be provided after the RetryService is created. + MarineInSituSamplingService? _marineInSituService; + RiverInSituSamplingService? _riverInSituService; // ADDED + AuthProvider? _authProvider; + + // Call this method from your main app setup to provide the necessary services. + void initialize({ + required MarineInSituSamplingService marineInSituService, + required RiverInSituSamplingService riverInSituService, // ADDED + required AuthProvider authProvider, + }) { + _marineInSituService = marineInSituService; + _riverInSituService = riverInSituService; // ADDED + _authProvider = authProvider; + } + // --- END: MODIFICATION FOR HANDLING COMPLEX TASKS --- + + + /// Adds a generic, complex task to the queue, to be handled by a background processor. + Future queueTask({ + required String type, + required Map payload, + }) async { + await _dbHelper.queueFailedRequest({ + 'type': type, + 'endpoint_or_path': 'N/A', + 'payload': jsonEncode(payload), + 'timestamp': DateTime.now().toIso8601String(), + 'status': 'pending', + }); + debugPrint("Task of type '$type' has been queued for background processing."); + } /// Adds a failed API request to the local database queue. Future addApiToQueue({ required String endpoint, - required String method, // e.g., 'POST' or 'POST_MULTIPART' + required String method, Map? body, Map? fields, Map? files, }) async { - // We must convert File objects to their string paths before saving to JSON. final serializableFiles = files?.map((key, value) => MapEntry(key, value.path)); - final payload = { 'method': method, 'body': body, 'fields': fields, 'files': serializableFiles, }; - await _dbHelper.queueFailedRequest({ 'type': 'api', 'endpoint_or_path': endpoint, @@ -83,11 +115,68 @@ class RetryService { final payload = jsonDecode(task['payload'] as String); try { - if (task['type'] == 'api') { + if (_authProvider == null) { + debugPrint("RetryService has not been initialized. Cannot process task."); + return false; + } + + if (task['type'] == 'insitu_submission') { + debugPrint("Retrying complex task 'insitu_submission' with ID $taskId."); + if (_marineInSituService == null) return false; + + final String logFilePath = payload['localLogPath']; + final file = File(logFilePath); + + if (!await file.exists()) { + debugPrint("Retry failed: Source log file no longer exists at $logFilePath"); + await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task + return false; + } + + final content = await file.readAsString(); + final jsonData = jsonDecode(content) as Map; + final InSituSamplingData dataToResubmit = InSituSamplingData.fromJson(jsonData); + final String logDirectoryPath = p.dirname(logFilePath); + + final result = await _marineInSituService!.submitInSituSample( + data: dataToResubmit, + appSettings: _authProvider!.appSettings, + authProvider: _authProvider!, + logDirectory: logDirectoryPath, + ); + success = result['success']; + + // --- START: ADDED LOGIC FOR RIVER IN-SITU SUBMISSION --- + } else if (task['type'] == 'river_insitu_submission') { + debugPrint("Retrying complex task 'river_insitu_submission' with ID $taskId."); + if (_riverInSituService == null) return false; + + final String logFilePath = payload['localLogPath']; + final file = File(logFilePath); + + if (!await file.exists()) { + debugPrint("Retry failed: Source log file no longer exists at $logFilePath"); + await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task + return false; + } + + final content = await file.readAsString(); + final jsonData = jsonDecode(content) as Map; + final RiverInSituSamplingData dataToResubmit = RiverInSituSamplingData.fromJson(jsonData); + final String logDirectoryPath = p.dirname(logFilePath); + + final result = await _riverInSituService!.submitData( + data: dataToResubmit, + appSettings: _authProvider!.appSettings, + authProvider: _authProvider!, + logDirectory: logDirectoryPath, + ); + success = result['success']; + // --- END: ADDED LOGIC FOR RIVER IN-SITU SUBMISSION --- + + } else if (task['type'] == 'api') { final endpoint = task['endpoint_or_path'] as String; final method = payload['method'] as String; - - // START CHANGE: Fetch the current active base URL to perform the retry final baseUrl = await _serverConfigService.getActiveApiUrl(); debugPrint("Retrying API task $taskId: $method to $baseUrl/$endpoint"); Map result; @@ -96,42 +185,31 @@ class RetryService { final Map fields = Map.from(payload['fields'] ?? {}); final Map files = (payload['files'] as Map?) ?.map((key, value) => MapEntry(key, File(value as String))) ?? {}; - result = await _baseApiService.postMultipart(baseUrl: baseUrl, endpoint: endpoint, fields: fields, files: files); - } else { // Assume 'POST' + } else { final Map body = Map.from(payload['body'] ?? {}); result = await _baseApiService.post(baseUrl, endpoint, body); } - // END CHANGE - success = result['success']; } else if (task['type'] == 'ftp') { final remotePath = task['endpoint_or_path'] as String; final localFile = File(payload['localFilePath'] as String); - debugPrint("Retrying FTP task $taskId: Uploading ${localFile.path} to $remotePath"); if (await localFile.exists()) { - // START CHANGE: On retry, attempt to upload to ALL available FTP servers. final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? []; if (ftpConfigs.isEmpty) { debugPrint("Retry failed for FTP task $taskId: No FTP configurations found."); return false; } - for (final config in ftpConfigs) { - final result = await _ftpService.uploadFile( - config: config, - fileToUpload: localFile, - remotePath: remotePath - ); + final result = await _ftpService.uploadFile(config: config, fileToUpload: localFile, remotePath: remotePath); if (result['success']) { success = true; - break; // Stop on the first successful upload + break; } } - // END CHANGE } else { debugPrint("Retry failed for FTP task $taskId: Source file no longer exists at ${localFile.path}"); success = false; diff --git a/lib/services/river_in_situ_sampling_service.dart b/lib/services/river_in_situ_sampling_service.dart index 6036e0c..81bd692 100644 --- a/lib/services/river_in_situ_sampling_service.dart +++ b/lib/services/river_in_situ_sampling_service.dart @@ -6,7 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:path/path.dart' as path; +import 'package:path/path.dart' as p; import 'package:image/image.dart' as img; import 'package:geolocator/geolocator.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -14,7 +14,10 @@ import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:usb_serial/usb_serial.dart'; import 'dart:convert'; import 'package:intl/intl.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:provider/provider.dart'; +import '../auth_provider.dart'; import 'location_service.dart'; import '../models/river_in_situ_sampling_data.dart'; import '../bluetooth/bluetooth_manager.dart'; @@ -26,6 +29,7 @@ import 'zipping_service.dart'; import 'submission_api_service.dart'; import 'submission_ftp_service.dart'; import 'telegram_service.dart'; +import 'retry_service.dart'; class RiverInSituSamplingService { @@ -38,6 +42,7 @@ class RiverInSituSamplingService { final LocalStorageService _localStorageService = LocalStorageService(); final ServerConfigService _serverConfigService = ServerConfigService(); final ZippingService _zippingService = ZippingService(); + final RetryService _retryService = RetryService(); final TelegramService _telegramService; final ImagePicker _picker = ImagePicker(); @@ -81,7 +86,7 @@ class RiverInSituSamplingService { final finalStationCode = stationCode ?? 'NA'; final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-'); final newFileName = "${finalStationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg"; - final filePath = path.join(tempDir.path, newFileName); + final filePath = p.join(tempDir.path, newFileName); return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); @@ -154,44 +159,207 @@ class RiverInSituSamplingService { _serialManager.dispose(); } - Future> submitData(RiverInSituSamplingData data, List>? appSettings) async { + Future> submitData({ + required RiverInSituSamplingData data, + required List>? appSettings, + required AuthProvider authProvider, + String? logDirectory, + }) async { const String moduleName = 'river_in_situ'; - final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default'; + final connectivityResult = await Connectivity().checkConnectivity(); + bool isOnline = connectivityResult != ConnectivityResult.none; + bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false); + + if (isOnline && isOfflineSession) { + debugPrint("River In-Situ submission online during offline session. Attempting auto-relogin..."); + final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession(); + if (transitionSuccess) { + isOfflineSession = false; + } else { + isOnline = false; + } + } + + if (isOnline && !isOfflineSession) { + debugPrint("Proceeding with direct ONLINE River In-Situ submission..."); + return await _performOnlineSubmission( + data: data, + appSettings: appSettings, + moduleName: moduleName, + authProvider: authProvider, + logDirectory: logDirectory, + ); + } else { + debugPrint("Proceeding with OFFLINE River In-Situ queuing mechanism..."); + return await _performOfflineQueuing( + data: data, + moduleName: moduleName, + ); + } + } + + Future> _performOnlineSubmission({ + required RiverInSituSamplingData data, + required List>? appSettings, + required String moduleName, + required AuthProvider authProvider, + String? logDirectory, + }) async { + final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default'; final imageFilesWithNulls = data.toApiImageFiles(); imageFilesWithNulls.removeWhere((key, value) => value == null); final Map finalImageFiles = imageFilesWithNulls.cast(); - final dataResult = await _submissionApiService.submitPost( - moduleName: moduleName, - endpoint: 'river/manual/sample', - body: data.toApiFormData(), + bool anyApiSuccess = false; + Map apiDataResult = {}; + Map apiImageResult = {}; + + try { + apiDataResult = await _submissionApiService.submitPost( + moduleName: moduleName, + endpoint: 'river/manual/sample', + body: data.toApiFormData(), + ); + + if (apiDataResult['success'] == false && + (apiDataResult['message'] as String?)?.contains('Unauthorized') == true) { + debugPrint("API submission failed with Unauthorized. Attempting silent relogin..."); + final bool reloginSuccess = await authProvider.attemptSilentRelogin(); + + if (reloginSuccess) { + debugPrint("Silent relogin successful. Retrying data submission..."); + apiDataResult = await _submissionApiService.submitPost( + moduleName: moduleName, + endpoint: 'river/manual/sample', + body: data.toApiFormData(), + ); + } + } + + if (apiDataResult['success'] == true) { + anyApiSuccess = true; + data.reportId = apiDataResult['data']?['r_man_id']?.toString(); + + if (data.reportId != null) { + if (finalImageFiles.isNotEmpty) { + apiImageResult = await _submissionApiService.submitMultipart( + moduleName: moduleName, + endpoint: 'river/manual/images', + fields: {'r_man_id': data.reportId!}, + files: finalImageFiles, + ); + if (apiImageResult['success'] != true) { + anyApiSuccess = false; + } + } + } else { + anyApiSuccess = false; + apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.'; + } + } + } on SocketException catch (e) { + final errorMessage = "API submission failed with network error: $e"; + debugPrint(errorMessage); + anyApiSuccess = false; + apiDataResult = {'success': false, 'message': errorMessage}; + await _retryService.addApiToQueue(endpoint: 'river/manual/sample', method: 'POST', body: data.toApiFormData()); + if (finalImageFiles.isNotEmpty && data.reportId != null) { + await _retryService.addApiToQueue(endpoint: 'river/manual/images', method: 'POST_MULTIPART', fields: {'r_man_id': data.reportId!}, files: finalImageFiles); + } + } on TimeoutException catch (e) { + final errorMessage = "API submission timed out: $e"; + debugPrint(errorMessage); + anyApiSuccess = false; + apiDataResult = {'success': false, 'message': errorMessage}; + await _retryService.addApiToQueue(endpoint: 'river/manual/sample', method: 'POST', body: data.toApiFormData()); + } + + Map ftpResults = {'statuses': []}; + bool anyFtpSuccess = false; + try { + ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName); + anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured'); + } on SocketException catch (e) { + debugPrint("FTP submission failed with network error: $e"); + anyFtpSuccess = false; + } on TimeoutException catch (e) { + debugPrint("FTP submission timed out: $e"); + anyFtpSuccess = false; + } + + final bool overallSuccess = anyApiSuccess || anyFtpSuccess; + String finalMessage; + String finalStatus; + + if (anyApiSuccess && anyFtpSuccess) { + finalMessage = 'Data submitted successfully to all destinations.'; + finalStatus = 'S4'; + } else if (anyApiSuccess && !anyFtpSuccess) { + finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.'; + finalStatus = 'S3'; + } else if (!anyApiSuccess && anyFtpSuccess) { + finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.'; + finalStatus = 'L4'; + } else { + finalMessage = 'All submission attempts failed and have been queued for retry.'; + finalStatus = 'L1'; + } + + await _logAndSave( + data: data, + status: finalStatus, + message: finalMessage, + apiResults: [apiDataResult, apiImageResult], + ftpStatuses: ftpResults['statuses'], + serverName: serverName, + logDirectory: logDirectory, ); - if (dataResult['success'] != true) { - await _logAndSave(data: data, status: 'L1', message: dataResult['message']!, apiResults: [dataResult], ftpStatuses: [], serverName: serverName); - // FIX: Also return the status code for the UI - return {'status': 'L1', 'success': false, 'message': dataResult['message']}; + if (overallSuccess) { + _handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty); } - final recordId = dataResult['data']?['r_man_id']?.toString(); - if (recordId == null) { - await _logAndSave(data: data, status: 'L1', message: 'API Error: Missing record ID.', apiResults: [dataResult], ftpStatuses: [], serverName: serverName); - return {'status': 'L1', 'success': false, 'message': 'API Error: Missing record ID.'}; - } - data.reportId = recordId; + return { + 'status': finalStatus, + 'success': overallSuccess, + 'message': finalMessage, + 'reportId': data.reportId + }; + } - Map imageResult = {'success': true, 'message': 'No images to upload.'}; - if (finalImageFiles.isNotEmpty) { - imageResult = await _submissionApiService.submitMultipart( - moduleName: moduleName, - endpoint: 'river/manual/images', - fields: {'r_man_id': recordId}, - files: finalImageFiles, - ); - } - final bool apiSuccess = imageResult['success'] == true; + Future> _performOfflineQueuing({ + required RiverInSituSamplingData data, + required String moduleName, + }) async { + final serverConfig = await _serverConfigService.getActiveApiConfig(); + final serverName = serverConfig?['config_name'] as String? ?? 'Default'; + data.submissionStatus = 'L1'; + data.submissionMessage = 'Submission queued due to being offline.'; + + final String? localLogPath = await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName); + + if (localLogPath == null) { + const message = "Failed to save submission to local device storage."; + await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName); + return {'status': 'Error', 'success': false, 'message': message}; + } + + await _retryService.queueTask( + type: 'river_insitu_submission', + payload: { + 'module': moduleName, + 'localLogPath': p.join(localLogPath, 'data.json'), + 'serverConfig': serverConfig, + }, + ); + + const successMessage = "No internet connection. Submission has been saved and queued for upload."; + return {'status': 'Queued', 'success': true, 'message': successMessage}; + } + + Future> _generateAndUploadFtpFiles(RiverInSituSamplingData data, Map imageFiles, String serverName, String moduleName) async { final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN'; final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); final baseFileName = "${stationCode}_$fileTimestamp"; @@ -202,7 +370,7 @@ class RiverInSituSamplingService { subModule: 'river_in_situ_sampling', ); - final Directory? localSubmissionDir = logDirectory != null ? Directory(path.join(logDirectory.path, data.reportId ?? baseFileName)) : null; + final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null; if (localSubmissionDir != null && !await localSubmissionDir.exists()) { await localSubmissionDir.create(recursive: true); } @@ -215,50 +383,25 @@ class RiverInSituSamplingService { Map ftpDataResult = {'success': true, 'statuses': []}; if (dataZip != null) { ftpDataResult = await _submissionFtpService.submit( - moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${path.basename(dataZip.path)}'); + moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${p.basename(dataZip.path)}'); } final imageZip = await _zippingService.createImageZip( - imageFiles: finalImageFiles.values.toList(), + imageFiles: imageFiles.values.toList(), baseFileName: baseFileName, destinationDir: localSubmissionDir, ); Map ftpImageResult = {'success': true, 'statuses': []}; if (imageZip != null) { ftpImageResult = await _submissionFtpService.submit( - moduleName: moduleName, fileToUpload: imageZip, remotePath: '/${path.basename(imageZip.path)}'); - } - final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true); - - String finalStatus; - String finalMessage; - if (apiSuccess) { - finalStatus = ftpSuccess ? 'S4' : 'S3'; - finalMessage = ftpSuccess ? 'Data submitted successfully.' : 'Data sent to API. FTP upload failed/queued.'; - } else { - finalStatus = ftpSuccess ? 'L4' : 'L1'; - finalMessage = ftpSuccess ? 'API failed, but files sent to FTP.' : 'All submission attempts failed.'; + moduleName: moduleName, fileToUpload: imageZip, remotePath: '/${p.basename(imageZip.path)}'); } - await _logAndSave( - data: data, - status: finalStatus, - message: finalMessage, - apiResults: [dataResult, imageResult], - ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], - serverName: serverName - ); - - if (apiSuccess || ftpSuccess) { - _handleSuccessAlert(data, appSettings, isDataOnly: !apiSuccess); - } - - // FIX: Add the 'status' and 'reportId' keys to the return map for the UI. return { - 'status': finalStatus, - 'success': apiSuccess || ftpSuccess, - 'message': finalMessage, - 'reportId': data.reportId + 'statuses': >[ + ...(ftpDataResult['statuses'] as List), + ...(ftpImageResult['statuses'] as List), + ], }; } @@ -269,10 +412,29 @@ class RiverInSituSamplingService { required List> apiResults, required List> ftpStatuses, required String serverName, + String? logDirectory, }) async { data.submissionStatus = status; data.submissionMessage = message; - await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName); + + if (logDirectory != null) { + final Map updatedLogData = data.toMap(); + updatedLogData['logDirectory'] = logDirectory; + updatedLogData['serverConfigName'] = serverName; + updatedLogData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList()); + updatedLogData['ftp_status'] = jsonEncode(ftpStatuses); + + final imageFilePaths = data.toApiImageFiles(); + imageFilePaths.forEach((key, file) { + if (file != null) { + updatedLogData[key] = file.path; + } + }); + + await _localStorageService.updateRiverInSituLog(updatedLogData); + } else { + await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName); + } final imagePaths = data.toApiImageFiles().values.whereType().map((f) => f.path).toList(); final logData = { diff --git a/pubspec.lock b/pubspec.lock index 7a087ce..de95eb6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + bcrypt: + dependency: "direct main" + description: + name: bcrypt + sha256: "9dc3f234d5935a76917a6056613e1a6d9b53f7fa56f98e24cd49b8969307764b" + url: "https://pub.dev" + source: hosted + version: "1.1.3" boolean_selector: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a46043b..14ea1e6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,9 @@ dependencies: http: ^1.2.1 intl: ^0.18.1 + # --- ADDED: For secure password hashing --- + bcrypt: ^1.1.3 + # --- Local Storage & Offline Capabilities --- shared_preferences: ^2.2.3 sqflite: ^2.3.3 @@ -29,7 +32,6 @@ dependencies: flutter_svg: ^2.0.9 google_fonts: ^6.1.0 dropdown_search: ^5.0.6 # For searchable dropdowns in forms - # --- ADDED: For opening document URLs --- url_launcher: ^6.2.6 flutter_pdfview: ^1.3.2 dio: ^5.4.3+1 @@ -46,13 +48,10 @@ dependencies: # --- Added for In-Situ Sampling Module --- simple_barcode_scanner: ^0.3.0 # For scanning sample IDs - #flutter_blue_classic: ^0.0.3 # For Bluetooth sonde connection - flutter_bluetooth_serial: git: url: https://github.com/PSTPSYCO/flutter_bluetooth_serial.git ref: my-edits - usb_serial: ^0.5.2 # For USB Serial sonde connection