add in river investigative module. revamp the services to use dedicated services for api and submission

This commit is contained in:
ALim Aidrus 2025-11-03 21:51:00 +08:00
parent a11c0d8df8
commit 0a0c31b405
60 changed files with 10167 additions and 1399 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 616 KiB

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 927 B

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -1,12 +1,13 @@
// lib/auth_provider.dart // lib/auth_provider.dart
import 'package:flutter/foundation.dart'; // Import for compute function import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; // Added import for post-frame callback
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:bcrypt/bcrypt.dart'; // Import bcrypt import 'package:bcrypt/bcrypt.dart'; // Import bcrypt
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; // NEW: Import secure storage import 'package:flutter_secure_storage/flutter_secure_storage.dart'; // Import secure storage
import 'package:environment_monitoring_app/services/api_service.dart'; import 'package:environment_monitoring_app/services/api_service.dart';
import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/base_api_service.dart';
@ -14,6 +15,9 @@ import 'package:environment_monitoring_app/services/server_config_service.dart';
import 'package:environment_monitoring_app/services/retry_service.dart'; import 'package:environment_monitoring_app/services/retry_service.dart';
import 'package:environment_monitoring_app/services/user_preferences_service.dart'; import 'package:environment_monitoring_app/services/user_preferences_service.dart';
// Removed _CacheDataContainer class
// Removed _loadCacheDataFromIsolate function
class AuthProvider with ChangeNotifier { class AuthProvider with ChangeNotifier {
late final ApiService _apiService; late final ApiService _apiService;
late final DatabaseHelper _dbHelper; late final DatabaseHelper _dbHelper;
@ -21,7 +25,7 @@ class AuthProvider with ChangeNotifier {
late final RetryService _retryService; late final RetryService _retryService;
final UserPreferencesService _userPreferencesService = UserPreferencesService(); final UserPreferencesService _userPreferencesService = UserPreferencesService();
// NEW: Initialize secure storage // Initialize secure storage
final _secureStorage = const FlutterSecureStorage(); final _secureStorage = const FlutterSecureStorage();
static const _passwordStorageKey = 'user_password'; static const _passwordStorageKey = 'user_password';
@ -34,15 +38,15 @@ class AuthProvider with ChangeNotifier {
Map<String, dynamic>? get profileData => _profileData; Map<String, dynamic>? get profileData => _profileData;
// --- App State --- // --- App State ---
bool _isLoading = true; bool _isLoading = true; // Keep true initially
bool _isFirstLogin = true; bool _isFirstLogin = true;
DateTime? _lastSyncTimestamp; DateTime? _lastSyncTimestamp;
bool _isBackgroundLoading = false; // Added flag for background loading
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
bool get isBackgroundLoading => _isBackgroundLoading;
bool get isFirstLogin => _isFirstLogin; bool get isFirstLogin => _isFirstLogin;
DateTime? get lastSyncTimestamp => _lastSyncTimestamp; DateTime? get lastSyncTimestamp => _lastSyncTimestamp;
/// This flag indicates the session is confirmed expired and auto-relogin failed.
/// The app should operate in offline mode until the user manually logs in again.
bool _isSessionExpired = false; bool _isSessionExpired = false;
bool get isSessionExpired => _isSessionExpired; bool get isSessionExpired => _isSessionExpired;
@ -60,12 +64,9 @@ class AuthProvider with ChangeNotifier {
List<Map<String, dynamic>>? _airManualStations; List<Map<String, dynamic>>? _airManualStations;
List<Map<String, dynamic>>? _states; List<Map<String, dynamic>>? _states;
List<Map<String, dynamic>>? _appSettings; List<Map<String, dynamic>>? _appSettings;
// --- START: MODIFIED PARAMETER LIMITS PROPERTIES ---
// The old generic list has been removed and replaced with three specific lists.
List<Map<String, dynamic>>? _npeParameterLimits; List<Map<String, dynamic>>? _npeParameterLimits;
List<Map<String, dynamic>>? _marineParameterLimits; List<Map<String, dynamic>>? _marineParameterLimits;
List<Map<String, dynamic>>? _riverParameterLimits; List<Map<String, dynamic>>? _riverParameterLimits;
// --- END: MODIFIED PARAMETER LIMITS PROPERTIES ---
List<Map<String, dynamic>>? _apiConfigs; List<Map<String, dynamic>>? _apiConfigs;
List<Map<String, dynamic>>? _ftpConfigs; List<Map<String, dynamic>>? _ftpConfigs;
List<Map<String, dynamic>>? _documents; List<Map<String, dynamic>>? _documents;
@ -85,11 +86,9 @@ class AuthProvider with ChangeNotifier {
List<Map<String, dynamic>>? get airManualStations => _airManualStations; List<Map<String, dynamic>>? get airManualStations => _airManualStations;
List<Map<String, dynamic>>? get states => _states; List<Map<String, dynamic>>? get states => _states;
List<Map<String, dynamic>>? get appSettings => _appSettings; List<Map<String, dynamic>>? get appSettings => _appSettings;
// --- START: GETTERS FOR NEW PARAMETER LIMITS ---
List<Map<String, dynamic>>? get npeParameterLimits => _npeParameterLimits; List<Map<String, dynamic>>? get npeParameterLimits => _npeParameterLimits;
List<Map<String, dynamic>>? get marineParameterLimits => _marineParameterLimits; List<Map<String, dynamic>>? get marineParameterLimits => _marineParameterLimits;
List<Map<String, dynamic>>? get riverParameterLimits => _riverParameterLimits; List<Map<String, dynamic>>? get riverParameterLimits => _riverParameterLimits;
// --- END: GETTERS FOR NEW PARAMETER LIMITS ---
List<Map<String, dynamic>>? get apiConfigs => _apiConfigs; List<Map<String, dynamic>>? get apiConfigs => _apiConfigs;
List<Map<String, dynamic>>? get ftpConfigs => _ftpConfigs; List<Map<String, dynamic>>? get ftpConfigs => _ftpConfigs;
List<Map<String, dynamic>>? get documents => _documents; List<Map<String, dynamic>>? get documents => _documents;
@ -113,7 +112,7 @@ class AuthProvider with ChangeNotifier {
_serverConfigService = serverConfigService, _serverConfigService = serverConfigService,
_retryService = retryService { _retryService = retryService {
debugPrint('AuthProvider: Initializing...'); debugPrint('AuthProvider: Initializing...');
_loadSessionAndSyncData(); _initializeAndLoadData(); // Use the updated method name
} }
Future<bool> isConnected() async { Future<bool> isConnected() async {
@ -121,22 +120,35 @@ class AuthProvider with ChangeNotifier {
return !connectivityResult.contains(ConnectivityResult.none); return !connectivityResult.contains(ConnectivityResult.none);
} }
Future<void> _loadSessionAndSyncData() async { // Updated method using SchedulerBinding instead of compute
Future<void> _initializeAndLoadData() async {
_isLoading = true; _isLoading = true;
notifyListeners(); notifyListeners(); // Notify UI about initial loading state
// 1. Perform quick SharedPreferences reads first.
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_jwtToken = prefs.getString(tokenKey); _jwtToken = prefs.getString(tokenKey);
_userEmail = prefs.getString(userEmailKey); _userEmail = prefs.getString(userEmailKey);
_isFirstLogin = prefs.getBool(isFirstLoginKey) ?? true; _isFirstLogin = prefs.getBool(isFirstLoginKey) ?? true;
final profileJson = prefs.getString(profileDataKey);
if (profileJson != null) {
try {
_profileData = jsonDecode(profileJson);
} catch (e) {
debugPrint("Failed to decode profile from prefs: $e");
prefs.remove(profileDataKey);
}
}
// Load server config early
final activeApiConfig = await _serverConfigService.getActiveApiConfig(); final activeApiConfig = await _serverConfigService.getActiveApiConfig();
if (activeApiConfig == null) { if (activeApiConfig == null) {
debugPrint("AuthProvider: No active API config found. Setting default bootstrap URL."); debugPrint("AuthProvider: No active API config found. Setting default bootstrap URL.");
final initialConfig = { final initialConfig = {
'api_config_id': 0, 'api_config_id': 0,
'config_name': 'Default Server', 'config_name': 'Default Server',
'api_url': 'https://mms-apiv4.pstw.com.my/v1', 'api_url': 'https://mms-apiv4.pstw.com.my/v1', // Use actual default if needed
}; };
await _serverConfigService.setActiveApiConfig(initialConfig); await _serverConfigService.setActiveApiConfig(initialConfig);
} }
@ -151,18 +163,40 @@ class AuthProvider with ChangeNotifier {
} }
} }
await _loadDataFromCache(); // 2. Set isLoading to false *before* scheduling heavy work.
if (_jwtToken != null) {
debugPrint('AuthProvider: Session loaded.');
await _userPreferencesService.applyAndSaveDefaultPreferencesIfNeeded();
// Sync logic moved to checkAndTransitionToOnlineSession to handle transitions correctly
} else {
debugPrint('AuthProvider: No active session. App is in offline mode.');
}
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners(); // Let the UI build
// 3. Schedule heavy database load *after* the first frame.
SchedulerBinding.instance.addPostFrameCallback((_) async {
debugPrint("AuthProvider: First frame built. Starting background cache load...");
_isBackgroundLoading = true; // Indicate background activity
notifyListeners(); // Show a secondary loading indicator if needed
try {
// Call the original cache loading method here
await _loadDataFromCache();
debugPrint("AuthProvider: Background cache load complete.");
// After loading cache, check session status and potentially sync
if (_jwtToken != null) {
debugPrint('AuthProvider: Session loaded.');
await _userPreferencesService.applyAndSaveDefaultPreferencesIfNeeded();
// Decide whether to call checkAndTransition or validateAndRefresh here
await checkAndTransitionToOnlineSession(); // Example: Check if transition needed
await validateAndRefreshSession(); // Example: Validate if already online
} else {
debugPrint('AuthProvider: No active session. App is in offline mode.');
}
} catch (e) {
debugPrint("AuthProvider: Error during background cache load: $e");
// Handle error appropriately
} finally {
_isBackgroundLoading = false; // Background load finished
notifyListeners(); // Update UI
}
});
} }
/// Checks if the session is offline and attempts to transition to an online session by performing a silent re-login. /// Checks if the session is offline and attempts to transition to an online session by performing a silent re-login.
@ -178,20 +212,18 @@ class AuthProvider with ChangeNotifier {
final bool inOfflineSession = _jwtToken != null && _jwtToken!.startsWith("offline-session-"); final bool inOfflineSession = _jwtToken != null && _jwtToken!.startsWith("offline-session-");
if (!inOfflineSession) { if (!inOfflineSession) {
// Already online or logged out, no transition needed. // Already online or logged out, no transition needed.
// If online, trigger a normal sync to ensure data freshness on connection restoration. // --- START: FIX FOR DOUBLE SYNC ---
// Removed the redundant syncAllData call from here.
if(_jwtToken != null) { if(_jwtToken != null) {
debugPrint("AuthProvider: Session is already online. Triggering standard sync."); debugPrint("AuthProvider: Session is already online. Skipping transition sync.");
// FIX: Add try-catch to prevent unhandled exceptions from crashing the app during background syncs. // Consider calling validateAndRefreshSession() here instead if needed,
try { // but avoid a full syncAllData().
await syncAllData();
} catch (e) {
debugPrint("AuthProvider: Background sync failed silently on transition check: $e");
}
} }
// --- END: FIX FOR DOUBLE SYNC ---
return false; return false;
} }
// FIX: Read password from secure storage instead of temporary variable. // Read password from secure storage
final String? password = await _secureStorage.read(key: _passwordStorageKey); final String? password = await _secureStorage.read(key: _passwordStorageKey);
if (password == null || _userEmail == null) { if (password == null || _userEmail == null) {
debugPrint("AuthProvider: In offline session, but no password in secure storage for auto-relogin. Manual login required."); debugPrint("AuthProvider: In offline session, but no password in secure storage for auto-relogin. Manual login required.");
@ -209,13 +241,12 @@ class AuthProvider with ChangeNotifier {
final Map<String, dynamic> profile = result['data']['profile']; final Map<String, dynamic> profile = result['data']['profile'];
// Use existing login method to set up session and trigger sync. // Use existing login method to set up session and trigger sync.
await login(token, profile, password); await login(token, profile, password); // This call includes syncAllData
notifyListeners(); // Ensure UI updates after state change notifyListeners(); // Ensure UI updates after state change
return true; return true;
} else { } else {
// Silent login failed (e.g., password changed on another device). // Silent login failed
// 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']}"); debugPrint("AuthProvider: Silent re-login failed: ${result['message']}");
return false; return false;
} }
@ -225,8 +256,6 @@ class AuthProvider with ChangeNotifier {
} }
} }
/// Orchestrates session validation and silent re-authentication.
/// This should be called whenever the app gains an internet connection.
Future<void> validateAndRefreshSession() async { Future<void> validateAndRefreshSession() async {
if (!(await isConnected())) { if (!(await isConnected())) {
debugPrint('AuthProvider: No connection, skipping session validation.'); debugPrint('AuthProvider: No connection, skipping session validation.');
@ -238,9 +267,8 @@ class AuthProvider with ChangeNotifier {
return; return;
} }
// Don't attempt validation if the user is not logged in or is in a temporary offline session.
if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) { if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) {
return; return; // No online session to validate
} }
try { try {
@ -254,7 +282,6 @@ class AuthProvider with ChangeNotifier {
debugPrint('AuthProvider: Silent re-login failed. Switching to session-expired offline mode.'); debugPrint('AuthProvider: Silent re-login failed. Switching to session-expired offline mode.');
_isSessionExpired = true; _isSessionExpired = true;
notifyListeners(); notifyListeners();
// You can optionally show a one-time notification here.
} else { } else {
debugPrint('AuthProvider: Silent re-login successful. Session restored.'); debugPrint('AuthProvider: Silent re-login successful. Session restored.');
} }
@ -263,15 +290,12 @@ class AuthProvider with ChangeNotifier {
} }
} }
/// Attempts to silently re-login to get a new token.
/// This can be called when a 401 Unauthorized error is detected.
Future<bool> attemptSilentRelogin() async { Future<bool> attemptSilentRelogin() async {
if (!(await isConnected())) { if (!(await isConnected())) {
debugPrint("AuthProvider: No internet for silent relogin."); debugPrint("AuthProvider: No internet for silent relogin.");
return false; return false;
} }
// FIX: Read password from secure storage.
final String? password = await _secureStorage.read(key: _passwordStorageKey); final String? password = await _secureStorage.read(key: _passwordStorageKey);
if (password == null || _userEmail == null) { if (password == null || _userEmail == null) {
debugPrint("AuthProvider: No cached credentials in secure storage for silent relogin."); debugPrint("AuthProvider: No cached credentials in secure storage for silent relogin.");
@ -285,8 +309,10 @@ class AuthProvider with ChangeNotifier {
debugPrint("AuthProvider: Silent re-login successful."); debugPrint("AuthProvider: Silent re-login successful.");
final String token = result['data']['token']; final String token = result['data']['token'];
final Map<String, dynamic> profile = result['data']['profile']; final Map<String, dynamic> profile = result['data']['profile'];
await login(token, profile, password); // Critical: Call the main login function to update token, profile, hash, etc.
_isSessionExpired = false; // Explicitly mark session as valid again. // BUT prevent it from triggering another full sync immediately if called during syncAllData
await _updateSessionInternals(token, profile, password); // Use helper to avoid sync loop
_isSessionExpired = false;
notifyListeners(); notifyListeners();
return true; return true;
} else { } else {
@ -299,13 +325,45 @@ class AuthProvider with ChangeNotifier {
} }
} }
// Helper to update session without triggering sync, used by attemptSilentRelogin
Future<void> _updateSessionInternals(String token, Map<String, dynamic> profile, String password) async {
_jwtToken = token;
_userEmail = profile['email'];
await _secureStorage.write(key: _passwordStorageKey, value: password);
final Map<String, dynamic> 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(_profileData));
await prefs.setString(lastOnlineLoginKey, DateTime.now().toIso8601String()); // Update last online time
await _dbHelper.saveProfile(_profileData!);
try {
debugPrint("AuthProvider: (Re-login) Hashing and caching password for offline login.");
final String hashedPassword = await compute(hashPassword, password);
await _dbHelper.upsertUserWithCredentials(
profile: profile,
passwordHash: hashedPassword,
);
debugPrint("AuthProvider: (Re-login) Credentials cached successfully.");
} catch (e) {
debugPrint("AuthProvider: (Re-login) Failed to cache password hash: $e");
}
// DO NOT call syncAllData here to prevent loops when called from syncAllData's catch block.
}
Future<void> syncAllData({bool forceRefresh = false}) async { Future<void> syncAllData({bool forceRefresh = false}) async {
if (!(await isConnected())) { if (!(await isConnected())) {
debugPrint("AuthProvider: Device is OFFLINE. Skipping sync."); debugPrint("AuthProvider: Device is OFFLINE. Skipping sync.");
return; return;
} }
// Proactively check if session is already marked as expired
if (_isSessionExpired) { if (_isSessionExpired) {
debugPrint("AuthProvider: Skipping sync, session is expired. Manual login required."); debugPrint("AuthProvider: Skipping sync, session is expired. Manual login required.");
throw Exception('Session expired. Please log in again to sync.'); throw Exception('Session expired. Please log in again to sync.');
@ -334,11 +392,10 @@ class AuthProvider with ChangeNotifier {
debugPrint("AuthProvider: First successful sync complete. isFirstLogin flag set to false."); debugPrint("AuthProvider: First successful sync complete. isFirstLogin flag set to false.");
} }
await _loadDataFromCache(); await _loadDataFromCache(); // Reload data after successful sync
notifyListeners(); notifyListeners();
} else { } else {
debugPrint("AuthProvider: Delta sync failed logically. Message: ${result['message']}"); debugPrint("AuthProvider: Delta sync failed logically. Message: ${result['message']}");
// We throw an exception here so the UI can report a failure.
throw Exception('Data sync failed. Please check the logs.'); throw Exception('Data sync failed. Please check the logs.');
} }
} on SessionExpiredException { } on SessionExpiredException {
@ -355,12 +412,10 @@ class AuthProvider with ChangeNotifier {
} }
} catch (e) { } catch (e) {
debugPrint("AuthProvider: A general error occurred during sync: $e"); debugPrint("AuthProvider: A general error occurred during sync: $e");
// Re-throw the exception so the UI can display it.
rethrow; rethrow;
} }
} }
// --- START: NEW METHOD FOR REGISTRATION SCREEN ---
Future<void> syncRegistrationData() async { Future<void> syncRegistrationData() async {
if (!(await isConnected())) { if (!(await isConnected())) {
debugPrint("AuthProvider: Device is OFFLINE. Skipping registration data sync."); debugPrint("AuthProvider: Device is OFFLINE. Skipping registration data sync.");
@ -371,14 +426,13 @@ class AuthProvider with ChangeNotifier {
final result = await _apiService.syncRegistrationData(); final result = await _apiService.syncRegistrationData();
if (result['success']) { if (result['success']) {
await _loadDataFromCache(); // Reload data from DB into the provider await _loadDataFromCache();
notifyListeners(); // Notify the UI to rebuild notifyListeners();
debugPrint("AuthProvider: Registration data loaded and UI notified."); debugPrint("AuthProvider: Registration data loaded and UI notified.");
} else { } else {
debugPrint("AuthProvider: Registration data sync failed."); debugPrint("AuthProvider: Registration data sync failed.");
} }
} }
// --- END: NEW METHOD FOR REGISTRATION SCREEN ---
Future<void> refreshProfile() async { Future<void> refreshProfile() async {
if (!(await isConnected())) { if (!(await isConnected())) {
@ -390,9 +444,16 @@ class AuthProvider with ChangeNotifier {
return; return;
} }
final result = await _apiService.refreshProfile(); try {
if (result['success']) { final result = await _apiService.refreshProfile();
await setProfileData(result['data']); if (result['success']) {
await setProfileData(result['data']);
}
} on SessionExpiredException {
debugPrint("AuthProvider: Session expired during profile refresh. Attempting silent re-login...");
await attemptSilentRelogin(); // Attempt re-login but don't retry refresh automatically here
} catch (e) {
debugPrint("AuthProvider: Error during profile refresh: $e");
} }
} }
@ -407,7 +468,7 @@ class AuthProvider with ChangeNotifier {
if (lastOnlineLoginString == null) { if (lastOnlineLoginString == null) {
debugPrint('AuthProvider: No last online login timestamp found, skipping proactive refresh.'); debugPrint('AuthProvider: No last online login timestamp found, skipping proactive refresh.');
return; // Never logged in online, nothing to refresh. return;
} }
try { try {
@ -423,14 +484,15 @@ class AuthProvider with ChangeNotifier {
} }
} }
// This method performs the actual DB reads
Future<void> _loadDataFromCache() async { Future<void> _loadDataFromCache() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final profileJson = prefs.getString(profileDataKey); final profileJson = prefs.getString(profileDataKey);
if (profileJson != null) { if (profileJson != null && _profileData == null) {
_profileData = jsonDecode(profileJson); try { _profileData = jsonDecode(profileJson); } catch(e) { /*...*/ }
} else {
_profileData = await _dbHelper.loadProfile();
} }
_profileData ??= await _dbHelper.loadProfile();
_allUsers = await _dbHelper.loadUsers(); _allUsers = await _dbHelper.loadUsers();
_tarballStations = await _dbHelper.loadTarballStations(); _tarballStations = await _dbHelper.loadTarballStations();
_manualStations = await _dbHelper.loadManualStations(); _manualStations = await _dbHelper.loadManualStations();
@ -444,65 +506,40 @@ class AuthProvider with ChangeNotifier {
_airManualStations = await _dbHelper.loadAirManualStations(); _airManualStations = await _dbHelper.loadAirManualStations();
_states = await _dbHelper.loadStates(); _states = await _dbHelper.loadStates();
_appSettings = await _dbHelper.loadAppSettings(); _appSettings = await _dbHelper.loadAppSettings();
// --- START: LOAD DATA FROM NEW PARAMETER LIMIT TABLES ---
_npeParameterLimits = await _dbHelper.loadNpeParameterLimits(); _npeParameterLimits = await _dbHelper.loadNpeParameterLimits();
_marineParameterLimits = await _dbHelper.loadMarineParameterLimits(); _marineParameterLimits = await _dbHelper.loadMarineParameterLimits();
_riverParameterLimits = await _dbHelper.loadRiverParameterLimits(); _riverParameterLimits = await _dbHelper.loadRiverParameterLimits();
// --- END: LOAD DATA FROM NEW PARAMETER LIMIT TABLES ---
_documents = await _dbHelper.loadDocuments(); _documents = await _dbHelper.loadDocuments();
_apiConfigs = await _dbHelper.loadApiConfigs(); _apiConfigs = await _dbHelper.loadApiConfigs();
_ftpConfigs = await _dbHelper.loadFtpConfigs(); _ftpConfigs = await _dbHelper.loadFtpConfigs();
_pendingRetries = await _retryService.getPendingTasks(); _pendingRetries = await _retryService.getPendingTasks(); // Use service here is okay
debugPrint("AuthProvider: All master data loaded from local DB cache."); debugPrint("AuthProvider: All master data loaded from local DB cache (background/sync).");
} }
Future<void> refreshPendingTasks() async { Future<void> refreshPendingTasks() async {
_pendingRetries = await _retryService.getPendingTasks(); _pendingRetries = await _retryService.getPendingTasks();
notifyListeners(); notifyListeners();
} }
Future<void> login(String token, Map<String, dynamic> profile, String password) async { Future<void> login(String token, Map<String, dynamic> profile, String password) async {
_jwtToken = token; // Call the internal helper first
_userEmail = profile['email']; await _updateSessionInternals(token, profile, password);
// FIX: Save password to secure storage instead of in-memory variable.
await _secureStorage.write(key: _passwordStorageKey, value: password);
final Map<String, dynamic> profileWithToken = Map.from(profile);
profileWithToken['token'] = token;
_profileData = profileWithToken;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(tokenKey, token);
await prefs.setString(userEmailKey, _userEmail!);
await prefs.setString(profileDataKey, jsonEncode(_profileData));
await prefs.setString(lastOnlineLoginKey, DateTime.now().toIso8601String());
await _dbHelper.saveProfile(_profileData!);
try {
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");
}
// Now proceed with post-login actions that *don't* belong in the helper
debugPrint('AuthProvider: Login successful. Session and profile persisted.'); debugPrint('AuthProvider: Login successful. Session and profile persisted.');
await _userPreferencesService.applyAndSaveDefaultPreferencesIfNeeded(); await _userPreferencesService.applyAndSaveDefaultPreferencesIfNeeded();
// The main sync triggered by a direct user login
await syncAllData(forceRefresh: true); await syncAllData(forceRefresh: true);
// Notify listeners *after* sync is attempted (or throws)
notifyListeners();
} }
Future<bool> loginOffline(String email, String password) async { Future<bool> loginOffline(String email, String password) async {
debugPrint("AuthProvider: Attempting offline login for user $email."); debugPrint("AuthProvider: Attempting offline login for user $email.");
try { try {
// 1. Retrieve stored hash from the local database based on email.
final String? storedHash = await _dbHelper.getUserPasswordHashByEmail(email); final String? storedHash = await _dbHelper.getUserPasswordHashByEmail(email);
if (storedHash == null || storedHash.isEmpty) { if (storedHash == null || storedHash.isEmpty) {
@ -510,24 +547,20 @@ class AuthProvider with ChangeNotifier {
return false; return false;
} }
// 2. Verify the provided password against the stored hash.
debugPrint("AuthProvider: Verifying password against stored hash..."); debugPrint("AuthProvider: Verifying password against stored hash...");
final bool passwordMatches = await compute(verifyPassword, CheckPasswordParams(password, storedHash)); final bool passwordMatches = await compute(verifyPassword, CheckPasswordParams(password, storedHash));
if (passwordMatches) { if (passwordMatches) {
debugPrint("AuthProvider: Offline password verification successful."); debugPrint("AuthProvider: Offline password verification successful.");
// 3. Load profile data from local storage.
final Map<String, dynamic>? cachedProfile = await _dbHelper.loadProfileByEmail(email); final Map<String, dynamic>? cachedProfile = await _dbHelper.loadProfileByEmail(email);
if (cachedProfile == null) { if (cachedProfile == null) {
debugPrint("AuthProvider DEBUG: Offline login failed because profile data was missing, even though password matched."); debugPrint("AuthProvider DEBUG: Offline login failed because profile data was missing, even though password matched.");
return false; return false;
} }
// 4. Initialize session state from cached profile data.
_jwtToken = "offline-session-${DateTime.now().millisecondsSinceEpoch}"; _jwtToken = "offline-session-${DateTime.now().millisecondsSinceEpoch}";
_userEmail = email; _userEmail = email;
// FIX: Save password to secure storage for future auto-relogin.
await _secureStorage.write(key: _passwordStorageKey, value: password); await _secureStorage.write(key: _passwordStorageKey, value: password);
final Map<String, dynamic> profileWithToken = Map.from(cachedProfile); final Map<String, dynamic> profileWithToken = Map.from(cachedProfile);
@ -539,6 +572,8 @@ class AuthProvider with ChangeNotifier {
await prefs.setString(userEmailKey, _userEmail!); await prefs.setString(userEmailKey, _userEmail!);
await prefs.setString(profileDataKey, jsonEncode(_profileData)); await prefs.setString(profileDataKey, jsonEncode(_profileData));
// Load cache data immediately after offline login succeeds
// This doesn't need the post-frame callback as it's triggered by user action
await _loadDataFromCache(); await _loadDataFromCache();
notifyListeners(); notifyListeners();
return true; return true;
@ -561,7 +596,7 @@ class AuthProvider with ChangeNotifier {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(profileDataKey, jsonEncode(_profileData)); await prefs.setString(profileDataKey, jsonEncode(_profileData));
await _dbHelper.saveProfile(_profileData!); // Also save to local DB await _dbHelper.saveProfile(_profileData!);
notifyListeners(); notifyListeners();
} }
@ -579,11 +614,11 @@ class AuthProvider with ChangeNotifier {
_profileData = null; _profileData = null;
_lastSyncTimestamp = null; _lastSyncTimestamp = null;
_isFirstLogin = true; _isFirstLogin = true;
_isSessionExpired = false; // Reset session expired flag on logout _isSessionExpired = false;
// FIX: Clear password from secure storage on logout.
await _secureStorage.delete(key: _passwordStorageKey); await _secureStorage.delete(key: _passwordStorageKey);
// Clear cached data
_allUsers = null; _allUsers = null;
_tarballStations = null; _tarballStations = null;
_manualStations = null; _manualStations = null;
@ -597,13 +632,9 @@ class AuthProvider with ChangeNotifier {
_airManualStations = null; _airManualStations = null;
_states = null; _states = null;
_appSettings = null; _appSettings = null;
// --- START: Clear new parameter limit lists ---
_npeParameterLimits = null; _npeParameterLimits = null;
_marineParameterLimits = null; _marineParameterLimits = null;
_riverParameterLimits = null; _riverParameterLimits = null;
// --- END: Clear new parameter limit lists ---
_documents = null; _documents = null;
_apiConfigs = null; _apiConfigs = null;
_ftpConfigs = null; _ftpConfigs = null;
@ -615,7 +646,7 @@ class AuthProvider with ChangeNotifier {
await prefs.remove(profileDataKey); await prefs.remove(profileDataKey);
await prefs.remove(lastSyncTimestampKey); await prefs.remove(lastSyncTimestampKey);
await prefs.remove(lastOnlineLoginKey); await prefs.remove(lastOnlineLoginKey);
await prefs.remove('default_preferences_saved'); await prefs.remove('default_preferences_saved'); // Also clear user prefs flag
await prefs.setBool(isFirstLoginKey, true); await prefs.setBool(isFirstLoginKey, true);
debugPrint('AuthProvider: All session and cached data cleared.'); debugPrint('AuthProvider: All session and cached data cleared.');
@ -623,10 +654,13 @@ class AuthProvider with ChangeNotifier {
} }
Future<Map<String, dynamic>> resetPassword(String email) { Future<Map<String, dynamic>> resetPassword(String email) {
// Assuming _apiService has a method for this, otherwise implement it.
// This looks correct based on your previous code structure.
return _apiService.post('auth/forgot-password', {'email': email}); return _apiService.post('auth/forgot-password', {'email': email});
} }
} }
// These remain unchanged as they are used by compute for password hashing/checking
class CheckPasswordParams { class CheckPasswordParams {
final String password; final String password;
final String hash; final String hash;

View File

@ -1,3 +1,5 @@
// collapsible_sidebar.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
// --- Data Structure for Sidebar Menu Items --- // --- Data Structure for Sidebar Menu Items ---
@ -87,6 +89,8 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
SidebarItem(icon: Icons.pin_drop, label: "In-Situ Sampling", route: '/river/manual/in-situ'), SidebarItem(icon: Icons.pin_drop, label: "In-Situ Sampling", route: '/river/manual/in-situ'),
SidebarItem(icon: Icons.date_range, label: "Triennial Sampling", route: '/river/manual/triennial'), 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.article, label: "Data Log", route: '/river/manual/data-log'),
// *** ADDED: From river_home_page.dart ***
SidebarItem(icon: Icons.image, label: "Image Request", route: '/river/manual/image-request'),
], ],
), ),
SidebarItem( SidebarItem(
@ -96,6 +100,8 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
SidebarItem( SidebarItem(
icon: Icons.search, label: "Investigative", isParent: true, children: [ icon: Icons.search, label: "Investigative", isParent: true, children: [
SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/river/investigative/info'), SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/river/investigative/info'),
// *** ADDED: From river_home_page.dart ***
SidebarItem(icon: Icons.biotech, label: "Investigative Sampling", route: '/river/investigative/manual-sampling'),
]), ]),
], ],
), ),
@ -115,6 +121,10 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
SidebarItem(icon: Icons.pin_drop, label: "In-Situ Sampling", route: '/marine/manual/in-situ'), 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.waves, label: "Tarball Sampling", route: '/marine/manual/tarball'),
SidebarItem(icon: Icons.article, label: "Data Log", route: '/marine/manual/data-log'), SidebarItem(icon: Icons.article, label: "Data Log", route: '/marine/manual/data-log'),
// *** ADDED: From marine_home_page.dart ***
SidebarItem(icon: Icons.image, label: "Image Request", route: '/marine/manual/image-request'),
// *** ADDED: From marine_home_page.dart ***
SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/manual/report'),
], ],
), ),
SidebarItem( SidebarItem(
@ -124,6 +134,8 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
SidebarItem( SidebarItem(
icon: Icons.search, label: "Investigative", isParent: true, children: [ icon: Icons.search, label: "Investigative", isParent: true, children: [
SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/investigative/info'), SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/investigative/info'),
// *** ADDED: From marine_home_page.dart ***
SidebarItem(icon: Icons.science_outlined, label: "Investigative Sampling", route: '/marine/investigative/manual-sampling'),
]), ]),
], ],
), ),

View File

@ -10,13 +10,16 @@ import 'package:environment_monitoring_app/services/api_service.dart';
import 'package:environment_monitoring_app/services/local_storage_service.dart'; import 'package:environment_monitoring_app/services/local_storage_service.dart';
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart'; import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart';
import 'package:environment_monitoring_app/services/river_manual_triennial_sampling_service.dart'; import 'package:environment_monitoring_app/services/river_manual_triennial_sampling_service.dart';
// *** ADDED: Import River Investigative Sampling Service ***
import 'package:environment_monitoring_app/services/river_investigative_sampling_service.dart';
import 'package:environment_monitoring_app/services/air_sampling_service.dart'; import 'package:environment_monitoring_app/services/air_sampling_service.dart';
import 'package:environment_monitoring_app/services/telegram_service.dart'; import 'package:environment_monitoring_app/services/telegram_service.dart';
import 'package:environment_monitoring_app/services/server_config_service.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart';
import 'package:environment_monitoring_app/services/retry_service.dart'; import 'package:environment_monitoring_app/services/retry_service.dart';
import 'package:environment_monitoring_app/services/marine_in_situ_sampling_service.dart'; import 'package:environment_monitoring_app/services/marine_in_situ_sampling_service.dart';
import 'package:environment_monitoring_app/services/marine_investigative_sampling_service.dart';
import 'package:environment_monitoring_app/services/marine_npe_report_service.dart'; import 'package:environment_monitoring_app/services/marine_npe_report_service.dart';
import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart'; import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart'; // Ensure this import is present
import 'package:environment_monitoring_app/services/marine_manual_pre_departure_service.dart'; import 'package:environment_monitoring_app/services/marine_manual_pre_departure_service.dart';
import 'package:environment_monitoring_app/services/marine_manual_sonde_calibration_service.dart'; import 'package:environment_monitoring_app/services/marine_manual_sonde_calibration_service.dart';
import 'package:environment_monitoring_app/services/marine_manual_equipment_maintenance_service.dart'; import 'package:environment_monitoring_app/services/marine_manual_equipment_maintenance_service.dart';
@ -66,6 +69,8 @@ import 'package:environment_monitoring_app/screens/river/continuous/overview.dar
import 'package:environment_monitoring_app/screens/river/continuous/entry.dart' as riverContinuousEntry; import 'package:environment_monitoring_app/screens/river/continuous/entry.dart' as riverContinuousEntry;
import 'package:environment_monitoring_app/screens/river/continuous/report.dart' as riverContinuousReport; import 'package:environment_monitoring_app/screens/river/continuous/report.dart' as riverContinuousReport;
import 'package:environment_monitoring_app/screens/river/investigative/river_investigative_info_centre_document.dart'; import 'package:environment_monitoring_app/screens/river/investigative/river_investigative_info_centre_document.dart';
// *** ADDED: Import River Investigative Manual Sampling Screen ***
import 'package:environment_monitoring_app/screens/river/investigative/river_investigative_manual_sampling.dart' as riverInvestigativeManualSampling;
import 'package:environment_monitoring_app/screens/river/investigative/overview.dart' as riverInvestigativeOverview; import 'package:environment_monitoring_app/screens/river/investigative/overview.dart' as riverInvestigativeOverview;
import 'package:environment_monitoring_app/screens/river/investigative/entry.dart' as riverInvestigativeEntry; import 'package:environment_monitoring_app/screens/river/investigative/entry.dart' as riverInvestigativeEntry;
import 'package:environment_monitoring_app/screens/river/investigative/report.dart' as riverInvestigativeReport; import 'package:environment_monitoring_app/screens/river/investigative/report.dart' as riverInvestigativeReport;
@ -76,16 +81,22 @@ import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_p
import 'package:environment_monitoring_app/screens/marine/manual/in_situ_sampling.dart' as marineManualInSituSampling; import 'package:environment_monitoring_app/screens/marine/manual/in_situ_sampling.dart' as marineManualInSituSampling;
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_report.dart' as marineManualReport; import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_report.dart' as marineManualReport;
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_npe_report_hub.dart'; import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_npe_report_hub.dart';
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_pre_departure_checklist_screen.dart' as marineManualPreDepartureChecklist; import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_pre_departure_checklist_screen.dart'
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart' as marineManualSondeCalibration; as marineManualPreDepartureChecklist;
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart' as marineManualEquipmentMaintenance; import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart'
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_data_status_log.dart' as marineManualDataStatusLog; as marineManualSondeCalibration;
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart'
as marineManualEquipmentMaintenance;
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_data_status_log.dart'
as marineManualDataStatusLog;
import 'package:environment_monitoring_app/screens/marine/manual/marine_image_request.dart' as marineManualImageRequest; import 'package:environment_monitoring_app/screens/marine/manual/marine_image_request.dart' as marineManualImageRequest;
import 'package:environment_monitoring_app/screens/marine/continuous/marine_continuous_info_centre_document.dart'; import 'package:environment_monitoring_app/screens/marine/continuous/marine_continuous_info_centre_document.dart';
import 'package:environment_monitoring_app/screens/marine/continuous/overview.dart' as marineContinuousOverview; import 'package:environment_monitoring_app/screens/marine/continuous/overview.dart' as marineContinuousOverview;
import 'package:environment_monitoring_app/screens/marine/continuous/entry.dart' as marineContinuousEntry; import 'package:environment_monitoring_app/screens/marine/continuous/entry.dart' as marineContinuousEntry;
import 'package:environment_monitoring_app/screens/marine/continuous/report.dart' as marineContinuousReport; import 'package:environment_monitoring_app/screens/marine/continuous/report.dart' as marineContinuousReport;
import 'package:environment_monitoring_app/screens/marine/investigative/marine_investigative_info_centre_document.dart'; import 'package:environment_monitoring_app/screens/marine/investigative/marine_investigative_info_centre_document.dart';
import 'package:environment_monitoring_app/screens/marine/investigative/marine_investigative_manual_sampling.dart'
as marineInvestigativeManualSampling;
import 'package:environment_monitoring_app/screens/marine/investigative/overview.dart' as marineInvestigativeOverview; import 'package:environment_monitoring_app/screens/marine/investigative/overview.dart' as marineInvestigativeOverview;
import 'package:environment_monitoring_app/screens/marine/investigative/entry.dart' as marineInvestigativeEntry; import 'package:environment_monitoring_app/screens/marine/investigative/entry.dart' as marineInvestigativeEntry;
import 'package:environment_monitoring_app/screens/marine/investigative/report.dart' as marineInvestigativeReport; import 'package:environment_monitoring_app/screens/marine/investigative/report.dart' as marineInvestigativeReport;
@ -105,6 +116,12 @@ void main() async {
final RetryService retryService = RetryService(); final RetryService retryService = RetryService();
final MarineInSituSamplingService marineInSituService = MarineInSituSamplingService(telegramService); final MarineInSituSamplingService marineInSituService = MarineInSituSamplingService(telegramService);
final RiverInSituSamplingService riverInSituService = RiverInSituSamplingService(telegramService); final RiverInSituSamplingService riverInSituService = RiverInSituSamplingService(telegramService);
final MarineInvestigativeSamplingService marineInvestigativeService =
MarineInvestigativeSamplingService(telegramService);
// *** ADDED: Create instance of RiverInvestigativeSamplingService ***
final RiverInvestigativeSamplingService riverInvestigativeService =
RiverInvestigativeSamplingService(telegramService);
final MarineTarballSamplingService marineTarballService = MarineTarballSamplingService(telegramService);
telegramService.setApiService(apiService); telegramService.setApiService(apiService);
@ -117,9 +134,13 @@ void main() async {
); );
// Initialize the retry service with all its dependencies. // Initialize the retry service with all its dependencies.
// *** MODIFIED: Added riverInvestigativeService dependency (and marineTarballService from previous request) ***
retryService.initialize( retryService.initialize(
marineInSituService: marineInSituService, marineInSituService: marineInSituService,
riverInSituService: riverInSituService, riverInSituService: riverInSituService,
marineInvestigativeService: marineInvestigativeService,
riverInvestigativeService: riverInvestigativeService, // <-- Added this line
marineTarballService: marineTarballService,
authProvider: authProvider, authProvider: authProvider,
); );
@ -135,21 +156,23 @@ void main() async {
Provider(create: (_) => LocalStorageService()), Provider(create: (_) => LocalStorageService()),
Provider.value(value: retryService), Provider.value(value: retryService),
Provider.value(value: marineInSituService), Provider.value(value: marineInSituService),
Provider.value(value: marineInvestigativeService),
Provider.value(value: riverInSituService), Provider.value(value: riverInSituService),
// *** ADDED: Provider for River Investigative Service ***
Provider.value(value: riverInvestigativeService), // Use Provider.value
Provider(create: (context) => RiverManualTriennialSamplingService(telegramService)), Provider(create: (context) => RiverManualTriennialSamplingService(telegramService)),
Provider(create: (context) => AirSamplingService(databaseHelper, telegramService)), Provider(create: (context) => AirSamplingService(databaseHelper, telegramService)),
Provider(create: (context) => MarineTarballSamplingService(telegramService)), Provider.value(value: marineTarballService), // Use Provider.value
Provider(create: (context) => MarineNpeReportService(Provider.of<TelegramService>(context, listen: false))), Provider(create: (context) => MarineNpeReportService(Provider.of<TelegramService>(context, listen: false))),
// --- UPDATED: Inject ApiService into the service constructors --- Provider(
Provider(create: (context) => MarineManualPreDepartureService( create: (context) =>
Provider.of<ApiService>(context, listen: false) MarineManualPreDepartureService(Provider.of<ApiService>(context, listen: false))),
)), Provider(
Provider(create: (context) => MarineManualSondeCalibrationService( create: (context) =>
Provider.of<ApiService>(context, listen: false) MarineManualSondeCalibrationService(Provider.of<ApiService>(context, listen: false))),
)), Provider(
Provider(create: (context) => MarineManualEquipmentMaintenanceService( create: (context) =>
Provider.of<ApiService>(context, listen: false) MarineManualEquipmentMaintenanceService(Provider.of<ApiService>(context, listen: false))),
)),
], ],
child: const RootApp(), child: const RootApp(),
), ),
@ -182,7 +205,6 @@ class RootApp extends StatefulWidget {
} }
class _RootAppState extends State<RootApp> { class _RootAppState extends State<RootApp> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -255,8 +277,8 @@ class _RootAppState extends State<RootApp> {
theme: AppTheme.darkBlueTheme, theme: AppTheme.darkBlueTheme,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
home: homeWidget, home: homeWidget,
onGenerateRoute: (settings) { onGenerateRoute: (settings) {
// Keep existing onGenerateRoute logic for Tarball
if (settings.name == '/marine/manual/tarball/step2') { if (settings.name == '/marine/manual/tarball/step2') {
final args = settings.arguments as TarballSamplingData; final args = settings.arguments as TarballSamplingData;
return MaterialPageRoute(builder: (context) { return MaterialPageRoute(builder: (context) {
@ -274,7 +296,8 @@ class _RootAppState extends State<RootApp> {
return const marineManualDataStatusLog.MarineManualDataStatusLog(); return const marineManualDataStatusLog.MarineManualDataStatusLog();
}); });
} }
return null; // Add other potential dynamic routes here if necessary
return null; // Let routes map handle named routes
}, },
routes: { routes: {
// Auth Routes // Auth Routes
@ -314,7 +337,8 @@ class _RootAppState extends State<RootApp> {
'/river/manual/info': (context) => const RiverManualInfoCentreDocument(), '/river/manual/info': (context) => const RiverManualInfoCentreDocument(),
'/river/manual/in-situ': (context) => riverManualInSituSampling.RiverInSituSamplingScreen(), '/river/manual/in-situ': (context) => riverManualInSituSampling.RiverInSituSamplingScreen(),
'/river/manual/report': (context) => riverManualReport.RiverManualReport(), '/river/manual/report': (context) => riverManualReport.RiverManualReport(),
'/river/manual/triennial': (context) => riverManualTriennialSampling.RiverManualTriennialSamplingScreen(), '/river/manual/triennial': (context) =>
riverManualTriennialSampling.RiverManualTriennialSamplingScreen(),
'/river/manual/data-log': (context) => riverManualDataStatusLog.RiverManualDataStatusLog(), '/river/manual/data-log': (context) => riverManualDataStatusLog.RiverManualDataStatusLog(),
'/river/manual/image-request': (context) => riverManualImageRequest.RiverManualImageRequest(), '/river/manual/image-request': (context) => riverManualImageRequest.RiverManualImageRequest(),
@ -326,9 +350,15 @@ class _RootAppState extends State<RootApp> {
// River Investigative // River Investigative
'/river/investigative/info': (context) => const RiverInvestigativeInfoCentreDocument(), '/river/investigative/info': (context) => const RiverInvestigativeInfoCentreDocument(),
'/river/investigative/overview': (context) => riverInvestigativeOverview.OverviewScreen(), // *** ADDED: Route for River Investigative Manual Sampling ***
'/river/investigative/entry': (context) => riverInvestigativeEntry.EntryScreen(), '/river/investigative/manual-sampling': (context) =>
'/river/investigative/report': (context) => riverInvestigativeReport.ReportScreen(), riverInvestigativeManualSampling.RiverInvestigativeManualSamplingScreen(),
'/river/investigative/overview': (context) =>
riverInvestigativeOverview.OverviewScreen(), // Keep placeholder/future routes
'/river/investigative/entry': (context) =>
riverInvestigativeEntry.EntryScreen(), // Keep placeholder/future routes
'/river/investigative/report': (context) =>
riverInvestigativeReport.ReportScreen(), // Keep placeholder/future routes
// Marine Manual // Marine Manual
'/marine/manual/info': (context) => marineManualInfoCentreDocument.MarineInfoCentreDocument(), '/marine/manual/info': (context) => marineManualInfoCentreDocument.MarineInfoCentreDocument(),
@ -337,9 +367,12 @@ class _RootAppState extends State<RootApp> {
'/marine/manual/tarball': (context) => const TarballSamplingStep1(), '/marine/manual/tarball': (context) => const TarballSamplingStep1(),
'/marine/manual/report': (context) => const marineManualReport.MarineManualReportHomePage(), '/marine/manual/report': (context) => const marineManualReport.MarineManualReportHomePage(),
'/marine/manual/report/npe': (context) => const MarineManualNPEReportHub(), '/marine/manual/report/npe': (context) => const MarineManualNPEReportHub(),
'/marine/manual/report/pre-departure': (context) => const marineManualPreDepartureChecklist.MarineManualPreDepartureChecklistScreen(), '/marine/manual/report/pre-departure': (context) =>
'/marine/manual/report/calibration': (context) => const marineManualSondeCalibration.MarineManualSondeCalibrationScreen(), const marineManualPreDepartureChecklist.MarineManualPreDepartureChecklistScreen(),
'/marine/manual/report/maintenance': (context) => const marineManualEquipmentMaintenance.MarineManualEquipmentMaintenanceScreen(), '/marine/manual/report/calibration': (context) =>
const marineManualSondeCalibration.MarineManualSondeCalibrationScreen(),
'/marine/manual/report/maintenance': (context) =>
const marineManualEquipmentMaintenance.MarineManualEquipmentMaintenanceScreen(),
'/marine/manual/image-request': (context) => const marineManualImageRequest.MarineImageRequestScreen(), '/marine/manual/image-request': (context) => const marineManualImageRequest.MarineImageRequestScreen(),
// Marine Continuous // Marine Continuous
@ -350,6 +383,8 @@ class _RootAppState extends State<RootApp> {
// Marine Investigative // Marine Investigative
'/marine/investigative/info': (context) => const MarineInvestigativeInfoCentreDocument(), '/marine/investigative/info': (context) => const MarineInvestigativeInfoCentreDocument(),
'/marine/investigative/manual-sampling': (context) =>
marineInvestigativeManualSampling.MarineInvestigativeManualSampling(),
'/marine/investigative/overview': (context) => marineInvestigativeOverview.OverviewScreen(), '/marine/investigative/overview': (context) => marineInvestigativeOverview.OverviewScreen(),
'/marine/investigative/entry': (context) => marineInvestigativeEntry.EntryScreen(), '/marine/investigative/entry': (context) => marineInvestigativeEntry.EntryScreen(),
'/marine/investigative/report': (context) => marineInvestigativeReport.ReportScreen(), '/marine/investigative/report': (context) => marineInvestigativeReport.ReportScreen(),
@ -370,27 +405,62 @@ class SessionAwareWrapper extends StatefulWidget {
class _SessionAwareWrapperState extends State<SessionAwareWrapper> { class _SessionAwareWrapperState extends State<SessionAwareWrapper> {
bool _isDialogShowing = false; bool _isDialogShowing = false;
// --- MODIFICATION START ---
// 1. Create a variable to hold the AuthProvider instance.
late AuthProvider _authProvider;
// --- MODIFICATION END ---
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
final auth = Provider.of<AuthProvider>(context); // --- MODIFICATION START ---
// 2. Get the provider reference here and add the listener.
_authProvider = Provider.of<AuthProvider>(context);
_authProvider.addListener(_handleSessionExpired);
// --- MODIFICATION END ---
if (auth.isSessionExpired && !_isDialogShowing) { // Call initial check here if needed, or rely on RootApp's check.
// Use addPostFrameCallback to show dialog after the build phase. // _checkAndShowDialogIfNeeded(_authProvider.isSessionExpired);
}
@override
void dispose() {
// --- MODIFICATION START ---
// 3. Use the saved reference to remove the listener. This is safe.
_authProvider.removeListener(_handleSessionExpired);
// --- MODIFICATION END ---
super.dispose();
}
void _handleSessionExpired() {
// --- MODIFICATION START ---
// 4. Use the saved _authProvider reference.
_checkAndShowDialogIfNeeded(_authProvider.isSessionExpired);
// --- MODIFICATION END ---
}
void _checkAndShowDialogIfNeeded(bool isExpired) {
if (isExpired && !_isDialogShowing && mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_showSessionExpiredDialog(); if (mounted && !_isDialogShowing) { // Double check mounted and flag
_showSessionExpiredDialog();
}
}); });
} else if (!isExpired && _isDialogShowing && mounted) {
// If session becomes valid again and dialog is showing, maybe dismiss it?
// Or rely on user action. For now, we only trigger ON expiry.
} }
} }
Future<void> _showSessionExpiredDialog() async { Future<void> _showSessionExpiredDialog() async {
if (!mounted) return;
setState(() => _isDialogShowing = true); setState(() => _isDialogShowing = true);
await showDialog( await showDialog(
context: context, context: context,
barrierDismissible: false, // User must make a choice barrierDismissible: false,
builder: (BuildContext dialogContext) { builder: (BuildContext dialogContext) {
final auth = Provider.of<AuthProvider>(context, listen: false); // Use the state's _authProvider reference, which is safe.
return AlertDialog( return AlertDialog(
title: const Text("Session Expired"), title: const Text("Session Expired"),
content: const Text( content: const Text(
@ -399,22 +469,26 @@ class _SessionAwareWrapperState extends State<SessionAwareWrapper> {
TextButton( TextButton(
child: const Text("Continue Offline"), child: const Text("Continue Offline"),
onPressed: () { onPressed: () {
Navigator.of(dialogContext).pop(); // Just close the dialog Navigator.of(dialogContext).pop();
// Optionally: _authProvider.clearSessionExpiredFlag(); // If needed
}, },
), ),
ElevatedButton( ElevatedButton(
child: const Text("Login Now"), child: const Text("Login Now"),
onPressed: () { onPressed: () {
// Logout clears all state and pushes to login screen via the RootApp builder // --- MODIFICATION START ---
auth.logout(); // 5. Use the saved reference to log out.
Navigator.of(dialogContext).pop(); _authProvider.logout();
// --- MODIFICATION END ---
Navigator.of(dialogContext).pop(); // Close dialog first
// RootApp builder will handle navigation to LoginScreen
}, },
), ),
], ],
); );
}, },
); );
// Once the dialog is dismissed, reset the flag. // Reset flag after dialog is dismissed
if (mounted) { if (mounted) {
setState(() => _isDialogShowing = false); setState(() => _isDialogShowing = false);
} }
@ -422,7 +496,6 @@ class _SessionAwareWrapperState extends State<SessionAwareWrapper> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// This widget just returns its child, its only job is to show the dialog.
return widget.child; return widget.child;
} }
} }

View File

@ -301,36 +301,12 @@ class InSituSamplingData {
'npe_field_observations': npeFieldObservations, 'npe_field_observations': npeFieldObservations,
'npe_others_observation_remark': npeOthersObservationRemark, 'npe_others_observation_remark': npeOthersObservationRemark,
'npe_possible_source': npePossibleSource, 'npe_possible_source': npePossibleSource,
// Image paths will be added/updated by LocalStorageService during saving/updating
}; };
} }
String generateTelegramAlertMessage({required bool isDataOnly}) { // --- REMOVED: generateTelegramAlertMessage method ---
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; // This logic is now in MarineInSituSamplingService
final stationName = selectedStation?['man_station_name'] ?? 'N/A';
final stationCode = selectedStation?['man_station_code'] ?? 'N/A';
final buffer = StringBuffer()
..writeln('✅ *In-Situ Sample $submissionType Submitted:*')
..writeln()
..writeln('*Station Name & Code:* $stationName ($stationCode)')
..writeln('*Date of Submission:* $samplingDate')
..writeln('*Submitted by User:* $firstSamplerName')
..writeln('*Sonde ID:* ${sondeId ?? "N/A"}')
..writeln('*Status of Submission:* Successful');
if (distanceDifferenceInKm != null && distanceDifferenceInKm! > 0) {
buffer
..writeln()
..writeln('🔔 *Alert:*')
..writeln('*Distance from station:* ${(distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters');
if (distanceDifferenceRemarks != null && distanceDifferenceRemarks!.isNotEmpty) {
buffer.writeln('*Remarks for distance:* $distanceDifferenceRemarks');
}
}
return buffer.toString();
}
Map<String, String> toApiFormData() { Map<String, String> toApiFormData() {
final Map<String, String> map = {}; final Map<String, String> map = {};

View File

@ -1 +1,361 @@
// lib/models/marine_inves_manual_sampling_data.dart // lib/models/marine_inves_manual_sampling_data.dart
import 'dart:io';
import 'dart:convert'; // Added for jsonEncode
// REMOVED: import 'package:environment_monitoring_app/models/marine_manual_npe_report_data.dart'; // No longer needed
/// A data model class to hold all information for the multi-step
/// Marine Investigative Manual Sampling form.
class MarineInvesManualSamplingData {
// --- Step 1: Sampling & Station Info ---
String? firstSamplerName;
int? firstSamplerUserId;
Map<String, dynamic>? secondSampler;
String? samplingDate;
String? samplingTime;
String? samplingType;
String? sampleIdCode;
// --- NEW: Step 1 Station Selection ---
String? stationTypeSelection; // 'Existing Manual Station', 'Existing Tarball Station', 'New Location'
// For 'Existing Manual Station'
String? selectedManualStateName;
String? selectedManualCategoryName;
Map<String, dynamic>? selectedStation; // This is the MANUAL station
// For 'Existing Tarball Station'
String? selectedTarballStateName;
Map<String, dynamic>? selectedTarballStation;
// For 'New Location'
String? newStationName;
String? newStationCode;
// --- Common Station/Location Fields ---
String? stationLatitude; // Populated from selected station OR manually entered
String? stationLongitude; // Populated from selected station OR manually entered
String? currentLatitude;
String? currentLongitude;
double? distanceDifferenceInKm;
String? distanceDifferenceRemarks;
// --- Step 2: Site Info & Photos ---
String? weather;
String? tideLevel;
String? seaCondition;
String? eventRemarks;
String? labRemarks;
File? leftLandViewImage;
File? rightLandViewImage;
File? waterFillingImage;
File? seawaterColorImage;
File? phPaperImage;
File? optionalImage1;
String? optionalRemark1;
File? optionalImage2;
String? optionalRemark2;
File? optionalImage3;
String? optionalRemark3;
File? optionalImage4;
String? optionalRemark4;
// --- Step 3: Data Capture ---
String? sondeId;
String? dataCaptureDate;
String? dataCaptureTime;
double? oxygenConcentration;
double? oxygenSaturation;
double? ph;
double? salinity;
double? electricalConductivity;
double? temperature;
double? tds;
double? turbidity;
double? tss;
double? batteryVoltage;
// --- Post-Submission Status ---
String? submissionStatus;
String? submissionMessage;
String? reportId; // This will be 'man_inves_id' from the DB
// REMOVED: All NPE Report Compatibility Fields (npeFieldObservations, npeOthersObservationRemark, etc.)
MarineInvesManualSamplingData({
this.samplingDate,
this.samplingTime,
this.stationTypeSelection = 'Existing Manual Station', // Default value
});
// REMOVED: toNpeReportData() method
/// Creates a single JSON object with all submission data for offline storage.
Map<String, dynamic> toDbJson() {
return {
// Step 1
'first_sampler_name': firstSamplerName,
'first_sampler_user_id': firstSamplerUserId,
'secondSampler': secondSampler,
'sampling_date': samplingDate,
'sampling_time': samplingTime,
'sampling_type': samplingType,
'sample_id_code': sampleIdCode,
'stationTypeSelection': stationTypeSelection,
'selectedManualStateName': selectedManualStateName,
'selectedManualCategoryName': selectedManualCategoryName,
'selectedStation': selectedStation,
'selectedTarballStateName': selectedTarballStateName,
'selectedTarballStation': selectedTarballStation,
'newStationName': newStationName,
'newStationCode': newStationCode,
'station_latitude': stationLatitude,
'station_longitude': stationLongitude,
'current_latitude': currentLatitude,
'current_longitude': currentLongitude,
'distance_difference_in_km': distanceDifferenceInKm,
'distance_difference_remarks': distanceDifferenceRemarks,
// Step 2
'weather': weather,
'tide_level': tideLevel,
'sea_condition': seaCondition,
'event_remarks': eventRemarks,
'lab_remarks': labRemarks,
'inves_optional_photo_01_remarks': optionalRemark1,
'inves_optional_photo_02_remarks': optionalRemark2,
'inves_optional_photo_03_remarks': optionalRemark3,
'inves_optional_photo_04_remarks': optionalRemark4,
// Step 3
'sonde_id': sondeId,
'data_capture_date': dataCaptureDate,
'data_capture_time': dataCaptureTime,
'oxygen_concentration': oxygenConcentration,
'oxygen_saturation': oxygenSaturation,
'ph': ph,
'salinity': salinity,
'electrical_conductivity': electricalConductivity,
'temperature': temperature,
'tds': tds,
'turbidity': turbidity,
'tss': tss,
'battery_voltage': batteryVoltage,
// Status
'submission_status': submissionStatus,
'submission_message': submissionMessage,
'report_id': reportId,
// REMOVED: NPE fields from JSON
// Image paths will be added by LocalStorageService during save
'inves_left_side_land_view': leftLandViewImage?.path,
'inves_right_side_land_view': rightLandViewImage?.path,
'inves_filling_water_into_sample_bottle': waterFillingImage?.path,
'inves_seawater_in_clear_glass_bottle': seawaterColorImage?.path,
'inves_examine_preservative_ph_paper': phPaperImage?.path,
'inves_optional_photo_01': optionalImage1?.path,
'inves_optional_photo_02': optionalImage2?.path,
'inves_optional_photo_03': optionalImage3?.path,
'inves_optional_photo_04': optionalImage4?.path,
};
}
/// Creates an InSituSamplingData object from a JSON map (for offline logs).
factory MarineInvesManualSamplingData.fromJson(Map<String, dynamic> json) {
double? doubleFromJson(dynamic value) {
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
int? intFromJson(dynamic value) {
if (value is int) return value;
if (value is String) return int.tryParse(value);
return null;
}
File? fileFromPath(dynamic path) {
// Ensure path is not null and not empty before creating File object
return (path is String && path.isNotEmpty) ? File(path) : null;
}
final data = MarineInvesManualSamplingData();
// Step 1
data.firstSamplerName = json['first_sampler_name'];
data.firstSamplerUserId = intFromJson(json['first_sampler_user_id']);
data.secondSampler = json['secondSampler']; // Assumes it's stored correctly as JSON Map
data.samplingDate = json['sampling_date'];
data.samplingTime = json['sampling_time'];
data.samplingType = json['sampling_type'];
data.sampleIdCode = json['sample_id_code'];
data.stationTypeSelection = json['stationTypeSelection'];
data.selectedManualStateName = json['selectedManualStateName'];
data.selectedManualCategoryName = json['selectedManualCategoryName'];
data.selectedStation = json['selectedStation']; // Assumes it's stored correctly as JSON Map
data.selectedTarballStateName = json['selectedTarballStateName'];
data.selectedTarballStation = json['selectedTarballStation']; // Assumes it's stored correctly as JSON Map
data.newStationName = json['newStationName'];
data.newStationCode = json['newStationCode'];
data.stationLatitude = json['station_latitude']?.toString(); // Ensure conversion to String
data.stationLongitude = json['station_longitude']?.toString(); // Ensure conversion to String
data.currentLatitude = json['current_latitude']?.toString(); // Ensure conversion to String
data.currentLongitude = json['current_longitude']?.toString(); // Ensure conversion to String
data.distanceDifferenceInKm = doubleFromJson(json['distance_difference_in_km']);
data.distanceDifferenceRemarks = json['distance_difference_remarks'];
// Step 2
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['inves_optional_photo_01_remarks'];
data.optionalRemark2 = json['inves_optional_photo_02_remarks'];
data.optionalRemark3 = json['inves_optional_photo_03_remarks'];
data.optionalRemark4 = json['inves_optional_photo_04_remarks'];
// Step 2 Images (Paths stored in JSON)
data.leftLandViewImage = fileFromPath(json['inves_left_side_land_view']);
data.rightLandViewImage = fileFromPath(json['inves_right_side_land_view']);
data.waterFillingImage = fileFromPath(json['inves_filling_water_into_sample_bottle']);
data.seawaterColorImage = fileFromPath(json['inves_seawater_in_clear_glass_bottle']);
data.phPaperImage = fileFromPath(json['inves_examine_preservative_ph_paper']);
data.optionalImage1 = fileFromPath(json['inves_optional_photo_01']);
data.optionalImage2 = fileFromPath(json['inves_optional_photo_02']);
data.optionalImage3 = fileFromPath(json['inves_optional_photo_03']);
data.optionalImage4 = fileFromPath(json['inves_optional_photo_04']);
// Step 3
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']);
// Status
data.submissionStatus = json['submission_status'];
data.submissionMessage = json['submission_message'];
data.reportId = json['report_id']?.toString(); // Ensure conversion to String
// REMOVED: NPE fields from deserialization
return data;
}
/// Maps data to keys for the API submission.
Map<String, String> toApiFormData() {
final Map<String, String> map = {};
void add(String key, dynamic value) {
if (value != null) {
String stringValue;
if (value is double) {
// Handle special -999.0 value
if (value == -999.0) {
stringValue = '-999';
} else {
// Format other doubles to 5 decimal places
stringValue = value.toStringAsFixed(5);
}
} else {
// Convert other types directly to string
stringValue = value.toString();
}
// Only add if the resulting string is not empty
if (stringValue.isNotEmpty) {
map[key] = stringValue;
}
}
}
// Add prefix 'inves_' to all keys to match new backend endpoints
add('inves_date', samplingDate);
add('inves_time', samplingTime);
add('first_sampler_user_id', firstSamplerUserId);
add('inves_second_sampler_id', secondSampler?['user_id']);
add('inves_type', samplingType);
add('inves_sample_id_code', sampleIdCode);
add('inves_current_latitude', currentLatitude);
add('inves_current_longitude', currentLongitude);
add('inves_distance_difference', distanceDifferenceInKm);
add('inves_distance_difference_remarks', distanceDifferenceRemarks);
// --- NEW: Add station selection logic ---
add('inves_station_type', stationTypeSelection);
if (stationTypeSelection == 'Existing Manual Station') {
add('station_id', selectedStation?['station_id']); // Foreign key to manual stations
add('inves_station_code', selectedStation?['man_station_code']);
add('inves_station_name', selectedStation?['man_station_name']);
} else if (stationTypeSelection == 'Existing Tarball Station') {
add('tbl_station_id', selectedTarballStation?['station_id']); // Foreign key to tarball stations
add('inves_station_code', selectedTarballStation?['tbl_station_code']);
add('inves_station_name', selectedTarballStation?['tbl_station_name']);
} else if (stationTypeSelection == 'New Location') {
add('inves_new_station_name', newStationName);
add('inves_new_station_code', newStationCode);
add('inves_station_latitude', stationLatitude); // Manually entered lat
add('inves_station_longitude', stationLongitude); // Manually entered lon
}
// --- END NEW ---
add('inves_weather', weather);
add('inves_tide_level', tideLevel);
add('inves_sea_condition', seaCondition);
add('inves_event_remark', eventRemarks);
add('inves_lab_remark', labRemarks);
add('inves_optional_photo_01_remarks', optionalRemark1);
add('inves_optional_photo_02_remarks', optionalRemark2);
add('inves_optional_photo_03_remarks', optionalRemark3);
add('inves_optional_photo_04_remarks', optionalRemark4);
add('inves_sondeID', sondeId);
add('data_capture_date', dataCaptureDate); // Note: No 'inves_' prefix assumed based on original model
add('data_capture_time', dataCaptureTime); // Note: No 'inves_' prefix assumed based on original model
add('inves_oxygen_conc', oxygenConcentration);
add('inves_oxygen_sat', oxygenSaturation);
add('inves_ph', ph);
add('inves_salinity', salinity);
add('inves_conductivity', electricalConductivity);
add('inves_temperature', temperature);
add('inves_tds', tds);
add('inves_turbidity', turbidity);
add('inves_tss', tss);
add('inves_battery_volt', batteryVoltage);
add('first_sampler_name', firstSamplerName); // For logging/display purposes on backend if needed
return map;
}
/// Maps image files to keys for the API submission.
Map<String, File?> toApiImageFiles() {
return {
// Add prefix 'inves_' to match backend expectations
'inves_left_side_land_view': leftLandViewImage,
'inves_right_side_land_view': rightLandViewImage,
'inves_filling_water_into_sample_bottle': waterFillingImage,
'inves_seawater_in_clear_glass_bottle': seawaterColorImage,
'inves_examine_preservative_ph_paper': phPaperImage,
'inves_optional_photo_01': optionalImage1,
'inves_optional_photo_02': optionalImage2,
'inves_optional_photo_03': optionalImage3,
'inves_optional_photo_04': optionalImage4,
};
}
// --- START: REMOVED generateInvestigativeTelegramAlertMessage ---
// This logic is now handled in MarineInvestigativeSamplingService
// --- END: REMOVED ---
}

View File

@ -164,7 +164,23 @@ class RiverInSituSamplingData {
void add(String key, dynamic value) { void add(String key, dynamic value) {
if (value != null) { if (value != null) {
map[key] = value.toString(); String stringValue;
// --- START FIX: Handle -999.0 correctly ---
if (value is double) {
if (value == -999.0) {
stringValue = '-999';
} else {
stringValue = value.toStringAsFixed(5);
}
} else {
stringValue = value.toString();
}
// --- END FIX ---
// Only add non-empty values
if (stringValue.isNotEmpty) {
map[key] = stringValue;
}
} }
} }
@ -175,7 +191,9 @@ class RiverInSituSamplingData {
add('r_man_time', samplingTime); add('r_man_time', samplingTime);
add('r_man_type', samplingType); add('r_man_type', samplingType);
add('r_man_sample_id_code', sampleIdCode); add('r_man_sample_id_code', sampleIdCode);
// --- START FIX: Use correct key 'station_id' ---
add('station_id', selectedStation?['station_id']); add('station_id', selectedStation?['station_id']);
// --- END FIX ---
add('r_man_current_latitude', currentLatitude); add('r_man_current_latitude', currentLatitude);
add('r_man_current_longitude', currentLongitude); add('r_man_current_longitude', currentLongitude);
add('r_man_distance_difference', distanceDifferenceInKm); add('r_man_distance_difference', distanceDifferenceInKm);
@ -304,43 +322,48 @@ class RiverInSituSamplingData {
// This is a direct conversion of the model's properties to a map, // This is a direct conversion of the model's properties to a map,
// with keys matching the expected JSON file format. // with keys matching the expected JSON file format.
final data = { final data = {
'battery_cap': batteryVoltage, // --- START FIX: Map model properties to correct db.json keys ---
'battery_cap': batteryVoltage == -999.0 ? null : batteryVoltage, // Handle -999
'device_name': sondeId, 'device_name': sondeId,
'sampling_type': samplingType, 'sampling_type': samplingType,
'report_id': reportId, 'report_id': reportId,
'sampler_2ndname': secondSampler?['user_name'], 'sampler_2ndname': secondSampler?['first_name'], // Use first_name as likely user name
'sample_state': selectedStateName, 'sample_state': selectedStateName,
'station_id': selectedStation?['sampling_station_code'], 'station_id': selectedStation?['sampling_station_code'],
'tech_id': firstSamplerUserId, 'tech_id': firstSamplerUserId,
'tech_name': firstSamplerName, 'tech_name': firstSamplerName,
'latitude': stationLatitude, 'latitude': stationLatitude, // Assuming station lat/lon is intended here
'longitude': stationLongitude, 'longitude': stationLongitude, // Assuming station lat/lon is intended here
'record_dt': '$samplingDate $samplingTime', 'record_dt': '$samplingDate $samplingTime',
'do_mgl': oxygenConcentration, 'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration,
'do_sat': oxygenSaturation, 'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation,
'ph': ph, 'ph': ph == -999.0 ? null : ph,
'salinity': salinity, 'salinity': salinity == -999.0 ? null : salinity,
'temperature': temperature, 'temperature': temperature == -999.0 ? null : temperature,
'turbidity': turbidity, 'turbidity': turbidity == -999.0 ? null : turbidity,
'tds': tds, 'tds': tds == -999.0 ? null : tds,
'electric_conductivity': electricalConductivity, 'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity,
'ammonia': ammonia, // MODIFIED: Added ammonia 'ammonia': ammonia == -999.0 ? null : ammonia,
'flowrate': flowrateValue, 'flowrate': flowrateValue,
'odour': '', // Assuming these are not collected in this form 'odour': '', // Not collected
'floatable': '', // Assuming these are not collected in this form 'floatable': '', // Not collected
'sample_id': sampleIdCode, 'sample_id': sampleIdCode,
'weather': weather, 'weather': weather,
'remarks_event': eventRemarks, 'remarks_event': eventRemarks,
'remarks_lab': labRemarks, 'remarks_lab': labRemarks,
// --- END FIX ---
}; };
// Remove null values before encoding
data.removeWhere((key, value) => value == null);
return jsonEncode(data); return jsonEncode(data);
} }
/// Creates a JSON object for basic form info, mimicking 'river_insitu_basic_form.json'. /// Creates a JSON object for basic form info, mimicking 'river_insitu_basic_form.json'.
String toBasicFormJson() { String toBasicFormJson() {
final data = { final data = {
// --- START FIX: Map model properties to correct form keys ---
'tech_name': firstSamplerName, 'tech_name': firstSamplerName,
'sampler_2ndname': secondSampler?['user_name'], 'sampler_2ndname': secondSampler?['first_name'],
'sample_date': samplingDate, 'sample_date': samplingDate,
'sample_time': samplingTime, 'sample_time': samplingTime,
'sampling_type': samplingType, 'sampling_type': samplingType,
@ -348,39 +371,50 @@ class RiverInSituSamplingData {
'station_id': selectedStation?['sampling_station_code'], 'station_id': selectedStation?['sampling_station_code'],
'station_latitude': stationLatitude, 'station_latitude': stationLatitude,
'station_longitude': stationLongitude, 'station_longitude': stationLongitude,
'latitude': currentLatitude, 'latitude': currentLatitude, // Current location lat
'longitude': currentLongitude, 'longitude': currentLongitude, // Current location lon
'sample_id': sampleIdCode, 'sample_id': sampleIdCode,
// --- END FIX ---
}; };
// Remove null values before encoding
data.removeWhere((key, value) => value == null);
return jsonEncode(data); return jsonEncode(data);
} }
/// Creates a JSON object for sensor readings, mimicking 'river_sampling_reading.json'. /// Creates a JSON object for sensor readings, mimicking 'river_sampling_reading.json'.
String toReadingJson() { String toReadingJson() {
final data = { final data = {
'do_mgl': oxygenConcentration, // --- START FIX: Map model properties to correct reading keys ---
'do_sat': oxygenSaturation, 'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration,
'ph': ph, 'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation,
'salinity': salinity, 'ph': ph == -999.0 ? null : ph,
'temperature': temperature, 'salinity': salinity == -999.0 ? null : salinity,
'turbidity': turbidity, 'temperature': temperature == -999.0 ? null : temperature,
'tds': tds, 'turbidity': turbidity == -999.0 ? null : turbidity,
'electric_conductivity': electricalConductivity, 'tds': tds == -999.0 ? null : tds,
'ammonia': ammonia, // MODIFIED: Added ammonia 'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity,
'ammonia': ammonia == -999.0 ? null : ammonia,
'flowrate': flowrateValue, 'flowrate': flowrateValue,
'date_sampling_reading': samplingDate, 'date_sampling_reading': dataCaptureDate, // Use data capture date/time
'time_sampling_reading': samplingTime, 'time_sampling_reading': dataCaptureTime, // Use data capture date/time
// --- END FIX ---
}; };
// Remove null values before encoding
data.removeWhere((key, value) => value == null);
return jsonEncode(data); return jsonEncode(data);
} }
/// Creates a JSON object for manual info, mimicking 'river_manual_info.json'. /// Creates a JSON object for manual info, mimicking 'river_manual_info.json'.
String toManualInfoJson() { String toManualInfoJson() {
final data = { final data = {
// --- START FIX: Map model properties to correct manual info keys ---
'weather': weather, 'weather': weather,
'remarks_event': eventRemarks, 'remarks_event': eventRemarks,
'remarks_lab': labRemarks, 'remarks_lab': labRemarks,
// --- END FIX ---
}; };
// Remove null values before encoding
data.removeWhere((key, value) => value == null);
return jsonEncode(data); return jsonEncode(data);
} }
} }

View File

@ -0,0 +1,520 @@
// lib/models/river_inves_manual_sampling_data.dart
import 'dart:io';
import 'dart:convert'; // Added for jsonEncode
/// Data model for the multi-step River Investigative Manual Sampling form.
class RiverInvesManualSamplingData {
// --- Step 1: Sampling & Station Info ---
String? firstSamplerName;
int? firstSamplerUserId;
Map<String, dynamic>? secondSampler;
String? samplingDate;
String? samplingTime;
String? samplingType = 'Investigative'; // Defaulted for this module
String? sampleIdCode;
// --- NEW: Station Type Selection ---
String? stationTypeSelection; // 'Existing Manual Station', 'Existing Triennial Station', 'New Location'
// --- Existing Station Fields ---
String? selectedStateName; // Used by Manual/Triennial and as input for New
String? selectedCategoryName; // Potentially relevant context? Keep for now.
Map<String, dynamic>? selectedStation; // Holds selected MANUAL station
Map<String, dynamic>? selectedTriennialStation; // Holds selected TRIENNIAL station
// --- New Location Fields ---
String? newStateName; // Specifically for New Location state input
String? newBasinName;
String? newRiverName;
// *** ADDED: newStationName field ***
String? newStationName; // User-defined name for the new location
// *** END ADDED ***
String? newStationCode; // Optional user-defined code for new location
// --- Location Fields ---
String? stationLatitude; // Derived from selected station OR user input for New
String? stationLongitude; // Derived from selected station OR user input for New
String? currentLatitude;
String? currentLongitude;
double? distanceDifferenceInKm;
String? distanceDifferenceRemarks;
// --- Step 2: Site Info & Photos ---
String? weather;
String? eventRemarks;
String? labRemarks;
File? backgroundStationImage;
File? upstreamRiverImage;
File? downstreamRiverImage;
// --- Step 4: Additional Photos ---
File? sampleTurbidityImage;
File? optionalImage1;
String? optionalRemark1;
File? optionalImage2;
String? optionalRemark2;
File? optionalImage3;
String? optionalRemark3;
File? optionalImage4;
String? optionalRemark4;
// --- Step 3: Data Capture ---
String? sondeId;
String? dataCaptureDate;
String? dataCaptureTime;
double? oxygenConcentration;
double? oxygenSaturation;
double? ph;
double? salinity;
double? electricalConductivity;
double? temperature;
double? tds;
double? turbidity;
double? ammonia; // Replaced tss
double? batteryVoltage;
// Flowrate properties (same as In-Situ)
String? flowrateMethod; // 'Surface Drifter', 'Flowmeter', 'NA'
double? flowrateSurfaceDrifterHeight;
double? flowrateSurfaceDrifterDistance;
String? flowrateSurfaceDrifterTimeFirst;
String? flowrateSurfaceDrifterTimeLast;
double? flowrateValue;
// --- Post-Submission Status ---
String? submissionStatus;
String? submissionMessage;
String? reportId; // Assuming the API returns an ID (e.g., r_inv_id)
RiverInvesManualSamplingData({
this.samplingDate,
this.samplingTime,
});
// Helper to get the determined station code regardless of type
String? getDeterminedStationCode() {
if (stationTypeSelection == 'Existing Manual Station') {
return selectedStation?['sampling_station_code'];
} else if (stationTypeSelection == 'Existing Triennial Station') {
return selectedTriennialStation?['triennial_station_code'];
} else if (stationTypeSelection == 'New Location') {
return newStationCode; // Use user-provided code or null
}
return null;
}
// Helper to get determined State Name
String? getDeterminedStateName() {
if (stationTypeSelection == 'Existing Manual Station' || stationTypeSelection == 'Existing Triennial Station') {
return selectedStateName;
} else if (stationTypeSelection == 'New Location') {
return newStateName;
}
return null;
}
// Helper to get determined River Name
String? getDeterminedRiverName() {
if (stationTypeSelection == 'Existing Manual Station') {
return selectedStation?['sampling_river'];
} else if (stationTypeSelection == 'Existing Triennial Station') {
return selectedTriennialStation?['triennial_river'];
} else if (stationTypeSelection == 'New Location') {
return newRiverName;
}
return null;
}
// Helper to get determined Basin Name
String? getDeterminedBasinName() {
if (stationTypeSelection == 'Existing Manual Station') {
return selectedStation?['sampling_basin'];
} else if (stationTypeSelection == 'Existing Triennial Station') {
return selectedTriennialStation?['triennial_basin'];
} else if (stationTypeSelection == 'New Location') {
return newBasinName;
}
return null;
}
// *** ADDED: getDeterminedStationName helper ***
String? getDeterminedStationName() {
// This combines River Name for existing stations or the New Station Name
if (stationTypeSelection == 'Existing Manual Station') {
return selectedStation?['sampling_river']; // Use river name as station name contextually
} else if (stationTypeSelection == 'Existing Triennial Station') {
return selectedTriennialStation?['triennial_river']; // Use river name as station name contextually
} else if (stationTypeSelection == 'New Location') {
return newStationName; // Use the specific name given for the new location
}
return null;
}
// *** END ADDED ***
factory RiverInvesManualSamplingData.fromJson(Map<String, dynamic> json) {
File? fileFromJson(dynamic path) {
return (path is String && path.isNotEmpty) ? File(path) : null;
}
double? doubleFromJson(dynamic value) {
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
int? intFromJson(dynamic value) {
if (value is int) return value;
if (value is String) return int.tryParse(value);
return null;
}
// Adapted from RiverInSituSamplingData.fromJson
return RiverInvesManualSamplingData()
// Step 1
..firstSamplerName = json['firstSamplerName']
..firstSamplerUserId = intFromJson(json['firstSamplerUserId'])
..secondSampler = json['secondSampler']
..samplingDate = json['samplingDate']
..samplingTime = json['samplingTime']
..samplingType = json['samplingType'] ?? 'Investigative'
..sampleIdCode = json['sampleIdCode']
..stationTypeSelection = json['stationTypeSelection']
..selectedStateName = json['selectedStateName'] // State for existing stations
..selectedCategoryName = json['selectedCategoryName']
..selectedStation = json['selectedStation'] // Manual
..selectedTriennialStation = json['selectedTriennialStation'] // Triennial
..newStateName = json['newStateName'] // New Location state
..newBasinName = json['newBasinName']
..newRiverName = json['newRiverName']
..newStationName = json['newStationName'] // Load newStationName
..newStationCode = json['newStationCode']
..stationLatitude = json['stationLatitude']
..stationLongitude = json['stationLongitude']
..currentLatitude = json['currentLatitude']?.toString()
..currentLongitude = json['currentLongitude']?.toString()
..distanceDifferenceInKm = doubleFromJson(json['distanceDifferenceInKm'])
..distanceDifferenceRemarks = json['distanceDifferenceRemarks']
// Step 2
..weather = json['weather']
..eventRemarks = json['eventRemarks']
..labRemarks = json['labRemarks']
..backgroundStationImage = fileFromJson(json['backgroundStationImage'])
..upstreamRiverImage = fileFromJson(json['upstreamRiverImage'])
..downstreamRiverImage = fileFromJson(json['downstreamRiverImage'])
// Step 4
..sampleTurbidityImage = fileFromJson(json['sampleTurbidityImage'])
..optionalImage1 = fileFromJson(json['optionalImage1'])
..optionalRemark1 = json['optionalRemark1']
..optionalImage2 = fileFromJson(json['optionalImage2'])
..optionalRemark2 = json['optionalRemark2']
..optionalImage3 = fileFromJson(json['optionalImage3'])
..optionalRemark3 = json['optionalRemark3']
..optionalImage4 = fileFromJson(json['optionalImage4'])
..optionalRemark4 = json['optionalRemark4']
// Step 3
..sondeId = json['sondeId']
..dataCaptureDate = json['dataCaptureDate']
..dataCaptureTime = json['dataCaptureTime']
..oxygenConcentration = doubleFromJson(json['oxygenConcentration'])
..oxygenSaturation = doubleFromJson(json['oxygenSaturation'])
..ph = doubleFromJson(json['ph'])
..salinity = doubleFromJson(json['salinity'])
..electricalConductivity = doubleFromJson(json['electricalConductivity'])
..temperature = doubleFromJson(json['temperature'])
..tds = doubleFromJson(json['tds'])
..turbidity = doubleFromJson(json['turbidity'])
..ammonia = doubleFromJson(json['ammonia'])
..batteryVoltage = doubleFromJson(json['batteryVoltage'])
..flowrateMethod = json['flowrateMethod']
..flowrateSurfaceDrifterHeight = doubleFromJson(json['flowrateSurfaceDrifterHeight'])
..flowrateSurfaceDrifterDistance = doubleFromJson(json['flowrateSurfaceDrifterDistance'])
..flowrateSurfaceDrifterTimeFirst = json['flowrateSurfaceDrifterTimeFirst']
..flowrateSurfaceDrifterTimeLast = json['flowrateSurfaceDrifterTimeLast']
..flowrateValue = doubleFromJson(json['flowrateValue'])
// Status
..submissionStatus = json['submissionStatus']
..submissionMessage = json['submissionMessage']
..reportId = json['reportId']?.toString();
}
/// Converts the data model into a Map<String, dynamic> for saving/logging locally.
Map<String, dynamic> toMap() {
return {
'firstSamplerName': firstSamplerName,
'firstSamplerUserId': firstSamplerUserId,
'secondSampler': secondSampler,
'samplingDate': samplingDate,
'samplingTime': samplingTime,
'samplingType': samplingType,
'sampleIdCode': sampleIdCode,
'stationTypeSelection': stationTypeSelection,
'selectedStateName': selectedStateName,
'selectedCategoryName': selectedCategoryName,
'selectedStation': selectedStation, // Manual
'selectedTriennialStation': selectedTriennialStation, // Triennial
'newStateName': newStateName, // New Loc
'newBasinName': newBasinName,
'newRiverName': newRiverName,
'newStationName': newStationName, // Include newStationName
'newStationCode': newStationCode,
'stationLatitude': stationLatitude,
'stationLongitude': stationLongitude,
'currentLatitude': currentLatitude,
'currentLongitude': currentLongitude,
'distanceDifferenceInKm': distanceDifferenceInKm,
'distanceDifferenceRemarks': distanceDifferenceRemarks,
'weather': weather,
'eventRemarks': eventRemarks,
'labRemarks': labRemarks,
'backgroundStationImage': backgroundStationImage?.path,
'upstreamRiverImage': upstreamRiverImage?.path,
'downstreamRiverImage': downstreamRiverImage?.path,
'sampleTurbidityImage': sampleTurbidityImage?.path,
'optionalImage1': optionalImage1?.path,
'optionalRemark1': optionalRemark1,
'optionalImage2': optionalImage2?.path,
'optionalRemark2': optionalRemark2,
'optionalImage3': optionalImage3?.path,
'optionalRemark3': optionalRemark3,
'optionalImage4': optionalImage4?.path,
'optionalRemark4': optionalRemark4,
'sondeId': sondeId,
'dataCaptureDate': dataCaptureDate,
'dataCaptureTime': dataCaptureTime,
'oxygenConcentration': oxygenConcentration,
'oxygenSaturation': oxygenSaturation,
'ph': ph,
'salinity': salinity,
'electricalConductivity': electricalConductivity,
'temperature': temperature,
'tds': tds,
'turbidity': turbidity,
'ammonia': ammonia,
'batteryVoltage': batteryVoltage,
'flowrateMethod': flowrateMethod,
'flowrateSurfaceDrifterHeight': flowrateSurfaceDrifterHeight,
'flowrateSurfaceDrifterDistance': flowrateSurfaceDrifterDistance,
'flowrateSurfaceDrifterTimeFirst': flowrateSurfaceDrifterTimeFirst,
'flowrateSurfaceDrifterTimeLast': flowrateSurfaceDrifterTimeLast,
'flowrateValue': flowrateValue,
'submissionStatus': submissionStatus,
'submissionMessage': submissionMessage,
'reportId': reportId,
};
}
/// Converts the data model into a Map<String, String> for the API form data.
/// Keys should match the expected API endpoint fields for Investigative sampling.
Map<String, String> toApiFormData() {
final Map<String, String> map = {};
void add(String key, dynamic value) {
if (value != null) {
String stringValue;
if (value is double) {
stringValue = (value == -999.0) ? '-999' : value.toStringAsFixed(5);
} else {
stringValue = value.toString();
}
if (stringValue.isNotEmpty) {
map[key] = stringValue;
}
}
}
// Sampler & Time Info (Assuming same API keys as manual)
add('first_sampler_user_id', firstSamplerUserId);
add('r_inv_second_sampler_id', secondSampler?['user_id']); // Prefixed inv?
add('r_inv_date', samplingDate);
add('r_inv_time', samplingTime);
add('r_inv_type', samplingType); // Should be 'Investigative'
add('r_inv_sample_id_code', sampleIdCode);
// Station Info (Conditional)
add('r_inv_station_type', stationTypeSelection);
if (stationTypeSelection == 'Existing Manual Station') {
add('station_id', selectedStation?['station_id']); // Assuming API wants the numeric ID
add('r_inv_station_code', selectedStation?['sampling_station_code']); // Add code for display/logging if needed
} else if (stationTypeSelection == 'Existing Triennial Station') {
add('triennial_station_id', selectedTriennialStation?['station_id']); // Assuming a different key
add('r_inv_station_code', selectedTriennialStation?['triennial_station_code']);
} else if (stationTypeSelection == 'New Location') {
add('r_inv_new_state_name', newStateName);
add('r_inv_new_basin_name', newBasinName);
add('r_inv_new_river_name', newRiverName);
add('r_inv_new_station_name', newStationName); // Include newStationName
add('r_inv_new_station_code', newStationCode); // Optional code
add('r_inv_station_latitude', stationLatitude); // Use the captured/entered lat/lon
add('r_inv_station_longitude', stationLongitude);
}
// Location Verification (Assuming same keys)
add('r_inv_current_latitude', currentLatitude);
add('r_inv_current_longitude', currentLongitude);
add('r_inv_distance_difference', distanceDifferenceInKm);
add('r_inv_distance_difference_remarks', distanceDifferenceRemarks);
// Site Info (Assuming same keys)
add('r_inv_weather', weather);
add('r_inv_event_remark', eventRemarks);
add('r_inv_lab_remark', labRemarks);
// Optional Remarks (Assuming same keys)
add('r_inv_optional_photo_01_remarks', optionalRemark1);
add('r_inv_optional_photo_02_remarks', optionalRemark2);
add('r_inv_optional_photo_03_remarks', optionalRemark3);
add('r_inv_optional_photo_04_remarks', optionalRemark4);
// Parameters (Assuming same keys)
add('r_inv_sondeID', sondeId);
add('data_capture_date', dataCaptureDate); // Reuse generic keys?
add('data_capture_time', dataCaptureTime); // Reuse generic keys?
add('r_inv_oxygen_conc', oxygenConcentration);
add('r_inv_oxygen_sat', oxygenSaturation);
add('r_inv_ph', ph);
add('r_inv_salinity', salinity);
add('r_inv_conductivity', electricalConductivity);
add('r_inv_temperature', temperature);
add('r_inv_tds', tds);
add('r_inv_turbidity', turbidity);
add('r_inv_ammonia', ammonia);
add('r_inv_battery_volt', batteryVoltage);
// Flowrate (Assuming same keys)
add('r_inv_flowrate_method', flowrateMethod);
add('r_inv_flowrate_sd_height', flowrateSurfaceDrifterHeight);
add('r_inv_flowrate_sd_distance', flowrateSurfaceDrifterDistance);
add('r_inv_flowrate_sd_time_first', flowrateSurfaceDrifterTimeFirst);
add('r_inv_flowrate_sd_time_last', flowrateSurfaceDrifterTimeLast);
add('r_inv_flowrate_value', flowrateValue);
// Additional data that might be useful for display or if API needs it redundantly
add('first_sampler_name', firstSamplerName);
add('determined_state_name', getDeterminedStateName()); // Add determined values
add('determined_basin_name', getDeterminedBasinName());
add('determined_river_name', getDeterminedRiverName());
add('determined_station_name', getDeterminedStationName()); // Add determined station name
return map;
}
/// Converts the image properties into a Map<String, File?> for the multipart API request.
/// Keys should match the expected API endpoint fields for Investigative images.
Map<String, File?> toApiImageFiles() {
// Assuming same keys as manual, but prefixed with r_inv_?
return {
'r_inv_background_station': backgroundStationImage,
'r_inv_upstream_river': upstreamRiverImage,
'r_inv_downstream_river': downstreamRiverImage,
'r_inv_sample_turbidity': sampleTurbidityImage,
'r_inv_optional_photo_01': optionalImage1,
'r_inv_optional_photo_02': optionalImage2,
'r_inv_optional_photo_03': optionalImage3,
'r_inv_optional_photo_04': optionalImage4,
};
}
/// Creates a single JSON object for FTP 'db.json', mimicking River In-Situ structure.
String toDbJson() {
final data = {
'battery_cap': batteryVoltage == -999.0 ? null : batteryVoltage,
'device_name': sondeId,
'sampling_type': samplingType, // 'Investigative'
'report_id': reportId,
'sampler_2ndname': secondSampler?['first_name'],
'sample_state': getDeterminedStateName(), // Use determined state
'station_id': getDeterminedStationCode(), // Use determined code
'tech_id': firstSamplerUserId,
'tech_name': firstSamplerName,
'latitude': stationLatitude, // Use captured/selected station lat
'longitude': stationLongitude, // Use captured/selected station lon
'record_dt': '$samplingDate $samplingTime',
'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration,
'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation,
'ph': ph == -999.0 ? null : ph,
'salinity': salinity == -999.0 ? null : salinity,
'temperature': temperature == -999.0 ? null : temperature,
'turbidity': turbidity == -999.0 ? null : turbidity,
'tds': tds == -999.0 ? null : tds,
'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity,
'ammonia': ammonia == -999.0 ? null : ammonia,
'flowrate': flowrateValue,
'odour': '', // Not collected
'floatable': '', // Not collected
'sample_id': sampleIdCode,
'weather': weather,
'remarks_event': eventRemarks,
'remarks_lab': labRemarks,
// --- Add Investigative Specific fields if needed by FTP structure ---
'station_type': stationTypeSelection, // e.g., 'New Location'
'new_basin': stationTypeSelection == 'New Location' ? newBasinName : null,
'new_river': stationTypeSelection == 'New Location' ? newRiverName : null,
'new_station_name': stationTypeSelection == 'New Location' ? newStationName : null, // Include newStationName
};
data.removeWhere((key, value) => value == null);
return jsonEncode(data);
}
/// Creates JSON for FTP 'river_inves_basic_form.json' (mimicking In-Situ).
String toBasicFormJson() {
final data = {
'tech_name': firstSamplerName,
'sampler_2ndname': secondSampler?['first_name'],
'sample_date': samplingDate,
'sample_time': samplingTime,
'sampling_type': samplingType, // 'Investigative'
'sample_state': getDeterminedStateName(),
'station_id': getDeterminedStationCode(),
'station_latitude': stationLatitude,
'station_longitude': stationLongitude,
'latitude': currentLatitude, // Current location lat
'longitude': currentLongitude, // Current location lon
'sample_id': sampleIdCode,
// --- Add Investigative Specific fields if needed ---
'station_type': stationTypeSelection,
'new_basin': stationTypeSelection == 'New Location' ? newBasinName : null,
'new_river': stationTypeSelection == 'New Location' ? newRiverName : null,
'new_station_name': stationTypeSelection == 'New Location' ? newStationName : null, // Include newStationName
};
data.removeWhere((key, value) => value == null);
return jsonEncode(data);
}
/// Creates JSON for FTP 'river_inves_reading.json' (mimicking In-Situ).
String toReadingJson() {
final data = {
'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration,
'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation,
'ph': ph == -999.0 ? null : ph,
'salinity': salinity == -999.0 ? null : salinity,
'temperature': temperature == -999.0 ? null : temperature,
'turbidity': turbidity == -999.0 ? null : turbidity,
'tds': tds == -999.0 ? null : tds,
'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity,
'ammonia': ammonia == -999.0 ? null : ammonia,
'flowrate': flowrateValue,
'date_sampling_reading': dataCaptureDate,
'time_sampling_reading': dataCaptureTime,
};
data.removeWhere((key, value) => value == null);
return jsonEncode(data);
}
/// Creates JSON for FTP 'river_inves_manual_info.json' (mimicking In-Situ).
String toManualInfoJson() {
final data = {
'weather': weather,
'remarks_event': eventRemarks,
'remarks_lab': labRemarks,
};
data.removeWhere((key, value) => value == null);
return jsonEncode(data);
}
}

View File

@ -1,8 +1,9 @@
// lib/models/river_manual_triennial_sampling_data.dart // lib/models/river_manual_triennial_sampling_data.dart
import 'dart:io'; import 'dart:io';
import 'dart:convert'; import 'dart:convert'; // Added for jsonEncode
/// Data model for the River Manual Triennial Sampling form.
class RiverManualTriennialSamplingData { class RiverManualTriennialSamplingData {
// --- Step 1: Sampling & Station Info --- // --- Step 1: Sampling & Station Info ---
String? firstSamplerName; String? firstSamplerName;
@ -14,8 +15,7 @@ class RiverManualTriennialSamplingData {
String? sampleIdCode; String? sampleIdCode;
String? selectedStateName; String? selectedStateName;
String? selectedCategoryName; Map<String, dynamic>? selectedStation; // Triennial stations don't have categories
Map<String, dynamic>? selectedStation;
String? stationLatitude; String? stationLatitude;
String? stationLongitude; String? stationLongitude;
@ -45,7 +45,7 @@ class RiverManualTriennialSamplingData {
File? optionalImage4; File? optionalImage4;
String? optionalRemark4; String? optionalRemark4;
// --- Step 3: Data Capture --- // --- Step 3: Data Capture (Mirrors River In-Situ for now) ---
String? sondeId; String? sondeId;
String? dataCaptureDate; String? dataCaptureDate;
String? dataCaptureTime; String? dataCaptureTime;
@ -57,10 +57,9 @@ class RiverManualTriennialSamplingData {
double? temperature; double? temperature;
double? tds; double? tds;
double? turbidity; double? turbidity;
double? ammonia; double? ammonia; // Replaced tss with ammonia
double? batteryVoltage; double? batteryVoltage;
// --- ADDED: Missing flowrate properties ---
String? flowrateMethod; String? flowrateMethod;
double? flowrateSurfaceDrifterHeight; double? flowrateSurfaceDrifterHeight;
double? flowrateSurfaceDrifterDistance; double? flowrateSurfaceDrifterDistance;
@ -76,37 +75,131 @@ class RiverManualTriennialSamplingData {
RiverManualTriennialSamplingData({ RiverManualTriennialSamplingData({
this.samplingDate, this.samplingDate,
this.samplingTime, this.samplingTime,
this.samplingType = 'Triennial', // Default for this form
}); });
factory RiverManualTriennialSamplingData.fromJson(Map<String, dynamic> json) {
File? fileFromJson(dynamic path) {
return (path is String && path.isNotEmpty) ? File(path) : null;
}
double? doubleFromJson(dynamic value) {
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
int? intFromJson(dynamic value) {
if (value is int) return value;
if (value is String) return int.tryParse(value);
return null;
}
return RiverManualTriennialSamplingData()
..firstSamplerName = json['firstSamplerName'] ?? json['first_sampler_name']
..firstSamplerUserId = intFromJson(json['firstSamplerUserId'] ?? json['first_sampler_user_id'])
..secondSampler = json['secondSampler']
..samplingDate = json['samplingDate'] ?? json['r_tri_date']
..samplingTime = json['samplingTime'] ?? json['r_tri_time']
..samplingType = json['samplingType'] ?? json['r_tri_type']
..sampleIdCode = json['sampleIdCode'] ?? json['r_tri_sample_id_code']
..selectedStateName = json['selectedStateName']
..selectedStation = json['selectedStation']
..stationLatitude = json['stationLatitude']
..stationLongitude = json['stationLongitude']
..currentLatitude = (json['currentLatitude'] ?? json['r_tri_current_latitude'])?.toString()
..currentLongitude = (json['currentLongitude'] ?? json['r_tri_current_longitude'])?.toString()
..distanceDifferenceInKm = doubleFromJson(json['distanceDifferenceInKm'] ?? json['r_tri_distance_difference'])
..distanceDifferenceRemarks = json['distanceDifferenceRemarks'] ?? json['r_tri_distance_difference_remarks']
..weather = json['weather'] ?? json['r_tri_weather']
..eventRemarks = json['eventRemarks'] ?? json['r_tri_event_remark']
..labRemarks = json['labRemarks'] ?? json['r_tri_lab_remark']
..sondeId = json['sondeId'] ?? json['r_tri_sondeID']
..dataCaptureDate = json['dataCaptureDate'] ?? json['data_capture_date']
..dataCaptureTime = json['dataCaptureTime'] ?? json['data_capture_time']
..oxygenConcentration = doubleFromJson(json['oxygenConcentration'] ?? json['r_tri_oxygen_conc'])
..oxygenSaturation = doubleFromJson(json['oxygenSaturation'] ?? json['r_tri_oxygen_sat'])
..ph = doubleFromJson(json['ph'] ?? json['r_tri_ph'])
..salinity = doubleFromJson(json['salinity'] ?? json['r_tri_salinity'])
..electricalConductivity = doubleFromJson(json['electricalConductivity'] ?? json['r_tri_conductivity'])
..temperature = doubleFromJson(json['temperature'] ?? json['r_tri_temperature'])
..tds = doubleFromJson(json['tds'] ?? json['r_tri_tds'])
..turbidity = doubleFromJson(json['turbidity'] ?? json['r_tri_turbidity'])
..ammonia = doubleFromJson(json['ammonia'] ?? json['r_tri_ammonia'])
..batteryVoltage = doubleFromJson(json['batteryVoltage'] ?? json['r_tri_battery_volt'])
..optionalRemark1 = json['optionalRemark1'] ?? json['r_tri_optional_photo_01_remarks']
..optionalRemark2 = json['optionalRemark2'] ?? json['r_tri_optional_photo_02_remarks']
..optionalRemark3 = json['optionalRemark3'] ?? json['r_tri_optional_photo_03_remarks']
..optionalRemark4 = json['optionalRemark4'] ?? json['r_tri_optional_photo_04_remarks']
..backgroundStationImage = fileFromJson(json['backgroundStationImage'] ?? json['r_tri_background_station'])
..upstreamRiverImage = fileFromJson(json['upstreamRiverImage'] ?? json['r_tri_upstream_river'])
..downstreamRiverImage = fileFromJson(json['downstreamRiverImage'] ?? json['r_tri_downstream_river'])
..sampleTurbidityImage = fileFromJson(json['sampleTurbidityImage'] ?? json['r_tri_sample_turbidity'])
..optionalImage1 = fileFromJson(json['optionalImage1'] ?? json['r_tri_optional_photo_01'])
..optionalImage2 = fileFromJson(json['optionalImage2'] ?? json['r_tri_optional_photo_02'])
..optionalImage3 = fileFromJson(json['optionalImage3'] ?? json['r_tri_optional_photo_03'])
..optionalImage4 = fileFromJson(json['optionalImage4'] ?? json['r_tri_optional_photo_04'])
..flowrateMethod = json['flowrateMethod'] ?? json['r_tri_flowrate_method']
..flowrateSurfaceDrifterHeight = doubleFromJson(json['flowrateSurfaceDrifterHeight'] ?? json['r_tri_flowrate_sd_height'])
..flowrateSurfaceDrifterDistance = doubleFromJson(json['flowrateSurfaceDrifterDistance'] ?? json['r_tri_flowrate_sd_distance'])
..flowrateSurfaceDrifterTimeFirst = json['flowrateSurfaceDrifterTimeFirst'] ?? json['r_tri_flowrate_sd_time_first']
..flowrateSurfaceDrifterTimeLast = json['flowrateSurfaceDrifterTimeLast'] ?? json['r_tri_flowrate_sd_time_last']
..flowrateValue = doubleFromJson(json['flowrateValue'] ?? json['r_tri_flowrate_value'])
..submissionStatus = json['submissionStatus']
..submissionMessage = json['submissionMessage']
..reportId = json['reportId']?.toString();
}
/// Converts the data model into a Map<String, String> for the API form data.
Map<String, String> toApiFormData() { Map<String, String> toApiFormData() {
final Map<String, String> map = {}; final Map<String, String> map = {};
void add(String key, dynamic value) { void add(String key, dynamic value) {
if (value != null) { if (value != null) {
map[key] = value.toString(); 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;
}
} }
} }
// Step 1 Data
add('first_sampler_user_id', firstSamplerUserId); add('first_sampler_user_id', firstSamplerUserId);
add('r_tri_second_sampler_id', secondSampler?['user_id']); add('r_tri_second_sampler_id', secondSampler?['user_id']);
add('r_tri_date', samplingDate); add('r_tri_date', samplingDate);
add('r_tri_time', samplingTime); add('r_tri_time', samplingTime);
add('r_tri_type', samplingType); add('r_tri_type', samplingType);
add('r_tri_sample_id_code', sampleIdCode); add('r_tri_sample_id_code', sampleIdCode);
add('station_id', selectedStation?['station_id']); add('station_id', selectedStation?['station_id']); // Ensure this is the correct foreign key
add('r_tri_current_latitude', currentLatitude); add('r_tri_current_latitude', currentLatitude);
add('r_tri_current_longitude', currentLongitude); add('r_tri_current_longitude', currentLongitude);
add('r_tri_distance_difference', distanceDifferenceInKm); add('r_tri_distance_difference', distanceDifferenceInKm);
add('r_tri_distance_difference_remarks', distanceDifferenceRemarks); add('r_tri_distance_difference_remarks', distanceDifferenceRemarks);
// Step 2 Data
add('r_tri_weather', weather); add('r_tri_weather', weather);
add('r_tri_event_remark', eventRemarks); add('r_tri_event_remark', eventRemarks);
add('r_tri_lab_remark', labRemarks); add('r_tri_lab_remark', labRemarks);
// Step 4 Data
add('r_tri_optional_photo_01_remarks', optionalRemark1); add('r_tri_optional_photo_01_remarks', optionalRemark1);
add('r_tri_optional_photo_02_remarks', optionalRemark2); add('r_tri_optional_photo_02_remarks', optionalRemark2);
add('r_tri_optional_photo_03_remarks', optionalRemark3); add('r_tri_optional_photo_03_remarks', optionalRemark3);
add('r_tri_optional_photo_04_remarks', optionalRemark4); add('r_tri_optional_photo_04_remarks', optionalRemark4);
// Step 3 Data
add('r_tri_sondeID', sondeId); add('r_tri_sondeID', sondeId);
add('data_capture_date', dataCaptureDate); add('data_capture_date', dataCaptureDate); // Note: Keys likely shared with in-situ for capture time
add('data_capture_time', dataCaptureTime); add('data_capture_time', dataCaptureTime);
add('r_tri_oxygen_conc', oxygenConcentration); add('r_tri_oxygen_conc', oxygenConcentration);
add('r_tri_oxygen_sat', oxygenSaturation); add('r_tri_oxygen_sat', oxygenSaturation);
@ -124,13 +217,17 @@ class RiverManualTriennialSamplingData {
add('r_tri_flowrate_sd_time_first', flowrateSurfaceDrifterTimeFirst); add('r_tri_flowrate_sd_time_first', flowrateSurfaceDrifterTimeFirst);
add('r_tri_flowrate_sd_time_last', flowrateSurfaceDrifterTimeLast); add('r_tri_flowrate_sd_time_last', flowrateSurfaceDrifterTimeLast);
add('r_tri_flowrate_value', flowrateValue); add('r_tri_flowrate_value', flowrateValue);
// Additional data for display or logging
add('first_sampler_name', firstSamplerName); add('first_sampler_name', firstSamplerName);
add('r_tri_station_code', selectedStation?['sampling_station_code']); add('r_tri_station_code', selectedStation?['sampling_station_code']);
add('r_tri_station_name', selectedStation?['sampling_river']); add('r_tri_station_name', selectedStation?['sampling_river']);
return map; return map;
} }
/// Converts the image properties into a Map<String, File?> for the multipart API request.
Map<String, File?> toApiImageFiles() { Map<String, File?> toApiImageFiles() {
return { return {
'r_tri_background_station': backgroundStationImage, 'r_tri_background_station': backgroundStationImage,
@ -144,7 +241,7 @@ class RiverManualTriennialSamplingData {
}; };
} }
// --- ADDED: Missing toMap() method --- /// Converts the data model into a Map suitable for saving to local storage or DB log.
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'firstSamplerName': firstSamplerName, 'firstSamplerName': firstSamplerName,
@ -155,7 +252,6 @@ class RiverManualTriennialSamplingData {
'samplingType': samplingType, 'samplingType': samplingType,
'sampleIdCode': sampleIdCode, 'sampleIdCode': sampleIdCode,
'selectedStateName': selectedStateName, 'selectedStateName': selectedStateName,
'selectedCategoryName': selectedCategoryName,
'selectedStation': selectedStation, 'selectedStation': selectedStation,
'stationLatitude': stationLatitude, 'stationLatitude': stationLatitude,
'stationLongitude': stationLongitude, 'stationLongitude': stationLongitude,
@ -202,4 +298,40 @@ class RiverManualTriennialSamplingData {
'reportId': reportId, 'reportId': reportId,
}; };
} }
/// Creates a single JSON object with all submission data, mimicking 'db.json'
String toDbJson() {
final data = {
'battery_cap': batteryVoltage == -999.0 ? null : batteryVoltage,
'device_name': sondeId,
'sampling_type': samplingType,
'report_id': reportId,
'sampler_2ndname': secondSampler?['first_name'],
'sample_state': selectedStateName,
'station_id': selectedStation?['sampling_station_code'],
'tech_id': firstSamplerUserId,
'tech_name': firstSamplerName,
'latitude': stationLatitude,
'longitude': stationLongitude,
'record_dt': '$samplingDate $samplingTime',
'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration,
'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation,
'ph': ph == -999.0 ? null : ph,
'salinity': salinity == -999.0 ? null : salinity,
'temperature': temperature == -999.0 ? null : temperature,
'turbidity': turbidity == -999.0 ? null : turbidity,
'tds': tds == -999.0 ? null : tds,
'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity,
'ammonia': ammonia == -999.0 ? null : ammonia,
'flowrate': flowrateValue,
'odour': '', // Not collected
'floatable': '', // Not collected
'sample_id': sampleIdCode,
'weather': weather,
'remarks_event': eventRemarks,
'remarks_lab': labRemarks,
};
data.removeWhere((key, value) => value == null);
return jsonEncode(data);
}
} }

View File

@ -54,7 +54,7 @@ class TarballSamplingData {
npeData.firstSamplerUserId = firstSamplerUserId; npeData.firstSamplerUserId = firstSamplerUserId;
npeData.eventDate = samplingDate; npeData.eventDate = samplingDate;
npeData.eventTime = samplingTime; npeData.eventTime = samplingTime;
npeData.selectedStation = selectedStation; npeData.selectedStation = selectedStation; // Pass the whole station map
npeData.latitude = currentLatitude; npeData.latitude = currentLatitude;
npeData.longitude = currentLongitude; npeData.longitude = currentLongitude;
npeData.stateName = selectedStateName; npeData.stateName = selectedStateName;
@ -62,40 +62,29 @@ class TarballSamplingData {
// Pre-tick the relevant observation for a tarball event. // Pre-tick the relevant observation for a tarball event.
npeData.fieldObservations['Observation of tar balls'] = true; npeData.fieldObservations['Observation of tar balls'] = true;
// Transfer images
final availableImages = [
leftCoastalViewImage,
rightCoastalViewImage,
verticalLinesImage,
horizontalLineImage,
optionalImage1,
optionalImage2,
optionalImage3,
optionalImage4,
].where((img) => img != null).cast<File>().toList();
if (availableImages.isNotEmpty) npeData.image1 = availableImages[0];
if (availableImages.length > 1) npeData.image2 = availableImages[1];
if (availableImages.length > 2) npeData.image3 = availableImages[2];
if (availableImages.length > 3) npeData.image4 = availableImages[3];
return npeData; return npeData;
} }
/// Generates a formatted Telegram alert message for successful submissions. // --- REMOVED: generateTelegramAlertMessage method ---
String generateTelegramAlertMessage({required bool isDataOnly}) { // Logic moved to MarineTarballSamplingService
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = selectedStation?['tbl_station_name'] ?? 'N/A';
final stationCode = selectedStation?['tbl_station_code'] ?? 'N/A';
// This logic now correctly uses the full classification object if available.
final classification = selectedClassification?['classification_name'] ?? classificationId?.toString() ?? 'N/A';
final buffer = StringBuffer()
..writeln('✅ *Tarball Sample $submissionType Submitted:*')
..writeln()
..writeln('*Station Name & Code:* $stationName ($stationCode)')
..writeln('*Date of Submission:* $samplingDate')
..writeln('*Submitted by User:* $firstSampler')
..writeln('*Classification:* $classification')
..writeln('*Status of Submission:* Successful');
// Add distance alert if relevant
if (distanceDifference != null && distanceDifference! > 0) {
buffer
..writeln()
..writeln('🔔 *Alert:*')
..writeln('*Distance from station:* ${(distanceDifference! * 1000).toStringAsFixed(0)} meters');
if (distanceDifferenceRemarks != null && distanceDifferenceRemarks!.isNotEmpty) {
buffer.writeln('*Remarks for distance:* $distanceDifferenceRemarks');
}
}
return buffer.toString();
}
/// Converts the form's text and selection data into a Map suitable for JSON encoding. /// Converts the form's text and selection data into a Map suitable for JSON encoding.
/// This map will be sent as the body of the first API request. /// This map will be sent as the body of the first API request.
@ -113,7 +102,7 @@ class TarballSamplingData {
'current_latitude': currentLatitude ?? '', 'current_latitude': currentLatitude ?? '',
'current_longitude': currentLongitude ?? '', 'current_longitude': currentLongitude ?? '',
'distance_difference': distanceDifference?.toString() ?? '', 'distance_difference': distanceDifference?.toString() ?? '',
'distance_remarks': distanceDifferenceRemarks ?? '', 'distance_remarks': distanceDifferenceRemarks ?? '', // Corrected key based on service
'optional_photo_remark_01': optionalRemark1 ?? '', 'optional_photo_remark_01': optionalRemark1 ?? '',
'optional_photo_remark_02': optionalRemark2 ?? '', 'optional_photo_remark_02': optionalRemark2 ?? '',
'optional_photo_remark_03': optionalRemark3 ?? '', 'optional_photo_remark_03': optionalRemark3 ?? '',
@ -125,6 +114,8 @@ class TarballSamplingData {
'first_sampler_name': firstSampler ?? '', 'first_sampler_name': firstSampler ?? '',
'classification_name': selectedClassification?['classification_name']?.toString() ?? '', 'classification_name': selectedClassification?['classification_name']?.toString() ?? '',
}; };
// Remove keys with empty string values before sending
data.removeWhere((key, value) => value.isEmpty);
return data; return data;
} }
@ -143,8 +134,9 @@ class TarballSamplingData {
}; };
} }
/// Creates a single JSON object with all submission data, mimicking 'db.json' /// Creates a single JSON object with all submission data for offline storage.
Map<String, dynamic> toDbJson() { Map<String, dynamic> toDbJson() {
// Include image paths for local storage
return { return {
'firstSampler': firstSampler, 'firstSampler': firstSampler,
'firstSamplerUserId': firstSamplerUserId, 'firstSamplerUserId': firstSamplerUserId,
@ -162,9 +154,17 @@ class TarballSamplingData {
'distanceDifferenceRemarks': distanceDifferenceRemarks, 'distanceDifferenceRemarks': distanceDifferenceRemarks,
'classificationId': classificationId, 'classificationId': classificationId,
'selectedClassification': selectedClassification, 'selectedClassification': selectedClassification,
'leftCoastalViewImage': leftCoastalViewImage?.path,
'rightCoastalViewImage': rightCoastalViewImage?.path,
'verticalLinesImage': verticalLinesImage?.path,
'horizontalLineImage': horizontalLineImage?.path,
'optionalImage1': optionalImage1?.path,
'optionalRemark1': optionalRemark1, 'optionalRemark1': optionalRemark1,
'optionalImage2': optionalImage2?.path,
'optionalRemark2': optionalRemark2, 'optionalRemark2': optionalRemark2,
'optionalImage3': optionalImage3?.path,
'optionalRemark3': optionalRemark3, 'optionalRemark3': optionalRemark3,
'optionalImage4': optionalImage4?.path,
'optionalRemark4': optionalRemark4, 'optionalRemark4': optionalRemark4,
'reportId': reportId, 'reportId': reportId,
'submissionStatus': submissionStatus, 'submissionStatus': submissionStatus,
@ -174,24 +174,26 @@ class TarballSamplingData {
/// Creates a JSON object for basic form info, mimicking 'basic_form.json'. /// Creates a JSON object for basic form info, mimicking 'basic_form.json'.
Map<String, dynamic> toBasicFormJson() { Map<String, dynamic> toBasicFormJson() {
return { final data = {
'tech_name': firstSampler, 'tech_name': firstSampler,
'sampler_2ndname': secondSampler?['user_name'], 'sampler_2ndname': secondSampler?['first_name'], // Assuming first_name is appropriate
'sample_date': samplingDate, 'sample_date': samplingDate,
'sample_time': samplingTime, 'sample_time': samplingTime,
'sample_state': selectedStateName, 'sample_state': selectedStateName,
'station_id': selectedStation?['tbl_station_code'], 'station_id': selectedStation?['tbl_station_code'], // Use station code
'station_latitude': stationLatitude, 'station_latitude': stationLatitude,
'station_longitude': stationLongitude, 'station_longitude': stationLongitude,
'latitude': currentLatitude, 'latitude': currentLatitude, // Current location
'longitude': currentLongitude, 'longitude': currentLongitude, // Current location
'sample_id': reportId, // Using reportId as a unique identifier for the sample. 'sample_id': reportId, // Using reportId if available
}; };
data.removeWhere((key, value) => value == null);
return data;
} }
/// Creates a JSON object for sensor readings, mimicking 'reading.json'. /// Creates a JSON object for sensor readings, mimicking 'reading.json'.
Map<String, dynamic> toReadingJson() { Map<String, dynamic> toReadingJson() {
return { final data = {
'classification': selectedClassification?['classification_name'], 'classification': selectedClassification?['classification_name'],
'classification_id': classificationId, 'classification_id': classificationId,
'optional_remark_1': optionalRemark1, 'optional_remark_1': optionalRemark1,
@ -199,17 +201,21 @@ class TarballSamplingData {
'optional_remark_3': optionalRemark3, 'optional_remark_3': optionalRemark3,
'optional_remark_4': optionalRemark4, 'optional_remark_4': optionalRemark4,
'distance_difference': distanceDifference, 'distance_difference': distanceDifference,
'distance_difference_remarks': distanceDifferenceRemarks, 'distance_difference_remarks': distanceDifferenceRemarks, // Corrected key
}; };
data.removeWhere((key, value) => value == null || (value is String && value.isEmpty));
return data;
} }
/// Creates a JSON object for manual info, mimicking 'manual_info.json'. /// Creates a JSON object for manual info, mimicking 'manual_info.json'.
Map<String, dynamic> toManualInfoJson() { Map<String, dynamic> toManualInfoJson() {
return { final data = {
// Tarball forms don't have a specific 'weather' or general remarks field, // Tarball forms don't have weather or general remarks separate from distance
// so we use the distance remarks as a stand-in if available. 'weather': null, // Explicitly null if not collected
'remarks_event': distanceDifferenceRemarks, 'remarks_event': distanceDifferenceRemarks, // Use distance remarks if relevant
'remarks_lab': null, 'remarks_lab': null, // Explicitly null if not collected
}; };
data.removeWhere((key, value) => value == null || (value is String && value.isEmpty));
return data;
} }
} }

View File

@ -1 +1,807 @@
//lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_1_sampling_info.dart // lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_1_sampling_info.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:dropdown_search/dropdown_search.dart';
import 'package:intl/intl.dart';
import 'package:simple_barcode_scanner/simple_barcode_scanner.dart';
import '../../../../auth_provider.dart';
import '../../../../models/marine_inves_manual_sampling_data.dart';
import '../../../../services/marine_investigative_sampling_service.dart';
class MarineInvesManualStep1SamplingInfo extends StatefulWidget {
final MarineInvesManualSamplingData data;
final VoidCallback onNext;
const MarineInvesManualStep1SamplingInfo({
super.key,
required this.data,
required this.onNext,
});
@override
State<MarineInvesManualStep1SamplingInfo> createState() => _MarineInvesManualStep1SamplingInfoState();
}
class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualStep1SamplingInfo> {
final _formKey = GlobalKey<FormState>();
bool _isLoadingLocation = false;
late final TextEditingController _firstSamplerController;
late final TextEditingController _dateController;
late final TextEditingController _timeController;
late final TextEditingController _barcodeController;
late final TextEditingController _stationLatController;
late final TextEditingController _stationLonController;
late final TextEditingController _currentLatController;
late final TextEditingController _currentLonController;
// --- NEW: Controllers for 'New Location' ---
late final TextEditingController _newStationNameController;
late final TextEditingController _newStationCodeController;
// --- NEW: State for Station Selection ---
String _stationType = 'Existing Manual Station';
final List<String> _stationTypeOptions = ['Existing Manual Station', 'Existing Tarball Station', 'New Location'];
// --- Lists for Dropdowns ---
List<String> _manualStatesList = [];
List<String> _categoriesForManualState = [];
List<Map<String, dynamic>> _stationsForManualCategory = [];
List<String> _tarballStatesList = [];
List<Map<String, dynamic>> _stationsForTarballState = [];
final List<String> _samplingTypes = ['Schedule', 'Ad-Hoc', 'Complaint', 'Investigative'];
@override
void initState() {
super.initState();
_initializeControllers();
_initializeForm();
}
@override
void dispose() {
_firstSamplerController.dispose();
_dateController.dispose();
_timeController.dispose();
_barcodeController.dispose();
_stationLatController.dispose();
_stationLonController.dispose();
_currentLatController.dispose();
_currentLonController.dispose();
_newStationNameController.dispose();
_newStationCodeController.dispose();
super.dispose();
}
void _initializeControllers() {
_firstSamplerController = TextEditingController();
_dateController = TextEditingController();
_timeController = TextEditingController();
_barcodeController = TextEditingController(text: widget.data.sampleIdCode);
_stationLatController = TextEditingController(text: widget.data.stationLatitude);
_stationLonController = TextEditingController(text: widget.data.stationLongitude);
_currentLatController = TextEditingController(text: widget.data.currentLatitude);
_currentLonController = TextEditingController(text: widget.data.currentLongitude);
_newStationNameController = TextEditingController(text: widget.data.newStationName);
_newStationCodeController = TextEditingController(text: widget.data.newStationCode);
}
void _initializeForm() {
final auth = Provider.of<AuthProvider>(context, listen: false);
widget.data.firstSamplerName = auth.profileData?['first_name'] ?? 'Current User';
widget.data.firstSamplerUserId = auth.profileData?['user_id'];
_firstSamplerController.text = widget.data.firstSamplerName!;
_dateController.text = widget.data.samplingDate!;
_timeController.text = widget.data.samplingTime!;
if (widget.data.samplingType == null) {
widget.data.samplingType = 'Investigative';
}
_stationType = widget.data.stationTypeSelection ?? 'Existing Manual Station';
// --- Load Manual Station Data ---
final allManualStations = auth.manualStations ?? [];
if (allManualStations.isNotEmpty) {
_manualStatesList = allManualStations.map((s) => s['state_name'] as String?).whereType<String>().toSet().toList()..sort();
if (widget.data.selectedManualStateName != null) {
_categoriesForManualState = allManualStations
.where((s) => s['state_name'] == widget.data.selectedManualStateName)
.map((s) => s['category_name'] as String?)
.whereType<String>()
.toSet().toList()..sort();
}
if (widget.data.selectedManualCategoryName != null) {
_stationsForManualCategory = allManualStations
.where((s) => s['state_name'] == widget.data.selectedManualStateName && s['category_name'] == widget.data.selectedManualCategoryName)
.toList()..sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? ''));
}
}
// --- Load Tarball Station Data ---
final allTarballStations = auth.tarballStations ?? [];
if (allTarballStations.isNotEmpty) {
_tarballStatesList = allTarballStations.map((s) => s['state_name'] as String?).whereType<String>().toSet().toList()..sort();
if (widget.data.selectedTarballStateName != null) {
_stationsForTarballState = allTarballStations
.where((s) => s['state_name'] == widget.data.selectedTarballStateName)
.toList()..sort((a, b) => (a['tbl_station_code'] ?? '').compareTo(b['tbl_station_code'] ?? ''));
}
}
}
/// --- NEW: Handle Station Type Change ---
void _handleStationTypeChange(String? value) {
if (value == null) return;
setState(() {
_stationType = value;
widget.data.stationTypeSelection = value;
// Clear all station-related data to avoid conflicts
widget.data.selectedManualStateName = null;
widget.data.selectedManualCategoryName = null;
widget.data.selectedStation = null;
widget.data.selectedTarballStateName = null;
widget.data.selectedTarballStation = null;
widget.data.newStationName = null;
widget.data.newStationCode = null;
_newStationNameController.clear();
_newStationCodeController.clear();
widget.data.stationLatitude = null;
widget.data.stationLongitude = null;
_stationLatController.clear();
_stationLonController.clear();
widget.data.distanceDifferenceInKm = null;
});
}
Future<void> _getCurrentLocation() async {
setState(() => _isLoadingLocation = true);
final service = Provider.of<MarineInvestigativeSamplingService>(context, listen: false);
try {
final position = await service.getCurrentLocation();
if (mounted) {
setState(() {
widget.data.currentLatitude = position.latitude.toString();
widget.data.currentLongitude = position.longitude.toString();
_currentLatController.text = widget.data.currentLatitude!;
_currentLonController.text = widget.data.currentLongitude!;
// If 'New Location' is selected, also populate the station lat/lon
if (_stationType == 'New Location') {
widget.data.stationLatitude = widget.data.currentLatitude;
widget.data.stationLongitude = widget.data.currentLongitude;
_stationLatController.text = widget.data.stationLatitude!;
_stationLonController.text = widget.data.stationLongitude!;
}
_calculateDistance();
});
}
} catch (e) {
if(mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to get location: $e')));
}
} finally {
if (mounted) {
setState(() => _isLoadingLocation = false);
}
}
}
void _calculateDistance() {
final lat1Str = widget.data.stationLatitude;
final lon1Str = widget.data.stationLongitude;
final lat2Str = widget.data.currentLatitude;
final lon2Str = widget.data.currentLongitude;
if (lat1Str != null && lon1Str != null && lat2Str != null && lon2Str != null) {
final service = Provider.of<MarineInvestigativeSamplingService>(context, listen: false);
final lat1 = double.tryParse(lat1Str);
final lon1 = double.tryParse(lon1Str);
final lat2 = double.tryParse(lat2Str);
final lon2 = double.tryParse(lon2Str);
if (lat1 != null && lon1 != null && lat2 != null && lon2 != null) {
final distance = service.calculateDistance(lat1, lon1, lat2, lon2);
setState(() {
widget.data.distanceDifferenceInKm = distance;
});
} else {
setState(() {
widget.data.distanceDifferenceInKm = null; // Clear distance if coords invalid
});
}
} else {
setState(() {
widget.data.distanceDifferenceInKm = null; // Clear distance if coords missing
});
}
}
Future<void> _scanBarcode() async {
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SimpleBarcodeScannerPage()),
);
if (result is String && result != '-1' && mounted) {
setState(() {
widget.data.sampleIdCode = result;
_barcodeController.text = result;
});
}
}
// --- Re-used from original for manual stations ---
Future<void> _findAndShowNearbyStations() async {
if (widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) {
await _getCurrentLocation();
if (!mounted || widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) {
return;
}
}
final service = Provider.of<MarineInvestigativeSamplingService>(context, listen: false);
final auth = Provider.of<AuthProvider>(context, listen: false);
final currentLat = double.parse(widget.data.currentLatitude!);
final currentLon = double.parse(widget.data.currentLongitude!);
final allStations = auth.manualStations ?? []; // Only search manual stations
final List<Map<String, dynamic>> nearbyStations = [];
for (var station in allStations) {
final stationLat = station['man_latitude'];
final stationLon = station['man_longitude'];
// Ensure coordinates are numbers before calculating distance
if (stationLat is num && stationLon is num) {
final distance = service.calculateDistance(currentLat, currentLon, stationLat.toDouble(), stationLon.toDouble());
if (distance <= 5.0) { // 5km radius
nearbyStations.add({'station': station, 'distance': distance});
}
} else {
debugPrint("Skipping station ${station['man_station_code']} due to invalid coordinates: Lat=$stationLat, Lon=$stationLon");
}
}
nearbyStations.sort((a, b) => a['distance'].compareTo(b['distance']));
if (!mounted) return;
final selectedStation = await showDialog<Map<String, dynamic>>(
context: context,
builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations),
);
if (selectedStation != null) {
_updateFormWithSelectedStation(selectedStation);
}
}
// --- Re-used from original for manual stations ---
void _updateFormWithSelectedStation(Map<String, dynamic> station) {
final allStations = Provider.of<AuthProvider>(context, listen: false).manualStations ?? [];
setState(() {
// Update State
widget.data.selectedManualStateName = station['state_name'];
// Update Category List based on new State
final categories = allStations
.where((s) => s['state_name'] == widget.data.selectedManualStateName)
.map((s) => s['category_name'] as String?)
.whereType<String>()
.toSet()
.toList();
categories.sort();
_categoriesForManualState = categories;
// Update Category
widget.data.selectedManualCategoryName = station['category_name'];
// Update Station List based on new State and Category
_stationsForManualCategory = allStations
.where((s) =>
s['state_name'] == widget.data.selectedManualStateName &&
s['category_name'] == widget.data.selectedManualCategoryName)
.toList()
..sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? ''));
// Update Selected Station and its coordinates
widget.data.selectedStation = station;
widget.data.stationLatitude = station['man_latitude']?.toString();
widget.data.stationLongitude = station['man_longitude']?.toString();
_stationLatController.text = widget.data.stationLatitude ?? '';
_stationLonController.text = widget.data.stationLongitude ?? '';
// Recalculate distance
_calculateDistance();
});
}
void _goToNextStep() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
// The distance check applies to all 3 types.
// For "New Location", it compares manually-entered Lat/Lon vs. Current Lat/Lon.
final distanceInMeters = (widget.data.distanceDifferenceInKm ?? 0) * 1000;
// Only show remark dialog if distance is > 50m AND station lat/lon are actually set
// (prevents dialog for 'New Location' before coords are entered/fetched)
if (distanceInMeters > 50 && widget.data.stationLatitude != null && widget.data.stationLongitude != null) {
_showDistanceRemarkDialog();
} else {
widget.data.distanceDifferenceRemarks = null; // Clear remarks if within limit
widget.onNext();
}
}
}
Future<void> _showDistanceRemarkDialog() async {
final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks);
final dialogFormKey = GlobalKey<FormState>();
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Distance Warning'),
content: SingleChildScrollView(
child: Form(
key: dialogFormKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Your current location is more than 50m away from the selected/entered station location.'),
const SizedBox(height: 16),
TextFormField(
controller: remarkController,
decoration: const InputDecoration(
labelText: 'Remarks *',
hintText: 'Please provide a reason...',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Remarks are required to continue.';
}
return null;
},
maxLines: 3,
),
],
),
),
),
actions: <Widget>[
TextButton(
child: const Text('Cancel'),
onPressed: () {
Navigator.of(context).pop();
},
),
FilledButton(
child: const Text('Confirm'),
onPressed: () {
if (dialogFormKey.currentState!.validate()) {
setState(() {
widget.data.distanceDifferenceRemarks = remarkController.text;
});
Navigator.of(context).pop();
widget.onNext(); // Proceed after confirming remark
}
},
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthProvider>(context, listen: false);
final allUsers = auth.allUsers ?? [];
final secondSamplersList = allUsers.where((user) => user['user_id'] != auth.profileData?['user_id']).toList()
..sort((a, b) => (a['first_name'] ?? '').compareTo(b['first_name'] ?? ''));
return Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24.0),
children: [
Text("Sampling Information", style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 24),
TextFormField(controller: _firstSamplerController, readOnly: true, decoration: const InputDecoration(labelText: '1st Sampler')),
const SizedBox(height: 16),
DropdownSearch<Map<String, dynamic>>(
items: secondSamplersList,
selectedItem: widget.data.secondSampler,
itemAsString: (sampler) => "${sampler['first_name']} ${sampler['last_name']}",
onChanged: (sampler) => widget.data.secondSampler = sampler,
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Sampler..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: '2nd Sampler (Optional)')),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(child: TextFormField(controller: _dateController, readOnly: true, decoration: const InputDecoration(labelText: 'Date'))),
const SizedBox(width: 16),
Expanded(child: TextFormField(controller: _timeController, readOnly: true, decoration: const InputDecoration(labelText: 'Time'))),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: widget.data.samplingType,
items: _samplingTypes.map((type) => DropdownMenuItem(value: type, child: Text(type))).toList(),
onChanged: (value) => setState(() => widget.data.samplingType = value),
decoration: const InputDecoration(labelText: 'Sampling Type *'),
validator: (value) => value == null ? 'Please select a type' : null,
),
const SizedBox(height: 24),
// --- NEW: Station Type Selection ---
Text("Station Selection", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _stationType,
items: _stationTypeOptions.map((type) => DropdownMenuItem(value: type, child: Text(type))).toList(),
onChanged: _handleStationTypeChange,
decoration: const InputDecoration(labelText: 'Station Source *'),
validator: (value) => value == null ? 'Please select a station source' : null, // Added validator
),
const SizedBox(height: 16),
// --- NEW: Conditional Station Widgets ---
if (_stationType == 'Existing Manual Station')
_buildManualStationSelectors(auth.manualStations ?? []),
if (_stationType == 'Existing Tarball Station')
_buildTarballStationSelectors(auth.tarballStations ?? []),
if (_stationType == 'New Location')
_buildNewLocationFields(),
// --- Location Verification (Common to all) ---
const SizedBox(height: 24),
Text("Location Verification", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
TextFormField(controller: _currentLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Latitude')),
const SizedBox(height: 16),
TextFormField(controller: _currentLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Longitude')),
if (widget.data.distanceDifferenceInKm != null)
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red.withOpacity(0.1) : Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red : Colors.green),
),
child: RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: Theme.of(context).textTheme.bodyLarge,
children: <TextSpan>[
const TextSpan(text: 'Distance from Station: '),
TextSpan(
text: '${(widget.data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters',
style: TextStyle(
fontWeight: FontWeight.bold,
color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red : Colors.green
),
),
],
),
),
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _isLoadingLocation ? null : _getCurrentLocation,
icon: _isLoadingLocation ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.location_searching),
label: Text(_stationType == 'New Location' ? "Get & Use Current Location" : "Get Current Location"),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12), // Consistent padding
),
),
const SizedBox(height: 16),
// --- Sample ID (Common to all) ---
TextFormField(
controller: _barcodeController,
decoration: InputDecoration(
labelText: 'Sample ID Code *',
suffixIcon: IconButton(
icon: const Icon(Icons.qr_code_scanner),
onPressed: _scanBarcode,
),
),
validator: (val) => val == null || val.isEmpty ? "Sample ID is required" : null,
onSaved: (val) => widget.data.sampleIdCode = val,
onChanged: (val) => widget.data.sampleIdCode = val, // Update data model on change
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _goToNextStep,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Next'),
),
const SizedBox(height: 16), // Add padding at the bottom
],
),
);
}
/// --- Widget builder for Manual Station selection ---
Widget _buildManualStationSelectors(List<Map<String, dynamic>> allStations) {
return Column(
children: [
DropdownSearch<String>(
items: _manualStatesList,
selectedItem: widget.data.selectedManualStateName,
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *")),
onChanged: (state) {
setState(() {
widget.data.selectedManualStateName = state;
widget.data.selectedManualCategoryName = null;
widget.data.selectedStation = null;
_stationLatController.clear();
_stationLonController.clear();
widget.data.distanceDifferenceInKm = null;
// --- CORRECTED LOGIC ---
if (state != null) {
_categoriesForManualState = allStations
.where((s) => s['state_name'] == state)
.map((s) => s['category_name'] as String?)
.whereType<String>()
.toSet()
.toList();
_categoriesForManualState.sort(); // Sort after creating the list
} else {
_categoriesForManualState = <String>[];
}
// --- END CORRECTION ---
_stationsForManualCategory = []; // Clear stations list
});
},
validator: (val) => val == null ? "State is required" : null,
),
const SizedBox(height: 16),
DropdownSearch<String>(
items: _categoriesForManualState,
selectedItem: widget.data.selectedManualCategoryName,
enabled: widget.data.selectedManualStateName != null,
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Category..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Category *")),
onChanged: (category) {
setState(() {
widget.data.selectedManualCategoryName = category;
widget.data.selectedStation = null;
_stationLatController.clear();
_stationLonController.clear();
widget.data.distanceDifferenceInKm = null;
// --- CORRECTED LOGIC (Similar structure) ---
if (category != null) {
_stationsForManualCategory = allStations
.where((s) => s['state_name'] == widget.data.selectedManualStateName && s['category_name'] == category)
.toList();
_stationsForManualCategory.sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? '')); // Sort after creating
} else {
_stationsForManualCategory = [];
}
// --- END CORRECTION ---
});
},
validator: (val) => widget.data.selectedManualStateName != null && val == null ? "Category is required" : null,
),
const SizedBox(height: 16),
DropdownSearch<Map<String, dynamic>>(
items: _stationsForManualCategory,
selectedItem: widget.data.selectedStation,
enabled: widget.data.selectedManualCategoryName != null,
itemAsString: (station) => "${station['man_station_code']} - ${station['man_station_name']}",
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Station..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Station *")),
onChanged: (station) => setState(() {
widget.data.selectedStation = station;
widget.data.stationLatitude = station?['man_latitude']?.toString();
widget.data.stationLongitude = station?['man_longitude']?.toString();
_stationLatController.text = widget.data.stationLatitude ?? '';
_stationLonController.text = widget.data.stationLongitude ?? '';
_calculateDistance(); // Recalculate distance when station changes
}),
validator: (val) => widget.data.selectedManualCategoryName != null && val == null ? "Station is required" : null,
),
const SizedBox(height: 16),
TextFormField(controller: _stationLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Latitude')),
const SizedBox(height: 16),
TextFormField(controller: _stationLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Longitude')),
const SizedBox(height: 16),
ElevatedButton.icon(
icon: const Icon(Icons.explore_outlined),
label: const Text("NEARBY STATION"),
onPressed: _isLoadingLocation ? null : _findAndShowNearbyStations,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)),
),
],
);
}
/// --- Widget builder for Tarball Station selection ---
Widget _buildTarballStationSelectors(List<Map<String, dynamic>> allStations) {
return Column(
children: [
DropdownSearch<String>(
items: _tarballStatesList,
selectedItem: widget.data.selectedTarballStateName,
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *")),
onChanged: (state) {
setState(() {
widget.data.selectedTarballStateName = state;
widget.data.selectedTarballStation = null;
_stationLatController.clear();
_stationLonController.clear();
widget.data.distanceDifferenceInKm = null;
// --- CORRECTED LOGIC ---
if (state != null) {
_stationsForTarballState = allStations
.where((s) => s['state_name'] == state)
.toList();
_stationsForTarballState.sort((a, b) => (a['tbl_station_code'] ?? '').compareTo(b['tbl_station_code'] ?? '')); // Sort after creating
} else {
_stationsForTarballState = <Map<String, dynamic>>[];
}
// --- END CORRECTION ---
});
},
validator: (val) => val == null ? "State is required" : null,
),
const SizedBox(height: 16),
DropdownSearch<Map<String, dynamic>>(
items: _stationsForTarballState,
selectedItem: widget.data.selectedTarballStation,
enabled: widget.data.selectedTarballStateName != null,
itemAsString: (station) => "${station['tbl_station_code']} - ${station['tbl_station_name']}",
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Station..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Station *")),
onChanged: (station) => setState(() {
widget.data.selectedTarballStation = station;
widget.data.stationLatitude = station?['tbl_latitude']?.toString();
widget.data.stationLongitude = station?['tbl_longitude']?.toString();
_stationLatController.text = widget.data.stationLatitude ?? '';
_stationLonController.text = widget.data.stationLongitude ?? '';
_calculateDistance(); // Recalculate distance when station changes
}),
validator: (val) => widget.data.selectedTarballStateName != null && val == null ? "Station is required" : null,
),
const SizedBox(height: 16),
TextFormField(controller: _stationLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Latitude')),
const SizedBox(height: 16),
TextFormField(controller: _stationLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Longitude')),
],
);
}
/// --- Widget builder for New Location fields ---
Widget _buildNewLocationFields() {
return Column(
children: [
TextFormField(
controller: _newStationNameController,
decoration: const InputDecoration(labelText: 'New Station Name *'),
validator: (val) => val == null || val.isEmpty ? "Station Name is required" : null,
onSaved: (val) => widget.data.newStationName = val,
onChanged: (val) => widget.data.newStationName = val, // Update data model on change
),
const SizedBox(height: 16),
TextFormField(
controller: _newStationCodeController,
decoration: const InputDecoration(labelText: 'New Station Code *', hintText: "e.g., INV-001"),
validator: (val) => val == null || val.isEmpty ? "Station Code is required" : null,
onSaved: (val) => widget.data.newStationCode = val,
onChanged: (val) => widget.data.newStationCode = val, // Update data model on change
),
const SizedBox(height: 16),
TextFormField(
controller: _stationLatController,
decoration: const InputDecoration(labelText: 'Station Latitude *', hintText: "Enter manually or use 'Get Location'"),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (val) {
if (val == null || val.isEmpty) return "Latitude is required";
final lat = double.tryParse(val);
if (lat == null || lat < -90 || lat > 90) return "Enter a valid latitude (-90 to 90)";
return null;
},
onSaved: (val) => widget.data.stationLatitude = val,
onChanged: (val) {
widget.data.stationLatitude = val; // Update data model on change
_calculateDistance(); // Recalculate distance when manually changed
},
),
const SizedBox(height: 16),
TextFormField(
controller: _stationLonController,
decoration: const InputDecoration(labelText: 'Station Longitude *', hintText: "Enter manually or use 'Get Location'"),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (val) {
if (val == null || val.isEmpty) return "Longitude is required";
final lon = double.tryParse(val);
if (lon == null || lon < -180 || lon > 180) return "Enter a valid longitude (-180 to 180)";
return null;
},
onSaved: (val) => widget.data.stationLongitude = val,
onChanged: (val) {
widget.data.stationLongitude = val; // Update data model on change
_calculateDistance(); // Recalculate distance when manually changed
},
),
],
);
}
}
// --- Re-used Dialog Widget for Nearby Stations ---
class _NearbyStationsDialog extends StatelessWidget {
final List<Map<String, dynamic>> nearbyStations;
const _NearbyStationsDialog({required this.nearbyStations});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Nearby Stations (within 5km)'),
content: SizedBox(
width: double.maxFinite,
child: nearbyStations.isEmpty
? const Center(child: Text('No stations found within 5km of your current location.')) // More informative text
: ListView.builder(
shrinkWrap: true,
itemCount: nearbyStations.length,
itemBuilder: (context, index) {
final item = nearbyStations[index];
final station = item['station'] as Map<String, dynamic>;
final distanceInMeters = (item['distance'] as double) * 1000;
return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0), // Add vertical margin
child: ListTile(
title: Text("${station['man_station_code'] ?? 'N/A'}"),
subtitle: Text("${station['man_station_name'] ?? 'N/A'}"),
trailing: Text("${distanceInMeters.toStringAsFixed(0)} m"),
onTap: () {
Navigator.of(context).pop(station); // Return selected station
},
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(), // Return null on cancel
child: const Text('Cancel'),
),
],
);
}
}

View File

@ -1 +1,220 @@
//lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart // lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '../../../../models/marine_inves_manual_sampling_data.dart';
import '../../../../services/marine_investigative_sampling_service.dart';
/// The second step of the Investigative Sampling form.
/// Gathers on-site conditions (weather, tide) and handles all photo attachments.
class MarineInvesManualStep2SiteInfo extends StatefulWidget {
final MarineInvesManualSamplingData data;
final VoidCallback onNext;
const MarineInvesManualStep2SiteInfo({
super.key,
required this.data,
required this.onNext,
});
@override
State<MarineInvesManualStep2SiteInfo> createState() => _MarineInvesManualStep2SiteInfoState();
}
class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2SiteInfo> {
final _formKey = GlobalKey<FormState>();
bool _isPickingImage = false;
late final TextEditingController _eventRemarksController;
late final TextEditingController _labRemarksController;
final List<String> _weatherOptions = ['Clear', 'Cloudy', 'Drizzle', 'Rainy', 'Windy'];
final List<String> _tideOptions = ['High', 'Low', 'Mid'];
final List<String> _seaConditionOptions = ['Calm', 'Moderate Wave', 'High Wave'];
@override
void initState() {
super.initState();
_eventRemarksController = TextEditingController(text: widget.data.eventRemarks);
_labRemarksController = TextEditingController(text: widget.data.labRemarks);
}
@override
void dispose() {
_eventRemarksController.dispose();
_labRemarksController.dispose();
super.dispose();
}
/// Handles picking and processing an image using the dedicated service.
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
if (_isPickingImage) return;
setState(() => _isPickingImage = true);
final service = Provider.of<MarineInvestigativeSamplingService>(context, listen: false);
// The service's pickAndProcessImage method will handle file naming
final file = await service.pickAndProcessImage(source, data: widget.data, imageInfo: imageInfo, isRequired: isRequired);
if (file != null) {
setState(() => setImageCallback(file));
} else if (mounted) {
_showSnackBar('Image selection failed. Please ensure all photos are taken in landscape mode.', isError: true);
}
if (mounted) {
setState(() => _isPickingImage = false);
}
}
/// Validates the form and all required images before proceeding.
void _goToNextStep() {
if (widget.data.leftLandViewImage == null ||
widget.data.rightLandViewImage == null ||
widget.data.waterFillingImage == null ||
widget.data.seawaterColorImage == null) {
_showSnackBar('Please attach all 4 required photos before proceeding.', isError: true);
return;
}
// Form validation handles the conditional requirement for Event Remarks
if (!_formKey.currentState!.validate()) {
return;
}
_formKey.currentState!.save();
widget.onNext();
}
void _showSnackBar(String message, {bool isError = false}) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red : null,
));
}
}
@override
Widget build(BuildContext context) {
// Logic to determine if Event Remarks are required
final bool areAdditionalPhotosAttached = widget.data.phPaperImage != null ||
widget.data.optionalImage1 != null ||
widget.data.optionalImage2 != null ||
widget.data.optionalImage3 != null ||
widget.data.optionalImage4 != null;
return Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24.0),
children: [
// --- Section: On-Site Information ---
Text("On-Site Information", style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 24),
DropdownButtonFormField<String>(
value: widget.data.weather,
items: _weatherOptions.map((item) => DropdownMenuItem(value: item, child: Text(item))).toList(),
onChanged: (value) => setState(() => widget.data.weather = value),
decoration: const InputDecoration(labelText: 'Weather *'),
validator: (value) => value == null ? 'Weather is required' : null,
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: widget.data.tideLevel,
items: _tideOptions.map((item) => DropdownMenuItem(value: item, child: Text(item))).toList(),
onChanged: (value) => setState(() => widget.data.tideLevel = value),
decoration: const InputDecoration(labelText: 'Tide Level *'),
validator: (value) => value == null ? 'Tide level is required' : null,
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: widget.data.seaCondition,
items: _seaConditionOptions.map((item) => DropdownMenuItem(value: item, child: Text(item))).toList(),
onChanged: (value) => setState(() => widget.data.seaCondition = value),
decoration: const InputDecoration(labelText: 'Sea Condition *'),
validator: (value) => value == null ? 'Sea condition is required' : null,
),
const SizedBox(height: 24),
// --- Section: Required Photos ---
Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge),
const Text("All photos must be taken in landscape (horizontal) orientation.", style: TextStyle(color: Colors.grey)),
const SizedBox(height: 8),
_buildImagePicker('Left Side Land View', 'LEFT_LAND_VIEW', widget.data.leftLandViewImage, (file) => widget.data.leftLandViewImage = file, isRequired: true),
_buildImagePicker('Right Side Land View', 'RIGHT_LAND_VIEW', widget.data.rightLandViewImage, (file) => widget.data.rightLandViewImage = file, isRequired: true),
_buildImagePicker('Filling Water into Sample Bottle', 'WATER_FILLING', widget.data.waterFillingImage, (file) => widget.data.waterFillingImage = file, isRequired: true),
_buildImagePicker('Seawater in Clear Glass Bottle', 'SEAWATER_COLOR', widget.data.seawaterColorImage, (file) => widget.data.seawaterColorImage = file, isRequired: true),
const SizedBox(height: 24),
// --- Section: Additional photos and conditional remarks ---
Text("Additional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
_buildImagePicker('Examine Preservative (pH paper)', 'PH_PAPER', widget.data.phPaperImage, (file) => widget.data.phPaperImage = file, isRequired: false),
_buildImagePicker('Optional Photo 1', 'OPTIONAL_1', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, isRequired: false),
_buildImagePicker('Optional Photo 2', 'OPTIONAL_2', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, isRequired: false),
_buildImagePicker('Optional Photo 3', 'OPTIONAL_3', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, isRequired: false),
_buildImagePicker('Optional Photo 4', 'OPTIONAL_4', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, isRequired: false),
const SizedBox(height: 24),
Text("Remarks", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
// Event Remarks field is conditionally required
TextFormField(
controller: _eventRemarksController,
decoration: InputDecoration(
labelText: areAdditionalPhotosAttached ? 'Event Remarks *' : 'Event Remarks (Optional)',
hintText: 'e.g., unusual smells, colors, etc.'
),
onSaved: (value) => widget.data.eventRemarks = value,
validator: (value) {
if (areAdditionalPhotosAttached && (value == null || value.trim().isEmpty)) {
return 'Event Remarks are required when attaching additional photos.';
}
return null;
},
maxLines: 3,
),
const SizedBox(height: 16),
TextFormField(
controller: _labRemarksController,
decoration: const InputDecoration(labelText: 'Lab Remarks (Optional)'),
onSaved: (value) => widget.data.labRemarks = value,
maxLines: 3,
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _goToNextStep,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Next'),
),
],
),
);
}
/// A reusable widget for picking and displaying an image
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {bool isRequired = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
if (imageFile != null)
Stack(
// ... (Image preview stack - same as original)
)
else
Row(
// ... (Camera/Gallery buttons - same as original)
),
],
),
);
}
}

View File

@ -1 +1,832 @@
//lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_3_data_capture.dart // lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_3_data_capture.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:usb_serial/usb_serial.dart';
import '../../../../auth_provider.dart';
import '../../../../models/marine_inves_manual_sampling_data.dart';
import '../../../../services/marine_investigative_sampling_service.dart';
import '../../../../bluetooth/bluetooth_manager.dart'; // For connection state enum
import '../../../../serial/serial_manager.dart'; // For connection state enum
import '../../../../bluetooth/widgets/bluetooth_device_list_dialog.dart';
import '../../../../serial/widget/serial_port_list_dialog.dart';
class MarineInvesManualStep3DataCapture extends StatefulWidget {
final MarineInvesManualSamplingData data;
final VoidCallback onNext;
const MarineInvesManualStep3DataCapture({
super.key,
required this.data,
required this.onNext,
});
@override
State<MarineInvesManualStep3DataCapture> createState() => _MarineInvesManualStep3DataCaptureState();
}
class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualStep3DataCapture> with WidgetsBindingObserver {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _isAutoReading = false;
StreamSubscription? _dataSubscription;
Timer? _lockoutTimer;
int _lockoutSecondsRemaining = 30;
bool _isLockedOut = false;
late final MarineInvestigativeSamplingService _samplingService;
Map<String, double>? _previousReadingsForComparison;
Set<String> _outOfBoundsKeys = {};
final Map<String, String> _parameterKeyToLimitName = const {
'oxygenConcentration': 'Oxygen Conc',
'oxygenSaturation': 'Oxygen Sat',
'ph': 'pH',
'salinity': 'Salinity',
'electricalConductivity': 'Conductivity',
'temperature': 'Temperature',
'tds': 'TDS',
'turbidity': 'Turbidity',
'tss': 'TSS',
'batteryVoltage': 'Battery',
};
final List<Map<String, dynamic>> _parameters = [];
final _sondeIdController = TextEditingController();
final _dateController = TextEditingController();
final _timeController = TextEditingController();
final _oxyConcController = TextEditingController();
final _oxySatController = TextEditingController();
final _phController = TextEditingController();
final _salinityController = TextEditingController();
final _ecController = TextEditingController();
final _tempController = TextEditingController();
final _tdsController = TextEditingController();
final _turbidityController = TextEditingController();
final _tssController = TextEditingController();
final _batteryController = TextEditingController();
@override
void initState() {
super.initState();
_samplingService = Provider.of<MarineInvestigativeSamplingService>(context, listen: false);
_initializeControllers();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
_dataSubscription?.cancel();
_lockoutTimer?.cancel();
if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_samplingService.disconnectFromBluetooth();
}
if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) {
_samplingService.disconnectFromSerial();
}
_disposeControllers();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
if (mounted) setState(() {});
}
}
void _initializeControllers() {
widget.data.dataCaptureDate = widget.data.samplingDate;
widget.data.dataCaptureTime = widget.data.samplingTime;
_sondeIdController.text = widget.data.sondeId ?? '';
_dateController.text = widget.data.dataCaptureDate ?? '';
_timeController.text = widget.data.dataCaptureTime ?? '';
widget.data.oxygenConcentration ??= -999.0;
widget.data.oxygenSaturation ??= -999.0;
widget.data.ph ??= -999.0;
widget.data.salinity ??= -999.0;
widget.data.electricalConductivity ??= -999.0;
widget.data.temperature ??= -999.0;
widget.data.tds ??= -999.0;
widget.data.turbidity ??= -999.0;
widget.data.tss ??= -999.0;
widget.data.batteryVoltage ??= -999.0;
_oxyConcController.text = widget.data.oxygenConcentration!.toString();
_oxySatController.text = widget.data.oxygenSaturation!.toString();
_phController.text = widget.data.ph!.toString();
_salinityController.text = widget.data.salinity!.toString();
_ecController.text = widget.data.electricalConductivity!.toString();
_tempController.text = widget.data.temperature!.toString();
_tdsController.text = widget.data.tds!.toString();
_turbidityController.text = widget.data.turbidity!.toString();
_tssController.text = widget.data.tss!.toString();
_batteryController.text = widget.data.batteryVoltage!.toString();
if (_parameters.isEmpty) {
_parameters.addAll([
{'key': 'oxygenConcentration', 'icon': Icons.air, 'label': 'Oxygen Conc.', 'unit': 'mg/L', 'controller': _oxyConcController},
{'key': 'oxygenSaturation', 'icon': Icons.percent, 'label': 'Oxygen Sat.', 'unit': '%', 'controller': _oxySatController},
{'key': 'ph', 'icon': Icons.science_outlined, 'label': 'pH', 'unit': '', 'controller': _phController},
{'key': 'salinity', 'icon': Icons.waves, 'label': 'Salinity', 'unit': 'ppt', 'controller': _salinityController},
{'key': 'electricalConductivity', 'icon': Icons.flash_on, 'label': 'Conductivity', 'unit': 'µS/cm', 'controller': _ecController},
{'key': 'temperature', 'icon': Icons.thermostat, 'label': 'Temperature', 'unit': '°C', 'controller': _tempController},
{'key': 'tds', 'icon': Icons.grain, 'label': 'TDS', 'unit': 'mg/L', 'controller': _tdsController},
{'key': 'turbidity', 'icon': Icons.opacity, 'label': 'Turbidity', 'unit': 'NTU', 'controller': _turbidityController},
{'key': 'tss', 'icon': Icons.filter_alt_outlined, 'label': 'TSS', 'unit': 'mg/L', 'controller': _tssController},
{'key': 'batteryVoltage', 'icon': Icons.battery_charging_full, 'label': 'Battery', 'unit': 'V', 'controller': _batteryController},
]);
}
}
void _disposeControllers() {
_sondeIdController.dispose();
_dateController.dispose();
_timeController.dispose();
_oxyConcController.dispose();
_oxySatController.dispose();
_phController.dispose();
_salinityController.dispose();
_ecController.dispose();
_tempController.dispose();
_tdsController.dispose();
_turbidityController.dispose();
_tssController.dispose();
_batteryController.dispose();
}
Future<void> _handleConnectionAttempt(String type) async {
final service = context.read<MarineInvestigativeSamplingService>();
final hasPermissions = await service.requestDevicePermissions();
if (!hasPermissions && mounted) {
_showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true);
return;
}
_disconnectFromAll();
await Future.delayed(const Duration(milliseconds: 250));
final bool connectionSuccess = await _connectToDevice(type);
if (connectionSuccess && mounted) {
_dataSubscription?.cancel();
final stream = type == 'bluetooth' ? service.bluetoothDataStream : service.serialDataStream;
_dataSubscription = stream.listen((readings) {
if (mounted) _updateTextFields(readings);
});
}
}
Future<bool> _connectToDevice(String type) async {
setState(() => _isLoading = true);
final service = context.read<MarineInvestigativeSamplingService>();
bool success = false;
try {
if (type == 'bluetooth') {
final devices = await service.getPairedBluetoothDevices();
if (devices.isEmpty && mounted) {
_showSnackBar('No paired Bluetooth devices found.', isError: true);
return false;
}
final selectedDevice = await showBluetoothDeviceListDialog(context: context, devices: devices);
if (selectedDevice != null) {
await service.connectToBluetoothDevice(selectedDevice);
success = true;
}
} else if (type == 'serial') {
final devices = await service.getAvailableSerialDevices();
if (devices.isEmpty && mounted) {
_showSnackBar('No USB Serial devices found.', isError: true);
return false;
}
final selectedDevice = await showSerialPortListDialog(context: context, devices: devices);
if (selectedDevice != null) {
await service.connectToSerialDevice(selectedDevice);
success = true;
}
}
} catch (e) {
debugPrint("Connection failed: $e");
if (mounted) _showConnectionFailedDialog();
} finally {
if (mounted) setState(() => _isLoading = false);
}
return success;
}
Future<void> _showConnectionFailedDialog() async {
if (!mounted) return;
return showDialog<void>(
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: <Widget>[
TextButton(
child: const Text('OK'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}
void _startLockoutTimer() {
_lockoutTimer?.cancel();
setState(() {
_isLockedOut = true;
_lockoutSecondsRemaining = 30;
});
_lockoutTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_lockoutSecondsRemaining > 0) {
if (mounted) {
setState(() {
_lockoutSecondsRemaining--;
});
}
} else {
timer.cancel();
if (mounted) {
setState(() {
_isLockedOut = false;
});
}
}
});
}
void _toggleAutoReading(String activeType) {
final service = context.read<MarineInvestigativeSamplingService>();
setState(() {
_isAutoReading = !_isAutoReading;
if (_isAutoReading) {
if (activeType == 'bluetooth') service.startBluetoothAutoReading(); else service.startSerialAutoReading();
_startLockoutTimer();
} else {
if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); else service.stopSerialAutoReading();
}
});
}
void _disconnect(String type) {
final service = context.read<MarineInvestigativeSamplingService>();
if (type == 'bluetooth') {
service.disconnectFromBluetooth();
} else {
service.disconnectFromSerial();
}
_dataSubscription?.cancel();
_dataSubscription = null;
_lockoutTimer?.cancel();
if (mounted) {
setState(() {
_isAutoReading = false;
_isLockedOut = false;
});
}
}
void _disconnectFromAll() {
final service = context.read<MarineInvestigativeSamplingService>();
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_disconnect('bluetooth');
}
if (service.serialConnectionState.value != SerialConnectionState.disconnected) {
_disconnect('serial');
}
}
void _updateTextFields(Map<String, double> readings) {
const defaultValue = -999.0;
setState(() {
_oxyConcController.text = (readings['Optical Dissolved Oxygen: Compensated mg/L'] ?? defaultValue).toStringAsFixed(5);
_oxySatController.text = (readings['Optical Dissolved Oxygen: Compensated % Saturation'] ?? defaultValue).toStringAsFixed(5);
_phController.text = (readings['PH: PH units'] ?? defaultValue).toStringAsFixed(5);
_tempController.text = (readings['External Temp: Degrees Celcius'] ?? defaultValue).toStringAsFixed(5);
_ecController.text = (readings['Conductivity: us/cm'] ?? defaultValue).toStringAsFixed(5);
_salinityController.text = (readings['Conductivity: Salinity'] ?? defaultValue).toStringAsFixed(5);
_tdsController.text = (readings['Conductivity:TDS mg/L'] ?? defaultValue).toStringAsFixed(5);
_tssController.text = (readings['Turbidity: TSS'] ?? defaultValue).toStringAsFixed(5);
_turbidityController.text = (readings['Turbidity: FNU'] ?? defaultValue).toStringAsFixed(5);
_batteryController.text = (readings['Sonde: Battery Voltage'] ?? defaultValue).toStringAsFixed(5);
});
}
void _validateAndProceed() {
if (_isLockedOut) {
_showSnackBar("Please wait for the initial reading period to complete.", isError: true);
return;
}
if (_isAutoReading) {
_showStopReadingDialog();
return;
}
if (!_formKey.currentState!.validate()) {
return;
}
_formKey.currentState!.save();
final currentReadings = _captureReadingsToMap();
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final marineLimits = authProvider.marineParameterLimits ?? [];
final outOfBoundsParams = _validateParameters(currentReadings, marineLimits);
setState(() {
_outOfBoundsKeys = outOfBoundsParams.map((p) => _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet();
});
if (outOfBoundsParams.isNotEmpty) {
_showParameterLimitDialog(outOfBoundsParams, currentReadings);
} else {
_saveDataAndMoveOn(currentReadings);
}
}
Map<String, double> _captureReadingsToMap() {
final Map<String, double> readings = {};
for (var param in _parameters) {
final key = param['key'] as String;
final controller = param['controller'] as TextEditingController;
readings[key] = double.tryParse(controller.text) ?? -999.0;
}
return readings;
}
List<Map<String, dynamic>> _validateParameters(Map<String, double> readings, List<Map<String, dynamic>> limits) {
final List<Map<String, dynamic>> invalidParams = [];
// --- MODIFIED: Get station ID based on station type ---
int? stationId;
if (widget.data.stationTypeSelection == 'Existing Manual Station') {
stationId = widget.data.selectedStation?['station_id'];
}
// Note: Add logic here if Tarball or New Locations have different limits
// For now, we only validate against manual station limits
debugPrint("--- Parameter Validation Start (Investigative) ---");
debugPrint("Selected Station ID: $stationId");
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 == -999.0) return;
final limitName = _parameterKeyToLimitName[key];
if (limitName == null) return;
debugPrint("Checking parameter: '$limitName' (key: '$key')");
Map<String, dynamic> limitData = {};
if (stationId != null) {
limitData = limits.firstWhere(
(l) => l['param_parameter_list'] == limitName && l['station_id']?.toString() == stationId.toString(),
orElse: () => {},
);
}
if (limitData.isNotEmpty) {
debugPrint(" > Found station-specific limit for Station ID $stationId: $limitData");
} else {
debugPrint(" > No station-specific limit found for Station ID $stationId. Skipping check for this parameter.");
}
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)) {
final paramInfo = _parameters.firstWhere((p) => p['key'] == key, orElse: () => {});
invalidParams.add({
'label': paramInfo['label'] ?? key,
'value': value,
'lower_limit': lowerLimit,
'upper_limit': upperLimit,
});
}
}
});
debugPrint("--- Parameter Validation End ---");
return invalidParams;
}
void _saveDataAndMoveOn(Map<String, double> readings) {
try {
const defaultValue = -999.0;
widget.data.temperature = readings['temperature'] ?? defaultValue;
widget.data.ph = readings['ph'] ?? defaultValue;
widget.data.salinity = readings['salinity'] ?? defaultValue;
widget.data.electricalConductivity = readings['electricalConductivity'] ?? defaultValue;
widget.data.oxygenConcentration = readings['oxygenConcentration'] ?? defaultValue;
widget.data.oxygenSaturation = readings['oxygenSaturation'] ?? defaultValue;
widget.data.tds = readings['tds'] ?? defaultValue;
widget.data.turbidity = readings['turbidity'] ?? defaultValue;
widget.data.tss = readings['tss'] ?? defaultValue;
widget.data.batteryVoltage = readings['batteryVoltage'] ?? defaultValue;
} catch (e) {
_showSnackBar("Could not save parameters due to a data format error.", isError: true);
return;
}
setState(() {
_outOfBoundsKeys.clear();
if (_previousReadingsForComparison != null) {
_previousReadingsForComparison = null;
}
});
widget.onNext();
}
void _showStopReadingDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Data Collection Active'),
content: const Text('Please stop the live data collection before proceeding.'),
actions: <Widget>[
TextButton(
child: const Text('OK'),
onPressed: () => Navigator.of(context).pop(),
),
],
);
},
);
}
void _showSnackBar(String message, {bool isError = false}) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red : null,
));
}
}
Map<String, dynamic>? _getActiveConnectionDetails() {
final service = context.watch<MarineInvestigativeSamplingService>();
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
return {'type': 'bluetooth', 'state': service.bluetoothConnectionState.value, 'name': service.connectedBluetoothDeviceName};
}
if (service.serialConnectionState.value != SerialConnectionState.disconnected) {
return {'type': 'serial', 'state': service.serialConnectionState.value, 'name': service.connectedSerialDeviceName};
}
return null;
}
@override
Widget build(BuildContext context) {
final service = context.watch<MarineInvestigativeSamplingService>();
final activeConnection = _getActiveConnectionDetails();
final String? activeType = activeConnection?['type'] as String?;
return WillPopScope(
onWillPop: () async {
if (_isLockedOut) {
_showSnackBar("Please wait for the initial reading period to complete.", isError: true);
return false; // Prevent back navigation
}
return true; // Allow back navigation
},
child: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24.0),
children: [
Text("Data Capture", style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: activeType == 'bluetooth'
? FilledButton.icon(icon: const Icon(Icons.bluetooth_connected), label: const Text("Bluetooth"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth'))
: OutlinedButton.icon(icon: const Icon(Icons.bluetooth), label: const Text("Bluetooth"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth')),
),
const SizedBox(width: 16),
Expanded(
child: activeType == 'serial'
? FilledButton.icon(icon: const Icon(Icons.usb), label: const Text("USB Serial"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial'))
: OutlinedButton.icon(icon: const Icon(Icons.usb), label: const Text("USB Serial"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial')),
),
],
),
const SizedBox(height: 16),
if (activeConnection != null)
_buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']),
const SizedBox(height: 24),
ValueListenableBuilder<String?>(
valueListenable: service.sondeId,
builder: (context, sondeId, child) {
final newSondeId = sondeId ?? '';
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _sondeIdController.text != newSondeId) {
_sondeIdController.text = newSondeId;
widget.data.sondeId = newSondeId;
}
});
return TextFormField(
controller: _sondeIdController,
decoration: const InputDecoration(labelText: 'Sonde ID *', hintText: 'Connect device or enter manually'),
validator: (v) => v == null || v.isEmpty ? 'Sonde ID is required' : null,
onChanged: (value) => widget.data.sondeId = value,
onSaved: (v) => widget.data.sondeId = v,
);
},
),
const SizedBox(height: 16),
Row(
children: [
Expanded(child: TextFormField(controller: _dateController, readOnly: true, decoration: const InputDecoration(labelText: 'Date'))),
const SizedBox(width: 16),
Expanded(child: TextFormField(controller: _timeController, readOnly: true, decoration: const InputDecoration(labelText: 'Time'))),
],
),
if (_previousReadingsForComparison != null)
_buildComparisonView(),
const Divider(height: 32),
Column(
children: _parameters.map((param) {
return _buildParameterListItem(
icon: param['icon'] as IconData,
label: param['label'] as String,
unit: param['unit'] as String,
controller: param['controller'] as TextEditingController,
isOutOfBounds: _outOfBoundsKeys.contains(param['key']),
);
}).toList(),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _isLockedOut ? null : _validateAndProceed,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: Text(_isLockedOut ? 'Next ($_lockoutSecondsRemaining\s)' : 'Next'),
),
],
),
),
);
}
Widget _buildComparisonView() {
final previousReadings = _previousReadingsForComparison!;
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
return Card(
margin: const EdgeInsets.only(top: 24.0),
color: Theme.of(context).cardColor,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: DefaultTextStyle(
style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Resample Comparison",
style: Theme.of(context).textTheme.titleLarge?.copyWith(color: Theme.of(context).primaryColor),
),
const SizedBox(height: 8),
Table(
columnWidths: const {
0: FlexColumnWidth(2),
1: FlexColumnWidth(1.5),
2: FlexColumnWidth(1.5),
},
border: TableBorder(
horizontalInside: BorderSide(width: 1, color: Colors.grey.shade700, style: BorderStyle.solid),
verticalInside: BorderSide(width: 1, color: Colors.grey.shade700, style: BorderStyle.solid),
top: BorderSide(width: 1.5, color: Colors.grey.shade500),
bottom: BorderSide(width: 1.5, color: Colors.grey.shade500),
),
children: [
TableRow(
decoration: BoxDecoration(color: isDarkTheme ? Colors.grey.shade800 : Colors.grey.shade200),
children: [
Padding(padding: const EdgeInsets.all(8.0), child: Text('Parameter', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleMedium?.color))),
Padding(padding: const EdgeInsets.all(8.0), child: Text('Previous', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleMedium?.color))),
Padding(padding: const EdgeInsets.all(8.0), child: Text('Current', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleMedium?.color))),
],
),
..._parameters.map((param) {
final key = param['key'] as String;
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: [
Padding(padding: const EdgeInsets.all(8.0), child: Text(label)),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
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(5),
style: TextStyle(
color: isCurrentValueOutOfBounds
? Colors.red
: (isDarkTheme ? Colors.green.shade200 : Colors.green.shade700),
fontWeight: FontWeight.bold
),
),
),
],
);
}).toList(),
],
),
],
),
),
),
);
}
Future<void> _showParameterLimitDialog(List<Map<String, dynamic>> invalidParams, Map<String, double> readings) async {
return showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
return AlertDialog(
title: const Text('Parameter Limit Warning'),
content: SingleChildScrollView(
child: DefaultTextStyle(
style: TextStyle(color: Theme.of(context).textTheme.bodyMedium?.color),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('The following parameters are outside the standard limits:'),
const SizedBox(height: 16),
Table(
columnWidths: const {
0: FlexColumnWidth(2),
1: FlexColumnWidth(2.5),
2: FlexColumnWidth(1.5),
},
border: TableBorder(
horizontalInside: BorderSide(width: 0.5, color: isDarkTheme ? Colors.grey.shade700 : Colors.grey.shade300),
verticalInside: BorderSide(width: 0.5, color: isDarkTheme ? Colors.grey.shade700 : Colors.grey.shade300),
top: BorderSide(width: 1, color: isDarkTheme ? Colors.grey.shade600 : Colors.grey.shade400),
bottom: BorderSide(width: 1, color: isDarkTheme ? Colors.grey.shade600 : Colors.grey.shade400),
),
children: [
TableRow(
decoration: BoxDecoration(color: isDarkTheme ? Colors.grey.shade800 : Colors.grey.shade200),
children: [
Padding(padding: const EdgeInsets.all(6.0), child: Text('Parameter', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleSmall?.color))),
Padding(padding: const EdgeInsets.all(6.0), child: Text('Limit Range', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleSmall?.color))),
Padding(padding: const EdgeInsets.all(6.0), child: Text('Current', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleSmall?.color))),
],
),
...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(5) ?? 'N/A'} - ${p['upper_limit']?.toStringAsFixed(5) ?? 'N/A'}')),
Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
p['value'].toStringAsFixed(5),
style: const TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold),
),
),
],
)).toList(),
],
),
const SizedBox(height: 16),
const Text('Please verify with standard solutions. Do you want to resample or proceed with the current values?'),
],
),
),
),
actions: <Widget>[
TextButton(
child: const Text('Resample'),
onPressed: () {
setState(() {
_previousReadingsForComparison = readings;
});
Navigator.of(context).pop();
},
),
FilledButton(
child: const Text('Proceed Anyway'),
onPressed: () {
Navigator.of(context).pop();
_saveDataAndMoveOn(readings);
},
),
],
);
},
);
}
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(
leading: Icon(icon, color: Theme.of(context).primaryColor, size: 32),
title: Text(displayLabel),
trailing: Text(
displayValue,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: valueColor),
),
),
);
}
Widget _buildConnectionCard({required String type, required dynamic connectionState, String? deviceName}) {
final isConnected = connectionState == BluetoothConnectionState.connected || connectionState == SerialConnectionState.connected;
final isConnecting = connectionState == BluetoothConnectionState.connecting || connectionState == SerialConnectionState.connecting;
Color statusColor = isConnected ? Colors.green : Colors.red;
String statusText = isConnected ? 'Connected to ${deviceName ?? 'device'}' : 'Disconnected';
if (isConnecting) {
statusColor = Colors.orange;
statusText = 'Connecting...';
}
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Text(statusText, style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 16),
if (isConnecting || _isLoading)
const CircularProgressIndicator()
else if (isConnected)
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
label: Text(_isAutoReading
? (_isLockedOut ? 'Stop Reading ($_lockoutSecondsRemaining\s)' : 'Stop Reading')
: 'Start Reading'),
onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type),
style: ElevatedButton.styleFrom(
backgroundColor: _isAutoReading
? (_isLockedOut ? Colors.grey.shade600 : Colors.orange)
: Colors.green,
foregroundColor: Colors.white,
),
),
TextButton.icon(
icon: const Icon(Icons.link_off),
label: const Text('Disconnect'),
onPressed: () => _disconnect(type),
style: TextButton.styleFrom(foregroundColor: Colors.red),
)
],
)
],
),
),
);
}
}

View File

@ -1 +1,484 @@
//lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_4_summary.dart // lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_4_summary.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../../auth_provider.dart';
import '../../../../models/marine_inves_manual_sampling_data.dart';
// REMOVED: Import for NPE Report Screen is no longer needed
// import '../reports/npe_report_from_investigative.dart';
class MarineInvesManualStep4Summary extends StatefulWidget {
final MarineInvesManualSamplingData data;
final Future<Map<String, dynamic>> Function()
onSubmit; // Expects a function that returns the submission result
final bool isLoading;
const MarineInvesManualStep4Summary({
super.key,
required this.data,
required this.onSubmit,
required this.isLoading,
});
@override
State<MarineInvesManualStep4Summary> createState() => _MarineInvesManualStep4SummaryState();
}
class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Summary> {
bool _isHandlingSubmit = false;
// Keep parameter names for highlighting out-of-bounds station limits
static const Map<String, String> _parameterKeyToLimitName = {
'oxygenConcentration': 'Oxygen Conc',
'oxygenSaturation': 'Oxygen Sat',
'ph': 'pH',
'salinity': 'Salinity',
'electricalConductivity': 'Conductivity',
'temperature': 'Temperature',
'tds': 'TDS',
'turbidity': 'Turbidity',
'tss': 'TSS',
'batteryVoltage': 'Battery',
};
// Keep this function to highlight parameters outside *station* limits
Set<String> _getOutOfBoundsKeys(BuildContext context) {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
// Use regular marine limits, not NPE limits
final marineLimits = authProvider.marineParameterLimits ?? [];
final Set<String> invalidKeys = {};
int? stationId;
if (widget.data.stationTypeSelection == 'Existing Manual Station') {
stationId = widget.data.selectedStation?['station_id'];
}
// Note: Only checking against manual station limits for now.
final readings = {
'oxygenConcentration': widget.data.oxygenConcentration,
'oxygenSaturation': widget.data.oxygenSaturation,
'ph': widget.data.ph,
'salinity': widget.data.salinity,
'electricalConductivity': widget.data.electricalConductivity,
'temperature': widget.data.temperature,
'tds': widget.data.tds,
'turbidity': widget.data.turbidity,
'tss': widget.data.tss,
'batteryVoltage': widget.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;
Map<String, dynamic> limitData = {};
if (stationId != null) {
limitData = marineLimits.firstWhere(
(l) =>
l['param_parameter_list'] == limitName &&
l['station_id'] == stationId,
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;
}
// REMOVED: _getNpeTriggeredParameters method
// REMOVED: _showNpeDialog method
/// Handles the complete submission flow WITHOUT NPE check.
Future<void> _handleSubmit(BuildContext context) async {
if (_isHandlingSubmit || widget.isLoading) return;
setState(() => _isHandlingSubmit = true);
try {
// Directly call the submission function provided by the parent
final result = await widget.onSubmit();
if (!mounted) return;
// Show feedback snackbar based on the result
final message = result['message'] ?? 'An unknown error occurred.';
final color = (result['success'] == true) ? Colors.green : Colors.red;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: color,
duration: const Duration(seconds: 4)),
);
// If submission was successful, navigate back to the home screen
if (result['success'] == true) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
// If submission failed, the user stays on the summary screen to potentially retry
} catch (e) {
// Catch any unexpected errors during submission
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Submission failed unexpectedly: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 4)),
);
}
} finally {
// Ensure the loading state is turned off
if (mounted) {
setState(() => _isHandlingSubmit = false);
}
}
}
// Helper to build station details dynamically
List<Widget> _buildStationDetails() {
final stationType = widget.data.stationTypeSelection;
if (stationType == 'Existing Manual Station') {
return [
_buildDetailRow("Station Source:", "Existing Manual Station"),
_buildDetailRow("State:", widget.data.selectedManualStateName),
_buildDetailRow("Category:", widget.data.selectedManualCategoryName),
_buildDetailRow("Station ID:", widget.data.selectedStation?['man_station_code']?.toString()),
_buildDetailRow("Station Name:", widget.data.selectedStation?['man_station_name']?.toString()),
_buildDetailRow("Station Location:", "${widget.data.stationLatitude}, ${widget.data.stationLongitude}"),
];
} else if (stationType == 'Existing Tarball Station') {
return [
_buildDetailRow("Station Source:", "Existing Tarball Station"),
_buildDetailRow("State:", widget.data.selectedTarballStateName),
_buildDetailRow("Station ID:", widget.data.selectedTarballStation?['tbl_station_code']?.toString()),
_buildDetailRow("Station Name:", widget.data.selectedTarballStation?['tbl_station_name']?.toString()),
_buildDetailRow("Station Location:", "${widget.data.stationLatitude}, ${widget.data.stationLongitude}"),
];
} else if (stationType == 'New Location') {
return [
_buildDetailRow("Station Source:", "New Location"),
_buildDetailRow("Station Name:", widget.data.newStationName),
_buildDetailRow("Station Code:", widget.data.newStationCode),
_buildDetailRow("Station Location:", "(Manual) ${widget.data.stationLatitude}, ${widget.data.stationLongitude}"),
];
}
return [_buildDetailRow("Station Info:", "Not specified")];
}
@override
Widget build(BuildContext context) {
// Still get out-of-bounds keys for station limits to highlight them
final outOfBoundsKeys = _getOutOfBoundsKeys(context);
return ListView(
padding: const EdgeInsets.all(16.0),
children: [
Text(
"Please review all information before submitting.",
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
_buildSectionCard(
context,
"Sampling & Station Details",
[
_buildDetailRow("1st Sampler:", widget.data.firstSamplerName),
_buildDetailRow(
"2nd Sampler:", widget.data.secondSampler?['first_name']?.toString()),
_buildDetailRow("Sampling Date:", widget.data.samplingDate),
_buildDetailRow("Sampling Time:", widget.data.samplingTime),
_buildDetailRow("Sampling Type:", widget.data.samplingType),
_buildDetailRow("Sample ID Code:", widget.data.sampleIdCode),
const Divider(height: 20),
..._buildStationDetails(), // Use dynamic station details
],
),
_buildSectionCard(
context,
"Location & On-Site Info",
[
_buildDetailRow("Current Location:",
"${widget.data.currentLatitude}, ${widget.data.currentLongitude}"),
_buildDetailRow(
"Distance Difference:",
widget.data.distanceDifferenceInKm != null
? "${(widget.data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters"
: "N/A"),
if (widget.data.distanceDifferenceRemarks != null &&
widget.data.distanceDifferenceRemarks!.isNotEmpty)
_buildDetailRow(
"Distance Remarks:", widget.data.distanceDifferenceRemarks),
const Divider(height: 20),
_buildDetailRow("Weather:", widget.data.weather),
_buildDetailRow("Tide Level:", widget.data.tideLevel),
_buildDetailRow("Sea Condition:", widget.data.seaCondition),
_buildDetailRow("Event Remarks:", widget.data.eventRemarks),
_buildDetailRow("Lab Remarks:", widget.data.labRemarks),
],
),
_buildSectionCard(
context,
"Attached Photos",
[
_buildImageCard("Left Side Land View", widget.data.leftLandViewImage),
_buildImageCard(
"Right Side Land View", widget.data.rightLandViewImage),
_buildImageCard(
"Filling Water into Bottle", widget.data.waterFillingImage),
_buildImageCard(
"Seawater Color in Bottle", widget.data.seawaterColorImage),
_buildImageCard(
"Examine Preservative (pH paper)", widget.data.phPaperImage),
const Divider(height: 24),
Text("Optional Photos",
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
_buildImageCard("Optional Photo 1", widget.data.optionalImage1,
remark: widget.data.optionalRemark1),
_buildImageCard("Optional Photo 2", widget.data.optionalImage2,
remark: widget.data.optionalRemark2),
_buildImageCard("Optional Photo 3", widget.data.optionalImage3,
remark: widget.data.optionalRemark3),
_buildImageCard("Optional Photo 4", widget.data.optionalImage4,
remark: widget.data.optionalRemark4),
],
),
_buildSectionCard(
context,
"Captured Parameters",
[
_buildDetailRow("Sonde ID:", widget.data.sondeId),
_buildDetailRow("Capture Time:",
"${widget.data.dataCaptureDate} ${widget.data.dataCaptureTime}"),
const Divider(height: 20),
_buildParameterListItem(context,
icon: Icons.air,
label: "Oxygen Conc.",
unit: "mg/L",
value: widget.data.oxygenConcentration,
isOutOfBounds:
outOfBoundsKeys.contains('oxygenConcentration')),
_buildParameterListItem(context,
icon: Icons.percent,
label: "Oxygen Sat.",
unit: "%",
value: widget.data.oxygenSaturation,
isOutOfBounds: outOfBoundsKeys.contains('oxygenSaturation')),
_buildParameterListItem(context,
icon: Icons.science_outlined,
label: "pH",
unit: "",
value: widget.data.ph,
isOutOfBounds: outOfBoundsKeys.contains('ph')),
_buildParameterListItem(context,
icon: Icons.waves,
label: "Salinity",
unit: "ppt",
value: widget.data.salinity,
isOutOfBounds: outOfBoundsKeys.contains('salinity')),
_buildParameterListItem(context,
icon: Icons.flash_on,
label: "Conductivity",
unit: "µS/cm",
value: widget.data.electricalConductivity,
isOutOfBounds:
outOfBoundsKeys.contains('electricalConductivity')),
_buildParameterListItem(context,
icon: Icons.thermostat,
label: "Temperature",
unit: "°C",
value: widget.data.temperature,
isOutOfBounds: outOfBoundsKeys.contains('temperature')),
_buildParameterListItem(context,
icon: Icons.grain,
label: "TDS",
unit: "mg/L",
value: widget.data.tds,
isOutOfBounds: outOfBoundsKeys.contains('tds')),
_buildParameterListItem(context,
icon: Icons.opacity,
label: "Turbidity",
unit: "NTU",
value: widget.data.turbidity,
isOutOfBounds: outOfBoundsKeys.contains('turbidity')),
_buildParameterListItem(context,
icon: Icons.filter_alt_outlined,
label: "TSS",
unit: "mg/L",
value: widget.data.tss,
isOutOfBounds: outOfBoundsKeys.contains('tss')),
_buildParameterListItem(context,
icon: Icons.battery_charging_full,
label: "Battery",
unit: "V",
value: widget.data.batteryVoltage,
isOutOfBounds: outOfBoundsKeys.contains('batteryVoltage')),
],
),
const SizedBox(height: 24),
(widget.isLoading || _isHandlingSubmit)
? const Center(child: CircularProgressIndicator())
: ElevatedButton.icon(
onPressed: () => _handleSubmit(context), // Simplified call
icon: const Icon(Icons.cloud_upload),
label: const Text('Confirm & Submit'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(
fontSize: 18, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 16),
],
);
}
// --- Helper widgets ---
Widget _buildSectionCard(
BuildContext context, String title, List<Widget> children) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8.0),
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
const Divider(height: 20, thickness: 1),
...children,
],
),
),
);
}
Widget _buildDetailRow(String label, String? value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
),
const SizedBox(width: 8),
Expanded(
flex: 3,
child: Text(value != null && value.isNotEmpty ? value : 'N/A',
style: const TextStyle(fontSize: 16)),
),
],
),
);
}
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;
final String displayValue =
isMissing ? 'N/A' : '${value.toStringAsFixed(5)} ${unit}'.trim();
final Color? defaultTextColor =
Theme.of(context).textTheme.bodyLarge?.color;
final Color valueColor = isOutOfBounds // Still highlight if outside station limits
? Colors.red
: (isMissing ? Colors.grey : defaultTextColor ?? Colors.black);
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(icon, color: Theme.of(context).primaryColor, size: 28),
title: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
trailing: Text(
displayValue,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: valueColor,
fontWeight: isOutOfBounds ? FontWeight.bold : null,
),
),
);
}
Widget _buildImageCard(String title, File? image, {String? remark}) {
final bool hasRemark = remark != null && remark.isNotEmpty;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style:
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 8),
if (image != null)
ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.file(image,
key: UniqueKey(),
height: 200,
width: double.infinity,
fit: BoxFit.cover),
)
else
Container(
height: 100,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8.0),
border: Border.all(color: Colors.grey[300]!)),
child: const Center(
child: Text('No Image Attached',
style: TextStyle(color: Colors.grey))),
),
if (hasRemark)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text('Remark: $remark',
style: const TextStyle(fontStyle: FontStyle.italic)),
),
],
),
);
}
}

View File

@ -1 +1,125 @@
//lib/screens/marine/investigative/marine_investigative_manual_sampling.dart // lib/screens/marine/investigative/marine_investigative_manual_sampling.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../models/marine_inves_manual_sampling_data.dart';
import '../../../services/marine_investigative_sampling_service.dart';
import '../../../auth_provider.dart';
import 'manual_sampling/marine_inves_manual_step_1_sampling_info.dart';
import 'manual_sampling/marine_inves_manual_step_2_site_info.dart';
import 'manual_sampling/marine_inves_manual_step_3_data_capture.dart';
import 'manual_sampling/marine_inves_manual_step_4_summary.dart';
class MarineInvestigativeManualSampling extends StatefulWidget {
const MarineInvestigativeManualSampling({super.key});
@override
State<MarineInvestigativeManualSampling> createState() => _MarineInvestigativeManualSamplingState();
}
class _MarineInvestigativeManualSamplingState extends State<MarineInvestigativeManualSampling> {
final PageController _pageController = PageController();
final MarineInvesManualSamplingData _data = MarineInvesManualSamplingData();
int _currentStep = 0;
bool _isLoading = false;
@override
void initState() {
super.initState();
// Pre-fill sampling date and time when the form is first created
final now = DateTime.now();
_data.samplingDate = "${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}";
_data.samplingTime = "${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}";
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _nextPage() {
if (_currentStep < 3) {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
void _previousPage() {
if (_currentStep > 0) {
_pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
Future<Map<String, dynamic>> _submitData() async {
setState(() => _isLoading = true);
final service = context.read<MarineInvestigativeSamplingService>();
final authProvider = context.read<AuthProvider>();
final appSettings = authProvider.appSettings;
try {
final result = await service.submitInvestigativeSample(
data: _data,
appSettings: appSettings,
authProvider: authProvider,
context: context,
);
return result;
} catch (e) {
return {'success': false, 'message': 'An unexpected error occurred: $e'};
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
final List<Widget> pages = [
MarineInvesManualStep1SamplingInfo(data: _data, onNext: _nextPage),
MarineInvesManualStep2SiteInfo(data: _data, onNext: _nextPage),
MarineInvesManualStep3DataCapture(data: _data, onNext: _nextPage),
MarineInvesManualStep4Summary(
data: _data,
onSubmit: _submitData,
isLoading: _isLoading,
),
];
return Scaffold(
appBar: AppBar(
title: Text('Marine Investigative Sampling (Step ${_currentStep + 1} of 4)'),
leading: _currentStep == 0
? IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
)
: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _previousPage,
),
),
body: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
onPageChanged: (index) {
setState(() {
_currentStep = index;
});
},
children: pages,
),
);
}
}

View File

@ -1,3 +1,5 @@
// lib/screens/marine/marine_home_page.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
// Re-defining SidebarItem here for self-containment, // Re-defining SidebarItem here for self-containment,
@ -60,6 +62,8 @@ class MarineHomePage extends StatelessWidget {
children: [ children: [
// MODIFIED: Updated label, icon, and route for the new Info Centre screen // MODIFIED: Updated label, icon, and route for the new Info Centre screen
SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/investigative/info'), SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/investigative/info'),
// *** ADDED: New menu item for Investigative Manual Sampling ***
SidebarItem(icon: Icons.science_outlined, label: "Investigative Sampling", route: '/marine/investigative/manual-sampling'),
//SidebarItem(icon: Icons.info, label: "Overview", route: '/marine/investigative/overview'), //SidebarItem(icon: Icons.info, label: "Overview", route: '/marine/investigative/overview'),
//SidebarItem(icon: Icons.input, label: "Entry", route: '/marine/investigative/entry'), //SidebarItem(icon: Icons.input, label: "Entry", route: '/marine/investigative/entry'),
//SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/investigative/report'), //SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/investigative/report'),

View File

@ -0,0 +1,877 @@
// lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_1_sampling_info.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:dropdown_search/dropdown_search.dart';
import 'package:intl/intl.dart';
import 'package:simple_barcode_scanner/simple_barcode_scanner.dart';
import '../../../../auth_provider.dart';
import '../../../../models/river_inves_manual_sampling_data.dart'; // Updated model
import '../../../../services/river_investigative_sampling_service.dart'; // Updated service
class RiverInvesStep1SamplingInfo extends StatefulWidget {
final RiverInvesManualSamplingData data;
final VoidCallback onNext;
const RiverInvesStep1SamplingInfo({
super.key,
required this.data,
required this.onNext,
});
@override
State<RiverInvesStep1SamplingInfo> createState() =>
_RiverInvesStep1SamplingInfoState();
}
class _RiverInvesStep1SamplingInfoState extends State<RiverInvesStep1SamplingInfo> {
final _formKey = GlobalKey<FormState>();
bool _isLoadingLocation = false;
late final TextEditingController _firstSamplerController;
late final TextEditingController _dateController;
late final TextEditingController _timeController;
late final TextEditingController _barcodeController;
late final TextEditingController _stationLatController;
late final TextEditingController _stationLonController;
late final TextEditingController _currentLatController;
late final TextEditingController _currentLonController;
// --- NEW: Controllers for New Location ---
late final TextEditingController _newStateController;
late final TextEditingController _newBasinController;
late final TextEditingController _newRiverController;
late final TextEditingController _newStationCodeController;
List<String> _statesList = [];
List<Map<String, dynamic>> _manualStationsForState = [];
List<Map<String, dynamic>> _triennialStationsForState = [];
final List<String> _stationTypes = [
'Existing Manual Station',
'Existing Triennial Station',
'New Location'
];
// Note: Investigative sampling type is fixed in the model
@override
void initState() {
super.initState();
_initializeControllers();
_initializeForm();
}
@override
void dispose() {
_firstSamplerController.dispose();
_dateController.dispose();
_timeController.dispose();
_barcodeController.dispose();
_stationLatController.dispose();
_stationLonController.dispose();
_currentLatController.dispose();
_currentLonController.dispose();
_newStateController.dispose();
_newBasinController.dispose();
_newRiverController.dispose();
_newStationCodeController.dispose();
super.dispose();
}
void _initializeControllers() {
_firstSamplerController = TextEditingController();
_dateController = TextEditingController();
_timeController = TextEditingController();
_barcodeController = TextEditingController(text: widget.data.sampleIdCode);
_stationLatController = TextEditingController(text: widget.data.stationLatitude);
_stationLonController = TextEditingController(text: widget.data.stationLongitude);
_currentLatController = TextEditingController(text: widget.data.currentLatitude);
_currentLonController = TextEditingController(text: widget.data.currentLongitude);
// New Location controllers
_newStateController = TextEditingController(text: widget.data.newStateName);
_newBasinController = TextEditingController(text: widget.data.newBasinName);
_newRiverController = TextEditingController(text: widget.data.newRiverName);
_newStationCodeController = TextEditingController(text: widget.data.newStationCode);
}
void _initializeForm() {
final auth = Provider.of<AuthProvider>(context, listen: false);
widget.data.firstSamplerName = auth.profileData?['first_name'] ?? 'Current User';
widget.data.firstSamplerUserId = auth.profileData?['user_id'];
_firstSamplerController.text = widget.data.firstSamplerName!;
final now = DateTime.now();
if (widget.data.samplingDate == null || widget.data.samplingDate!.isEmpty) {
widget.data.samplingDate = DateFormat('yyyy-MM-dd').format(now);
widget.data.samplingTime = DateFormat('HH:mm:ss').format(now);
}
_dateController.text = widget.data.samplingDate!;
_timeController.text = widget.data.samplingTime!;
// Sampling type is fixed to Investigative in the model
// Populate states list from Manual stations (assuming they cover all states)
final allManualStations = auth.riverManualStations ?? [];
if (allManualStations.isNotEmpty) {
final states = allManualStations
.map((s) => s['state_name'] as String?)
.whereType<String>()
.toSet()
.toList();
states.sort();
setState(() {
_statesList = states;
});
} else {
// Fallback: If no manual stations, try getting states from Triennial or general States list
final allTriennialStations = auth.riverTriennialStations ?? [];
if (allTriennialStations.isNotEmpty) {
final states = allTriennialStations
.map((s) => s['state_name'] as String?) // Assuming Triennial has state_name
.whereType<String>()
.toSet()
.toList();
states.sort();
setState(() { _statesList = states; });
} else {
// Further fallback
final generalStates = auth.states ?? [];
final states = generalStates
.map((s) => s['state_name'] as String?)
.whereType<String>()
.toSet()
.toList();
states.sort();
setState(() { _statesList = states; });
}
}
// Pre-load stations if state and type are already selected (e.g., coming back to step)
_loadStationsForSelectedState();
_calculateDistance(); // Recalculate distance on init
}
void _loadStationsForSelectedState() {
if (widget.data.selectedStateName == null) return;
final auth = Provider.of<AuthProvider>(context, listen: false);
final allManualStations = auth.riverManualStations ?? [];
final allTriennialStations = auth.riverTriennialStations ?? [];
setState(() {
_manualStationsForState = allManualStations
.where((s) => s['state_name'] == widget.data.selectedStateName)
.toList()
..sort((a, b) => (a['sampling_station_code'] ?? '')
.compareTo(b['sampling_station_code'] ?? ''));
_triennialStationsForState = allTriennialStations
.where((s) => s['state_name'] == widget.data.selectedStateName) // Assuming Triennial has state_name
.toList()
..sort((a, b) => (a['triennial_station_code'] ?? '')
.compareTo(b['triennial_station_code'] ?? ''));
});
}
Future<void> _getCurrentLocation() async {
setState(() => _isLoadingLocation = true);
final service = Provider.of<RiverInvestigativeSamplingService>(context, listen: false);
try {
final position = await service.getCurrentLocation();
if (mounted) {
setState(() {
widget.data.currentLatitude = position.latitude.toString();
widget.data.currentLongitude = position.longitude.toString();
_currentLatController.text = widget.data.currentLatitude!;
_currentLonController.text = widget.data.currentLongitude!;
// --- MODIFICATION: Update station lat/lon ONLY if 'New Location' ---
if (widget.data.stationTypeSelection == 'New Location') {
widget.data.stationLatitude = position.latitude.toString();
widget.data.stationLongitude = position.longitude.toString();
_stationLatController.text = widget.data.stationLatitude!;
_stationLonController.text = widget.data.stationLongitude!;
}
// --- END MODIFICATION ---
_calculateDistance(); // Always calculate distance after getting current location
});
}
} catch (e) {
if(mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to get location: $e')));
}
} finally {
if (mounted) {
setState(() => _isLoadingLocation = false);
}
}
}
void _calculateDistance() {
final lat1Str = widget.data.stationLatitude;
final lon1Str = widget.data.stationLongitude;
final lat2Str = widget.data.currentLatitude;
final lon2Str = widget.data.currentLongitude;
if (lat1Str != null && lon1Str != null && lat2Str != null && lon2Str != null) {
final service = Provider.of<RiverInvestigativeSamplingService>(context, listen: false);
final lat1 = double.tryParse(lat1Str);
final lon1 = double.tryParse(lon1Str);
final lat2 = double.tryParse(lat2Str);
final lon2 = double.tryParse(lon2Str);
if (lat1 != null && lon1 != null && lat2 != null && lon2 != null) {
final distance = service.calculateDistance(lat1, lon1, lat2, lon2);
setState(() {
widget.data.distanceDifferenceInKm = distance;
});
} else {
setState(() { widget.data.distanceDifferenceInKm = null; });
}
} else {
setState(() { widget.data.distanceDifferenceInKm = null; });
}
}
Future<void> _scanBarcode() async {
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SimpleBarcodeScannerPage()),
);
if (result is String && result != '-1' && mounted) {
setState(() {
widget.data.sampleIdCode = result;
_barcodeController.text = result;
});
}
}
// --- MODIFICATION: Disable Nearby Station for now, or adapt later ---
Future<void> _findAndShowNearbyStations() async {
// Only works for Manual Stations currently
if (widget.data.stationTypeSelection != 'Existing Manual Station') {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Nearby station search only available for Manual Stations.')));
return;
}
if (widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) {
await _getCurrentLocation();
if (!mounted || widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) {
return;
}
}
final service = Provider.of<RiverInvestigativeSamplingService>(context, listen: false);
final auth = Provider.of<AuthProvider>(context, listen: false);
final currentLat = double.parse(widget.data.currentLatitude!);
final currentLon = double.parse(widget.data.currentLongitude!);
final allStations = auth.riverManualStations ?? []; // Only search Manual
final List<Map<String, dynamic>> nearbyStations = [];
for (var station in allStations) {
final stationLat = station['sampling_lat'];
final stationLon = station['sampling_long'];
if (stationLat is num && stationLon is num) {
final distance = service.calculateDistance(currentLat, currentLon, stationLat.toDouble(), stationLon.toDouble());
if (distance <= 3.0) { // 3km radius
nearbyStations.add({'station': station, 'distance': distance});
}
}
}
nearbyStations.sort((a, b) => a['distance'].compareTo(b['distance']));
if (!mounted) return;
final selectedStation = await showDialog<Map<String, dynamic>>(
context: context,
builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations), // Use the same dialog
);
if (selectedStation != null) {
_updateFormWithSelectedManualStation(selectedStation);
}
}
void _updateFormWithSelectedManualStation(Map<String, dynamic> station) {
// This specifically handles selecting a MANUAL station from nearby search or dropdown
final auth = Provider.of<AuthProvider>(context, listen: false);
final allManualStations = auth.riverManualStations ?? [];
setState(() {
widget.data.stationTypeSelection = 'Existing Manual Station'; // Ensure type is correct
widget.data.selectedStateName = station['state_name'];
widget.data.selectedStation = station; // Set manual station
widget.data.selectedTriennialStation = null; // Clear triennial
_clearNewLocationFields(); // Clear new location fields
widget.data.stationLatitude = station['sampling_lat']?.toString();
widget.data.stationLongitude = station['sampling_long']?.toString();
_stationLatController.text = widget.data.stationLatitude ?? '';
_stationLonController.text = widget.data.stationLongitude ?? '';
// Reload stations for the selected state if needed (mainly for UI consistency)
_manualStationsForState = allManualStations
.where((s) => s['state_name'] == widget.data.selectedStateName)
.toList()
..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? ''));
_calculateDistance();
});
}
void _updateFormWithSelectedTriennialStation(Map<String, dynamic> station) {
// This specifically handles selecting a TRIENNIAL station from dropdown
final auth = Provider.of<AuthProvider>(context, listen: false);
final allTriennialStations = auth.riverTriennialStations ?? [];
setState(() {
widget.data.stationTypeSelection = 'Existing Triennial Station';
widget.data.selectedStateName = station['state_name']; // Use state from Triennial data
widget.data.selectedTriennialStation = station; // Set triennial station
widget.data.selectedStation = null; // Clear manual
_clearNewLocationFields();
widget.data.stationLatitude = station['triennial_lat']?.toString(); // Use triennial keys
widget.data.stationLongitude = station['triennial_long']?.toString(); // Use triennial keys
_stationLatController.text = widget.data.stationLatitude ?? '';
_stationLonController.text = widget.data.stationLongitude ?? '';
// Reload stations for state (UI consistency)
_triennialStationsForState = allTriennialStations
.where((s) => s['state_name'] == widget.data.selectedStateName)
.toList()
..sort((a, b) => (a['triennial_station_code'] ?? '').compareTo(b['triennial_station_code'] ?? ''));
_calculateDistance();
});
}
void _clearStationSelections() {
widget.data.selectedStation = null;
widget.data.selectedTriennialStation = null;
widget.data.stationLatitude = null;
widget.data.stationLongitude = null;
_stationLatController.clear();
_stationLonController.clear();
widget.data.distanceDifferenceInKm = null;
}
void _clearNewLocationFields() {
widget.data.newStateName = null;
widget.data.newBasinName = null;
widget.data.newRiverName = null;
widget.data.newStationCode = null;
_newStateController.clear();
_newBasinController.clear();
_newRiverController.clear();
_newStationCodeController.clear();
// Don't clear station lat/lon here, as they might be set by GPS for new location
}
void _goToNextStep() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save(); // Save form fields to widget.data
// --- Additional Validation for New Location ---
if (widget.data.stationTypeSelection == 'New Location') {
if (widget.data.stationLatitude == null || widget.data.stationLatitude!.isEmpty ||
widget.data.stationLongitude == null || widget.data.stationLongitude!.isEmpty ) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please capture GPS coordinates for the new location.'), backgroundColor: Colors.red)
);
return;
}
}
// --- End Additional Validation ---
final distanceInMeters = (widget.data.distanceDifferenceInKm ?? 0) * 1000;
// Only show distance warning if NOT a new location and distance > 50m
if (widget.data.stationTypeSelection != 'New Location' && distanceInMeters > 50) {
_showDistanceRemarkDialog();
} else {
widget.data.distanceDifferenceRemarks = null; // Clear remark if not needed
widget.onNext();
}
}
}
Future<void> _showDistanceRemarkDialog() async {
final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks);
final dialogFormKey = GlobalKey<FormState>();
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Distance Warning'),
content: SingleChildScrollView(
child: Form(
key: dialogFormKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Your current location is more than 50m away from the selected station.'),
const SizedBox(height: 16),
TextFormField(
controller: remarkController,
decoration: const InputDecoration(
labelText: 'Remarks *',
hintText: 'Please provide a reason...',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Remarks are required to continue.';
}
return null;
},
maxLines: 3,
),
],
),
),
),
actions: <Widget>[
TextButton(
child: const Text('Cancel'),
onPressed: () {
Navigator.of(context).pop();
},
),
FilledButton(
child: const Text('Confirm'),
onPressed: () {
if (dialogFormKey.currentState!.validate()) {
setState(() {
widget.data.distanceDifferenceRemarks = remarkController.text;
});
Navigator.of(context).pop();
widget.onNext(); // Proceed after confirming remark
}
},
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthProvider>(context, listen: false);
// Note: Station lists (_manualStationsForState, _triennialStationsForState) are updated in callbacks
final allUsers = auth.allUsers ?? [];
final secondSamplersList = allUsers
.where((user) => user['user_id'] != auth.profileData?['user_id'])
.toList()
..sort((a, b) =>
(a['first_name'] ?? '').compareTo(b['first_name'] ?? ''));
bool isNewLocation = widget.data.stationTypeSelection == 'New Location';
return Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24.0),
children: [
Text("Investigative Sampling Information",
style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 24),
// --- Sampler and Time ---
TextFormField(
controller: _firstSamplerController,
readOnly: true,
decoration: const InputDecoration(labelText: '1st Sampler')),
const SizedBox(height: 16),
DropdownSearch<Map<String, dynamic>>(
items: secondSamplersList,
selectedItem: widget.data.secondSampler,
itemAsString: (sampler) =>
"${sampler['first_name']} ${sampler['last_name']}",
onChanged: (sampler) => widget.data.secondSampler = sampler,
popupProps: const PopupProps.menu(
showSearchBox: true,
searchFieldProps: TextFieldProps(
decoration: InputDecoration(hintText: "Search Sampler..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(
dropdownSearchDecoration:
InputDecoration(labelText: '2nd Sampler (Optional)')),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _dateController,
readOnly: true,
decoration: const InputDecoration(labelText: 'Date'))),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _timeController,
readOnly: true,
decoration: const InputDecoration(labelText: 'Time'))),
],
),
const SizedBox(height: 16),
// Sampling Type is fixed for Investigative
// --- Sample ID ---
TextFormField(
controller: _barcodeController,
decoration: InputDecoration(
labelText: 'Sample ID Code *',
suffixIcon: IconButton(
icon: const Icon(Icons.qr_code_scanner),
onPressed: _scanBarcode,
),
),
validator: (val) =>
val == null || val.isEmpty ? "Sample ID is required" : null,
onSaved: (val) => widget.data.sampleIdCode = val,
onChanged: (val) => widget.data.sampleIdCode = val, // Update model immediately
),
const SizedBox(height: 24),
// --- NEW: Station Type Selection ---
DropdownButtonFormField<String>(
value: widget.data.stationTypeSelection,
items: _stationTypes
.map((type) => DropdownMenuItem(value: type, child: Text(type)))
.toList(),
onChanged: (value) {
setState(() {
widget.data.stationTypeSelection = value;
_clearStationSelections();
_clearNewLocationFields();
// If selecting New Location, prepopulate station coords with current if available
if (value == 'New Location' && widget.data.currentLatitude != null) {
widget.data.stationLatitude = widget.data.currentLatitude;
widget.data.stationLongitude = widget.data.currentLongitude;
_stationLatController.text = widget.data.stationLatitude!;
_stationLonController.text = widget.data.stationLongitude!;
}
_calculateDistance(); // Recalculate distance
});
},
decoration: const InputDecoration(labelText: 'Station Type *'),
validator: (value) => value == null ? 'Please select station type' : null,
),
const SizedBox(height: 16),
// --- Conditional Station/Location Inputs ---
// == Existing Manual Station ==
if (widget.data.stationTypeSelection == 'Existing Manual Station') ...[
DropdownSearch<String>(
items: _statesList,
selectedItem: widget.data.selectedStateName,
popupProps: const PopupProps.menu(
showSearchBox: true,
searchFieldProps: TextFieldProps(
decoration: InputDecoration(hintText: "Search State..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(
dropdownSearchDecoration:
InputDecoration(labelText: "Select State *")),
onChanged: (state) {
setState(() {
widget.data.selectedStateName = state;
_clearStationSelections(); // Clear selections when state changes
_loadStationsForSelectedState();
_calculateDistance();
});
},
validator: (val) => val == null ? "State is required" : null,
),
const SizedBox(height: 16),
DropdownSearch<Map<String, dynamic>>(
items: _manualStationsForState,
selectedItem: widget.data.selectedStation,
enabled: widget.data.selectedStateName != null,
itemAsString: (station) =>
"${station['sampling_station_code']} | ${station['sampling_river']} | ${station['sampling_basin']}",
popupProps: const PopupProps.menu(
showSearchBox: true,
searchFieldProps: TextFieldProps(
decoration:
InputDecoration(hintText: "Search Station..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(
dropdownSearchDecoration:
InputDecoration(labelText: "Select Manual Station *")),
onChanged: (station) {
if (station != null) {
_updateFormWithSelectedManualStation(station);
}
},
validator: (val) => widget.data.selectedStateName != null && val == null
? "Manual Station is required"
: null,
),
const SizedBox(height: 16),
ElevatedButton.icon(
icon: const Icon(Icons.explore_outlined),
label: const Text("NEARBY MANUAL STATION"),
onPressed: _isLoadingLocation ? null : _findAndShowNearbyStations,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
],
// == Existing Triennial Station ==
if (widget.data.stationTypeSelection == 'Existing Triennial Station') ...[
DropdownSearch<String>( // State selection might be needed if not pre-selected
items: _statesList,
selectedItem: widget.data.selectedStateName,
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *")),
onChanged: (state) {
setState(() {
widget.data.selectedStateName = state;
_clearStationSelections();
_loadStationsForSelectedState(); // Reloads both manual and triennial lists
_calculateDistance();
});
},
validator: (val) => val == null ? "State is required" : null,
),
const SizedBox(height: 16),
DropdownSearch<Map<String, dynamic>>(
items: _triennialStationsForState,
selectedItem: widget.data.selectedTriennialStation,
enabled: widget.data.selectedStateName != null,
itemAsString: (station) =>
"${station['triennial_station_code']} | ${station['triennial_river']} | ${station['triennial_basin']}", // Use triennial keys
popupProps: const PopupProps.menu(
showSearchBox: true,
searchFieldProps: TextFieldProps(
decoration:
InputDecoration(hintText: "Search Station..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(
dropdownSearchDecoration:
InputDecoration(labelText: "Select Triennial Station *")),
onChanged: (station) {
if (station != null) {
_updateFormWithSelectedTriennialStation(station);
}
},
validator: (val) => widget.data.selectedStateName != null && val == null
? "Triennial Station is required"
: null,
),
],
// == New Location ==
if (widget.data.stationTypeSelection == 'New Location') ...[
DropdownSearch<String>( // Use Dropdown for State consistency
items: _statesList,
selectedItem: widget.data.newStateName,
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *")),
onChanged: (state) {
setState(() {
widget.data.newStateName = state;
widget.data.selectedStateName = state; // Keep consistent if needed elsewhere
});
},
validator: (val) => val == null ? "State is required" : null,
onSaved: (val) => widget.data.newStateName = val,
),
const SizedBox(height: 16),
TextFormField(
controller: _newBasinController,
decoration: const InputDecoration(labelText: 'Basin Name *'),
validator: (val) =>
val == null || val.isEmpty ? "Basin name is required" : null,
onSaved: (val) => widget.data.newBasinName = val,
onChanged: (val) => widget.data.newBasinName = val,
),
const SizedBox(height: 16),
TextFormField(
controller: _newRiverController,
decoration: const InputDecoration(labelText: 'River Name *'),
validator: (val) =>
val == null || val.isEmpty ? "River name is required" : null,
onSaved: (val) => widget.data.newRiverName = val,
onChanged: (val) => widget.data.newRiverName = val,
),
const SizedBox(height: 16),
TextFormField( // Optional Station Code for New Location
controller: _newStationCodeController,
decoration: const InputDecoration(labelText: 'Station Code (Optional)'),
onSaved: (val) => widget.data.newStationCode = val,
onChanged: (val) => widget.data.newStationCode = val,
),
],
const SizedBox(height: 16),
// --- Station Coordinates (Read-only for existing, editable/GPS-fed for new) ---
TextFormField(
controller: _stationLatController,
readOnly: !isNewLocation, // Editable only for New Location
decoration: InputDecoration(
labelText: 'Station Latitude ${isNewLocation ? "*" : ""}',
hintText: isNewLocation ? 'Use GPS or enter manually' : null
),
keyboardType: TextInputType.numberWithOptions(decimal: true),
validator: (val) => isNewLocation && (val == null || val.isEmpty) ? "Latitude is required for new location" : null,
onChanged: (val) { // Allow manual edit for New Location
if (isNewLocation) {
widget.data.stationLatitude = val;
_calculateDistance(); // Recalculate if manually changed
}
},
onSaved: (val) => widget.data.stationLatitude = val,
),
const SizedBox(height: 16),
TextFormField(
controller: _stationLonController,
readOnly: !isNewLocation, // Editable only for New Location
decoration: InputDecoration(
labelText: 'Station Longitude ${isNewLocation ? "*" : ""}',
hintText: isNewLocation ? 'Use GPS or enter manually' : null
),
keyboardType: TextInputType.numberWithOptions(decimal: true),
validator: (val) => isNewLocation && (val == null || val.isEmpty) ? "Longitude is required for new location" : null,
onChanged: (val) { // Allow manual edit for New Location
if (isNewLocation) {
widget.data.stationLongitude = val;
_calculateDistance(); // Recalculate if manually changed
}
},
onSaved: (val) => widget.data.stationLongitude = val,
),
const SizedBox(height: 24),
// --- Location Verification ---
Text("Location Verification",
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
TextFormField(
controller: _currentLatController,
readOnly: true,
decoration: const InputDecoration(labelText: 'Current Latitude')),
const SizedBox(height: 16),
TextFormField(
controller: _currentLonController,
readOnly: true,
decoration: const InputDecoration(labelText: 'Current Longitude')),
if (widget.data.distanceDifferenceInKm != null && widget.data.stationTypeSelection != 'New Location') // Only show distance if NOT new location
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50
? Colors.red.withOpacity(0.1)
: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50
? Colors.red
: Colors.green),
),
child: RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: Theme.of(context).textTheme.bodyLarge,
children: <TextSpan>[
const TextSpan(text: 'Distance from Station: '),
TextSpan(
text:
'${(widget.data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters',
style: TextStyle(
fontWeight: FontWeight.bold,
color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50
? Colors.red
: Colors.green),
),
],
),
),
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _isLoadingLocation ? null : _getCurrentLocation,
icon: _isLoadingLocation
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.location_searching),
label: Text(isNewLocation ? "Get Current Location (for Station & Verification)" : "Get Current Location"),
),
const SizedBox(height: 32),
// --- Navigation ---
ElevatedButton(
onPressed: _goToNextStep,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Next'),
),
],
),
);
}
}
// Re-use the same dialog as River Manual In-Situ for nearby stations
class _NearbyStationsDialog extends StatelessWidget {
final List<Map<String, dynamic>> nearbyStations;
const _NearbyStationsDialog({required this.nearbyStations});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Nearby Manual Stations (within 3km)'),
content: SizedBox(
width: double.maxFinite,
child: nearbyStations.isEmpty
? const Center(child: Text('No stations found.'))
: ListView.builder(
shrinkWrap: true,
itemCount: nearbyStations.length,
itemBuilder: (context, index) {
final item = nearbyStations[index];
final station = item['station'] as Map<String, dynamic>;
final distanceInMeters = (item['distance'] as double) * 1000;
return Card(
child: ListTile(
title: Text("${station['sampling_station_code'] ?? 'N/A'}"),
subtitle: Text("${station['sampling_river'] ?? 'N/A'}"),
trailing: Text("${distanceInMeters.toStringAsFixed(0)} m"),
onTap: () {
Navigator.of(context).pop(station); // Return the selected station map
},
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
],
);
}
}

View File

@ -0,0 +1,231 @@
// lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_2_site_info.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '../../../../models/river_inves_manual_sampling_data.dart'; // Updated model
import '../../../../services/river_investigative_sampling_service.dart'; // Updated service
class RiverInvesStep2SiteInfo extends StatefulWidget {
final RiverInvesManualSamplingData data;
final VoidCallback onNext;
const RiverInvesStep2SiteInfo({
super.key,
required this.data,
required this.onNext,
});
@override
State<RiverInvesStep2SiteInfo> createState() =>
_RiverInvesStep2SiteInfoState();
}
class _RiverInvesStep2SiteInfoState extends State<RiverInvesStep2SiteInfo> {
final _formKey = GlobalKey<FormState>();
bool _isPickingImage = false;
late final TextEditingController _eventRemarksController;
late final TextEditingController _labRemarksController;
final List<String> _weatherOptions = [
'Cloudy',
'Drizzle',
'Rainy',
'Sunny',
'Windy'
];
@override
void initState() {
super.initState();
_eventRemarksController = TextEditingController(text: widget.data.eventRemarks);
_labRemarksController = TextEditingController(text: widget.data.labRemarks);
}
@override
void dispose() {
_eventRemarksController.dispose();
_labRemarksController.dispose();
super.dispose();
}
void _setImage(Function(File?) setImageCallback, ImageSource source,
String imageInfo, {required bool isRequired}) async {
if (_isPickingImage) return;
setState(() => _isPickingImage = true);
final service = Provider.of<RiverInvestigativeSamplingService>(context, listen: false);
// --- MODIFICATION: Get station code based on selection ---
final String? stationCode = widget.data.getDeterminedStationCode();
// --- END MODIFICATION ---
final file = await service.pickAndProcessImage( // Call the service's method
source,
data: widget.data, // Pass the investigative data model
imageInfo: imageInfo,
isRequired: isRequired,
stationCode: stationCode, // Pass the determined station code
);
if (file != null) {
setState(() => setImageCallback(file));
} else if (mounted) {
_showSnackBar(
'Image selection failed. Please ensure all photos are taken in landscape mode.',
isError: true);
}
if (mounted) {
setState(() => _isPickingImage = false);
}
}
void _goToNextStep() {
if (!_formKey.currentState!.validate()) {
return;
}
_formKey.currentState!.save();
if (widget.data.backgroundStationImage == null ||
widget.data.upstreamRiverImage == null ||
widget.data.downstreamRiverImage == null) {
_showSnackBar('Please attach all 3 required photos before proceeding.',
isError: true);
return;
}
widget.onNext();
}
void _showSnackBar(String message, {bool isError = false}) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red : null,
));
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24.0),
children: [
Text("On-Site Information",
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: widget.data.weather,
items: _weatherOptions
.map((item) => DropdownMenuItem(value: item, child: Text(item)))
.toList(),
onChanged: (value) => setState(() => widget.data.weather = value),
decoration: const InputDecoration(labelText: 'Weather *'),
validator: (value) => value == null ? 'Weather is required' : null,
onSaved: (value) => widget.data.weather = value,
),
const SizedBox(height: 16),
TextFormField(
controller: _eventRemarksController,
decoration: const InputDecoration(
labelText: 'Event Remarks (Optional)',
hintText: 'e.g., unusual smells, colors, etc.'),
onSaved: (value) => widget.data.eventRemarks = value,
maxLines: 3,
),
const SizedBox(height: 16),
TextFormField(
controller: _labRemarksController,
decoration: const InputDecoration(labelText: 'Lab Remarks (Optional)'),
onSaved: (value) => widget.data.labRemarks = value,
maxLines: 3,
),
const Divider(height: 32),
Text("Required Photos *",
style: Theme.of(context).textTheme.titleLarge),
const Text("All photos must be taken in landscape (horizontal) orientation.",
style: TextStyle(color: Colors.grey)),
const SizedBox(height: 8),
_buildImagePicker(
'Background Station',
'BACKGROUND_STATION',
widget.data.backgroundStationImage,
(file) => widget.data.backgroundStationImage = file,
isRequired: true),
_buildImagePicker('Upstream River', 'UPSTREAM_RIVER',
widget.data.upstreamRiverImage, (file) => widget.data.upstreamRiverImage = file,
isRequired: true),
_buildImagePicker(
'Downstream River',
'DOWNSTREAM_RIVER',
widget.data.downstreamRiverImage,
(file) => widget.data.downstreamRiverImage = file,
isRequired: true),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _goToNextStep,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Next'),
),
],
),
);
}
// _buildImagePicker remains the same as in RiverInSituStep2SiteInfo
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
if (imageFile != null)
Stack(
alignment: Alignment.topRight,
children: [
ClipRRect(borderRadius: BorderRadius.circular(8.0), child: Image.file(imageFile, key: UniqueKey(), height: 150, width: double.infinity, fit: BoxFit.cover)),
Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
child: IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close, color: Colors.white, size: 20),
onPressed: () => setState(() => setImageCallback(null)),
),
),
],
)
else
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")),
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")),
],
),
if (remarkController != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TextFormField(
controller: remarkController,
decoration: InputDecoration(
labelText: 'Remarks for $title',
hintText: 'Add an optional remark...',
border: const OutlineInputBorder(),
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,216 @@
// lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_4_additional_info.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '../../../../models/river_inves_manual_sampling_data.dart'; // Updated model
import '../../../../services/river_investigative_sampling_service.dart'; // Updated service
class RiverInvesStep4AdditionalInfo extends StatefulWidget {
final RiverInvesManualSamplingData data;
final VoidCallback onNext;
const RiverInvesStep4AdditionalInfo({
super.key,
required this.data,
required this.onNext,
});
@override
State<RiverInvesStep4AdditionalInfo> createState() =>
_RiverInvesStep4AdditionalInfoState();
}
class _RiverInvesStep4AdditionalInfoState
extends State<RiverInvesStep4AdditionalInfo> {
final _formKey = GlobalKey<FormState>();
bool _isPickingImage = false;
late final TextEditingController _optionalRemark1Controller;
late final TextEditingController _optionalRemark2Controller;
late final TextEditingController _optionalRemark3Controller;
late final TextEditingController _optionalRemark4Controller;
@override
void initState() {
super.initState();
_optionalRemark1Controller = TextEditingController(text: widget.data.optionalRemark1);
_optionalRemark2Controller = TextEditingController(text: widget.data.optionalRemark2);
_optionalRemark3Controller = TextEditingController(text: widget.data.optionalRemark3);
_optionalRemark4Controller = TextEditingController(text: widget.data.optionalRemark4);
}
@override
void dispose() {
_optionalRemark1Controller.dispose();
_optionalRemark2Controller.dispose();
_optionalRemark3Controller.dispose();
_optionalRemark4Controller.dispose();
super.dispose();
}
void _setImage(Function(File?) setImageCallback, ImageSource source,
String imageInfo, {required bool isRequired}) async {
if (_isPickingImage) return;
setState(() => _isPickingImage = true);
final service = Provider.of<RiverInvestigativeSamplingService>(context, listen: false);
// --- MODIFICATION: Get station code based on selection ---
final String? stationCode = widget.data.getDeterminedStationCode();
// --- END MODIFICATION ---
final file = await service.pickAndProcessImage( // Call the service's method
source,
data: widget.data, // Pass investigative data
imageInfo: imageInfo,
isRequired: isRequired,
stationCode: stationCode, // Pass determined code
);
if (file != null) {
setState(() => setImageCallback(file));
} else if (mounted) {
_showSnackBar(
'Image selection failed. Please ensure all photos are taken in landscape mode.',
isError: true);
}
if (mounted) {
setState(() => _isPickingImage = false);
}
}
void _goToNextStep() {
// Save remarks explicitly before validating/proceeding
widget.data.optionalRemark1 = _optionalRemark1Controller.text.trim();
widget.data.optionalRemark2 = _optionalRemark2Controller.text.trim();
widget.data.optionalRemark3 = _optionalRemark3Controller.text.trim();
widget.data.optionalRemark4 = _optionalRemark4Controller.text.trim();
if (_formKey.currentState!.validate()) { // Validation (if any) is done here
_formKey.currentState!.save(); // Save form fields (if any)
if (widget.data.sampleTurbidityImage == null) {
_showSnackBar(
'Please attach the Sample Turbidity photo before proceeding.',
isError: true);
return;
}
widget.onNext();
}
}
void _showSnackBar(String message, {bool isError = false}) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red : null,
));
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey, // Needed if you add any validating FormFields later
child: ListView(
padding: const EdgeInsets.all(24.0),
children: [
Text("Additional Photos",
style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 24),
Text("Required Photo *",
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
_buildImagePicker(
'Sample Turbidity',
'SAMPLE_TURBIDITY',
widget.data.sampleTurbidityImage,
(file) => widget.data.sampleTurbidityImage = file,
isRequired: true),
const Divider(height: 32),
Text("Optional Photos & Remarks",
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
_buildImagePicker('Optional Photo 1', 'OPTIONAL_1',
widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file,
remarkController: _optionalRemark1Controller, isRequired: false),
_buildImagePicker('Optional Photo 2', 'OPTIONAL_2',
widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file,
remarkController: _optionalRemark2Controller, isRequired: false),
_buildImagePicker('Optional Photo 3', 'OPTIONAL_3',
widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file,
remarkController: _optionalRemark3Controller, isRequired: false),
_buildImagePicker('Optional Photo 4', 'OPTIONAL_4',
widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file,
remarkController: _optionalRemark4Controller, isRequired: false),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _goToNextStep,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Next'),
),
],
),
);
}
// _buildImagePicker remains the same as in RiverInSituStep4AdditionalInfo
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) {
// Copied from RiverInSituStep4AdditionalInfoState._buildImagePicker
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
if (imageFile != null)
Stack(
alignment: Alignment.topRight,
children: [
ClipRRect(borderRadius: BorderRadius.circular(8.0), child: Image.file(imageFile, key: UniqueKey(), height: 150, width: double.infinity, fit: BoxFit.cover)),
Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
child: IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close, color: Colors.white, size: 20),
onPressed: () => setState(() => setImageCallback(null)), // Clear the image file in the data model
),
),
],
)
else
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Use the _setImage method defined in this state class
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")),
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")),
],
),
// Remarks field, linked via the passed controller
if (remarkController != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TextFormField(
controller: remarkController,
decoration: InputDecoration(
labelText: 'Remarks for $title',
hintText: 'Add an optional remark...',
border: const OutlineInputBorder(),
),
// No validator needed for optional remarks
// onSaved handled externally by _goToNextStep reading controllers
),
),
],
),
);
}
}

View File

@ -0,0 +1,371 @@
// lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_5_summary.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../../auth_provider.dart';
import '../../../../models/river_inves_manual_sampling_data.dart'; // Use Investigative model
class RiverInvesStep5Summary extends StatelessWidget { // Renamed class
final RiverInvesManualSamplingData data; // Use Investigative data model
final VoidCallback onSubmit;
final bool isLoading;
const RiverInvesStep5Summary({ // Renamed constructor
super.key,
required this.data,
required this.onSubmit,
required this.isLoading,
});
// Parameter validation logic remains the same as it uses river limits
// Maps the app's internal parameter keys to the names used in the database.
static const Map<String, String> _parameterKeyToLimitName = {
'oxygenConcentration': 'Oxygen Conc',
'oxygenSaturation': 'Oxygen Sat',
'ph': 'pH',
'salinity': 'Salinity',
'electricalConductivity': 'Conductivity',
'temperature': 'Temperature',
'tds': 'TDS',
'turbidity': 'Turbidity',
'ammonia': 'Ammonia',
'batteryVoltage': 'Battery',
}; //
/// Re-validates the final parameters against the defined limits.
Set<String> _getOutOfBoundsKeys(BuildContext context) {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
// Use the same river parameter limits as the manual module
final riverLimits = authProvider.riverParameterLimits ?? [];
final Set<String> invalidKeys = {};
// Access fields from the RiverInvesManualSamplingData model
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,
'ammonia': data.ammonia, '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 = riverLimits.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: Parameter Validation Logic ---
@override
Widget build(BuildContext context) {
// Get the set of out-of-bounds keys before building the list.
final outOfBoundsKeys = _getOutOfBoundsKeys(context); //
return ListView(
padding: const EdgeInsets.all(16.0),
children: [
Text(
"Please review all information before submitting.",
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
_buildSectionCard(
context,
"Sampling & Station Details",
[
_buildDetailRow("1st Sampler:", data.firstSamplerName), //
_buildDetailRow("2nd Sampler:", data.secondSampler?['first_name']?.toString()), //
_buildDetailRow("Sampling Date:", data.samplingDate), //
_buildDetailRow("Sampling Time:", data.samplingTime), //
_buildDetailRow("Sampling Type:", data.samplingType), // Should display "Investigative"
_buildDetailRow("Sample ID Code:", data.sampleIdCode), //
const Divider(height: 20),
// --- MODIFICATION: Display station/location based on type ---
_buildDetailRow("Station Type:", data.stationTypeSelection), //
if (data.stationTypeSelection == 'Existing Manual Station') ...[
_buildDetailRow("State:", data.selectedStateName), //
_buildDetailRow(
"Manual Station:",
"${data.selectedStation?['sampling_station_code']} | ${data.selectedStation?['sampling_river']} | ${data.selectedStation?['sampling_basin']}"
), //
] else if (data.stationTypeSelection == 'Existing Triennial Station') ...[
_buildDetailRow("State:", data.selectedStateName), //
_buildDetailRow(
"Triennial Station:",
"${data.selectedTriennialStation?['triennial_station_code']} | ${data.selectedTriennialStation?['triennial_river']} | ${data.selectedTriennialStation?['triennial_basin']}"
), // (Using assumed keys from model)
] else if (data.stationTypeSelection == 'New Location') ...[
_buildDetailRow("New Location State:", data.newStateName), //
_buildDetailRow("New Location Basin:", data.newBasinName), //
_buildDetailRow("New Location River:", data.newRiverName), //
_buildDetailRow("New Location Code:", data.newStationCode), // Optional
],
_buildDetailRow("Determined Station Location:", "${data.stationLatitude}, ${data.stationLongitude}"), // Lat/Lon determined in Step 1
// --- END MODIFICATION ---
],
),
_buildSectionCard(
context,
"Site Info & Required Photos",
[
_buildDetailRow("Current Location:", "${data.currentLatitude}, ${data.currentLongitude}"), //
// Only show distance if it's not a new location
if (data.stationTypeSelection != 'New Location')
_buildDetailRow("Distance Difference:", data.distanceDifferenceInKm != null ? "${(data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters" : "N/A"), //
// Only show distance remarks if not a new location
if (data.stationTypeSelection != 'New Location' && data.distanceDifferenceRemarks != null && data.distanceDifferenceRemarks!.isNotEmpty)
_buildDetailRow("Distance Remarks:", data.distanceDifferenceRemarks), //
const Divider(height: 20),
_buildDetailRow("Weather:", data.weather), //
_buildDetailRow("Event Remarks:", data.eventRemarks), //
_buildDetailRow("Lab Remarks:", data.labRemarks), //
const Divider(height: 20),
_buildImageCard("Background Station", data.backgroundStationImage), //
_buildImageCard("Upstream River", data.upstreamRiverImage), //
_buildImageCard("Downstream River", data.downstreamRiverImage), //
],
),
_buildSectionCard(
context,
"Additional Photos & Remarks",
[
_buildImageCard("Sample Turbidity", data.sampleTurbidityImage), //
const Divider(height: 24),
Text("Optional Photos", style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
_buildImageCard("Optional Photo 1", data.optionalImage1, remark: data.optionalRemark1), //
_buildImageCard("Optional Photo 2", data.optionalImage2, remark: data.optionalRemark2), //
_buildImageCard("Optional Photo 3", data.optionalImage3, remark: data.optionalRemark3), //
_buildImageCard("Optional Photo 4", data.optionalImage4, remark: data.optionalRemark4), //
],
),
_buildSectionCard(
context,
"Captured Parameters",
[
_buildDetailRow("Sonde ID:", data.sondeId), //
_buildDetailRow("Capture Time:", "${data.dataCaptureDate} ${data.dataCaptureTime}"), //
const Divider(height: 20),
// Parameter list remains the same, uses the same helper and outOfBoundsKeys
_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.science, label: "Ammonia", unit: "mg/L", value: data.ammonia, isOutOfBounds: outOfBoundsKeys.contains('ammonia')),
_buildParameterListItem(context, icon: Icons.battery_charging_full, label: "Battery", unit: "V", value: data.batteryVoltage, isOutOfBounds: outOfBoundsKeys.contains('batteryVoltage')),
const Divider(height: 20),
// Flowrate summary remains the same
_buildFlowrateSummary(context),
],
),
const SizedBox(height: 24),
isLoading
? const Center(child: CircularProgressIndicator())
: ElevatedButton.icon(
onPressed: onSubmit,
icon: const Icon(Icons.cloud_upload),
label: const Text('Confirm & Submit'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 16),
],
);
}
// Helper widgets (_buildSectionCard, _buildDetailRow, _buildParameterListItem,
// _buildImageCard, _buildFlowrateSummary) are identical to the ones in
// river_in_situ_step_5_summary.dart and are reused here.
Widget _buildSectionCard(BuildContext context, String title, List<Widget> children) {
// Copied from RiverInSituStep5Summary
return Card(
margin: const EdgeInsets.symmetric(vertical: 8.0),
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
const Divider(height: 20, thickness: 1),
...children,
],
),
),
);
}
Widget _buildDetailRow(String label, String? value) {
// Copied from RiverInSituStep5Summary
// Handles cleaning up potential 'null' strings from map access
String displayValue = value
?.replaceAll('null - null', '')
?.replaceAll('null |', '')
?.replaceAll('| null', '')
?.trim() ?? 'N/A';
if (displayValue.isEmpty || displayValue == "-") {
displayValue = 'N/A';
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
),
const SizedBox(width: 8),
Expanded(
flex: 3,
child: Text(displayValue, style: const TextStyle(fontSize: 16)),
),
],
),
);
}
Widget _buildParameterListItem(BuildContext context, {required IconData icon, required String label, required String unit, required double? value, bool isOutOfBounds = false}) {
// Copied from RiverInSituStep5Summary
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();
// 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 // Out of bounds = Red
: (isMissing ? Colors.grey : defaultTextColor ?? Colors.black); // Missing = Grey, else Default
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(icon, color: Theme.of(context).primaryColor, size: 28),
title: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
trailing: Text(
displayValue,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: valueColor,
fontWeight: isOutOfBounds ? FontWeight.bold : null, // Bold if out of bounds
),
),
);
}
Widget _buildImageCard(String title, File? image, {String? remark}) {
// Copied from RiverInSituStep5Summary
final bool hasRemark = remark != null && remark.isNotEmpty;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 8),
if (image != null)
ClipRRect(
borderRadius: BorderRadius.circular(8.0),
// Use UniqueKey to force rebuild if image file path is the same but content changed (less likely here)
child: Image.file(image, key: UniqueKey(), height: 200, width: double.infinity, fit: BoxFit.cover),
)
else
Container(
height: 100,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8.0),
border: Border.all(color: Colors.grey[300]!)),
child: const Center(child: Text('No Image Attached', style: TextStyle(color: Colors.grey))),
),
if (hasRemark)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text('Remark: $remark', style: const TextStyle(fontStyle: FontStyle.italic)),
),
],
),
);
}
Widget _buildFlowrateSummary(BuildContext context) {
// Copied from RiverInSituStep5Summary
final method = data.flowrateMethod ?? 'N/A'; //
List<Widget> children = [
_buildDetailRow("Flowrate Method:", method), //
];
if (method == 'Surface Drifter') {
children.add(
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 4.0),
child: Column(
children: [
_buildDetailRow("Height:", data.flowrateSurfaceDrifterHeight != null ? "${data.flowrateSurfaceDrifterHeight} m" : "N/A"), //
_buildDetailRow("Distance:", data.flowrateSurfaceDrifterDistance != null ? "${data.flowrateSurfaceDrifterDistance} m" : "N/A"), //
_buildDetailRow("Time First:", data.flowrateSurfaceDrifterTimeFirst ?? "N/A"), //
_buildDetailRow("Time Last:", data.flowrateSurfaceDrifterTimeLast ?? "N/A"), //
],
),
)
);
}
// Always show the final flowrate value row
children.add(
_buildDetailRow("Flowrate Value:", data.flowrateValue != null ? '${data.flowrateValue!.toStringAsFixed(4)} m/s' : 'NA') //
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
);
}
}

View File

@ -0,0 +1,189 @@
// lib/screens/river/investigative/river_investigative_manual_sampling.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; // For formatting date/time
import '../../../../auth_provider.dart';
import '../../../../models/river_inves_manual_sampling_data.dart'; // Updated model
import '../../../../services/river_investigative_sampling_service.dart'; // Updated service
// Removed CustomStepper import
// Import Step Widgets
import 'manual_sampling/river_inves_in_situ_step_1_sampling_info.dart';
import 'manual_sampling/river_inves_in_situ_step_2_site_info.dart';
import 'manual_sampling/river_inves_in_situ_step_3_data_capture.dart';
import 'manual_sampling/river_inves_in_situ_step_4_additional_info.dart';
import 'manual_sampling/river_inves_in_situ_step_5_summary.dart';
class RiverInvestigativeManualSamplingScreen extends StatefulWidget {
const RiverInvestigativeManualSamplingScreen({super.key});
@override
State<RiverInvestigativeManualSamplingScreen> createState() =>
_RiverInvestigativeManualSamplingScreenState();
}
class _RiverInvestigativeManualSamplingScreenState
extends State<RiverInvestigativeManualSamplingScreen> {
int _currentStep = 0;
bool _isLoading = false;
late RiverInvesManualSamplingData _samplingData;
@override
void initState() {
super.initState();
_samplingData = RiverInvesManualSamplingData(
// Initialize with current date and time if needed, handled in Step 1 init
);
}
void _nextStep() {
if (_currentStep < 4) {
setState(() {
_currentStep++;
});
} else {
_submitForm();
}
}
void _previousStep() {
if (_currentStep > 0) {
setState(() {
_currentStep--;
});
} else {
Navigator.of(context).pop();
}
}
Future<void> _submitForm() async {
setState(() {
_isLoading = true;
});
final service = Provider.of<RiverInvestigativeSamplingService>(context, listen: false);
final auth = Provider.of<AuthProvider>(context, listen: false);
try {
final result = await service.submitData(
data: _samplingData,
appSettings: auth.appSettings,
authProvider: auth,
// logDirectory: null, // Let service handle initial log creation
);
_samplingData.submissionStatus = result['status'];
_samplingData.submissionMessage = result['message'];
_samplingData.reportId = result['reportId'];
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Submission processed.'),
backgroundColor: result['success'] ? Colors.green : Colors.orange,
),
);
if (result['success']) {
Future.delayed(const Duration(seconds: 2), () {
if (mounted) {
Navigator.of(context).pop();
}
});
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('An unexpected error occurred: $e'),
backgroundColor: Colors.red,
),
);
}
_samplingData.submissionStatus = 'Error';
_samplingData.submissionMessage = 'An unexpected error occurred: $e';
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
// --- MODIFICATION: Removed _getStepTitle method ---
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
if (_isLoading) return false;
if (_currentStep > 0) {
final shouldPop = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discard Sampling Data?'),
content: const Text('Are you sure you want to go back? All unsaved data for this sampling event will be lost.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Discard'),
),
],
),
);
return shouldPop ?? false;
}
return true;
},
child: Scaffold(
appBar: AppBar(
// --- MODIFICATION: Title is now dynamic to match river manual ---
title: Text('In-Situ Sampling (${_currentStep + 1}/5)'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _isLoading ? null : _previousStep,
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(4.0),
child: _isLoading ? const LinearProgressIndicator() : const SizedBox.shrink(),
),
),
// --- MODIFICATION: Body is now the IndexedStack directly ---
body: IndexedStack(
index: _currentStep,
children: <Widget>[
RiverInvesStep1SamplingInfo(
data: _samplingData,
onNext: _nextStep,
),
RiverInvesStep2SiteInfo(
data: _samplingData,
onNext: _nextStep,
),
RiverInvesStep3DataCapture(
data: _samplingData,
onNext: _nextStep,
),
RiverInvesStep4AdditionalInfo(
data: _samplingData,
onNext: _nextStep,
),
RiverInvesStep5Summary(
data: _samplingData,
onSubmit: _submitForm,
isLoading: _isLoading,
),
],
),
// --- END MODIFICATION ---
),
);
}
}

View File

@ -59,9 +59,11 @@ class RiverHomePage extends StatelessWidget {
children: [ children: [
// MODIFIED: Updated to point to the new Info Centre screen // MODIFIED: Updated to point to the new Info Centre screen
SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/river/investigative/info'), SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/river/investigative/info'),
// SidebarItem(icon: Icons.info, label: "Overview", route: '/river/investigative/overview'), // *** ADDED: Link to River Investigative Manual Sampling ***
//SidebarItem(icon: Icons.input, label: "Entry", route: '/river/investigative/entry'), SidebarItem(icon: Icons.biotech, label: "Investigative Sampling", route: '/river/investigative/manual-sampling'), // Added Icon
//SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/investigative/report'), // SidebarItem(icon: Icons.info, label: "Overview", route: '/river/investigative/overview'), // Keep placeholder/future items commented
//SidebarItem(icon: Icons.input, label: "Entry", route: '/river/investigative/entry'), // Keep placeholder/future items commented
//SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/investigative/report'), // Keep placeholder/future items commented
], ],
), ),
]; ];
@ -136,30 +138,31 @@ class RiverHomePage extends StatelessWidget {
Navigator.pushNamed(context, subItem.route!); Navigator.pushNamed(context, subItem.route!);
} }
}, },
borderRadius: BorderRadius.circular(0), borderRadius: BorderRadius.circular(0), // No rounded corners for grid items
child: Container( child: Container(
margin: const EdgeInsets.all(4.0), // Added margin for better spacing margin: const EdgeInsets.all(4.0), // Added margin for better spacing
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: Colors.white24, width: 0.5), // Optional: subtle border border: Border.all(color: Colors.white24, width: 0.5), // Optional: subtle border
// No background color unless desired
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, // Align content to start
children: [ children: [
subItem.icon != null subItem.icon != null
? Icon(subItem.icon, color: Colors.white70, size: 24) ? Icon(subItem.icon, color: Colors.white70, size: 24) // Adjusted icon size
: const SizedBox.shrink(), : const SizedBox.shrink(), // Or provide a placeholder
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded( // Allow text to take remaining space
child: Text( child: Text(
subItem.label, subItem.label,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.white70, color: Colors.white70, // Slightly lighter text
fontSize: 12, // Slightly increased font size fontSize: 12, // Slightly increased font size
), ),
textAlign: TextAlign.left, textAlign: TextAlign.left, // Left align text
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis, // Prevent overflow
maxLines: 2, // Allow for two lines if needed maxLines: 2, // Allow for two lines if needed
), ),
), ),
@ -170,7 +173,7 @@ class RiverHomePage extends StatelessWidget {
); );
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16), // Spacing between categories
], ],
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,11 @@ import '../models/in_situ_sampling_data.dart';
import '../models/marine_manual_npe_report_data.dart'; import '../models/marine_manual_npe_report_data.dart';
import '../models/river_in_situ_sampling_data.dart'; import '../models/river_in_situ_sampling_data.dart';
import '../models/river_manual_triennial_sampling_data.dart'; import '../models/river_manual_triennial_sampling_data.dart';
// --- ADDED IMPORT ---
import '../models/marine_inves_manual_sampling_data.dart';
// --- ADDED IMPORT FOR RIVER INVESTIGATIVE ---
import '../models/river_inves_manual_sampling_data.dart';
// --- END ADDED IMPORT ---
class LocalStorageService { class LocalStorageService {
@ -803,6 +808,263 @@ class LocalStorageService {
} }
} }
// =======================================================================
// --- ADDED: Part 6.5: Marine Investigative Specific Methods ---
// =======================================================================
Future<Directory?> _getInvestigativeBaseDir({required String serverName}) async {
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
if (mmsv4Dir == null) return null;
// Use a new subModule path for investigative logs
final inSituDir = Directory(p.join(mmsv4Dir.path, 'marine', 'marine_investigative_sampling'));
if (!await inSituDir.exists()) {
await inSituDir.create(recursive: true);
}
return inSituDir;
}
/// Saves Marine Investigative sampling data to the local log
Future<String?> saveInvestigativeSamplingData(MarineInvesManualSamplingData data, {required String serverName}) async {
final baseDir = await _getInvestigativeBaseDir(serverName: serverName);
if (baseDir == null) {
debugPrint("Could not get public storage directory for Investigative. Check permissions.");
return null;
}
try {
// --- Generate folder name based on station type ---
String stationCode = 'NA';
if (data.stationTypeSelection == 'Existing Manual Station') {
stationCode = data.selectedStation?['man_station_code'] ?? 'MANUAL_NA';
} else if (data.stationTypeSelection == 'Existing Tarball Station') {
stationCode = data.selectedTarballStation?['tbl_station_code'] ?? 'TARBALL_NA';
} else if (data.stationTypeSelection == 'New Location') {
stationCode = data.newStationCode ?? 'NEW_NA';
}
final timestamp = "${data.samplingDate}_${data.samplingTime?.replaceAll(':', '-')}";
final eventFolderName = "${stationCode}_$timestamp";
final eventDir = Directory(p.join(baseDir.path, eventFolderName));
if (!await eventDir.exists()) {
await eventDir.create(recursive: true);
}
final Map<String, dynamic> jsonData = data.toDbJson(); //
jsonData['submissionStatus'] = data.submissionStatus;
jsonData['submissionMessage'] = data.submissionMessage;
jsonData['reportId'] = data.reportId;
jsonData['serverConfigName'] = serverName;
final imageFiles = data.toApiImageFiles(); //
for (var entry in imageFiles.entries) {
final File? imageFile = entry.value;
if (imageFile != null && imageFile.path.isNotEmpty) {
try {
// Check if file is already in the correct directory (e.g., from a retry)
if (p.dirname(imageFile.path) == eventDir.path) {
jsonData[entry.key] = imageFile.path;
} else {
// Copy file from temp cache to persistent log directory
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 Investigative image file ${imageFile.path}: $e");
jsonData[entry.key] = null;
}
}
}
final jsonFile = File(p.join(eventDir.path, 'data.json'));
await jsonFile.writeAsString(jsonEncode(jsonData));
debugPrint("Investigative log saved to: ${jsonFile.path}");
return eventDir.path; // Return the path to the saved log directory
} catch (e) {
debugPrint("Error saving Investigative log to local storage: $e");
return null;
}
}
/// Fetches all saved Marine Investigative logs
Future<List<Map<String, dynamic>>> getAllInvestigativeLogs() async {
final mmsv4Root = await _getPublicMMSV4Directory(serverName: '');
if (mmsv4Root == null || !await mmsv4Root.exists()) return [];
final List<Map<String, dynamic>> allLogs = [];
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
for (var serverDir in serverDirs) {
final baseDir = Directory(p.join(serverDir.path, 'marine', 'marine_investigative_sampling'));
if (!await baseDir.exists()) continue;
try {
final entities = baseDir.listSync();
for (var entity in entities) {
if (entity is Directory) {
final jsonFile = File(p.join(entity.path, 'data.json'));
if (await jsonFile.exists()) {
final content = await jsonFile.readAsString();
final data = jsonDecode(content) as Map<String, dynamic>;
data['logDirectory'] = entity.path;
allLogs.add(data);
}
}
}
} catch (e) {
debugPrint("Error reading investigative logs from ${baseDir.path}: $e");
}
}
return allLogs;
}
/// Updates an existing Marine Investigative log file (e.g., after a retry)
Future<void> updateInvestigativeLog(Map<String, dynamic> updatedLogData) async {
final logDir = updatedLogData['logDirectory'];
if (logDir == null) {
debugPrint("Cannot update investigative log: logDirectory key is missing.");
return;
}
try {
final jsonFile = File(p.join(logDir, 'data.json'));
if (await jsonFile.exists()) {
updatedLogData.remove('isResubmitting'); // Clean up temporary flags
await jsonFile.writeAsString(jsonEncode(updatedLogData));
debugPrint("Investigative log updated successfully at: ${jsonFile.path}");
}
} catch (e) {
debugPrint("Error updating investigative log: $e");
}
}
// =======================================================================
// --- ADDED: Part 6.6: River Investigative Specific Methods ---
// =======================================================================
/// Gets the base directory for River Investigative logs.
Future<Directory?> getRiverInvestigativeBaseDir({required String serverName}) async {
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
if (mmsv4Dir == null) return null;
final invesDir = Directory(p.join(mmsv4Dir.path, 'river', 'river_investigative_sampling'));
if (!await invesDir.exists()) {
await invesDir.create(recursive: true);
}
return invesDir;
}
/// Saves River Investigative sampling data to the local log.
Future<String?> saveRiverInvestigativeSamplingData(RiverInvesManualSamplingData data, {required String serverName}) async {
final baseDir = await getRiverInvestigativeBaseDir(serverName: serverName);
if (baseDir == null) {
debugPrint("Could not get public storage directory for River Investigative. Check permissions.");
return null;
}
try {
final stationCode = data.getDeterminedStationCode() ?? 'UNKNOWN';
final timestamp = "${data.samplingDate}_${data.samplingTime?.replaceAll(':', '-')}";
final eventFolderName = "${stationCode}_$timestamp";
final eventDir = Directory(p.join(baseDir.path, eventFolderName));
if (!await eventDir.exists()) {
await eventDir.create(recursive: true);
}
// Use the .toMap() method from the data model, which is designed for local logging
final Map<String, dynamic> jsonData = data.toMap();
jsonData['serverConfigName'] = serverName;
// Status, message, and reportId are already included by .toMap()
final imageFiles = data.toApiImageFiles();
for (var entry in imageFiles.entries) {
final File? imageFile = entry.value;
if (imageFile != null && imageFile.path.isNotEmpty) {
try {
if (p.dirname(imageFile.path) == eventDir.path) {
jsonData[entry.key] = imageFile.path; // Already in log dir
} else {
final String originalFileName = p.basename(imageFile.path);
final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName));
jsonData[entry.key] = newFile.path; // Store the new persistent path
}
} catch (e) {
debugPrint("Error processing River Investigative image file ${imageFile.path}: $e");
jsonData[entry.key] = null; // Store null if copy failed
}
} else {
// Ensure keys for null images are also present if needed, though .toMap() handles this
jsonData[entry.key] = null;
}
}
final jsonFile = File(p.join(eventDir.path, 'data.json'));
await jsonFile.writeAsString(jsonEncode(jsonData));
debugPrint("River Investigative log saved to: ${jsonFile.path}");
return eventDir.path;
} catch (e) {
debugPrint("Error saving River Investigative log to local storage: $e");
return null;
}
}
/// Fetches all saved River Investigative logs.
Future<List<Map<String, dynamic>>> getAllRiverInvestigativeLogs() async {
final mmsv4Root = await _getPublicMMSV4Directory(serverName: '');
if (mmsv4Root == null || !await mmsv4Root.exists()) return [];
final List<Map<String, dynamic>> allLogs = [];
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
for (var serverDir in serverDirs) {
final baseDir = Directory(p.join(serverDir.path, 'river', 'river_investigative_sampling'));
if (!await baseDir.exists()) continue;
try {
final entities = baseDir.listSync();
for (var entity in entities) {
if (entity is Directory) {
final jsonFile = File(p.join(entity.path, 'data.json'));
if (await jsonFile.exists()) {
final content = await jsonFile.readAsString();
final data = jsonDecode(content) as Map<String, dynamic>;
data['logDirectory'] = entity.path;
allLogs.add(data);
}
}
}
} catch (e) {
debugPrint("Error reading river investigative logs from ${baseDir.path}: $e");
}
}
return allLogs;
}
/// Updates an existing River Investigative log file (e.g., after a retry).
Future<void> updateRiverInvestigativeLog(Map<String, dynamic> updatedLogData) async {
final logDir = updatedLogData['logDirectory'];
if (logDir == null) {
debugPrint("Cannot update river investigative log: logDirectory key is missing.");
return;
}
try {
final jsonFile = File(p.join(logDir, 'data.json'));
if (await jsonFile.exists()) {
updatedLogData.remove('isResubmitting'); // Clean up temporary flags
await jsonFile.writeAsString(jsonEncode(updatedLogData));
debugPrint("River Investigative log updated successfully at: ${jsonFile.path}");
}
} catch (e) {
debugPrint("Error updating river investigative log: $e");
}
}
// ======================================================================= // =======================================================================
// --- ADDED: Part 7: Info Centre Document Management --- // --- ADDED: Part 7: Info Centre Document Management ---

View File

@ -166,21 +166,26 @@ class MarineInSituSamplingService {
required InSituSamplingData data, required InSituSamplingData data,
required List<Map<String, dynamic>>? appSettings, required List<Map<String, dynamic>>? appSettings,
required AuthProvider authProvider, required AuthProvider authProvider,
BuildContext? context, BuildContext? context, // Context no longer needed here, but kept for signature consistency
String? logDirectory, String? logDirectory,
}) async { }) async {
const String moduleName = 'marine_in_situ'; const String moduleName = 'marine_in_situ';
final connectivityResult = await Connectivity().checkConnectivity(); final connectivityResult = await Connectivity().checkConnectivity();
bool isOnline = connectivityResult != ConnectivityResult.none; bool isOnline = !connectivityResult.contains(ConnectivityResult.none);
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false); bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
if (isOnline && isOfflineSession) { if (isOnline && isOfflineSession) {
debugPrint("In-Situ submission online during offline session. Attempting auto-relogin..."); debugPrint("In-Situ submission online during offline session. Attempting auto-relogin...");
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession(); try {
if (transitionSuccess) { final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
isOfflineSession = false; if (transitionSuccess) {
} else { isOfflineSession = false;
} else {
isOnline = false; // Auto-relogin failed, treat as offline
}
} on SessionExpiredException catch (_) {
debugPrint("Session expired during auto-relogin check. Treating as offline.");
isOnline = false; isOnline = false;
} }
} }
@ -199,15 +204,17 @@ class MarineInSituSamplingService {
return await _performOfflineQueuing( return await _performOfflineQueuing(
data: data, data: data,
moduleName: moduleName, moduleName: moduleName,
logDirectory: logDirectory, // Pass for potential update
); );
} }
} }
/// Handles the online submission flow using generic services.
Future<Map<String, dynamic>> _performOnlineSubmission({ Future<Map<String, dynamic>> _performOnlineSubmission({
required InSituSamplingData data, required InSituSamplingData data,
required List<Map<String, dynamic>>? appSettings, required List<Map<String, dynamic>>? appSettings,
required String moduleName, required String moduleName,
required AuthProvider authProvider, required AuthProvider authProvider, // Still needed for session check inside this method
String? logDirectory, String? logDirectory,
}) async { }) async {
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default'; final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
@ -218,25 +225,29 @@ class MarineInSituSamplingService {
bool anyApiSuccess = false; bool anyApiSuccess = false;
Map<String, dynamic> apiDataResult = {}; Map<String, dynamic> apiDataResult = {};
Map<String, dynamic> apiImageResult = {}; Map<String, dynamic> apiImageResult = {};
String finalMessage = '';
String finalStatus = '';
bool isSessionKnownToBeExpired = false; bool isSessionKnownToBeExpired = false;
try { try {
// 1. Submit Form Data
apiDataResult = await _submissionApiService.submitPost( apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName, moduleName: moduleName,
endpoint: 'marine/manual/sample', endpoint: 'marine/manual/sample', // Correct endpoint for In-Situ data
body: data.toApiFormData(), body: data.toApiFormData(),
); );
if (apiDataResult['success'] == true) { if (apiDataResult['success'] == true) {
anyApiSuccess = true; anyApiSuccess = true;
data.reportId = apiDataResult['data']?['man_id']?.toString(); data.reportId = apiDataResult['data']?['man_id']?.toString(); // Correct ID key for In-Situ
if (data.reportId != null) { if (data.reportId != null) {
if (finalImageFiles.isNotEmpty) { if (finalImageFiles.isNotEmpty) {
// 2. Submit Images
apiImageResult = await _submissionApiService.submitMultipart( apiImageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName, moduleName: moduleName,
endpoint: 'marine/manual/images', endpoint: 'marine/manual/images', // Correct endpoint for In-Situ images
fields: {'man_id': data.reportId!}, fields: {'man_id': data.reportId!}, // Correct field key for In-Situ
files: finalImageFiles, files: finalImageFiles,
); );
if (apiImageResult['success'] != true) { if (apiImageResult['success'] != true) {
@ -248,65 +259,92 @@ class MarineInSituSamplingService {
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.'; apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
} }
} }
} on SessionExpiredException catch (_) { // If apiDataResult['success'] is false, SubmissionApiService queued it.
debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin...");
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
if (reloginSuccess) { } on SessionExpiredException catch (_) {
debugPrint("Silent relogin successful. Retrying entire online submission process..."); debugPrint("Online submission failed due to session expiry that could not be refreshed.");
return await _performOnlineSubmission( isSessionKnownToBeExpired = true;
data: data,
appSettings: appSettings,
moduleName: moduleName,
authProvider: authProvider,
logDirectory: logDirectory,
);
} else {
debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP.");
isSessionKnownToBeExpired = true;
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
await _retryService.addApiToQueue(endpoint: 'marine/manual/sample', method: 'POST', body: data.toApiFormData());
}
} on SocketException catch (e) {
final errorMessage = "API submission failed with network error: $e";
debugPrint(errorMessage);
anyApiSuccess = false; anyApiSuccess = false;
apiDataResult = {'success': false, 'message': errorMessage}; apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
// Manually queue API calls
await _retryService.addApiToQueue(endpoint: 'marine/manual/sample', method: 'POST', body: data.toApiFormData()); await _retryService.addApiToQueue(endpoint: 'marine/manual/sample', method: 'POST', body: data.toApiFormData());
if (finalImageFiles.isNotEmpty && data.reportId != null) { if (finalImageFiles.isNotEmpty && data.reportId != null) {
// Also queue images if data call might have partially succeeded before expiry
await _retryService.addApiToQueue(endpoint: 'marine/manual/images', method: 'POST_MULTIPART', fields: {'man_id': data.reportId!}, files: finalImageFiles); 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());
} }
// 3. Submit FTP Files
Map<String, dynamic> ftpResults = {'statuses': []}; Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false; bool anyFtpSuccess = false;
try {
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName); if (isSessionKnownToBeExpired) {
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured'); debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
} on SocketException catch (e) { final baseFileNameForQueue = _generateBaseFileName(data); // Use helper
debugPrint("FTP submission failed with network error: $e");
anyFtpSuccess = false; // --- START FIX: Add ftpConfigId when queuing ---
} on TimeoutException catch (e) { final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
debugPrint("FTP submission timed out: $e");
final dataZip = await _zippingService.createDataZip(
jsonDataMap: {'db.json': jsonEncode(data.toDbJson())},
baseFileName: baseFileNameForQueue,
destinationDir: null, // Use temp dir
);
if (dataZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) {
final configId = config['ftp_config_id'];
if (configId != null) {
await _retryService.addFtpToQueue(
localFilePath: dataZip.path,
remotePath: '/${p.basename(dataZip.path)}',
ftpConfigId: configId // Provide the specific config ID
);
}
}
}
if (finalImageFiles.isNotEmpty) {
final imageZip = await _zippingService.createImageZip(
imageFiles: finalImageFiles.values.toList(),
baseFileName: baseFileNameForQueue,
destinationDir: null, // Use temp dir
);
if (imageZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) {
final configId = config['ftp_config_id'];
if (configId != null) {
await _retryService.addFtpToQueue(
localFilePath: imageZip.path,
remotePath: '/${p.basename(imageZip.path)}',
ftpConfigId: configId // Provide the specific config ID
);
}
}
}
}
// --- END FIX ---
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
anyFtpSuccess = false; anyFtpSuccess = false;
} else {
try {
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
} catch (e) {
debugPrint("Unexpected FTP submission error: $e");
anyFtpSuccess = false;
}
} }
// 4. Determine Final Status
final bool overallSuccess = anyApiSuccess || anyFtpSuccess; final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
String finalMessage;
String finalStatus;
if (anyApiSuccess && anyFtpSuccess) { if (anyApiSuccess && anyFtpSuccess) {
finalMessage = 'Data submitted successfully to all destinations.'; finalMessage = 'Data submitted successfully to all destinations.';
finalStatus = 'S4'; finalStatus = 'S4';
} else if (anyApiSuccess && !anyFtpSuccess) { } else if (anyApiSuccess && !anyFtpSuccess) {
finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.'; finalMessage = 'Data sent to API, but some FTP uploads failed or were queued.';
finalStatus = 'S3'; finalStatus = 'S3';
} else if (!anyApiSuccess && anyFtpSuccess) { } else if (!anyApiSuccess && anyFtpSuccess) {
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.'; finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
@ -316,6 +354,7 @@ class MarineInSituSamplingService {
finalStatus = 'L1'; finalStatus = 'L1';
} }
// 5. Log Locally
await _logAndSave( await _logAndSave(
data: data, data: data,
status: finalStatus, status: finalStatus,
@ -323,10 +362,11 @@ class MarineInSituSamplingService {
apiResults: [apiDataResult, apiImageResult], apiResults: [apiDataResult, apiImageResult],
ftpStatuses: ftpResults['statuses'], ftpStatuses: ftpResults['statuses'],
serverName: serverName, serverName: serverName,
finalImageFiles: finalImageFiles, finalImageFiles: finalImageFiles, // Pass the map of actual files
logDirectory: logDirectory, logDirectory: logDirectory,
); );
// 6. Send Alert
if (overallSuccess) { if (overallSuccess) {
_handleInSituSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired); _handleInSituSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
} }
@ -334,55 +374,88 @@ class MarineInSituSamplingService {
return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId}; return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId};
} }
/// Handles queuing the submission data when the device is offline.
Future<Map<String, dynamic>> _performOfflineQueuing({ Future<Map<String, dynamic>> _performOfflineQueuing({
required InSituSamplingData data, required InSituSamplingData data,
required String moduleName, required String moduleName,
String? logDirectory, // Added for potential update
}) async { }) async {
final serverConfig = await _serverConfigService.getActiveApiConfig(); final serverConfig = await _serverConfigService.getActiveApiConfig();
final serverName = serverConfig?['config_name'] as String? ?? 'Default'; final serverName = serverConfig?['config_name'] as String? ?? 'Default';
// Set initial status before first save
data.submissionStatus = 'L1'; data.submissionStatus = 'L1';
data.submissionMessage = 'Submission queued for later retry.'; data.submissionMessage = 'Submission queued for later retry.';
final String? localLogPath = await _localStorageService.saveInSituSamplingData(data, serverName: serverName); String? savedLogPath = logDirectory; // Use existing path if provided
if (localLogPath == null) { // Save/Update local log first
if (savedLogPath != null && savedLogPath.isNotEmpty) {
// Need to reconstruct the map with file paths for updating
Map<String, dynamic> logUpdateData = data.toDbJson();
final imageFiles = data.toApiImageFiles();
imageFiles.forEach((key, file) {
logUpdateData[key] = file?.path; // Add paths back
});
logUpdateData['logDirectory'] = savedLogPath;
await _localStorageService.updateInSituLog(logUpdateData);
debugPrint("Updated existing In-Situ log for queuing: $savedLogPath");
} else {
savedLogPath = await _localStorageService.saveInSituSamplingData(data, serverName: serverName);
debugPrint("Saved new In-Situ log for queuing: $savedLogPath");
}
if (savedLogPath == null) {
const message = "Failed to save submission to local device storage."; const message = "Failed to save submission to local device storage.";
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}); // Use empty map for finalImageFiles as saving failed
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}, logDirectory: logDirectory);
return {'success': false, 'message': message}; return {'success': false, 'message': message};
} }
// Use the correct task type for In-Situ retry
await _retryService.queueTask( await _retryService.queueTask(
type: 'insitu_submission', type: 'insitu_submission',
payload: { payload: {
'module': moduleName, 'module': moduleName,
'localLogPath': localLogPath, 'localLogPath': savedLogPath, // Pass directory path
'serverConfig': serverConfig, 'serverConfig': serverConfig,
}, },
); );
const successMessage = "Submission failed to send and has been queued for later retry."; const successMessage = "Device offline. Submission has been saved locally and queued for automatic retry when connection is restored.";
return {'success': true, 'message': successMessage}; // Log final queued state to central DB
// await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}, logDirectory: savedLogPath);
return {'success': true, 'message': successMessage, 'reportId': null}; // No report ID yet
} }
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(InSituSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async { /// Helper to generate the base filename for ZIP files.
String _generateBaseFileName(InSituSamplingData data) {
final stationCode = data.selectedStation?['man_station_code'] ?? 'NA'; final stationCode = data.selectedStation?['man_station_code'] ?? 'NA';
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
final baseFileName = '${stationCode}_$fileTimestamp'; return '${stationCode}_$fileTimestamp';
}
/// Generates data and image ZIP files and uploads them using SubmissionFtpService.
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(InSituSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
final baseFileName = _generateBaseFileName(data);
final Directory? logDirectory = await _localStorageService.getLogDirectory( final Directory? logDirectory = await _localStorageService.getLogDirectory(
serverName: serverName, serverName: serverName,
module: 'marine', module: 'marine',
subModule: 'marine_in_situ_sampling', subModule: 'marine_in_situ_sampling', // Correct sub-module path
); );
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null; final folderName = data.reportId ?? baseFileName;
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) { if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
await localSubmissionDir.create(recursive: true); await localSubmissionDir.create(recursive: true);
} }
// Create and upload data ZIP
final dataZip = await _zippingService.createDataZip( final dataZip = await _zippingService.createDataZip(
jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, // Use toDbJson for FTP
baseFileName: baseFileName, baseFileName: baseFileName,
destinationDir: localSubmissionDir, destinationDir: localSubmissionDir,
); );
@ -395,6 +468,7 @@ class MarineInSituSamplingService {
); );
} }
// Create and upload image ZIP
final imageZip = await _zippingService.createImageZip( final imageZip = await _zippingService.createImageZip(
imageFiles: imageFiles.values.toList(), imageFiles: imageFiles.values.toList(),
baseFileName: baseFileName, baseFileName: baseFileName,
@ -411,12 +485,13 @@ class MarineInSituSamplingService {
return { return {
'statuses': <Map<String, dynamic>>[ 'statuses': <Map<String, dynamic>>[
...(ftpDataResult['statuses'] as List<dynamic>? ?? []), ...(ftpDataResult['statuses'] as List? ?? []), // Use null-aware spread
...(ftpImageResult['statuses'] as List<dynamic>? ?? []), ...(ftpImageResult['statuses'] as List? ?? []), // Use null-aware spread
], ],
}; };
} }
/// Saves or updates the local log file and saves a record to the central DB log.
Future<void> _logAndSave({ Future<void> _logAndSave({
required InSituSamplingData data, required InSituSamplingData data,
required String status, required String status,
@ -424,64 +499,110 @@ class MarineInSituSamplingService {
required List<Map<String, dynamic>> apiResults, required List<Map<String, dynamic>> apiResults,
required List<Map<String, dynamic>> ftpStatuses, required List<Map<String, dynamic>> ftpStatuses,
required String serverName, required String serverName,
required Map<String, File> finalImageFiles, required Map<String, File> finalImageFiles, // Changed to Map<String, File>
String? logDirectory, String? logDirectory,
}) async { }) async {
data.submissionStatus = status; data.submissionStatus = status;
data.submissionMessage = message; data.submissionMessage = message;
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); final baseFileName = _generateBaseFileName(data); // Use helper
if (logDirectory != null) { // Prepare log data map including file paths
final Map<String, dynamic> updatedLogData = data.toDbJson(); Map<String, dynamic> logMapData = data.toDbJson();
final imageFileMap = data.toApiImageFiles();
imageFileMap.forEach((key, file) {
logMapData[key] = file?.path; // Store path or null
});
// Add submission metadata
logMapData['submissionStatus'] = status;
logMapData['submissionMessage'] = message;
logMapData['reportId'] = data.reportId;
logMapData['serverConfigName'] = serverName;
logMapData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
logMapData['ftp_status'] = jsonEncode(ftpStatuses);
updatedLogData['submissionStatus'] = status; if (logDirectory != null && logDirectory.isNotEmpty) {
updatedLogData['submissionMessage'] = message; // Update existing log
logMapData['logDirectory'] = logDirectory; // Ensure logDirectory path is in the map
updatedLogData['logDirectory'] = logDirectory; await _localStorageService.updateInSituLog(logMapData);
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 { } else {
// Save new log - saveInSituSamplingData handles adding file paths
await _localStorageService.saveInSituSamplingData(data, serverName: serverName); await _localStorageService.saveInSituSamplingData(data, serverName: serverName);
} }
// Save to central DB log
final logData = { final logData = {
'submission_id': data.reportId ?? fileTimestamp, 'submission_id': data.reportId ?? baseFileName, // Use helper result
'module': 'marine', 'module': 'marine',
'type': 'In-Situ', 'type': 'In-Situ', // Correct type
'status': data.submissionStatus, 'status': status,
'message': data.submissionMessage, 'message': message,
'report_id': data.reportId, 'report_id': data.reportId,
'created_at': DateTime.now().toIso8601String(), 'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toDbJson()), 'form_data': jsonEncode(logMapData), // Log the comprehensive map with paths
'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()), 'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()), // List of paths for files actually submitted/zipped
'server_name': serverName, 'server_name': serverName,
'api_status': jsonEncode(apiResults), 'api_status': jsonEncode(apiResults),
'ftp_status': jsonEncode(ftpStatuses), 'ftp_status': jsonEncode(ftpStatuses),
}; };
await _dbHelper.saveSubmissionLog(logData); try {
await _dbHelper.saveSubmissionLog(logData);
} catch (e) {
debugPrint("Error saving In-Situ submission log to DB: $e");
}
} }
/// Handles sending or queuing the Telegram alert for In-Situ submissions.
Future<void> _handleInSituSuccessAlert(InSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async { Future<void> _handleInSituSuccessAlert(InSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
// --- START: Logic moved from data model ---
String generateInSituTelegramAlertMessage(InSituSamplingData data, {required bool isDataOnly}) {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = data.selectedStation?['man_station_name'] ?? 'N/A';
final stationCode = data.selectedStation?['man_station_code'] ?? 'N/A';
final buffer = StringBuffer()
..writeln('✅ *In-Situ Sample $submissionType Submitted:*')
..writeln()
..writeln('*Station Name & Code:* $stationName ($stationCode)')
..writeln('*Date of Submission:* ${data.samplingDate}')
..writeln('*Submitted by User:* ${data.firstSamplerName}')
..writeln('*Sonde ID:* ${data.sondeId ?? "N/A"}')
..writeln('*Status of Submission:* Successful');
final distanceKm = data.distanceDifferenceInKm ?? 0;
final distanceRemarks = data.distanceDifferenceRemarks ?? '';
if (distanceKm * 1000 > 50) { // Check distance > 50m
buffer
..writeln()
..writeln('🔔 *Distance Alert:*')
..writeln('*Distance from station:* ${(distanceKm * 1000).toStringAsFixed(0)} meters');
if (distanceRemarks.isNotEmpty) {
buffer.writeln('*Remarks for distance:* $distanceRemarks');
}
}
// Note: The logic to check parameter limits requires async DB access,
// which cannot be done directly here without further refactoring.
// This part is omitted for now as per the previous refactor.
return buffer.toString();
}
// --- END: Logic moved from data model ---
try { try {
final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly); final message = generateInSituTelegramAlertMessage(data, isDataOnly: isDataOnly); // Call local function
final alertKey = 'marine_in_situ'; // Correct key
if (isSessionExpired) { if (isSessionExpired) {
debugPrint("Session is expired; queuing Telegram alert directly."); debugPrint("Session is expired; queuing Telegram alert directly for $alertKey.");
await _telegramService.queueMessage('marine_in_situ', message, appSettings); await _telegramService.queueMessage(alertKey, message, appSettings);
} else { } else {
final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings); final bool wasSent = await _telegramService.sendAlertImmediately(alertKey, message, appSettings);
if (!wasSent) { if (!wasSent) {
await _telegramService.queueMessage('marine_in_situ', message, appSettings); await _telegramService.queueMessage(alertKey, message, appSettings);
} }
} }
} catch (e) { } catch (e) {

View File

@ -0,0 +1,629 @@
// lib/services/marine_investigative_sampling_service.dart
import 'dart:async';
import 'dart:io';
import 'dart:convert';
import 'package:flutter/material.dart'; // Needed for debugPrint and BuildContext
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 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 'package:connectivity_plus/connectivity_plus.dart';
import '../auth_provider.dart';
import 'location_service.dart';
import '../models/marine_inves_manual_sampling_data.dart';
import '../bluetooth/bluetooth_manager.dart';
import '../serial/serial_manager.dart';
import 'local_storage_service.dart';
import 'server_config_service.dart';
import 'zipping_service.dart';
import 'submission_api_service.dart';
import 'submission_ftp_service.dart';
import 'telegram_service.dart';
import 'retry_service.dart';
import 'base_api_service.dart'; // Import for SessionExpiredException
import 'api_service.dart'; // Import for DatabaseHelper
/// A dedicated service for the Marine Investigative Sampling feature.
class MarineInvestigativeSamplingService {
// Business Logic Services
final LocationService _locationService = LocationService();
final BluetoothManager _bluetoothManager = BluetoothManager();
final SerialManager _serialManager = SerialManager();
// Submission & Utility Services
final SubmissionApiService _submissionApiService = SubmissionApiService();
final SubmissionFtpService _submissionFtpService = SubmissionFtpService();
final ZippingService _zippingService = ZippingService();
final LocalStorageService _localStorageService = LocalStorageService();
final ServerConfigService _serverConfigService = ServerConfigService();
final DatabaseHelper _dbHelper = DatabaseHelper();
final RetryService _retryService = RetryService();
final TelegramService _telegramService;
MarineInvestigativeSamplingService(this._telegramService);
static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
// --- Location Services ---
Future<Position> getCurrentLocation() => _locationService.getCurrentLocation();
double calculateDistance(double lat1, double lon1, double lat2, double lon2) => _locationService.calculateDistance(lat1, lon1, lat2, lon2);
// --- Image Processing ---
Future<File?> pickAndProcessImage(ImageSource source, {
required MarineInvesManualSamplingData data,
required String imageInfo,
bool isRequired = false,
}) async {
final picker = ImagePicker();
final XFile? photo = await picker.pickImage(source: source, imageQuality: 85, maxWidth: 1024);
if (photo == null) return null;
final bytes = await photo.readAsBytes();
img.Image? originalImage = img.decodeImage(bytes);
if (originalImage == null) return null;
if (isRequired && originalImage.height > originalImage.width) {
debugPrint("Image rejected: Must be in landscape orientation.");
return null;
}
final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}";
final font = img.arial24;
final textWidth = watermarkTimestamp.length * 12; // Approximate width calculation
// Ensure overlay box fits the text
img.fillRect(originalImage, x1: 5, y1: 5, x2: textWidth + 15, y2: 35, color: img.ColorRgb8(255, 255, 255));
img.drawString(originalImage, watermarkTimestamp, font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0));
final tempDir = await getTemporaryDirectory();
String stationCode = 'NA';
if (data.stationTypeSelection == 'Existing Manual Station') {
stationCode = data.selectedStation?['man_station_code'] ?? 'MANUAL_NA';
} else if (data.stationTypeSelection == 'Existing Tarball Station') {
stationCode = data.selectedTarballStation?['tbl_station_code'] ?? 'TARBALL_NA';
} else if (data.stationTypeSelection == 'New Location') {
stationCode = data.newStationCode ?? 'NEW_NA';
}
final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-');
final newFileName = "${stationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg";
final filePath = p.join(tempDir.path, newFileName);
File processedFile = File(filePath);
await processedFile.writeAsBytes(img.encodeJpg(originalImage));
return processedFile;
}
// --- Device Connection (Delegated to Managers) ---
ValueNotifier<BluetoothConnectionState> get bluetoothConnectionState => _bluetoothManager.connectionState;
ValueNotifier<SerialConnectionState> get serialConnectionState => _serialManager.connectionState;
ValueNotifier<String?> get sondeId => _bluetoothManager.connectionState.value != BluetoothConnectionState.disconnected ? _bluetoothManager.sondeId : _serialManager.sondeId;
Stream<Map<String, double>> get bluetoothDataStream => _bluetoothManager.dataStream;
Stream<Map<String, double>> get serialDataStream => _serialManager.dataStream;
String? get connectedBluetoothDeviceName => _bluetoothManager.connectedDeviceName.value;
String? get connectedSerialDeviceName => _serialManager.connectedDeviceName.value;
// --- Permissions ---
Future<bool> requestDevicePermissions() async {
Map<Permission, PermissionStatus> statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
Permission.locationWhenInUse,
].request();
if (statuses[Permission.bluetoothScan] == PermissionStatus.granted &&
statuses[Permission.bluetoothConnect] == PermissionStatus.granted &&
statuses[Permission.locationWhenInUse] == PermissionStatus.granted) {
return true;
} else {
debugPrint("Bluetooth or Location permissions denied.");
return false;
}
}
// --- Bluetooth Methods ---
Future<List<BluetoothDevice>> getPairedBluetoothDevices() => _bluetoothManager.getPairedDevices();
Future<void> connectToBluetoothDevice(BluetoothDevice device) => _bluetoothManager.connect(device);
void disconnectFromBluetooth() => _bluetoothManager.disconnect();
void startBluetoothAutoReading({Duration? interval}) => _bluetoothManager.startAutoReading(interval: interval ?? const Duration(seconds: 2));
void stopBluetoothAutoReading() => _bluetoothManager.stopAutoReading();
// --- USB Serial Methods ---
Future<List<UsbDevice>> getAvailableSerialDevices() => _serialManager.getAvailableDevices();
Future<bool> requestUsbPermission(UsbDevice device) async {
try {
final bool? granted = await platform.invokeMethod('requestUsbPermission', {'vid': device.vid, 'pid': device.pid});
return granted ?? false;
} on PlatformException catch (e) {
debugPrint("Failed to request USB permission: '${e.message}'.");
return false;
}
}
Future<void> connectToSerialDevice(UsbDevice device) async {
final bool permissionGranted = await requestUsbPermission(device);
if (permissionGranted) {
await _serialManager.connect(device);
} else {
throw Exception("USB permission was not granted.");
}
}
void disconnectFromSerial() => _serialManager.disconnect();
void startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 2));
void stopSerialAutoReading() => _serialManager.stopAutoReading();
void dispose() {
_bluetoothManager.dispose();
_serialManager.dispose();
}
Future<Map<String, dynamic>> submitInvestigativeSample({
required MarineInvesManualSamplingData data,
required List<Map<String, dynamic>>? appSettings,
required AuthProvider authProvider,
BuildContext? context,
String? logDirectory,
}) async {
const String moduleName = 'marine_investigative';
final connectivityResult = await Connectivity().checkConnectivity();
bool isOnline = !connectivityResult.contains(ConnectivityResult.none);
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
if (isOnline && isOfflineSession) {
debugPrint("Investigative submission online during offline session. Attempting auto-relogin...");
try {
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
if (transitionSuccess) {
isOfflineSession = false;
} else {
isOnline = false; // Auto-relogin failed, treat as offline
}
} on SessionExpiredException catch (_) {
debugPrint("Session expired during auto-relogin check. Treating as offline.");
isOnline = false;
}
}
if (isOnline && !isOfflineSession) {
debugPrint("Proceeding with direct ONLINE Investigative submission...");
return await _performOnlineSubmission(
data: data,
appSettings: appSettings,
moduleName: moduleName,
authProvider: authProvider,
logDirectory: logDirectory,
);
} else {
debugPrint("Proceeding with OFFLINE Investigative queuing mechanism...");
return await _performOfflineQueuing(
data: data,
moduleName: moduleName,
logDirectory: logDirectory, // Pass for potential update
);
}
}
Future<Map<String, dynamic>> _performOnlineSubmission({
required MarineInvesManualSamplingData data,
required List<Map<String, dynamic>>? 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<String, File> finalImageFiles = imageFilesWithNulls.cast<String, File>();
bool anyApiSuccess = false;
Map<String, dynamic> apiDataResult = {};
Map<String, dynamic> apiImageResult = {};
String finalMessage = '';
String finalStatus = '';
bool isSessionKnownToBeExpired = false;
try {
// 1. Submit Form Data
apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/investigative/sample',
body: data.toApiFormData(),
);
if (apiDataResult['success'] == true) {
anyApiSuccess = true;
data.reportId = apiDataResult['data']?['man_inves_id']?.toString();
if (data.reportId != null) {
if (finalImageFiles.isNotEmpty) {
// 2. Submit Images
apiImageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName,
endpoint: 'marine/investigative/images',
fields: {'man_inves_id': data.reportId!},
files: finalImageFiles,
);
if (apiImageResult['success'] != true) {
anyApiSuccess = false; // Mark as failed if images fail
}
}
} else {
anyApiSuccess = false;
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
}
}
} on SessionExpiredException catch (_) {
debugPrint("Online submission failed due to session expiry that could not be refreshed.");
isSessionKnownToBeExpired = true; // Mark session as expired
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
// Manually queue the API call since SubmissionApiService was never called or failed internally due to session
await _retryService.addApiToQueue(endpoint: 'marine/investigative/sample', method: 'POST', body: data.toApiFormData());
if (finalImageFiles.isNotEmpty && data.reportId != null) {
// Also queue images if data call might have partially succeeded before expiry
await _retryService.addApiToQueue(endpoint: 'marine/investigative/images', method: 'POST_MULTIPART', fields: {'man_inves_id': data.reportId!}, files: finalImageFiles);
}
}
// We no longer catch SocketException or TimeoutException here.
// 3. Submit FTP Files
Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false;
if (isSessionKnownToBeExpired) {
debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
final baseFileNameForQueue = _generateBaseFileName(data);
// --- START FIX: Add ftpConfigId when queuing ---
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
final dataZip = await _zippingService.createDataZip(
jsonDataMap: {'db.json': jsonEncode(data.toDbJson())},
baseFileName: baseFileNameForQueue,
destinationDir: null, // Use temp dir
);
if (dataZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) {
final configId = config['ftp_config_id'];
if (configId != null) {
await _retryService.addFtpToQueue(
localFilePath: dataZip.path,
remotePath: '/${p.basename(dataZip.path)}',
ftpConfigId: configId // Provide the specific config ID
);
}
}
}
if (finalImageFiles.isNotEmpty) {
final imageZip = await _zippingService.createImageZip(
imageFiles: finalImageFiles.values.toList(),
baseFileName: baseFileNameForQueue,
destinationDir: null, // Use temp dir
);
if (imageZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) {
final configId = config['ftp_config_id'];
if (configId != null) {
await _retryService.addFtpToQueue(
localFilePath: imageZip.path,
remotePath: '/${p.basename(imageZip.path)}',
ftpConfigId: configId // Provide the specific config ID
);
}
}
}
}
// --- END FIX ---
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
anyFtpSuccess = false;
} else {
// Session is OK, proceed with normal FTP attempt
try {
// _generateAndUploadFtpFiles already uses the generic SubmissionFtpService
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
// Check if *any* configured FTP target succeeded (excluding 'Not Configured')
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
} catch (e) {
debugPrint("Unexpected FTP submission error: $e");
anyFtpSuccess = false; // FTP failures are auto-queued by SubmissionFtpService
}
}
// 4. Determine Final Status
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
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 or 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 = apiDataResult['message'] ?? 'All submission attempts failed and have been queued for retry.';
finalStatus = 'L1';
}
// 5. Log Locally
await _logAndSave(
data: data,
status: finalStatus,
message: finalMessage,
apiResults: [apiDataResult, apiImageResult],
ftpStatuses: ftpResults['statuses'],
serverName: serverName,
finalImageFiles: finalImageFiles,
logDirectory: logDirectory,
);
// 6. Send Alert
if (overallSuccess) {
_handleInvestigativeSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
}
return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId};
}
Future<Map<String, dynamic>> _performOfflineQueuing({
required MarineInvesManualSamplingData data,
required String moduleName,
String? logDirectory, // Added for potential update
}) async {
final serverConfig = await _serverConfigService.getActiveApiConfig();
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
data.submissionStatus = 'L1';
data.submissionMessage = 'Submission queued for later retry.';
String? savedLogPath = logDirectory; // Use existing path if provided
// Save/Update local log first
if (savedLogPath != null && savedLogPath.isNotEmpty) {
// Prepare map with file paths for update
Map<String, dynamic> logUpdateData = data.toDbJson();
final imageFiles = data.toApiImageFiles();
imageFiles.forEach((key, file) {
logUpdateData[key] = file?.path; // Add paths back
});
logUpdateData['logDirectory'] = savedLogPath;
await _localStorageService.updateInvestigativeLog(logUpdateData);
debugPrint("Updated existing Investigative log for queuing: $savedLogPath");
} else {
savedLogPath = await _localStorageService.saveInvestigativeSamplingData(data, serverName: serverName);
debugPrint("Saved new Investigative log for queuing: $savedLogPath");
}
if (savedLogPath == null) {
const message = "Failed to save submission to local device storage.";
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}, logDirectory: logDirectory);
return {'success': false, 'message': message};
}
await _retryService.queueTask(
type: 'investigative_submission',
payload: {
'module': moduleName,
'localLogPath': savedLogPath, // Pass directory path
'serverConfig': serverConfig,
},
);
const successMessage = "Device offline. Submission has been saved locally and queued for automatic retry when connection is restored.";
// Log final queued state to central DB
// await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}, logDirectory: savedLogPath);
return {'success': true, 'message': successMessage, 'reportId': null}; // No report ID yet
}
String _generateBaseFileName(MarineInvesManualSamplingData data) {
String stationCode = 'NA';
if (data.stationTypeSelection == 'Existing Manual Station') {
stationCode = data.selectedStation?['man_station_code'] ?? 'MANUAL_NA';
} else if (data.stationTypeSelection == 'Existing Tarball Station') {
stationCode = data.selectedTarballStation?['tbl_station_code'] ?? 'TARBALL_NA';
} else if (data.stationTypeSelection == 'New Location') {
stationCode = data.newStationCode ?? 'NEW_NA';
}
final datePart = data.samplingDate ?? 'NODATE';
final timePart = (data.samplingTime ?? 'NOTIME').replaceAll(':', '-');
final fileTimestamp = "${datePart}_${timePart}".replaceAll(' ', '_');
return '${stationCode}_$fileTimestamp';
}
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(MarineInvesManualSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
final baseFileName = _generateBaseFileName(data);
final Directory? logDirectory = await _localStorageService.getLogDirectory(
serverName: serverName,
module: 'marine',
subModule: 'marine_investigative_sampling',
);
final folderName = data.reportId ?? baseFileName;
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
try {
await localSubmissionDir.create(recursive: true);
} catch (e) {
debugPrint("Error creating local submission directory ${localSubmissionDir.path}: $e");
}
}
final dataZip = await _zippingService.createDataZip(
jsonDataMap: {'db.json': jsonEncode(data.toDbJson())},
baseFileName: baseFileName,
destinationDir: localSubmissionDir,
);
Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []};
if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit(
moduleName: moduleName,
fileToUpload: dataZip,
remotePath: '/${p.basename(dataZip.path)}',
);
} else {
debugPrint("Data ZIP file was null, skipping FTP upload for data.");
}
final imageZip = await _zippingService.createImageZip(
imageFiles: imageFiles.values.toList(),
baseFileName: baseFileName,
destinationDir: localSubmissionDir,
);
Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []};
if (imageZip != null) {
ftpImageResult = await _submissionFtpService.submit(
moduleName: moduleName,
fileToUpload: imageZip,
remotePath: '/${p.basename(imageZip.path)}',
);
} else {
debugPrint("Image ZIP file was null, skipping FTP upload for images.");
}
return {
'statuses': <Map<String, dynamic>>[
...(ftpDataResult['statuses'] as List? ?? []),
...(ftpImageResult['statuses'] as List? ?? []),
],
};
}
Future<void> _logAndSave({
required MarineInvesManualSamplingData data,
required String status,
required String message,
required List<Map<String, dynamic>> apiResults,
required List<Map<String, dynamic>> ftpStatuses,
required String serverName,
required Map<String, File> finalImageFiles, // Use final images map
String? logDirectory, // Existing log directory path if updating
}) async {
data.submissionStatus = status;
data.submissionMessage = message;
final baseFileName = _generateBaseFileName(data);
// Prepare log data map including file paths
Map<String, dynamic> logMapData = data.toDbJson();
final imageFileMap = data.toApiImageFiles();
imageFileMap.forEach((key, file) {
logMapData[key] = file?.path; // Store path or null
});
// Add submission metadata
logMapData['submissionStatus'] = status;
logMapData['submissionMessage'] = message;
logMapData['reportId'] = data.reportId;
logMapData['serverConfigName'] = serverName;
logMapData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
logMapData['ftp_status'] = jsonEncode(ftpStatuses);
if (logDirectory != null && logDirectory.isNotEmpty) {
logMapData['logDirectory'] = logDirectory; // Ensure path is in map
await _localStorageService.updateInvestigativeLog(logMapData); // Use specific update
} else {
await _localStorageService.saveInvestigativeSamplingData(data, serverName: serverName); // Use specific save
}
final logData = {
'submission_id': data.reportId ?? baseFileName,
'module': 'marine',
'type': 'Investigative',
'status': status,
'message': message,
'report_id': data.reportId,
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(logMapData), // Log comprehensive map
'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()),
'server_name': serverName,
'api_status': jsonEncode(apiResults),
'ftp_status': jsonEncode(ftpStatuses),
};
try {
await _dbHelper.saveSubmissionLog(logData);
} catch (e) {
debugPrint("Error saving Investigative submission log to DB: $e");
}
}
Future<void> _handleInvestigativeSuccessAlert(MarineInvesManualSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
String generateInvestigativeTelegramAlertMessage(MarineInvesManualSamplingData data, {required bool isDataOnly}) {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
String stationName = 'N/A';
String stationCode = 'N/A';
if (data.stationTypeSelection == 'Existing Manual Station') {
stationName = data.selectedStation?['man_station_name'] ?? 'N/A';
stationCode = data.selectedStation?['man_station_code'] ?? 'N/A';
} else if (data.stationTypeSelection == 'Existing Tarball Station') {
stationName = data.selectedTarballStation?['tbl_station_name'] ?? 'N/A';
stationCode = data.selectedTarballStation?['tbl_station_code'] ?? 'N/A';
} else if (data.stationTypeSelection == 'New Location') {
stationName = data.newStationName ?? 'New Location';
stationCode = data.newStationCode ?? 'NEW';
}
final buffer = StringBuffer()
..writeln('🕵️ *Marine Investigative Sample $submissionType Submitted:*')
..writeln()
..writeln('*Station Name & Code:* $stationName ($stationCode)')
..writeln('*Date of Submitted:* ${data.samplingDate}')
..writeln('*Submitted by User:* ${data.firstSamplerName}')
..writeln('*Sonde ID:* ${data.sondeId ?? "N/A"}')
..writeln('*Status of Submission:* Successful');
if (data.distanceDifferenceInKm != null && data.distanceDifferenceInKm! * 1000 > 50) {
buffer
..writeln()
..writeln('🔔 *Distance Alert:*')
..writeln('*Distance from station:* ${(data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters');
if (data.distanceDifferenceRemarks != null && data.distanceDifferenceRemarks!.isNotEmpty) {
buffer.writeln('*Remarks for distance:* ${data.distanceDifferenceRemarks}');
}
}
return buffer.toString();
}
try {
final message = generateInvestigativeTelegramAlertMessage(data, isDataOnly: isDataOnly);
final alertKey = 'marine_investigative';
if (isSessionExpired) {
debugPrint("Session is expired; queuing Telegram alert directly for $alertKey.");
await _telegramService.queueMessage(alertKey, message, appSettings);
} else {
final bool wasSent = await _telegramService.sendAlertImmediately(alertKey, message, appSettings);
if (!wasSent) {
await _telegramService.queueMessage(alertKey, message, appSettings);
}
}
} catch (e) {
debugPrint("Failed to handle Investigative Telegram alert: $e");
}
}
}

View File

@ -37,38 +37,62 @@ class MarineTarballSamplingService {
Future<Map<String, dynamic>> submitTarballSample({ Future<Map<String, dynamic>> submitTarballSample({
required TarballSamplingData data, required TarballSamplingData data,
required List<Map<String, dynamic>>? appSettings, required List<Map<String, dynamic>>? appSettings,
required BuildContext context, // --- START FIX: Make BuildContext nullable ---
required BuildContext? context,
// --- END FIX ---
String? logDirectory, // Added for retry consistency
}) async { }) async {
const String moduleName = 'marine_tarball'; const String moduleName = 'marine_tarball';
final authProvider = Provider.of<AuthProvider>(context, listen: false); // --- START FIX: Handle nullable context ---
final authProvider = context != null ? Provider.of<AuthProvider>(context, listen: false) : null;
// Need a fallback mechanism if context is null (e.g., during retry)
// One option is to ensure AuthProvider is always accessible, maybe via a singleton or passed differently.
// For now, we'll proceed assuming authProvider might be null during retry,
// which could affect session checks. Consider injecting AuthProvider if needed globally.
if (authProvider == null && context != null) {
// If context was provided but provider failed, log error
debugPrint("Error: AuthProvider not found in context for Tarball submission.");
return {'success': false, 'message': 'Internal error: AuthProvider not available.'};
}
// --- END FIX ---
final connectivityResult = await Connectivity().checkConnectivity(); final connectivityResult = await Connectivity().checkConnectivity();
bool isOnline = connectivityResult != ConnectivityResult.none; bool isOnline = !connectivityResult.contains(ConnectivityResult.none);
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false); // --- START FIX: Handle potentially null authProvider ---
bool isOfflineSession = authProvider != null && authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
if (isOnline && isOfflineSession) { if (isOnline && isOfflineSession && authProvider != null) {
debugPrint("Submission initiated online during an offline session. Attempting auto-relogin..."); debugPrint("Tarball submission online during an offline session. Attempting auto-relogin...");
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession(); try {
if (transitionSuccess) { final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
isOfflineSession = false; if (transitionSuccess) {
} else { isOfflineSession = false;
} else {
isOnline = false; // Auto-relogin failed, treat as offline
}
} on SessionExpiredException catch (_) {
debugPrint("Session expired during auto-relogin check. Treating as offline.");
isOnline = false; isOnline = false;
} }
} }
// --- END FIX ---
if (isOnline && !isOfflineSession) { if (isOnline && !isOfflineSession) {
debugPrint("Proceeding with direct ONLINE submission..."); debugPrint("Proceeding with direct ONLINE Tarball submission...");
return await _performOnlineSubmission( return await _performOnlineSubmission(
data: data, data: data,
appSettings: appSettings, appSettings: appSettings,
moduleName: moduleName, moduleName: moduleName,
authProvider: authProvider, authProvider: authProvider, // Pass potentially null provider
logDirectory: logDirectory,
); );
} else { } else {
debugPrint("Proceeding with OFFLINE queuing mechanism..."); debugPrint("Proceeding with OFFLINE Tarball queuing mechanism...");
return await _performOfflineQueuing( return await _performOfflineQueuing(
data: data, data: data,
moduleName: moduleName, moduleName: moduleName,
logDirectory: logDirectory, // Pass logDirectory for potential update
); );
} }
} }
@ -77,7 +101,8 @@ class MarineTarballSamplingService {
required TarballSamplingData data, required TarballSamplingData data,
required List<Map<String, dynamic>>? appSettings, required List<Map<String, dynamic>>? appSettings,
required String moduleName, required String moduleName,
required AuthProvider authProvider, required AuthProvider? authProvider, // Accept potentially null provider
String? logDirectory, // Added for retry consistency
}) async { }) async {
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default'; final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
final imageFiles = data.toImageFiles()..removeWhere((key, value) => value == null); final imageFiles = data.toImageFiles()..removeWhere((key, value) => value == null);
@ -86,92 +111,133 @@ class MarineTarballSamplingService {
bool anyApiSuccess = false; bool anyApiSuccess = false;
Map<String, dynamic> apiDataResult = {}; Map<String, dynamic> apiDataResult = {};
Map<String, dynamic> apiImageResult = {}; Map<String, dynamic> apiImageResult = {};
String finalMessage = '';
String finalStatus = '';
bool isSessionKnownToBeExpired = false; bool isSessionKnownToBeExpired = false;
try { try {
// 1. Submit Form Data
apiDataResult = await _submissionApiService.submitPost( apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName, moduleName: moduleName,
endpoint: 'marine/tarball/sample', endpoint: 'marine/tarball/sample', // Correct endpoint
body: data.toFormData(), body: data.toFormData(), // Use specific method for tarball form data
); );
if (apiDataResult['success'] == true) { if (apiDataResult['success'] == true) {
anyApiSuccess = true; anyApiSuccess = true;
data.reportId = apiDataResult['data']?['autoid']?.toString(); data.reportId = apiDataResult['data']?['autoid']?.toString(); // Correct ID key
if (data.reportId != null) { if (data.reportId != null) {
apiImageResult = await _submissionApiService.submitMultipart( if (finalImageFiles.isNotEmpty) {
moduleName: moduleName, // 2. Submit Images
endpoint: 'marine/tarball/images', apiImageResult = await _submissionApiService.submitMultipart(
fields: {'autoid': data.reportId!}, moduleName: moduleName,
files: finalImageFiles, endpoint: 'marine/tarball/images', // Correct endpoint
); fields: {'autoid': data.reportId!}, // Correct field key
if (apiImageResult['success'] != true) { files: finalImageFiles,
anyApiSuccess = false; // Downgrade success if images fail );
if (apiImageResult['success'] != true) {
anyApiSuccess = false; // Downgrade success if images fail
}
} }
// If data succeeded but no images, API part is still successful
} else { } else {
anyApiSuccess = false; anyApiSuccess = false;
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.'; apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
} }
} }
} on SessionExpiredException catch (_) { // If apiDataResult['success'] is false, SubmissionApiService queued it.
debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin...");
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
if (reloginSuccess) { } on SessionExpiredException catch (_) {
debugPrint("Silent relogin successful. Retrying entire online submission process..."); debugPrint("API submission failed with SessionExpiredException during online submission.");
return await _performOnlineSubmission( isSessionKnownToBeExpired = true;
data: data,
appSettings: appSettings,
moduleName: moduleName,
authProvider: authProvider,
);
} else {
debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP.");
isSessionKnownToBeExpired = true;
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData());
}
} on SocketException catch (e) {
final errorMessage = "API submission failed with network error: $e";
debugPrint(errorMessage);
anyApiSuccess = false; anyApiSuccess = false;
apiDataResult = {'success': false, 'message': errorMessage}; apiDataResult = {'success': false, 'message': 'Session expired. API submission queued.'};
// Manually queue API calls
await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData()); await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData());
if(finalImageFiles.isNotEmpty && data.reportId != null) { if (finalImageFiles.isNotEmpty && data.reportId != null) {
// Queue images if data might have partially succeeded
await _retryService.addApiToQueue(endpoint: 'marine/tarball/images', method: 'POST_MULTIPART', fields: {'autoid': data.reportId!}, files: finalImageFiles); await _retryService.addApiToQueue(endpoint: 'marine/tarball/images', method: 'POST_MULTIPART', fields: {'autoid': data.reportId!}, files: finalImageFiles);
} }
} 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());
} }
// 3. Submit FTP Files
Map<String, dynamic> ftpResults = {'statuses': []}; Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false; bool anyFtpSuccess = false;
try {
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName); if (isSessionKnownToBeExpired) {
anyFtpSuccess = !ftpResults['statuses'].any((status) => status['success'] == false); debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
} on SocketException catch (e) { final baseFileNameForQueue = _generateBaseFileName(data); // Use helper
debugPrint("FTP submission failed with network error: $e");
anyFtpSuccess = false; // --- START FIX: Add ftpConfigId when queuing ---
} on TimeoutException catch (e) { final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
debugPrint("FTP submission timed out: $e");
final dataZip = await _zippingService.createDataZip(
jsonDataMap: { // Use specific JSON structures for Tarball FTP
'data.json': jsonEncode(data.toDbJson()),
'basic_form.json': jsonEncode(data.toBasicFormJson()),
'reading.json': jsonEncode(data.toReadingJson()),
'manual_info.json': jsonEncode(data.toManualInfoJson()),
},
baseFileName: baseFileNameForQueue,
destinationDir: null,
);
if (dataZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) {
final configId = config['ftp_config_id'];
if (configId != null) {
await _retryService.addFtpToQueue(
localFilePath: dataZip.path,
remotePath: '/${p.basename(dataZip.path)}',
ftpConfigId: configId // Provide the specific config ID
);
}
}
}
if (finalImageFiles.isNotEmpty) {
final imageZip = await _zippingService.createImageZip(
imageFiles: finalImageFiles.values.toList(),
baseFileName: baseFileNameForQueue,
destinationDir: null,
);
if (imageZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) {
final configId = config['ftp_config_id'];
if (configId != null) {
await _retryService.addFtpToQueue(
localFilePath: imageZip.path,
remotePath: '/${p.basename(imageZip.path)}',
ftpConfigId: configId // Provide the specific config ID
);
}
}
}
}
// --- END FIX ---
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
anyFtpSuccess = false; anyFtpSuccess = false;
} else {
try {
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
} catch (e) {
debugPrint("Unexpected FTP submission error: $e");
anyFtpSuccess = false;
}
} }
// 4. Determine Final Status
final bool overallSuccess = anyApiSuccess || anyFtpSuccess; final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
String finalMessage;
String finalStatus;
if (anyApiSuccess && anyFtpSuccess) { if (anyApiSuccess && anyFtpSuccess) {
finalMessage = 'Data submitted successfully to all destinations.'; finalMessage = 'Data submitted successfully to all destinations.';
finalStatus = 'S4'; finalStatus = 'S4';
} else if (anyApiSuccess && !anyFtpSuccess) { } else if (anyApiSuccess && !anyFtpSuccess) {
finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.'; finalMessage = 'Data sent to API, but some FTP uploads failed or were queued.';
finalStatus = 'S3'; finalStatus = 'S3';
} else if (!anyApiSuccess && anyFtpSuccess) { } else if (!anyApiSuccess && anyFtpSuccess) {
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.'; finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
@ -181,6 +247,7 @@ class MarineTarballSamplingService {
finalStatus = 'L1'; finalStatus = 'L1';
} }
// 5. Log Locally
await _logAndSave( await _logAndSave(
data: data, data: data,
status: finalStatus, status: finalStatus,
@ -189,8 +256,10 @@ class MarineTarballSamplingService {
ftpStatuses: ftpResults['statuses'], ftpStatuses: ftpResults['statuses'],
serverName: serverName, serverName: serverName,
finalImageFiles: finalImageFiles, finalImageFiles: finalImageFiles,
logDirectory: logDirectory, // Pass logDirectory for potential update
); );
// 6. Send Alert
if (overallSuccess) { if (overallSuccess) {
_handleTarballSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired); _handleTarballSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
} }
@ -201,50 +270,82 @@ class MarineTarballSamplingService {
Future<Map<String, dynamic>> _performOfflineQueuing({ Future<Map<String, dynamic>> _performOfflineQueuing({
required TarballSamplingData data, required TarballSamplingData data,
required String moduleName, required String moduleName,
String? logDirectory, // Added for potential update
}) async { }) async {
final serverConfig = await _serverConfigService.getActiveApiConfig(); final serverConfig = await _serverConfigService.getActiveApiConfig();
final serverName = serverConfig?['config_name'] as String? ?? 'Default'; final serverName = serverConfig?['config_name'] as String? ?? 'Default';
final String? localLogPath = await _localStorageService.saveTarballSamplingData(data, serverName: serverName); // Set status before saving/updating
data.submissionStatus = 'L1'; // Logged locally or Queued
data.submissionMessage = 'Submission queued for later retry.';
if (localLogPath == null) { String? savedLogPath = logDirectory; // Use existing path if provided
// Save/Update local log first
if (savedLogPath != null && savedLogPath.isNotEmpty) {
await _localStorageService.updateTarballLog(data.toDbJson()..['logDirectory'] = savedLogPath);
debugPrint("Updated existing Tarball log for queuing: $savedLogPath");
} else {
savedLogPath = await _localStorageService.saveTarballSamplingData(data, serverName: serverName);
debugPrint("Saved new Tarball log for queuing: $savedLogPath");
}
if (savedLogPath == null) {
const message = "Failed to save submission to local device storage."; const message = "Failed to save submission to local device storage.";
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}); // Log failure state if saving fails
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}, logDirectory: logDirectory);
return {'success': false, 'message': message}; return {'success': false, 'message': message};
} }
// Queue a single task for the RetryService
await _retryService.queueTask( await _retryService.queueTask(
type: 'tarball_submission', type: 'tarball_submission', // Use specific type
payload: { payload: {
'module': moduleName, 'module': moduleName,
'localLogPath': localLogPath, 'localLogPath': savedLogPath, // Point retry service to the saved log *directory*
'serverConfig': serverConfig, 'serverConfig': serverConfig,
}, },
); );
const successMessage = "Submission failed to send and has been queued for later retry."; const successMessage = "Device offline. Submission has been saved locally and queued for automatic retry when connection is restored.";
await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}); // Log final queued state to central DB
// await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}, logDirectory: savedLogPath);
return {'success': true, 'message': successMessage}; return {'success': true, 'message': successMessage, 'reportId': null}; // No report ID yet
} }
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(TarballSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async { /// Helper to generate the base filename for ZIP files.
String _generateBaseFileName(TarballSamplingData data) {
final stationCode = data.selectedStation?['tbl_station_code'] ?? 'NA'; final stationCode = data.selectedStation?['tbl_station_code'] ?? 'NA';
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
final baseFileName = '${stationCode}_$fileTimestamp'; return '${stationCode}_$fileTimestamp';
}
/// Generates data and image ZIP files and uploads them using SubmissionFtpService.
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(TarballSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
final baseFileName = _generateBaseFileName(data);
final Directory? logDirectory = await _localStorageService.getLogDirectory( final Directory? logDirectory = await _localStorageService.getLogDirectory(
serverName: serverName, serverName: serverName,
module: 'marine', module: 'marine',
subModule: 'marine_tarball_sampling', subModule: 'marine_tarball_sampling', // Correct sub-module
); );
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null; final folderName = data.reportId ?? baseFileName;
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) { if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
await localSubmissionDir.create(recursive: true); await localSubmissionDir.create(recursive: true);
} }
// Create and upload data ZIP (with multiple JSON files specific to Tarball)
final dataZip = await _zippingService.createDataZip( final dataZip = await _zippingService.createDataZip(
jsonDataMap: {'data.json': jsonEncode(data.toDbJson())}, jsonDataMap: {
'data.json': jsonEncode(data.toDbJson()), // Use specific method
'basic_form.json': jsonEncode(data.toBasicFormJson()), // Use specific method
'reading.json': jsonEncode(data.toReadingJson()), // Use specific method
'manual_info.json': jsonEncode(data.toManualInfoJson()), // Use specific method
},
baseFileName: baseFileName, baseFileName: baseFileName,
destinationDir: localSubmissionDir, destinationDir: localSubmissionDir,
); );
@ -257,6 +358,7 @@ class MarineTarballSamplingService {
); );
} }
// Create and upload image ZIP
final imageZip = await _zippingService.createImageZip( final imageZip = await _zippingService.createImageZip(
imageFiles: imageFiles.values.toList(), imageFiles: imageFiles.values.toList(),
baseFileName: baseFileName, baseFileName: baseFileName,
@ -273,12 +375,13 @@ class MarineTarballSamplingService {
return { return {
'statuses': <Map<String, dynamic>>[ 'statuses': <Map<String, dynamic>>[
...?(ftpDataResult['statuses'] as List?), ...(ftpDataResult['statuses'] as List? ?? []),
...?(ftpImageResult['statuses'] as List?), ...(ftpImageResult['statuses'] as List? ?? []),
], ],
}; };
} }
/// Saves or updates the local log file and saves a record to the central DB log.
Future<void> _logAndSave({ Future<void> _logAndSave({
required TarballSamplingData data, required TarballSamplingData data,
required String status, required String status,
@ -287,40 +390,103 @@ class MarineTarballSamplingService {
required List<Map<String, dynamic>> ftpStatuses, required List<Map<String, dynamic>> ftpStatuses,
required String serverName, required String serverName,
required Map<String, File> finalImageFiles, required Map<String, File> finalImageFiles,
String? logDirectory, // Added for potential update
}) async { }) async {
data.submissionStatus = status; data.submissionStatus = status;
data.submissionMessage = message; data.submissionMessage = message;
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); final baseFileName = _generateBaseFileName(data); // Use helper
await _localStorageService.saveTarballSamplingData(data, serverName: serverName); // Prepare log data map including file paths
Map<String, dynamic> logMapData = data.toDbJson();
final imageFileMap = data.toImageFiles();
imageFileMap.forEach((key, file) {
logMapData[key] = file?.path; // Store path or null
});
// Add submission metadata
logMapData['submissionStatus'] = status;
logMapData['submissionMessage'] = message;
logMapData['reportId'] = data.reportId;
logMapData['serverConfigName'] = serverName;
logMapData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
logMapData['ftp_status'] = jsonEncode(ftpStatuses);
// Update or save the specific local JSON log file
if (logDirectory != null && logDirectory.isNotEmpty) {
logMapData['logDirectory'] = logDirectory; // Ensure logDirectory path is in the map
await _localStorageService.updateTarballLog(logMapData); // Use specific update method
} else {
await _localStorageService.saveTarballSamplingData(data, serverName: serverName); // Use specific save method
}
// Save a record to the central SQLite submission log table
final logData = { final logData = {
'submission_id': data.reportId ?? fileTimestamp, 'submission_id': data.reportId ?? baseFileName, // Use helper result
'module': 'marine', 'module': 'marine', // Correct module
'type': 'Tarball', 'type': 'Tarball', // Correct type
'status': status, 'status': status,
'message': message, 'message': message,
'report_id': data.reportId, 'report_id': data.reportId,
'created_at': DateTime.now().toIso8601String(), 'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toDbJson()), 'form_data': jsonEncode(logMapData), // Log the comprehensive map with paths
'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()), 'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()),
'server_name': serverName, 'server_name': serverName,
'api_status': jsonEncode(apiResults), 'api_status': jsonEncode(apiResults),
'ftp_status': jsonEncode(ftpStatuses), 'ftp_status': jsonEncode(ftpStatuses),
}; };
await _dbHelper.saveSubmissionLog(logData); try {
await _dbHelper.saveSubmissionLog(logData);
} catch (e) {
debugPrint("Error saving Tarball submission log to DB: $e");
}
} }
/// Handles sending or queuing the Telegram alert for Tarball submissions.
Future<void> _handleTarballSuccessAlert(TarballSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async { Future<void> _handleTarballSuccessAlert(TarballSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
// --- START: Logic moved from data model ---
String generateTarballTelegramAlertMessage(TarballSamplingData data, {required bool isDataOnly}) {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = data.selectedStation?['tbl_station_name'] ?? 'N/A';
final stationCode = data.selectedStation?['tbl_station_code'] ?? 'N/A';
final classification = data.selectedClassification?['classification_name'] ?? data.classificationId?.toString() ?? 'N/A';
final buffer = StringBuffer()
..writeln('✅ *Tarball Sample $submissionType Submitted:*')
..writeln()
..writeln('*Station Name & Code:* $stationName ($stationCode)')
..writeln('*Date of Submission:* ${data.samplingDate}')
..writeln('*Submitted by User:* ${data.firstSampler}') // Use firstSampler from data model
..writeln('*Classification:* $classification')
..writeln('*Status of Submission:* Successful');
final distanceKm = data.distanceDifference ?? 0; // Use distanceDifference from data model
final distanceRemarks = data.distanceDifferenceRemarks ?? '';
if (distanceKm * 1000 > 50) { // Check distance > 50m
buffer
..writeln()
..writeln('🔔 *Distance Alert:*')
..writeln('*Distance from station:* ${(distanceKm * 1000).toStringAsFixed(0)} meters');
if (distanceRemarks.isNotEmpty) {
buffer.writeln('*Remarks for distance:* $distanceRemarks');
}
}
return buffer.toString();
}
// --- END: Logic moved from data model ---
try { try {
final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly); final message = generateTarballTelegramAlertMessage(data, isDataOnly: isDataOnly); // Call local function
final alertKey = 'marine_tarball'; // Correct key
if (isSessionExpired) { if (isSessionExpired) {
debugPrint("Session is expired; queuing Telegram alert directly."); debugPrint("Session is expired; queuing Telegram alert directly for $alertKey.");
await _telegramService.queueMessage('marine_tarball', message, appSettings); await _telegramService.queueMessage(alertKey, message, appSettings);
} else { } else {
final bool wasSent = await _telegramService.sendAlertImmediately('marine_tarball', message, appSettings); final bool wasSent = await _telegramService.sendAlertImmediately(alertKey, message, appSettings);
if (!wasSent) { if (!wasSent) {
await _telegramService.queueMessage('marine_tarball', message, appSettings); await _telegramService.queueMessage(alertKey, message, appSettings);
} }
} }
} catch (e) { } catch (e) {

View File

@ -7,8 +7,16 @@ import 'package:path/path.dart' as p;
import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart'; 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/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/models/river_in_situ_sampling_data.dart';
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart'; // ADDED import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart';
// *** ADDED: Import River Investigative Model and Service ***
import 'package:environment_monitoring_app/models/river_inves_manual_sampling_data.dart';
import 'package:environment_monitoring_app/services/river_investigative_sampling_service.dart';
// *** END ADDED ***
import 'package:environment_monitoring_app/models/marine_inves_manual_sampling_data.dart';
import 'package:environment_monitoring_app/services/marine_investigative_sampling_service.dart';
import 'package:environment_monitoring_app/models/tarball_data.dart';
import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart';
import 'package:environment_monitoring_app/services/api_service.dart'; import 'package:environment_monitoring_app/services/api_service.dart';
import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/base_api_service.dart';
import 'package:environment_monitoring_app/services/ftp_service.dart'; import 'package:environment_monitoring_app/services/ftp_service.dart';
@ -23,23 +31,32 @@ class RetryService {
final ServerConfigService _serverConfigService = ServerConfigService(); final ServerConfigService _serverConfigService = ServerConfigService();
bool _isProcessing = false; bool _isProcessing = false;
// --- START: MODIFICATION FOR HANDLING COMPLEX TASKS ---
// These services will be provided after the RetryService is created.
MarineInSituSamplingService? _marineInSituService; MarineInSituSamplingService? _marineInSituService;
RiverInSituSamplingService? _riverInSituService; // ADDED RiverInSituSamplingService? _riverInSituService;
MarineInvestigativeSamplingService? _marineInvestigativeService;
MarineTarballSamplingService? _marineTarballService;
// *** ADDED: River Investigative Service member ***
RiverInvestigativeSamplingService? _riverInvestigativeService;
// *** END ADDED ***
AuthProvider? _authProvider; AuthProvider? _authProvider;
// Call this method from your main app setup to provide the necessary services. // *** MODIFIED: Added riverInvestigativeService to initialize ***
void initialize({ void initialize({
required MarineInSituSamplingService marineInSituService, required MarineInSituSamplingService marineInSituService,
required RiverInSituSamplingService riverInSituService, // ADDED required RiverInSituSamplingService riverInSituService,
required MarineInvestigativeSamplingService marineInvestigativeService,
required RiverInvestigativeSamplingService riverInvestigativeService, // <-- Added parameter
required MarineTarballSamplingService marineTarballService,
required AuthProvider authProvider, required AuthProvider authProvider,
}) { }) {
_marineInSituService = marineInSituService; _marineInSituService = marineInSituService;
_riverInSituService = riverInSituService; // ADDED _riverInSituService = riverInSituService;
_marineInvestigativeService = marineInvestigativeService;
_riverInvestigativeService = riverInvestigativeService; // <-- Assign parameter
_marineTarballService = marineTarballService;
_authProvider = authProvider; _authProvider = authProvider;
} }
// --- END: MODIFICATION FOR HANDLING COMPLEX TASKS --- // *** END MODIFIED ***
/// Adds a generic, complex task to the queue, to be handled by a background processor. /// Adds a generic, complex task to the queue, to be handled by a background processor.
@ -49,7 +66,7 @@ class RetryService {
}) async { }) async {
await _dbHelper.queueFailedRequest({ await _dbHelper.queueFailedRequest({
'type': type, 'type': type,
'endpoint_or_path': 'N/A', 'endpoint_or_path': 'N/A', // Not applicable for complex tasks initially
'payload': jsonEncode(payload), 'payload': jsonEncode(payload),
'timestamp': DateTime.now().toIso8601String(), 'timestamp': DateTime.now().toIso8601String(),
'status': 'pending', 'status': 'pending',
@ -65,12 +82,13 @@ class RetryService {
Map<String, String>? fields, Map<String, String>? fields,
Map<String, File>? files, Map<String, File>? files,
}) async { }) async {
// Convert File objects to paths for JSON serialization
final serializableFiles = files?.map((key, value) => MapEntry(key, value.path)); final serializableFiles = files?.map((key, value) => MapEntry(key, value.path));
final payload = { final payload = {
'method': method, 'method': method,
'body': body, 'body': body,
'fields': fields, 'fields': fields,
'files': serializableFiles, 'files': serializableFiles, // Store paths instead of File objects
}; };
await _dbHelper.queueFailedRequest({ await _dbHelper.queueFailedRequest({
'type': 'api', 'type': 'api',
@ -86,8 +104,12 @@ class RetryService {
Future<void> addFtpToQueue({ Future<void> addFtpToQueue({
required String localFilePath, required String localFilePath,
required String remotePath, required String remotePath,
required int ftpConfigId, // Added to specify which destination failed
}) async { }) async {
final payload = {'localFilePath': localFilePath}; final payload = {
'localFilePath': localFilePath,
'ftpConfigId': ftpConfigId, // Store the specific config ID
};
await _dbHelper.queueFailedRequest({ await _dbHelper.queueFailedRequest({
'type': 'ftp', 'type': 'ftp',
'endpoint_or_path': remotePath, 'endpoint_or_path': remotePath,
@ -95,9 +117,10 @@ class RetryService {
'timestamp': DateTime.now().toIso8601String(), 'timestamp': DateTime.now().toIso8601String(),
'status': 'pending', 'status': 'pending',
}); });
debugPrint("FTP upload for file '$localFilePath' has been queued for retry."); debugPrint("FTP upload for file '$localFilePath' to config ID $ftpConfigId has been queued for retry.");
} }
/// Retrieves all tasks currently in the 'pending' state from the queue. /// Retrieves all tasks currently in the 'pending' state from the queue.
Future<List<Map<String, dynamic>>> getPendingTasks() { Future<List<Map<String, dynamic>>> getPendingTasks() {
return _dbHelper.getPendingRequests(); return _dbHelper.getPendingRequests();
@ -119,6 +142,7 @@ class RetryService {
return; return;
} }
// Check internet connection *before* processing
if (_authProvider == null || !await _authProvider!.isConnected()) { if (_authProvider == null || !await _authProvider!.isConnected()) {
debugPrint("[RetryService] ❌ No internet connection. Aborting queue processing."); debugPrint("[RetryService] ❌ No internet connection. Aborting queue processing.");
_isProcessing = false; _isProcessing = false;
@ -126,8 +150,14 @@ class RetryService {
} }
debugPrint("[RetryService] 🔎 Found ${pendingTasks.length} pending tasks."); debugPrint("[RetryService] 🔎 Found ${pendingTasks.length} pending tasks.");
// Process tasks one by one
for (final task in pendingTasks) { for (final task in pendingTasks) {
await retryTask(task['id'] as int); // Add safety check in case a task is deleted mid-processing by another call
if (await _dbHelper.getRequestById(task['id'] as int) != null) {
await retryTask(task['id'] as int);
}
// Optional: Add a small delay between tasks if needed
// await Future.delayed(Duration(seconds: 1));
} }
debugPrint("[RetryService] ⏹️ Finished processing retry queue."); debugPrint("[RetryService] ⏹️ Finished processing retry queue.");
@ -139,28 +169,44 @@ class RetryService {
Future<bool> retryTask(int taskId) async { Future<bool> retryTask(int taskId) async {
final task = await _dbHelper.getRequestById(taskId); final task = await _dbHelper.getRequestById(taskId);
if (task == null) { if (task == null) {
debugPrint("Retry failed: Task with ID $taskId not found in the queue."); debugPrint("Retry failed: Task with ID $taskId not found in the queue (might have been processed already).");
return false; return false; // Task doesn't exist or was processed elsewhere
} }
bool success = false; bool success = false;
final payload = jsonDecode(task['payload'] as String); Map<String, dynamic> payload; // Declare outside try-catch
final String taskType = task['type'] as String; // Get type early for logging
try { try {
payload = jsonDecode(task['payload'] as String); // Decode payload inside try
} catch (e) {
debugPrint("Error decoding payload for task $taskId (Type: $taskType): $e. Removing invalid task.");
await _dbHelper.deleteRequestFromQueue(taskId);
return false; // Cannot process without valid payload
}
try {
// Ensure AuthProvider is initialized and we are online (checked in processRetryQueue)
if (_authProvider == null) { if (_authProvider == null) {
debugPrint("RetryService has not been initialized. Cannot process task."); debugPrint("RetryService has not been initialized. Cannot process task $taskId.");
return false; return false;
} }
if (task['type'] == 'insitu_submission') { // --- Complex Task Handlers ---
if (taskType == 'insitu_submission') {
debugPrint("Retrying complex task 'insitu_submission' with ID $taskId."); debugPrint("Retrying complex task 'insitu_submission' with ID $taskId.");
if (_marineInSituService == null) return false; if (_marineInSituService == null) {
debugPrint("Retry failed: MarineInSituSamplingService not initialized.");
return false;
}
final String logFilePath = payload['localLogPath']; final String logDirectoryPath = payload['localLogPath']; // Path to the directory
final file = File(logFilePath); final jsonFilePath = p.join(logDirectoryPath, 'data.json');
final file = File(jsonFilePath);
if (!await file.exists()) { if (!await file.exists()) {
debugPrint("Retry failed: Source log file no longer exists at $logFilePath"); debugPrint("Retry failed: Source log file no longer exists at $jsonFilePath");
await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task
return false; return false;
} }
@ -168,34 +214,37 @@ class RetryService {
final content = await file.readAsString(); final content = await file.readAsString();
final jsonData = jsonDecode(content) as Map<String, dynamic>; final jsonData = jsonDecode(content) as Map<String, dynamic>;
final InSituSamplingData dataToResubmit = InSituSamplingData.fromJson(jsonData); final InSituSamplingData dataToResubmit = InSituSamplingData.fromJson(jsonData);
final String logDirectoryPath = p.dirname(logFilePath);
// Re-run the original submission logic, passing the log directory
final result = await _marineInSituService!.submitInSituSample( final result = await _marineInSituService!.submitInSituSample(
data: dataToResubmit, data: dataToResubmit,
appSettings: _authProvider!.appSettings, appSettings: _authProvider!.appSettings, // Get current settings
authProvider: _authProvider!, authProvider: _authProvider!,
logDirectory: logDirectoryPath, logDirectory: logDirectoryPath, // Pass directory to update log
); );
success = result['success']; success = result['success'];
// --- START: ADDED LOGIC FOR RIVER IN-SITU SUBMISSION --- } else if (taskType == 'river_insitu_submission') {
} else if (task['type'] == 'river_insitu_submission') {
debugPrint("Retrying complex task 'river_insitu_submission' with ID $taskId."); debugPrint("Retrying complex task 'river_insitu_submission' with ID $taskId.");
if (_riverInSituService == null) return false; if (_riverInSituService == null) {
debugPrint("Retry failed: RiverInSituSamplingService not initialized.");
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; return false;
} }
final String jsonFilePath = payload['localLogPath']; // Path to the JSON file
final file = File(jsonFilePath);
if (!await file.exists()) {
debugPrint("Retry failed: Source log file no longer exists at $jsonFilePath");
await _dbHelper.deleteRequestFromQueue(taskId);
return false;
}
final String logDirectoryPath = p.dirname(jsonFilePath); // Get directory from file path
final content = await file.readAsString(); final content = await file.readAsString();
final jsonData = jsonDecode(content) as Map<String, dynamic>; final jsonData = jsonDecode(content) as Map<String, dynamic>;
final RiverInSituSamplingData dataToResubmit = RiverInSituSamplingData.fromJson(jsonData); final RiverInSituSamplingData dataToResubmit = RiverInSituSamplingData.fromJson(jsonData);
final String logDirectoryPath = p.dirname(logFilePath);
final result = await _riverInSituService!.submitData( final result = await _riverInSituService!.submitData(
data: dataToResubmit, data: dataToResubmit,
@ -204,61 +253,292 @@ class RetryService {
logDirectory: logDirectoryPath, logDirectory: logDirectoryPath,
); );
success = result['success']; success = result['success'];
// --- END: ADDED LOGIC FOR RIVER IN-SITU SUBMISSION ---
} else if (task['type'] == 'api') { // *** ADDED: Handler for river_investigative_submission ***
} else if (taskType == 'river_investigative_submission') {
debugPrint("Retrying complex task 'river_investigative_submission' with ID $taskId.");
if (_riverInvestigativeService == null) {
debugPrint("Retry failed: RiverInvestigativeSamplingService not initialized.");
return false;
}
final String jsonFilePath = payload['localLogPath']; // Path to the JSON file
final file = File(jsonFilePath);
if (!await file.exists()) {
debugPrint("Retry failed: Source log file no longer exists at $jsonFilePath");
await _dbHelper.deleteRequestFromQueue(taskId);
return false;
}
final String logDirectoryPath = p.dirname(jsonFilePath); // Get directory from file path
final content = await file.readAsString();
final jsonData = jsonDecode(content) as Map<String, dynamic>;
// Use the correct Investigative data model
final RiverInvesManualSamplingData dataToResubmit = RiverInvesManualSamplingData.fromJson(jsonData);
// Call the submitData method from the Investigative service
final result = await _riverInvestigativeService!.submitData(
data: dataToResubmit,
appSettings: _authProvider!.appSettings,
authProvider: _authProvider!,
logDirectory: logDirectoryPath,
);
success = result['success'];
// *** END ADDED ***
} else if (taskType == 'investigative_submission') {
debugPrint("Retrying complex task 'investigative_submission' with ID $taskId.");
if (_marineInvestigativeService == null) {
debugPrint("Retry failed: MarineInvestigativeSamplingService not initialized.");
return false;
}
final String logDirectoryPath = payload['localLogPath']; // Path to the directory
final jsonFilePath = p.join(logDirectoryPath, 'data.json');
final file = File(jsonFilePath);
if (!await file.exists()) {
debugPrint("Retry failed: Source log file no longer exists at $jsonFilePath");
await _dbHelper.deleteRequestFromQueue(taskId);
return false;
}
final content = await file.readAsString();
final jsonData = jsonDecode(content) as Map<String, dynamic>;
final MarineInvesManualSamplingData dataToResubmit = MarineInvesManualSamplingData.fromJson(jsonData);
final result = await _marineInvestigativeService!.submitInvestigativeSample(
data: dataToResubmit,
appSettings: _authProvider!.appSettings,
authProvider: _authProvider!,
logDirectory: logDirectoryPath,
);
success = result['success'];
} else if (taskType == 'tarball_submission') {
debugPrint("Retrying complex task 'tarball_submission' with ID $taskId.");
if (_marineTarballService == null) {
debugPrint("Retry failed: MarineTarballSamplingService not initialized.");
return false;
}
final String logDirectoryPath = payload['localLogPath']; // Path to the directory
final jsonFilePath = p.join(logDirectoryPath, 'data.json');
final file = File(jsonFilePath);
if (!await file.exists()) {
debugPrint("Retry failed: Source log file no longer exists at $jsonFilePath");
await _dbHelper.deleteRequestFromQueue(taskId);
return false;
}
final content = await file.readAsString();
final jsonData = jsonDecode(content) as Map<String, dynamic>;
// Recreate File objects from paths
File? fileFromJson(dynamic path) => (path is String && path.isNotEmpty) ? File(path) : null;
final TarballSamplingData dataToResubmit = TarballSamplingData()
// Reconstruct the object from JSON data
..firstSampler = jsonData['firstSampler']
..firstSamplerUserId = jsonData['firstSamplerUserId']
..secondSampler = jsonData['secondSampler']
..samplingDate = jsonData['samplingDate']
..samplingTime = jsonData['samplingTime']
..selectedStateName = jsonData['selectedStateName']
..selectedCategoryName = jsonData['selectedCategoryName']
..selectedStation = jsonData['selectedStation']
..stationLatitude = jsonData['stationLatitude']
..stationLongitude = jsonData['stationLongitude']
..currentLatitude = jsonData['currentLatitude']
..currentLongitude = jsonData['currentLongitude']
..distanceDifference = jsonData['distanceDifference'] is num ? (jsonData['distanceDifference'] as num).toDouble() : null // Safe cast
..distanceDifferenceRemarks = jsonData['distanceDifferenceRemarks']
..classificationId = jsonData['classificationId'] is num ? (jsonData['classificationId'] as num).toInt() : null // Safe cast
..selectedClassification = jsonData['selectedClassification']
..leftCoastalViewImage = fileFromJson(jsonData['leftCoastalViewImage'])
..rightCoastalViewImage = fileFromJson(jsonData['rightCoastalViewImage'])
..verticalLinesImage = fileFromJson(jsonData['verticalLinesImage'])
..horizontalLineImage = fileFromJson(jsonData['horizontalLineImage'])
..optionalImage1 = fileFromJson(jsonData['optionalImage1'])
..optionalRemark1 = jsonData['optionalRemark1']
..optionalImage2 = fileFromJson(jsonData['optionalImage2'])
..optionalRemark2 = jsonData['optionalRemark2']
..optionalImage3 = fileFromJson(jsonData['optionalImage3'])
..optionalRemark3 = jsonData['optionalRemark3']
..optionalImage4 = fileFromJson(jsonData['optionalImage4'])
..optionalRemark4 = jsonData['optionalRemark4']
..reportId = jsonData['reportId'] // Preserve reportId if it exists
..submissionStatus = jsonData['submissionStatus'] // Preserve status info
..submissionMessage = jsonData['submissionMessage'];
debugPrint("Retrying Tarball submission...");
// Pass null for BuildContext, and the logDirectory path
final result = await _marineTarballService!.submitTarballSample(
data: dataToResubmit,
appSettings: _authProvider!.appSettings,
context: null, // Pass null for BuildContext during retry
logDirectory: logDirectoryPath, // Pass the directory path for potential update
);
success = result['success'];
// --- Simple Task Handlers ---
} else if (taskType == 'api') {
final endpoint = task['endpoint_or_path'] as String; final endpoint = task['endpoint_or_path'] as String;
final method = payload['method'] as String; final method = payload['method'] as String;
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl(); // Get current active URL
debugPrint("Retrying API task $taskId: $method to $baseUrl/$endpoint"); debugPrint("Retrying API task $taskId: $method to $baseUrl/$endpoint");
Map<String, dynamic> result; Map<String, dynamic> result;
if (method == 'POST_MULTIPART') { if (method == 'POST_MULTIPART') {
final Map<String, String> fields = Map<String, String>.from(payload['fields'] ?? {}); final Map<String, String> fields = Map<String, String>.from(payload['fields'] ?? {});
// Recreate File objects from paths stored in the payload
final Map<String, File> files = (payload['files'] as Map<String, dynamic>?) final Map<String, File> files = (payload['files'] as Map<String, dynamic>?)
?.map((key, value) => MapEntry(key, File(value as String))) ?? {}; ?.map((key, value) => MapEntry(key, File(value as String))) ?? {};
// Check if files still exist before attempting upload
bool allFilesExist = true;
List<String> missingFiles = []; // Keep track of missing files
for (var entry in files.entries) {
File file = entry.value;
if (!await file.exists()) {
debugPrint("Retry failed for API task $taskId: File ${file.path} (key: ${entry.key}) no longer exists.");
allFilesExist = false;
missingFiles.add(entry.key);
// break; // Stop checking further if one is missing
}
}
// If some files are missing, fail the entire task.
if (!allFilesExist) {
debugPrint("API Multipart retry failed for task $taskId because files are missing: ${missingFiles.join(', ')}. Removing task.");
await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task
return false;
}
result = await _baseApiService.postMultipart(baseUrl: baseUrl, endpoint: endpoint, fields: fields, files: files); result = await _baseApiService.postMultipart(baseUrl: baseUrl, endpoint: endpoint, fields: fields, files: files);
} else { } else { // Standard POST
final Map<String, dynamic> body = Map<String, dynamic>.from(payload['body'] ?? {}); final Map<String, dynamic> body = Map<String, dynamic>.from(payload['body'] ?? {});
result = await _baseApiService.post(baseUrl, endpoint, body); result = await _baseApiService.post(baseUrl, endpoint, body);
} }
success = result['success']; success = result['success'];
} else if (task['type'] == 'ftp') { } else if (taskType == 'ftp') {
final remotePath = task['endpoint_or_path'] as String; final remotePath = task['endpoint_or_path'] as String;
final localFile = File(payload['localFilePath'] as String); final localFile = File(payload['localFilePath'] as String);
debugPrint("Retrying FTP task $taskId: Uploading ${localFile.path} to $remotePath"); final int? ftpConfigId = payload['ftpConfigId'] as int?;
debugPrint("Retrying FTP task $taskId: Uploading ${localFile.path} to $remotePath using config ID $ftpConfigId");
if (ftpConfigId == null) {
debugPrint("Retry failed for FTP task $taskId: Missing FTP configuration ID in payload.");
await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task
return false;
}
if (await localFile.exists()) { if (await localFile.exists()) {
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? []; final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
if (ftpConfigs.isEmpty) { final config = ftpConfigs.firstWhere((c) => c['ftp_config_id'] == ftpConfigId, orElse: () => <String, dynamic>{}); // Use explicit type
debugPrint("Retry failed for FTP task $taskId: No FTP configurations found.");
return false;
} if (config.isEmpty) {
for (final config in ftpConfigs) { debugPrint("Retry failed for FTP task $taskId: FTP configuration with ID $ftpConfigId not found.");
final result = await _ftpService.uploadFile(config: config, fileToUpload: localFile, remotePath: remotePath); return false; // Fail the retry attempt, keep in queue
if (result['success']) {
success = true;
break;
}
} }
// Attempt upload using the specific config
final result = await _ftpService.uploadFile(config: config, fileToUpload: localFile, remotePath: remotePath);
success = result['success'];
} else { } else {
debugPrint("Retry failed for FTP task $taskId: Source file no longer exists at ${localFile.path}"); debugPrint("Retry failed for FTP task $taskId: Source file no longer exists at ${localFile.path}");
success = false; await _dbHelper.deleteRequestFromQueue(taskId); // Remove task if file is gone
return false; // Explicitly return false as success is false
} }
} else {
debugPrint("Unknown task type '$taskType' for task ID $taskId. Cannot retry. Removing task.");
await _dbHelper.deleteRequestFromQueue(taskId);
} }
} catch (e) {
debugPrint("A critical error occurred while retrying task $taskId: $e"); } on SessionExpiredException catch (e) {
success = false; debugPrint("Session expired during retry attempt for task $taskId (Type: $taskType): $e. Task remains in queue.");
success = false; // Session expiry during retry means failure for this attempt
} catch (e, stacktrace) { // Catch potential exceptions during processing
debugPrint("A critical error occurred while retrying task $taskId (Type: $taskType): $e");
debugPrint("Stacktrace: $stacktrace"); // Log stacktrace for detailed debugging
success = false; // Ensure success is false on exception
} }
// Post-processing: Remove successful tasks from queue
if (success) { if (success) {
debugPrint("Task $taskId completed successfully. Removing from queue."); debugPrint("Task $taskId (Type: $taskType) completed successfully. Removing from queue.");
await _dbHelper.deleteRequestFromQueue(taskId); await _dbHelper.deleteRequestFromQueue(taskId);
// If it was a complex task involving temporary ZIP files, attempt to delete them
if (taskType.endsWith('_submission') && payload['localLogPath'] != null) {
// Assume localLogPath points to the JSON file, get directory for cleanup
String pathToCheck = payload['localLogPath'];
// Check if it's a directory path already (for older marine insitu logs)
bool isDirectory = await Directory(pathToCheck).exists();
if (!isDirectory && pathToCheck.endsWith('.json')) {
pathToCheck = p.dirname(pathToCheck); // Get directory if it's a file path
isDirectory = true; // Now we are checking the directory
}
_cleanUpTemporaryZipFiles(pathToCheck, isDirectory: isDirectory);
}
// If it was an FTP task, attempt to delete the temporary ZIP file
if (taskType == 'ftp' && payload['localFilePath'] != null && (payload['localFilePath'] as String).endsWith('.zip')) {
_cleanUpTemporaryZipFiles(payload['localFilePath'], isDirectory: false);
}
} else { } else {
debugPrint("Retry attempt for task $taskId failed. It will remain in the queue."); debugPrint("Retry attempt for task $taskId (Type: $taskType) failed. It will remain in the queue.");
// Optional: Implement a retry limit here. If retries > X, mark task as 'failed' instead of 'pending'.
// e.g., await _dbHelper.updateTaskStatus(taskId, 'failed');
} }
return success; return success;
} }
}
/// Helper function to delete temporary zip files after successful retry.
void _cleanUpTemporaryZipFiles(String path, {required bool isDirectory}) async {
try {
if (isDirectory) {
final dir = Directory(path);
if (await dir.exists()) {
final filesInDir = dir.listSync();
for (var entity in filesInDir) {
// Delete only ZIP files within the log directory
if (entity is File && entity.path.endsWith('.zip')) {
debugPrint("Deleting temporary zip file from directory: ${entity.path}");
await entity.delete();
}
}
// Optional: Delete the directory itself if now empty, ONLY if safe.
// Be cautious as data.json might still be needed or other files exist.
// if (await dir.listSync().isEmpty) {
// await dir.delete();
// debugPrint("Deleted empty log directory: ${dir.path}");
// }
} else {
debugPrint("Log directory not found for cleanup: $path");
}
} else {
// If it's a specific file path (like from FTP task)
final file = File(path);
if (await file.exists() && path.endsWith('.zip')) { // Ensure it's a zip file
debugPrint("Deleting temporary zip file: ${file.path}");
await file.delete();
} else if (!path.endsWith('.zip')) {
debugPrint("Skipping cleanup for non-zip file path: $path");
} else {
debugPrint("Temporary zip file not found for cleanup: $path");
}
}
} catch (e) {
debugPrint("Error cleaning up temporary zip file(s) for path $path: $e");
}
}
} // End of RetryService class

View File

@ -169,15 +169,20 @@ class RiverInSituSamplingService {
const String moduleName = 'river_in_situ'; const String moduleName = 'river_in_situ';
final connectivityResult = await Connectivity().checkConnectivity(); final connectivityResult = await Connectivity().checkConnectivity();
bool isOnline = connectivityResult != ConnectivityResult.none; bool isOnline = !connectivityResult.contains(ConnectivityResult.none);
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false); bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
if (isOnline && isOfflineSession) { if (isOnline && isOfflineSession) {
debugPrint("River In-Situ submission online during offline session. Attempting auto-relogin..."); debugPrint("River In-Situ submission online during offline session. Attempting auto-relogin...");
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession(); try {
if (transitionSuccess) { final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
isOfflineSession = false; if (transitionSuccess) {
} else { isOfflineSession = false;
} else {
isOnline = false; // Auto-relogin failed, treat as offline
}
} on SessionExpiredException catch (_) {
debugPrint("Session expired during auto-relogin check. Treating as offline.");
isOnline = false; isOnline = false;
} }
} }
@ -196,6 +201,7 @@ class RiverInSituSamplingService {
return await _performOfflineQueuing( return await _performOfflineQueuing(
data: data, data: data,
moduleName: moduleName, moduleName: moduleName,
logDirectory: logDirectory, // Pass for potential update
); );
} }
} }
@ -215,25 +221,29 @@ class RiverInSituSamplingService {
bool anyApiSuccess = false; bool anyApiSuccess = false;
Map<String, dynamic> apiDataResult = {}; Map<String, dynamic> apiDataResult = {};
Map<String, dynamic> apiImageResult = {}; Map<String, dynamic> apiImageResult = {};
String finalMessage = '';
String finalStatus = '';
bool isSessionKnownToBeExpired = false; bool isSessionKnownToBeExpired = false;
try { try {
// 1. Submit Form Data
apiDataResult = await _submissionApiService.submitPost( apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName, moduleName: moduleName,
endpoint: 'river/manual/sample', endpoint: 'river/manual/sample', // Correct endpoint
body: data.toApiFormData(), body: data.toApiFormData(),
); );
if (apiDataResult['success'] == true) { if (apiDataResult['success'] == true) {
anyApiSuccess = true; anyApiSuccess = true;
data.reportId = apiDataResult['data']?['r_man_id']?.toString(); data.reportId = apiDataResult['data']?['r_man_id']?.toString(); // Correct ID key
if (data.reportId != null) { if (data.reportId != null) {
if (finalImageFiles.isNotEmpty) { if (finalImageFiles.isNotEmpty) {
// 2. Submit Images
apiImageResult = await _submissionApiService.submitMultipart( apiImageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName, moduleName: moduleName,
endpoint: 'river/manual/images', endpoint: 'river/manual/images', // Correct endpoint
fields: {'r_man_id': data.reportId!}, fields: {'r_man_id': data.reportId!}, // Correct field key
files: finalImageFiles, files: finalImageFiles,
); );
if (apiImageResult['success'] != true) { if (apiImageResult['success'] != true) {
@ -245,65 +255,98 @@ class RiverInSituSamplingService {
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.'; apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
} }
} }
} on SessionExpiredException catch (_) { // If apiDataResult['success'] is false, SubmissionApiService queued it.
debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin...");
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
if (reloginSuccess) { } on SessionExpiredException catch (_) {
debugPrint("Silent relogin successful. Retrying entire online submission process..."); debugPrint("Online submission failed due to session expiry that could not be refreshed.");
return await _performOnlineSubmission( isSessionKnownToBeExpired = true;
data: data,
appSettings: appSettings,
moduleName: moduleName,
authProvider: authProvider,
logDirectory: logDirectory,
);
} else {
debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP.");
isSessionKnownToBeExpired = true;
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
await _retryService.addApiToQueue(endpoint: 'river/manual/sample', method: 'POST', body: data.toApiFormData());
}
} on SocketException catch (e) {
final errorMessage = "API submission failed with network error: $e";
debugPrint(errorMessage);
anyApiSuccess = false; anyApiSuccess = false;
apiDataResult = {'success': false, 'message': errorMessage}; apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
// Manually queue API calls
await _retryService.addApiToQueue(endpoint: 'river/manual/sample', method: 'POST', body: data.toApiFormData()); await _retryService.addApiToQueue(endpoint: 'river/manual/sample', method: 'POST', body: data.toApiFormData());
if (finalImageFiles.isNotEmpty && data.reportId != null) { if (finalImageFiles.isNotEmpty && data.reportId != null) {
// Also queue images if data call might have partially succeeded before expiry
await _retryService.addApiToQueue(endpoint: 'river/manual/images', method: 'POST_MULTIPART', fields: {'r_man_id': data.reportId!}, files: finalImageFiles); 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());
} }
// 3. Submit FTP Files
Map<String, dynamic> ftpResults = {'statuses': []}; Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false; bool anyFtpSuccess = false;
try {
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName); if (isSessionKnownToBeExpired) {
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured'); debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
} on SocketException catch (e) { final baseFileNameForQueue = _generateBaseFileName(data); // Use helper
debugPrint("FTP submission failed with network error: $e");
anyFtpSuccess = false; // --- START FIX: Add ftpConfigId when queuing ---
} on TimeoutException catch (e) { // Get all potential FTP configs
debugPrint("FTP submission timed out: $e"); final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
final dataZip = await _zippingService.createDataZip(
jsonDataMap: { // Use specific JSON structures for River In-Situ FTP
'db.json': data.toDbJson(),
'river_insitu_basic_form.json': data.toBasicFormJson(),
'river_sampling_reading.json': data.toReadingJson(),
'river_manual_info.json': data.toManualInfoJson(),
},
baseFileName: baseFileNameForQueue,
destinationDir: null,
);
if (dataZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) {
final configId = config['ftp_config_id'];
if (configId != null) {
await _retryService.addFtpToQueue(
localFilePath: dataZip.path,
remotePath: '/${p.basename(dataZip.path)}',
ftpConfigId: configId // Provide the specific config ID
);
}
}
}
if (finalImageFiles.isNotEmpty) {
final imageZip = await _zippingService.createImageZip(
imageFiles: finalImageFiles.values.toList(),
baseFileName: baseFileNameForQueue,
destinationDir: null,
);
if (imageZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) {
final configId = config['ftp_config_id'];
if (configId != null) {
await _retryService.addFtpToQueue(
localFilePath: imageZip.path,
remotePath: '/${p.basename(imageZip.path)}',
ftpConfigId: configId // Provide the specific config ID
);
}
}
}
}
// --- END FIX ---
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
anyFtpSuccess = false; anyFtpSuccess = false;
} else {
try {
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
} catch (e) {
debugPrint("Unexpected FTP submission error: $e");
anyFtpSuccess = false;
}
} }
// 4. Determine Final Status
final bool overallSuccess = anyApiSuccess || anyFtpSuccess; final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
String finalMessage;
String finalStatus;
if (anyApiSuccess && anyFtpSuccess) { if (anyApiSuccess && anyFtpSuccess) {
finalMessage = 'Data submitted successfully to all destinations.'; finalMessage = 'Data submitted successfully to all destinations.';
finalStatus = 'S4'; finalStatus = 'S4';
} else if (anyApiSuccess && !anyFtpSuccess) { } else if (anyApiSuccess && !anyFtpSuccess) {
finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.'; finalMessage = 'Data sent to API, but some FTP uploads failed or were queued.';
finalStatus = 'S3'; finalStatus = 'S3';
} else if (!anyApiSuccess && anyFtpSuccess) { } else if (!anyApiSuccess && anyFtpSuccess) {
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.'; finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
@ -313,6 +356,7 @@ class RiverInSituSamplingService {
finalStatus = 'L1'; finalStatus = 'L1';
} }
// 5. Log Locally
await _logAndSave( await _logAndSave(
data: data, data: data,
status: finalStatus, status: finalStatus,
@ -323,10 +367,12 @@ class RiverInSituSamplingService {
logDirectory: logDirectory, logDirectory: logDirectory,
); );
// 6. Send Alert
if (overallSuccess) { if (overallSuccess) {
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired); _handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
} }
// Return consistent format
return { return {
'status': finalStatus, 'status': finalStatus,
'success': overallSuccess, 'success': overallSuccess,
@ -335,9 +381,12 @@ class RiverInSituSamplingService {
}; };
} }
/// Handles queuing the submission data when the device is offline.
Future<Map<String, dynamic>> _performOfflineQueuing({ Future<Map<String, dynamic>> _performOfflineQueuing({
required RiverInSituSamplingData data, required RiverInSituSamplingData data,
required String moduleName, required String moduleName,
String? logDirectory, // Added for potential update
}) async { }) async {
final serverConfig = await _serverConfigService.getActiveApiConfig(); final serverConfig = await _serverConfigService.getActiveApiConfig();
final serverName = serverConfig?['config_name'] as String? ?? 'Default'; final serverName = serverConfig?['config_name'] as String? ?? 'Default';
@ -345,45 +394,66 @@ class RiverInSituSamplingService {
data.submissionStatus = 'L1'; data.submissionStatus = 'L1';
data.submissionMessage = 'Submission queued for later retry.'; data.submissionMessage = 'Submission queued for later retry.';
final String? localLogPath = await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName); String? savedLogPath = logDirectory; // Use existing path if provided
if (localLogPath == null) { // Save/Update local log first
if (savedLogPath != null && savedLogPath.isNotEmpty) {
await _localStorageService.updateRiverInSituLog(data.toMap()..['logDirectory'] = savedLogPath);
debugPrint("Updated existing River In-Situ log for queuing: $savedLogPath");
} else {
savedLogPath = await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName);
debugPrint("Saved new River In-Situ log for queuing: $savedLogPath");
}
if (savedLogPath == null) {
const message = "Failed to save submission to local device storage."; const message = "Failed to save submission to local device storage.";
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName); await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, logDirectory: logDirectory);
return {'status': 'Error', 'success': false, 'message': message}; return {'status': 'Error', 'success': false, 'message': message};
} }
await _retryService.queueTask( await _retryService.queueTask(
type: 'river_insitu_submission', type: 'river_insitu_submission', // Correct type
payload: { payload: {
'module': moduleName, 'module': moduleName,
'localLogPath': p.join(localLogPath, 'data.json'), 'localLogPath': p.join(savedLogPath, 'data.json'), // Point to the json file
'serverConfig': serverConfig, 'serverConfig': serverConfig,
}, },
); );
const successMessage = "Submission failed to send and has been queued for later retry."; const successMessage = "Device offline. Submission has been saved locally and queued for automatic retry when connection is restored.";
return {'status': 'Queued', 'success': true, 'message': successMessage}; // Log final queued state to central DB
// await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, logDirectory: savedLogPath);
return {'status': 'Queued', 'success': true, 'message': successMessage, 'reportId': null};
} }
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(RiverInSituSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async { /// Helper to generate the base filename for ZIP files.
String _generateBaseFileName(RiverInSituSamplingData data) {
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN'; final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN';
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
final baseFileName = "${stationCode}_$fileTimestamp"; return "${stationCode}_$fileTimestamp";
}
final Directory? logDirectory = await _localStorageService.getLogDirectory( /// Generates data and image ZIP files and uploads them using SubmissionFtpService.
serverName: serverName, Future<Map<String, dynamic>> _generateAndUploadFtpFiles(RiverInSituSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
module: 'river', final baseFileName = _generateBaseFileName(data);
subModule: 'river_in_situ_sampling',
);
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null; final Directory? logDirectory = await _localStorageService.getRiverInSituBaseDir(data.samplingType, serverName: serverName); // Use correct base dir getter
final folderName = data.reportId ?? baseFileName;
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) { if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
await localSubmissionDir.create(recursive: true); await localSubmissionDir.create(recursive: true);
} }
// Create and upload data ZIP (with multiple JSON files specific to River In-Situ)
final dataZip = await _zippingService.createDataZip( final dataZip = await _zippingService.createDataZip(
jsonDataMap: {'db.json': jsonEncode(data.toApiFormData())}, jsonDataMap: {
'db.json': data.toDbJson(),
'river_insitu_basic_form.json': data.toBasicFormJson(),
'river_sampling_reading.json': data.toReadingJson(),
'river_manual_info.json': data.toManualInfoJson(),
},
baseFileName: baseFileName, baseFileName: baseFileName,
destinationDir: localSubmissionDir, destinationDir: localSubmissionDir,
); );
@ -393,6 +463,7 @@ class RiverInSituSamplingService {
moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${p.basename(dataZip.path)}'); moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${p.basename(dataZip.path)}');
} }
// Create and upload image ZIP
final imageZip = await _zippingService.createImageZip( final imageZip = await _zippingService.createImageZip(
imageFiles: imageFiles.values.toList(), imageFiles: imageFiles.values.toList(),
baseFileName: baseFileName, baseFileName: baseFileName,
@ -406,12 +477,13 @@ class RiverInSituSamplingService {
return { return {
'statuses': <Map<String, dynamic>>[ 'statuses': <Map<String, dynamic>>[
...(ftpDataResult['statuses'] as List), ...(ftpDataResult['statuses'] as List? ?? []), // Use null-aware spread
...(ftpImageResult['statuses'] as List), ...(ftpImageResult['statuses'] as List? ?? []), // Use null-aware spread
], ],
}; };
} }
/// Saves or updates the local log file and saves a record to the central DB log.
Future<void> _logAndSave({ Future<void> _logAndSave({
required RiverInSituSamplingData data, required RiverInSituSamplingData data,
required String status, required String status,
@ -423,47 +495,64 @@ class RiverInSituSamplingService {
}) async { }) async {
data.submissionStatus = status; data.submissionStatus = status;
data.submissionMessage = message; data.submissionMessage = message;
final baseFileName = _generateBaseFileName(data); // Use helper
if (logDirectory != null) { // Prepare log data map using toMap()
final Map<String, dynamic> updatedLogData = data.toMap(); final Map<String, dynamic> logMapData = data.toMap();
updatedLogData['logDirectory'] = logDirectory; // Add submission metadata
updatedLogData['serverConfigName'] = serverName; logMapData['submissionStatus'] = status;
updatedLogData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList()); logMapData['submissionMessage'] = message;
updatedLogData['ftp_status'] = jsonEncode(ftpStatuses); logMapData['reportId'] = data.reportId;
logMapData['serverConfigName'] = serverName;
logMapData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
logMapData['ftp_status'] = jsonEncode(ftpStatuses);
final imageFilePaths = data.toApiImageFiles(); if (logDirectory != null && logDirectory.isNotEmpty) {
imageFilePaths.forEach((key, file) { // Update existing log
if (file != null) { logMapData['logDirectory'] = logDirectory; // Ensure logDirectory path is in the map
updatedLogData[key] = file.path; await _localStorageService.updateRiverInSituLog(logMapData);
}
});
await _localStorageService.updateRiverInSituLog(updatedLogData);
} else { } else {
// Save new log
await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName); await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName);
} }
// Save to central DB log
final imagePaths = data.toApiImageFiles().values.whereType<File>().map((f) => f.path).toList(); final imagePaths = data.toApiImageFiles().values.whereType<File>().map((f) => f.path).toList();
final logData = { final centralLogData = {
'submission_id': data.reportId ?? DateTime.now().millisecondsSinceEpoch.toString(), 'submission_id': data.reportId ?? baseFileName, // Use helper result
'module': 'river', 'type': data.samplingType ?? 'In-Situ', 'status': status, 'module': 'river',
'message': message, 'report_id': data.reportId, 'created_at': DateTime.now().toIso8601String(), 'type': data.samplingType ?? 'In-Situ', // Correct type
'form_data': jsonEncode(data.toMap()), 'image_data': jsonEncode(imagePaths), 'status': status,
'server_name': serverName, 'api_status': jsonEncode(apiResults), 'ftp_status': jsonEncode(ftpStatuses), 'message': message,
'report_id': data.reportId,
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(logMapData), // Log the comprehensive map
'image_data': jsonEncode(imagePaths),
'server_name': serverName,
'api_status': jsonEncode(apiResults),
'ftp_status': jsonEncode(ftpStatuses),
}; };
await _dbHelper.saveSubmissionLog(logData); try {
await _dbHelper.saveSubmissionLog(centralLogData);
} catch (e) {
debugPrint("Error saving River In-Situ submission log to DB: $e");
}
} }
/// Handles sending or queuing the Telegram alert for River In-Situ submissions.
Future<void> _handleSuccessAlert(RiverInSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async { Future<void> _handleSuccessAlert(RiverInSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
try { try {
final message = await _generateInSituAlertMessage(data, isDataOnly: isDataOnly); final message = await _generateInSituAlertMessage(data, isDataOnly: isDataOnly); // Call local helper
final alertKey = 'river_in_situ'; // Correct key
if (isSessionExpired) { if (isSessionExpired) {
debugPrint("Session is expired; queuing Telegram alert directly."); debugPrint("Session is expired; queuing Telegram alert directly for $alertKey.");
await _telegramService.queueMessage('river_in_situ', message, appSettings); await _telegramService.queueMessage(alertKey, message, appSettings);
} else { } else {
final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings); final bool wasSent = await _telegramService.sendAlertImmediately(alertKey, message, appSettings);
if (!wasSent) { if (!wasSent) {
await _telegramService.queueMessage('river_in_situ', message, appSettings); await _telegramService.queueMessage(alertKey, message, appSettings);
} }
} }
} catch (e) { } catch (e) {
@ -471,6 +560,7 @@ class RiverInSituSamplingService {
} }
} }
/// Generates the specific Telegram alert message content for River In-Situ.
Future<String> _generateInSituAlertMessage(RiverInSituSamplingData data, {required bool isDataOnly}) async { Future<String> _generateInSituAlertMessage(RiverInSituSamplingData data, {required bool isDataOnly}) async {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = data.selectedStation?['sampling_river'] ?? 'N/A'; final stationName = data.selectedStation?['sampling_river'] ?? 'N/A';
@ -491,7 +581,7 @@ class RiverInSituSamplingService {
..writeln('*Sonde ID:* $sondeID') ..writeln('*Sonde ID:* $sondeID')
..writeln('*Status of Submission:* Successful'); ..writeln('*Status of Submission:* Successful');
if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { if (distanceKm * 1000 > 50 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { // Check if distance > 50m
buffer buffer
..writeln() ..writeln()
..writeln('🔔 *Distance Alert:*') ..writeln('🔔 *Distance Alert:*')
@ -500,6 +590,79 @@ class RiverInSituSamplingService {
buffer.writeln('*Remarks for distance:* $distanceRemarks'); buffer.writeln('*Remarks for distance:* $distanceRemarks');
} }
} }
// Add parameter limit check section if needed
final outOfBoundsAlert = await _getOutOfBoundsAlertSection(data);
if (outOfBoundsAlert.isNotEmpty) {
buffer.write(outOfBoundsAlert);
}
return buffer.toString();
}
/// Helper to generate the parameter limit alert section for Telegram.
Future<String> _getOutOfBoundsAlertSection(RiverInSituSamplingData data) async {
// Define mapping from data model keys to parameter names used in limits table
const Map<String, String> _parameterKeyToLimitName = {
'oxygenConcentration': 'Oxygen Conc', 'oxygenSaturation': 'Oxygen Sat', 'ph': 'pH',
'salinity': 'Salinity', 'electricalConductivity': 'Conductivity', 'temperature': 'Temperature',
'tds': 'TDS', 'turbidity': 'Turbidity', 'ammonia': 'Ammonia', 'batteryVoltage': 'Battery',
};
final allLimits = await _dbHelper.loadRiverParameterLimits() ?? []; // Load river limits
if (allLimits.isEmpty) return "";
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,
'ammonia': data.ammonia, 'batteryVoltage': data.batteryVoltage,
};
final List<String> outOfBoundsMessages = [];
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;
// Find the limit data for this parameter (river limits are not station-specific in the current DB structure)
final limitData = allLimits.firstWhere(
(l) => l['param_parameter_list'] == limitName,
orElse: () => <String, dynamic>{}, // Use explicit type
);
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)) {
final valueStr = value.toStringAsFixed(5);
final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A';
final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/A';
outOfBoundsMessages.add('- *$limitName*: `$valueStr` (Limit: `$lowerStr` - `$upperStr`)');
}
}
});
if (outOfBoundsMessages.isEmpty) {
return "";
}
final buffer = StringBuffer()
..writeln()
..writeln('⚠️ *Parameter Limit Alert:*')
..writeln('The following parameters were outside their defined limits:');
buffer.writeAll(outOfBoundsMessages, '\n');
return buffer.toString(); return buffer.toString();
} }
} }

View File

@ -0,0 +1,774 @@
// lib/services/river_investigative_sampling_service.dart
import 'dart:async';
import 'dart:io';
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 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:intl/intl.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:provider/provider.dart'; // Keep provider import if needed internally, though less common in services
import '../auth_provider.dart';
import 'location_service.dart';
import '../models/river_inves_manual_sampling_data.dart'; // Use Investigative model
import '../bluetooth/bluetooth_manager.dart';
import '../serial/serial_manager.dart';
import 'api_service.dart'; // Keep ApiService import for DatabaseHelper access within service if needed, or remove if unused directly
import 'local_storage_service.dart';
import 'server_config_service.dart';
import 'zipping_service.dart';
import 'submission_api_service.dart';
import 'submission_ftp_service.dart';
import 'telegram_service.dart';
import 'retry_service.dart';
import 'base_api_service.dart'; // Import for SessionExpiredException
class RiverInvestigativeSamplingService { // Renamed class
final LocationService _locationService = LocationService();
final BluetoothManager _bluetoothManager = BluetoothManager();
final SerialManager _serialManager = SerialManager();
final SubmissionApiService _submissionApiService = SubmissionApiService();
final SubmissionFtpService _submissionFtpService = SubmissionFtpService();
final DatabaseHelper _dbHelper = DatabaseHelper();
final LocalStorageService _localStorageService = LocalStorageService();
final ServerConfigService _serverConfigService = ServerConfigService();
final ZippingService _zippingService = ZippingService();
final RetryService _retryService = RetryService();
final TelegramService _telegramService;
final ImagePicker _picker = ImagePicker();
static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
RiverInvestigativeSamplingService(this._telegramService); // Constructor remains similar
Future<Position> getCurrentLocation() => _locationService.getCurrentLocation();
double calculateDistance(double lat1, double lon1, double lat2, double lon2) => _locationService.calculateDistance(lat1, lon1, lat2, lon2);
// Adapted image processing for Investigative data
Future<File?> pickAndProcessImage(ImageSource source, { required RiverInvesManualSamplingData data, required String imageInfo, bool isRequired = false, String? stationCode}) async { // Updated model type
try {
final XFile? pickedFile = await _picker.pickImage(
source: source,
imageQuality: 85, // Keep quality settings
maxWidth: 1024, // Keep resolution settings
);
if (pickedFile == null) {
return null;
}
final bytes = await pickedFile.readAsBytes();
img.Image? originalImage = img.decodeImage(bytes);
if (originalImage == null) {
return null;
}
// Keep landscape requirement for required photos
if (isRequired && originalImage.height > originalImage.width) {
debugPrint("Image rejected: Must be in landscape orientation.");
return null;
}
// Watermark using investigative data
final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}";
final font = img.arial24; // Use consistent font
final textWidth = watermarkTimestamp.length * 12; // Approximate width
// Draw background rectangle for text visibility
img.fillRect(originalImage, x1: 5, y1: 5, x2: textWidth + 15, y2: 35, color: img.ColorRgb8(255, 255, 255));
// Draw timestamp string
img.drawString(originalImage, watermarkTimestamp, font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0));
final tempDir = await getTemporaryDirectory();
// Use the determined station code passed in (handles Manual/Triennial/New)
final finalStationCode = stationCode ?? 'NA';
final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-');
// Consistent filename format
final newFileName = "${finalStationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg";
final filePath = p.join(tempDir.path, newFileName);
// Encode and write the processed image
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
} catch (e) {
debugPrint('Error in pickAndProcessImage (River Investigative): $e');
return null;
}
}
// Bluetooth and Serial Management - No changes needed, uses shared managers
ValueNotifier<BluetoothConnectionState> get bluetoothConnectionState => _bluetoothManager.connectionState;
ValueNotifier<SerialConnectionState> get serialConnectionState => _serialManager.connectionState;
ValueNotifier<String?> get sondeId {
if (_bluetoothManager.connectionState.value != BluetoothConnectionState.disconnected) {
return _bluetoothManager.sondeId;
}
return _serialManager.sondeId;
}
Stream<Map<String, double>> get bluetoothDataStream => _bluetoothManager.dataStream;
Stream<Map<String, double>> get serialDataStream => _serialManager.dataStream;
String? get connectedBluetoothDeviceName => _bluetoothManager.connectedDeviceName.value;
String? get connectedSerialDeviceName => _serialManager.connectedDeviceName.value;
Future<bool> requestDevicePermissions() async {
// Permission logic remains the same
Map<Permission, PermissionStatus> statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
Permission.locationWhenInUse, // Keep location permission for GPS
].request();
if (statuses[Permission.bluetoothScan] == PermissionStatus.granted &&
statuses[Permission.bluetoothConnect] == PermissionStatus.granted &&
statuses[Permission.locationWhenInUse] == PermissionStatus.granted) { // Ensure location is granted too
return true;
} else {
debugPrint("Bluetooth Scan: ${statuses[Permission.bluetoothScan]}, Bluetooth Connect: ${statuses[Permission.bluetoothConnect]}, Location: ${statuses[Permission.locationWhenInUse]}");
return false;
}
}
Future<List<BluetoothDevice>> getPairedBluetoothDevices() => _bluetoothManager.getPairedDevices();
Future<void> connectToBluetoothDevice(BluetoothDevice device) => _bluetoothManager.connect(device);
void disconnectFromBluetooth() => _bluetoothManager.disconnect();
void startBluetoothAutoReading({Duration? interval}) => _bluetoothManager.startAutoReading(interval: interval ?? const Duration(seconds: 2));
void stopBluetoothAutoReading() => _bluetoothManager.stopAutoReading();
Future<List<UsbDevice>> getAvailableSerialDevices() => _serialManager.getAvailableDevices();
Future<bool> requestUsbPermission(UsbDevice device) async {
// USB permission logic remains the same
try {
// Ensure the platform channel name matches what's defined in your native code (Android/iOS)
return await platform.invokeMethod('requestUsbPermission', {'vid': device.vid, 'pid': device.pid}) ?? false;
} on PlatformException catch (e) {
debugPrint("Failed to request USB permission: '${e.message}'.");
return false;
}
}
Future<void> connectToSerialDevice(UsbDevice device) async {
// Serial connection logic remains the same
final bool permissionGranted = await requestUsbPermission(device);
if (permissionGranted) {
await _serialManager.connect(device);
} else {
throw Exception("USB permission was not granted.");
}
}
void disconnectFromSerial() => _serialManager.disconnect();
void startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 2));
void stopSerialAutoReading() => _serialManager.stopAutoReading();
void dispose() {
_bluetoothManager.dispose();
_serialManager.dispose();
}
// Adapted Submission Logic for Investigative
Future<Map<String, dynamic>> submitData({
required RiverInvesManualSamplingData data, // Updated model type
required List<Map<String, dynamic>>? appSettings,
required AuthProvider authProvider,
String? logDirectory,
}) async {
// *** MODIFIED: Module name changed ***
const String moduleName = 'river_investigative';
final connectivityResult = await Connectivity().checkConnectivity();
bool isOnline = !connectivityResult.contains(ConnectivityResult.none);
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
// Auto-relogin logic remains the same
if (isOnline && isOfflineSession) {
debugPrint("River Investigative submission online during offline session. Attempting auto-relogin..."); // Log context update
try {
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
if (transitionSuccess) {
isOfflineSession = false; // Successfully transitioned to online
} else {
isOnline = false; // Auto-relogin failed, treat as offline
}
} on SessionExpiredException catch (_) {
debugPrint("Session expired during auto-relogin check. Treating as offline.");
isOnline = false;
}
}
// Branch based on connectivity and session status
if (isOnline && !isOfflineSession) {
debugPrint("Proceeding with direct ONLINE River Investigative submission..."); // Log context update
return await _performOnlineSubmission(
data: data,
appSettings: appSettings,
moduleName: moduleName,
authProvider: authProvider,
logDirectory: logDirectory,
);
} else {
debugPrint("Proceeding with OFFLINE River Investigative queuing mechanism..."); // Log context update
return await _performOfflineQueuing(
data: data,
moduleName: moduleName,
logDirectory: logDirectory, // Pass for potential update
);
}
}
Future<Map<String, dynamic>> _performOnlineSubmission({
required RiverInvesManualSamplingData data, // Updated model type
required List<Map<String, dynamic>>? appSettings,
required String moduleName, // Passed in as 'river_investigative'
required AuthProvider authProvider,
String? logDirectory,
}) async {
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
// Get image files using the Investigative model's method
final imageFilesWithNulls = data.toApiImageFiles();
imageFilesWithNulls.removeWhere((key, value) => value == null); // Remove nulls
final Map<String, File> finalImageFiles = imageFilesWithNulls.cast<String, File>();
bool anyApiSuccess = false;
Map<String, dynamic> apiDataResult = {};
Map<String, dynamic> apiImageResult = {};
String finalMessage = '';
String finalStatus = '';
bool isSessionKnownToBeExpired = false;
try {
// 1. Submit Form Data (using Investigative endpoint and data)
apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName, // 'river_investigative'
// *** MODIFIED: API Endpoint ***
endpoint: 'river/investigative/sample', // Assumed endpoint for investigative data
body: data.toApiFormData(), // Use Investigative model's method
);
if (apiDataResult['success'] == true) {
anyApiSuccess = true;
// *** MODIFIED: Extract report ID using assumed key ***
data.reportId = apiDataResult['data']?['r_inv_id']?.toString(); // Assumed key for investigative ID
if (data.reportId != null) {
if (finalImageFiles.isNotEmpty) {
// 2. Submit Images (using Investigative endpoint)
apiImageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName, // 'river_investigative'
// *** MODIFIED: API Endpoint ***
endpoint: 'river/investigative/images', // Assumed endpoint for investigative images
// *** MODIFIED: Field key for ID ***
fields: {'r_inv_id': data.reportId!}, // Use assumed investigative ID key
files: finalImageFiles,
);
if (apiImageResult['success'] != true) {
// If image upload fails after data success, mark API part as failed overall for simplicity, or handle partially.
anyApiSuccess = false; // Treat as overall API failure if images fail
}
}
// If no images, data submission success is enough
} else {
// API succeeded but didn't return an ID - treat as failure
anyApiSuccess = false;
apiDataResult['success'] = false; // Mark as failed
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
}
}
// If apiDataResult['success'] is false initially, SubmissionApiService queued it.
} on SessionExpiredException catch (_) {
debugPrint("Online River Investigative submission failed due to session expiry that could not be refreshed."); // Log context update
isSessionKnownToBeExpired = true;
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
// Manually queue API calls if session expired during attempt
// *** MODIFIED: Use Investigative endpoints for queueing ***
await _retryService.addApiToQueue(endpoint: 'river/investigative/sample', method: 'POST', body: data.toApiFormData());
if (finalImageFiles.isNotEmpty && data.reportId != null) {
// Queue images only if we might have gotten an ID before expiry
await _retryService.addApiToQueue(endpoint: 'river/investigative/images', method: 'POST_MULTIPART', fields: {'r_inv_id': data.reportId!}, files: finalImageFiles);
} else if (finalImageFiles.isNotEmpty && data.reportId == null) {
// If data call failed before getting ID, queue images without ID - might need manual linking later or separate retry logic
debugPrint("Queueing investigative images without report ID due to session expiry during data submission.");
// How to handle this depends on backend capabilities or manual intervention needs.
// Option: Queue a complex task instead? For now, queueing individually.
await _retryService.addApiToQueue(endpoint: 'river/investigative/images', method: 'POST_MULTIPART', fields: {}, files: finalImageFiles); // Queue images without ID
}
}
// 3. Submit FTP Files (Logic remains similar, uses specific JSON methods)
Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false;
if (isSessionKnownToBeExpired) {
debugPrint("Skipping FTP attempt for River Investigative due to known expired session. Manually queuing FTP tasks."); // Log context update
final baseFileNameForQueue = _generateBaseFileName(data); // Use helper
// --- START FIX: Add ftpConfigId when queuing --- (Copied from In-Situ, ensure DB structure matches)
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
final dataZip = await _zippingService.createDataZip(
jsonDataMap: { // Use specific JSON structures for River Investigative FTP
'db.json': data.toDbJson(), // Use Investigative model's method
'river_inves_basic_form.json': data.toBasicFormJson(), // Use Investigative model's method
'river_inves_reading.json': data.toReadingJson(), // Use Investigative model's method
'river_inves_manual_info.json': data.toManualInfoJson(), // Use Investigative model's method
},
baseFileName: baseFileNameForQueue,
destinationDir: null, // Save to temp dir
);
if (dataZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) {
final configId = config['ftp_config_id'];
if (configId != null) {
await _retryService.addFtpToQueue(
localFilePath: dataZip.path,
remotePath: '/${p.basename(dataZip.path)}', // Standard remote path
ftpConfigId: configId // Provide the specific config ID
);
}
}
}
if (finalImageFiles.isNotEmpty) {
final imageZip = await _zippingService.createImageZip(
imageFiles: finalImageFiles.values.toList(),
baseFileName: baseFileNameForQueue,
destinationDir: null, // Save to temp dir
);
if (imageZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) {
final configId = config['ftp_config_id'];
if (configId != null) {
await _retryService.addFtpToQueue(
localFilePath: imageZip.path,
remotePath: '/${p.basename(imageZip.path)}', // Standard remote path
ftpConfigId: configId // Provide the specific config ID
);
}
}
}
}
// --- END FIX ---
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
anyFtpSuccess = false; // Mark FTP as unsuccessful for overall status determination
} else {
// Proceed with FTP attempt if session is okay
try {
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName); // Call helper
// Determine success based on statuses (excluding 'Not Configured')
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
} catch (e) {
debugPrint("Unexpected River Investigative FTP submission error: $e"); // Log context update
anyFtpSuccess = false; // Mark FTP as failed on error
ftpResults = {'statuses': [{'status': 'Error', 'message': 'FTP process failed: $e.', 'success': false}]}; // Provide error status
}
}
// 4. Determine Final Status (Logic remains the same)
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
if (anyApiSuccess && anyFtpSuccess) {
finalMessage = 'Data submitted successfully to all destinations.';
finalStatus = 'S4'; // API OK, FTP OK
} else if (anyApiSuccess && !anyFtpSuccess) {
finalMessage = 'Data sent to API, but some FTP uploads failed or were queued.';
finalStatus = 'S3'; // API OK, FTP Failed/Queued
} else if (!anyApiSuccess && anyFtpSuccess) {
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
finalStatus = 'L4'; // API Failed/Queued, FTP OK
} else { // Neither API nor FTP fully succeeded without queueing/errors
finalMessage = apiDataResult['message'] ?? 'All submission attempts failed and have been queued for retry.';
finalStatus = 'L1'; // API Failed/Queued, FTP Failed/Queued
}
// 5. Log Locally (using Investigative log method)
await _logAndSave(
data: data,
status: finalStatus,
message: finalMessage,
apiResults: [apiDataResult, apiImageResult].where((r) => r.isNotEmpty).toList(), // Filter out empty results
ftpStatuses: ftpResults['statuses'] ?? [],
serverName: serverName,
logDirectory: logDirectory,
);
// 6. Send Alert (using Investigative alert method)
if (overallSuccess) { // Send alert only if at least one part (API or FTP) succeeded without errors/queueing immediately
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
}
// Return consistent result format
return {
'status': finalStatus,
'success': overallSuccess, // Reflects if *any* part succeeded now
'message': finalMessage,
'reportId': data.reportId // May be null if API failed
};
}
/// Handles queuing the submission data when the device is offline for Investigative.
Future<Map<String, dynamic>> _performOfflineQueuing({
required RiverInvesManualSamplingData data, // Updated model type
required String moduleName, // Passed in as 'river_investigative'
String? logDirectory, // Added for potential update
}) async {
final serverConfig = await _serverConfigService.getActiveApiConfig();
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
data.submissionStatus = 'Queued'; // Tentative status, will be L1 after saving
data.submissionMessage = 'Submission queued for later retry.';
String? savedLogPath = logDirectory; // Use existing path if provided for an update
// Save/Update local log first using the specific Investigative save method
if (savedLogPath != null && savedLogPath.isNotEmpty) {
// *** MODIFIED: Use correct update method ***
await _localStorageService.updateRiverInvestigativeLog(data.toMap()..['logDirectory'] = savedLogPath); // Add path for update method
debugPrint("Updated existing River Investigative log for queuing: $savedLogPath"); // Log context update
} else {
// *** MODIFIED: Use correct save method ***
savedLogPath = await _localStorageService.saveRiverInvestigativeSamplingData(data, serverName: serverName);
debugPrint("Saved new River Investigative log for queuing: $savedLogPath"); // Log context update
}
if (savedLogPath == null) {
// If saving the log itself failed
const message = "Failed to save River Investigative submission to local device storage."; // Log context update
// Log failure to central DB log if possible
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, logDirectory: logDirectory);
return {'status': 'Error', 'success': false, 'message': message};
}
// Queue the task for the RetryService
// *** MODIFIED: Use specific task type ***
await _retryService.queueTask(
type: 'river_investigative_submission', // Specific type for retry handler
payload: {
'module': moduleName, // 'river_investigative'
'localLogPath': p.join(savedLogPath, 'data.json'), // Point to the json file within the saved directory
'serverConfig': serverConfig, // Pass current server config at time of queueing
},
);
const successMessage = "Device offline. River Investigative submission has been saved locally and queued for automatic retry when connection is restored."; // Log context update
// Update final status in the data object and potentially update log again, or just log to central DB
data.submissionStatus = 'L1'; // Final queued status
data.submissionMessage = successMessage;
// Log final queued state to central DB log
await _logAndSave(data: data, status: 'L1', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, logDirectory: savedLogPath); // Ensure log reflects final state
return {'status': 'Queued', 'success': true, 'message': successMessage, 'reportId': null};
}
/// Helper to generate the base filename for ZIP files (Investigative).
String _generateBaseFileName(RiverInvesManualSamplingData data) { // Updated model type
// Use the determined station code helper
final stationCode = data.getDeterminedStationCode() ?? 'UNKNOWN';
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
return "${stationCode}_$fileTimestamp"; // Consistent format
}
/// Generates data and image ZIP files and uploads them using SubmissionFtpService (Investigative).
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(RiverInvesManualSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async { // Updated model type
final baseFileName = _generateBaseFileName(data); // Use helper
// *** MODIFIED: Use correct base dir getter ***
final Directory? logDirectory = await _localStorageService.getRiverInvestigativeBaseDir(serverName: serverName); // NEW GETTER
// Determine the specific folder for this submission log within the base directory
final folderName = data.reportId ?? baseFileName; // Use report ID if available, else generated name
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
await localSubmissionDir.create(recursive: true); // Create if doesn't exist
}
// Create and upload data ZIP (with multiple JSON files specific to River Investigative)
final dataZip = await _zippingService.createDataZip(
jsonDataMap: {
// *** MODIFIED: Use Investigative model's JSON methods and filenames ***
'db.json': jsonEncode(data.toDbJson()), // Main data structure
'river_inves_basic_form.json': data.toBasicFormJson(),
'river_inves_reading.json': data.toReadingJson(),
'river_inves_manual_info.json': data.toManualInfoJson(),
},
baseFileName: baseFileName,
destinationDir: localSubmissionDir, // Save ZIP in the specific log folder
);
Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []}; // Default success if no file
if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit(
moduleName: moduleName, // 'river_investigative'
fileToUpload: dataZip,
remotePath: '/${p.basename(dataZip.path)}' // Standard remote path
);
}
// Create and upload image ZIP (if images exist)
Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []}; // Default success if no images
if (imageFiles.isNotEmpty) {
final imageZip = await _zippingService.createImageZip(
imageFiles: imageFiles.values.toList(),
baseFileName: baseFileName,
destinationDir: localSubmissionDir, // Save ZIP in the specific log folder
);
if (imageZip != null) {
ftpImageResult = await _submissionFtpService.submit(
moduleName: moduleName, // 'river_investigative'
fileToUpload: imageZip,
remotePath: '/${p.basename(imageZip.path)}' // Standard remote path
);
}
}
// Combine statuses from both uploads
return {
'statuses': <Map<String, dynamic>>[
...(ftpDataResult['statuses'] as List? ?? []), // Use null-aware spread
...(ftpImageResult['statuses'] as List? ?? []), // Use null-aware spread
],
};
}
/// Saves or updates the local log file and saves a record to the central DB log (Investigative).
Future<void> _logAndSave({
required RiverInvesManualSamplingData data, // Updated model type
required String status,
required String message,
required List<Map<String, dynamic>> apiResults,
required List<Map<String, dynamic>> ftpStatuses,
required String serverName,
String? logDirectory, // Can be null initially, gets populated on first save
}) async {
data.submissionStatus = status;
data.submissionMessage = message;
final baseFileName = _generateBaseFileName(data); // Use helper for consistent naming
// Prepare log data map using toMap()
final Map<String, dynamic> logMapData = data.toMap();
// Add submission metadata that might not be in toMap() or needs overriding
logMapData['submissionStatus'] = status;
logMapData['submissionMessage'] = message;
logMapData['reportId'] = data.reportId;
logMapData['serverConfigName'] = serverName;
// Store API/FTP results as JSON strings
logMapData['api_status'] = jsonEncode(apiResults); // Ensure apiResults is a list
logMapData['ftp_status'] = jsonEncode(ftpStatuses); // Ensure ftpStatuses is a list
String? savedLogPath = logDirectory;
// Save or Update local log file (data.json)
if (savedLogPath != null && savedLogPath.isNotEmpty) {
// Update existing log
logMapData['logDirectory'] = savedLogPath; // Ensure logDirectory path is in the map for update method
// *** MODIFIED: Use correct update method ***
await _localStorageService.updateRiverInvestigativeLog(logMapData); // NEW UPDATE METHOD
} else {
// Save new log and get the path
// *** MODIFIED: Use correct save method ***
savedLogPath = await _localStorageService.saveRiverInvestigativeSamplingData(data, serverName: serverName); // NEW SAVE METHOD
if (savedLogPath != null) {
logMapData['logDirectory'] = savedLogPath; // Add the new path for central log
} else {
debugPrint("Failed to save River Investigative log locally, central DB log might be incomplete.");
// Handle case where local save failed? Maybe skip central log or log with error?
}
}
// Save record to central DB log (submission_log table)
final imagePaths = data.toApiImageFiles().values.whereType<File>().map((f) => f.path).toList();
final centralLogData = {
'submission_id': data.reportId ?? baseFileName, // Use report ID or generated name as unique ID
// *** MODIFIED: Module and Type ***
'module': 'river', // Keep main module as 'river'
'type': 'Investigative', // Specific type
'status': status,
'message': message,
'report_id': data.reportId,
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(logMapData), // Log the comprehensive map including paths and status
'image_data': jsonEncode(imagePaths), // Log original image paths used for submission attempt
'server_name': serverName,
'api_status': jsonEncode(apiResults), // Log API results
'ftp_status': jsonEncode(ftpStatuses), // Log FTP results
};
try {
await _dbHelper.saveSubmissionLog(centralLogData);
} catch (e) {
debugPrint("Error saving River Investigative submission log to DB: $e"); // Log context update
}
}
/// Handles sending or queuing the Telegram alert for River Investigative submissions.
Future<void> _handleSuccessAlert(RiverInvesManualSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async { // Updated model type
try {
final message = await _generateInvestigativeAlertMessage(data, isDataOnly: isDataOnly); // Call specific helper
// *** MODIFIED: Telegram key ***
final alertKey = 'river_investigative'; // Specific key for this module
if (isSessionExpired) {
debugPrint("Session is expired; queuing River Investigative Telegram alert directly for $alertKey."); // Log context update
await _telegramService.queueMessage(alertKey, message, appSettings);
} else {
final bool wasSent = await _telegramService.sendAlertImmediately(alertKey, message, appSettings);
if (!wasSent) {
// Fallback to queueing if immediate send fails
await _telegramService.queueMessage(alertKey, message, appSettings);
}
}
} catch (e) {
debugPrint("Failed to handle River Investigative Telegram alert: $e"); // Log context update
}
}
/// Generates the specific Telegram alert message content for River Investigative.
Future<String> _generateInvestigativeAlertMessage(RiverInvesManualSamplingData data, {required bool isDataOnly}) async { // Updated model type
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
// Use helpers to get determined names/codes
final stationName = data.getDeterminedRiverName() ?? data.getDeterminedStationName() ?? 'N/A'; // Combine river/station name
final stationCode = data.getDeterminedStationCode() ?? 'N/A';
final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now());
final submitter = data.firstSamplerName ?? 'N/A';
final sondeID = data.sondeId ?? 'N/A';
final distanceKm = data.distanceDifferenceInKm ?? 0;
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
final distanceRemarks = data.distanceDifferenceRemarks ?? ''; // Default to empty string
final buffer = StringBuffer()
..writeln('✅ *River Investigative Sample ${submissionType} Submitted:*') // Updated title
..writeln();
// Adapt station info based on type
buffer.writeln('*Station Type:* ${data.stationTypeSelection ?? 'N/A'}');
if (data.stationTypeSelection == 'New Location') {
buffer.writeln('*New Location Name:* ${data.newStationName ?? 'N/A'}');
buffer.writeln('*New Location Code:* ${data.newStationCode ?? 'N/A'}');
buffer.writeln('*New Location State:* ${data.newStateName ?? 'N/A'}');
buffer.writeln('*New Location Basin:* ${data.newBasinName ?? 'N/A'}');
buffer.writeln('*New Location River:* ${data.newRiverName ?? 'N/A'}');
buffer.writeln('*Coordinates:* ${data.stationLatitude ?? 'N/A'}, ${data.stationLongitude ?? 'N/A'}');
} else {
buffer.writeln('*Station Name & Code:* $stationName ($stationCode)');
}
buffer
..writeln('*Date of Submitted:* $submissionDate')
..writeln('*Submitted by User:* $submitter')
..writeln('*Sonde ID:* $sondeID')
..writeln('*Status of Submission:* Successful');
// Include distance warning only if NOT a new location and distance > 50m
if (data.stationTypeSelection != 'New Location' && (distanceKm * 1000 > 50 || distanceRemarks.isNotEmpty)) {
buffer
..writeln()
..writeln('🔔 *Distance Alert:*')
..writeln('*Distance from station:* $distanceMeters meters');
if (distanceRemarks.isNotEmpty) {
buffer.writeln('*Remarks for distance:* $distanceRemarks');
}
}
// Add parameter limit check section (uses the same river limits)
final outOfBoundsAlert = await _getOutOfBoundsAlertSection(data); // Call helper
if (outOfBoundsAlert.isNotEmpty) {
buffer.write(outOfBoundsAlert);
}
return buffer.toString();
}
/// Helper to generate the parameter limit alert section for Telegram (River Investigative).
Future<String> _getOutOfBoundsAlertSection(RiverInvesManualSamplingData data) async { // Updated model type
// Define mapping from data model keys to parameter names used in limits table
// This mapping should be consistent with River In-Situ
const Map<String, String> _parameterKeyToLimitName = {
'oxygenConcentration': 'Oxygen Conc', 'oxygenSaturation': 'Oxygen Sat', 'ph': 'pH',
'salinity': 'Salinity', 'electricalConductivity': 'Conductivity', 'temperature': 'Temperature',
'tds': 'TDS', 'turbidity': 'Turbidity', 'ammonia': 'Ammonia', 'batteryVoltage': 'Battery',
};
// Load the same river parameter limits as In-Situ
final allLimits = await _dbHelper.loadRiverParameterLimits() ?? [];
if (allLimits.isEmpty) return ""; // No limits defined
// Get current readings from the investigative data model
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,
'ammonia': data.ammonia, 'batteryVoltage': data.batteryVoltage,
};
final List<String> outOfBoundsMessages = [];
// Helper to parse limit values (copied from In-Situ)
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;
}
// Iterate through readings and check against limits
readings.forEach((key, value) {
if (value == null || value == -999.0) return; // Skip missing/default values
final limitName = _parameterKeyToLimitName[key];
if (limitName == null) return; // Skip if parameter not in mapping
// Find the limit data for this parameter
final limitData = allLimits.firstWhere(
(l) => l['param_parameter_list'] == limitName,
orElse: () => <String, dynamic>{}, // Return empty map if not found
);
if (limitData.isNotEmpty) {
final lowerLimit = parseLimitValue(limitData['param_lower_limit']);
final upperLimit = parseLimitValue(limitData['param_upper_limit']);
bool isOutOfBounds = false;
// Check bounds
if (lowerLimit != null && value < lowerLimit) isOutOfBounds = true;
if (upperLimit != null && value > upperLimit) isOutOfBounds = true;
if (isOutOfBounds) {
// Format message for Telegram
final valueStr = value.toStringAsFixed(5);
final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A';
final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/A';
outOfBoundsMessages.add('- *$limitName*: `$valueStr` (Limit: `$lowerStr` - `$upperStr`)');
}
}
});
// If no parameters were out of bounds, return empty string
if (outOfBoundsMessages.isEmpty) {
return "";
}
// Construct the alert section header and messages
final buffer = StringBuffer()
..writeln() // Add spacing
..writeln('⚠️ *Parameter Limit Alert:*')
..writeln('The following parameters were outside their defined limits:');
buffer.writeAll(outOfBoundsMessages, '\n'); // Add each message on a new line
return buffer.toString();
}
} // End of RiverInvestigativeSamplingService class

View File

@ -2,7 +2,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart'; // Keep material import
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@ -15,14 +15,14 @@ import 'package:usb_serial/usb_serial.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart'; // Keep provider import
import '../auth_provider.dart'; import '../auth_provider.dart';
import 'location_service.dart'; import 'location_service.dart';
import '../models/river_manual_triennial_sampling_data.dart'; import '../models/river_manual_triennial_sampling_data.dart';
import '../bluetooth/bluetooth_manager.dart'; import '../bluetooth/bluetooth_manager.dart';
import '../serial/serial_manager.dart'; import '../serial/serial_manager.dart';
import 'api_service.dart'; import 'api_service.dart'; // Keep DatabaseHelper import
import 'local_storage_service.dart'; import 'local_storage_service.dart';
import 'server_config_service.dart'; import 'server_config_service.dart';
import 'zipping_service.dart'; import 'zipping_service.dart';
@ -166,18 +166,23 @@ class RiverManualTriennialSamplingService {
required AuthProvider authProvider, required AuthProvider authProvider,
String? logDirectory, String? logDirectory,
}) async { }) async {
const String moduleName = 'river_triennial'; const String moduleName = 'river_triennial'; // Correct module name
final connectivityResult = await Connectivity().checkConnectivity(); final connectivityResult = await Connectivity().checkConnectivity();
bool isOnline = connectivityResult != ConnectivityResult.none; bool isOnline = !connectivityResult.contains(ConnectivityResult.none);
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false); bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
if (isOnline && isOfflineSession) { if (isOnline && isOfflineSession) {
debugPrint("River Triennial submission online during offline session. Attempting auto-relogin..."); debugPrint("River Triennial submission online during offline session. Attempting auto-relogin...");
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession(); try {
if (transitionSuccess) { final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
isOfflineSession = false; if (transitionSuccess) {
} else { isOfflineSession = false;
} else {
isOnline = false; // Auto-relogin failed, treat as offline
}
} on SessionExpiredException catch (_) {
debugPrint("Session expired during auto-relogin check. Treating as offline.");
isOnline = false; isOnline = false;
} }
} }
@ -196,6 +201,7 @@ class RiverManualTriennialSamplingService {
return await _performOfflineQueuing( return await _performOfflineQueuing(
data: data, data: data,
moduleName: moduleName, moduleName: moduleName,
logDirectory: logDirectory, // Pass for potential update
); );
} }
} }
@ -215,25 +221,29 @@ class RiverManualTriennialSamplingService {
bool anyApiSuccess = false; bool anyApiSuccess = false;
Map<String, dynamic> apiDataResult = {}; Map<String, dynamic> apiDataResult = {};
Map<String, dynamic> apiImageResult = {}; Map<String, dynamic> apiImageResult = {};
String finalMessage = '';
String finalStatus = '';
bool isSessionKnownToBeExpired = false; bool isSessionKnownToBeExpired = false;
try { try {
// 1. Submit Form Data
apiDataResult = await _submissionApiService.submitPost( apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName, moduleName: moduleName,
endpoint: 'river/triennial/sample', endpoint: 'river/triennial/sample', // Correct endpoint
body: data.toApiFormData(), body: data.toApiFormData(),
); );
if (apiDataResult['success'] == true) { if (apiDataResult['success'] == true) {
anyApiSuccess = true; anyApiSuccess = true;
data.reportId = apiDataResult['data']?['r_tri_id']?.toString(); data.reportId = apiDataResult['data']?['r_tri_id']?.toString(); // Correct ID key
if (data.reportId != null) { if (data.reportId != null) {
if (finalImageFiles.isNotEmpty) { if (finalImageFiles.isNotEmpty) {
// 2. Submit Images
apiImageResult = await _submissionApiService.submitMultipart( apiImageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName, moduleName: moduleName,
endpoint: 'river/triennial/images', endpoint: 'river/triennial/images', // Correct endpoint
fields: {'r_tri_id': data.reportId!}, fields: {'r_tri_id': data.reportId!}, // Correct field key
files: finalImageFiles, files: finalImageFiles,
); );
if (apiImageResult['success'] != true) { if (apiImageResult['success'] != true) {
@ -245,65 +255,96 @@ class RiverManualTriennialSamplingService {
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.'; apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
} }
} }
} on SessionExpiredException catch (_) { // If apiDataResult['success'] is false, SubmissionApiService queued it.
debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin...");
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
if (reloginSuccess) { } on SessionExpiredException catch (_) {
debugPrint("Silent relogin successful. Retrying entire online submission process..."); debugPrint("Online submission failed due to session expiry that could not be refreshed.");
return await _performOnlineSubmission( isSessionKnownToBeExpired = true;
data: data,
appSettings: appSettings,
moduleName: moduleName,
authProvider: authProvider,
logDirectory: logDirectory,
);
} else {
debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP.");
isSessionKnownToBeExpired = true;
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
await _retryService.addApiToQueue(endpoint: 'river/triennial/sample', method: 'POST', body: data.toApiFormData());
}
} on SocketException catch (e) {
final errorMessage = "API submission failed with network error: $e";
debugPrint(errorMessage);
anyApiSuccess = false; anyApiSuccess = false;
apiDataResult = {'success': false, 'message': errorMessage}; apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
// Manually queue API calls
await _retryService.addApiToQueue(endpoint: 'river/triennial/sample', method: 'POST', body: data.toApiFormData()); await _retryService.addApiToQueue(endpoint: 'river/triennial/sample', method: 'POST', body: data.toApiFormData());
if (finalImageFiles.isNotEmpty && data.reportId != null) { if (finalImageFiles.isNotEmpty && data.reportId != null) {
// Also queue images if data call might have partially succeeded before expiry
await _retryService.addApiToQueue(endpoint: 'river/triennial/images', method: 'POST_MULTIPART', fields: {'r_tri_id': data.reportId!}, files: finalImageFiles); await _retryService.addApiToQueue(endpoint: 'river/triennial/images', method: 'POST_MULTIPART', fields: {'r_tri_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/triennial/sample', method: 'POST', body: data.toApiFormData());
} }
// 3. Submit FTP Files
Map<String, dynamic> ftpResults = {'statuses': []}; Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false; bool anyFtpSuccess = false;
try {
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName); if (isSessionKnownToBeExpired) {
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured'); debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
} on SocketException catch (e) { final baseFileNameForQueue = _generateBaseFileName(data); // Use helper
debugPrint("FTP submission failed with network error: $e");
anyFtpSuccess = false; // --- START FIX: Add ftpConfigId when queuing ---
} on TimeoutException catch (e) { // Get all potential FTP configs
debugPrint("FTP submission timed out: $e"); final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
final dataZip = await _zippingService.createDataZip(
jsonDataMap: { // Use specific JSON structures for River Triennial FTP
'db.json': data.toDbJson(), // Assuming similar structure is needed, adjust if different
// Add other JSON files if required for Triennial FTP
},
baseFileName: baseFileNameForQueue,
destinationDir: null,
);
if (dataZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) {
final configId = config['ftp_config_id'];
if (configId != null) {
await _retryService.addFtpToQueue(
localFilePath: dataZip.path,
remotePath: '/${p.basename(dataZip.path)}',
ftpConfigId: configId // Provide the specific config ID
);
}
}
}
if (finalImageFiles.isNotEmpty) {
final imageZip = await _zippingService.createImageZip(
imageFiles: finalImageFiles.values.toList(),
baseFileName: baseFileNameForQueue,
destinationDir: null,
);
if (imageZip != null) {
// Queue for each config separately
for (final config in ftpConfigs) {
final configId = config['ftp_config_id'];
if (configId != null) {
await _retryService.addFtpToQueue(
localFilePath: imageZip.path,
remotePath: '/${p.basename(imageZip.path)}',
ftpConfigId: configId // Provide the specific config ID
);
}
}
}
}
// --- END FIX ---
ftpResults = {'statuses': [{'status': 'Queued', 'message': 'FTP upload queued due to API session issue.', 'success': false}]};
anyFtpSuccess = false; anyFtpSuccess = false;
} else {
try {
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
} catch (e) {
debugPrint("Unexpected FTP submission error: $e");
anyFtpSuccess = false;
}
} }
// 4. Determine Final Status
final bool overallSuccess = anyApiSuccess || anyFtpSuccess; final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
String finalMessage;
String finalStatus;
if (anyApiSuccess && anyFtpSuccess) { if (anyApiSuccess && anyFtpSuccess) {
finalMessage = 'Data submitted successfully to all destinations.'; finalMessage = 'Data submitted successfully to all destinations.';
finalStatus = 'S4'; finalStatus = 'S4';
} else if (anyApiSuccess && !anyFtpSuccess) { } else if (anyApiSuccess && !anyFtpSuccess) {
finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.'; finalMessage = 'Data sent to API, but some FTP uploads failed or were queued.';
finalStatus = 'S3'; finalStatus = 'S3';
} else if (!anyApiSuccess && anyFtpSuccess) { } else if (!anyApiSuccess && anyFtpSuccess) {
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.'; finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
@ -313,6 +354,7 @@ class RiverManualTriennialSamplingService {
finalStatus = 'L1'; finalStatus = 'L1';
} }
// 5. Log Locally
await _logAndSave( await _logAndSave(
data: data, data: data,
status: finalStatus, status: finalStatus,
@ -323,10 +365,12 @@ class RiverManualTriennialSamplingService {
logDirectory: logDirectory, logDirectory: logDirectory,
); );
// 6. Send Alert
if (overallSuccess) { if (overallSuccess) {
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired); _handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
} }
// Return consistent format
return { return {
'status': finalStatus, 'status': finalStatus,
'success': overallSuccess, 'success': overallSuccess,
@ -335,9 +379,11 @@ class RiverManualTriennialSamplingService {
}; };
} }
/// Handles queuing the submission data when the device is offline.
Future<Map<String, dynamic>> _performOfflineQueuing({ Future<Map<String, dynamic>> _performOfflineQueuing({
required RiverManualTriennialSamplingData data, required RiverManualTriennialSamplingData data,
required String moduleName, required String moduleName,
String? logDirectory, // Added for potential update
}) async { }) async {
final serverConfig = await _serverConfigService.getActiveApiConfig(); final serverConfig = await _serverConfigService.getActiveApiConfig();
final serverName = serverConfig?['config_name'] as String? ?? 'Default'; final serverName = serverConfig?['config_name'] as String? ?? 'Default';
@ -345,45 +391,67 @@ class RiverManualTriennialSamplingService {
data.submissionStatus = 'L1'; data.submissionStatus = 'L1';
data.submissionMessage = 'Submission queued for later retry.'; data.submissionMessage = 'Submission queued for later retry.';
final String? localLogPath = await _localStorageService.saveRiverManualTriennialSamplingData(data, serverName: serverName); String? savedLogPath = logDirectory; // Use existing path if provided
if (localLogPath == null) { // Save/Update local log first
if (savedLogPath != null && savedLogPath.isNotEmpty) {
await _localStorageService.updateRiverManualTriennialLog(data.toMap()..['logDirectory'] = savedLogPath);
debugPrint("Updated existing River Triennial log for queuing: $savedLogPath");
} else {
savedLogPath = await _localStorageService.saveRiverManualTriennialSamplingData(data, serverName: serverName);
debugPrint("Saved new River Triennial log for queuing: $savedLogPath");
}
if (savedLogPath == null) {
const message = "Failed to save submission to local device storage."; const message = "Failed to save submission to local device storage.";
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName); await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, logDirectory: logDirectory);
return {'status': 'Error', 'success': false, 'message': message}; return {'status': 'Error', 'success': false, 'message': message};
} }
await _retryService.queueTask( await _retryService.queueTask(
type: 'river_triennial_submission', type: 'river_triennial_submission', // Correct type
payload: { payload: {
'module': moduleName, 'module': moduleName,
'localLogPath': p.join(localLogPath, 'data.json'), 'localLogPath': p.join(savedLogPath, 'data.json'), // Point to the json file
'serverConfig': serverConfig, 'serverConfig': serverConfig,
}, },
); );
const successMessage = "Submission failed to send and has been queued for later retry."; const successMessage = "Device offline. Submission has been saved locally and queued for automatic retry when connection is restored.";
return {'status': 'Queued', 'success': true, 'message': successMessage}; // Log final queued state to central DB
// await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, logDirectory: savedLogPath);
return {'status': 'Queued', 'success': true, 'message': successMessage, 'reportId': null};
} }
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(RiverManualTriennialSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async { /// Helper to generate the base filename for ZIP files.
String _generateBaseFileName(RiverManualTriennialSamplingData data) {
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN'; final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN';
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
final baseFileName = "${stationCode}_$fileTimestamp"; return "${stationCode}_$fileTimestamp";
}
final Directory? logDirectory = await _localStorageService.getLogDirectory(
/// Generates data and image ZIP files and uploads them using SubmissionFtpService.
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(RiverManualTriennialSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
final baseFileName = _generateBaseFileName(data);
final Directory? logDirectory = await _localStorageService.getLogDirectory( // Use generic getter
serverName: serverName, serverName: serverName,
module: 'river', module: 'river',
subModule: 'river_triennial_sampling', subModule: 'river_triennial_sampling', // Correct sub-module path
); );
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null; final folderName = data.reportId ?? baseFileName;
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) { if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
await localSubmissionDir.create(recursive: true); await localSubmissionDir.create(recursive: true);
} }
// Create and upload data ZIP
final dataZip = await _zippingService.createDataZip( final dataZip = await _zippingService.createDataZip(
jsonDataMap: {'db.json': jsonEncode(data.toApiFormData())}, // Assuming similar structure is needed jsonDataMap: {'db.json': data.toDbJson()}, // Assuming similar structure, adjust if needed
baseFileName: baseFileName, baseFileName: baseFileName,
destinationDir: localSubmissionDir, destinationDir: localSubmissionDir,
); );
@ -393,6 +461,7 @@ class RiverManualTriennialSamplingService {
moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${p.basename(dataZip.path)}'); moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${p.basename(dataZip.path)}');
} }
// Create and upload image ZIP
final imageZip = await _zippingService.createImageZip( final imageZip = await _zippingService.createImageZip(
imageFiles: imageFiles.values.toList(), imageFiles: imageFiles.values.toList(),
baseFileName: baseFileName, baseFileName: baseFileName,
@ -406,12 +475,13 @@ class RiverManualTriennialSamplingService {
return { return {
'statuses': <Map<String, dynamic>>[ 'statuses': <Map<String, dynamic>>[
...(ftpDataResult['statuses'] as List), ...(ftpDataResult['statuses'] as List? ?? []), // Use null-aware spread
...(ftpImageResult['statuses'] as List), ...(ftpImageResult['statuses'] as List? ?? []), // Use null-aware spread
], ],
}; };
} }
/// Saves or updates the local log file and saves a record to the central DB log.
Future<void> _logAndSave({ Future<void> _logAndSave({
required RiverManualTriennialSamplingData data, required RiverManualTriennialSamplingData data,
required String status, required String status,
@ -423,47 +493,65 @@ class RiverManualTriennialSamplingService {
}) async { }) async {
data.submissionStatus = status; data.submissionStatus = status;
data.submissionMessage = message; data.submissionMessage = message;
final baseFileName = _generateBaseFileName(data); // Use helper
if (logDirectory != null) { // Prepare log data map using toMap()
final Map<String, dynamic> updatedLogData = data.toMap(); final Map<String, dynamic> logMapData = data.toMap();
updatedLogData['logDirectory'] = logDirectory; // Add submission metadata
updatedLogData['serverConfigName'] = serverName; logMapData['submissionStatus'] = status;
updatedLogData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList()); logMapData['submissionMessage'] = message;
updatedLogData['ftp_status'] = jsonEncode(ftpStatuses); logMapData['reportId'] = data.reportId;
logMapData['serverConfigName'] = serverName;
logMapData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
logMapData['ftp_status'] = jsonEncode(ftpStatuses);
final imageFilePaths = data.toApiImageFiles();
imageFilePaths.forEach((key, file) {
if (file != null) {
updatedLogData[key] = file.path;
}
});
await _localStorageService.updateRiverManualTriennialLog(updatedLogData); if (logDirectory != null && logDirectory.isNotEmpty) {
// Update existing log
logMapData['logDirectory'] = logDirectory; // Ensure logDirectory path is in the map
await _localStorageService.updateRiverManualTriennialLog(logMapData); // Use specific update method
} else { } else {
await _localStorageService.saveRiverManualTriennialSamplingData(data, serverName: serverName); // Save new log
await _localStorageService.saveRiverManualTriennialSamplingData(data, serverName: serverName); // Use specific save method
} }
// Save to central DB log
final imagePaths = data.toApiImageFiles().values.whereType<File>().map((f) => f.path).toList(); final imagePaths = data.toApiImageFiles().values.whereType<File>().map((f) => f.path).toList();
final logData = { final centralLogData = {
'submission_id': data.reportId ?? DateTime.now().millisecondsSinceEpoch.toString(), 'submission_id': data.reportId ?? baseFileName, // Use helper result
'module': 'river', 'type': data.samplingType ?? 'Triennial', 'status': status, 'module': 'river',
'message': message, 'report_id': data.reportId, 'created_at': DateTime.now().toIso8601String(), 'type': data.samplingType ?? 'Triennial', // Correct type
'form_data': jsonEncode(data.toMap()), 'image_data': jsonEncode(imagePaths), 'status': status,
'server_name': serverName, 'api_status': jsonEncode(apiResults), 'ftp_status': jsonEncode(ftpStatuses), 'message': message,
'report_id': data.reportId,
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(logMapData), // Log the comprehensive map
'image_data': jsonEncode(imagePaths),
'server_name': serverName,
'api_status': jsonEncode(apiResults),
'ftp_status': jsonEncode(ftpStatuses),
}; };
await _dbHelper.saveSubmissionLog(logData); try {
await _dbHelper.saveSubmissionLog(centralLogData);
} catch (e) {
debugPrint("Error saving River Triennial submission log to DB: $e");
}
} }
/// Handles sending or queuing the Telegram alert for River Triennial submissions.
Future<void> _handleSuccessAlert(RiverManualTriennialSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async { Future<void> _handleSuccessAlert(RiverManualTriennialSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
try { try {
final message = await _generateSuccessAlertMessage(data, isDataOnly: isDataOnly); final message = await _generateSuccessAlertMessage(data, isDataOnly: isDataOnly); // Call local helper
final alertKey = 'river_triennial'; // Correct key
if (isSessionExpired) { if (isSessionExpired) {
debugPrint("Session is expired; queuing Telegram alert directly."); debugPrint("Session is expired; queuing Telegram alert directly for $alertKey.");
await _telegramService.queueMessage('river_triennial', message, appSettings); await _telegramService.queueMessage(alertKey, message, appSettings);
} else { } else {
final bool wasSent = await _telegramService.sendAlertImmediately('river_triennial', message, appSettings); final bool wasSent = await _telegramService.sendAlertImmediately(alertKey, message, appSettings);
if (!wasSent) { if (!wasSent) {
await _telegramService.queueMessage('river_triennial', message, appSettings); await _telegramService.queueMessage(alertKey, message, appSettings);
} }
} }
} catch (e) { } catch (e) {
@ -471,6 +559,7 @@ class RiverManualTriennialSamplingService {
} }
} }
/// Generates the specific Telegram alert message content for River Triennial.
Future<String> _generateSuccessAlertMessage(RiverManualTriennialSamplingData data, {required bool isDataOnly}) async { Future<String> _generateSuccessAlertMessage(RiverManualTriennialSamplingData data, {required bool isDataOnly}) async {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = data.selectedStation?['sampling_river'] ?? 'N/A'; final stationName = data.selectedStation?['sampling_river'] ?? 'N/A';
@ -491,15 +580,20 @@ class RiverManualTriennialSamplingService {
..writeln('*Sonde ID:* $sondeID') ..writeln('*Sonde ID:* $sondeID')
..writeln('*Status of Submission:* Successful'); ..writeln('*Status of Submission:* Successful');
if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { if (distanceKm * 1000 > 50 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { // Check if distance > 50m
buffer buffer
..writeln() ..writeln()
..writeln('🔔 *Alert:*') ..writeln('🔔 *Distance Alert:*')
..writeln('*Distance from station:* $distanceMeters meters'); ..writeln('*Distance from station:* $distanceMeters meters');
if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') { if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
buffer.writeln('*Remarks for distance:* $distanceRemarks'); buffer.writeln('*Remarks for distance:* $distanceRemarks');
} }
} }
// Note: Parameter limit checks are not typically done for Triennial in the same way as In-Situ.
// If needed, similar logic to _getOutOfBoundsAlertSection in RiverInSituSamplingService
// would need to be adapted here, potentially using riverParameterLimits from the DB.
return buffer.toString(); return buffer.toString();
} }
} }

View File

@ -2,10 +2,15 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:convert'; // Added for jsonEncode
import 'package:path/path.dart' as p; // Added for basename
import 'package:environment_monitoring_app/services/user_preferences_service.dart'; import 'package:environment_monitoring_app/services/user_preferences_service.dart';
import 'package:environment_monitoring_app/services/ftp_service.dart'; import 'package:environment_monitoring_app/services/ftp_service.dart';
import 'package:environment_monitoring_app/services/retry_service.dart'; import 'package:environment_monitoring_app/services/retry_service.dart';
// Import necessary services and models if needed for queueFtpTasksForSkippedAttempt
import 'package:environment_monitoring_app/services/zipping_service.dart';
import 'package:environment_monitoring_app/services/api_service.dart'; // For DatabaseHelper
/// A generic, reusable service for handling the FTP submission process. /// A generic, reusable service for handling the FTP submission process.
/// It respects user preferences for enabled destinations for any given module. /// It respects user preferences for enabled destinations for any given module.
@ -13,6 +18,10 @@ class SubmissionFtpService {
final UserPreferencesService _userPreferencesService = UserPreferencesService(); final UserPreferencesService _userPreferencesService = UserPreferencesService();
final FtpService _ftpService = FtpService(); final FtpService _ftpService = FtpService();
final RetryService _retryService = RetryService(); final RetryService _retryService = RetryService();
// Add ZippingService and DatabaseHelper if queueFtpTasksForSkippedAttempt needs them
final ZippingService _zippingService = ZippingService();
final DatabaseHelper _dbHelper = DatabaseHelper();
/// Submits a file to all enabled FTP destinations for a given module. /// Submits a file to all enabled FTP destinations for a given module.
/// ///
@ -30,15 +39,35 @@ class SubmissionFtpService {
if (destinations.isEmpty) { if (destinations.isEmpty) {
debugPrint("SubmissionFtpService: No enabled FTP destinations for module '$moduleName'. Skipping."); debugPrint("SubmissionFtpService: No enabled FTP destinations for module '$moduleName'. Skipping.");
return {'success': true, 'message': 'No FTP destinations enabled for this module.'}; // Return success with a specific status indicating no config
return {
'success': true, // Process succeeded because there was nothing to do
'message': 'No FTP destinations enabled for this module.',
'statuses': [{'status': 'Not Configured', 'message': 'No destinations enabled.', 'success': true}]
};
} }
final List<Map<String, dynamic>> statuses = []; final List<Map<String, dynamic>> statuses = [];
bool allSucceeded = true; bool allSucceededOrNotConfigured = true; // Track if all attempts either succeeded or weren't configured
for (final dest in destinations) { for (final dest in destinations) {
final configName = dest['config_name'] as String? ?? 'Unknown FTP'; final configName = dest['config_name'] as String? ?? 'Unknown FTP';
debugPrint("SubmissionFtpService: Attempting to upload to '$configName'"); final int? configId = dest['ftp_config_id'] as int?; // Get the config ID
// Skip if config ID is missing (should not happen with DB data)
if (configId == null) {
debugPrint("SubmissionFtpService: Skipping destination '$configName' due to missing config ID.");
statuses.add({
'config_name': configName,
'status': 'Error',
'success': false,
'message': 'Configuration ID missing.',
});
allSucceededOrNotConfigured = false;
continue;
}
debugPrint("SubmissionFtpService: Attempting to upload to '$configName' (ID: $configId)");
final result = await _ftpService.uploadFile( final result = await _ftpService.uploadFile(
config: dest, config: dest,
@ -48,22 +77,27 @@ class SubmissionFtpService {
statuses.add({ statuses.add({
'config_name': configName, 'config_name': configName,
'ftp_config_id': configId, // Include ID in status
'success': result['success'], 'success': result['success'],
'message': result['message'], 'message': result['message'],
'status': result['success'] ? 'Success' : 'Failed', // Add status text
}); });
if (result['success'] != true) { if (result['success'] != true) {
allSucceeded = false; allSucceededOrNotConfigured = false;
// If an individual upload fails, queue it for manual retry. // If an individual upload fails, queue it for manual retry.
debugPrint("SubmissionFtpService: Upload to '$configName' failed. Queuing for retry."); debugPrint("SubmissionFtpService: Upload to '$configName' (ID: $configId) failed. Queuing for retry.");
// --- START FIX: Add ftpConfigId ---
await _retryService.addFtpToQueue( await _retryService.addFtpToQueue(
localFilePath: fileToUpload.path, localFilePath: fileToUpload.path,
remotePath: remotePath, remotePath: remotePath,
ftpConfigId: configId, // Pass the specific config ID
); );
// --- END FIX ---
} }
} }
if (allSucceeded) { if (allSucceededOrNotConfigured) {
return { return {
'success': true, 'success': true,
'message': 'File successfully uploaded to all enabled FTP destinations.', 'message': 'File successfully uploaded to all enabled FTP destinations.',
@ -71,10 +105,70 @@ class SubmissionFtpService {
}; };
} else { } else {
return { return {
'success': true, // The process itself succeeded, even if some uploads were queued. 'success': true, // The process itself succeeded (attempted all), even if some uploads were queued.
'message': 'One or more FTP uploads failed and have been queued for retry.', 'message': 'One or more FTP uploads failed and have been queued for retry.',
'statuses': statuses, 'statuses': statuses,
}; };
} }
} }
/// Manually queues FTP tasks when the initial FTP attempt is skipped (e.g., due to session expiry).
Future<void> queueFtpTasksForSkippedAttempt({
required String moduleName,
required Map<String, dynamic> dataJson, // The data model converted to JSON (toDbJson)
required Map<String, File> imageFiles,
required String baseFileName, // Base name for zip files
}) async {
debugPrint("Manually queuing FTP tasks for skipped attempt (Module: $moduleName).");
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
if (ftpConfigs.isEmpty) {
debugPrint("Cannot queue skipped FTP tasks: No FTP configurations found.");
return;
}
// 1. Create Data ZIP (in temp directory)
final dataZip = await _zippingService.createDataZip(
// Adapt jsonDataMap based on module if needed, using dataJson
jsonDataMap: {'db.json': jsonEncode(dataJson)}, // Default, adjust per module if needed
baseFileName: baseFileName,
destinationDir: null, // Save to temp dir
);
// 2. Create Image ZIP (in temp directory)
File? imageZip;
if (imageFiles.isNotEmpty) {
imageZip = await _zippingService.createImageZip(
imageFiles: imageFiles.values.toList(),
baseFileName: baseFileName,
destinationDir: null, // Save to temp dir
);
}
// 3. Queue uploads for each config
for (final config in ftpConfigs) {
final configId = config['ftp_config_id'];
if (configId != null) {
// Queue data zip upload
if (dataZip != null) {
await _retryService.addFtpToQueue(
localFilePath: dataZip.path,
remotePath: '/${p.basename(dataZip.path)}',
ftpConfigId: configId
);
debugPrint("Queued skipped data ZIP upload for FTP config ID $configId");
}
// Queue image zip upload
if (imageZip != null) {
await _retryService.addFtpToQueue(
localFilePath: imageZip.path,
remotePath: '/${p.basename(imageZip.path)}',
ftpConfigId: configId
);
debugPrint("Queued skipped image ZIP upload for FTP config ID $configId");
}
}
}
// Temporary ZIP files will be cleaned up by OS eventually, or handled by retry logic upon success/failure.
}
} }

View File

@ -854,6 +854,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.5" version: "2.5.5"
sqflite_common_ffi:
dependency: "direct main"
description:
name: sqflite_common_ffi
sha256: "9faa2fedc5385ef238ce772589f7718c24cdddd27419b609bb9c6f703ea27988"
url: "https://pub.dev"
source: hosted
version: "2.3.6"
sqflite_darwin: sqflite_darwin:
dependency: transitive dependency: transitive
description: description:
@ -870,6 +878,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" version: "2.4.0"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: f18fd9a72d7a1ad2920db61368f2a69368f1cc9b56b8233e9d83b47b0a8435aa
url: "https://pub.dev"
source: hosted
version: "2.9.3"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:

View File

@ -23,6 +23,7 @@ dependencies:
# --- Local Storage & Offline Capabilities --- # --- Local Storage & Offline Capabilities ---
shared_preferences: ^2.2.3 shared_preferences: ^2.2.3
sqflite: ^2.3.3 sqflite: ^2.3.3
sqflite_common_ffi: ^2.3.3
path_provider: ^2.1.3 path_provider: ^2.1.3
path: ^1.8.3 # Explicitly added for path manipulation path: ^1.8.3 # Explicitly added for path manipulation
connectivity_plus: ^6.0.1 connectivity_plus: ^6.0.1
@ -73,4 +74,4 @@ flutter:
flutter_launcher_icons: flutter_launcher_icons:
android: true android: true
ios: true ios: true
image_path: "assets/icon_2_512x512.png" image_path: "assets/icon_3_512x512.png"