add in river investigative module. revamp the services to use dedicated services for api and submission
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 616 KiB After Width: | Height: | Size: 626 KiB |
|
Before Width: | Height: | Size: 927 B After Width: | Height: | Size: 998 B |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 33 KiB |
@ -1,12 +1,13 @@
|
||||
// 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/scheduler.dart'; // Added import for post-frame callback
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:bcrypt/bcrypt.dart'; // Import bcrypt
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; // NEW: Import secure storage
|
||||
import 'package: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/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/user_preferences_service.dart';
|
||||
|
||||
// Removed _CacheDataContainer class
|
||||
// Removed _loadCacheDataFromIsolate function
|
||||
|
||||
class AuthProvider with ChangeNotifier {
|
||||
late final ApiService _apiService;
|
||||
late final DatabaseHelper _dbHelper;
|
||||
@ -21,7 +25,7 @@ class AuthProvider with ChangeNotifier {
|
||||
late final RetryService _retryService;
|
||||
final UserPreferencesService _userPreferencesService = UserPreferencesService();
|
||||
|
||||
// NEW: Initialize secure storage
|
||||
// Initialize secure storage
|
||||
final _secureStorage = const FlutterSecureStorage();
|
||||
static const _passwordStorageKey = 'user_password';
|
||||
|
||||
@ -34,15 +38,15 @@ class AuthProvider with ChangeNotifier {
|
||||
Map<String, dynamic>? get profileData => _profileData;
|
||||
|
||||
// --- App State ---
|
||||
bool _isLoading = true;
|
||||
bool _isLoading = true; // Keep true initially
|
||||
bool _isFirstLogin = true;
|
||||
DateTime? _lastSyncTimestamp;
|
||||
bool _isBackgroundLoading = false; // Added flag for background loading
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isBackgroundLoading => _isBackgroundLoading;
|
||||
bool get isFirstLogin => _isFirstLogin;
|
||||
DateTime? get lastSyncTimestamp => _lastSyncTimestamp;
|
||||
|
||||
/// This flag indicates the session is confirmed expired and auto-relogin failed.
|
||||
/// The app should operate in offline mode until the user manually logs in again.
|
||||
bool _isSessionExpired = false;
|
||||
bool get isSessionExpired => _isSessionExpired;
|
||||
|
||||
@ -60,12 +64,9 @@ class AuthProvider with ChangeNotifier {
|
||||
List<Map<String, dynamic>>? _airManualStations;
|
||||
List<Map<String, dynamic>>? _states;
|
||||
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>>? _marineParameterLimits;
|
||||
List<Map<String, dynamic>>? _riverParameterLimits;
|
||||
// --- END: MODIFIED PARAMETER LIMITS PROPERTIES ---
|
||||
List<Map<String, dynamic>>? _apiConfigs;
|
||||
List<Map<String, dynamic>>? _ftpConfigs;
|
||||
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 states => _states;
|
||||
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 marineParameterLimits => _marineParameterLimits;
|
||||
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 ftpConfigs => _ftpConfigs;
|
||||
List<Map<String, dynamic>>? get documents => _documents;
|
||||
@ -113,7 +112,7 @@ class AuthProvider with ChangeNotifier {
|
||||
_serverConfigService = serverConfigService,
|
||||
_retryService = retryService {
|
||||
debugPrint('AuthProvider: Initializing...');
|
||||
_loadSessionAndSyncData();
|
||||
_initializeAndLoadData(); // Use the updated method name
|
||||
}
|
||||
|
||||
Future<bool> isConnected() async {
|
||||
@ -121,22 +120,35 @@ class AuthProvider with ChangeNotifier {
|
||||
return !connectivityResult.contains(ConnectivityResult.none);
|
||||
}
|
||||
|
||||
Future<void> _loadSessionAndSyncData() async {
|
||||
// Updated method using SchedulerBinding instead of compute
|
||||
Future<void> _initializeAndLoadData() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
notifyListeners(); // Notify UI about initial loading state
|
||||
|
||||
// 1. Perform quick SharedPreferences reads first.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_jwtToken = prefs.getString(tokenKey);
|
||||
_userEmail = prefs.getString(userEmailKey);
|
||||
_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();
|
||||
if (activeApiConfig == null) {
|
||||
debugPrint("AuthProvider: No active API config found. Setting default bootstrap URL.");
|
||||
final initialConfig = {
|
||||
'api_config_id': 0,
|
||||
'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);
|
||||
}
|
||||
@ -151,18 +163,40 @@ class AuthProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
await _loadDataFromCache();
|
||||
// 2. Set isLoading to false *before* scheduling heavy work.
|
||||
_isLoading = false;
|
||||
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();
|
||||
// Sync logic moved to checkAndTransitionToOnlineSession to handle transitions correctly
|
||||
// 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.');
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} 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.
|
||||
@ -178,20 +212,18 @@ class AuthProvider with ChangeNotifier {
|
||||
final bool inOfflineSession = _jwtToken != null && _jwtToken!.startsWith("offline-session-");
|
||||
if (!inOfflineSession) {
|
||||
// Already online or logged out, no transition needed.
|
||||
// If online, trigger a normal sync to ensure data freshness on connection restoration.
|
||||
// --- START: FIX FOR DOUBLE SYNC ---
|
||||
// Removed the redundant syncAllData call from here.
|
||||
if(_jwtToken != null) {
|
||||
debugPrint("AuthProvider: Session is already online. Triggering standard sync.");
|
||||
// FIX: Add try-catch to prevent unhandled exceptions from crashing the app during background syncs.
|
||||
try {
|
||||
await syncAllData();
|
||||
} catch (e) {
|
||||
debugPrint("AuthProvider: Background sync failed silently on transition check: $e");
|
||||
}
|
||||
debugPrint("AuthProvider: Session is already online. Skipping transition sync.");
|
||||
// Consider calling validateAndRefreshSession() here instead if needed,
|
||||
// but avoid a full syncAllData().
|
||||
}
|
||||
// --- END: FIX FOR DOUBLE SYNC ---
|
||||
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);
|
||||
if (password == null || _userEmail == null) {
|
||||
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'];
|
||||
|
||||
// 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
|
||||
return true;
|
||||
} else {
|
||||
// Silent login failed (e.g., password changed on another device).
|
||||
// Keep user in offline mode for now. They will need to log out and log back in manually.
|
||||
// Silent login failed
|
||||
debugPrint("AuthProvider: Silent re-login failed: ${result['message']}");
|
||||
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 {
|
||||
if (!(await isConnected())) {
|
||||
debugPrint('AuthProvider: No connection, skipping session validation.');
|
||||
@ -238,9 +267,8 @@ class AuthProvider with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't attempt validation if the user is not logged in or is in a temporary offline session.
|
||||
if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) {
|
||||
return;
|
||||
return; // No online session to validate
|
||||
}
|
||||
|
||||
try {
|
||||
@ -254,7 +282,6 @@ class AuthProvider with ChangeNotifier {
|
||||
debugPrint('AuthProvider: Silent re-login failed. Switching to session-expired offline mode.');
|
||||
_isSessionExpired = true;
|
||||
notifyListeners();
|
||||
// You can optionally show a one-time notification here.
|
||||
} else {
|
||||
debugPrint('AuthProvider: Silent re-login successful. Session restored.');
|
||||
}
|
||||
@ -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 {
|
||||
if (!(await isConnected())) {
|
||||
debugPrint("AuthProvider: No internet for silent relogin.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// FIX: Read password from secure storage.
|
||||
final String? password = await _secureStorage.read(key: _passwordStorageKey);
|
||||
if (password == null || _userEmail == null) {
|
||||
debugPrint("AuthProvider: No cached credentials in secure storage for silent relogin.");
|
||||
@ -285,8 +309,10 @@ class AuthProvider with ChangeNotifier {
|
||||
debugPrint("AuthProvider: Silent re-login successful.");
|
||||
final String token = result['data']['token'];
|
||||
final Map<String, dynamic> profile = result['data']['profile'];
|
||||
await login(token, profile, password);
|
||||
_isSessionExpired = false; // Explicitly mark session as valid again.
|
||||
// Critical: Call the main login function to update token, profile, hash, etc.
|
||||
// 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();
|
||||
return true;
|
||||
} 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 {
|
||||
if (!(await isConnected())) {
|
||||
debugPrint("AuthProvider: Device is OFFLINE. Skipping sync.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Proactively check if session is already marked as expired
|
||||
if (_isSessionExpired) {
|
||||
debugPrint("AuthProvider: Skipping sync, session is expired. Manual login required.");
|
||||
throw Exception('Session expired. Please log in again to sync.');
|
||||
@ -334,11 +392,10 @@ class AuthProvider with ChangeNotifier {
|
||||
debugPrint("AuthProvider: First successful sync complete. isFirstLogin flag set to false.");
|
||||
}
|
||||
|
||||
await _loadDataFromCache();
|
||||
await _loadDataFromCache(); // Reload data after successful sync
|
||||
notifyListeners();
|
||||
} else {
|
||||
debugPrint("AuthProvider: Delta sync failed logically. Message: ${result['message']}");
|
||||
// We throw an exception here so the UI can report a failure.
|
||||
throw Exception('Data sync failed. Please check the logs.');
|
||||
}
|
||||
} on SessionExpiredException {
|
||||
@ -355,12 +412,10 @@ class AuthProvider with ChangeNotifier {
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("AuthProvider: A general error occurred during sync: $e");
|
||||
// Re-throw the exception so the UI can display it.
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// --- START: NEW METHOD FOR REGISTRATION SCREEN ---
|
||||
Future<void> syncRegistrationData() async {
|
||||
if (!(await isConnected())) {
|
||||
debugPrint("AuthProvider: Device is OFFLINE. Skipping registration data sync.");
|
||||
@ -371,14 +426,13 @@ class AuthProvider with ChangeNotifier {
|
||||
final result = await _apiService.syncRegistrationData();
|
||||
|
||||
if (result['success']) {
|
||||
await _loadDataFromCache(); // Reload data from DB into the provider
|
||||
notifyListeners(); // Notify the UI to rebuild
|
||||
await _loadDataFromCache();
|
||||
notifyListeners();
|
||||
debugPrint("AuthProvider: Registration data loaded and UI notified.");
|
||||
} else {
|
||||
debugPrint("AuthProvider: Registration data sync failed.");
|
||||
}
|
||||
}
|
||||
// --- END: NEW METHOD FOR REGISTRATION SCREEN ---
|
||||
|
||||
Future<void> refreshProfile() async {
|
||||
if (!(await isConnected())) {
|
||||
@ -390,10 +444,17 @@ class AuthProvider with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final result = await _apiService.refreshProfile();
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> proactiveTokenRefresh() async {
|
||||
@ -407,7 +468,7 @@ class AuthProvider with ChangeNotifier {
|
||||
|
||||
if (lastOnlineLoginString == null) {
|
||||
debugPrint('AuthProvider: No last online login timestamp found, skipping proactive refresh.');
|
||||
return; // Never logged in online, nothing to refresh.
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
@ -423,14 +484,15 @@ class AuthProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// This method performs the actual DB reads
|
||||
Future<void> _loadDataFromCache() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final profileJson = prefs.getString(profileDataKey);
|
||||
if (profileJson != null) {
|
||||
_profileData = jsonDecode(profileJson);
|
||||
} else {
|
||||
_profileData = await _dbHelper.loadProfile();
|
||||
if (profileJson != null && _profileData == null) {
|
||||
try { _profileData = jsonDecode(profileJson); } catch(e) { /*...*/ }
|
||||
}
|
||||
_profileData ??= await _dbHelper.loadProfile();
|
||||
|
||||
_allUsers = await _dbHelper.loadUsers();
|
||||
_tarballStations = await _dbHelper.loadTarballStations();
|
||||
_manualStations = await _dbHelper.loadManualStations();
|
||||
@ -444,65 +506,40 @@ class AuthProvider with ChangeNotifier {
|
||||
_airManualStations = await _dbHelper.loadAirManualStations();
|
||||
_states = await _dbHelper.loadStates();
|
||||
_appSettings = await _dbHelper.loadAppSettings();
|
||||
|
||||
// --- START: LOAD DATA FROM NEW PARAMETER LIMIT TABLES ---
|
||||
_npeParameterLimits = await _dbHelper.loadNpeParameterLimits();
|
||||
_marineParameterLimits = await _dbHelper.loadMarineParameterLimits();
|
||||
_riverParameterLimits = await _dbHelper.loadRiverParameterLimits();
|
||||
// --- END: LOAD DATA FROM NEW PARAMETER LIMIT TABLES ---
|
||||
|
||||
_documents = await _dbHelper.loadDocuments();
|
||||
_apiConfigs = await _dbHelper.loadApiConfigs();
|
||||
_ftpConfigs = await _dbHelper.loadFtpConfigs();
|
||||
_pendingRetries = await _retryService.getPendingTasks();
|
||||
debugPrint("AuthProvider: All master data loaded from local DB cache.");
|
||||
_pendingRetries = await _retryService.getPendingTasks(); // Use service here is okay
|
||||
debugPrint("AuthProvider: All master data loaded from local DB cache (background/sync).");
|
||||
}
|
||||
|
||||
|
||||
Future<void> refreshPendingTasks() async {
|
||||
_pendingRetries = await _retryService.getPendingTasks();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> login(String token, Map<String, dynamic> profile, String password) async {
|
||||
_jwtToken = token;
|
||||
_userEmail = profile['email'];
|
||||
|
||||
// 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");
|
||||
}
|
||||
// Call the internal helper first
|
||||
await _updateSessionInternals(token, profile, password);
|
||||
|
||||
// Now proceed with post-login actions that *don't* belong in the helper
|
||||
debugPrint('AuthProvider: Login successful. Session and profile persisted.');
|
||||
await _userPreferencesService.applyAndSaveDefaultPreferencesIfNeeded();
|
||||
// The main sync triggered by a direct user login
|
||||
await syncAllData(forceRefresh: true);
|
||||
// Notify listeners *after* sync is attempted (or throws)
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
Future<bool> loginOffline(String email, String password) async {
|
||||
debugPrint("AuthProvider: Attempting offline login for user $email.");
|
||||
|
||||
try {
|
||||
// 1. Retrieve stored hash from the local database based on email.
|
||||
final String? storedHash = await _dbHelper.getUserPasswordHashByEmail(email);
|
||||
|
||||
if (storedHash == null || storedHash.isEmpty) {
|
||||
@ -510,24 +547,20 @@ class AuthProvider with ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Verify the provided password against the stored hash.
|
||||
debugPrint("AuthProvider: Verifying password against stored hash...");
|
||||
final bool passwordMatches = await compute(verifyPassword, CheckPasswordParams(password, storedHash));
|
||||
|
||||
if (passwordMatches) {
|
||||
debugPrint("AuthProvider: Offline password verification successful.");
|
||||
// 3. Load profile data from local storage.
|
||||
final Map<String, dynamic>? cachedProfile = await _dbHelper.loadProfileByEmail(email);
|
||||
if (cachedProfile == null) {
|
||||
debugPrint("AuthProvider DEBUG: Offline login failed because profile data was missing, even though password matched.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. Initialize session state from cached profile data.
|
||||
_jwtToken = "offline-session-${DateTime.now().millisecondsSinceEpoch}";
|
||||
_userEmail = email;
|
||||
|
||||
// FIX: Save password to secure storage for future auto-relogin.
|
||||
await _secureStorage.write(key: _passwordStorageKey, value: password);
|
||||
|
||||
final Map<String, dynamic> profileWithToken = Map.from(cachedProfile);
|
||||
@ -539,6 +572,8 @@ class AuthProvider with ChangeNotifier {
|
||||
await prefs.setString(userEmailKey, _userEmail!);
|
||||
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();
|
||||
notifyListeners();
|
||||
return true;
|
||||
@ -561,7 +596,7 @@ class AuthProvider with ChangeNotifier {
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(profileDataKey, jsonEncode(_profileData));
|
||||
await _dbHelper.saveProfile(_profileData!); // Also save to local DB
|
||||
await _dbHelper.saveProfile(_profileData!);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@ -579,11 +614,11 @@ class AuthProvider with ChangeNotifier {
|
||||
_profileData = null;
|
||||
_lastSyncTimestamp = null;
|
||||
_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);
|
||||
|
||||
// Clear cached data
|
||||
_allUsers = null;
|
||||
_tarballStations = null;
|
||||
_manualStations = null;
|
||||
@ -597,13 +632,9 @@ class AuthProvider with ChangeNotifier {
|
||||
_airManualStations = null;
|
||||
_states = null;
|
||||
_appSettings = null;
|
||||
|
||||
// --- START: Clear new parameter limit lists ---
|
||||
_npeParameterLimits = null;
|
||||
_marineParameterLimits = null;
|
||||
_riverParameterLimits = null;
|
||||
// --- END: Clear new parameter limit lists ---
|
||||
|
||||
_documents = null;
|
||||
_apiConfigs = null;
|
||||
_ftpConfigs = null;
|
||||
@ -615,7 +646,7 @@ class AuthProvider with ChangeNotifier {
|
||||
await prefs.remove(profileDataKey);
|
||||
await prefs.remove(lastSyncTimestampKey);
|
||||
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);
|
||||
|
||||
debugPrint('AuthProvider: All session and cached data cleared.');
|
||||
@ -623,10 +654,13 @@ class AuthProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
}
|
||||
|
||||
// These remain unchanged as they are used by compute for password hashing/checking
|
||||
class CheckPasswordParams {
|
||||
final String password;
|
||||
final String hash;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// collapsible_sidebar.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// --- 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.date_range, label: "Triennial Sampling", route: '/river/manual/triennial'),
|
||||
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(
|
||||
@ -96,6 +100,8 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
|
||||
SidebarItem(
|
||||
icon: Icons.search, label: "Investigative", isParent: true, children: [
|
||||
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.waves, label: "Tarball Sampling", route: '/marine/manual/tarball'),
|
||||
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(
|
||||
@ -124,6 +134,8 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
|
||||
SidebarItem(
|
||||
icon: Icons.search, label: "Investigative", isParent: true, children: [
|
||||
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'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
|
||||
147
lib/main.dart
@ -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/river_in_situ_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/telegram_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/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_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_sonde_calibration_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/report.dart' as riverContinuousReport;
|
||||
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/entry.dart' as riverInvestigativeEntry;
|
||||
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/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_pre_departure_checklist_screen.dart' as marineManualPreDepartureChecklist;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart' 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/reports/marine_manual_pre_departure_checklist_screen.dart'
|
||||
as marineManualPreDepartureChecklist;
|
||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart'
|
||||
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/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/entry.dart' as marineContinuousEntry;
|
||||
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_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/entry.dart' as marineInvestigativeEntry;
|
||||
import 'package:environment_monitoring_app/screens/marine/investigative/report.dart' as marineInvestigativeReport;
|
||||
@ -105,6 +116,12 @@ void main() async {
|
||||
final RetryService retryService = RetryService();
|
||||
final MarineInSituSamplingService marineInSituService = MarineInSituSamplingService(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);
|
||||
|
||||
@ -117,9 +134,13 @@ void main() async {
|
||||
);
|
||||
|
||||
// Initialize the retry service with all its dependencies.
|
||||
// *** MODIFIED: Added riverInvestigativeService dependency (and marineTarballService from previous request) ***
|
||||
retryService.initialize(
|
||||
marineInSituService: marineInSituService,
|
||||
riverInSituService: riverInSituService,
|
||||
marineInvestigativeService: marineInvestigativeService,
|
||||
riverInvestigativeService: riverInvestigativeService, // <-- Added this line
|
||||
marineTarballService: marineTarballService,
|
||||
authProvider: authProvider,
|
||||
);
|
||||
|
||||
@ -135,21 +156,23 @@ void main() async {
|
||||
Provider(create: (_) => LocalStorageService()),
|
||||
Provider.value(value: retryService),
|
||||
Provider.value(value: marineInSituService),
|
||||
Provider.value(value: marineInvestigativeService),
|
||||
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) => 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))),
|
||||
// --- UPDATED: Inject ApiService into the service constructors ---
|
||||
Provider(create: (context) => MarineManualPreDepartureService(
|
||||
Provider.of<ApiService>(context, listen: false)
|
||||
)),
|
||||
Provider(create: (context) => MarineManualSondeCalibrationService(
|
||||
Provider.of<ApiService>(context, listen: false)
|
||||
)),
|
||||
Provider(create: (context) => MarineManualEquipmentMaintenanceService(
|
||||
Provider.of<ApiService>(context, listen: false)
|
||||
)),
|
||||
Provider(
|
||||
create: (context) =>
|
||||
MarineManualPreDepartureService(Provider.of<ApiService>(context, listen: false))),
|
||||
Provider(
|
||||
create: (context) =>
|
||||
MarineManualSondeCalibrationService(Provider.of<ApiService>(context, listen: false))),
|
||||
Provider(
|
||||
create: (context) =>
|
||||
MarineManualEquipmentMaintenanceService(Provider.of<ApiService>(context, listen: false))),
|
||||
],
|
||||
child: const RootApp(),
|
||||
),
|
||||
@ -182,7 +205,6 @@ class RootApp extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _RootAppState extends State<RootApp> {
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@ -255,8 +277,8 @@ class _RootAppState extends State<RootApp> {
|
||||
theme: AppTheme.darkBlueTheme,
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: homeWidget,
|
||||
|
||||
onGenerateRoute: (settings) {
|
||||
// Keep existing onGenerateRoute logic for Tarball
|
||||
if (settings.name == '/marine/manual/tarball/step2') {
|
||||
final args = settings.arguments as TarballSamplingData;
|
||||
return MaterialPageRoute(builder: (context) {
|
||||
@ -274,7 +296,8 @@ class _RootAppState extends State<RootApp> {
|
||||
return const marineManualDataStatusLog.MarineManualDataStatusLog();
|
||||
});
|
||||
}
|
||||
return null;
|
||||
// Add other potential dynamic routes here if necessary
|
||||
return null; // Let routes map handle named routes
|
||||
},
|
||||
routes: {
|
||||
// Auth Routes
|
||||
@ -314,7 +337,8 @@ class _RootAppState extends State<RootApp> {
|
||||
'/river/manual/info': (context) => const RiverManualInfoCentreDocument(),
|
||||
'/river/manual/in-situ': (context) => riverManualInSituSampling.RiverInSituSamplingScreen(),
|
||||
'/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/image-request': (context) => riverManualImageRequest.RiverManualImageRequest(),
|
||||
|
||||
@ -326,9 +350,15 @@ class _RootAppState extends State<RootApp> {
|
||||
|
||||
// River Investigative
|
||||
'/river/investigative/info': (context) => const RiverInvestigativeInfoCentreDocument(),
|
||||
'/river/investigative/overview': (context) => riverInvestigativeOverview.OverviewScreen(),
|
||||
'/river/investigative/entry': (context) => riverInvestigativeEntry.EntryScreen(),
|
||||
'/river/investigative/report': (context) => riverInvestigativeReport.ReportScreen(),
|
||||
// *** ADDED: Route for River Investigative Manual Sampling ***
|
||||
'/river/investigative/manual-sampling': (context) =>
|
||||
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/info': (context) => marineManualInfoCentreDocument.MarineInfoCentreDocument(),
|
||||
@ -337,9 +367,12 @@ class _RootAppState extends State<RootApp> {
|
||||
'/marine/manual/tarball': (context) => const TarballSamplingStep1(),
|
||||
'/marine/manual/report': (context) => const marineManualReport.MarineManualReportHomePage(),
|
||||
'/marine/manual/report/npe': (context) => const MarineManualNPEReportHub(),
|
||||
'/marine/manual/report/pre-departure': (context) => const marineManualPreDepartureChecklist.MarineManualPreDepartureChecklistScreen(),
|
||||
'/marine/manual/report/calibration': (context) => const marineManualSondeCalibration.MarineManualSondeCalibrationScreen(),
|
||||
'/marine/manual/report/maintenance': (context) => const marineManualEquipmentMaintenance.MarineManualEquipmentMaintenanceScreen(),
|
||||
'/marine/manual/report/pre-departure': (context) =>
|
||||
const marineManualPreDepartureChecklist.MarineManualPreDepartureChecklistScreen(),
|
||||
'/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 Continuous
|
||||
@ -350,6 +383,8 @@ class _RootAppState extends State<RootApp> {
|
||||
|
||||
// Marine Investigative
|
||||
'/marine/investigative/info': (context) => const MarineInvestigativeInfoCentreDocument(),
|
||||
'/marine/investigative/manual-sampling': (context) =>
|
||||
marineInvestigativeManualSampling.MarineInvestigativeManualSampling(),
|
||||
'/marine/investigative/overview': (context) => marineInvestigativeOverview.OverviewScreen(),
|
||||
'/marine/investigative/entry': (context) => marineInvestigativeEntry.EntryScreen(),
|
||||
'/marine/investigative/report': (context) => marineInvestigativeReport.ReportScreen(),
|
||||
@ -370,27 +405,62 @@ class SessionAwareWrapper extends StatefulWidget {
|
||||
|
||||
class _SessionAwareWrapperState extends State<SessionAwareWrapper> {
|
||||
bool _isDialogShowing = false;
|
||||
// --- MODIFICATION START ---
|
||||
// 1. Create a variable to hold the AuthProvider instance.
|
||||
late AuthProvider _authProvider;
|
||||
// --- MODIFICATION END ---
|
||||
|
||||
@override
|
||||
void 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) {
|
||||
// Use addPostFrameCallback to show dialog after the build phase.
|
||||
// Call initial check here if needed, or rely on RootApp's check.
|
||||
// _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((_) {
|
||||
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 {
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _isDialogShowing = true);
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false, // User must make a choice
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||
// Use the state's _authProvider reference, which is safe.
|
||||
return AlertDialog(
|
||||
title: const Text("Session Expired"),
|
||||
content: const Text(
|
||||
@ -399,22 +469,26 @@ class _SessionAwareWrapperState extends State<SessionAwareWrapper> {
|
||||
TextButton(
|
||||
child: const Text("Continue Offline"),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop(); // Just close the dialog
|
||||
Navigator.of(dialogContext).pop();
|
||||
// Optionally: _authProvider.clearSessionExpiredFlag(); // If needed
|
||||
},
|
||||
),
|
||||
ElevatedButton(
|
||||
child: const Text("Login Now"),
|
||||
onPressed: () {
|
||||
// Logout clears all state and pushes to login screen via the RootApp builder
|
||||
auth.logout();
|
||||
Navigator.of(dialogContext).pop();
|
||||
// --- MODIFICATION START ---
|
||||
// 5. Use the saved reference to log out.
|
||||
_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) {
|
||||
setState(() => _isDialogShowing = false);
|
||||
}
|
||||
@ -422,7 +496,6 @@ class _SessionAwareWrapperState extends State<SessionAwareWrapper> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This widget just returns its child, its only job is to show the dialog.
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
@ -301,36 +301,12 @@ class InSituSamplingData {
|
||||
'npe_field_observations': npeFieldObservations,
|
||||
'npe_others_observation_remark': npeOthersObservationRemark,
|
||||
'npe_possible_source': npePossibleSource,
|
||||
// Image paths will be added/updated by LocalStorageService during saving/updating
|
||||
};
|
||||
}
|
||||
|
||||
String generateTelegramAlertMessage({required bool isDataOnly}) {
|
||||
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||
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();
|
||||
}
|
||||
// --- REMOVED: generateTelegramAlertMessage method ---
|
||||
// This logic is now in MarineInSituSamplingService
|
||||
|
||||
Map<String, String> toApiFormData() {
|
||||
final Map<String, String> map = {};
|
||||
|
||||
@ -1 +1,361 @@
|
||||
// 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 ---
|
||||
}
|
||||
@ -164,7 +164,23 @@ class RiverInSituSamplingData {
|
||||
|
||||
void add(String key, dynamic value) {
|
||||
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_type', samplingType);
|
||||
add('r_man_sample_id_code', sampleIdCode);
|
||||
// --- START FIX: Use correct key 'station_id' ---
|
||||
add('station_id', selectedStation?['station_id']);
|
||||
// --- END FIX ---
|
||||
add('r_man_current_latitude', currentLatitude);
|
||||
add('r_man_current_longitude', currentLongitude);
|
||||
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,
|
||||
// with keys matching the expected JSON file format.
|
||||
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,
|
||||
'sampling_type': samplingType,
|
||||
'report_id': reportId,
|
||||
'sampler_2ndname': secondSampler?['user_name'],
|
||||
'sampler_2ndname': secondSampler?['first_name'], // Use first_name as likely user name
|
||||
'sample_state': selectedStateName,
|
||||
'station_id': selectedStation?['sampling_station_code'],
|
||||
'tech_id': firstSamplerUserId,
|
||||
'tech_name': firstSamplerName,
|
||||
'latitude': stationLatitude,
|
||||
'longitude': stationLongitude,
|
||||
'latitude': stationLatitude, // Assuming station lat/lon is intended here
|
||||
'longitude': stationLongitude, // Assuming station lat/lon is intended here
|
||||
'record_dt': '$samplingDate $samplingTime',
|
||||
'do_mgl': oxygenConcentration,
|
||||
'do_sat': oxygenSaturation,
|
||||
'ph': ph,
|
||||
'salinity': salinity,
|
||||
'temperature': temperature,
|
||||
'turbidity': turbidity,
|
||||
'tds': tds,
|
||||
'electric_conductivity': electricalConductivity,
|
||||
'ammonia': ammonia, // MODIFIED: Added ammonia
|
||||
'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': '', // Assuming these are not collected in this form
|
||||
'floatable': '', // Assuming these are not collected in this form
|
||||
'odour': '', // Not collected
|
||||
'floatable': '', // Not collected
|
||||
'sample_id': sampleIdCode,
|
||||
'weather': weather,
|
||||
'remarks_event': eventRemarks,
|
||||
'remarks_lab': labRemarks,
|
||||
// --- END FIX ---
|
||||
};
|
||||
// Remove null values before encoding
|
||||
data.removeWhere((key, value) => value == null);
|
||||
return jsonEncode(data);
|
||||
}
|
||||
|
||||
/// Creates a JSON object for basic form info, mimicking 'river_insitu_basic_form.json'.
|
||||
String toBasicFormJson() {
|
||||
final data = {
|
||||
// --- START FIX: Map model properties to correct form keys ---
|
||||
'tech_name': firstSamplerName,
|
||||
'sampler_2ndname': secondSampler?['user_name'],
|
||||
'sampler_2ndname': secondSampler?['first_name'],
|
||||
'sample_date': samplingDate,
|
||||
'sample_time': samplingTime,
|
||||
'sampling_type': samplingType,
|
||||
@ -348,39 +371,50 @@ class RiverInSituSamplingData {
|
||||
'station_id': selectedStation?['sampling_station_code'],
|
||||
'station_latitude': stationLatitude,
|
||||
'station_longitude': stationLongitude,
|
||||
'latitude': currentLatitude,
|
||||
'longitude': currentLongitude,
|
||||
'latitude': currentLatitude, // Current location lat
|
||||
'longitude': currentLongitude, // Current location lon
|
||||
'sample_id': sampleIdCode,
|
||||
// --- END FIX ---
|
||||
};
|
||||
// Remove null values before encoding
|
||||
data.removeWhere((key, value) => value == null);
|
||||
return jsonEncode(data);
|
||||
}
|
||||
|
||||
/// Creates a JSON object for sensor readings, mimicking 'river_sampling_reading.json'.
|
||||
String toReadingJson() {
|
||||
final data = {
|
||||
'do_mgl': oxygenConcentration,
|
||||
'do_sat': oxygenSaturation,
|
||||
'ph': ph,
|
||||
'salinity': salinity,
|
||||
'temperature': temperature,
|
||||
'turbidity': turbidity,
|
||||
'tds': tds,
|
||||
'electric_conductivity': electricalConductivity,
|
||||
'ammonia': ammonia, // MODIFIED: Added ammonia
|
||||
// --- START FIX: Map model properties to correct reading keys ---
|
||||
'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': samplingDate,
|
||||
'time_sampling_reading': samplingTime,
|
||||
'date_sampling_reading': dataCaptureDate, // Use data capture date/time
|
||||
'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);
|
||||
}
|
||||
|
||||
/// Creates a JSON object for manual info, mimicking 'river_manual_info.json'.
|
||||
String toManualInfoJson() {
|
||||
final data = {
|
||||
// --- START FIX: Map model properties to correct manual info keys ---
|
||||
'weather': weather,
|
||||
'remarks_event': eventRemarks,
|
||||
'remarks_lab': labRemarks,
|
||||
// --- END FIX ---
|
||||
};
|
||||
// Remove null values before encoding
|
||||
data.removeWhere((key, value) => value == null);
|
||||
return jsonEncode(data);
|
||||
}
|
||||
}
|
||||
520
lib/models/river_inves_manual_sampling_data.dart
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
// lib/models/river_manual_triennial_sampling_data.dart
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'dart:convert'; // Added for jsonEncode
|
||||
|
||||
/// Data model for the River Manual Triennial Sampling form.
|
||||
class RiverManualTriennialSamplingData {
|
||||
// --- Step 1: Sampling & Station Info ---
|
||||
String? firstSamplerName;
|
||||
@ -14,8 +15,7 @@ class RiverManualTriennialSamplingData {
|
||||
String? sampleIdCode;
|
||||
|
||||
String? selectedStateName;
|
||||
String? selectedCategoryName;
|
||||
Map<String, dynamic>? selectedStation;
|
||||
Map<String, dynamic>? selectedStation; // Triennial stations don't have categories
|
||||
|
||||
String? stationLatitude;
|
||||
String? stationLongitude;
|
||||
@ -45,7 +45,7 @@ class RiverManualTriennialSamplingData {
|
||||
File? optionalImage4;
|
||||
String? optionalRemark4;
|
||||
|
||||
// --- Step 3: Data Capture ---
|
||||
// --- Step 3: Data Capture (Mirrors River In-Situ for now) ---
|
||||
String? sondeId;
|
||||
String? dataCaptureDate;
|
||||
String? dataCaptureTime;
|
||||
@ -57,10 +57,9 @@ class RiverManualTriennialSamplingData {
|
||||
double? temperature;
|
||||
double? tds;
|
||||
double? turbidity;
|
||||
double? ammonia;
|
||||
double? ammonia; // Replaced tss with ammonia
|
||||
double? batteryVoltage;
|
||||
|
||||
// --- ADDED: Missing flowrate properties ---
|
||||
String? flowrateMethod;
|
||||
double? flowrateSurfaceDrifterHeight;
|
||||
double? flowrateSurfaceDrifterDistance;
|
||||
@ -76,37 +75,131 @@ class RiverManualTriennialSamplingData {
|
||||
RiverManualTriennialSamplingData({
|
||||
this.samplingDate,
|
||||
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() {
|
||||
final Map<String, String> map = {};
|
||||
|
||||
void add(String key, dynamic value) {
|
||||
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('r_tri_second_sampler_id', secondSampler?['user_id']);
|
||||
add('r_tri_date', samplingDate);
|
||||
add('r_tri_time', samplingTime);
|
||||
add('r_tri_type', samplingType);
|
||||
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_longitude', currentLongitude);
|
||||
add('r_tri_distance_difference', distanceDifferenceInKm);
|
||||
add('r_tri_distance_difference_remarks', distanceDifferenceRemarks);
|
||||
|
||||
// Step 2 Data
|
||||
add('r_tri_weather', weather);
|
||||
add('r_tri_event_remark', eventRemarks);
|
||||
add('r_tri_lab_remark', labRemarks);
|
||||
|
||||
// Step 4 Data
|
||||
add('r_tri_optional_photo_01_remarks', optionalRemark1);
|
||||
add('r_tri_optional_photo_02_remarks', optionalRemark2);
|
||||
add('r_tri_optional_photo_03_remarks', optionalRemark3);
|
||||
add('r_tri_optional_photo_04_remarks', optionalRemark4);
|
||||
|
||||
// Step 3 Data
|
||||
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('r_tri_oxygen_conc', oxygenConcentration);
|
||||
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_last', flowrateSurfaceDrifterTimeLast);
|
||||
add('r_tri_flowrate_value', flowrateValue);
|
||||
|
||||
// Additional data for display or logging
|
||||
add('first_sampler_name', firstSamplerName);
|
||||
add('r_tri_station_code', selectedStation?['sampling_station_code']);
|
||||
add('r_tri_station_name', selectedStation?['sampling_river']);
|
||||
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/// Converts the image properties into a Map<String, File?> for the multipart API request.
|
||||
Map<String, File?> toApiImageFiles() {
|
||||
return {
|
||||
'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() {
|
||||
return {
|
||||
'firstSamplerName': firstSamplerName,
|
||||
@ -155,7 +252,6 @@ class RiverManualTriennialSamplingData {
|
||||
'samplingType': samplingType,
|
||||
'sampleIdCode': sampleIdCode,
|
||||
'selectedStateName': selectedStateName,
|
||||
'selectedCategoryName': selectedCategoryName,
|
||||
'selectedStation': selectedStation,
|
||||
'stationLatitude': stationLatitude,
|
||||
'stationLongitude': stationLongitude,
|
||||
@ -202,4 +298,40 @@ class RiverManualTriennialSamplingData {
|
||||
'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);
|
||||
}
|
||||
}
|
||||
@ -54,7 +54,7 @@ class TarballSamplingData {
|
||||
npeData.firstSamplerUserId = firstSamplerUserId;
|
||||
npeData.eventDate = samplingDate;
|
||||
npeData.eventTime = samplingTime;
|
||||
npeData.selectedStation = selectedStation;
|
||||
npeData.selectedStation = selectedStation; // Pass the whole station map
|
||||
npeData.latitude = currentLatitude;
|
||||
npeData.longitude = currentLongitude;
|
||||
npeData.stateName = selectedStateName;
|
||||
@ -62,40 +62,29 @@ class TarballSamplingData {
|
||||
// Pre-tick the relevant observation for a tarball event.
|
||||
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;
|
||||
}
|
||||
|
||||
/// Generates a formatted Telegram alert message for successful submissions.
|
||||
String generateTelegramAlertMessage({required bool isDataOnly}) {
|
||||
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();
|
||||
}
|
||||
// --- REMOVED: generateTelegramAlertMessage method ---
|
||||
// Logic moved to MarineTarballSamplingService
|
||||
|
||||
/// 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.
|
||||
@ -113,7 +102,7 @@ class TarballSamplingData {
|
||||
'current_latitude': currentLatitude ?? '',
|
||||
'current_longitude': currentLongitude ?? '',
|
||||
'distance_difference': distanceDifference?.toString() ?? '',
|
||||
'distance_remarks': distanceDifferenceRemarks ?? '',
|
||||
'distance_remarks': distanceDifferenceRemarks ?? '', // Corrected key based on service
|
||||
'optional_photo_remark_01': optionalRemark1 ?? '',
|
||||
'optional_photo_remark_02': optionalRemark2 ?? '',
|
||||
'optional_photo_remark_03': optionalRemark3 ?? '',
|
||||
@ -125,6 +114,8 @@ class TarballSamplingData {
|
||||
'first_sampler_name': firstSampler ?? '',
|
||||
'classification_name': selectedClassification?['classification_name']?.toString() ?? '',
|
||||
};
|
||||
// Remove keys with empty string values before sending
|
||||
data.removeWhere((key, value) => value.isEmpty);
|
||||
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() {
|
||||
// Include image paths for local storage
|
||||
return {
|
||||
'firstSampler': firstSampler,
|
||||
'firstSamplerUserId': firstSamplerUserId,
|
||||
@ -162,9 +154,17 @@ class TarballSamplingData {
|
||||
'distanceDifferenceRemarks': distanceDifferenceRemarks,
|
||||
'classificationId': classificationId,
|
||||
'selectedClassification': selectedClassification,
|
||||
'leftCoastalViewImage': leftCoastalViewImage?.path,
|
||||
'rightCoastalViewImage': rightCoastalViewImage?.path,
|
||||
'verticalLinesImage': verticalLinesImage?.path,
|
||||
'horizontalLineImage': horizontalLineImage?.path,
|
||||
'optionalImage1': optionalImage1?.path,
|
||||
'optionalRemark1': optionalRemark1,
|
||||
'optionalImage2': optionalImage2?.path,
|
||||
'optionalRemark2': optionalRemark2,
|
||||
'optionalImage3': optionalImage3?.path,
|
||||
'optionalRemark3': optionalRemark3,
|
||||
'optionalImage4': optionalImage4?.path,
|
||||
'optionalRemark4': optionalRemark4,
|
||||
'reportId': reportId,
|
||||
'submissionStatus': submissionStatus,
|
||||
@ -174,24 +174,26 @@ class TarballSamplingData {
|
||||
|
||||
/// Creates a JSON object for basic form info, mimicking 'basic_form.json'.
|
||||
Map<String, dynamic> toBasicFormJson() {
|
||||
return {
|
||||
final data = {
|
||||
'tech_name': firstSampler,
|
||||
'sampler_2ndname': secondSampler?['user_name'],
|
||||
'sampler_2ndname': secondSampler?['first_name'], // Assuming first_name is appropriate
|
||||
'sample_date': samplingDate,
|
||||
'sample_time': samplingTime,
|
||||
'sample_state': selectedStateName,
|
||||
'station_id': selectedStation?['tbl_station_code'],
|
||||
'station_id': selectedStation?['tbl_station_code'], // Use station code
|
||||
'station_latitude': stationLatitude,
|
||||
'station_longitude': stationLongitude,
|
||||
'latitude': currentLatitude,
|
||||
'longitude': currentLongitude,
|
||||
'sample_id': reportId, // Using reportId as a unique identifier for the sample.
|
||||
'latitude': currentLatitude, // Current location
|
||||
'longitude': currentLongitude, // Current location
|
||||
'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'.
|
||||
Map<String, dynamic> toReadingJson() {
|
||||
return {
|
||||
final data = {
|
||||
'classification': selectedClassification?['classification_name'],
|
||||
'classification_id': classificationId,
|
||||
'optional_remark_1': optionalRemark1,
|
||||
@ -199,17 +201,21 @@ class TarballSamplingData {
|
||||
'optional_remark_3': optionalRemark3,
|
||||
'optional_remark_4': optionalRemark4,
|
||||
'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'.
|
||||
Map<String, dynamic> toManualInfoJson() {
|
||||
return {
|
||||
// Tarball forms don't have a specific 'weather' or general remarks field,
|
||||
// so we use the distance remarks as a stand-in if available.
|
||||
'remarks_event': distanceDifferenceRemarks,
|
||||
'remarks_lab': null,
|
||||
final data = {
|
||||
// Tarball forms don't have weather or general remarks separate from distance
|
||||
'weather': null, // Explicitly null if not collected
|
||||
'remarks_event': distanceDifferenceRemarks, // Use distance remarks if relevant
|
||||
'remarks_lab': null, // Explicitly null if not collected
|
||||
};
|
||||
data.removeWhere((key, value) => value == null || (value is String && value.isEmpty));
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@ -1 +1,807 @@
|
||||
// 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1 +1,220 @@
|
||||
// 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)
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1 +1,832 @@
|
||||
// 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),
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1 +1,484 @@
|
||||
// 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1 +1,125 @@
|
||||
// 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
// lib/screens/marine/marine_home_page.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// Re-defining SidebarItem here for self-containment,
|
||||
@ -60,6 +62,8 @@ class MarineHomePage extends StatelessWidget {
|
||||
children: [
|
||||
// MODIFIED: Updated label, icon, and route for the new Info Centre screen
|
||||
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.input, label: "Entry", route: '/marine/investigative/entry'),
|
||||
//SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/investigative/report'),
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 ---
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -59,9 +59,11 @@ class RiverHomePage extends StatelessWidget {
|
||||
children: [
|
||||
// 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.info, label: "Overview", route: '/river/investigative/overview'),
|
||||
//SidebarItem(icon: Icons.input, label: "Entry", route: '/river/investigative/entry'),
|
||||
//SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/investigative/report'),
|
||||
// *** ADDED: Link to River Investigative Manual Sampling ***
|
||||
SidebarItem(icon: Icons.biotech, label: "Investigative Sampling", route: '/river/investigative/manual-sampling'), // Added Icon
|
||||
// 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!);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(0),
|
||||
borderRadius: BorderRadius.circular(0), // No rounded corners for grid items
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(4.0), // Added margin for better spacing
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.white24, width: 0.5), // Optional: subtle border
|
||||
// No background color unless desired
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start, // Align content to start
|
||||
children: [
|
||||
subItem.icon != null
|
||||
? Icon(subItem.icon, color: Colors.white70, size: 24)
|
||||
: const SizedBox.shrink(),
|
||||
? Icon(subItem.icon, color: Colors.white70, size: 24) // Adjusted icon size
|
||||
: const SizedBox.shrink(), // Or provide a placeholder
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
Expanded( // Allow text to take remaining space
|
||||
child: Text(
|
||||
subItem.label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white70,
|
||||
color: Colors.white70, // Slightly lighter text
|
||||
fontSize: 12, // Slightly increased font size
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.left, // Left align text
|
||||
overflow: TextOverflow.ellipsis, // Prevent overflow
|
||||
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
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -16,6 +16,11 @@ import '../models/in_situ_sampling_data.dart';
|
||||
import '../models/marine_manual_npe_report_data.dart';
|
||||
import '../models/river_in_situ_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 {
|
||||
|
||||
@ -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 ---
|
||||
|
||||
@ -166,21 +166,26 @@ class MarineInSituSamplingService {
|
||||
required InSituSamplingData data,
|
||||
required List<Map<String, dynamic>>? appSettings,
|
||||
required AuthProvider authProvider,
|
||||
BuildContext? context,
|
||||
BuildContext? context, // Context no longer needed here, but kept for signature consistency
|
||||
String? logDirectory,
|
||||
}) async {
|
||||
const String moduleName = 'marine_in_situ';
|
||||
|
||||
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);
|
||||
|
||||
if (isOnline && isOfflineSession) {
|
||||
debugPrint("In-Situ 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;
|
||||
}
|
||||
}
|
||||
@ -199,15 +204,17 @@ class MarineInSituSamplingService {
|
||||
return await _performOfflineQueuing(
|
||||
data: data,
|
||||
moduleName: moduleName,
|
||||
logDirectory: logDirectory, // Pass for potential update
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the online submission flow using generic services.
|
||||
Future<Map<String, dynamic>> _performOnlineSubmission({
|
||||
required InSituSamplingData data,
|
||||
required List<Map<String, dynamic>>? appSettings,
|
||||
required String moduleName,
|
||||
required AuthProvider authProvider,
|
||||
required AuthProvider authProvider, // Still needed for session check inside this method
|
||||
String? logDirectory,
|
||||
}) async {
|
||||
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
|
||||
@ -218,25 +225,29 @@ class MarineInSituSamplingService {
|
||||
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/manual/sample',
|
||||
endpoint: 'marine/manual/sample', // Correct endpoint for In-Situ data
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
|
||||
if (apiDataResult['success'] == 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 (finalImageFiles.isNotEmpty) {
|
||||
// 2. Submit Images
|
||||
apiImageResult = await _submissionApiService.submitMultipart(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine/manual/images',
|
||||
fields: {'man_id': data.reportId!},
|
||||
endpoint: 'marine/manual/images', // Correct endpoint for In-Situ images
|
||||
fields: {'man_id': data.reportId!}, // Correct field key for In-Situ
|
||||
files: finalImageFiles,
|
||||
);
|
||||
if (apiImageResult['success'] != true) {
|
||||
@ -248,65 +259,92 @@ class MarineInSituSamplingService {
|
||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
||||
}
|
||||
}
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin...");
|
||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
||||
// If apiDataResult['success'] is false, SubmissionApiService queued it.
|
||||
|
||||
if (reloginSuccess) {
|
||||
debugPrint("Silent relogin successful. Retrying entire online submission process...");
|
||||
return await _performOnlineSubmission(
|
||||
data: data,
|
||||
appSettings: appSettings,
|
||||
moduleName: moduleName,
|
||||
authProvider: authProvider,
|
||||
logDirectory: logDirectory,
|
||||
);
|
||||
} else {
|
||||
debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP.");
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("Online submission failed due to session expiry that could not be refreshed.");
|
||||
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;
|
||||
apiDataResult = {'success': false, 'message': errorMessage};
|
||||
// Manually queue API calls
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/manual/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/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': []};
|
||||
bool anyFtpSuccess = false;
|
||||
|
||||
if (isSessionKnownToBeExpired) {
|
||||
debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
|
||||
final baseFileNameForQueue = _generateBaseFileName(data); // Use helper
|
||||
|
||||
// --- 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 {
|
||||
try {
|
||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||
} on SocketException catch (e) {
|
||||
debugPrint("FTP submission failed with network error: $e");
|
||||
anyFtpSuccess = false;
|
||||
} on TimeoutException catch (e) {
|
||||
debugPrint("FTP submission timed out: $e");
|
||||
} catch (e) {
|
||||
debugPrint("Unexpected FTP submission error: $e");
|
||||
anyFtpSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Determine Final Status
|
||||
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
|
||||
String finalMessage;
|
||||
String finalStatus;
|
||||
|
||||
if (anyApiSuccess && anyFtpSuccess) {
|
||||
finalMessage = 'Data submitted successfully to all destinations.';
|
||||
finalStatus = 'S4';
|
||||
} else if (anyApiSuccess && !anyFtpSuccess) {
|
||||
finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.';
|
||||
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.';
|
||||
@ -316,6 +354,7 @@ class MarineInSituSamplingService {
|
||||
finalStatus = 'L1';
|
||||
}
|
||||
|
||||
// 5. Log Locally
|
||||
await _logAndSave(
|
||||
data: data,
|
||||
status: finalStatus,
|
||||
@ -323,10 +362,11 @@ class MarineInSituSamplingService {
|
||||
apiResults: [apiDataResult, apiImageResult],
|
||||
ftpStatuses: ftpResults['statuses'],
|
||||
serverName: serverName,
|
||||
finalImageFiles: finalImageFiles,
|
||||
finalImageFiles: finalImageFiles, // Pass the map of actual files
|
||||
logDirectory: logDirectory,
|
||||
);
|
||||
|
||||
// 6. Send Alert
|
||||
if (overallSuccess) {
|
||||
_handleInSituSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
||||
}
|
||||
@ -334,55 +374,88 @@ class MarineInSituSamplingService {
|
||||
return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId};
|
||||
}
|
||||
|
||||
|
||||
/// Handles queuing the submission data when the device is offline.
|
||||
Future<Map<String, dynamic>> _performOfflineQueuing({
|
||||
required InSituSamplingData data,
|
||||
required String moduleName,
|
||||
String? logDirectory, // Added for potential update
|
||||
}) async {
|
||||
final serverConfig = await _serverConfigService.getActiveApiConfig();
|
||||
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
|
||||
|
||||
// Set initial status before first save
|
||||
data.submissionStatus = 'L1';
|
||||
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.";
|
||||
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};
|
||||
}
|
||||
|
||||
// Use the correct task type for In-Situ retry
|
||||
await _retryService.queueTask(
|
||||
type: 'insitu_submission',
|
||||
payload: {
|
||||
'module': moduleName,
|
||||
'localLogPath': localLogPath,
|
||||
'localLogPath': savedLogPath, // Pass directory path
|
||||
'serverConfig': serverConfig,
|
||||
},
|
||||
);
|
||||
|
||||
const successMessage = "Submission failed to send and has been queued for later retry.";
|
||||
return {'success': true, 'message': successMessage};
|
||||
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
|
||||
}
|
||||
|
||||
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 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(
|
||||
serverName: serverName,
|
||||
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()) {
|
||||
await localSubmissionDir.create(recursive: true);
|
||||
}
|
||||
|
||||
// Create and upload data ZIP
|
||||
final dataZip = await _zippingService.createDataZip(
|
||||
jsonDataMap: {'db.json': jsonEncode(data.toDbJson())},
|
||||
jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, // Use toDbJson for FTP
|
||||
baseFileName: baseFileName,
|
||||
destinationDir: localSubmissionDir,
|
||||
);
|
||||
@ -395,6 +468,7 @@ class MarineInSituSamplingService {
|
||||
);
|
||||
}
|
||||
|
||||
// Create and upload image ZIP
|
||||
final imageZip = await _zippingService.createImageZip(
|
||||
imageFiles: imageFiles.values.toList(),
|
||||
baseFileName: baseFileName,
|
||||
@ -411,12 +485,13 @@ class MarineInSituSamplingService {
|
||||
|
||||
return {
|
||||
'statuses': <Map<String, dynamic>>[
|
||||
...(ftpDataResult['statuses'] as List<dynamic>? ?? []),
|
||||
...(ftpImageResult['statuses'] as List<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.
|
||||
Future<void> _logAndSave({
|
||||
required InSituSamplingData data,
|
||||
required String status,
|
||||
@ -424,64 +499,110 @@ class MarineInSituSamplingService {
|
||||
required List<Map<String, dynamic>> apiResults,
|
||||
required List<Map<String, dynamic>> ftpStatuses,
|
||||
required String serverName,
|
||||
required Map<String, File> finalImageFiles,
|
||||
required Map<String, File> finalImageFiles, // Changed to Map<String, File>
|
||||
String? logDirectory,
|
||||
}) async {
|
||||
data.submissionStatus = status;
|
||||
data.submissionMessage = message;
|
||||
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
|
||||
final baseFileName = _generateBaseFileName(data); // Use helper
|
||||
|
||||
if (logDirectory != null) {
|
||||
final Map<String, dynamic> updatedLogData = data.toDbJson();
|
||||
|
||||
updatedLogData['submissionStatus'] = status;
|
||||
updatedLogData['submissionMessage'] = message;
|
||||
|
||||
updatedLogData['logDirectory'] = logDirectory;
|
||||
updatedLogData['serverConfigName'] = serverName;
|
||||
updatedLogData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
|
||||
updatedLogData['ftp_status'] = jsonEncode(ftpStatuses);
|
||||
|
||||
final imageFilePaths = data.toApiImageFiles();
|
||||
imageFilePaths.forEach((key, file) {
|
||||
if (file != null) {
|
||||
updatedLogData[key] = file.path;
|
||||
}
|
||||
// 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);
|
||||
|
||||
await _localStorageService.updateInSituLog(updatedLogData);
|
||||
if (logDirectory != null && logDirectory.isNotEmpty) {
|
||||
// Update existing log
|
||||
logMapData['logDirectory'] = logDirectory; // Ensure logDirectory path is in the map
|
||||
await _localStorageService.updateInSituLog(logMapData);
|
||||
} else {
|
||||
// Save new log - saveInSituSamplingData handles adding file paths
|
||||
await _localStorageService.saveInSituSamplingData(data, serverName: serverName);
|
||||
}
|
||||
|
||||
|
||||
// Save to central DB log
|
||||
final logData = {
|
||||
'submission_id': data.reportId ?? fileTimestamp,
|
||||
'submission_id': data.reportId ?? baseFileName, // Use helper result
|
||||
'module': 'marine',
|
||||
'type': 'In-Situ',
|
||||
'status': data.submissionStatus,
|
||||
'message': data.submissionMessage,
|
||||
'type': 'In-Situ', // Correct type
|
||||
'status': status,
|
||||
'message': message,
|
||||
'report_id': data.reportId,
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toDbJson()),
|
||||
'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()),
|
||||
'form_data': jsonEncode(logMapData), // Log the comprehensive map with paths
|
||||
'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()), // List of paths for files actually submitted/zipped
|
||||
'server_name': serverName,
|
||||
'api_status': jsonEncode(apiResults),
|
||||
'ftp_status': jsonEncode(ftpStatuses),
|
||||
};
|
||||
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 {
|
||||
|
||||
// --- 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 {
|
||||
final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly);
|
||||
final message = generateInSituTelegramAlertMessage(data, isDataOnly: isDataOnly); // Call local function
|
||||
final alertKey = 'marine_in_situ'; // Correct key
|
||||
|
||||
if (isSessionExpired) {
|
||||
debugPrint("Session is expired; queuing Telegram alert directly.");
|
||||
await _telegramService.queueMessage('marine_in_situ', message, appSettings);
|
||||
debugPrint("Session is expired; queuing Telegram alert directly for $alertKey.");
|
||||
await _telegramService.queueMessage(alertKey, message, appSettings);
|
||||
} else {
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings);
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately(alertKey, message, appSettings);
|
||||
if (!wasSent) {
|
||||
await _telegramService.queueMessage('marine_in_situ', message, appSettings);
|
||||
await _telegramService.queueMessage(alertKey, message, appSettings);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
629
lib/services/marine_investigative_sampling_service.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -37,38 +37,62 @@ class MarineTarballSamplingService {
|
||||
Future<Map<String, dynamic>> submitTarballSample({
|
||||
required TarballSamplingData data,
|
||||
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 {
|
||||
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();
|
||||
bool isOnline = connectivityResult != ConnectivityResult.none;
|
||||
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
|
||||
bool isOnline = !connectivityResult.contains(ConnectivityResult.none);
|
||||
// --- START FIX: Handle potentially null authProvider ---
|
||||
bool isOfflineSession = authProvider != null && authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
|
||||
|
||||
if (isOnline && isOfflineSession) {
|
||||
debugPrint("Submission initiated online during an offline session. Attempting auto-relogin...");
|
||||
if (isOnline && isOfflineSession && authProvider != null) {
|
||||
debugPrint("Tarball submission online during an 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;
|
||||
}
|
||||
}
|
||||
// --- END FIX ---
|
||||
|
||||
|
||||
if (isOnline && !isOfflineSession) {
|
||||
debugPrint("Proceeding with direct ONLINE submission...");
|
||||
debugPrint("Proceeding with direct ONLINE Tarball submission...");
|
||||
return await _performOnlineSubmission(
|
||||
data: data,
|
||||
appSettings: appSettings,
|
||||
moduleName: moduleName,
|
||||
authProvider: authProvider,
|
||||
authProvider: authProvider, // Pass potentially null provider
|
||||
logDirectory: logDirectory,
|
||||
);
|
||||
} else {
|
||||
debugPrint("Proceeding with OFFLINE queuing mechanism...");
|
||||
debugPrint("Proceeding with OFFLINE Tarball queuing mechanism...");
|
||||
return await _performOfflineQueuing(
|
||||
data: data,
|
||||
moduleName: moduleName,
|
||||
logDirectory: logDirectory, // Pass logDirectory for potential update
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -77,7 +101,8 @@ class MarineTarballSamplingService {
|
||||
required TarballSamplingData data,
|
||||
required List<Map<String, dynamic>>? appSettings,
|
||||
required String moduleName,
|
||||
required AuthProvider authProvider,
|
||||
required AuthProvider? authProvider, // Accept potentially null provider
|
||||
String? logDirectory, // Added for retry consistency
|
||||
}) async {
|
||||
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
|
||||
final imageFiles = data.toImageFiles()..removeWhere((key, value) => value == null);
|
||||
@ -86,92 +111,133 @@ class MarineTarballSamplingService {
|
||||
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/tarball/sample',
|
||||
body: data.toFormData(),
|
||||
endpoint: 'marine/tarball/sample', // Correct endpoint
|
||||
body: data.toFormData(), // Use specific method for tarball form data
|
||||
);
|
||||
|
||||
if (apiDataResult['success'] == true) {
|
||||
anyApiSuccess = true;
|
||||
data.reportId = apiDataResult['data']?['autoid']?.toString();
|
||||
data.reportId = apiDataResult['data']?['autoid']?.toString(); // Correct ID key
|
||||
|
||||
if (data.reportId != null) {
|
||||
if (finalImageFiles.isNotEmpty) {
|
||||
// 2. Submit Images
|
||||
apiImageResult = await _submissionApiService.submitMultipart(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'marine/tarball/images',
|
||||
fields: {'autoid': data.reportId!},
|
||||
endpoint: 'marine/tarball/images', // Correct endpoint
|
||||
fields: {'autoid': data.reportId!}, // Correct field key
|
||||
files: finalImageFiles,
|
||||
);
|
||||
if (apiImageResult['success'] != true) {
|
||||
anyApiSuccess = false; // Downgrade success if images fail
|
||||
}
|
||||
}
|
||||
// If data succeeded but no images, API part is still successful
|
||||
} else {
|
||||
anyApiSuccess = false;
|
||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
||||
}
|
||||
}
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin...");
|
||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
||||
// If apiDataResult['success'] is false, SubmissionApiService queued it.
|
||||
|
||||
if (reloginSuccess) {
|
||||
debugPrint("Silent relogin successful. Retrying entire online submission process...");
|
||||
return await _performOnlineSubmission(
|
||||
data: data,
|
||||
appSettings: appSettings,
|
||||
moduleName: moduleName,
|
||||
authProvider: authProvider,
|
||||
);
|
||||
} else {
|
||||
debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP.");
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("API submission failed with SessionExpiredException during online submission.");
|
||||
isSessionKnownToBeExpired = true;
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
||||
await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData());
|
||||
}
|
||||
} on SocketException catch (e) {
|
||||
final errorMessage = "API submission failed with network error: $e";
|
||||
debugPrint(errorMessage);
|
||||
anyApiSuccess = false;
|
||||
apiDataResult = {'success': false, 'message': errorMessage};
|
||||
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());
|
||||
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);
|
||||
}
|
||||
} 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': []};
|
||||
bool anyFtpSuccess = false;
|
||||
try {
|
||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||
anyFtpSuccess = !ftpResults['statuses'].any((status) => status['success'] == false);
|
||||
} on SocketException catch (e) {
|
||||
debugPrint("FTP submission failed with network error: $e");
|
||||
anyFtpSuccess = false;
|
||||
} on TimeoutException catch (e) {
|
||||
debugPrint("FTP submission timed out: $e");
|
||||
anyFtpSuccess = false;
|
||||
|
||||
if (isSessionKnownToBeExpired) {
|
||||
debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
|
||||
final baseFileNameForQueue = _generateBaseFileName(data); // Use helper
|
||||
|
||||
// --- START FIX: Add ftpConfigId when queuing ---
|
||||
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||
|
||||
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;
|
||||
} 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;
|
||||
String finalMessage;
|
||||
String finalStatus;
|
||||
|
||||
if (anyApiSuccess && anyFtpSuccess) {
|
||||
finalMessage = 'Data submitted successfully to all destinations.';
|
||||
finalStatus = 'S4';
|
||||
} else if (anyApiSuccess && !anyFtpSuccess) {
|
||||
finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.';
|
||||
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.';
|
||||
@ -181,6 +247,7 @@ class MarineTarballSamplingService {
|
||||
finalStatus = 'L1';
|
||||
}
|
||||
|
||||
// 5. Log Locally
|
||||
await _logAndSave(
|
||||
data: data,
|
||||
status: finalStatus,
|
||||
@ -189,8 +256,10 @@ class MarineTarballSamplingService {
|
||||
ftpStatuses: ftpResults['statuses'],
|
||||
serverName: serverName,
|
||||
finalImageFiles: finalImageFiles,
|
||||
logDirectory: logDirectory, // Pass logDirectory for potential update
|
||||
);
|
||||
|
||||
// 6. Send Alert
|
||||
if (overallSuccess) {
|
||||
_handleTarballSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
||||
}
|
||||
@ -201,50 +270,82 @@ class MarineTarballSamplingService {
|
||||
Future<Map<String, dynamic>> _performOfflineQueuing({
|
||||
required TarballSamplingData data,
|
||||
required String moduleName,
|
||||
String? logDirectory, // Added for potential update
|
||||
}) async {
|
||||
final serverConfig = await _serverConfigService.getActiveApiConfig();
|
||||
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.";
|
||||
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};
|
||||
}
|
||||
|
||||
// Queue a single task for the RetryService
|
||||
await _retryService.queueTask(
|
||||
type: 'tarball_submission',
|
||||
type: 'tarball_submission', // Use specific type
|
||||
payload: {
|
||||
'module': moduleName,
|
||||
'localLogPath': localLogPath,
|
||||
'localLogPath': savedLogPath, // Point retry service to the saved log *directory*
|
||||
'serverConfig': serverConfig,
|
||||
},
|
||||
);
|
||||
|
||||
const successMessage = "Submission failed to send and has been queued for later retry.";
|
||||
await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {});
|
||||
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};
|
||||
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 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(
|
||||
serverName: serverName,
|
||||
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()) {
|
||||
await localSubmissionDir.create(recursive: true);
|
||||
}
|
||||
|
||||
// Create and upload data ZIP (with multiple JSON files specific to Tarball)
|
||||
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,
|
||||
destinationDir: localSubmissionDir,
|
||||
);
|
||||
@ -257,6 +358,7 @@ class MarineTarballSamplingService {
|
||||
);
|
||||
}
|
||||
|
||||
// Create and upload image ZIP
|
||||
final imageZip = await _zippingService.createImageZip(
|
||||
imageFiles: imageFiles.values.toList(),
|
||||
baseFileName: baseFileName,
|
||||
@ -273,12 +375,13 @@ class MarineTarballSamplingService {
|
||||
|
||||
return {
|
||||
'statuses': <Map<String, dynamic>>[
|
||||
...?(ftpDataResult['statuses'] as List?),
|
||||
...?(ftpImageResult['statuses'] as List?),
|
||||
...(ftpDataResult['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({
|
||||
required TarballSamplingData data,
|
||||
required String status,
|
||||
@ -287,40 +390,103 @@ class MarineTarballSamplingService {
|
||||
required List<Map<String, dynamic>> ftpStatuses,
|
||||
required String serverName,
|
||||
required Map<String, File> finalImageFiles,
|
||||
String? logDirectory, // Added for potential update
|
||||
}) async {
|
||||
data.submissionStatus = status;
|
||||
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 = {
|
||||
'submission_id': data.reportId ?? fileTimestamp,
|
||||
'module': 'marine',
|
||||
'type': 'Tarball',
|
||||
'submission_id': data.reportId ?? baseFileName, // Use helper result
|
||||
'module': 'marine', // Correct module
|
||||
'type': 'Tarball', // Correct type
|
||||
'status': status,
|
||||
'message': message,
|
||||
'report_id': data.reportId,
|
||||
'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()),
|
||||
'server_name': serverName,
|
||||
'api_status': jsonEncode(apiResults),
|
||||
'ftp_status': jsonEncode(ftpStatuses),
|
||||
};
|
||||
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 {
|
||||
|
||||
// --- 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 {
|
||||
final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly);
|
||||
final message = generateTarballTelegramAlertMessage(data, isDataOnly: isDataOnly); // Call local function
|
||||
final alertKey = 'marine_tarball'; // Correct key
|
||||
|
||||
if (isSessionExpired) {
|
||||
debugPrint("Session is expired; queuing Telegram alert directly.");
|
||||
await _telegramService.queueMessage('marine_tarball', message, appSettings);
|
||||
debugPrint("Session is expired; queuing Telegram alert directly for $alertKey.");
|
||||
await _telegramService.queueMessage(alertKey, message, appSettings);
|
||||
} else {
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('marine_tarball', message, appSettings);
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately(alertKey, message, appSettings);
|
||||
if (!wasSent) {
|
||||
await _telegramService.queueMessage('marine_tarball', message, appSettings);
|
||||
await _telegramService.queueMessage(alertKey, message, appSettings);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@ -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/services/marine_in_situ_sampling_service.dart';
|
||||
import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.dart'; // ADDED
|
||||
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart'; // ADDED
|
||||
import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.dart';
|
||||
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/base_api_service.dart';
|
||||
import 'package:environment_monitoring_app/services/ftp_service.dart';
|
||||
@ -23,23 +31,32 @@ class RetryService {
|
||||
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||
bool _isProcessing = false;
|
||||
|
||||
// --- START: MODIFICATION FOR HANDLING COMPLEX TASKS ---
|
||||
// These services will be provided after the RetryService is created.
|
||||
MarineInSituSamplingService? _marineInSituService;
|
||||
RiverInSituSamplingService? _riverInSituService; // ADDED
|
||||
RiverInSituSamplingService? _riverInSituService;
|
||||
MarineInvestigativeSamplingService? _marineInvestigativeService;
|
||||
MarineTarballSamplingService? _marineTarballService;
|
||||
// *** ADDED: River Investigative Service member ***
|
||||
RiverInvestigativeSamplingService? _riverInvestigativeService;
|
||||
// *** END ADDED ***
|
||||
AuthProvider? _authProvider;
|
||||
|
||||
// Call this method from your main app setup to provide the necessary services.
|
||||
// *** MODIFIED: Added riverInvestigativeService to initialize ***
|
||||
void initialize({
|
||||
required MarineInSituSamplingService marineInSituService,
|
||||
required RiverInSituSamplingService riverInSituService, // ADDED
|
||||
required RiverInSituSamplingService riverInSituService,
|
||||
required MarineInvestigativeSamplingService marineInvestigativeService,
|
||||
required RiverInvestigativeSamplingService riverInvestigativeService, // <-- Added parameter
|
||||
required MarineTarballSamplingService marineTarballService,
|
||||
required AuthProvider authProvider,
|
||||
}) {
|
||||
_marineInSituService = marineInSituService;
|
||||
_riverInSituService = riverInSituService; // ADDED
|
||||
_riverInSituService = riverInSituService;
|
||||
_marineInvestigativeService = marineInvestigativeService;
|
||||
_riverInvestigativeService = riverInvestigativeService; // <-- Assign parameter
|
||||
_marineTarballService = marineTarballService;
|
||||
_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.
|
||||
@ -49,7 +66,7 @@ class RetryService {
|
||||
}) async {
|
||||
await _dbHelper.queueFailedRequest({
|
||||
'type': type,
|
||||
'endpoint_or_path': 'N/A',
|
||||
'endpoint_or_path': 'N/A', // Not applicable for complex tasks initially
|
||||
'payload': jsonEncode(payload),
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
'status': 'pending',
|
||||
@ -65,12 +82,13 @@ class RetryService {
|
||||
Map<String, String>? fields,
|
||||
Map<String, File>? files,
|
||||
}) async {
|
||||
// Convert File objects to paths for JSON serialization
|
||||
final serializableFiles = files?.map((key, value) => MapEntry(key, value.path));
|
||||
final payload = {
|
||||
'method': method,
|
||||
'body': body,
|
||||
'fields': fields,
|
||||
'files': serializableFiles,
|
||||
'files': serializableFiles, // Store paths instead of File objects
|
||||
};
|
||||
await _dbHelper.queueFailedRequest({
|
||||
'type': 'api',
|
||||
@ -86,8 +104,12 @@ class RetryService {
|
||||
Future<void> addFtpToQueue({
|
||||
required String localFilePath,
|
||||
required String remotePath,
|
||||
required int ftpConfigId, // Added to specify which destination failed
|
||||
}) async {
|
||||
final payload = {'localFilePath': localFilePath};
|
||||
final payload = {
|
||||
'localFilePath': localFilePath,
|
||||
'ftpConfigId': ftpConfigId, // Store the specific config ID
|
||||
};
|
||||
await _dbHelper.queueFailedRequest({
|
||||
'type': 'ftp',
|
||||
'endpoint_or_path': remotePath,
|
||||
@ -95,9 +117,10 @@ class RetryService {
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
'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.
|
||||
Future<List<Map<String, dynamic>>> getPendingTasks() {
|
||||
return _dbHelper.getPendingRequests();
|
||||
@ -119,6 +142,7 @@ class RetryService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check internet connection *before* processing
|
||||
if (_authProvider == null || !await _authProvider!.isConnected()) {
|
||||
debugPrint("[RetryService] ❌ No internet connection. Aborting queue processing.");
|
||||
_isProcessing = false;
|
||||
@ -126,9 +150,15 @@ class RetryService {
|
||||
}
|
||||
|
||||
debugPrint("[RetryService] 🔎 Found ${pendingTasks.length} pending tasks.");
|
||||
// Process tasks one by one
|
||||
for (final task in pendingTasks) {
|
||||
// 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.");
|
||||
_isProcessing = false;
|
||||
@ -139,28 +169,44 @@ class RetryService {
|
||||
Future<bool> retryTask(int taskId) async {
|
||||
final task = await _dbHelper.getRequestById(taskId);
|
||||
if (task == null) {
|
||||
debugPrint("Retry failed: Task with ID $taskId not found in the queue.");
|
||||
return false;
|
||||
debugPrint("Retry failed: Task with ID $taskId not found in the queue (might have been processed already).");
|
||||
return false; // Task doesn't exist or was processed elsewhere
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
debugPrint("RetryService has not been initialized. Cannot process task.");
|
||||
debugPrint("RetryService has not been initialized. Cannot process task $taskId.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (task['type'] == 'insitu_submission') {
|
||||
// --- Complex Task Handlers ---
|
||||
if (taskType == 'insitu_submission') {
|
||||
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 file = File(logFilePath);
|
||||
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 $logFilePath");
|
||||
debugPrint("Retry failed: Source log file no longer exists at $jsonFilePath");
|
||||
await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task
|
||||
return false;
|
||||
}
|
||||
@ -168,34 +214,37 @@ class RetryService {
|
||||
final content = await file.readAsString();
|
||||
final jsonData = jsonDecode(content) as Map<String, dynamic>;
|
||||
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(
|
||||
data: dataToResubmit,
|
||||
appSettings: _authProvider!.appSettings,
|
||||
appSettings: _authProvider!.appSettings, // Get current settings
|
||||
authProvider: _authProvider!,
|
||||
logDirectory: logDirectoryPath,
|
||||
logDirectory: logDirectoryPath, // Pass directory to update log
|
||||
);
|
||||
success = result['success'];
|
||||
|
||||
// --- START: ADDED LOGIC FOR RIVER IN-SITU SUBMISSION ---
|
||||
} else if (task['type'] == 'river_insitu_submission') {
|
||||
} else if (taskType == 'river_insitu_submission') {
|
||||
debugPrint("Retrying complex task 'river_insitu_submission' with ID $taskId.");
|
||||
if (_riverInSituService == null) return false;
|
||||
|
||||
final String logFilePath = payload['localLogPath'];
|
||||
final file = File(logFilePath);
|
||||
|
||||
if (!await file.exists()) {
|
||||
debugPrint("Retry failed: Source log file no longer exists at $logFilePath");
|
||||
await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task
|
||||
if (_riverInSituService == null) {
|
||||
debugPrint("Retry failed: RiverInSituSamplingService 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>;
|
||||
final RiverInSituSamplingData dataToResubmit = RiverInSituSamplingData.fromJson(jsonData);
|
||||
final String logDirectoryPath = p.dirname(logFilePath);
|
||||
|
||||
final result = await _riverInSituService!.submitData(
|
||||
data: dataToResubmit,
|
||||
@ -204,61 +253,292 @@ class RetryService {
|
||||
logDirectory: logDirectoryPath,
|
||||
);
|
||||
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 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");
|
||||
Map<String, dynamic> result;
|
||||
|
||||
if (method == 'POST_MULTIPART') {
|
||||
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>?)
|
||||
?.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);
|
||||
} else {
|
||||
} else { // Standard POST
|
||||
final Map<String, dynamic> body = Map<String, dynamic>.from(payload['body'] ?? {});
|
||||
result = await _baseApiService.post(baseUrl, endpoint, body);
|
||||
}
|
||||
success = result['success'];
|
||||
|
||||
} else if (task['type'] == 'ftp') {
|
||||
} else if (taskType == 'ftp') {
|
||||
final remotePath = task['endpoint_or_path'] as String;
|
||||
final localFile = File(payload['localFilePath'] as String);
|
||||
debugPrint("Retrying FTP task $taskId: Uploading ${localFile.path} to $remotePath");
|
||||
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()) {
|
||||
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||
if (ftpConfigs.isEmpty) {
|
||||
debugPrint("Retry failed for FTP task $taskId: No FTP configurations found.");
|
||||
return false;
|
||||
}
|
||||
for (final config in ftpConfigs) {
|
||||
final result = await _ftpService.uploadFile(config: config, fileToUpload: localFile, remotePath: remotePath);
|
||||
if (result['success']) {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugPrint("Retry failed for FTP task $taskId: Source file no longer exists at ${localFile.path}");
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("A critical error occurred while retrying task $taskId: $e");
|
||||
success = false;
|
||||
final config = ftpConfigs.firstWhere((c) => c['ftp_config_id'] == ftpConfigId, orElse: () => <String, dynamic>{}); // Use explicit type
|
||||
|
||||
|
||||
if (config.isEmpty) {
|
||||
debugPrint("Retry failed for FTP task $taskId: FTP configuration with ID $ftpConfigId not found.");
|
||||
return false; // Fail the retry attempt, keep in queue
|
||||
}
|
||||
|
||||
if (success) {
|
||||
debugPrint("Task $taskId completed successfully. Removing from queue.");
|
||||
await _dbHelper.deleteRequestFromQueue(taskId);
|
||||
// Attempt upload using the specific config
|
||||
final result = await _ftpService.uploadFile(config: config, fileToUpload: localFile, remotePath: remotePath);
|
||||
success = result['success'];
|
||||
|
||||
} else {
|
||||
debugPrint("Retry attempt for task $taskId failed. It will remain in the queue.");
|
||||
debugPrint("Retry failed for FTP task $taskId: Source file no longer exists at ${localFile.path}");
|
||||
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);
|
||||
}
|
||||
|
||||
} on SessionExpiredException catch (e) {
|
||||
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) {
|
||||
debugPrint("Task $taskId (Type: $taskType) completed successfully. Removing from queue.");
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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
|
||||
@ -169,15 +169,20 @@ class RiverInSituSamplingService {
|
||||
const String moduleName = 'river_in_situ';
|
||||
|
||||
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);
|
||||
|
||||
if (isOnline && isOfflineSession) {
|
||||
debugPrint("River In-Situ 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;
|
||||
}
|
||||
}
|
||||
@ -196,6 +201,7 @@ class RiverInSituSamplingService {
|
||||
return await _performOfflineQueuing(
|
||||
data: data,
|
||||
moduleName: moduleName,
|
||||
logDirectory: logDirectory, // Pass for potential update
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -215,25 +221,29 @@ class RiverInSituSamplingService {
|
||||
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: 'river/manual/sample',
|
||||
endpoint: 'river/manual/sample', // Correct endpoint
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
|
||||
if (apiDataResult['success'] == 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 (finalImageFiles.isNotEmpty) {
|
||||
// 2. Submit Images
|
||||
apiImageResult = await _submissionApiService.submitMultipart(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'river/manual/images',
|
||||
fields: {'r_man_id': data.reportId!},
|
||||
endpoint: 'river/manual/images', // Correct endpoint
|
||||
fields: {'r_man_id': data.reportId!}, // Correct field key
|
||||
files: finalImageFiles,
|
||||
);
|
||||
if (apiImageResult['success'] != true) {
|
||||
@ -245,65 +255,98 @@ class RiverInSituSamplingService {
|
||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
||||
}
|
||||
}
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin...");
|
||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
||||
// If apiDataResult['success'] is false, SubmissionApiService queued it.
|
||||
|
||||
if (reloginSuccess) {
|
||||
debugPrint("Silent relogin successful. Retrying entire online submission process...");
|
||||
return await _performOnlineSubmission(
|
||||
data: data,
|
||||
appSettings: appSettings,
|
||||
moduleName: moduleName,
|
||||
authProvider: authProvider,
|
||||
logDirectory: logDirectory,
|
||||
);
|
||||
} else {
|
||||
debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP.");
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("Online submission failed due to session expiry that could not be refreshed.");
|
||||
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;
|
||||
apiDataResult = {'success': false, 'message': errorMessage};
|
||||
// Manually queue API calls
|
||||
await _retryService.addApiToQueue(endpoint: 'river/manual/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: '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': []};
|
||||
bool anyFtpSuccess = false;
|
||||
|
||||
if (isSessionKnownToBeExpired) {
|
||||
debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
|
||||
final baseFileNameForQueue = _generateBaseFileName(data); // Use helper
|
||||
|
||||
// --- START FIX: Add ftpConfigId when queuing ---
|
||||
// Get all potential FTP configs
|
||||
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;
|
||||
} else {
|
||||
try {
|
||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||
} on SocketException catch (e) {
|
||||
debugPrint("FTP submission failed with network error: $e");
|
||||
anyFtpSuccess = false;
|
||||
} on TimeoutException catch (e) {
|
||||
debugPrint("FTP submission timed out: $e");
|
||||
} catch (e) {
|
||||
debugPrint("Unexpected FTP submission error: $e");
|
||||
anyFtpSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Determine Final Status
|
||||
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
|
||||
String finalMessage;
|
||||
String finalStatus;
|
||||
|
||||
if (anyApiSuccess && anyFtpSuccess) {
|
||||
finalMessage = 'Data submitted successfully to all destinations.';
|
||||
finalStatus = 'S4';
|
||||
} else if (anyApiSuccess && !anyFtpSuccess) {
|
||||
finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.';
|
||||
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.';
|
||||
@ -313,6 +356,7 @@ class RiverInSituSamplingService {
|
||||
finalStatus = 'L1';
|
||||
}
|
||||
|
||||
// 5. Log Locally
|
||||
await _logAndSave(
|
||||
data: data,
|
||||
status: finalStatus,
|
||||
@ -323,10 +367,12 @@ class RiverInSituSamplingService {
|
||||
logDirectory: logDirectory,
|
||||
);
|
||||
|
||||
// 6. Send Alert
|
||||
if (overallSuccess) {
|
||||
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
||||
}
|
||||
|
||||
// Return consistent format
|
||||
return {
|
||||
'status': finalStatus,
|
||||
'success': overallSuccess,
|
||||
@ -335,9 +381,12 @@ class RiverInSituSamplingService {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// Handles queuing the submission data when the device is offline.
|
||||
Future<Map<String, dynamic>> _performOfflineQueuing({
|
||||
required RiverInSituSamplingData data,
|
||||
required String moduleName,
|
||||
String? logDirectory, // Added for potential update
|
||||
}) async {
|
||||
final serverConfig = await _serverConfigService.getActiveApiConfig();
|
||||
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
|
||||
@ -345,45 +394,66 @@ class RiverInSituSamplingService {
|
||||
data.submissionStatus = 'L1';
|
||||
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.";
|
||||
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};
|
||||
}
|
||||
|
||||
await _retryService.queueTask(
|
||||
type: 'river_insitu_submission',
|
||||
type: 'river_insitu_submission', // Correct type
|
||||
payload: {
|
||||
'module': moduleName,
|
||||
'localLogPath': p.join(localLogPath, 'data.json'),
|
||||
'localLogPath': p.join(savedLogPath, 'data.json'), // Point to the json file
|
||||
'serverConfig': serverConfig,
|
||||
},
|
||||
);
|
||||
|
||||
const successMessage = "Submission failed to send and has been queued for later retry.";
|
||||
return {'status': 'Queued', 'success': true, 'message': successMessage};
|
||||
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, 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 fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
|
||||
final baseFileName = "${stationCode}_$fileTimestamp";
|
||||
return "${stationCode}_$fileTimestamp";
|
||||
}
|
||||
|
||||
final Directory? logDirectory = await _localStorageService.getLogDirectory(
|
||||
serverName: serverName,
|
||||
module: 'river',
|
||||
subModule: 'river_in_situ_sampling',
|
||||
);
|
||||
/// Generates data and image ZIP files and uploads them using SubmissionFtpService.
|
||||
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(RiverInSituSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
|
||||
final baseFileName = _generateBaseFileName(data);
|
||||
|
||||
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()) {
|
||||
await localSubmissionDir.create(recursive: true);
|
||||
}
|
||||
|
||||
// Create and upload data ZIP (with multiple JSON files specific to River In-Situ)
|
||||
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,
|
||||
destinationDir: localSubmissionDir,
|
||||
);
|
||||
@ -393,6 +463,7 @@ class RiverInSituSamplingService {
|
||||
moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${p.basename(dataZip.path)}');
|
||||
}
|
||||
|
||||
// Create and upload image ZIP
|
||||
final imageZip = await _zippingService.createImageZip(
|
||||
imageFiles: imageFiles.values.toList(),
|
||||
baseFileName: baseFileName,
|
||||
@ -406,12 +477,13 @@ class RiverInSituSamplingService {
|
||||
|
||||
return {
|
||||
'statuses': <Map<String, dynamic>>[
|
||||
...(ftpDataResult['statuses'] as List),
|
||||
...(ftpImageResult['statuses'] as List),
|
||||
...(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.
|
||||
Future<void> _logAndSave({
|
||||
required RiverInSituSamplingData data,
|
||||
required String status,
|
||||
@ -423,47 +495,64 @@ class RiverInSituSamplingService {
|
||||
}) async {
|
||||
data.submissionStatus = status;
|
||||
data.submissionMessage = message;
|
||||
final baseFileName = _generateBaseFileName(data); // Use helper
|
||||
|
||||
if (logDirectory != null) {
|
||||
final Map<String, dynamic> updatedLogData = data.toMap();
|
||||
updatedLogData['logDirectory'] = logDirectory;
|
||||
updatedLogData['serverConfigName'] = serverName;
|
||||
updatedLogData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
|
||||
updatedLogData['ftp_status'] = jsonEncode(ftpStatuses);
|
||||
// Prepare log data map using toMap()
|
||||
final Map<String, dynamic> logMapData = data.toMap();
|
||||
// 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);
|
||||
|
||||
final imageFilePaths = data.toApiImageFiles();
|
||||
imageFilePaths.forEach((key, file) {
|
||||
if (file != null) {
|
||||
updatedLogData[key] = file.path;
|
||||
}
|
||||
});
|
||||
|
||||
await _localStorageService.updateRiverInSituLog(updatedLogData);
|
||||
if (logDirectory != null && logDirectory.isNotEmpty) {
|
||||
// Update existing log
|
||||
logMapData['logDirectory'] = logDirectory; // Ensure logDirectory path is in the map
|
||||
await _localStorageService.updateRiverInSituLog(logMapData);
|
||||
} else {
|
||||
// Save new log
|
||||
await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName);
|
||||
}
|
||||
|
||||
// Save to central DB log
|
||||
final imagePaths = data.toApiImageFiles().values.whereType<File>().map((f) => f.path).toList();
|
||||
final logData = {
|
||||
'submission_id': data.reportId ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
'module': 'river', 'type': data.samplingType ?? 'In-Situ', 'status': status,
|
||||
'message': message, 'report_id': data.reportId, 'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toMap()), 'image_data': jsonEncode(imagePaths),
|
||||
'server_name': serverName, 'api_status': jsonEncode(apiResults), 'ftp_status': jsonEncode(ftpStatuses),
|
||||
final centralLogData = {
|
||||
'submission_id': data.reportId ?? baseFileName, // Use helper result
|
||||
'module': 'river',
|
||||
'type': data.samplingType ?? 'In-Situ', // Correct type
|
||||
'status': status,
|
||||
'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 {
|
||||
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) {
|
||||
debugPrint("Session is expired; queuing Telegram alert directly.");
|
||||
await _telegramService.queueMessage('river_in_situ', message, appSettings);
|
||||
debugPrint("Session is expired; queuing Telegram alert directly for $alertKey.");
|
||||
await _telegramService.queueMessage(alertKey, message, appSettings);
|
||||
} else {
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings);
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately(alertKey, message, appSettings);
|
||||
if (!wasSent) {
|
||||
await _telegramService.queueMessage('river_in_situ', message, appSettings);
|
||||
await _telegramService.queueMessage(alertKey, message, appSettings);
|
||||
}
|
||||
}
|
||||
} 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 {
|
||||
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||
final stationName = data.selectedStation?['sampling_river'] ?? 'N/A';
|
||||
@ -491,7 +581,7 @@ class RiverInSituSamplingService {
|
||||
..writeln('*Sonde ID:* $sondeID')
|
||||
..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
|
||||
..writeln()
|
||||
..writeln('🔔 *Distance Alert:*')
|
||||
@ -500,6 +590,79 @@ class RiverInSituSamplingService {
|
||||
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();
|
||||
}
|
||||
}
|
||||
774
lib/services/river_investigative_sampling_service.dart
Normal 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
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/material.dart'; // Keep material import
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@ -15,14 +15,14 @@ import 'package:usb_serial/usb_serial.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:provider/provider.dart'; // Keep provider import
|
||||
|
||||
import '../auth_provider.dart';
|
||||
import 'location_service.dart';
|
||||
import '../models/river_manual_triennial_sampling_data.dart';
|
||||
import '../bluetooth/bluetooth_manager.dart';
|
||||
import '../serial/serial_manager.dart';
|
||||
import 'api_service.dart';
|
||||
import 'api_service.dart'; // Keep DatabaseHelper import
|
||||
import 'local_storage_service.dart';
|
||||
import 'server_config_service.dart';
|
||||
import 'zipping_service.dart';
|
||||
@ -166,18 +166,23 @@ class RiverManualTriennialSamplingService {
|
||||
required AuthProvider authProvider,
|
||||
String? logDirectory,
|
||||
}) async {
|
||||
const String moduleName = 'river_triennial';
|
||||
const String moduleName = 'river_triennial'; // Correct module name
|
||||
|
||||
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);
|
||||
|
||||
if (isOnline && isOfflineSession) {
|
||||
debugPrint("River Triennial 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;
|
||||
}
|
||||
}
|
||||
@ -196,6 +201,7 @@ class RiverManualTriennialSamplingService {
|
||||
return await _performOfflineQueuing(
|
||||
data: data,
|
||||
moduleName: moduleName,
|
||||
logDirectory: logDirectory, // Pass for potential update
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -215,25 +221,29 @@ class RiverManualTriennialSamplingService {
|
||||
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: 'river/triennial/sample',
|
||||
endpoint: 'river/triennial/sample', // Correct endpoint
|
||||
body: data.toApiFormData(),
|
||||
);
|
||||
|
||||
if (apiDataResult['success'] == 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 (finalImageFiles.isNotEmpty) {
|
||||
// 2. Submit Images
|
||||
apiImageResult = await _submissionApiService.submitMultipart(
|
||||
moduleName: moduleName,
|
||||
endpoint: 'river/triennial/images',
|
||||
fields: {'r_tri_id': data.reportId!},
|
||||
endpoint: 'river/triennial/images', // Correct endpoint
|
||||
fields: {'r_tri_id': data.reportId!}, // Correct field key
|
||||
files: finalImageFiles,
|
||||
);
|
||||
if (apiImageResult['success'] != true) {
|
||||
@ -245,65 +255,96 @@ class RiverManualTriennialSamplingService {
|
||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
||||
}
|
||||
}
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin...");
|
||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
||||
// If apiDataResult['success'] is false, SubmissionApiService queued it.
|
||||
|
||||
if (reloginSuccess) {
|
||||
debugPrint("Silent relogin successful. Retrying entire online submission process...");
|
||||
return await _performOnlineSubmission(
|
||||
data: data,
|
||||
appSettings: appSettings,
|
||||
moduleName: moduleName,
|
||||
authProvider: authProvider,
|
||||
logDirectory: logDirectory,
|
||||
);
|
||||
} else {
|
||||
debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP.");
|
||||
} on SessionExpiredException catch (_) {
|
||||
debugPrint("Online submission failed due to session expiry that could not be refreshed.");
|
||||
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;
|
||||
apiDataResult = {'success': false, 'message': errorMessage};
|
||||
// Manually queue API calls
|
||||
await _retryService.addApiToQueue(endpoint: 'river/triennial/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: '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': []};
|
||||
bool anyFtpSuccess = false;
|
||||
|
||||
if (isSessionKnownToBeExpired) {
|
||||
debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
|
||||
final baseFileNameForQueue = _generateBaseFileName(data); // Use helper
|
||||
|
||||
// --- START FIX: Add ftpConfigId when queuing ---
|
||||
// Get all potential FTP configs
|
||||
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;
|
||||
} else {
|
||||
try {
|
||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||
} on SocketException catch (e) {
|
||||
debugPrint("FTP submission failed with network error: $e");
|
||||
anyFtpSuccess = false;
|
||||
} on TimeoutException catch (e) {
|
||||
debugPrint("FTP submission timed out: $e");
|
||||
} catch (e) {
|
||||
debugPrint("Unexpected FTP submission error: $e");
|
||||
anyFtpSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Determine Final Status
|
||||
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
|
||||
String finalMessage;
|
||||
String finalStatus;
|
||||
|
||||
if (anyApiSuccess && anyFtpSuccess) {
|
||||
finalMessage = 'Data submitted successfully to all destinations.';
|
||||
finalStatus = 'S4';
|
||||
} else if (anyApiSuccess && !anyFtpSuccess) {
|
||||
finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.';
|
||||
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.';
|
||||
@ -313,6 +354,7 @@ class RiverManualTriennialSamplingService {
|
||||
finalStatus = 'L1';
|
||||
}
|
||||
|
||||
// 5. Log Locally
|
||||
await _logAndSave(
|
||||
data: data,
|
||||
status: finalStatus,
|
||||
@ -323,10 +365,12 @@ class RiverManualTriennialSamplingService {
|
||||
logDirectory: logDirectory,
|
||||
);
|
||||
|
||||
// 6. Send Alert
|
||||
if (overallSuccess) {
|
||||
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
||||
}
|
||||
|
||||
// Return consistent format
|
||||
return {
|
||||
'status': finalStatus,
|
||||
'success': overallSuccess,
|
||||
@ -335,9 +379,11 @@ class RiverManualTriennialSamplingService {
|
||||
};
|
||||
}
|
||||
|
||||
/// Handles queuing the submission data when the device is offline.
|
||||
Future<Map<String, dynamic>> _performOfflineQueuing({
|
||||
required RiverManualTriennialSamplingData data,
|
||||
required String moduleName,
|
||||
String? logDirectory, // Added for potential update
|
||||
}) async {
|
||||
final serverConfig = await _serverConfigService.getActiveApiConfig();
|
||||
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
|
||||
@ -345,45 +391,67 @@ class RiverManualTriennialSamplingService {
|
||||
data.submissionStatus = 'L1';
|
||||
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.";
|
||||
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};
|
||||
}
|
||||
|
||||
await _retryService.queueTask(
|
||||
type: 'river_triennial_submission',
|
||||
type: 'river_triennial_submission', // Correct type
|
||||
payload: {
|
||||
'module': moduleName,
|
||||
'localLogPath': p.join(localLogPath, 'data.json'),
|
||||
'localLogPath': p.join(savedLogPath, 'data.json'), // Point to the json file
|
||||
'serverConfig': serverConfig,
|
||||
},
|
||||
);
|
||||
|
||||
const successMessage = "Submission failed to send and has been queued for later retry.";
|
||||
return {'status': 'Queued', 'success': true, 'message': successMessage};
|
||||
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, 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 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,
|
||||
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()) {
|
||||
await localSubmissionDir.create(recursive: true);
|
||||
}
|
||||
|
||||
// Create and upload data ZIP
|
||||
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,
|
||||
destinationDir: localSubmissionDir,
|
||||
);
|
||||
@ -393,6 +461,7 @@ class RiverManualTriennialSamplingService {
|
||||
moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${p.basename(dataZip.path)}');
|
||||
}
|
||||
|
||||
// Create and upload image ZIP
|
||||
final imageZip = await _zippingService.createImageZip(
|
||||
imageFiles: imageFiles.values.toList(),
|
||||
baseFileName: baseFileName,
|
||||
@ -406,12 +475,13 @@ class RiverManualTriennialSamplingService {
|
||||
|
||||
return {
|
||||
'statuses': <Map<String, dynamic>>[
|
||||
...(ftpDataResult['statuses'] as List),
|
||||
...(ftpImageResult['statuses'] as List),
|
||||
...(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.
|
||||
Future<void> _logAndSave({
|
||||
required RiverManualTriennialSamplingData data,
|
||||
required String status,
|
||||
@ -423,47 +493,65 @@ class RiverManualTriennialSamplingService {
|
||||
}) async {
|
||||
data.submissionStatus = status;
|
||||
data.submissionMessage = message;
|
||||
final baseFileName = _generateBaseFileName(data); // Use helper
|
||||
|
||||
if (logDirectory != null) {
|
||||
final Map<String, dynamic> updatedLogData = data.toMap();
|
||||
updatedLogData['logDirectory'] = logDirectory;
|
||||
updatedLogData['serverConfigName'] = serverName;
|
||||
updatedLogData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
|
||||
updatedLogData['ftp_status'] = jsonEncode(ftpStatuses);
|
||||
// Prepare log data map using toMap()
|
||||
final Map<String, dynamic> logMapData = data.toMap();
|
||||
// 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);
|
||||
|
||||
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 {
|
||||
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 logData = {
|
||||
'submission_id': data.reportId ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
'module': 'river', 'type': data.samplingType ?? 'Triennial', 'status': status,
|
||||
'message': message, 'report_id': data.reportId, 'created_at': DateTime.now().toIso8601String(),
|
||||
'form_data': jsonEncode(data.toMap()), 'image_data': jsonEncode(imagePaths),
|
||||
'server_name': serverName, 'api_status': jsonEncode(apiResults), 'ftp_status': jsonEncode(ftpStatuses),
|
||||
final centralLogData = {
|
||||
'submission_id': data.reportId ?? baseFileName, // Use helper result
|
||||
'module': 'river',
|
||||
'type': data.samplingType ?? 'Triennial', // Correct type
|
||||
'status': status,
|
||||
'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 {
|
||||
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) {
|
||||
debugPrint("Session is expired; queuing Telegram alert directly.");
|
||||
await _telegramService.queueMessage('river_triennial', message, appSettings);
|
||||
debugPrint("Session is expired; queuing Telegram alert directly for $alertKey.");
|
||||
await _telegramService.queueMessage(alertKey, message, appSettings);
|
||||
} else {
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('river_triennial', message, appSettings);
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately(alertKey, message, appSettings);
|
||||
if (!wasSent) {
|
||||
await _telegramService.queueMessage('river_triennial', message, appSettings);
|
||||
await _telegramService.queueMessage(alertKey, message, appSettings);
|
||||
}
|
||||
}
|
||||
} 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 {
|
||||
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||
final stationName = data.selectedStation?['sampling_river'] ?? 'N/A';
|
||||
@ -491,15 +580,20 @@ class RiverManualTriennialSamplingService {
|
||||
..writeln('*Sonde ID:* $sondeID')
|
||||
..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
|
||||
..writeln()
|
||||
..writeln('🔔 *Alert:*')
|
||||
..writeln('🔔 *Distance Alert:*')
|
||||
..writeln('*Distance from station:* $distanceMeters meters');
|
||||
if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -2,10 +2,15 @@
|
||||
|
||||
import 'dart:io';
|
||||
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/ftp_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.
|
||||
/// It respects user preferences for enabled destinations for any given module.
|
||||
@ -13,6 +18,10 @@ class SubmissionFtpService {
|
||||
final UserPreferencesService _userPreferencesService = UserPreferencesService();
|
||||
final FtpService _ftpService = FtpService();
|
||||
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.
|
||||
///
|
||||
@ -30,15 +39,35 @@ class SubmissionFtpService {
|
||||
|
||||
if (destinations.isEmpty) {
|
||||
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 = [];
|
||||
bool allSucceeded = true;
|
||||
bool allSucceededOrNotConfigured = true; // Track if all attempts either succeeded or weren't configured
|
||||
|
||||
for (final dest in destinations) {
|
||||
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(
|
||||
config: dest,
|
||||
@ -48,22 +77,27 @@ class SubmissionFtpService {
|
||||
|
||||
statuses.add({
|
||||
'config_name': configName,
|
||||
'ftp_config_id': configId, // Include ID in status
|
||||
'success': result['success'],
|
||||
'message': result['message'],
|
||||
'status': result['success'] ? 'Success' : 'Failed', // Add status text
|
||||
});
|
||||
|
||||
if (result['success'] != true) {
|
||||
allSucceeded = false;
|
||||
allSucceededOrNotConfigured = false;
|
||||
// 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(
|
||||
localFilePath: fileToUpload.path,
|
||||
remotePath: remotePath,
|
||||
ftpConfigId: configId, // Pass the specific config ID
|
||||
);
|
||||
// --- END FIX ---
|
||||
}
|
||||
}
|
||||
|
||||
if (allSucceeded) {
|
||||
if (allSucceededOrNotConfigured) {
|
||||
return {
|
||||
'success': true,
|
||||
'message': 'File successfully uploaded to all enabled FTP destinations.',
|
||||
@ -71,10 +105,70 @@ class SubmissionFtpService {
|
||||
};
|
||||
} else {
|
||||
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.',
|
||||
'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.
|
||||
}
|
||||
|
||||
}
|
||||
16
pubspec.lock
@ -854,6 +854,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -870,6 +878,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: f18fd9a72d7a1ad2920db61368f2a69368f1cc9b56b8233e9d83b47b0a8435aa
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.3"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -23,6 +23,7 @@ dependencies:
|
||||
# --- Local Storage & Offline Capabilities ---
|
||||
shared_preferences: ^2.2.3
|
||||
sqflite: ^2.3.3
|
||||
sqflite_common_ffi: ^2.3.3
|
||||
path_provider: ^2.1.3
|
||||
path: ^1.8.3 # Explicitly added for path manipulation
|
||||
connectivity_plus: ^6.0.1
|
||||
@ -73,4 +74,4 @@ flutter:
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: true
|
||||
image_path: "assets/icon_2_512x512.png"
|
||||
image_path: "assets/icon_3_512x512.png"
|
||||