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
|
// lib/auth_provider.dart
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart'; // Import for compute function
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart'; // Added import for post-frame callback
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:bcrypt/bcrypt.dart'; // Import bcrypt
|
import 'package:bcrypt/bcrypt.dart'; // Import bcrypt
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; // NEW: Import secure storage
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; // Import secure storage
|
||||||
|
|
||||||
import 'package:environment_monitoring_app/services/api_service.dart';
|
import 'package:environment_monitoring_app/services/api_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/base_api_service.dart';
|
import 'package:environment_monitoring_app/services/base_api_service.dart';
|
||||||
@ -14,6 +15,9 @@ import 'package:environment_monitoring_app/services/server_config_service.dart';
|
|||||||
import 'package:environment_monitoring_app/services/retry_service.dart';
|
import 'package:environment_monitoring_app/services/retry_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/user_preferences_service.dart';
|
import 'package:environment_monitoring_app/services/user_preferences_service.dart';
|
||||||
|
|
||||||
|
// Removed _CacheDataContainer class
|
||||||
|
// Removed _loadCacheDataFromIsolate function
|
||||||
|
|
||||||
class AuthProvider with ChangeNotifier {
|
class AuthProvider with ChangeNotifier {
|
||||||
late final ApiService _apiService;
|
late final ApiService _apiService;
|
||||||
late final DatabaseHelper _dbHelper;
|
late final DatabaseHelper _dbHelper;
|
||||||
@ -21,7 +25,7 @@ class AuthProvider with ChangeNotifier {
|
|||||||
late final RetryService _retryService;
|
late final RetryService _retryService;
|
||||||
final UserPreferencesService _userPreferencesService = UserPreferencesService();
|
final UserPreferencesService _userPreferencesService = UserPreferencesService();
|
||||||
|
|
||||||
// NEW: Initialize secure storage
|
// Initialize secure storage
|
||||||
final _secureStorage = const FlutterSecureStorage();
|
final _secureStorage = const FlutterSecureStorage();
|
||||||
static const _passwordStorageKey = 'user_password';
|
static const _passwordStorageKey = 'user_password';
|
||||||
|
|
||||||
@ -34,15 +38,15 @@ class AuthProvider with ChangeNotifier {
|
|||||||
Map<String, dynamic>? get profileData => _profileData;
|
Map<String, dynamic>? get profileData => _profileData;
|
||||||
|
|
||||||
// --- App State ---
|
// --- App State ---
|
||||||
bool _isLoading = true;
|
bool _isLoading = true; // Keep true initially
|
||||||
bool _isFirstLogin = true;
|
bool _isFirstLogin = true;
|
||||||
DateTime? _lastSyncTimestamp;
|
DateTime? _lastSyncTimestamp;
|
||||||
|
bool _isBackgroundLoading = false; // Added flag for background loading
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isBackgroundLoading => _isBackgroundLoading;
|
||||||
bool get isFirstLogin => _isFirstLogin;
|
bool get isFirstLogin => _isFirstLogin;
|
||||||
DateTime? get lastSyncTimestamp => _lastSyncTimestamp;
|
DateTime? get lastSyncTimestamp => _lastSyncTimestamp;
|
||||||
|
|
||||||
/// This flag indicates the session is confirmed expired and auto-relogin failed.
|
|
||||||
/// The app should operate in offline mode until the user manually logs in again.
|
|
||||||
bool _isSessionExpired = false;
|
bool _isSessionExpired = false;
|
||||||
bool get isSessionExpired => _isSessionExpired;
|
bool get isSessionExpired => _isSessionExpired;
|
||||||
|
|
||||||
@ -60,12 +64,9 @@ class AuthProvider with ChangeNotifier {
|
|||||||
List<Map<String, dynamic>>? _airManualStations;
|
List<Map<String, dynamic>>? _airManualStations;
|
||||||
List<Map<String, dynamic>>? _states;
|
List<Map<String, dynamic>>? _states;
|
||||||
List<Map<String, dynamic>>? _appSettings;
|
List<Map<String, dynamic>>? _appSettings;
|
||||||
// --- START: MODIFIED PARAMETER LIMITS PROPERTIES ---
|
|
||||||
// The old generic list has been removed and replaced with three specific lists.
|
|
||||||
List<Map<String, dynamic>>? _npeParameterLimits;
|
List<Map<String, dynamic>>? _npeParameterLimits;
|
||||||
List<Map<String, dynamic>>? _marineParameterLimits;
|
List<Map<String, dynamic>>? _marineParameterLimits;
|
||||||
List<Map<String, dynamic>>? _riverParameterLimits;
|
List<Map<String, dynamic>>? _riverParameterLimits;
|
||||||
// --- END: MODIFIED PARAMETER LIMITS PROPERTIES ---
|
|
||||||
List<Map<String, dynamic>>? _apiConfigs;
|
List<Map<String, dynamic>>? _apiConfigs;
|
||||||
List<Map<String, dynamic>>? _ftpConfigs;
|
List<Map<String, dynamic>>? _ftpConfigs;
|
||||||
List<Map<String, dynamic>>? _documents;
|
List<Map<String, dynamic>>? _documents;
|
||||||
@ -85,11 +86,9 @@ class AuthProvider with ChangeNotifier {
|
|||||||
List<Map<String, dynamic>>? get airManualStations => _airManualStations;
|
List<Map<String, dynamic>>? get airManualStations => _airManualStations;
|
||||||
List<Map<String, dynamic>>? get states => _states;
|
List<Map<String, dynamic>>? get states => _states;
|
||||||
List<Map<String, dynamic>>? get appSettings => _appSettings;
|
List<Map<String, dynamic>>? get appSettings => _appSettings;
|
||||||
// --- START: GETTERS FOR NEW PARAMETER LIMITS ---
|
|
||||||
List<Map<String, dynamic>>? get npeParameterLimits => _npeParameterLimits;
|
List<Map<String, dynamic>>? get npeParameterLimits => _npeParameterLimits;
|
||||||
List<Map<String, dynamic>>? get marineParameterLimits => _marineParameterLimits;
|
List<Map<String, dynamic>>? get marineParameterLimits => _marineParameterLimits;
|
||||||
List<Map<String, dynamic>>? get riverParameterLimits => _riverParameterLimits;
|
List<Map<String, dynamic>>? get riverParameterLimits => _riverParameterLimits;
|
||||||
// --- END: GETTERS FOR NEW PARAMETER LIMITS ---
|
|
||||||
List<Map<String, dynamic>>? get apiConfigs => _apiConfigs;
|
List<Map<String, dynamic>>? get apiConfigs => _apiConfigs;
|
||||||
List<Map<String, dynamic>>? get ftpConfigs => _ftpConfigs;
|
List<Map<String, dynamic>>? get ftpConfigs => _ftpConfigs;
|
||||||
List<Map<String, dynamic>>? get documents => _documents;
|
List<Map<String, dynamic>>? get documents => _documents;
|
||||||
@ -113,7 +112,7 @@ class AuthProvider with ChangeNotifier {
|
|||||||
_serverConfigService = serverConfigService,
|
_serverConfigService = serverConfigService,
|
||||||
_retryService = retryService {
|
_retryService = retryService {
|
||||||
debugPrint('AuthProvider: Initializing...');
|
debugPrint('AuthProvider: Initializing...');
|
||||||
_loadSessionAndSyncData();
|
_initializeAndLoadData(); // Use the updated method name
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> isConnected() async {
|
Future<bool> isConnected() async {
|
||||||
@ -121,22 +120,35 @@ class AuthProvider with ChangeNotifier {
|
|||||||
return !connectivityResult.contains(ConnectivityResult.none);
|
return !connectivityResult.contains(ConnectivityResult.none);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadSessionAndSyncData() async {
|
// Updated method using SchedulerBinding instead of compute
|
||||||
|
Future<void> _initializeAndLoadData() async {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
notifyListeners();
|
notifyListeners(); // Notify UI about initial loading state
|
||||||
|
|
||||||
|
// 1. Perform quick SharedPreferences reads first.
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
_jwtToken = prefs.getString(tokenKey);
|
_jwtToken = prefs.getString(tokenKey);
|
||||||
_userEmail = prefs.getString(userEmailKey);
|
_userEmail = prefs.getString(userEmailKey);
|
||||||
_isFirstLogin = prefs.getBool(isFirstLoginKey) ?? true;
|
_isFirstLogin = prefs.getBool(isFirstLoginKey) ?? true;
|
||||||
|
|
||||||
|
final profileJson = prefs.getString(profileDataKey);
|
||||||
|
if (profileJson != null) {
|
||||||
|
try {
|
||||||
|
_profileData = jsonDecode(profileJson);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Failed to decode profile from prefs: $e");
|
||||||
|
prefs.remove(profileDataKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load server config early
|
||||||
final activeApiConfig = await _serverConfigService.getActiveApiConfig();
|
final activeApiConfig = await _serverConfigService.getActiveApiConfig();
|
||||||
if (activeApiConfig == null) {
|
if (activeApiConfig == null) {
|
||||||
debugPrint("AuthProvider: No active API config found. Setting default bootstrap URL.");
|
debugPrint("AuthProvider: No active API config found. Setting default bootstrap URL.");
|
||||||
final initialConfig = {
|
final initialConfig = {
|
||||||
'api_config_id': 0,
|
'api_config_id': 0,
|
||||||
'config_name': 'Default Server',
|
'config_name': 'Default Server',
|
||||||
'api_url': 'https://mms-apiv4.pstw.com.my/v1',
|
'api_url': 'https://mms-apiv4.pstw.com.my/v1', // Use actual default if needed
|
||||||
};
|
};
|
||||||
await _serverConfigService.setActiveApiConfig(initialConfig);
|
await _serverConfigService.setActiveApiConfig(initialConfig);
|
||||||
}
|
}
|
||||||
@ -151,18 +163,40 @@ class AuthProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _loadDataFromCache();
|
// 2. Set isLoading to false *before* scheduling heavy work.
|
||||||
|
_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) {
|
if (_jwtToken != null) {
|
||||||
debugPrint('AuthProvider: Session loaded.');
|
debugPrint('AuthProvider: Session loaded.');
|
||||||
await _userPreferencesService.applyAndSaveDefaultPreferencesIfNeeded();
|
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 {
|
} else {
|
||||||
debugPrint('AuthProvider: No active session. App is in offline mode.');
|
debugPrint('AuthProvider: No active session. App is in offline mode.');
|
||||||
}
|
}
|
||||||
|
|
||||||
_isLoading = false;
|
} catch (e) {
|
||||||
notifyListeners();
|
debugPrint("AuthProvider: Error during background cache load: $e");
|
||||||
|
// Handle error appropriately
|
||||||
|
} finally {
|
||||||
|
_isBackgroundLoading = false; // Background load finished
|
||||||
|
notifyListeners(); // Update UI
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if the session is offline and attempts to transition to an online session by performing a silent re-login.
|
/// Checks if the session is offline and attempts to transition to an online session by performing a silent re-login.
|
||||||
@ -178,20 +212,18 @@ class AuthProvider with ChangeNotifier {
|
|||||||
final bool inOfflineSession = _jwtToken != null && _jwtToken!.startsWith("offline-session-");
|
final bool inOfflineSession = _jwtToken != null && _jwtToken!.startsWith("offline-session-");
|
||||||
if (!inOfflineSession) {
|
if (!inOfflineSession) {
|
||||||
// Already online or logged out, no transition needed.
|
// Already online or logged out, no transition needed.
|
||||||
// If online, trigger a normal sync to ensure data freshness on connection restoration.
|
// --- START: FIX FOR DOUBLE SYNC ---
|
||||||
|
// Removed the redundant syncAllData call from here.
|
||||||
if(_jwtToken != null) {
|
if(_jwtToken != null) {
|
||||||
debugPrint("AuthProvider: Session is already online. Triggering standard sync.");
|
debugPrint("AuthProvider: Session is already online. Skipping transition sync.");
|
||||||
// FIX: Add try-catch to prevent unhandled exceptions from crashing the app during background syncs.
|
// Consider calling validateAndRefreshSession() here instead if needed,
|
||||||
try {
|
// but avoid a full syncAllData().
|
||||||
await syncAllData();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("AuthProvider: Background sync failed silently on transition check: $e");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// --- END: FIX FOR DOUBLE SYNC ---
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIX: Read password from secure storage instead of temporary variable.
|
// Read password from secure storage
|
||||||
final String? password = await _secureStorage.read(key: _passwordStorageKey);
|
final String? password = await _secureStorage.read(key: _passwordStorageKey);
|
||||||
if (password == null || _userEmail == null) {
|
if (password == null || _userEmail == null) {
|
||||||
debugPrint("AuthProvider: In offline session, but no password in secure storage for auto-relogin. Manual login required.");
|
debugPrint("AuthProvider: In offline session, but no password in secure storage for auto-relogin. Manual login required.");
|
||||||
@ -209,13 +241,12 @@ class AuthProvider with ChangeNotifier {
|
|||||||
final Map<String, dynamic> profile = result['data']['profile'];
|
final Map<String, dynamic> profile = result['data']['profile'];
|
||||||
|
|
||||||
// Use existing login method to set up session and trigger sync.
|
// Use existing login method to set up session and trigger sync.
|
||||||
await login(token, profile, password);
|
await login(token, profile, password); // This call includes syncAllData
|
||||||
|
|
||||||
notifyListeners(); // Ensure UI updates after state change
|
notifyListeners(); // Ensure UI updates after state change
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
// Silent login failed (e.g., password changed on another device).
|
// Silent login failed
|
||||||
// Keep user in offline mode for now. They will need to log out and log back in manually.
|
|
||||||
debugPrint("AuthProvider: Silent re-login failed: ${result['message']}");
|
debugPrint("AuthProvider: Silent re-login failed: ${result['message']}");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -225,8 +256,6 @@ class AuthProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ⚡️ Orchestrates session validation and silent re-authentication.
|
|
||||||
/// This should be called whenever the app gains an internet connection.
|
|
||||||
Future<void> validateAndRefreshSession() async {
|
Future<void> validateAndRefreshSession() async {
|
||||||
if (!(await isConnected())) {
|
if (!(await isConnected())) {
|
||||||
debugPrint('AuthProvider: No connection, skipping session validation.');
|
debugPrint('AuthProvider: No connection, skipping session validation.');
|
||||||
@ -238,9 +267,8 @@ class AuthProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't attempt validation if the user is not logged in or is in a temporary offline session.
|
|
||||||
if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) {
|
if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) {
|
||||||
return;
|
return; // No online session to validate
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -254,7 +282,6 @@ class AuthProvider with ChangeNotifier {
|
|||||||
debugPrint('AuthProvider: Silent re-login failed. Switching to session-expired offline mode.');
|
debugPrint('AuthProvider: Silent re-login failed. Switching to session-expired offline mode.');
|
||||||
_isSessionExpired = true;
|
_isSessionExpired = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
// You can optionally show a one-time notification here.
|
|
||||||
} else {
|
} else {
|
||||||
debugPrint('AuthProvider: Silent re-login successful. Session restored.');
|
debugPrint('AuthProvider: Silent re-login successful. Session restored.');
|
||||||
}
|
}
|
||||||
@ -263,15 +290,12 @@ class AuthProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempts to silently re-login to get a new token.
|
|
||||||
/// This can be called when a 401 Unauthorized error is detected.
|
|
||||||
Future<bool> attemptSilentRelogin() async {
|
Future<bool> attemptSilentRelogin() async {
|
||||||
if (!(await isConnected())) {
|
if (!(await isConnected())) {
|
||||||
debugPrint("AuthProvider: No internet for silent relogin.");
|
debugPrint("AuthProvider: No internet for silent relogin.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIX: Read password from secure storage.
|
|
||||||
final String? password = await _secureStorage.read(key: _passwordStorageKey);
|
final String? password = await _secureStorage.read(key: _passwordStorageKey);
|
||||||
if (password == null || _userEmail == null) {
|
if (password == null || _userEmail == null) {
|
||||||
debugPrint("AuthProvider: No cached credentials in secure storage for silent relogin.");
|
debugPrint("AuthProvider: No cached credentials in secure storage for silent relogin.");
|
||||||
@ -285,8 +309,10 @@ class AuthProvider with ChangeNotifier {
|
|||||||
debugPrint("AuthProvider: Silent re-login successful.");
|
debugPrint("AuthProvider: Silent re-login successful.");
|
||||||
final String token = result['data']['token'];
|
final String token = result['data']['token'];
|
||||||
final Map<String, dynamic> profile = result['data']['profile'];
|
final Map<String, dynamic> profile = result['data']['profile'];
|
||||||
await login(token, profile, password);
|
// Critical: Call the main login function to update token, profile, hash, etc.
|
||||||
_isSessionExpired = false; // Explicitly mark session as valid again.
|
// BUT prevent it from triggering another full sync immediately if called during syncAllData
|
||||||
|
await _updateSessionInternals(token, profile, password); // Use helper to avoid sync loop
|
||||||
|
_isSessionExpired = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
@ -299,13 +325,45 @@ class AuthProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to update session without triggering sync, used by attemptSilentRelogin
|
||||||
|
Future<void> _updateSessionInternals(String token, Map<String, dynamic> profile, String password) async {
|
||||||
|
_jwtToken = token;
|
||||||
|
_userEmail = profile['email'];
|
||||||
|
|
||||||
|
await _secureStorage.write(key: _passwordStorageKey, value: password);
|
||||||
|
|
||||||
|
final Map<String, dynamic> profileWithToken = Map.from(profile);
|
||||||
|
profileWithToken['token'] = token;
|
||||||
|
_profileData = profileWithToken;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(tokenKey, token);
|
||||||
|
await prefs.setString(userEmailKey, _userEmail!);
|
||||||
|
await prefs.setString(profileDataKey, jsonEncode(_profileData));
|
||||||
|
await prefs.setString(lastOnlineLoginKey, DateTime.now().toIso8601String()); // Update last online time
|
||||||
|
await _dbHelper.saveProfile(_profileData!);
|
||||||
|
|
||||||
|
try {
|
||||||
|
debugPrint("AuthProvider: (Re-login) Hashing and caching password for offline login.");
|
||||||
|
final String hashedPassword = await compute(hashPassword, password);
|
||||||
|
await _dbHelper.upsertUserWithCredentials(
|
||||||
|
profile: profile,
|
||||||
|
passwordHash: hashedPassword,
|
||||||
|
);
|
||||||
|
debugPrint("AuthProvider: (Re-login) Credentials cached successfully.");
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("AuthProvider: (Re-login) Failed to cache password hash: $e");
|
||||||
|
}
|
||||||
|
// DO NOT call syncAllData here to prevent loops when called from syncAllData's catch block.
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> syncAllData({bool forceRefresh = false}) async {
|
Future<void> syncAllData({bool forceRefresh = false}) async {
|
||||||
if (!(await isConnected())) {
|
if (!(await isConnected())) {
|
||||||
debugPrint("AuthProvider: Device is OFFLINE. Skipping sync.");
|
debugPrint("AuthProvider: Device is OFFLINE. Skipping sync.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proactively check if session is already marked as expired
|
|
||||||
if (_isSessionExpired) {
|
if (_isSessionExpired) {
|
||||||
debugPrint("AuthProvider: Skipping sync, session is expired. Manual login required.");
|
debugPrint("AuthProvider: Skipping sync, session is expired. Manual login required.");
|
||||||
throw Exception('Session expired. Please log in again to sync.');
|
throw Exception('Session expired. Please log in again to sync.');
|
||||||
@ -334,11 +392,10 @@ class AuthProvider with ChangeNotifier {
|
|||||||
debugPrint("AuthProvider: First successful sync complete. isFirstLogin flag set to false.");
|
debugPrint("AuthProvider: First successful sync complete. isFirstLogin flag set to false.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await _loadDataFromCache();
|
await _loadDataFromCache(); // Reload data after successful sync
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} else {
|
} else {
|
||||||
debugPrint("AuthProvider: Delta sync failed logically. Message: ${result['message']}");
|
debugPrint("AuthProvider: Delta sync failed logically. Message: ${result['message']}");
|
||||||
// We throw an exception here so the UI can report a failure.
|
|
||||||
throw Exception('Data sync failed. Please check the logs.');
|
throw Exception('Data sync failed. Please check the logs.');
|
||||||
}
|
}
|
||||||
} on SessionExpiredException {
|
} on SessionExpiredException {
|
||||||
@ -355,12 +412,10 @@ class AuthProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("AuthProvider: A general error occurred during sync: $e");
|
debugPrint("AuthProvider: A general error occurred during sync: $e");
|
||||||
// Re-throw the exception so the UI can display it.
|
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- START: NEW METHOD FOR REGISTRATION SCREEN ---
|
|
||||||
Future<void> syncRegistrationData() async {
|
Future<void> syncRegistrationData() async {
|
||||||
if (!(await isConnected())) {
|
if (!(await isConnected())) {
|
||||||
debugPrint("AuthProvider: Device is OFFLINE. Skipping registration data sync.");
|
debugPrint("AuthProvider: Device is OFFLINE. Skipping registration data sync.");
|
||||||
@ -371,14 +426,13 @@ class AuthProvider with ChangeNotifier {
|
|||||||
final result = await _apiService.syncRegistrationData();
|
final result = await _apiService.syncRegistrationData();
|
||||||
|
|
||||||
if (result['success']) {
|
if (result['success']) {
|
||||||
await _loadDataFromCache(); // Reload data from DB into the provider
|
await _loadDataFromCache();
|
||||||
notifyListeners(); // Notify the UI to rebuild
|
notifyListeners();
|
||||||
debugPrint("AuthProvider: Registration data loaded and UI notified.");
|
debugPrint("AuthProvider: Registration data loaded and UI notified.");
|
||||||
} else {
|
} else {
|
||||||
debugPrint("AuthProvider: Registration data sync failed.");
|
debugPrint("AuthProvider: Registration data sync failed.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// --- END: NEW METHOD FOR REGISTRATION SCREEN ---
|
|
||||||
|
|
||||||
Future<void> refreshProfile() async {
|
Future<void> refreshProfile() async {
|
||||||
if (!(await isConnected())) {
|
if (!(await isConnected())) {
|
||||||
@ -390,10 +444,17 @@ class AuthProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
final result = await _apiService.refreshProfile();
|
final result = await _apiService.refreshProfile();
|
||||||
if (result['success']) {
|
if (result['success']) {
|
||||||
await setProfileData(result['data']);
|
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 {
|
Future<void> proactiveTokenRefresh() async {
|
||||||
@ -407,7 +468,7 @@ class AuthProvider with ChangeNotifier {
|
|||||||
|
|
||||||
if (lastOnlineLoginString == null) {
|
if (lastOnlineLoginString == null) {
|
||||||
debugPrint('AuthProvider: No last online login timestamp found, skipping proactive refresh.');
|
debugPrint('AuthProvider: No last online login timestamp found, skipping proactive refresh.');
|
||||||
return; // Never logged in online, nothing to refresh.
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -423,14 +484,15 @@ class AuthProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This method performs the actual DB reads
|
||||||
Future<void> _loadDataFromCache() async {
|
Future<void> _loadDataFromCache() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final profileJson = prefs.getString(profileDataKey);
|
final profileJson = prefs.getString(profileDataKey);
|
||||||
if (profileJson != null) {
|
if (profileJson != null && _profileData == null) {
|
||||||
_profileData = jsonDecode(profileJson);
|
try { _profileData = jsonDecode(profileJson); } catch(e) { /*...*/ }
|
||||||
} else {
|
|
||||||
_profileData = await _dbHelper.loadProfile();
|
|
||||||
}
|
}
|
||||||
|
_profileData ??= await _dbHelper.loadProfile();
|
||||||
|
|
||||||
_allUsers = await _dbHelper.loadUsers();
|
_allUsers = await _dbHelper.loadUsers();
|
||||||
_tarballStations = await _dbHelper.loadTarballStations();
|
_tarballStations = await _dbHelper.loadTarballStations();
|
||||||
_manualStations = await _dbHelper.loadManualStations();
|
_manualStations = await _dbHelper.loadManualStations();
|
||||||
@ -444,65 +506,40 @@ class AuthProvider with ChangeNotifier {
|
|||||||
_airManualStations = await _dbHelper.loadAirManualStations();
|
_airManualStations = await _dbHelper.loadAirManualStations();
|
||||||
_states = await _dbHelper.loadStates();
|
_states = await _dbHelper.loadStates();
|
||||||
_appSettings = await _dbHelper.loadAppSettings();
|
_appSettings = await _dbHelper.loadAppSettings();
|
||||||
|
|
||||||
// --- START: LOAD DATA FROM NEW PARAMETER LIMIT TABLES ---
|
|
||||||
_npeParameterLimits = await _dbHelper.loadNpeParameterLimits();
|
_npeParameterLimits = await _dbHelper.loadNpeParameterLimits();
|
||||||
_marineParameterLimits = await _dbHelper.loadMarineParameterLimits();
|
_marineParameterLimits = await _dbHelper.loadMarineParameterLimits();
|
||||||
_riverParameterLimits = await _dbHelper.loadRiverParameterLimits();
|
_riverParameterLimits = await _dbHelper.loadRiverParameterLimits();
|
||||||
// --- END: LOAD DATA FROM NEW PARAMETER LIMIT TABLES ---
|
|
||||||
|
|
||||||
_documents = await _dbHelper.loadDocuments();
|
_documents = await _dbHelper.loadDocuments();
|
||||||
_apiConfigs = await _dbHelper.loadApiConfigs();
|
_apiConfigs = await _dbHelper.loadApiConfigs();
|
||||||
_ftpConfigs = await _dbHelper.loadFtpConfigs();
|
_ftpConfigs = await _dbHelper.loadFtpConfigs();
|
||||||
_pendingRetries = await _retryService.getPendingTasks();
|
_pendingRetries = await _retryService.getPendingTasks(); // Use service here is okay
|
||||||
debugPrint("AuthProvider: All master data loaded from local DB cache.");
|
debugPrint("AuthProvider: All master data loaded from local DB cache (background/sync).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> refreshPendingTasks() async {
|
Future<void> refreshPendingTasks() async {
|
||||||
_pendingRetries = await _retryService.getPendingTasks();
|
_pendingRetries = await _retryService.getPendingTasks();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> login(String token, Map<String, dynamic> profile, String password) async {
|
Future<void> login(String token, Map<String, dynamic> profile, String password) async {
|
||||||
_jwtToken = token;
|
// Call the internal helper first
|
||||||
_userEmail = profile['email'];
|
await _updateSessionInternals(token, profile, password);
|
||||||
|
|
||||||
// FIX: Save password to secure storage instead of in-memory variable.
|
|
||||||
await _secureStorage.write(key: _passwordStorageKey, value: password);
|
|
||||||
|
|
||||||
final Map<String, dynamic> profileWithToken = Map.from(profile);
|
|
||||||
profileWithToken['token'] = token;
|
|
||||||
_profileData = profileWithToken;
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString(tokenKey, token);
|
|
||||||
await prefs.setString(userEmailKey, _userEmail!);
|
|
||||||
await prefs.setString(profileDataKey, jsonEncode(_profileData));
|
|
||||||
await prefs.setString(lastOnlineLoginKey, DateTime.now().toIso8601String());
|
|
||||||
await _dbHelper.saveProfile(_profileData!);
|
|
||||||
|
|
||||||
try {
|
|
||||||
debugPrint("AuthProvider: Hashing and caching password for offline login.");
|
|
||||||
final String hashedPassword = await compute(hashPassword, password);
|
|
||||||
await _dbHelper.upsertUserWithCredentials(
|
|
||||||
profile: profile,
|
|
||||||
passwordHash: hashedPassword,
|
|
||||||
);
|
|
||||||
debugPrint("AuthProvider: Credentials cached successfully.");
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("AuthProvider: Failed to cache password hash: $e");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Now proceed with post-login actions that *don't* belong in the helper
|
||||||
debugPrint('AuthProvider: Login successful. Session and profile persisted.');
|
debugPrint('AuthProvider: Login successful. Session and profile persisted.');
|
||||||
await _userPreferencesService.applyAndSaveDefaultPreferencesIfNeeded();
|
await _userPreferencesService.applyAndSaveDefaultPreferencesIfNeeded();
|
||||||
|
// The main sync triggered by a direct user login
|
||||||
await syncAllData(forceRefresh: true);
|
await syncAllData(forceRefresh: true);
|
||||||
|
// Notify listeners *after* sync is attempted (or throws)
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<bool> loginOffline(String email, String password) async {
|
Future<bool> loginOffline(String email, String password) async {
|
||||||
debugPrint("AuthProvider: Attempting offline login for user $email.");
|
debugPrint("AuthProvider: Attempting offline login for user $email.");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Retrieve stored hash from the local database based on email.
|
|
||||||
final String? storedHash = await _dbHelper.getUserPasswordHashByEmail(email);
|
final String? storedHash = await _dbHelper.getUserPasswordHashByEmail(email);
|
||||||
|
|
||||||
if (storedHash == null || storedHash.isEmpty) {
|
if (storedHash == null || storedHash.isEmpty) {
|
||||||
@ -510,24 +547,20 @@ class AuthProvider with ChangeNotifier {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Verify the provided password against the stored hash.
|
|
||||||
debugPrint("AuthProvider: Verifying password against stored hash...");
|
debugPrint("AuthProvider: Verifying password against stored hash...");
|
||||||
final bool passwordMatches = await compute(verifyPassword, CheckPasswordParams(password, storedHash));
|
final bool passwordMatches = await compute(verifyPassword, CheckPasswordParams(password, storedHash));
|
||||||
|
|
||||||
if (passwordMatches) {
|
if (passwordMatches) {
|
||||||
debugPrint("AuthProvider: Offline password verification successful.");
|
debugPrint("AuthProvider: Offline password verification successful.");
|
||||||
// 3. Load profile data from local storage.
|
|
||||||
final Map<String, dynamic>? cachedProfile = await _dbHelper.loadProfileByEmail(email);
|
final Map<String, dynamic>? cachedProfile = await _dbHelper.loadProfileByEmail(email);
|
||||||
if (cachedProfile == null) {
|
if (cachedProfile == null) {
|
||||||
debugPrint("AuthProvider DEBUG: Offline login failed because profile data was missing, even though password matched.");
|
debugPrint("AuthProvider DEBUG: Offline login failed because profile data was missing, even though password matched.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Initialize session state from cached profile data.
|
|
||||||
_jwtToken = "offline-session-${DateTime.now().millisecondsSinceEpoch}";
|
_jwtToken = "offline-session-${DateTime.now().millisecondsSinceEpoch}";
|
||||||
_userEmail = email;
|
_userEmail = email;
|
||||||
|
|
||||||
// FIX: Save password to secure storage for future auto-relogin.
|
|
||||||
await _secureStorage.write(key: _passwordStorageKey, value: password);
|
await _secureStorage.write(key: _passwordStorageKey, value: password);
|
||||||
|
|
||||||
final Map<String, dynamic> profileWithToken = Map.from(cachedProfile);
|
final Map<String, dynamic> profileWithToken = Map.from(cachedProfile);
|
||||||
@ -539,6 +572,8 @@ class AuthProvider with ChangeNotifier {
|
|||||||
await prefs.setString(userEmailKey, _userEmail!);
|
await prefs.setString(userEmailKey, _userEmail!);
|
||||||
await prefs.setString(profileDataKey, jsonEncode(_profileData));
|
await prefs.setString(profileDataKey, jsonEncode(_profileData));
|
||||||
|
|
||||||
|
// Load cache data immediately after offline login succeeds
|
||||||
|
// This doesn't need the post-frame callback as it's triggered by user action
|
||||||
await _loadDataFromCache();
|
await _loadDataFromCache();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return true;
|
return true;
|
||||||
@ -561,7 +596,7 @@ class AuthProvider with ChangeNotifier {
|
|||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(profileDataKey, jsonEncode(_profileData));
|
await prefs.setString(profileDataKey, jsonEncode(_profileData));
|
||||||
await _dbHelper.saveProfile(_profileData!); // Also save to local DB
|
await _dbHelper.saveProfile(_profileData!);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -579,11 +614,11 @@ class AuthProvider with ChangeNotifier {
|
|||||||
_profileData = null;
|
_profileData = null;
|
||||||
_lastSyncTimestamp = null;
|
_lastSyncTimestamp = null;
|
||||||
_isFirstLogin = true;
|
_isFirstLogin = true;
|
||||||
_isSessionExpired = false; // Reset session expired flag on logout
|
_isSessionExpired = false;
|
||||||
|
|
||||||
// FIX: Clear password from secure storage on logout.
|
|
||||||
await _secureStorage.delete(key: _passwordStorageKey);
|
await _secureStorage.delete(key: _passwordStorageKey);
|
||||||
|
|
||||||
|
// Clear cached data
|
||||||
_allUsers = null;
|
_allUsers = null;
|
||||||
_tarballStations = null;
|
_tarballStations = null;
|
||||||
_manualStations = null;
|
_manualStations = null;
|
||||||
@ -597,13 +632,9 @@ class AuthProvider with ChangeNotifier {
|
|||||||
_airManualStations = null;
|
_airManualStations = null;
|
||||||
_states = null;
|
_states = null;
|
||||||
_appSettings = null;
|
_appSettings = null;
|
||||||
|
|
||||||
// --- START: Clear new parameter limit lists ---
|
|
||||||
_npeParameterLimits = null;
|
_npeParameterLimits = null;
|
||||||
_marineParameterLimits = null;
|
_marineParameterLimits = null;
|
||||||
_riverParameterLimits = null;
|
_riverParameterLimits = null;
|
||||||
// --- END: Clear new parameter limit lists ---
|
|
||||||
|
|
||||||
_documents = null;
|
_documents = null;
|
||||||
_apiConfigs = null;
|
_apiConfigs = null;
|
||||||
_ftpConfigs = null;
|
_ftpConfigs = null;
|
||||||
@ -615,7 +646,7 @@ class AuthProvider with ChangeNotifier {
|
|||||||
await prefs.remove(profileDataKey);
|
await prefs.remove(profileDataKey);
|
||||||
await prefs.remove(lastSyncTimestampKey);
|
await prefs.remove(lastSyncTimestampKey);
|
||||||
await prefs.remove(lastOnlineLoginKey);
|
await prefs.remove(lastOnlineLoginKey);
|
||||||
await prefs.remove('default_preferences_saved');
|
await prefs.remove('default_preferences_saved'); // Also clear user prefs flag
|
||||||
await prefs.setBool(isFirstLoginKey, true);
|
await prefs.setBool(isFirstLoginKey, true);
|
||||||
|
|
||||||
debugPrint('AuthProvider: All session and cached data cleared.');
|
debugPrint('AuthProvider: All session and cached data cleared.');
|
||||||
@ -623,10 +654,13 @@ class AuthProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> resetPassword(String email) {
|
Future<Map<String, dynamic>> resetPassword(String email) {
|
||||||
|
// Assuming _apiService has a method for this, otherwise implement it.
|
||||||
|
// This looks correct based on your previous code structure.
|
||||||
return _apiService.post('auth/forgot-password', {'email': email});
|
return _apiService.post('auth/forgot-password', {'email': email});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// These remain unchanged as they are used by compute for password hashing/checking
|
||||||
class CheckPasswordParams {
|
class CheckPasswordParams {
|
||||||
final String password;
|
final String password;
|
||||||
final String hash;
|
final String hash;
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// collapsible_sidebar.dart
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
// --- Data Structure for Sidebar Menu Items ---
|
// --- Data Structure for Sidebar Menu Items ---
|
||||||
@ -87,6 +89,8 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
|
|||||||
SidebarItem(icon: Icons.pin_drop, label: "In-Situ Sampling", route: '/river/manual/in-situ'),
|
SidebarItem(icon: Icons.pin_drop, label: "In-Situ Sampling", route: '/river/manual/in-situ'),
|
||||||
SidebarItem(icon: Icons.date_range, label: "Triennial Sampling", route: '/river/manual/triennial'),
|
SidebarItem(icon: Icons.date_range, label: "Triennial Sampling", route: '/river/manual/triennial'),
|
||||||
SidebarItem(icon: Icons.article, label: "Data Log", route: '/river/manual/data-log'),
|
SidebarItem(icon: Icons.article, label: "Data Log", route: '/river/manual/data-log'),
|
||||||
|
// *** ADDED: From river_home_page.dart ***
|
||||||
|
SidebarItem(icon: Icons.image, label: "Image Request", route: '/river/manual/image-request'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SidebarItem(
|
SidebarItem(
|
||||||
@ -96,6 +100,8 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
|
|||||||
SidebarItem(
|
SidebarItem(
|
||||||
icon: Icons.search, label: "Investigative", isParent: true, children: [
|
icon: Icons.search, label: "Investigative", isParent: true, children: [
|
||||||
SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/river/investigative/info'),
|
SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/river/investigative/info'),
|
||||||
|
// *** ADDED: From river_home_page.dart ***
|
||||||
|
SidebarItem(icon: Icons.biotech, label: "Investigative Sampling", route: '/river/investigative/manual-sampling'),
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -115,6 +121,10 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
|
|||||||
SidebarItem(icon: Icons.pin_drop, label: "In-Situ Sampling", route: '/marine/manual/in-situ'),
|
SidebarItem(icon: Icons.pin_drop, label: "In-Situ Sampling", route: '/marine/manual/in-situ'),
|
||||||
SidebarItem(icon: Icons.waves, label: "Tarball Sampling", route: '/marine/manual/tarball'),
|
SidebarItem(icon: Icons.waves, label: "Tarball Sampling", route: '/marine/manual/tarball'),
|
||||||
SidebarItem(icon: Icons.article, label: "Data Log", route: '/marine/manual/data-log'),
|
SidebarItem(icon: Icons.article, label: "Data Log", route: '/marine/manual/data-log'),
|
||||||
|
// *** ADDED: From marine_home_page.dart ***
|
||||||
|
SidebarItem(icon: Icons.image, label: "Image Request", route: '/marine/manual/image-request'),
|
||||||
|
// *** ADDED: From marine_home_page.dart ***
|
||||||
|
SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/manual/report'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SidebarItem(
|
SidebarItem(
|
||||||
@ -124,6 +134,8 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
|
|||||||
SidebarItem(
|
SidebarItem(
|
||||||
icon: Icons.search, label: "Investigative", isParent: true, children: [
|
icon: Icons.search, label: "Investigative", isParent: true, children: [
|
||||||
SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/investigative/info'),
|
SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/investigative/info'),
|
||||||
|
// *** ADDED: From marine_home_page.dart ***
|
||||||
|
SidebarItem(icon: Icons.science_outlined, label: "Investigative Sampling", route: '/marine/investigative/manual-sampling'),
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
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/local_storage_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart';
|
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/river_manual_triennial_sampling_service.dart';
|
import 'package:environment_monitoring_app/services/river_manual_triennial_sampling_service.dart';
|
||||||
|
// *** ADDED: Import River Investigative Sampling Service ***
|
||||||
|
import 'package:environment_monitoring_app/services/river_investigative_sampling_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/air_sampling_service.dart';
|
import 'package:environment_monitoring_app/services/air_sampling_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/telegram_service.dart';
|
import 'package:environment_monitoring_app/services/telegram_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/server_config_service.dart';
|
import 'package:environment_monitoring_app/services/server_config_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/retry_service.dart';
|
import 'package:environment_monitoring_app/services/retry_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/marine_in_situ_sampling_service.dart';
|
import 'package:environment_monitoring_app/services/marine_in_situ_sampling_service.dart';
|
||||||
|
import 'package:environment_monitoring_app/services/marine_investigative_sampling_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/marine_npe_report_service.dart';
|
import 'package:environment_monitoring_app/services/marine_npe_report_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart';
|
import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart'; // Ensure this import is present
|
||||||
import 'package:environment_monitoring_app/services/marine_manual_pre_departure_service.dart';
|
import 'package:environment_monitoring_app/services/marine_manual_pre_departure_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/marine_manual_sonde_calibration_service.dart';
|
import 'package:environment_monitoring_app/services/marine_manual_sonde_calibration_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/marine_manual_equipment_maintenance_service.dart';
|
import 'package:environment_monitoring_app/services/marine_manual_equipment_maintenance_service.dart';
|
||||||
@ -66,6 +69,8 @@ import 'package:environment_monitoring_app/screens/river/continuous/overview.dar
|
|||||||
import 'package:environment_monitoring_app/screens/river/continuous/entry.dart' as riverContinuousEntry;
|
import 'package:environment_monitoring_app/screens/river/continuous/entry.dart' as riverContinuousEntry;
|
||||||
import 'package:environment_monitoring_app/screens/river/continuous/report.dart' as riverContinuousReport;
|
import 'package:environment_monitoring_app/screens/river/continuous/report.dart' as riverContinuousReport;
|
||||||
import 'package:environment_monitoring_app/screens/river/investigative/river_investigative_info_centre_document.dart';
|
import 'package:environment_monitoring_app/screens/river/investigative/river_investigative_info_centre_document.dart';
|
||||||
|
// *** ADDED: Import River Investigative Manual Sampling Screen ***
|
||||||
|
import 'package:environment_monitoring_app/screens/river/investigative/river_investigative_manual_sampling.dart' as riverInvestigativeManualSampling;
|
||||||
import 'package:environment_monitoring_app/screens/river/investigative/overview.dart' as riverInvestigativeOverview;
|
import 'package:environment_monitoring_app/screens/river/investigative/overview.dart' as riverInvestigativeOverview;
|
||||||
import 'package:environment_monitoring_app/screens/river/investigative/entry.dart' as riverInvestigativeEntry;
|
import 'package:environment_monitoring_app/screens/river/investigative/entry.dart' as riverInvestigativeEntry;
|
||||||
import 'package:environment_monitoring_app/screens/river/investigative/report.dart' as riverInvestigativeReport;
|
import 'package:environment_monitoring_app/screens/river/investigative/report.dart' as riverInvestigativeReport;
|
||||||
@ -76,16 +81,22 @@ import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_p
|
|||||||
import 'package:environment_monitoring_app/screens/marine/manual/in_situ_sampling.dart' as marineManualInSituSampling;
|
import 'package:environment_monitoring_app/screens/marine/manual/in_situ_sampling.dart' as marineManualInSituSampling;
|
||||||
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_report.dart' as marineManualReport;
|
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_report.dart' as marineManualReport;
|
||||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_npe_report_hub.dart';
|
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_npe_report_hub.dart';
|
||||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_pre_departure_checklist_screen.dart' as marineManualPreDepartureChecklist;
|
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_pre_departure_checklist_screen.dart'
|
||||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart' as marineManualSondeCalibration;
|
as marineManualPreDepartureChecklist;
|
||||||
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart' as marineManualEquipmentMaintenance;
|
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart'
|
||||||
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_data_status_log.dart' as marineManualDataStatusLog;
|
as marineManualSondeCalibration;
|
||||||
|
import 'package:environment_monitoring_app/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart'
|
||||||
|
as marineManualEquipmentMaintenance;
|
||||||
|
import 'package:environment_monitoring_app/screens/marine/manual/marine_manual_data_status_log.dart'
|
||||||
|
as marineManualDataStatusLog;
|
||||||
import 'package:environment_monitoring_app/screens/marine/manual/marine_image_request.dart' as marineManualImageRequest;
|
import 'package:environment_monitoring_app/screens/marine/manual/marine_image_request.dart' as marineManualImageRequest;
|
||||||
import 'package:environment_monitoring_app/screens/marine/continuous/marine_continuous_info_centre_document.dart';
|
import 'package:environment_monitoring_app/screens/marine/continuous/marine_continuous_info_centre_document.dart';
|
||||||
import 'package:environment_monitoring_app/screens/marine/continuous/overview.dart' as marineContinuousOverview;
|
import 'package:environment_monitoring_app/screens/marine/continuous/overview.dart' as marineContinuousOverview;
|
||||||
import 'package:environment_monitoring_app/screens/marine/continuous/entry.dart' as marineContinuousEntry;
|
import 'package:environment_monitoring_app/screens/marine/continuous/entry.dart' as marineContinuousEntry;
|
||||||
import 'package:environment_monitoring_app/screens/marine/continuous/report.dart' as marineContinuousReport;
|
import 'package:environment_monitoring_app/screens/marine/continuous/report.dart' as marineContinuousReport;
|
||||||
import 'package:environment_monitoring_app/screens/marine/investigative/marine_investigative_info_centre_document.dart';
|
import 'package:environment_monitoring_app/screens/marine/investigative/marine_investigative_info_centre_document.dart';
|
||||||
|
import 'package:environment_monitoring_app/screens/marine/investigative/marine_investigative_manual_sampling.dart'
|
||||||
|
as marineInvestigativeManualSampling;
|
||||||
import 'package:environment_monitoring_app/screens/marine/investigative/overview.dart' as marineInvestigativeOverview;
|
import 'package:environment_monitoring_app/screens/marine/investigative/overview.dart' as marineInvestigativeOverview;
|
||||||
import 'package:environment_monitoring_app/screens/marine/investigative/entry.dart' as marineInvestigativeEntry;
|
import 'package:environment_monitoring_app/screens/marine/investigative/entry.dart' as marineInvestigativeEntry;
|
||||||
import 'package:environment_monitoring_app/screens/marine/investigative/report.dart' as marineInvestigativeReport;
|
import 'package:environment_monitoring_app/screens/marine/investigative/report.dart' as marineInvestigativeReport;
|
||||||
@ -105,6 +116,12 @@ void main() async {
|
|||||||
final RetryService retryService = RetryService();
|
final RetryService retryService = RetryService();
|
||||||
final MarineInSituSamplingService marineInSituService = MarineInSituSamplingService(telegramService);
|
final MarineInSituSamplingService marineInSituService = MarineInSituSamplingService(telegramService);
|
||||||
final RiverInSituSamplingService riverInSituService = RiverInSituSamplingService(telegramService);
|
final RiverInSituSamplingService riverInSituService = RiverInSituSamplingService(telegramService);
|
||||||
|
final MarineInvestigativeSamplingService marineInvestigativeService =
|
||||||
|
MarineInvestigativeSamplingService(telegramService);
|
||||||
|
// *** ADDED: Create instance of RiverInvestigativeSamplingService ***
|
||||||
|
final RiverInvestigativeSamplingService riverInvestigativeService =
|
||||||
|
RiverInvestigativeSamplingService(telegramService);
|
||||||
|
final MarineTarballSamplingService marineTarballService = MarineTarballSamplingService(telegramService);
|
||||||
|
|
||||||
telegramService.setApiService(apiService);
|
telegramService.setApiService(apiService);
|
||||||
|
|
||||||
@ -117,9 +134,13 @@ void main() async {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Initialize the retry service with all its dependencies.
|
// Initialize the retry service with all its dependencies.
|
||||||
|
// *** MODIFIED: Added riverInvestigativeService dependency (and marineTarballService from previous request) ***
|
||||||
retryService.initialize(
|
retryService.initialize(
|
||||||
marineInSituService: marineInSituService,
|
marineInSituService: marineInSituService,
|
||||||
riverInSituService: riverInSituService,
|
riverInSituService: riverInSituService,
|
||||||
|
marineInvestigativeService: marineInvestigativeService,
|
||||||
|
riverInvestigativeService: riverInvestigativeService, // <-- Added this line
|
||||||
|
marineTarballService: marineTarballService,
|
||||||
authProvider: authProvider,
|
authProvider: authProvider,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -135,21 +156,23 @@ void main() async {
|
|||||||
Provider(create: (_) => LocalStorageService()),
|
Provider(create: (_) => LocalStorageService()),
|
||||||
Provider.value(value: retryService),
|
Provider.value(value: retryService),
|
||||||
Provider.value(value: marineInSituService),
|
Provider.value(value: marineInSituService),
|
||||||
|
Provider.value(value: marineInvestigativeService),
|
||||||
Provider.value(value: riverInSituService),
|
Provider.value(value: riverInSituService),
|
||||||
|
// *** ADDED: Provider for River Investigative Service ***
|
||||||
|
Provider.value(value: riverInvestigativeService), // Use Provider.value
|
||||||
Provider(create: (context) => RiverManualTriennialSamplingService(telegramService)),
|
Provider(create: (context) => RiverManualTriennialSamplingService(telegramService)),
|
||||||
Provider(create: (context) => AirSamplingService(databaseHelper, telegramService)),
|
Provider(create: (context) => AirSamplingService(databaseHelper, telegramService)),
|
||||||
Provider(create: (context) => MarineTarballSamplingService(telegramService)),
|
Provider.value(value: marineTarballService), // Use Provider.value
|
||||||
Provider(create: (context) => MarineNpeReportService(Provider.of<TelegramService>(context, listen: false))),
|
Provider(create: (context) => MarineNpeReportService(Provider.of<TelegramService>(context, listen: false))),
|
||||||
// --- UPDATED: Inject ApiService into the service constructors ---
|
Provider(
|
||||||
Provider(create: (context) => MarineManualPreDepartureService(
|
create: (context) =>
|
||||||
Provider.of<ApiService>(context, listen: false)
|
MarineManualPreDepartureService(Provider.of<ApiService>(context, listen: false))),
|
||||||
)),
|
Provider(
|
||||||
Provider(create: (context) => MarineManualSondeCalibrationService(
|
create: (context) =>
|
||||||
Provider.of<ApiService>(context, listen: false)
|
MarineManualSondeCalibrationService(Provider.of<ApiService>(context, listen: false))),
|
||||||
)),
|
Provider(
|
||||||
Provider(create: (context) => MarineManualEquipmentMaintenanceService(
|
create: (context) =>
|
||||||
Provider.of<ApiService>(context, listen: false)
|
MarineManualEquipmentMaintenanceService(Provider.of<ApiService>(context, listen: false))),
|
||||||
)),
|
|
||||||
],
|
],
|
||||||
child: const RootApp(),
|
child: const RootApp(),
|
||||||
),
|
),
|
||||||
@ -182,7 +205,6 @@ class RootApp extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _RootAppState extends State<RootApp> {
|
class _RootAppState extends State<RootApp> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@ -255,8 +277,8 @@ class _RootAppState extends State<RootApp> {
|
|||||||
theme: AppTheme.darkBlueTheme,
|
theme: AppTheme.darkBlueTheme,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
home: homeWidget,
|
home: homeWidget,
|
||||||
|
|
||||||
onGenerateRoute: (settings) {
|
onGenerateRoute: (settings) {
|
||||||
|
// Keep existing onGenerateRoute logic for Tarball
|
||||||
if (settings.name == '/marine/manual/tarball/step2') {
|
if (settings.name == '/marine/manual/tarball/step2') {
|
||||||
final args = settings.arguments as TarballSamplingData;
|
final args = settings.arguments as TarballSamplingData;
|
||||||
return MaterialPageRoute(builder: (context) {
|
return MaterialPageRoute(builder: (context) {
|
||||||
@ -274,7 +296,8 @@ class _RootAppState extends State<RootApp> {
|
|||||||
return const marineManualDataStatusLog.MarineManualDataStatusLog();
|
return const marineManualDataStatusLog.MarineManualDataStatusLog();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return null;
|
// Add other potential dynamic routes here if necessary
|
||||||
|
return null; // Let routes map handle named routes
|
||||||
},
|
},
|
||||||
routes: {
|
routes: {
|
||||||
// Auth Routes
|
// Auth Routes
|
||||||
@ -314,7 +337,8 @@ class _RootAppState extends State<RootApp> {
|
|||||||
'/river/manual/info': (context) => const RiverManualInfoCentreDocument(),
|
'/river/manual/info': (context) => const RiverManualInfoCentreDocument(),
|
||||||
'/river/manual/in-situ': (context) => riverManualInSituSampling.RiverInSituSamplingScreen(),
|
'/river/manual/in-situ': (context) => riverManualInSituSampling.RiverInSituSamplingScreen(),
|
||||||
'/river/manual/report': (context) => riverManualReport.RiverManualReport(),
|
'/river/manual/report': (context) => riverManualReport.RiverManualReport(),
|
||||||
'/river/manual/triennial': (context) => riverManualTriennialSampling.RiverManualTriennialSamplingScreen(),
|
'/river/manual/triennial': (context) =>
|
||||||
|
riverManualTriennialSampling.RiverManualTriennialSamplingScreen(),
|
||||||
'/river/manual/data-log': (context) => riverManualDataStatusLog.RiverManualDataStatusLog(),
|
'/river/manual/data-log': (context) => riverManualDataStatusLog.RiverManualDataStatusLog(),
|
||||||
'/river/manual/image-request': (context) => riverManualImageRequest.RiverManualImageRequest(),
|
'/river/manual/image-request': (context) => riverManualImageRequest.RiverManualImageRequest(),
|
||||||
|
|
||||||
@ -326,9 +350,15 @@ class _RootAppState extends State<RootApp> {
|
|||||||
|
|
||||||
// River Investigative
|
// River Investigative
|
||||||
'/river/investigative/info': (context) => const RiverInvestigativeInfoCentreDocument(),
|
'/river/investigative/info': (context) => const RiverInvestigativeInfoCentreDocument(),
|
||||||
'/river/investigative/overview': (context) => riverInvestigativeOverview.OverviewScreen(),
|
// *** ADDED: Route for River Investigative Manual Sampling ***
|
||||||
'/river/investigative/entry': (context) => riverInvestigativeEntry.EntryScreen(),
|
'/river/investigative/manual-sampling': (context) =>
|
||||||
'/river/investigative/report': (context) => riverInvestigativeReport.ReportScreen(),
|
riverInvestigativeManualSampling.RiverInvestigativeManualSamplingScreen(),
|
||||||
|
'/river/investigative/overview': (context) =>
|
||||||
|
riverInvestigativeOverview.OverviewScreen(), // Keep placeholder/future routes
|
||||||
|
'/river/investigative/entry': (context) =>
|
||||||
|
riverInvestigativeEntry.EntryScreen(), // Keep placeholder/future routes
|
||||||
|
'/river/investigative/report': (context) =>
|
||||||
|
riverInvestigativeReport.ReportScreen(), // Keep placeholder/future routes
|
||||||
|
|
||||||
// Marine Manual
|
// Marine Manual
|
||||||
'/marine/manual/info': (context) => marineManualInfoCentreDocument.MarineInfoCentreDocument(),
|
'/marine/manual/info': (context) => marineManualInfoCentreDocument.MarineInfoCentreDocument(),
|
||||||
@ -337,9 +367,12 @@ class _RootAppState extends State<RootApp> {
|
|||||||
'/marine/manual/tarball': (context) => const TarballSamplingStep1(),
|
'/marine/manual/tarball': (context) => const TarballSamplingStep1(),
|
||||||
'/marine/manual/report': (context) => const marineManualReport.MarineManualReportHomePage(),
|
'/marine/manual/report': (context) => const marineManualReport.MarineManualReportHomePage(),
|
||||||
'/marine/manual/report/npe': (context) => const MarineManualNPEReportHub(),
|
'/marine/manual/report/npe': (context) => const MarineManualNPEReportHub(),
|
||||||
'/marine/manual/report/pre-departure': (context) => const marineManualPreDepartureChecklist.MarineManualPreDepartureChecklistScreen(),
|
'/marine/manual/report/pre-departure': (context) =>
|
||||||
'/marine/manual/report/calibration': (context) => const marineManualSondeCalibration.MarineManualSondeCalibrationScreen(),
|
const marineManualPreDepartureChecklist.MarineManualPreDepartureChecklistScreen(),
|
||||||
'/marine/manual/report/maintenance': (context) => const marineManualEquipmentMaintenance.MarineManualEquipmentMaintenanceScreen(),
|
'/marine/manual/report/calibration': (context) =>
|
||||||
|
const marineManualSondeCalibration.MarineManualSondeCalibrationScreen(),
|
||||||
|
'/marine/manual/report/maintenance': (context) =>
|
||||||
|
const marineManualEquipmentMaintenance.MarineManualEquipmentMaintenanceScreen(),
|
||||||
'/marine/manual/image-request': (context) => const marineManualImageRequest.MarineImageRequestScreen(),
|
'/marine/manual/image-request': (context) => const marineManualImageRequest.MarineImageRequestScreen(),
|
||||||
|
|
||||||
// Marine Continuous
|
// Marine Continuous
|
||||||
@ -350,6 +383,8 @@ class _RootAppState extends State<RootApp> {
|
|||||||
|
|
||||||
// Marine Investigative
|
// Marine Investigative
|
||||||
'/marine/investigative/info': (context) => const MarineInvestigativeInfoCentreDocument(),
|
'/marine/investigative/info': (context) => const MarineInvestigativeInfoCentreDocument(),
|
||||||
|
'/marine/investigative/manual-sampling': (context) =>
|
||||||
|
marineInvestigativeManualSampling.MarineInvestigativeManualSampling(),
|
||||||
'/marine/investigative/overview': (context) => marineInvestigativeOverview.OverviewScreen(),
|
'/marine/investigative/overview': (context) => marineInvestigativeOverview.OverviewScreen(),
|
||||||
'/marine/investigative/entry': (context) => marineInvestigativeEntry.EntryScreen(),
|
'/marine/investigative/entry': (context) => marineInvestigativeEntry.EntryScreen(),
|
||||||
'/marine/investigative/report': (context) => marineInvestigativeReport.ReportScreen(),
|
'/marine/investigative/report': (context) => marineInvestigativeReport.ReportScreen(),
|
||||||
@ -370,27 +405,62 @@ class SessionAwareWrapper extends StatefulWidget {
|
|||||||
|
|
||||||
class _SessionAwareWrapperState extends State<SessionAwareWrapper> {
|
class _SessionAwareWrapperState extends State<SessionAwareWrapper> {
|
||||||
bool _isDialogShowing = false;
|
bool _isDialogShowing = false;
|
||||||
|
// --- MODIFICATION START ---
|
||||||
|
// 1. Create a variable to hold the AuthProvider instance.
|
||||||
|
late AuthProvider _authProvider;
|
||||||
|
// --- MODIFICATION END ---
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
final auth = Provider.of<AuthProvider>(context);
|
// --- MODIFICATION START ---
|
||||||
|
// 2. Get the provider reference here and add the listener.
|
||||||
|
_authProvider = Provider.of<AuthProvider>(context);
|
||||||
|
_authProvider.addListener(_handleSessionExpired);
|
||||||
|
// --- MODIFICATION END ---
|
||||||
|
|
||||||
if (auth.isSessionExpired && !_isDialogShowing) {
|
// Call initial check here if needed, or rely on RootApp's check.
|
||||||
// Use addPostFrameCallback to show dialog after the build phase.
|
// _checkAndShowDialogIfNeeded(_authProvider.isSessionExpired);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
// --- MODIFICATION START ---
|
||||||
|
// 3. Use the saved reference to remove the listener. This is safe.
|
||||||
|
_authProvider.removeListener(_handleSessionExpired);
|
||||||
|
// --- MODIFICATION END ---
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSessionExpired() {
|
||||||
|
// --- MODIFICATION START ---
|
||||||
|
// 4. Use the saved _authProvider reference.
|
||||||
|
_checkAndShowDialogIfNeeded(_authProvider.isSessionExpired);
|
||||||
|
// --- MODIFICATION END ---
|
||||||
|
}
|
||||||
|
|
||||||
|
void _checkAndShowDialogIfNeeded(bool isExpired) {
|
||||||
|
if (isExpired && !_isDialogShowing && mounted) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && !_isDialogShowing) { // Double check mounted and flag
|
||||||
_showSessionExpiredDialog();
|
_showSessionExpiredDialog();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
} else if (!isExpired && _isDialogShowing && mounted) {
|
||||||
|
// If session becomes valid again and dialog is showing, maybe dismiss it?
|
||||||
|
// Or rely on user action. For now, we only trigger ON expiry.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showSessionExpiredDialog() async {
|
Future<void> _showSessionExpiredDialog() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
setState(() => _isDialogShowing = true);
|
setState(() => _isDialogShowing = true);
|
||||||
await showDialog(
|
await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false, // User must make a choice
|
barrierDismissible: false,
|
||||||
builder: (BuildContext dialogContext) {
|
builder: (BuildContext dialogContext) {
|
||||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
// Use the state's _authProvider reference, which is safe.
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text("Session Expired"),
|
title: const Text("Session Expired"),
|
||||||
content: const Text(
|
content: const Text(
|
||||||
@ -399,22 +469,26 @@ class _SessionAwareWrapperState extends State<SessionAwareWrapper> {
|
|||||||
TextButton(
|
TextButton(
|
||||||
child: const Text("Continue Offline"),
|
child: const Text("Continue Offline"),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(dialogContext).pop(); // Just close the dialog
|
Navigator.of(dialogContext).pop();
|
||||||
|
// Optionally: _authProvider.clearSessionExpiredFlag(); // If needed
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
child: const Text("Login Now"),
|
child: const Text("Login Now"),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// Logout clears all state and pushes to login screen via the RootApp builder
|
// --- MODIFICATION START ---
|
||||||
auth.logout();
|
// 5. Use the saved reference to log out.
|
||||||
Navigator.of(dialogContext).pop();
|
_authProvider.logout();
|
||||||
|
// --- MODIFICATION END ---
|
||||||
|
Navigator.of(dialogContext).pop(); // Close dialog first
|
||||||
|
// RootApp builder will handle navigation to LoginScreen
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// Once the dialog is dismissed, reset the flag.
|
// Reset flag after dialog is dismissed
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isDialogShowing = false);
|
setState(() => _isDialogShowing = false);
|
||||||
}
|
}
|
||||||
@ -422,7 +496,6 @@ class _SessionAwareWrapperState extends State<SessionAwareWrapper> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// This widget just returns its child, its only job is to show the dialog.
|
|
||||||
return widget.child;
|
return widget.child;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -301,36 +301,12 @@ class InSituSamplingData {
|
|||||||
'npe_field_observations': npeFieldObservations,
|
'npe_field_observations': npeFieldObservations,
|
||||||
'npe_others_observation_remark': npeOthersObservationRemark,
|
'npe_others_observation_remark': npeOthersObservationRemark,
|
||||||
'npe_possible_source': npePossibleSource,
|
'npe_possible_source': npePossibleSource,
|
||||||
|
// Image paths will be added/updated by LocalStorageService during saving/updating
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
String generateTelegramAlertMessage({required bool isDataOnly}) {
|
// --- REMOVED: generateTelegramAlertMessage method ---
|
||||||
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
// This logic is now in MarineInSituSamplingService
|
||||||
final stationName = selectedStation?['man_station_name'] ?? 'N/A';
|
|
||||||
final stationCode = selectedStation?['man_station_code'] ?? 'N/A';
|
|
||||||
|
|
||||||
final buffer = StringBuffer()
|
|
||||||
..writeln('✅ *In-Situ Sample $submissionType Submitted:*')
|
|
||||||
..writeln()
|
|
||||||
..writeln('*Station Name & Code:* $stationName ($stationCode)')
|
|
||||||
..writeln('*Date of Submission:* $samplingDate')
|
|
||||||
..writeln('*Submitted by User:* $firstSamplerName')
|
|
||||||
..writeln('*Sonde ID:* ${sondeId ?? "N/A"}')
|
|
||||||
..writeln('*Status of Submission:* Successful');
|
|
||||||
|
|
||||||
if (distanceDifferenceInKm != null && distanceDifferenceInKm! > 0) {
|
|
||||||
buffer
|
|
||||||
..writeln()
|
|
||||||
..writeln('🔔 *Alert:*')
|
|
||||||
..writeln('*Distance from station:* ${(distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters');
|
|
||||||
|
|
||||||
if (distanceDifferenceRemarks != null && distanceDifferenceRemarks!.isNotEmpty) {
|
|
||||||
buffer.writeln('*Remarks for distance:* $distanceDifferenceRemarks');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return buffer.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, String> toApiFormData() {
|
Map<String, String> toApiFormData() {
|
||||||
final Map<String, String> map = {};
|
final Map<String, String> map = {};
|
||||||
|
|||||||
@ -1 +1,361 @@
|
|||||||
// lib/models/marine_inves_manual_sampling_data.dart
|
// lib/models/marine_inves_manual_sampling_data.dart
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:convert'; // Added for jsonEncode
|
||||||
|
// REMOVED: import 'package:environment_monitoring_app/models/marine_manual_npe_report_data.dart'; // No longer needed
|
||||||
|
|
||||||
|
/// A data model class to hold all information for the multi-step
|
||||||
|
/// Marine Investigative Manual Sampling form.
|
||||||
|
class MarineInvesManualSamplingData {
|
||||||
|
// --- Step 1: Sampling & Station Info ---
|
||||||
|
String? firstSamplerName;
|
||||||
|
int? firstSamplerUserId;
|
||||||
|
Map<String, dynamic>? secondSampler;
|
||||||
|
String? samplingDate;
|
||||||
|
String? samplingTime;
|
||||||
|
String? samplingType;
|
||||||
|
String? sampleIdCode;
|
||||||
|
|
||||||
|
// --- NEW: Step 1 Station Selection ---
|
||||||
|
String? stationTypeSelection; // 'Existing Manual Station', 'Existing Tarball Station', 'New Location'
|
||||||
|
|
||||||
|
// For 'Existing Manual Station'
|
||||||
|
String? selectedManualStateName;
|
||||||
|
String? selectedManualCategoryName;
|
||||||
|
Map<String, dynamic>? selectedStation; // This is the MANUAL station
|
||||||
|
|
||||||
|
// For 'Existing Tarball Station'
|
||||||
|
String? selectedTarballStateName;
|
||||||
|
Map<String, dynamic>? selectedTarballStation;
|
||||||
|
|
||||||
|
// For 'New Location'
|
||||||
|
String? newStationName;
|
||||||
|
String? newStationCode;
|
||||||
|
|
||||||
|
// --- Common Station/Location Fields ---
|
||||||
|
String? stationLatitude; // Populated from selected station OR manually entered
|
||||||
|
String? stationLongitude; // Populated from selected station OR manually entered
|
||||||
|
String? currentLatitude;
|
||||||
|
String? currentLongitude;
|
||||||
|
double? distanceDifferenceInKm;
|
||||||
|
String? distanceDifferenceRemarks;
|
||||||
|
|
||||||
|
// --- Step 2: Site Info & Photos ---
|
||||||
|
String? weather;
|
||||||
|
String? tideLevel;
|
||||||
|
String? seaCondition;
|
||||||
|
String? eventRemarks;
|
||||||
|
String? labRemarks;
|
||||||
|
|
||||||
|
File? leftLandViewImage;
|
||||||
|
File? rightLandViewImage;
|
||||||
|
File? waterFillingImage;
|
||||||
|
File? seawaterColorImage;
|
||||||
|
File? phPaperImage;
|
||||||
|
|
||||||
|
File? optionalImage1;
|
||||||
|
String? optionalRemark1;
|
||||||
|
File? optionalImage2;
|
||||||
|
String? optionalRemark2;
|
||||||
|
File? optionalImage3;
|
||||||
|
String? optionalRemark3;
|
||||||
|
File? optionalImage4;
|
||||||
|
String? optionalRemark4;
|
||||||
|
|
||||||
|
// --- Step 3: Data Capture ---
|
||||||
|
String? sondeId;
|
||||||
|
String? dataCaptureDate;
|
||||||
|
String? dataCaptureTime;
|
||||||
|
double? oxygenConcentration;
|
||||||
|
double? oxygenSaturation;
|
||||||
|
double? ph;
|
||||||
|
double? salinity;
|
||||||
|
double? electricalConductivity;
|
||||||
|
double? temperature;
|
||||||
|
double? tds;
|
||||||
|
double? turbidity;
|
||||||
|
double? tss;
|
||||||
|
double? batteryVoltage;
|
||||||
|
|
||||||
|
// --- Post-Submission Status ---
|
||||||
|
String? submissionStatus;
|
||||||
|
String? submissionMessage;
|
||||||
|
String? reportId; // This will be 'man_inves_id' from the DB
|
||||||
|
|
||||||
|
// REMOVED: All NPE Report Compatibility Fields (npeFieldObservations, npeOthersObservationRemark, etc.)
|
||||||
|
|
||||||
|
|
||||||
|
MarineInvesManualSamplingData({
|
||||||
|
this.samplingDate,
|
||||||
|
this.samplingTime,
|
||||||
|
this.stationTypeSelection = 'Existing Manual Station', // Default value
|
||||||
|
});
|
||||||
|
|
||||||
|
// REMOVED: toNpeReportData() method
|
||||||
|
|
||||||
|
|
||||||
|
/// Creates a single JSON object with all submission data for offline storage.
|
||||||
|
Map<String, dynamic> toDbJson() {
|
||||||
|
return {
|
||||||
|
// Step 1
|
||||||
|
'first_sampler_name': firstSamplerName,
|
||||||
|
'first_sampler_user_id': firstSamplerUserId,
|
||||||
|
'secondSampler': secondSampler,
|
||||||
|
'sampling_date': samplingDate,
|
||||||
|
'sampling_time': samplingTime,
|
||||||
|
'sampling_type': samplingType,
|
||||||
|
'sample_id_code': sampleIdCode,
|
||||||
|
'stationTypeSelection': stationTypeSelection,
|
||||||
|
'selectedManualStateName': selectedManualStateName,
|
||||||
|
'selectedManualCategoryName': selectedManualCategoryName,
|
||||||
|
'selectedStation': selectedStation,
|
||||||
|
'selectedTarballStateName': selectedTarballStateName,
|
||||||
|
'selectedTarballStation': selectedTarballStation,
|
||||||
|
'newStationName': newStationName,
|
||||||
|
'newStationCode': newStationCode,
|
||||||
|
'station_latitude': stationLatitude,
|
||||||
|
'station_longitude': stationLongitude,
|
||||||
|
'current_latitude': currentLatitude,
|
||||||
|
'current_longitude': currentLongitude,
|
||||||
|
'distance_difference_in_km': distanceDifferenceInKm,
|
||||||
|
'distance_difference_remarks': distanceDifferenceRemarks,
|
||||||
|
// Step 2
|
||||||
|
'weather': weather,
|
||||||
|
'tide_level': tideLevel,
|
||||||
|
'sea_condition': seaCondition,
|
||||||
|
'event_remarks': eventRemarks,
|
||||||
|
'lab_remarks': labRemarks,
|
||||||
|
'inves_optional_photo_01_remarks': optionalRemark1,
|
||||||
|
'inves_optional_photo_02_remarks': optionalRemark2,
|
||||||
|
'inves_optional_photo_03_remarks': optionalRemark3,
|
||||||
|
'inves_optional_photo_04_remarks': optionalRemark4,
|
||||||
|
// Step 3
|
||||||
|
'sonde_id': sondeId,
|
||||||
|
'data_capture_date': dataCaptureDate,
|
||||||
|
'data_capture_time': dataCaptureTime,
|
||||||
|
'oxygen_concentration': oxygenConcentration,
|
||||||
|
'oxygen_saturation': oxygenSaturation,
|
||||||
|
'ph': ph,
|
||||||
|
'salinity': salinity,
|
||||||
|
'electrical_conductivity': electricalConductivity,
|
||||||
|
'temperature': temperature,
|
||||||
|
'tds': tds,
|
||||||
|
'turbidity': turbidity,
|
||||||
|
'tss': tss,
|
||||||
|
'battery_voltage': batteryVoltage,
|
||||||
|
// Status
|
||||||
|
'submission_status': submissionStatus,
|
||||||
|
'submission_message': submissionMessage,
|
||||||
|
'report_id': reportId,
|
||||||
|
// REMOVED: NPE fields from JSON
|
||||||
|
|
||||||
|
// Image paths will be added by LocalStorageService during save
|
||||||
|
'inves_left_side_land_view': leftLandViewImage?.path,
|
||||||
|
'inves_right_side_land_view': rightLandViewImage?.path,
|
||||||
|
'inves_filling_water_into_sample_bottle': waterFillingImage?.path,
|
||||||
|
'inves_seawater_in_clear_glass_bottle': seawaterColorImage?.path,
|
||||||
|
'inves_examine_preservative_ph_paper': phPaperImage?.path,
|
||||||
|
'inves_optional_photo_01': optionalImage1?.path,
|
||||||
|
'inves_optional_photo_02': optionalImage2?.path,
|
||||||
|
'inves_optional_photo_03': optionalImage3?.path,
|
||||||
|
'inves_optional_photo_04': optionalImage4?.path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an InSituSamplingData object from a JSON map (for offline logs).
|
||||||
|
factory MarineInvesManualSamplingData.fromJson(Map<String, dynamic> json) {
|
||||||
|
double? doubleFromJson(dynamic value) {
|
||||||
|
if (value is num) return value.toDouble();
|
||||||
|
if (value is String) return double.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? intFromJson(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is String) return int.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
File? fileFromPath(dynamic path) {
|
||||||
|
// Ensure path is not null and not empty before creating File object
|
||||||
|
return (path is String && path.isNotEmpty) ? File(path) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = MarineInvesManualSamplingData();
|
||||||
|
|
||||||
|
// Step 1
|
||||||
|
data.firstSamplerName = json['first_sampler_name'];
|
||||||
|
data.firstSamplerUserId = intFromJson(json['first_sampler_user_id']);
|
||||||
|
data.secondSampler = json['secondSampler']; // Assumes it's stored correctly as JSON Map
|
||||||
|
data.samplingDate = json['sampling_date'];
|
||||||
|
data.samplingTime = json['sampling_time'];
|
||||||
|
data.samplingType = json['sampling_type'];
|
||||||
|
data.sampleIdCode = json['sample_id_code'];
|
||||||
|
data.stationTypeSelection = json['stationTypeSelection'];
|
||||||
|
data.selectedManualStateName = json['selectedManualStateName'];
|
||||||
|
data.selectedManualCategoryName = json['selectedManualCategoryName'];
|
||||||
|
data.selectedStation = json['selectedStation']; // Assumes it's stored correctly as JSON Map
|
||||||
|
data.selectedTarballStateName = json['selectedTarballStateName'];
|
||||||
|
data.selectedTarballStation = json['selectedTarballStation']; // Assumes it's stored correctly as JSON Map
|
||||||
|
data.newStationName = json['newStationName'];
|
||||||
|
data.newStationCode = json['newStationCode'];
|
||||||
|
data.stationLatitude = json['station_latitude']?.toString(); // Ensure conversion to String
|
||||||
|
data.stationLongitude = json['station_longitude']?.toString(); // Ensure conversion to String
|
||||||
|
data.currentLatitude = json['current_latitude']?.toString(); // Ensure conversion to String
|
||||||
|
data.currentLongitude = json['current_longitude']?.toString(); // Ensure conversion to String
|
||||||
|
data.distanceDifferenceInKm = doubleFromJson(json['distance_difference_in_km']);
|
||||||
|
data.distanceDifferenceRemarks = json['distance_difference_remarks'];
|
||||||
|
|
||||||
|
// Step 2
|
||||||
|
data.weather = json['weather'];
|
||||||
|
data.tideLevel = json['tide_level'];
|
||||||
|
data.seaCondition = json['sea_condition'];
|
||||||
|
data.eventRemarks = json['event_remarks'];
|
||||||
|
data.labRemarks = json['lab_remarks'];
|
||||||
|
data.optionalRemark1 = json['inves_optional_photo_01_remarks'];
|
||||||
|
data.optionalRemark2 = json['inves_optional_photo_02_remarks'];
|
||||||
|
data.optionalRemark3 = json['inves_optional_photo_03_remarks'];
|
||||||
|
data.optionalRemark4 = json['inves_optional_photo_04_remarks'];
|
||||||
|
|
||||||
|
// Step 2 Images (Paths stored in JSON)
|
||||||
|
data.leftLandViewImage = fileFromPath(json['inves_left_side_land_view']);
|
||||||
|
data.rightLandViewImage = fileFromPath(json['inves_right_side_land_view']);
|
||||||
|
data.waterFillingImage = fileFromPath(json['inves_filling_water_into_sample_bottle']);
|
||||||
|
data.seawaterColorImage = fileFromPath(json['inves_seawater_in_clear_glass_bottle']);
|
||||||
|
data.phPaperImage = fileFromPath(json['inves_examine_preservative_ph_paper']);
|
||||||
|
data.optionalImage1 = fileFromPath(json['inves_optional_photo_01']);
|
||||||
|
data.optionalImage2 = fileFromPath(json['inves_optional_photo_02']);
|
||||||
|
data.optionalImage3 = fileFromPath(json['inves_optional_photo_03']);
|
||||||
|
data.optionalImage4 = fileFromPath(json['inves_optional_photo_04']);
|
||||||
|
|
||||||
|
// Step 3
|
||||||
|
data.sondeId = json['sonde_id'];
|
||||||
|
data.dataCaptureDate = json['data_capture_date'];
|
||||||
|
data.dataCaptureTime = json['data_capture_time'];
|
||||||
|
data.oxygenConcentration = doubleFromJson(json['oxygen_concentration']);
|
||||||
|
data.oxygenSaturation = doubleFromJson(json['oxygen_saturation']);
|
||||||
|
data.ph = doubleFromJson(json['ph']);
|
||||||
|
data.salinity = doubleFromJson(json['salinity']);
|
||||||
|
data.electricalConductivity = doubleFromJson(json['electrical_conductivity']);
|
||||||
|
data.temperature = doubleFromJson(json['temperature']);
|
||||||
|
data.tds = doubleFromJson(json['tds']);
|
||||||
|
data.turbidity = doubleFromJson(json['turbidity']);
|
||||||
|
data.tss = doubleFromJson(json['tss']);
|
||||||
|
data.batteryVoltage = doubleFromJson(json['battery_voltage']);
|
||||||
|
|
||||||
|
// Status
|
||||||
|
data.submissionStatus = json['submission_status'];
|
||||||
|
data.submissionMessage = json['submission_message'];
|
||||||
|
data.reportId = json['report_id']?.toString(); // Ensure conversion to String
|
||||||
|
|
||||||
|
// REMOVED: NPE fields from deserialization
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Maps data to keys for the API submission.
|
||||||
|
Map<String, String> toApiFormData() {
|
||||||
|
final Map<String, String> map = {};
|
||||||
|
|
||||||
|
void add(String key, dynamic value) {
|
||||||
|
if (value != null) {
|
||||||
|
String stringValue;
|
||||||
|
if (value is double) {
|
||||||
|
// Handle special -999.0 value
|
||||||
|
if (value == -999.0) {
|
||||||
|
stringValue = '-999';
|
||||||
|
} else {
|
||||||
|
// Format other doubles to 5 decimal places
|
||||||
|
stringValue = value.toStringAsFixed(5);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Convert other types directly to string
|
||||||
|
stringValue = value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add if the resulting string is not empty
|
||||||
|
if (stringValue.isNotEmpty) {
|
||||||
|
map[key] = stringValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add prefix 'inves_' to all keys to match new backend endpoints
|
||||||
|
add('inves_date', samplingDate);
|
||||||
|
add('inves_time', samplingTime);
|
||||||
|
add('first_sampler_user_id', firstSamplerUserId);
|
||||||
|
add('inves_second_sampler_id', secondSampler?['user_id']);
|
||||||
|
add('inves_type', samplingType);
|
||||||
|
add('inves_sample_id_code', sampleIdCode);
|
||||||
|
add('inves_current_latitude', currentLatitude);
|
||||||
|
add('inves_current_longitude', currentLongitude);
|
||||||
|
add('inves_distance_difference', distanceDifferenceInKm);
|
||||||
|
add('inves_distance_difference_remarks', distanceDifferenceRemarks);
|
||||||
|
|
||||||
|
// --- NEW: Add station selection logic ---
|
||||||
|
add('inves_station_type', stationTypeSelection);
|
||||||
|
if (stationTypeSelection == 'Existing Manual Station') {
|
||||||
|
add('station_id', selectedStation?['station_id']); // Foreign key to manual stations
|
||||||
|
add('inves_station_code', selectedStation?['man_station_code']);
|
||||||
|
add('inves_station_name', selectedStation?['man_station_name']);
|
||||||
|
} else if (stationTypeSelection == 'Existing Tarball Station') {
|
||||||
|
add('tbl_station_id', selectedTarballStation?['station_id']); // Foreign key to tarball stations
|
||||||
|
add('inves_station_code', selectedTarballStation?['tbl_station_code']);
|
||||||
|
add('inves_station_name', selectedTarballStation?['tbl_station_name']);
|
||||||
|
} else if (stationTypeSelection == 'New Location') {
|
||||||
|
add('inves_new_station_name', newStationName);
|
||||||
|
add('inves_new_station_code', newStationCode);
|
||||||
|
add('inves_station_latitude', stationLatitude); // Manually entered lat
|
||||||
|
add('inves_station_longitude', stationLongitude); // Manually entered lon
|
||||||
|
}
|
||||||
|
// --- END NEW ---
|
||||||
|
|
||||||
|
add('inves_weather', weather);
|
||||||
|
add('inves_tide_level', tideLevel);
|
||||||
|
add('inves_sea_condition', seaCondition);
|
||||||
|
add('inves_event_remark', eventRemarks);
|
||||||
|
add('inves_lab_remark', labRemarks);
|
||||||
|
add('inves_optional_photo_01_remarks', optionalRemark1);
|
||||||
|
add('inves_optional_photo_02_remarks', optionalRemark2);
|
||||||
|
add('inves_optional_photo_03_remarks', optionalRemark3);
|
||||||
|
add('inves_optional_photo_04_remarks', optionalRemark4);
|
||||||
|
add('inves_sondeID', sondeId);
|
||||||
|
add('data_capture_date', dataCaptureDate); // Note: No 'inves_' prefix assumed based on original model
|
||||||
|
add('data_capture_time', dataCaptureTime); // Note: No 'inves_' prefix assumed based on original model
|
||||||
|
add('inves_oxygen_conc', oxygenConcentration);
|
||||||
|
add('inves_oxygen_sat', oxygenSaturation);
|
||||||
|
add('inves_ph', ph);
|
||||||
|
add('inves_salinity', salinity);
|
||||||
|
add('inves_conductivity', electricalConductivity);
|
||||||
|
add('inves_temperature', temperature);
|
||||||
|
add('inves_tds', tds);
|
||||||
|
add('inves_turbidity', turbidity);
|
||||||
|
add('inves_tss', tss);
|
||||||
|
add('inves_battery_volt', batteryVoltage);
|
||||||
|
|
||||||
|
add('first_sampler_name', firstSamplerName); // For logging/display purposes on backend if needed
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps image files to keys for the API submission.
|
||||||
|
Map<String, File?> toApiImageFiles() {
|
||||||
|
return {
|
||||||
|
// Add prefix 'inves_' to match backend expectations
|
||||||
|
'inves_left_side_land_view': leftLandViewImage,
|
||||||
|
'inves_right_side_land_view': rightLandViewImage,
|
||||||
|
'inves_filling_water_into_sample_bottle': waterFillingImage,
|
||||||
|
'inves_seawater_in_clear_glass_bottle': seawaterColorImage,
|
||||||
|
'inves_examine_preservative_ph_paper': phPaperImage,
|
||||||
|
'inves_optional_photo_01': optionalImage1,
|
||||||
|
'inves_optional_photo_02': optionalImage2,
|
||||||
|
'inves_optional_photo_03': optionalImage3,
|
||||||
|
'inves_optional_photo_04': optionalImage4,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- START: REMOVED generateInvestigativeTelegramAlertMessage ---
|
||||||
|
// This logic is now handled in MarineInvestigativeSamplingService
|
||||||
|
// --- END: REMOVED ---
|
||||||
|
}
|
||||||
@ -164,7 +164,23 @@ class RiverInSituSamplingData {
|
|||||||
|
|
||||||
void add(String key, dynamic value) {
|
void add(String key, dynamic value) {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
map[key] = value.toString();
|
String stringValue;
|
||||||
|
// --- START FIX: Handle -999.0 correctly ---
|
||||||
|
if (value is double) {
|
||||||
|
if (value == -999.0) {
|
||||||
|
stringValue = '-999';
|
||||||
|
} else {
|
||||||
|
stringValue = value.toStringAsFixed(5);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stringValue = value.toString();
|
||||||
|
}
|
||||||
|
// --- END FIX ---
|
||||||
|
|
||||||
|
// Only add non-empty values
|
||||||
|
if (stringValue.isNotEmpty) {
|
||||||
|
map[key] = stringValue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,7 +191,9 @@ class RiverInSituSamplingData {
|
|||||||
add('r_man_time', samplingTime);
|
add('r_man_time', samplingTime);
|
||||||
add('r_man_type', samplingType);
|
add('r_man_type', samplingType);
|
||||||
add('r_man_sample_id_code', sampleIdCode);
|
add('r_man_sample_id_code', sampleIdCode);
|
||||||
|
// --- START FIX: Use correct key 'station_id' ---
|
||||||
add('station_id', selectedStation?['station_id']);
|
add('station_id', selectedStation?['station_id']);
|
||||||
|
// --- END FIX ---
|
||||||
add('r_man_current_latitude', currentLatitude);
|
add('r_man_current_latitude', currentLatitude);
|
||||||
add('r_man_current_longitude', currentLongitude);
|
add('r_man_current_longitude', currentLongitude);
|
||||||
add('r_man_distance_difference', distanceDifferenceInKm);
|
add('r_man_distance_difference', distanceDifferenceInKm);
|
||||||
@ -304,43 +322,48 @@ class RiverInSituSamplingData {
|
|||||||
// This is a direct conversion of the model's properties to a map,
|
// This is a direct conversion of the model's properties to a map,
|
||||||
// with keys matching the expected JSON file format.
|
// with keys matching the expected JSON file format.
|
||||||
final data = {
|
final data = {
|
||||||
'battery_cap': batteryVoltage,
|
// --- START FIX: Map model properties to correct db.json keys ---
|
||||||
|
'battery_cap': batteryVoltage == -999.0 ? null : batteryVoltage, // Handle -999
|
||||||
'device_name': sondeId,
|
'device_name': sondeId,
|
||||||
'sampling_type': samplingType,
|
'sampling_type': samplingType,
|
||||||
'report_id': reportId,
|
'report_id': reportId,
|
||||||
'sampler_2ndname': secondSampler?['user_name'],
|
'sampler_2ndname': secondSampler?['first_name'], // Use first_name as likely user name
|
||||||
'sample_state': selectedStateName,
|
'sample_state': selectedStateName,
|
||||||
'station_id': selectedStation?['sampling_station_code'],
|
'station_id': selectedStation?['sampling_station_code'],
|
||||||
'tech_id': firstSamplerUserId,
|
'tech_id': firstSamplerUserId,
|
||||||
'tech_name': firstSamplerName,
|
'tech_name': firstSamplerName,
|
||||||
'latitude': stationLatitude,
|
'latitude': stationLatitude, // Assuming station lat/lon is intended here
|
||||||
'longitude': stationLongitude,
|
'longitude': stationLongitude, // Assuming station lat/lon is intended here
|
||||||
'record_dt': '$samplingDate $samplingTime',
|
'record_dt': '$samplingDate $samplingTime',
|
||||||
'do_mgl': oxygenConcentration,
|
'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration,
|
||||||
'do_sat': oxygenSaturation,
|
'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation,
|
||||||
'ph': ph,
|
'ph': ph == -999.0 ? null : ph,
|
||||||
'salinity': salinity,
|
'salinity': salinity == -999.0 ? null : salinity,
|
||||||
'temperature': temperature,
|
'temperature': temperature == -999.0 ? null : temperature,
|
||||||
'turbidity': turbidity,
|
'turbidity': turbidity == -999.0 ? null : turbidity,
|
||||||
'tds': tds,
|
'tds': tds == -999.0 ? null : tds,
|
||||||
'electric_conductivity': electricalConductivity,
|
'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity,
|
||||||
'ammonia': ammonia, // MODIFIED: Added ammonia
|
'ammonia': ammonia == -999.0 ? null : ammonia,
|
||||||
'flowrate': flowrateValue,
|
'flowrate': flowrateValue,
|
||||||
'odour': '', // Assuming these are not collected in this form
|
'odour': '', // Not collected
|
||||||
'floatable': '', // Assuming these are not collected in this form
|
'floatable': '', // Not collected
|
||||||
'sample_id': sampleIdCode,
|
'sample_id': sampleIdCode,
|
||||||
'weather': weather,
|
'weather': weather,
|
||||||
'remarks_event': eventRemarks,
|
'remarks_event': eventRemarks,
|
||||||
'remarks_lab': labRemarks,
|
'remarks_lab': labRemarks,
|
||||||
|
// --- END FIX ---
|
||||||
};
|
};
|
||||||
|
// Remove null values before encoding
|
||||||
|
data.removeWhere((key, value) => value == null);
|
||||||
return jsonEncode(data);
|
return jsonEncode(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a JSON object for basic form info, mimicking 'river_insitu_basic_form.json'.
|
/// Creates a JSON object for basic form info, mimicking 'river_insitu_basic_form.json'.
|
||||||
String toBasicFormJson() {
|
String toBasicFormJson() {
|
||||||
final data = {
|
final data = {
|
||||||
|
// --- START FIX: Map model properties to correct form keys ---
|
||||||
'tech_name': firstSamplerName,
|
'tech_name': firstSamplerName,
|
||||||
'sampler_2ndname': secondSampler?['user_name'],
|
'sampler_2ndname': secondSampler?['first_name'],
|
||||||
'sample_date': samplingDate,
|
'sample_date': samplingDate,
|
||||||
'sample_time': samplingTime,
|
'sample_time': samplingTime,
|
||||||
'sampling_type': samplingType,
|
'sampling_type': samplingType,
|
||||||
@ -348,39 +371,50 @@ class RiverInSituSamplingData {
|
|||||||
'station_id': selectedStation?['sampling_station_code'],
|
'station_id': selectedStation?['sampling_station_code'],
|
||||||
'station_latitude': stationLatitude,
|
'station_latitude': stationLatitude,
|
||||||
'station_longitude': stationLongitude,
|
'station_longitude': stationLongitude,
|
||||||
'latitude': currentLatitude,
|
'latitude': currentLatitude, // Current location lat
|
||||||
'longitude': currentLongitude,
|
'longitude': currentLongitude, // Current location lon
|
||||||
'sample_id': sampleIdCode,
|
'sample_id': sampleIdCode,
|
||||||
|
// --- END FIX ---
|
||||||
};
|
};
|
||||||
|
// Remove null values before encoding
|
||||||
|
data.removeWhere((key, value) => value == null);
|
||||||
return jsonEncode(data);
|
return jsonEncode(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a JSON object for sensor readings, mimicking 'river_sampling_reading.json'.
|
/// Creates a JSON object for sensor readings, mimicking 'river_sampling_reading.json'.
|
||||||
String toReadingJson() {
|
String toReadingJson() {
|
||||||
final data = {
|
final data = {
|
||||||
'do_mgl': oxygenConcentration,
|
// --- START FIX: Map model properties to correct reading keys ---
|
||||||
'do_sat': oxygenSaturation,
|
'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration,
|
||||||
'ph': ph,
|
'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation,
|
||||||
'salinity': salinity,
|
'ph': ph == -999.0 ? null : ph,
|
||||||
'temperature': temperature,
|
'salinity': salinity == -999.0 ? null : salinity,
|
||||||
'turbidity': turbidity,
|
'temperature': temperature == -999.0 ? null : temperature,
|
||||||
'tds': tds,
|
'turbidity': turbidity == -999.0 ? null : turbidity,
|
||||||
'electric_conductivity': electricalConductivity,
|
'tds': tds == -999.0 ? null : tds,
|
||||||
'ammonia': ammonia, // MODIFIED: Added ammonia
|
'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity,
|
||||||
|
'ammonia': ammonia == -999.0 ? null : ammonia,
|
||||||
'flowrate': flowrateValue,
|
'flowrate': flowrateValue,
|
||||||
'date_sampling_reading': samplingDate,
|
'date_sampling_reading': dataCaptureDate, // Use data capture date/time
|
||||||
'time_sampling_reading': samplingTime,
|
'time_sampling_reading': dataCaptureTime, // Use data capture date/time
|
||||||
|
// --- END FIX ---
|
||||||
};
|
};
|
||||||
|
// Remove null values before encoding
|
||||||
|
data.removeWhere((key, value) => value == null);
|
||||||
return jsonEncode(data);
|
return jsonEncode(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a JSON object for manual info, mimicking 'river_manual_info.json'.
|
/// Creates a JSON object for manual info, mimicking 'river_manual_info.json'.
|
||||||
String toManualInfoJson() {
|
String toManualInfoJson() {
|
||||||
final data = {
|
final data = {
|
||||||
|
// --- START FIX: Map model properties to correct manual info keys ---
|
||||||
'weather': weather,
|
'weather': weather,
|
||||||
'remarks_event': eventRemarks,
|
'remarks_event': eventRemarks,
|
||||||
'remarks_lab': labRemarks,
|
'remarks_lab': labRemarks,
|
||||||
|
// --- END FIX ---
|
||||||
};
|
};
|
||||||
|
// Remove null values before encoding
|
||||||
|
data.removeWhere((key, value) => value == null);
|
||||||
return jsonEncode(data);
|
return jsonEncode(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
// lib/models/river_manual_triennial_sampling_data.dart
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:convert';
|
import 'dart:convert'; // Added for jsonEncode
|
||||||
|
|
||||||
|
/// Data model for the River Manual Triennial Sampling form.
|
||||||
class RiverManualTriennialSamplingData {
|
class RiverManualTriennialSamplingData {
|
||||||
// --- Step 1: Sampling & Station Info ---
|
// --- Step 1: Sampling & Station Info ---
|
||||||
String? firstSamplerName;
|
String? firstSamplerName;
|
||||||
@ -14,8 +15,7 @@ class RiverManualTriennialSamplingData {
|
|||||||
String? sampleIdCode;
|
String? sampleIdCode;
|
||||||
|
|
||||||
String? selectedStateName;
|
String? selectedStateName;
|
||||||
String? selectedCategoryName;
|
Map<String, dynamic>? selectedStation; // Triennial stations don't have categories
|
||||||
Map<String, dynamic>? selectedStation;
|
|
||||||
|
|
||||||
String? stationLatitude;
|
String? stationLatitude;
|
||||||
String? stationLongitude;
|
String? stationLongitude;
|
||||||
@ -45,7 +45,7 @@ class RiverManualTriennialSamplingData {
|
|||||||
File? optionalImage4;
|
File? optionalImage4;
|
||||||
String? optionalRemark4;
|
String? optionalRemark4;
|
||||||
|
|
||||||
// --- Step 3: Data Capture ---
|
// --- Step 3: Data Capture (Mirrors River In-Situ for now) ---
|
||||||
String? sondeId;
|
String? sondeId;
|
||||||
String? dataCaptureDate;
|
String? dataCaptureDate;
|
||||||
String? dataCaptureTime;
|
String? dataCaptureTime;
|
||||||
@ -57,10 +57,9 @@ class RiverManualTriennialSamplingData {
|
|||||||
double? temperature;
|
double? temperature;
|
||||||
double? tds;
|
double? tds;
|
||||||
double? turbidity;
|
double? turbidity;
|
||||||
double? ammonia;
|
double? ammonia; // Replaced tss with ammonia
|
||||||
double? batteryVoltage;
|
double? batteryVoltage;
|
||||||
|
|
||||||
// --- ADDED: Missing flowrate properties ---
|
|
||||||
String? flowrateMethod;
|
String? flowrateMethod;
|
||||||
double? flowrateSurfaceDrifterHeight;
|
double? flowrateSurfaceDrifterHeight;
|
||||||
double? flowrateSurfaceDrifterDistance;
|
double? flowrateSurfaceDrifterDistance;
|
||||||
@ -76,37 +75,131 @@ class RiverManualTriennialSamplingData {
|
|||||||
RiverManualTriennialSamplingData({
|
RiverManualTriennialSamplingData({
|
||||||
this.samplingDate,
|
this.samplingDate,
|
||||||
this.samplingTime,
|
this.samplingTime,
|
||||||
|
this.samplingType = 'Triennial', // Default for this form
|
||||||
});
|
});
|
||||||
|
|
||||||
|
factory RiverManualTriennialSamplingData.fromJson(Map<String, dynamic> json) {
|
||||||
|
File? fileFromJson(dynamic path) {
|
||||||
|
return (path is String && path.isNotEmpty) ? File(path) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
double? doubleFromJson(dynamic value) {
|
||||||
|
if (value is num) return value.toDouble();
|
||||||
|
if (value is String) return double.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? intFromJson(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is String) return int.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RiverManualTriennialSamplingData()
|
||||||
|
..firstSamplerName = json['firstSamplerName'] ?? json['first_sampler_name']
|
||||||
|
..firstSamplerUserId = intFromJson(json['firstSamplerUserId'] ?? json['first_sampler_user_id'])
|
||||||
|
..secondSampler = json['secondSampler']
|
||||||
|
..samplingDate = json['samplingDate'] ?? json['r_tri_date']
|
||||||
|
..samplingTime = json['samplingTime'] ?? json['r_tri_time']
|
||||||
|
..samplingType = json['samplingType'] ?? json['r_tri_type']
|
||||||
|
..sampleIdCode = json['sampleIdCode'] ?? json['r_tri_sample_id_code']
|
||||||
|
..selectedStateName = json['selectedStateName']
|
||||||
|
..selectedStation = json['selectedStation']
|
||||||
|
..stationLatitude = json['stationLatitude']
|
||||||
|
..stationLongitude = json['stationLongitude']
|
||||||
|
..currentLatitude = (json['currentLatitude'] ?? json['r_tri_current_latitude'])?.toString()
|
||||||
|
..currentLongitude = (json['currentLongitude'] ?? json['r_tri_current_longitude'])?.toString()
|
||||||
|
..distanceDifferenceInKm = doubleFromJson(json['distanceDifferenceInKm'] ?? json['r_tri_distance_difference'])
|
||||||
|
..distanceDifferenceRemarks = json['distanceDifferenceRemarks'] ?? json['r_tri_distance_difference_remarks']
|
||||||
|
..weather = json['weather'] ?? json['r_tri_weather']
|
||||||
|
..eventRemarks = json['eventRemarks'] ?? json['r_tri_event_remark']
|
||||||
|
..labRemarks = json['labRemarks'] ?? json['r_tri_lab_remark']
|
||||||
|
..sondeId = json['sondeId'] ?? json['r_tri_sondeID']
|
||||||
|
..dataCaptureDate = json['dataCaptureDate'] ?? json['data_capture_date']
|
||||||
|
..dataCaptureTime = json['dataCaptureTime'] ?? json['data_capture_time']
|
||||||
|
..oxygenConcentration = doubleFromJson(json['oxygenConcentration'] ?? json['r_tri_oxygen_conc'])
|
||||||
|
..oxygenSaturation = doubleFromJson(json['oxygenSaturation'] ?? json['r_tri_oxygen_sat'])
|
||||||
|
..ph = doubleFromJson(json['ph'] ?? json['r_tri_ph'])
|
||||||
|
..salinity = doubleFromJson(json['salinity'] ?? json['r_tri_salinity'])
|
||||||
|
..electricalConductivity = doubleFromJson(json['electricalConductivity'] ?? json['r_tri_conductivity'])
|
||||||
|
..temperature = doubleFromJson(json['temperature'] ?? json['r_tri_temperature'])
|
||||||
|
..tds = doubleFromJson(json['tds'] ?? json['r_tri_tds'])
|
||||||
|
..turbidity = doubleFromJson(json['turbidity'] ?? json['r_tri_turbidity'])
|
||||||
|
..ammonia = doubleFromJson(json['ammonia'] ?? json['r_tri_ammonia'])
|
||||||
|
..batteryVoltage = doubleFromJson(json['batteryVoltage'] ?? json['r_tri_battery_volt'])
|
||||||
|
..optionalRemark1 = json['optionalRemark1'] ?? json['r_tri_optional_photo_01_remarks']
|
||||||
|
..optionalRemark2 = json['optionalRemark2'] ?? json['r_tri_optional_photo_02_remarks']
|
||||||
|
..optionalRemark3 = json['optionalRemark3'] ?? json['r_tri_optional_photo_03_remarks']
|
||||||
|
..optionalRemark4 = json['optionalRemark4'] ?? json['r_tri_optional_photo_04_remarks']
|
||||||
|
..backgroundStationImage = fileFromJson(json['backgroundStationImage'] ?? json['r_tri_background_station'])
|
||||||
|
..upstreamRiverImage = fileFromJson(json['upstreamRiverImage'] ?? json['r_tri_upstream_river'])
|
||||||
|
..downstreamRiverImage = fileFromJson(json['downstreamRiverImage'] ?? json['r_tri_downstream_river'])
|
||||||
|
..sampleTurbidityImage = fileFromJson(json['sampleTurbidityImage'] ?? json['r_tri_sample_turbidity'])
|
||||||
|
..optionalImage1 = fileFromJson(json['optionalImage1'] ?? json['r_tri_optional_photo_01'])
|
||||||
|
..optionalImage2 = fileFromJson(json['optionalImage2'] ?? json['r_tri_optional_photo_02'])
|
||||||
|
..optionalImage3 = fileFromJson(json['optionalImage3'] ?? json['r_tri_optional_photo_03'])
|
||||||
|
..optionalImage4 = fileFromJson(json['optionalImage4'] ?? json['r_tri_optional_photo_04'])
|
||||||
|
..flowrateMethod = json['flowrateMethod'] ?? json['r_tri_flowrate_method']
|
||||||
|
..flowrateSurfaceDrifterHeight = doubleFromJson(json['flowrateSurfaceDrifterHeight'] ?? json['r_tri_flowrate_sd_height'])
|
||||||
|
..flowrateSurfaceDrifterDistance = doubleFromJson(json['flowrateSurfaceDrifterDistance'] ?? json['r_tri_flowrate_sd_distance'])
|
||||||
|
..flowrateSurfaceDrifterTimeFirst = json['flowrateSurfaceDrifterTimeFirst'] ?? json['r_tri_flowrate_sd_time_first']
|
||||||
|
..flowrateSurfaceDrifterTimeLast = json['flowrateSurfaceDrifterTimeLast'] ?? json['r_tri_flowrate_sd_time_last']
|
||||||
|
..flowrateValue = doubleFromJson(json['flowrateValue'] ?? json['r_tri_flowrate_value'])
|
||||||
|
..submissionStatus = json['submissionStatus']
|
||||||
|
..submissionMessage = json['submissionMessage']
|
||||||
|
..reportId = json['reportId']?.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Converts the data model into a Map<String, String> for the API form data.
|
||||||
Map<String, String> toApiFormData() {
|
Map<String, String> toApiFormData() {
|
||||||
final Map<String, String> map = {};
|
final Map<String, String> map = {};
|
||||||
|
|
||||||
void add(String key, dynamic value) {
|
void add(String key, dynamic value) {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
map[key] = value.toString();
|
String stringValue;
|
||||||
|
if (value is double) {
|
||||||
|
if (value == -999.0) {
|
||||||
|
stringValue = '-999';
|
||||||
|
} else {
|
||||||
|
stringValue = value.toStringAsFixed(5);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stringValue = value.toString();
|
||||||
|
}
|
||||||
|
if (stringValue.isNotEmpty) {
|
||||||
|
map[key] = stringValue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 1 Data
|
||||||
add('first_sampler_user_id', firstSamplerUserId);
|
add('first_sampler_user_id', firstSamplerUserId);
|
||||||
add('r_tri_second_sampler_id', secondSampler?['user_id']);
|
add('r_tri_second_sampler_id', secondSampler?['user_id']);
|
||||||
add('r_tri_date', samplingDate);
|
add('r_tri_date', samplingDate);
|
||||||
add('r_tri_time', samplingTime);
|
add('r_tri_time', samplingTime);
|
||||||
add('r_tri_type', samplingType);
|
add('r_tri_type', samplingType);
|
||||||
add('r_tri_sample_id_code', sampleIdCode);
|
add('r_tri_sample_id_code', sampleIdCode);
|
||||||
add('station_id', selectedStation?['station_id']);
|
add('station_id', selectedStation?['station_id']); // Ensure this is the correct foreign key
|
||||||
add('r_tri_current_latitude', currentLatitude);
|
add('r_tri_current_latitude', currentLatitude);
|
||||||
add('r_tri_current_longitude', currentLongitude);
|
add('r_tri_current_longitude', currentLongitude);
|
||||||
add('r_tri_distance_difference', distanceDifferenceInKm);
|
add('r_tri_distance_difference', distanceDifferenceInKm);
|
||||||
add('r_tri_distance_difference_remarks', distanceDifferenceRemarks);
|
add('r_tri_distance_difference_remarks', distanceDifferenceRemarks);
|
||||||
|
|
||||||
|
// Step 2 Data
|
||||||
add('r_tri_weather', weather);
|
add('r_tri_weather', weather);
|
||||||
add('r_tri_event_remark', eventRemarks);
|
add('r_tri_event_remark', eventRemarks);
|
||||||
add('r_tri_lab_remark', labRemarks);
|
add('r_tri_lab_remark', labRemarks);
|
||||||
|
|
||||||
|
// Step 4 Data
|
||||||
add('r_tri_optional_photo_01_remarks', optionalRemark1);
|
add('r_tri_optional_photo_01_remarks', optionalRemark1);
|
||||||
add('r_tri_optional_photo_02_remarks', optionalRemark2);
|
add('r_tri_optional_photo_02_remarks', optionalRemark2);
|
||||||
add('r_tri_optional_photo_03_remarks', optionalRemark3);
|
add('r_tri_optional_photo_03_remarks', optionalRemark3);
|
||||||
add('r_tri_optional_photo_04_remarks', optionalRemark4);
|
add('r_tri_optional_photo_04_remarks', optionalRemark4);
|
||||||
|
|
||||||
|
// Step 3 Data
|
||||||
add('r_tri_sondeID', sondeId);
|
add('r_tri_sondeID', sondeId);
|
||||||
add('data_capture_date', dataCaptureDate);
|
add('data_capture_date', dataCaptureDate); // Note: Keys likely shared with in-situ for capture time
|
||||||
add('data_capture_time', dataCaptureTime);
|
add('data_capture_time', dataCaptureTime);
|
||||||
add('r_tri_oxygen_conc', oxygenConcentration);
|
add('r_tri_oxygen_conc', oxygenConcentration);
|
||||||
add('r_tri_oxygen_sat', oxygenSaturation);
|
add('r_tri_oxygen_sat', oxygenSaturation);
|
||||||
@ -124,13 +217,17 @@ class RiverManualTriennialSamplingData {
|
|||||||
add('r_tri_flowrate_sd_time_first', flowrateSurfaceDrifterTimeFirst);
|
add('r_tri_flowrate_sd_time_first', flowrateSurfaceDrifterTimeFirst);
|
||||||
add('r_tri_flowrate_sd_time_last', flowrateSurfaceDrifterTimeLast);
|
add('r_tri_flowrate_sd_time_last', flowrateSurfaceDrifterTimeLast);
|
||||||
add('r_tri_flowrate_value', flowrateValue);
|
add('r_tri_flowrate_value', flowrateValue);
|
||||||
|
|
||||||
|
// Additional data for display or logging
|
||||||
add('first_sampler_name', firstSamplerName);
|
add('first_sampler_name', firstSamplerName);
|
||||||
add('r_tri_station_code', selectedStation?['sampling_station_code']);
|
add('r_tri_station_code', selectedStation?['sampling_station_code']);
|
||||||
add('r_tri_station_name', selectedStation?['sampling_river']);
|
add('r_tri_station_name', selectedStation?['sampling_river']);
|
||||||
|
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts the image properties into a Map<String, File?> for the multipart API request.
|
||||||
Map<String, File?> toApiImageFiles() {
|
Map<String, File?> toApiImageFiles() {
|
||||||
return {
|
return {
|
||||||
'r_tri_background_station': backgroundStationImage,
|
'r_tri_background_station': backgroundStationImage,
|
||||||
@ -144,7 +241,7 @@ class RiverManualTriennialSamplingData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ADDED: Missing toMap() method ---
|
/// Converts the data model into a Map suitable for saving to local storage or DB log.
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
return {
|
return {
|
||||||
'firstSamplerName': firstSamplerName,
|
'firstSamplerName': firstSamplerName,
|
||||||
@ -155,7 +252,6 @@ class RiverManualTriennialSamplingData {
|
|||||||
'samplingType': samplingType,
|
'samplingType': samplingType,
|
||||||
'sampleIdCode': sampleIdCode,
|
'sampleIdCode': sampleIdCode,
|
||||||
'selectedStateName': selectedStateName,
|
'selectedStateName': selectedStateName,
|
||||||
'selectedCategoryName': selectedCategoryName,
|
|
||||||
'selectedStation': selectedStation,
|
'selectedStation': selectedStation,
|
||||||
'stationLatitude': stationLatitude,
|
'stationLatitude': stationLatitude,
|
||||||
'stationLongitude': stationLongitude,
|
'stationLongitude': stationLongitude,
|
||||||
@ -202,4 +298,40 @@ class RiverManualTriennialSamplingData {
|
|||||||
'reportId': reportId,
|
'reportId': reportId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a single JSON object with all submission data, mimicking 'db.json'
|
||||||
|
String toDbJson() {
|
||||||
|
final data = {
|
||||||
|
'battery_cap': batteryVoltage == -999.0 ? null : batteryVoltage,
|
||||||
|
'device_name': sondeId,
|
||||||
|
'sampling_type': samplingType,
|
||||||
|
'report_id': reportId,
|
||||||
|
'sampler_2ndname': secondSampler?['first_name'],
|
||||||
|
'sample_state': selectedStateName,
|
||||||
|
'station_id': selectedStation?['sampling_station_code'],
|
||||||
|
'tech_id': firstSamplerUserId,
|
||||||
|
'tech_name': firstSamplerName,
|
||||||
|
'latitude': stationLatitude,
|
||||||
|
'longitude': stationLongitude,
|
||||||
|
'record_dt': '$samplingDate $samplingTime',
|
||||||
|
'do_mgl': oxygenConcentration == -999.0 ? null : oxygenConcentration,
|
||||||
|
'do_sat': oxygenSaturation == -999.0 ? null : oxygenSaturation,
|
||||||
|
'ph': ph == -999.0 ? null : ph,
|
||||||
|
'salinity': salinity == -999.0 ? null : salinity,
|
||||||
|
'temperature': temperature == -999.0 ? null : temperature,
|
||||||
|
'turbidity': turbidity == -999.0 ? null : turbidity,
|
||||||
|
'tds': tds == -999.0 ? null : tds,
|
||||||
|
'electric_conductivity': electricalConductivity == -999.0 ? null : electricalConductivity,
|
||||||
|
'ammonia': ammonia == -999.0 ? null : ammonia,
|
||||||
|
'flowrate': flowrateValue,
|
||||||
|
'odour': '', // Not collected
|
||||||
|
'floatable': '', // Not collected
|
||||||
|
'sample_id': sampleIdCode,
|
||||||
|
'weather': weather,
|
||||||
|
'remarks_event': eventRemarks,
|
||||||
|
'remarks_lab': labRemarks,
|
||||||
|
};
|
||||||
|
data.removeWhere((key, value) => value == null);
|
||||||
|
return jsonEncode(data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -54,7 +54,7 @@ class TarballSamplingData {
|
|||||||
npeData.firstSamplerUserId = firstSamplerUserId;
|
npeData.firstSamplerUserId = firstSamplerUserId;
|
||||||
npeData.eventDate = samplingDate;
|
npeData.eventDate = samplingDate;
|
||||||
npeData.eventTime = samplingTime;
|
npeData.eventTime = samplingTime;
|
||||||
npeData.selectedStation = selectedStation;
|
npeData.selectedStation = selectedStation; // Pass the whole station map
|
||||||
npeData.latitude = currentLatitude;
|
npeData.latitude = currentLatitude;
|
||||||
npeData.longitude = currentLongitude;
|
npeData.longitude = currentLongitude;
|
||||||
npeData.stateName = selectedStateName;
|
npeData.stateName = selectedStateName;
|
||||||
@ -62,40 +62,29 @@ class TarballSamplingData {
|
|||||||
// Pre-tick the relevant observation for a tarball event.
|
// Pre-tick the relevant observation for a tarball event.
|
||||||
npeData.fieldObservations['Observation of tar balls'] = true;
|
npeData.fieldObservations['Observation of tar balls'] = true;
|
||||||
|
|
||||||
|
// Transfer images
|
||||||
|
final availableImages = [
|
||||||
|
leftCoastalViewImage,
|
||||||
|
rightCoastalViewImage,
|
||||||
|
verticalLinesImage,
|
||||||
|
horizontalLineImage,
|
||||||
|
optionalImage1,
|
||||||
|
optionalImage2,
|
||||||
|
optionalImage3,
|
||||||
|
optionalImage4,
|
||||||
|
].where((img) => img != null).cast<File>().toList();
|
||||||
|
|
||||||
|
if (availableImages.isNotEmpty) npeData.image1 = availableImages[0];
|
||||||
|
if (availableImages.length > 1) npeData.image2 = availableImages[1];
|
||||||
|
if (availableImages.length > 2) npeData.image3 = availableImages[2];
|
||||||
|
if (availableImages.length > 3) npeData.image4 = availableImages[3];
|
||||||
|
|
||||||
|
|
||||||
return npeData;
|
return npeData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a formatted Telegram alert message for successful submissions.
|
// --- REMOVED: generateTelegramAlertMessage method ---
|
||||||
String generateTelegramAlertMessage({required bool isDataOnly}) {
|
// Logic moved to MarineTarballSamplingService
|
||||||
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
|
||||||
final stationName = selectedStation?['tbl_station_name'] ?? 'N/A';
|
|
||||||
final stationCode = selectedStation?['tbl_station_code'] ?? 'N/A';
|
|
||||||
// This logic now correctly uses the full classification object if available.
|
|
||||||
final classification = selectedClassification?['classification_name'] ?? classificationId?.toString() ?? 'N/A';
|
|
||||||
|
|
||||||
final buffer = StringBuffer()
|
|
||||||
..writeln('✅ *Tarball Sample $submissionType Submitted:*')
|
|
||||||
..writeln()
|
|
||||||
..writeln('*Station Name & Code:* $stationName ($stationCode)')
|
|
||||||
..writeln('*Date of Submission:* $samplingDate')
|
|
||||||
..writeln('*Submitted by User:* $firstSampler')
|
|
||||||
..writeln('*Classification:* $classification')
|
|
||||||
..writeln('*Status of Submission:* Successful');
|
|
||||||
|
|
||||||
// Add distance alert if relevant
|
|
||||||
if (distanceDifference != null && distanceDifference! > 0) {
|
|
||||||
buffer
|
|
||||||
..writeln()
|
|
||||||
..writeln('🔔 *Alert:*')
|
|
||||||
..writeln('*Distance from station:* ${(distanceDifference! * 1000).toStringAsFixed(0)} meters');
|
|
||||||
|
|
||||||
if (distanceDifferenceRemarks != null && distanceDifferenceRemarks!.isNotEmpty) {
|
|
||||||
buffer.writeln('*Remarks for distance:* $distanceDifferenceRemarks');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return buffer.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Converts the form's text and selection data into a Map suitable for JSON encoding.
|
/// Converts the form's text and selection data into a Map suitable for JSON encoding.
|
||||||
/// This map will be sent as the body of the first API request.
|
/// This map will be sent as the body of the first API request.
|
||||||
@ -113,7 +102,7 @@ class TarballSamplingData {
|
|||||||
'current_latitude': currentLatitude ?? '',
|
'current_latitude': currentLatitude ?? '',
|
||||||
'current_longitude': currentLongitude ?? '',
|
'current_longitude': currentLongitude ?? '',
|
||||||
'distance_difference': distanceDifference?.toString() ?? '',
|
'distance_difference': distanceDifference?.toString() ?? '',
|
||||||
'distance_remarks': distanceDifferenceRemarks ?? '',
|
'distance_remarks': distanceDifferenceRemarks ?? '', // Corrected key based on service
|
||||||
'optional_photo_remark_01': optionalRemark1 ?? '',
|
'optional_photo_remark_01': optionalRemark1 ?? '',
|
||||||
'optional_photo_remark_02': optionalRemark2 ?? '',
|
'optional_photo_remark_02': optionalRemark2 ?? '',
|
||||||
'optional_photo_remark_03': optionalRemark3 ?? '',
|
'optional_photo_remark_03': optionalRemark3 ?? '',
|
||||||
@ -125,6 +114,8 @@ class TarballSamplingData {
|
|||||||
'first_sampler_name': firstSampler ?? '',
|
'first_sampler_name': firstSampler ?? '',
|
||||||
'classification_name': selectedClassification?['classification_name']?.toString() ?? '',
|
'classification_name': selectedClassification?['classification_name']?.toString() ?? '',
|
||||||
};
|
};
|
||||||
|
// Remove keys with empty string values before sending
|
||||||
|
data.removeWhere((key, value) => value.isEmpty);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,8 +134,9 @@ class TarballSamplingData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a single JSON object with all submission data, mimicking 'db.json'
|
/// Creates a single JSON object with all submission data for offline storage.
|
||||||
Map<String, dynamic> toDbJson() {
|
Map<String, dynamic> toDbJson() {
|
||||||
|
// Include image paths for local storage
|
||||||
return {
|
return {
|
||||||
'firstSampler': firstSampler,
|
'firstSampler': firstSampler,
|
||||||
'firstSamplerUserId': firstSamplerUserId,
|
'firstSamplerUserId': firstSamplerUserId,
|
||||||
@ -162,9 +154,17 @@ class TarballSamplingData {
|
|||||||
'distanceDifferenceRemarks': distanceDifferenceRemarks,
|
'distanceDifferenceRemarks': distanceDifferenceRemarks,
|
||||||
'classificationId': classificationId,
|
'classificationId': classificationId,
|
||||||
'selectedClassification': selectedClassification,
|
'selectedClassification': selectedClassification,
|
||||||
|
'leftCoastalViewImage': leftCoastalViewImage?.path,
|
||||||
|
'rightCoastalViewImage': rightCoastalViewImage?.path,
|
||||||
|
'verticalLinesImage': verticalLinesImage?.path,
|
||||||
|
'horizontalLineImage': horizontalLineImage?.path,
|
||||||
|
'optionalImage1': optionalImage1?.path,
|
||||||
'optionalRemark1': optionalRemark1,
|
'optionalRemark1': optionalRemark1,
|
||||||
|
'optionalImage2': optionalImage2?.path,
|
||||||
'optionalRemark2': optionalRemark2,
|
'optionalRemark2': optionalRemark2,
|
||||||
|
'optionalImage3': optionalImage3?.path,
|
||||||
'optionalRemark3': optionalRemark3,
|
'optionalRemark3': optionalRemark3,
|
||||||
|
'optionalImage4': optionalImage4?.path,
|
||||||
'optionalRemark4': optionalRemark4,
|
'optionalRemark4': optionalRemark4,
|
||||||
'reportId': reportId,
|
'reportId': reportId,
|
||||||
'submissionStatus': submissionStatus,
|
'submissionStatus': submissionStatus,
|
||||||
@ -174,24 +174,26 @@ class TarballSamplingData {
|
|||||||
|
|
||||||
/// Creates a JSON object for basic form info, mimicking 'basic_form.json'.
|
/// Creates a JSON object for basic form info, mimicking 'basic_form.json'.
|
||||||
Map<String, dynamic> toBasicFormJson() {
|
Map<String, dynamic> toBasicFormJson() {
|
||||||
return {
|
final data = {
|
||||||
'tech_name': firstSampler,
|
'tech_name': firstSampler,
|
||||||
'sampler_2ndname': secondSampler?['user_name'],
|
'sampler_2ndname': secondSampler?['first_name'], // Assuming first_name is appropriate
|
||||||
'sample_date': samplingDate,
|
'sample_date': samplingDate,
|
||||||
'sample_time': samplingTime,
|
'sample_time': samplingTime,
|
||||||
'sample_state': selectedStateName,
|
'sample_state': selectedStateName,
|
||||||
'station_id': selectedStation?['tbl_station_code'],
|
'station_id': selectedStation?['tbl_station_code'], // Use station code
|
||||||
'station_latitude': stationLatitude,
|
'station_latitude': stationLatitude,
|
||||||
'station_longitude': stationLongitude,
|
'station_longitude': stationLongitude,
|
||||||
'latitude': currentLatitude,
|
'latitude': currentLatitude, // Current location
|
||||||
'longitude': currentLongitude,
|
'longitude': currentLongitude, // Current location
|
||||||
'sample_id': reportId, // Using reportId as a unique identifier for the sample.
|
'sample_id': reportId, // Using reportId if available
|
||||||
};
|
};
|
||||||
|
data.removeWhere((key, value) => value == null);
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a JSON object for sensor readings, mimicking 'reading.json'.
|
/// Creates a JSON object for sensor readings, mimicking 'reading.json'.
|
||||||
Map<String, dynamic> toReadingJson() {
|
Map<String, dynamic> toReadingJson() {
|
||||||
return {
|
final data = {
|
||||||
'classification': selectedClassification?['classification_name'],
|
'classification': selectedClassification?['classification_name'],
|
||||||
'classification_id': classificationId,
|
'classification_id': classificationId,
|
||||||
'optional_remark_1': optionalRemark1,
|
'optional_remark_1': optionalRemark1,
|
||||||
@ -199,17 +201,21 @@ class TarballSamplingData {
|
|||||||
'optional_remark_3': optionalRemark3,
|
'optional_remark_3': optionalRemark3,
|
||||||
'optional_remark_4': optionalRemark4,
|
'optional_remark_4': optionalRemark4,
|
||||||
'distance_difference': distanceDifference,
|
'distance_difference': distanceDifference,
|
||||||
'distance_difference_remarks': distanceDifferenceRemarks,
|
'distance_difference_remarks': distanceDifferenceRemarks, // Corrected key
|
||||||
};
|
};
|
||||||
|
data.removeWhere((key, value) => value == null || (value is String && value.isEmpty));
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a JSON object for manual info, mimicking 'manual_info.json'.
|
/// Creates a JSON object for manual info, mimicking 'manual_info.json'.
|
||||||
Map<String, dynamic> toManualInfoJson() {
|
Map<String, dynamic> toManualInfoJson() {
|
||||||
return {
|
final data = {
|
||||||
// Tarball forms don't have a specific 'weather' or general remarks field,
|
// Tarball forms don't have weather or general remarks separate from distance
|
||||||
// so we use the distance remarks as a stand-in if available.
|
'weather': null, // Explicitly null if not collected
|
||||||
'remarks_event': distanceDifferenceRemarks,
|
'remarks_event': distanceDifferenceRemarks, // Use distance remarks if relevant
|
||||||
'remarks_lab': null,
|
'remarks_lab': null, // Explicitly null if not collected
|
||||||
};
|
};
|
||||||
|
data.removeWhere((key, value) => value == null || (value is String && value.isEmpty));
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1 +1,807 @@
|
|||||||
// lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_1_sampling_info.dart
|
// lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_1_sampling_info.dart
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:dropdown_search/dropdown_search.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:simple_barcode_scanner/simple_barcode_scanner.dart';
|
||||||
|
|
||||||
|
import '../../../../auth_provider.dart';
|
||||||
|
import '../../../../models/marine_inves_manual_sampling_data.dart';
|
||||||
|
import '../../../../services/marine_investigative_sampling_service.dart';
|
||||||
|
|
||||||
|
class MarineInvesManualStep1SamplingInfo extends StatefulWidget {
|
||||||
|
final MarineInvesManualSamplingData data;
|
||||||
|
final VoidCallback onNext;
|
||||||
|
|
||||||
|
const MarineInvesManualStep1SamplingInfo({
|
||||||
|
super.key,
|
||||||
|
required this.data,
|
||||||
|
required this.onNext,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MarineInvesManualStep1SamplingInfo> createState() => _MarineInvesManualStep1SamplingInfoState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MarineInvesManualStep1SamplingInfoState extends State<MarineInvesManualStep1SamplingInfo> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
bool _isLoadingLocation = false;
|
||||||
|
|
||||||
|
late final TextEditingController _firstSamplerController;
|
||||||
|
late final TextEditingController _dateController;
|
||||||
|
late final TextEditingController _timeController;
|
||||||
|
late final TextEditingController _barcodeController;
|
||||||
|
late final TextEditingController _stationLatController;
|
||||||
|
late final TextEditingController _stationLonController;
|
||||||
|
late final TextEditingController _currentLatController;
|
||||||
|
late final TextEditingController _currentLonController;
|
||||||
|
|
||||||
|
// --- NEW: Controllers for 'New Location' ---
|
||||||
|
late final TextEditingController _newStationNameController;
|
||||||
|
late final TextEditingController _newStationCodeController;
|
||||||
|
|
||||||
|
// --- NEW: State for Station Selection ---
|
||||||
|
String _stationType = 'Existing Manual Station';
|
||||||
|
final List<String> _stationTypeOptions = ['Existing Manual Station', 'Existing Tarball Station', 'New Location'];
|
||||||
|
|
||||||
|
// --- Lists for Dropdowns ---
|
||||||
|
List<String> _manualStatesList = [];
|
||||||
|
List<String> _categoriesForManualState = [];
|
||||||
|
List<Map<String, dynamic>> _stationsForManualCategory = [];
|
||||||
|
|
||||||
|
List<String> _tarballStatesList = [];
|
||||||
|
List<Map<String, dynamic>> _stationsForTarballState = [];
|
||||||
|
|
||||||
|
final List<String> _samplingTypes = ['Schedule', 'Ad-Hoc', 'Complaint', 'Investigative'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeControllers();
|
||||||
|
_initializeForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_firstSamplerController.dispose();
|
||||||
|
_dateController.dispose();
|
||||||
|
_timeController.dispose();
|
||||||
|
_barcodeController.dispose();
|
||||||
|
_stationLatController.dispose();
|
||||||
|
_stationLonController.dispose();
|
||||||
|
_currentLatController.dispose();
|
||||||
|
_currentLonController.dispose();
|
||||||
|
_newStationNameController.dispose();
|
||||||
|
_newStationCodeController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeControllers() {
|
||||||
|
_firstSamplerController = TextEditingController();
|
||||||
|
_dateController = TextEditingController();
|
||||||
|
_timeController = TextEditingController();
|
||||||
|
_barcodeController = TextEditingController(text: widget.data.sampleIdCode);
|
||||||
|
_stationLatController = TextEditingController(text: widget.data.stationLatitude);
|
||||||
|
_stationLonController = TextEditingController(text: widget.data.stationLongitude);
|
||||||
|
_currentLatController = TextEditingController(text: widget.data.currentLatitude);
|
||||||
|
_currentLonController = TextEditingController(text: widget.data.currentLongitude);
|
||||||
|
|
||||||
|
_newStationNameController = TextEditingController(text: widget.data.newStationName);
|
||||||
|
_newStationCodeController = TextEditingController(text: widget.data.newStationCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeForm() {
|
||||||
|
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||||
|
|
||||||
|
widget.data.firstSamplerName = auth.profileData?['first_name'] ?? 'Current User';
|
||||||
|
widget.data.firstSamplerUserId = auth.profileData?['user_id'];
|
||||||
|
_firstSamplerController.text = widget.data.firstSamplerName!;
|
||||||
|
|
||||||
|
_dateController.text = widget.data.samplingDate!;
|
||||||
|
_timeController.text = widget.data.samplingTime!;
|
||||||
|
|
||||||
|
if (widget.data.samplingType == null) {
|
||||||
|
widget.data.samplingType = 'Investigative';
|
||||||
|
}
|
||||||
|
|
||||||
|
_stationType = widget.data.stationTypeSelection ?? 'Existing Manual Station';
|
||||||
|
|
||||||
|
// --- Load Manual Station Data ---
|
||||||
|
final allManualStations = auth.manualStations ?? [];
|
||||||
|
if (allManualStations.isNotEmpty) {
|
||||||
|
_manualStatesList = allManualStations.map((s) => s['state_name'] as String?).whereType<String>().toSet().toList()..sort();
|
||||||
|
if (widget.data.selectedManualStateName != null) {
|
||||||
|
_categoriesForManualState = allManualStations
|
||||||
|
.where((s) => s['state_name'] == widget.data.selectedManualStateName)
|
||||||
|
.map((s) => s['category_name'] as String?)
|
||||||
|
.whereType<String>()
|
||||||
|
.toSet().toList()..sort();
|
||||||
|
}
|
||||||
|
if (widget.data.selectedManualCategoryName != null) {
|
||||||
|
_stationsForManualCategory = allManualStations
|
||||||
|
.where((s) => s['state_name'] == widget.data.selectedManualStateName && s['category_name'] == widget.data.selectedManualCategoryName)
|
||||||
|
.toList()..sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Load Tarball Station Data ---
|
||||||
|
final allTarballStations = auth.tarballStations ?? [];
|
||||||
|
if (allTarballStations.isNotEmpty) {
|
||||||
|
_tarballStatesList = allTarballStations.map((s) => s['state_name'] as String?).whereType<String>().toSet().toList()..sort();
|
||||||
|
if (widget.data.selectedTarballStateName != null) {
|
||||||
|
_stationsForTarballState = allTarballStations
|
||||||
|
.where((s) => s['state_name'] == widget.data.selectedTarballStateName)
|
||||||
|
.toList()..sort((a, b) => (a['tbl_station_code'] ?? '').compareTo(b['tbl_station_code'] ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// --- NEW: Handle Station Type Change ---
|
||||||
|
void _handleStationTypeChange(String? value) {
|
||||||
|
if (value == null) return;
|
||||||
|
setState(() {
|
||||||
|
_stationType = value;
|
||||||
|
widget.data.stationTypeSelection = value;
|
||||||
|
|
||||||
|
// Clear all station-related data to avoid conflicts
|
||||||
|
widget.data.selectedManualStateName = null;
|
||||||
|
widget.data.selectedManualCategoryName = null;
|
||||||
|
widget.data.selectedStation = null;
|
||||||
|
|
||||||
|
widget.data.selectedTarballStateName = null;
|
||||||
|
widget.data.selectedTarballStation = null;
|
||||||
|
|
||||||
|
widget.data.newStationName = null;
|
||||||
|
widget.data.newStationCode = null;
|
||||||
|
_newStationNameController.clear();
|
||||||
|
_newStationCodeController.clear();
|
||||||
|
|
||||||
|
widget.data.stationLatitude = null;
|
||||||
|
widget.data.stationLongitude = null;
|
||||||
|
_stationLatController.clear();
|
||||||
|
_stationLonController.clear();
|
||||||
|
|
||||||
|
widget.data.distanceDifferenceInKm = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _getCurrentLocation() async {
|
||||||
|
setState(() => _isLoadingLocation = true);
|
||||||
|
final service = Provider.of<MarineInvestigativeSamplingService>(context, listen: false);
|
||||||
|
try {
|
||||||
|
final position = await service.getCurrentLocation();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
widget.data.currentLatitude = position.latitude.toString();
|
||||||
|
widget.data.currentLongitude = position.longitude.toString();
|
||||||
|
_currentLatController.text = widget.data.currentLatitude!;
|
||||||
|
_currentLonController.text = widget.data.currentLongitude!;
|
||||||
|
|
||||||
|
// If 'New Location' is selected, also populate the station lat/lon
|
||||||
|
if (_stationType == 'New Location') {
|
||||||
|
widget.data.stationLatitude = widget.data.currentLatitude;
|
||||||
|
widget.data.stationLongitude = widget.data.currentLongitude;
|
||||||
|
_stationLatController.text = widget.data.stationLatitude!;
|
||||||
|
_stationLonController.text = widget.data.stationLongitude!;
|
||||||
|
}
|
||||||
|
_calculateDistance();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if(mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to get location: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoadingLocation = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _calculateDistance() {
|
||||||
|
final lat1Str = widget.data.stationLatitude;
|
||||||
|
final lon1Str = widget.data.stationLongitude;
|
||||||
|
final lat2Str = widget.data.currentLatitude;
|
||||||
|
final lon2Str = widget.data.currentLongitude;
|
||||||
|
|
||||||
|
if (lat1Str != null && lon1Str != null && lat2Str != null && lon2Str != null) {
|
||||||
|
final service = Provider.of<MarineInvestigativeSamplingService>(context, listen: false);
|
||||||
|
final lat1 = double.tryParse(lat1Str);
|
||||||
|
final lon1 = double.tryParse(lon1Str);
|
||||||
|
final lat2 = double.tryParse(lat2Str);
|
||||||
|
final lon2 = double.tryParse(lon2Str);
|
||||||
|
|
||||||
|
if (lat1 != null && lon1 != null && lat2 != null && lon2 != null) {
|
||||||
|
final distance = service.calculateDistance(lat1, lon1, lat2, lon2);
|
||||||
|
setState(() {
|
||||||
|
widget.data.distanceDifferenceInKm = distance;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
widget.data.distanceDifferenceInKm = null; // Clear distance if coords invalid
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
widget.data.distanceDifferenceInKm = null; // Clear distance if coords missing
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _scanBarcode() async {
|
||||||
|
final result = await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const SimpleBarcodeScannerPage()),
|
||||||
|
);
|
||||||
|
if (result is String && result != '-1' && mounted) {
|
||||||
|
setState(() {
|
||||||
|
widget.data.sampleIdCode = result;
|
||||||
|
_barcodeController.text = result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Re-used from original for manual stations ---
|
||||||
|
Future<void> _findAndShowNearbyStations() async {
|
||||||
|
if (widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) {
|
||||||
|
await _getCurrentLocation();
|
||||||
|
if (!mounted || widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final service = Provider.of<MarineInvestigativeSamplingService>(context, listen: false);
|
||||||
|
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||||
|
|
||||||
|
final currentLat = double.parse(widget.data.currentLatitude!);
|
||||||
|
final currentLon = double.parse(widget.data.currentLongitude!);
|
||||||
|
final allStations = auth.manualStations ?? []; // Only search manual stations
|
||||||
|
final List<Map<String, dynamic>> nearbyStations = [];
|
||||||
|
|
||||||
|
for (var station in allStations) {
|
||||||
|
final stationLat = station['man_latitude'];
|
||||||
|
final stationLon = station['man_longitude'];
|
||||||
|
|
||||||
|
// Ensure coordinates are numbers before calculating distance
|
||||||
|
if (stationLat is num && stationLon is num) {
|
||||||
|
final distance = service.calculateDistance(currentLat, currentLon, stationLat.toDouble(), stationLon.toDouble());
|
||||||
|
if (distance <= 5.0) { // 5km radius
|
||||||
|
nearbyStations.add({'station': station, 'distance': distance});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debugPrint("Skipping station ${station['man_station_code']} due to invalid coordinates: Lat=$stationLat, Lon=$stationLon");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nearbyStations.sort((a, b) => a['distance'].compareTo(b['distance']));
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final selectedStation = await showDialog<Map<String, dynamic>>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedStation != null) {
|
||||||
|
_updateFormWithSelectedStation(selectedStation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Re-used from original for manual stations ---
|
||||||
|
void _updateFormWithSelectedStation(Map<String, dynamic> station) {
|
||||||
|
final allStations = Provider.of<AuthProvider>(context, listen: false).manualStations ?? [];
|
||||||
|
setState(() {
|
||||||
|
// Update State
|
||||||
|
widget.data.selectedManualStateName = station['state_name'];
|
||||||
|
|
||||||
|
// Update Category List based on new State
|
||||||
|
final categories = allStations
|
||||||
|
.where((s) => s['state_name'] == widget.data.selectedManualStateName)
|
||||||
|
.map((s) => s['category_name'] as String?)
|
||||||
|
.whereType<String>()
|
||||||
|
.toSet()
|
||||||
|
.toList();
|
||||||
|
categories.sort();
|
||||||
|
_categoriesForManualState = categories;
|
||||||
|
|
||||||
|
// Update Category
|
||||||
|
widget.data.selectedManualCategoryName = station['category_name'];
|
||||||
|
|
||||||
|
// Update Station List based on new State and Category
|
||||||
|
_stationsForManualCategory = allStations
|
||||||
|
.where((s) =>
|
||||||
|
s['state_name'] == widget.data.selectedManualStateName &&
|
||||||
|
s['category_name'] == widget.data.selectedManualCategoryName)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? ''));
|
||||||
|
|
||||||
|
// Update Selected Station and its coordinates
|
||||||
|
widget.data.selectedStation = station;
|
||||||
|
widget.data.stationLatitude = station['man_latitude']?.toString();
|
||||||
|
widget.data.stationLongitude = station['man_longitude']?.toString();
|
||||||
|
_stationLatController.text = widget.data.stationLatitude ?? '';
|
||||||
|
_stationLonController.text = widget.data.stationLongitude ?? '';
|
||||||
|
|
||||||
|
// Recalculate distance
|
||||||
|
_calculateDistance();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goToNextStep() {
|
||||||
|
if (_formKey.currentState!.validate()) {
|
||||||
|
_formKey.currentState!.save();
|
||||||
|
|
||||||
|
// The distance check applies to all 3 types.
|
||||||
|
// For "New Location", it compares manually-entered Lat/Lon vs. Current Lat/Lon.
|
||||||
|
final distanceInMeters = (widget.data.distanceDifferenceInKm ?? 0) * 1000;
|
||||||
|
|
||||||
|
// Only show remark dialog if distance is > 50m AND station lat/lon are actually set
|
||||||
|
// (prevents dialog for 'New Location' before coords are entered/fetched)
|
||||||
|
if (distanceInMeters > 50 && widget.data.stationLatitude != null && widget.data.stationLongitude != null) {
|
||||||
|
_showDistanceRemarkDialog();
|
||||||
|
} else {
|
||||||
|
widget.data.distanceDifferenceRemarks = null; // Clear remarks if within limit
|
||||||
|
widget.onNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showDistanceRemarkDialog() async {
|
||||||
|
final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks);
|
||||||
|
final dialogFormKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Distance Warning'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Form(
|
||||||
|
key: dialogFormKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Your current location is more than 50m away from the selected/entered station location.'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: remarkController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Remarks *',
|
||||||
|
hintText: 'Please provide a reason...',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Remarks are required to continue.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
child: const Text('Confirm'),
|
||||||
|
onPressed: () {
|
||||||
|
if (dialogFormKey.currentState!.validate()) {
|
||||||
|
setState(() {
|
||||||
|
widget.data.distanceDifferenceRemarks = remarkController.text;
|
||||||
|
});
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
widget.onNext(); // Proceed after confirming remark
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||||
|
final allUsers = auth.allUsers ?? [];
|
||||||
|
final secondSamplersList = allUsers.where((user) => user['user_id'] != auth.profileData?['user_id']).toList()
|
||||||
|
..sort((a, b) => (a['first_name'] ?? '').compareTo(b['first_name'] ?? ''));
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
children: [
|
||||||
|
Text("Sampling Information", style: Theme.of(context).textTheme.headlineSmall),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
TextFormField(controller: _firstSamplerController, readOnly: true, decoration: const InputDecoration(labelText: '1st Sampler')),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownSearch<Map<String, dynamic>>(
|
||||||
|
items: secondSamplersList,
|
||||||
|
selectedItem: widget.data.secondSampler,
|
||||||
|
itemAsString: (sampler) => "${sampler['first_name']} ${sampler['last_name']}",
|
||||||
|
onChanged: (sampler) => widget.data.secondSampler = sampler,
|
||||||
|
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Sampler..."))),
|
||||||
|
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: '2nd Sampler (Optional)')),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: TextFormField(controller: _dateController, readOnly: true, decoration: const InputDecoration(labelText: 'Date'))),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: TextFormField(controller: _timeController, readOnly: true, decoration: const InputDecoration(labelText: 'Time'))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: widget.data.samplingType,
|
||||||
|
items: _samplingTypes.map((type) => DropdownMenuItem(value: type, child: Text(type))).toList(),
|
||||||
|
onChanged: (value) => setState(() => widget.data.samplingType = value),
|
||||||
|
decoration: const InputDecoration(labelText: 'Sampling Type *'),
|
||||||
|
validator: (value) => value == null ? 'Please select a type' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// --- NEW: Station Type Selection ---
|
||||||
|
Text("Station Selection", style: Theme.of(context).textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: _stationType,
|
||||||
|
items: _stationTypeOptions.map((type) => DropdownMenuItem(value: type, child: Text(type))).toList(),
|
||||||
|
onChanged: _handleStationTypeChange,
|
||||||
|
decoration: const InputDecoration(labelText: 'Station Source *'),
|
||||||
|
validator: (value) => value == null ? 'Please select a station source' : null, // Added validator
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// --- NEW: Conditional Station Widgets ---
|
||||||
|
if (_stationType == 'Existing Manual Station')
|
||||||
|
_buildManualStationSelectors(auth.manualStations ?? []),
|
||||||
|
|
||||||
|
if (_stationType == 'Existing Tarball Station')
|
||||||
|
_buildTarballStationSelectors(auth.tarballStations ?? []),
|
||||||
|
|
||||||
|
if (_stationType == 'New Location')
|
||||||
|
_buildNewLocationFields(),
|
||||||
|
|
||||||
|
// --- Location Verification (Common to all) ---
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text("Location Verification", style: Theme.of(context).textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(controller: _currentLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Latitude')),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(controller: _currentLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Longitude')),
|
||||||
|
if (widget.data.distanceDifferenceInKm != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16.0),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red.withOpacity(0.1) : Colors.green.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red : Colors.green),
|
||||||
|
),
|
||||||
|
child: RichText(
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
text: TextSpan(
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
children: <TextSpan>[
|
||||||
|
const TextSpan(text: 'Distance from Station: '),
|
||||||
|
TextSpan(
|
||||||
|
text: '${(widget.data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red : Colors.green
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _isLoadingLocation ? null : _getCurrentLocation,
|
||||||
|
icon: _isLoadingLocation ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.location_searching),
|
||||||
|
label: Text(_stationType == 'New Location' ? "Get & Use Current Location" : "Get Current Location"),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12), // Consistent padding
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// --- Sample ID (Common to all) ---
|
||||||
|
TextFormField(
|
||||||
|
controller: _barcodeController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Sample ID Code *',
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Icons.qr_code_scanner),
|
||||||
|
onPressed: _scanBarcode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
validator: (val) => val == null || val.isEmpty ? "Sample ID is required" : null,
|
||||||
|
onSaved: (val) => widget.data.sampleIdCode = val,
|
||||||
|
onChanged: (val) => widget.data.sampleIdCode = val, // Update data model on change
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _goToNextStep,
|
||||||
|
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||||
|
child: const Text('Next'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16), // Add padding at the bottom
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// --- Widget builder for Manual Station selection ---
|
||||||
|
Widget _buildManualStationSelectors(List<Map<String, dynamic>> allStations) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
DropdownSearch<String>(
|
||||||
|
items: _manualStatesList,
|
||||||
|
selectedItem: widget.data.selectedManualStateName,
|
||||||
|
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))),
|
||||||
|
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *")),
|
||||||
|
onChanged: (state) {
|
||||||
|
setState(() {
|
||||||
|
widget.data.selectedManualStateName = state;
|
||||||
|
widget.data.selectedManualCategoryName = null;
|
||||||
|
widget.data.selectedStation = null;
|
||||||
|
_stationLatController.clear();
|
||||||
|
_stationLonController.clear();
|
||||||
|
widget.data.distanceDifferenceInKm = null;
|
||||||
|
|
||||||
|
// --- CORRECTED LOGIC ---
|
||||||
|
if (state != null) {
|
||||||
|
_categoriesForManualState = allStations
|
||||||
|
.where((s) => s['state_name'] == state)
|
||||||
|
.map((s) => s['category_name'] as String?)
|
||||||
|
.whereType<String>()
|
||||||
|
.toSet()
|
||||||
|
.toList();
|
||||||
|
_categoriesForManualState.sort(); // Sort after creating the list
|
||||||
|
} else {
|
||||||
|
_categoriesForManualState = <String>[];
|
||||||
|
}
|
||||||
|
// --- END CORRECTION ---
|
||||||
|
|
||||||
|
_stationsForManualCategory = []; // Clear stations list
|
||||||
|
});
|
||||||
|
},
|
||||||
|
validator: (val) => val == null ? "State is required" : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownSearch<String>(
|
||||||
|
items: _categoriesForManualState,
|
||||||
|
selectedItem: widget.data.selectedManualCategoryName,
|
||||||
|
enabled: widget.data.selectedManualStateName != null,
|
||||||
|
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Category..."))),
|
||||||
|
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Category *")),
|
||||||
|
onChanged: (category) {
|
||||||
|
setState(() {
|
||||||
|
widget.data.selectedManualCategoryName = category;
|
||||||
|
widget.data.selectedStation = null;
|
||||||
|
_stationLatController.clear();
|
||||||
|
_stationLonController.clear();
|
||||||
|
widget.data.distanceDifferenceInKm = null;
|
||||||
|
|
||||||
|
// --- CORRECTED LOGIC (Similar structure) ---
|
||||||
|
if (category != null) {
|
||||||
|
_stationsForManualCategory = allStations
|
||||||
|
.where((s) => s['state_name'] == widget.data.selectedManualStateName && s['category_name'] == category)
|
||||||
|
.toList();
|
||||||
|
_stationsForManualCategory.sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? '')); // Sort after creating
|
||||||
|
} else {
|
||||||
|
_stationsForManualCategory = [];
|
||||||
|
}
|
||||||
|
// --- END CORRECTION ---
|
||||||
|
});
|
||||||
|
},
|
||||||
|
validator: (val) => widget.data.selectedManualStateName != null && val == null ? "Category is required" : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownSearch<Map<String, dynamic>>(
|
||||||
|
items: _stationsForManualCategory,
|
||||||
|
selectedItem: widget.data.selectedStation,
|
||||||
|
enabled: widget.data.selectedManualCategoryName != null,
|
||||||
|
itemAsString: (station) => "${station['man_station_code']} - ${station['man_station_name']}",
|
||||||
|
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Station..."))),
|
||||||
|
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Station *")),
|
||||||
|
onChanged: (station) => setState(() {
|
||||||
|
widget.data.selectedStation = station;
|
||||||
|
widget.data.stationLatitude = station?['man_latitude']?.toString();
|
||||||
|
widget.data.stationLongitude = station?['man_longitude']?.toString();
|
||||||
|
_stationLatController.text = widget.data.stationLatitude ?? '';
|
||||||
|
_stationLonController.text = widget.data.stationLongitude ?? '';
|
||||||
|
_calculateDistance(); // Recalculate distance when station changes
|
||||||
|
}),
|
||||||
|
validator: (val) => widget.data.selectedManualCategoryName != null && val == null ? "Station is required" : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(controller: _stationLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Latitude')),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(controller: _stationLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Longitude')),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.explore_outlined),
|
||||||
|
label: const Text("NEARBY STATION"),
|
||||||
|
onPressed: _isLoadingLocation ? null : _findAndShowNearbyStations,
|
||||||
|
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// --- Widget builder for Tarball Station selection ---
|
||||||
|
Widget _buildTarballStationSelectors(List<Map<String, dynamic>> allStations) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
DropdownSearch<String>(
|
||||||
|
items: _tarballStatesList,
|
||||||
|
selectedItem: widget.data.selectedTarballStateName,
|
||||||
|
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))),
|
||||||
|
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *")),
|
||||||
|
onChanged: (state) {
|
||||||
|
setState(() {
|
||||||
|
widget.data.selectedTarballStateName = state;
|
||||||
|
widget.data.selectedTarballStation = null;
|
||||||
|
_stationLatController.clear();
|
||||||
|
_stationLonController.clear();
|
||||||
|
widget.data.distanceDifferenceInKm = null;
|
||||||
|
|
||||||
|
// --- CORRECTED LOGIC ---
|
||||||
|
if (state != null) {
|
||||||
|
_stationsForTarballState = allStations
|
||||||
|
.where((s) => s['state_name'] == state)
|
||||||
|
.toList();
|
||||||
|
_stationsForTarballState.sort((a, b) => (a['tbl_station_code'] ?? '').compareTo(b['tbl_station_code'] ?? '')); // Sort after creating
|
||||||
|
} else {
|
||||||
|
_stationsForTarballState = <Map<String, dynamic>>[];
|
||||||
|
}
|
||||||
|
// --- END CORRECTION ---
|
||||||
|
});
|
||||||
|
},
|
||||||
|
validator: (val) => val == null ? "State is required" : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownSearch<Map<String, dynamic>>(
|
||||||
|
items: _stationsForTarballState,
|
||||||
|
selectedItem: widget.data.selectedTarballStation,
|
||||||
|
enabled: widget.data.selectedTarballStateName != null,
|
||||||
|
itemAsString: (station) => "${station['tbl_station_code']} - ${station['tbl_station_name']}",
|
||||||
|
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Station..."))),
|
||||||
|
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Station *")),
|
||||||
|
onChanged: (station) => setState(() {
|
||||||
|
widget.data.selectedTarballStation = station;
|
||||||
|
widget.data.stationLatitude = station?['tbl_latitude']?.toString();
|
||||||
|
widget.data.stationLongitude = station?['tbl_longitude']?.toString();
|
||||||
|
_stationLatController.text = widget.data.stationLatitude ?? '';
|
||||||
|
_stationLonController.text = widget.data.stationLongitude ?? '';
|
||||||
|
_calculateDistance(); // Recalculate distance when station changes
|
||||||
|
}),
|
||||||
|
validator: (val) => widget.data.selectedTarballStateName != null && val == null ? "Station is required" : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(controller: _stationLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Latitude')),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(controller: _stationLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Longitude')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// --- Widget builder for New Location fields ---
|
||||||
|
Widget _buildNewLocationFields() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _newStationNameController,
|
||||||
|
decoration: const InputDecoration(labelText: 'New Station Name *'),
|
||||||
|
validator: (val) => val == null || val.isEmpty ? "Station Name is required" : null,
|
||||||
|
onSaved: (val) => widget.data.newStationName = val,
|
||||||
|
onChanged: (val) => widget.data.newStationName = val, // Update data model on change
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _newStationCodeController,
|
||||||
|
decoration: const InputDecoration(labelText: 'New Station Code *', hintText: "e.g., INV-001"),
|
||||||
|
validator: (val) => val == null || val.isEmpty ? "Station Code is required" : null,
|
||||||
|
onSaved: (val) => widget.data.newStationCode = val,
|
||||||
|
onChanged: (val) => widget.data.newStationCode = val, // Update data model on change
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _stationLatController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Station Latitude *', hintText: "Enter manually or use 'Get Location'"),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
validator: (val) {
|
||||||
|
if (val == null || val.isEmpty) return "Latitude is required";
|
||||||
|
final lat = double.tryParse(val);
|
||||||
|
if (lat == null || lat < -90 || lat > 90) return "Enter a valid latitude (-90 to 90)";
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onSaved: (val) => widget.data.stationLatitude = val,
|
||||||
|
onChanged: (val) {
|
||||||
|
widget.data.stationLatitude = val; // Update data model on change
|
||||||
|
_calculateDistance(); // Recalculate distance when manually changed
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _stationLonController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Station Longitude *', hintText: "Enter manually or use 'Get Location'"),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
validator: (val) {
|
||||||
|
if (val == null || val.isEmpty) return "Longitude is required";
|
||||||
|
final lon = double.tryParse(val);
|
||||||
|
if (lon == null || lon < -180 || lon > 180) return "Enter a valid longitude (-180 to 180)";
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onSaved: (val) => widget.data.stationLongitude = val,
|
||||||
|
onChanged: (val) {
|
||||||
|
widget.data.stationLongitude = val; // Update data model on change
|
||||||
|
_calculateDistance(); // Recalculate distance when manually changed
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Re-used Dialog Widget for Nearby Stations ---
|
||||||
|
class _NearbyStationsDialog extends StatelessWidget {
|
||||||
|
final List<Map<String, dynamic>> nearbyStations;
|
||||||
|
|
||||||
|
const _NearbyStationsDialog({required this.nearbyStations});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Nearby Stations (within 5km)'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: nearbyStations.isEmpty
|
||||||
|
? const Center(child: Text('No stations found within 5km of your current location.')) // More informative text
|
||||||
|
: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: nearbyStations.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = nearbyStations[index];
|
||||||
|
final station = item['station'] as Map<String, dynamic>;
|
||||||
|
final distanceInMeters = (item['distance'] as double) * 1000;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4.0), // Add vertical margin
|
||||||
|
child: ListTile(
|
||||||
|
title: Text("${station['man_station_code'] ?? 'N/A'}"),
|
||||||
|
subtitle: Text("${station['man_station_name'] ?? 'N/A'}"),
|
||||||
|
trailing: Text("${distanceInMeters.toStringAsFixed(0)} m"),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop(station); // Return selected station
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(), // Return null on cancel
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1 +1,220 @@
|
|||||||
// lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart
|
// lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_2_site_info.dart
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../../../models/marine_inves_manual_sampling_data.dart';
|
||||||
|
import '../../../../services/marine_investigative_sampling_service.dart';
|
||||||
|
|
||||||
|
/// The second step of the Investigative Sampling form.
|
||||||
|
/// Gathers on-site conditions (weather, tide) and handles all photo attachments.
|
||||||
|
class MarineInvesManualStep2SiteInfo extends StatefulWidget {
|
||||||
|
final MarineInvesManualSamplingData data;
|
||||||
|
final VoidCallback onNext;
|
||||||
|
|
||||||
|
const MarineInvesManualStep2SiteInfo({
|
||||||
|
super.key,
|
||||||
|
required this.data,
|
||||||
|
required this.onNext,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MarineInvesManualStep2SiteInfo> createState() => _MarineInvesManualStep2SiteInfoState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MarineInvesManualStep2SiteInfoState extends State<MarineInvesManualStep2SiteInfo> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
bool _isPickingImage = false;
|
||||||
|
|
||||||
|
late final TextEditingController _eventRemarksController;
|
||||||
|
late final TextEditingController _labRemarksController;
|
||||||
|
|
||||||
|
final List<String> _weatherOptions = ['Clear', 'Cloudy', 'Drizzle', 'Rainy', 'Windy'];
|
||||||
|
final List<String> _tideOptions = ['High', 'Low', 'Mid'];
|
||||||
|
final List<String> _seaConditionOptions = ['Calm', 'Moderate Wave', 'High Wave'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_eventRemarksController = TextEditingController(text: widget.data.eventRemarks);
|
||||||
|
_labRemarksController = TextEditingController(text: widget.data.labRemarks);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_eventRemarksController.dispose();
|
||||||
|
_labRemarksController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles picking and processing an image using the dedicated service.
|
||||||
|
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
|
||||||
|
if (_isPickingImage) return;
|
||||||
|
setState(() => _isPickingImage = true);
|
||||||
|
|
||||||
|
final service = Provider.of<MarineInvestigativeSamplingService>(context, listen: false);
|
||||||
|
|
||||||
|
// The service's pickAndProcessImage method will handle file naming
|
||||||
|
final file = await service.pickAndProcessImage(source, data: widget.data, imageInfo: imageInfo, isRequired: isRequired);
|
||||||
|
|
||||||
|
if (file != null) {
|
||||||
|
setState(() => setImageCallback(file));
|
||||||
|
} else if (mounted) {
|
||||||
|
_showSnackBar('Image selection failed. Please ensure all photos are taken in landscape mode.', isError: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isPickingImage = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates the form and all required images before proceeding.
|
||||||
|
void _goToNextStep() {
|
||||||
|
if (widget.data.leftLandViewImage == null ||
|
||||||
|
widget.data.rightLandViewImage == null ||
|
||||||
|
widget.data.waterFillingImage == null ||
|
||||||
|
widget.data.seawaterColorImage == null) {
|
||||||
|
_showSnackBar('Please attach all 4 required photos before proceeding.', isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form validation handles the conditional requirement for Event Remarks
|
||||||
|
if (!_formKey.currentState!.validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_formKey.currentState!.save();
|
||||||
|
widget.onNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSnackBar(String message, {bool isError = false}) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: isError ? Colors.red : null,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Logic to determine if Event Remarks are required
|
||||||
|
final bool areAdditionalPhotosAttached = widget.data.phPaperImage != null ||
|
||||||
|
widget.data.optionalImage1 != null ||
|
||||||
|
widget.data.optionalImage2 != null ||
|
||||||
|
widget.data.optionalImage3 != null ||
|
||||||
|
widget.data.optionalImage4 != null;
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
children: [
|
||||||
|
// --- Section: On-Site Information ---
|
||||||
|
Text("On-Site Information", style: Theme.of(context).textTheme.headlineSmall),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: widget.data.weather,
|
||||||
|
items: _weatherOptions.map((item) => DropdownMenuItem(value: item, child: Text(item))).toList(),
|
||||||
|
onChanged: (value) => setState(() => widget.data.weather = value),
|
||||||
|
decoration: const InputDecoration(labelText: 'Weather *'),
|
||||||
|
validator: (value) => value == null ? 'Weather is required' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: widget.data.tideLevel,
|
||||||
|
items: _tideOptions.map((item) => DropdownMenuItem(value: item, child: Text(item))).toList(),
|
||||||
|
onChanged: (value) => setState(() => widget.data.tideLevel = value),
|
||||||
|
decoration: const InputDecoration(labelText: 'Tide Level *'),
|
||||||
|
validator: (value) => value == null ? 'Tide level is required' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: widget.data.seaCondition,
|
||||||
|
items: _seaConditionOptions.map((item) => DropdownMenuItem(value: item, child: Text(item))).toList(),
|
||||||
|
onChanged: (value) => setState(() => widget.data.seaCondition = value),
|
||||||
|
decoration: const InputDecoration(labelText: 'Sea Condition *'),
|
||||||
|
validator: (value) => value == null ? 'Sea condition is required' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// --- Section: Required Photos ---
|
||||||
|
Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge),
|
||||||
|
const Text("All photos must be taken in landscape (horizontal) orientation.", style: TextStyle(color: Colors.grey)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildImagePicker('Left Side Land View', 'LEFT_LAND_VIEW', widget.data.leftLandViewImage, (file) => widget.data.leftLandViewImage = file, isRequired: true),
|
||||||
|
_buildImagePicker('Right Side Land View', 'RIGHT_LAND_VIEW', widget.data.rightLandViewImage, (file) => widget.data.rightLandViewImage = file, isRequired: true),
|
||||||
|
_buildImagePicker('Filling Water into Sample Bottle', 'WATER_FILLING', widget.data.waterFillingImage, (file) => widget.data.waterFillingImage = file, isRequired: true),
|
||||||
|
_buildImagePicker('Seawater in Clear Glass Bottle', 'SEAWATER_COLOR', widget.data.seawaterColorImage, (file) => widget.data.seawaterColorImage = file, isRequired: true),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// --- Section: Additional photos and conditional remarks ---
|
||||||
|
Text("Additional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildImagePicker('Examine Preservative (pH paper)', 'PH_PAPER', widget.data.phPaperImage, (file) => widget.data.phPaperImage = file, isRequired: false),
|
||||||
|
_buildImagePicker('Optional Photo 1', 'OPTIONAL_1', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, isRequired: false),
|
||||||
|
_buildImagePicker('Optional Photo 2', 'OPTIONAL_2', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, isRequired: false),
|
||||||
|
_buildImagePicker('Optional Photo 3', 'OPTIONAL_3', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, isRequired: false),
|
||||||
|
_buildImagePicker('Optional Photo 4', 'OPTIONAL_4', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, isRequired: false),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
Text("Remarks", style: Theme.of(context).textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Event Remarks field is conditionally required
|
||||||
|
TextFormField(
|
||||||
|
controller: _eventRemarksController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: areAdditionalPhotosAttached ? 'Event Remarks *' : 'Event Remarks (Optional)',
|
||||||
|
hintText: 'e.g., unusual smells, colors, etc.'
|
||||||
|
),
|
||||||
|
onSaved: (value) => widget.data.eventRemarks = value,
|
||||||
|
validator: (value) {
|
||||||
|
if (areAdditionalPhotosAttached && (value == null || value.trim().isEmpty)) {
|
||||||
|
return 'Event Remarks are required when attaching additional photos.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _labRemarksController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Lab Remarks (Optional)'),
|
||||||
|
onSaved: (value) => widget.data.labRemarks = value,
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _goToNextStep,
|
||||||
|
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||||
|
child: const Text('Next'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reusable widget for picking and displaying an image
|
||||||
|
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {bool isRequired = false}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (imageFile != null)
|
||||||
|
Stack(
|
||||||
|
// ... (Image preview stack - same as original)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Row(
|
||||||
|
// ... (Camera/Gallery buttons - same as original)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1 +1,832 @@
|
|||||||
// lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_3_data_capture.dart
|
// lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_3_data_capture.dart
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||||
|
import 'package:usb_serial/usb_serial.dart';
|
||||||
|
|
||||||
|
import '../../../../auth_provider.dart';
|
||||||
|
import '../../../../models/marine_inves_manual_sampling_data.dart';
|
||||||
|
import '../../../../services/marine_investigative_sampling_service.dart';
|
||||||
|
import '../../../../bluetooth/bluetooth_manager.dart'; // For connection state enum
|
||||||
|
import '../../../../serial/serial_manager.dart'; // For connection state enum
|
||||||
|
import '../../../../bluetooth/widgets/bluetooth_device_list_dialog.dart';
|
||||||
|
import '../../../../serial/widget/serial_port_list_dialog.dart';
|
||||||
|
|
||||||
|
class MarineInvesManualStep3DataCapture extends StatefulWidget {
|
||||||
|
final MarineInvesManualSamplingData data;
|
||||||
|
final VoidCallback onNext;
|
||||||
|
|
||||||
|
const MarineInvesManualStep3DataCapture({
|
||||||
|
super.key,
|
||||||
|
required this.data,
|
||||||
|
required this.onNext,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MarineInvesManualStep3DataCapture> createState() => _MarineInvesManualStep3DataCaptureState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MarineInvesManualStep3DataCaptureState extends State<MarineInvesManualStep3DataCapture> with WidgetsBindingObserver {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isAutoReading = false;
|
||||||
|
StreamSubscription? _dataSubscription;
|
||||||
|
|
||||||
|
Timer? _lockoutTimer;
|
||||||
|
int _lockoutSecondsRemaining = 30;
|
||||||
|
bool _isLockedOut = false;
|
||||||
|
|
||||||
|
late final MarineInvestigativeSamplingService _samplingService;
|
||||||
|
|
||||||
|
Map<String, double>? _previousReadingsForComparison;
|
||||||
|
Set<String> _outOfBoundsKeys = {};
|
||||||
|
|
||||||
|
final Map<String, String> _parameterKeyToLimitName = const {
|
||||||
|
'oxygenConcentration': 'Oxygen Conc',
|
||||||
|
'oxygenSaturation': 'Oxygen Sat',
|
||||||
|
'ph': 'pH',
|
||||||
|
'salinity': 'Salinity',
|
||||||
|
'electricalConductivity': 'Conductivity',
|
||||||
|
'temperature': 'Temperature',
|
||||||
|
'tds': 'TDS',
|
||||||
|
'turbidity': 'Turbidity',
|
||||||
|
'tss': 'TSS',
|
||||||
|
'batteryVoltage': 'Battery',
|
||||||
|
};
|
||||||
|
|
||||||
|
final List<Map<String, dynamic>> _parameters = [];
|
||||||
|
|
||||||
|
final _sondeIdController = TextEditingController();
|
||||||
|
final _dateController = TextEditingController();
|
||||||
|
final _timeController = TextEditingController();
|
||||||
|
final _oxyConcController = TextEditingController();
|
||||||
|
final _oxySatController = TextEditingController();
|
||||||
|
final _phController = TextEditingController();
|
||||||
|
final _salinityController = TextEditingController();
|
||||||
|
final _ecController = TextEditingController();
|
||||||
|
final _tempController = TextEditingController();
|
||||||
|
final _tdsController = TextEditingController();
|
||||||
|
final _turbidityController = TextEditingController();
|
||||||
|
final _tssController = TextEditingController();
|
||||||
|
final _batteryController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_samplingService = Provider.of<MarineInvestigativeSamplingService>(context, listen: false);
|
||||||
|
_initializeControllers();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_dataSubscription?.cancel();
|
||||||
|
_lockoutTimer?.cancel();
|
||||||
|
if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
|
||||||
|
_samplingService.disconnectFromBluetooth();
|
||||||
|
}
|
||||||
|
if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) {
|
||||||
|
_samplingService.disconnectFromSerial();
|
||||||
|
}
|
||||||
|
_disposeControllers();
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeControllers() {
|
||||||
|
widget.data.dataCaptureDate = widget.data.samplingDate;
|
||||||
|
widget.data.dataCaptureTime = widget.data.samplingTime;
|
||||||
|
|
||||||
|
_sondeIdController.text = widget.data.sondeId ?? '';
|
||||||
|
_dateController.text = widget.data.dataCaptureDate ?? '';
|
||||||
|
_timeController.text = widget.data.dataCaptureTime ?? '';
|
||||||
|
|
||||||
|
widget.data.oxygenConcentration ??= -999.0;
|
||||||
|
widget.data.oxygenSaturation ??= -999.0;
|
||||||
|
widget.data.ph ??= -999.0;
|
||||||
|
widget.data.salinity ??= -999.0;
|
||||||
|
widget.data.electricalConductivity ??= -999.0;
|
||||||
|
widget.data.temperature ??= -999.0;
|
||||||
|
widget.data.tds ??= -999.0;
|
||||||
|
widget.data.turbidity ??= -999.0;
|
||||||
|
widget.data.tss ??= -999.0;
|
||||||
|
widget.data.batteryVoltage ??= -999.0;
|
||||||
|
|
||||||
|
_oxyConcController.text = widget.data.oxygenConcentration!.toString();
|
||||||
|
_oxySatController.text = widget.data.oxygenSaturation!.toString();
|
||||||
|
_phController.text = widget.data.ph!.toString();
|
||||||
|
_salinityController.text = widget.data.salinity!.toString();
|
||||||
|
_ecController.text = widget.data.electricalConductivity!.toString();
|
||||||
|
_tempController.text = widget.data.temperature!.toString();
|
||||||
|
_tdsController.text = widget.data.tds!.toString();
|
||||||
|
_turbidityController.text = widget.data.turbidity!.toString();
|
||||||
|
_tssController.text = widget.data.tss!.toString();
|
||||||
|
_batteryController.text = widget.data.batteryVoltage!.toString();
|
||||||
|
|
||||||
|
if (_parameters.isEmpty) {
|
||||||
|
_parameters.addAll([
|
||||||
|
{'key': 'oxygenConcentration', 'icon': Icons.air, 'label': 'Oxygen Conc.', 'unit': 'mg/L', 'controller': _oxyConcController},
|
||||||
|
{'key': 'oxygenSaturation', 'icon': Icons.percent, 'label': 'Oxygen Sat.', 'unit': '%', 'controller': _oxySatController},
|
||||||
|
{'key': 'ph', 'icon': Icons.science_outlined, 'label': 'pH', 'unit': '', 'controller': _phController},
|
||||||
|
{'key': 'salinity', 'icon': Icons.waves, 'label': 'Salinity', 'unit': 'ppt', 'controller': _salinityController},
|
||||||
|
{'key': 'electricalConductivity', 'icon': Icons.flash_on, 'label': 'Conductivity', 'unit': 'µS/cm', 'controller': _ecController},
|
||||||
|
{'key': 'temperature', 'icon': Icons.thermostat, 'label': 'Temperature', 'unit': '°C', 'controller': _tempController},
|
||||||
|
{'key': 'tds', 'icon': Icons.grain, 'label': 'TDS', 'unit': 'mg/L', 'controller': _tdsController},
|
||||||
|
{'key': 'turbidity', 'icon': Icons.opacity, 'label': 'Turbidity', 'unit': 'NTU', 'controller': _turbidityController},
|
||||||
|
{'key': 'tss', 'icon': Icons.filter_alt_outlined, 'label': 'TSS', 'unit': 'mg/L', 'controller': _tssController},
|
||||||
|
{'key': 'batteryVoltage', 'icon': Icons.battery_charging_full, 'label': 'Battery', 'unit': 'V', 'controller': _batteryController},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _disposeControllers() {
|
||||||
|
_sondeIdController.dispose();
|
||||||
|
_dateController.dispose();
|
||||||
|
_timeController.dispose();
|
||||||
|
_oxyConcController.dispose();
|
||||||
|
_oxySatController.dispose();
|
||||||
|
_phController.dispose();
|
||||||
|
_salinityController.dispose();
|
||||||
|
_ecController.dispose();
|
||||||
|
_tempController.dispose();
|
||||||
|
_tdsController.dispose();
|
||||||
|
_turbidityController.dispose();
|
||||||
|
_tssController.dispose();
|
||||||
|
_batteryController.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleConnectionAttempt(String type) async {
|
||||||
|
final service = context.read<MarineInvestigativeSamplingService>();
|
||||||
|
final hasPermissions = await service.requestDevicePermissions();
|
||||||
|
if (!hasPermissions && mounted) {
|
||||||
|
_showSnackBar("Bluetooth & Location permissions are required to connect.", isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_disconnectFromAll();
|
||||||
|
await Future.delayed(const Duration(milliseconds: 250));
|
||||||
|
final bool connectionSuccess = await _connectToDevice(type);
|
||||||
|
if (connectionSuccess && mounted) {
|
||||||
|
_dataSubscription?.cancel();
|
||||||
|
final stream = type == 'bluetooth' ? service.bluetoothDataStream : service.serialDataStream;
|
||||||
|
_dataSubscription = stream.listen((readings) {
|
||||||
|
if (mounted) _updateTextFields(readings);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _connectToDevice(String type) async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
final service = context.read<MarineInvestigativeSamplingService>();
|
||||||
|
bool success = false;
|
||||||
|
try {
|
||||||
|
if (type == 'bluetooth') {
|
||||||
|
final devices = await service.getPairedBluetoothDevices();
|
||||||
|
if (devices.isEmpty && mounted) {
|
||||||
|
_showSnackBar('No paired Bluetooth devices found.', isError: true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final selectedDevice = await showBluetoothDeviceListDialog(context: context, devices: devices);
|
||||||
|
if (selectedDevice != null) {
|
||||||
|
await service.connectToBluetoothDevice(selectedDevice);
|
||||||
|
success = true;
|
||||||
|
}
|
||||||
|
} else if (type == 'serial') {
|
||||||
|
final devices = await service.getAvailableSerialDevices();
|
||||||
|
if (devices.isEmpty && mounted) {
|
||||||
|
_showSnackBar('No USB Serial devices found.', isError: true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final selectedDevice = await showSerialPortListDialog(context: context, devices: devices);
|
||||||
|
if (selectedDevice != null) {
|
||||||
|
await service.connectToSerialDevice(selectedDevice);
|
||||||
|
success = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Connection failed: $e");
|
||||||
|
if (mounted) _showConnectionFailedDialog();
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showConnectionFailedDialog() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Connection Failed'),
|
||||||
|
content: const SingleChildScrollView(
|
||||||
|
child: Text('Could not connect to the device. Please check that the device is turned on, within range, and not connected to another application.'),
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
child: const Text('OK'),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startLockoutTimer() {
|
||||||
|
_lockoutTimer?.cancel();
|
||||||
|
setState(() {
|
||||||
|
_isLockedOut = true;
|
||||||
|
_lockoutSecondsRemaining = 30;
|
||||||
|
});
|
||||||
|
|
||||||
|
_lockoutTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||||
|
if (_lockoutSecondsRemaining > 0) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_lockoutSecondsRemaining--;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
timer.cancel();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLockedOut = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleAutoReading(String activeType) {
|
||||||
|
final service = context.read<MarineInvestigativeSamplingService>();
|
||||||
|
setState(() {
|
||||||
|
_isAutoReading = !_isAutoReading;
|
||||||
|
if (_isAutoReading) {
|
||||||
|
if (activeType == 'bluetooth') service.startBluetoothAutoReading(); else service.startSerialAutoReading();
|
||||||
|
_startLockoutTimer();
|
||||||
|
} else {
|
||||||
|
if (activeType == 'bluetooth') service.stopBluetoothAutoReading(); else service.stopSerialAutoReading();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _disconnect(String type) {
|
||||||
|
final service = context.read<MarineInvestigativeSamplingService>();
|
||||||
|
if (type == 'bluetooth') {
|
||||||
|
service.disconnectFromBluetooth();
|
||||||
|
} else {
|
||||||
|
service.disconnectFromSerial();
|
||||||
|
}
|
||||||
|
_dataSubscription?.cancel();
|
||||||
|
_dataSubscription = null;
|
||||||
|
_lockoutTimer?.cancel();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isAutoReading = false;
|
||||||
|
_isLockedOut = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _disconnectFromAll() {
|
||||||
|
final service = context.read<MarineInvestigativeSamplingService>();
|
||||||
|
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
|
||||||
|
_disconnect('bluetooth');
|
||||||
|
}
|
||||||
|
if (service.serialConnectionState.value != SerialConnectionState.disconnected) {
|
||||||
|
_disconnect('serial');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateTextFields(Map<String, double> readings) {
|
||||||
|
const defaultValue = -999.0;
|
||||||
|
setState(() {
|
||||||
|
_oxyConcController.text = (readings['Optical Dissolved Oxygen: Compensated mg/L'] ?? defaultValue).toStringAsFixed(5);
|
||||||
|
_oxySatController.text = (readings['Optical Dissolved Oxygen: Compensated % Saturation'] ?? defaultValue).toStringAsFixed(5);
|
||||||
|
_phController.text = (readings['PH: PH units'] ?? defaultValue).toStringAsFixed(5);
|
||||||
|
_tempController.text = (readings['External Temp: Degrees Celcius'] ?? defaultValue).toStringAsFixed(5);
|
||||||
|
_ecController.text = (readings['Conductivity: us/cm'] ?? defaultValue).toStringAsFixed(5);
|
||||||
|
_salinityController.text = (readings['Conductivity: Salinity'] ?? defaultValue).toStringAsFixed(5);
|
||||||
|
_tdsController.text = (readings['Conductivity:TDS mg/L'] ?? defaultValue).toStringAsFixed(5);
|
||||||
|
_tssController.text = (readings['Turbidity: TSS'] ?? defaultValue).toStringAsFixed(5);
|
||||||
|
_turbidityController.text = (readings['Turbidity: FNU'] ?? defaultValue).toStringAsFixed(5);
|
||||||
|
_batteryController.text = (readings['Sonde: Battery Voltage'] ?? defaultValue).toStringAsFixed(5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateAndProceed() {
|
||||||
|
if (_isLockedOut) {
|
||||||
|
_showSnackBar("Please wait for the initial reading period to complete.", isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isAutoReading) {
|
||||||
|
_showStopReadingDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_formKey.currentState!.validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_formKey.currentState!.save();
|
||||||
|
|
||||||
|
final currentReadings = _captureReadingsToMap();
|
||||||
|
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||||
|
final marineLimits = authProvider.marineParameterLimits ?? [];
|
||||||
|
final outOfBoundsParams = _validateParameters(currentReadings, marineLimits);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_outOfBoundsKeys = outOfBoundsParams.map((p) => _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (outOfBoundsParams.isNotEmpty) {
|
||||||
|
_showParameterLimitDialog(outOfBoundsParams, currentReadings);
|
||||||
|
} else {
|
||||||
|
_saveDataAndMoveOn(currentReadings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, double> _captureReadingsToMap() {
|
||||||
|
final Map<String, double> readings = {};
|
||||||
|
for (var param in _parameters) {
|
||||||
|
final key = param['key'] as String;
|
||||||
|
final controller = param['controller'] as TextEditingController;
|
||||||
|
readings[key] = double.tryParse(controller.text) ?? -999.0;
|
||||||
|
}
|
||||||
|
return readings;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _validateParameters(Map<String, double> readings, List<Map<String, dynamic>> limits) {
|
||||||
|
final List<Map<String, dynamic>> invalidParams = [];
|
||||||
|
|
||||||
|
// --- MODIFIED: Get station ID based on station type ---
|
||||||
|
int? stationId;
|
||||||
|
if (widget.data.stationTypeSelection == 'Existing Manual Station') {
|
||||||
|
stationId = widget.data.selectedStation?['station_id'];
|
||||||
|
}
|
||||||
|
// Note: Add logic here if Tarball or New Locations have different limits
|
||||||
|
// For now, we only validate against manual station limits
|
||||||
|
|
||||||
|
debugPrint("--- Parameter Validation Start (Investigative) ---");
|
||||||
|
debugPrint("Selected Station ID: $stationId");
|
||||||
|
|
||||||
|
double? _parseLimitValue(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is num) return value.toDouble();
|
||||||
|
if (value is String) return double.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
readings.forEach((key, value) {
|
||||||
|
if (value == -999.0) return;
|
||||||
|
|
||||||
|
final limitName = _parameterKeyToLimitName[key];
|
||||||
|
if (limitName == null) return;
|
||||||
|
|
||||||
|
debugPrint("Checking parameter: '$limitName' (key: '$key')");
|
||||||
|
|
||||||
|
Map<String, dynamic> limitData = {};
|
||||||
|
|
||||||
|
if (stationId != null) {
|
||||||
|
limitData = limits.firstWhere(
|
||||||
|
(l) => l['param_parameter_list'] == limitName && l['station_id']?.toString() == stationId.toString(),
|
||||||
|
orElse: () => {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (limitData.isNotEmpty) {
|
||||||
|
debugPrint(" > Found station-specific limit for Station ID $stationId: $limitData");
|
||||||
|
} else {
|
||||||
|
debugPrint(" > No station-specific limit found for Station ID $stationId. Skipping check for this parameter.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (limitData.isNotEmpty) {
|
||||||
|
final lowerLimit = _parseLimitValue(limitData['param_lower_limit']);
|
||||||
|
final upperLimit = _parseLimitValue(limitData['param_upper_limit']);
|
||||||
|
|
||||||
|
if ((lowerLimit != null && value < lowerLimit) || (upperLimit != null && value > upperLimit)) {
|
||||||
|
final paramInfo = _parameters.firstWhere((p) => p['key'] == key, orElse: () => {});
|
||||||
|
invalidParams.add({
|
||||||
|
'label': paramInfo['label'] ?? key,
|
||||||
|
'value': value,
|
||||||
|
'lower_limit': lowerLimit,
|
||||||
|
'upper_limit': upperLimit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
debugPrint("--- Parameter Validation End ---");
|
||||||
|
|
||||||
|
return invalidParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _saveDataAndMoveOn(Map<String, double> readings) {
|
||||||
|
try {
|
||||||
|
const defaultValue = -999.0;
|
||||||
|
widget.data.temperature = readings['temperature'] ?? defaultValue;
|
||||||
|
widget.data.ph = readings['ph'] ?? defaultValue;
|
||||||
|
widget.data.salinity = readings['salinity'] ?? defaultValue;
|
||||||
|
widget.data.electricalConductivity = readings['electricalConductivity'] ?? defaultValue;
|
||||||
|
widget.data.oxygenConcentration = readings['oxygenConcentration'] ?? defaultValue;
|
||||||
|
widget.data.oxygenSaturation = readings['oxygenSaturation'] ?? defaultValue;
|
||||||
|
widget.data.tds = readings['tds'] ?? defaultValue;
|
||||||
|
widget.data.turbidity = readings['turbidity'] ?? defaultValue;
|
||||||
|
widget.data.tss = readings['tss'] ?? defaultValue;
|
||||||
|
widget.data.batteryVoltage = readings['batteryVoltage'] ?? defaultValue;
|
||||||
|
} catch (e) {
|
||||||
|
_showSnackBar("Could not save parameters due to a data format error.", isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_outOfBoundsKeys.clear();
|
||||||
|
if (_previousReadingsForComparison != null) {
|
||||||
|
_previousReadingsForComparison = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
widget.onNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showStopReadingDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Data Collection Active'),
|
||||||
|
content: const Text('Please stop the live data collection before proceeding.'),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
child: const Text('OK'),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSnackBar(String message, {bool isError = false}) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: isError ? Colors.red : null,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? _getActiveConnectionDetails() {
|
||||||
|
final service = context.watch<MarineInvestigativeSamplingService>();
|
||||||
|
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
|
||||||
|
return {'type': 'bluetooth', 'state': service.bluetoothConnectionState.value, 'name': service.connectedBluetoothDeviceName};
|
||||||
|
}
|
||||||
|
if (service.serialConnectionState.value != SerialConnectionState.disconnected) {
|
||||||
|
return {'type': 'serial', 'state': service.serialConnectionState.value, 'name': service.connectedSerialDeviceName};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final service = context.watch<MarineInvestigativeSamplingService>();
|
||||||
|
final activeConnection = _getActiveConnectionDetails();
|
||||||
|
final String? activeType = activeConnection?['type'] as String?;
|
||||||
|
|
||||||
|
return WillPopScope(
|
||||||
|
onWillPop: () async {
|
||||||
|
if (_isLockedOut) {
|
||||||
|
_showSnackBar("Please wait for the initial reading period to complete.", isError: true);
|
||||||
|
return false; // Prevent back navigation
|
||||||
|
}
|
||||||
|
return true; // Allow back navigation
|
||||||
|
},
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
children: [
|
||||||
|
Text("Data Capture", style: Theme.of(context).textTheme.headlineSmall),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: activeType == 'bluetooth'
|
||||||
|
? FilledButton.icon(icon: const Icon(Icons.bluetooth_connected), label: const Text("Bluetooth"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth'))
|
||||||
|
: OutlinedButton.icon(icon: const Icon(Icons.bluetooth), label: const Text("Bluetooth"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('bluetooth')),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: activeType == 'serial'
|
||||||
|
? FilledButton.icon(icon: const Icon(Icons.usb), label: const Text("USB Serial"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial'))
|
||||||
|
: OutlinedButton.icon(icon: const Icon(Icons.usb), label: const Text("USB Serial"), onPressed: _isLoading ? null : () => _handleConnectionAttempt('serial')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (activeConnection != null)
|
||||||
|
_buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ValueListenableBuilder<String?>(
|
||||||
|
valueListenable: service.sondeId,
|
||||||
|
builder: (context, sondeId, child) {
|
||||||
|
final newSondeId = sondeId ?? '';
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && _sondeIdController.text != newSondeId) {
|
||||||
|
_sondeIdController.text = newSondeId;
|
||||||
|
widget.data.sondeId = newSondeId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return TextFormField(
|
||||||
|
controller: _sondeIdController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Sonde ID *', hintText: 'Connect device or enter manually'),
|
||||||
|
validator: (v) => v == null || v.isEmpty ? 'Sonde ID is required' : null,
|
||||||
|
onChanged: (value) => widget.data.sondeId = value,
|
||||||
|
onSaved: (v) => widget.data.sondeId = v,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: TextFormField(controller: _dateController, readOnly: true, decoration: const InputDecoration(labelText: 'Date'))),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: TextFormField(controller: _timeController, readOnly: true, decoration: const InputDecoration(labelText: 'Time'))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
if (_previousReadingsForComparison != null)
|
||||||
|
_buildComparisonView(),
|
||||||
|
|
||||||
|
const Divider(height: 32),
|
||||||
|
Column(
|
||||||
|
children: _parameters.map((param) {
|
||||||
|
return _buildParameterListItem(
|
||||||
|
icon: param['icon'] as IconData,
|
||||||
|
label: param['label'] as String,
|
||||||
|
unit: param['unit'] as String,
|
||||||
|
controller: param['controller'] as TextEditingController,
|
||||||
|
isOutOfBounds: _outOfBoundsKeys.contains(param['key']),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isLockedOut ? null : _validateAndProceed,
|
||||||
|
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||||
|
child: Text(_isLockedOut ? 'Next ($_lockoutSecondsRemaining\s)' : 'Next'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildComparisonView() {
|
||||||
|
final previousReadings = _previousReadingsForComparison!;
|
||||||
|
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(top: 24.0),
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: DefaultTextStyle(
|
||||||
|
style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Resample Comparison",
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(color: Theme.of(context).primaryColor),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Table(
|
||||||
|
columnWidths: const {
|
||||||
|
0: FlexColumnWidth(2),
|
||||||
|
1: FlexColumnWidth(1.5),
|
||||||
|
2: FlexColumnWidth(1.5),
|
||||||
|
},
|
||||||
|
border: TableBorder(
|
||||||
|
horizontalInside: BorderSide(width: 1, color: Colors.grey.shade700, style: BorderStyle.solid),
|
||||||
|
verticalInside: BorderSide(width: 1, color: Colors.grey.shade700, style: BorderStyle.solid),
|
||||||
|
top: BorderSide(width: 1.5, color: Colors.grey.shade500),
|
||||||
|
bottom: BorderSide(width: 1.5, color: Colors.grey.shade500),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TableRow(
|
||||||
|
decoration: BoxDecoration(color: isDarkTheme ? Colors.grey.shade800 : Colors.grey.shade200),
|
||||||
|
children: [
|
||||||
|
Padding(padding: const EdgeInsets.all(8.0), child: Text('Parameter', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleMedium?.color))),
|
||||||
|
Padding(padding: const EdgeInsets.all(8.0), child: Text('Previous', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleMedium?.color))),
|
||||||
|
Padding(padding: const EdgeInsets.all(8.0), child: Text('Current', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleMedium?.color))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
..._parameters.map((param) {
|
||||||
|
final key = param['key'] as String;
|
||||||
|
final label = param['label'] as String;
|
||||||
|
final controller = param['controller'] as TextEditingController;
|
||||||
|
final previousValue = previousReadings[key];
|
||||||
|
final bool isCurrentValueOutOfBounds = _outOfBoundsKeys.contains(key);
|
||||||
|
|
||||||
|
return TableRow(
|
||||||
|
children: [
|
||||||
|
Padding(padding: const EdgeInsets.all(8.0), child: Text(label)),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Text(
|
||||||
|
previousValue == -999.0 ? '-.--' : previousValue!.toStringAsFixed(5),
|
||||||
|
style: TextStyle(color: isDarkTheme ? Colors.orange.shade200 : Colors.orange.shade700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Text(
|
||||||
|
controller.text.contains('-999') ? '-.--' : (double.tryParse(controller.text) ?? 0).toStringAsFixed(5),
|
||||||
|
style: TextStyle(
|
||||||
|
color: isCurrentValueOutOfBounds
|
||||||
|
? Colors.red
|
||||||
|
: (isDarkTheme ? Colors.green.shade200 : Colors.green.shade700),
|
||||||
|
fontWeight: FontWeight.bold
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showParameterLimitDialog(List<Map<String, dynamic>> invalidParams, Map<String, double> readings) async {
|
||||||
|
return showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Parameter Limit Warning'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: DefaultTextStyle(
|
||||||
|
style: TextStyle(color: Theme.of(context).textTheme.bodyMedium?.color),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('The following parameters are outside the standard limits:'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Table(
|
||||||
|
columnWidths: const {
|
||||||
|
0: FlexColumnWidth(2),
|
||||||
|
1: FlexColumnWidth(2.5),
|
||||||
|
2: FlexColumnWidth(1.5),
|
||||||
|
},
|
||||||
|
border: TableBorder(
|
||||||
|
horizontalInside: BorderSide(width: 0.5, color: isDarkTheme ? Colors.grey.shade700 : Colors.grey.shade300),
|
||||||
|
verticalInside: BorderSide(width: 0.5, color: isDarkTheme ? Colors.grey.shade700 : Colors.grey.shade300),
|
||||||
|
top: BorderSide(width: 1, color: isDarkTheme ? Colors.grey.shade600 : Colors.grey.shade400),
|
||||||
|
bottom: BorderSide(width: 1, color: isDarkTheme ? Colors.grey.shade600 : Colors.grey.shade400),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TableRow(
|
||||||
|
decoration: BoxDecoration(color: isDarkTheme ? Colors.grey.shade800 : Colors.grey.shade200),
|
||||||
|
children: [
|
||||||
|
Padding(padding: const EdgeInsets.all(6.0), child: Text('Parameter', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleSmall?.color))),
|
||||||
|
Padding(padding: const EdgeInsets.all(6.0), child: Text('Limit Range', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleSmall?.color))),
|
||||||
|
Padding(padding: const EdgeInsets.all(6.0), child: Text('Current', style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.titleSmall?.color))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
...invalidParams.map((p) => TableRow(
|
||||||
|
children: [
|
||||||
|
Padding(padding: const EdgeInsets.all(6.0), child: Text(p['label'])),
|
||||||
|
Padding(padding: const EdgeInsets.all(6.0), child: Text('${p['lower_limit']?.toStringAsFixed(5) ?? 'N/A'} - ${p['upper_limit']?.toStringAsFixed(5) ?? 'N/A'}')),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(6.0),
|
||||||
|
child: Text(
|
||||||
|
p['value'].toStringAsFixed(5),
|
||||||
|
style: const TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)).toList(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Please verify with standard solutions. Do you want to resample or proceed with the current values?'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
child: const Text('Resample'),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_previousReadingsForComparison = readings;
|
||||||
|
});
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
child: const Text('Proceed Anyway'),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_saveDataAndMoveOn(readings);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildParameterListItem({required IconData icon, required String label, required String unit, required TextEditingController controller, bool isOutOfBounds = false}) {
|
||||||
|
final bool isMissing = controller.text.isEmpty || controller.text.contains('-999');
|
||||||
|
final String displayValue = isMissing ? '-.--' : controller.text;
|
||||||
|
final String displayLabel = unit.isEmpty ? label : '$label ($unit)';
|
||||||
|
|
||||||
|
final Color valueColor = isOutOfBounds
|
||||||
|
? Colors.red
|
||||||
|
: (isMissing ? Colors.grey : Theme.of(context).colorScheme.primary);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(icon, color: Theme.of(context).primaryColor, size: 32),
|
||||||
|
title: Text(displayLabel),
|
||||||
|
trailing: Text(
|
||||||
|
displayValue,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: valueColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildConnectionCard({required String type, required dynamic connectionState, String? deviceName}) {
|
||||||
|
final isConnected = connectionState == BluetoothConnectionState.connected || connectionState == SerialConnectionState.connected;
|
||||||
|
final isConnecting = connectionState == BluetoothConnectionState.connecting || connectionState == SerialConnectionState.connecting;
|
||||||
|
Color statusColor = isConnected ? Colors.green : Colors.red;
|
||||||
|
String statusText = isConnected ? 'Connected to ${deviceName ?? 'device'}' : 'Disconnected';
|
||||||
|
if (isConnecting) {
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
statusText = 'Connecting...';
|
||||||
|
}
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(statusText, style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (isConnecting || _isLoading)
|
||||||
|
const CircularProgressIndicator()
|
||||||
|
else if (isConnected)
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
|
||||||
|
label: Text(_isAutoReading
|
||||||
|
? (_isLockedOut ? 'Stop Reading ($_lockoutSecondsRemaining\s)' : 'Stop Reading')
|
||||||
|
: 'Start Reading'),
|
||||||
|
onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: _isAutoReading
|
||||||
|
? (_isLockedOut ? Colors.grey.shade600 : Colors.orange)
|
||||||
|
: Colors.green,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Icon(Icons.link_off),
|
||||||
|
label: const Text('Disconnect'),
|
||||||
|
onPressed: () => _disconnect(type),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1 +1,484 @@
|
|||||||
// lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_4_summary.dart
|
// lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_4_summary.dart
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../../../auth_provider.dart';
|
||||||
|
import '../../../../models/marine_inves_manual_sampling_data.dart';
|
||||||
|
// REMOVED: Import for NPE Report Screen is no longer needed
|
||||||
|
// import '../reports/npe_report_from_investigative.dart';
|
||||||
|
|
||||||
|
class MarineInvesManualStep4Summary extends StatefulWidget {
|
||||||
|
final MarineInvesManualSamplingData data;
|
||||||
|
final Future<Map<String, dynamic>> Function()
|
||||||
|
onSubmit; // Expects a function that returns the submission result
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
const MarineInvesManualStep4Summary({
|
||||||
|
super.key,
|
||||||
|
required this.data,
|
||||||
|
required this.onSubmit,
|
||||||
|
required this.isLoading,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MarineInvesManualStep4Summary> createState() => _MarineInvesManualStep4SummaryState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MarineInvesManualStep4SummaryState extends State<MarineInvesManualStep4Summary> {
|
||||||
|
bool _isHandlingSubmit = false;
|
||||||
|
|
||||||
|
// Keep parameter names for highlighting out-of-bounds station limits
|
||||||
|
static const Map<String, String> _parameterKeyToLimitName = {
|
||||||
|
'oxygenConcentration': 'Oxygen Conc',
|
||||||
|
'oxygenSaturation': 'Oxygen Sat',
|
||||||
|
'ph': 'pH',
|
||||||
|
'salinity': 'Salinity',
|
||||||
|
'electricalConductivity': 'Conductivity',
|
||||||
|
'temperature': 'Temperature',
|
||||||
|
'tds': 'TDS',
|
||||||
|
'turbidity': 'Turbidity',
|
||||||
|
'tss': 'TSS',
|
||||||
|
'batteryVoltage': 'Battery',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep this function to highlight parameters outside *station* limits
|
||||||
|
Set<String> _getOutOfBoundsKeys(BuildContext context) {
|
||||||
|
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||||
|
// Use regular marine limits, not NPE limits
|
||||||
|
final marineLimits = authProvider.marineParameterLimits ?? [];
|
||||||
|
final Set<String> invalidKeys = {};
|
||||||
|
|
||||||
|
int? stationId;
|
||||||
|
if (widget.data.stationTypeSelection == 'Existing Manual Station') {
|
||||||
|
stationId = widget.data.selectedStation?['station_id'];
|
||||||
|
}
|
||||||
|
// Note: Only checking against manual station limits for now.
|
||||||
|
|
||||||
|
final readings = {
|
||||||
|
'oxygenConcentration': widget.data.oxygenConcentration,
|
||||||
|
'oxygenSaturation': widget.data.oxygenSaturation,
|
||||||
|
'ph': widget.data.ph,
|
||||||
|
'salinity': widget.data.salinity,
|
||||||
|
'electricalConductivity': widget.data.electricalConductivity,
|
||||||
|
'temperature': widget.data.temperature,
|
||||||
|
'tds': widget.data.tds,
|
||||||
|
'turbidity': widget.data.turbidity,
|
||||||
|
'tss': widget.data.tss,
|
||||||
|
'batteryVoltage': widget.data.batteryVoltage,
|
||||||
|
};
|
||||||
|
|
||||||
|
double? parseLimitValue(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is num) return value.toDouble();
|
||||||
|
if (value is String) return double.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
readings.forEach((key, value) {
|
||||||
|
if (value == null || value == -999.0) return;
|
||||||
|
|
||||||
|
final limitName = _parameterKeyToLimitName[key];
|
||||||
|
if (limitName == null) return;
|
||||||
|
|
||||||
|
Map<String, dynamic> limitData = {};
|
||||||
|
|
||||||
|
if (stationId != null) {
|
||||||
|
limitData = marineLimits.firstWhere(
|
||||||
|
(l) =>
|
||||||
|
l['param_parameter_list'] == limitName &&
|
||||||
|
l['station_id'] == stationId,
|
||||||
|
orElse: () => {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (limitData.isNotEmpty) {
|
||||||
|
final lowerLimit = parseLimitValue(limitData['param_lower_limit']);
|
||||||
|
final upperLimit = parseLimitValue(limitData['param_upper_limit']);
|
||||||
|
|
||||||
|
if ((lowerLimit != null && value < lowerLimit) ||
|
||||||
|
(upperLimit != null && value > upperLimit)) {
|
||||||
|
invalidKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return invalidKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
// REMOVED: _getNpeTriggeredParameters method
|
||||||
|
// REMOVED: _showNpeDialog method
|
||||||
|
|
||||||
|
/// Handles the complete submission flow WITHOUT NPE check.
|
||||||
|
Future<void> _handleSubmit(BuildContext context) async {
|
||||||
|
if (_isHandlingSubmit || widget.isLoading) return;
|
||||||
|
|
||||||
|
setState(() => _isHandlingSubmit = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Directly call the submission function provided by the parent
|
||||||
|
final result = await widget.onSubmit();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Show feedback snackbar based on the result
|
||||||
|
final message = result['message'] ?? 'An unknown error occurred.';
|
||||||
|
final color = (result['success'] == true) ? Colors.green : Colors.red;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: color,
|
||||||
|
duration: const Duration(seconds: 4)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If submission was successful, navigate back to the home screen
|
||||||
|
if (result['success'] == true) {
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
}
|
||||||
|
// If submission failed, the user stays on the summary screen to potentially retry
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
// Catch any unexpected errors during submission
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Submission failed unexpectedly: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
duration: const Duration(seconds: 4)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Ensure the loading state is turned off
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isHandlingSubmit = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to build station details dynamically
|
||||||
|
List<Widget> _buildStationDetails() {
|
||||||
|
final stationType = widget.data.stationTypeSelection;
|
||||||
|
|
||||||
|
if (stationType == 'Existing Manual Station') {
|
||||||
|
return [
|
||||||
|
_buildDetailRow("Station Source:", "Existing Manual Station"),
|
||||||
|
_buildDetailRow("State:", widget.data.selectedManualStateName),
|
||||||
|
_buildDetailRow("Category:", widget.data.selectedManualCategoryName),
|
||||||
|
_buildDetailRow("Station ID:", widget.data.selectedStation?['man_station_code']?.toString()),
|
||||||
|
_buildDetailRow("Station Name:", widget.data.selectedStation?['man_station_name']?.toString()),
|
||||||
|
_buildDetailRow("Station Location:", "${widget.data.stationLatitude}, ${widget.data.stationLongitude}"),
|
||||||
|
];
|
||||||
|
} else if (stationType == 'Existing Tarball Station') {
|
||||||
|
return [
|
||||||
|
_buildDetailRow("Station Source:", "Existing Tarball Station"),
|
||||||
|
_buildDetailRow("State:", widget.data.selectedTarballStateName),
|
||||||
|
_buildDetailRow("Station ID:", widget.data.selectedTarballStation?['tbl_station_code']?.toString()),
|
||||||
|
_buildDetailRow("Station Name:", widget.data.selectedTarballStation?['tbl_station_name']?.toString()),
|
||||||
|
_buildDetailRow("Station Location:", "${widget.data.stationLatitude}, ${widget.data.stationLongitude}"),
|
||||||
|
];
|
||||||
|
} else if (stationType == 'New Location') {
|
||||||
|
return [
|
||||||
|
_buildDetailRow("Station Source:", "New Location"),
|
||||||
|
_buildDetailRow("Station Name:", widget.data.newStationName),
|
||||||
|
_buildDetailRow("Station Code:", widget.data.newStationCode),
|
||||||
|
_buildDetailRow("Station Location:", "(Manual) ${widget.data.stationLatitude}, ${widget.data.stationLongitude}"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [_buildDetailRow("Station Info:", "Not specified")];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Still get out-of-bounds keys for station limits to highlight them
|
||||||
|
final outOfBoundsKeys = _getOutOfBoundsKeys(context);
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Please review all information before submitting.",
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildSectionCard(
|
||||||
|
context,
|
||||||
|
"Sampling & Station Details",
|
||||||
|
[
|
||||||
|
_buildDetailRow("1st Sampler:", widget.data.firstSamplerName),
|
||||||
|
_buildDetailRow(
|
||||||
|
"2nd Sampler:", widget.data.secondSampler?['first_name']?.toString()),
|
||||||
|
_buildDetailRow("Sampling Date:", widget.data.samplingDate),
|
||||||
|
_buildDetailRow("Sampling Time:", widget.data.samplingTime),
|
||||||
|
_buildDetailRow("Sampling Type:", widget.data.samplingType),
|
||||||
|
_buildDetailRow("Sample ID Code:", widget.data.sampleIdCode),
|
||||||
|
const Divider(height: 20),
|
||||||
|
..._buildStationDetails(), // Use dynamic station details
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_buildSectionCard(
|
||||||
|
context,
|
||||||
|
"Location & On-Site Info",
|
||||||
|
[
|
||||||
|
_buildDetailRow("Current Location:",
|
||||||
|
"${widget.data.currentLatitude}, ${widget.data.currentLongitude}"),
|
||||||
|
_buildDetailRow(
|
||||||
|
"Distance Difference:",
|
||||||
|
widget.data.distanceDifferenceInKm != null
|
||||||
|
? "${(widget.data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters"
|
||||||
|
: "N/A"),
|
||||||
|
if (widget.data.distanceDifferenceRemarks != null &&
|
||||||
|
widget.data.distanceDifferenceRemarks!.isNotEmpty)
|
||||||
|
_buildDetailRow(
|
||||||
|
"Distance Remarks:", widget.data.distanceDifferenceRemarks),
|
||||||
|
const Divider(height: 20),
|
||||||
|
_buildDetailRow("Weather:", widget.data.weather),
|
||||||
|
_buildDetailRow("Tide Level:", widget.data.tideLevel),
|
||||||
|
_buildDetailRow("Sea Condition:", widget.data.seaCondition),
|
||||||
|
_buildDetailRow("Event Remarks:", widget.data.eventRemarks),
|
||||||
|
_buildDetailRow("Lab Remarks:", widget.data.labRemarks),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_buildSectionCard(
|
||||||
|
context,
|
||||||
|
"Attached Photos",
|
||||||
|
[
|
||||||
|
_buildImageCard("Left Side Land View", widget.data.leftLandViewImage),
|
||||||
|
_buildImageCard(
|
||||||
|
"Right Side Land View", widget.data.rightLandViewImage),
|
||||||
|
_buildImageCard(
|
||||||
|
"Filling Water into Bottle", widget.data.waterFillingImage),
|
||||||
|
_buildImageCard(
|
||||||
|
"Seawater Color in Bottle", widget.data.seawaterColorImage),
|
||||||
|
_buildImageCard(
|
||||||
|
"Examine Preservative (pH paper)", widget.data.phPaperImage),
|
||||||
|
const Divider(height: 24),
|
||||||
|
Text("Optional Photos",
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildImageCard("Optional Photo 1", widget.data.optionalImage1,
|
||||||
|
remark: widget.data.optionalRemark1),
|
||||||
|
_buildImageCard("Optional Photo 2", widget.data.optionalImage2,
|
||||||
|
remark: widget.data.optionalRemark2),
|
||||||
|
_buildImageCard("Optional Photo 3", widget.data.optionalImage3,
|
||||||
|
remark: widget.data.optionalRemark3),
|
||||||
|
_buildImageCard("Optional Photo 4", widget.data.optionalImage4,
|
||||||
|
remark: widget.data.optionalRemark4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_buildSectionCard(
|
||||||
|
context,
|
||||||
|
"Captured Parameters",
|
||||||
|
[
|
||||||
|
_buildDetailRow("Sonde ID:", widget.data.sondeId),
|
||||||
|
_buildDetailRow("Capture Time:",
|
||||||
|
"${widget.data.dataCaptureDate} ${widget.data.dataCaptureTime}"),
|
||||||
|
const Divider(height: 20),
|
||||||
|
_buildParameterListItem(context,
|
||||||
|
icon: Icons.air,
|
||||||
|
label: "Oxygen Conc.",
|
||||||
|
unit: "mg/L",
|
||||||
|
value: widget.data.oxygenConcentration,
|
||||||
|
isOutOfBounds:
|
||||||
|
outOfBoundsKeys.contains('oxygenConcentration')),
|
||||||
|
_buildParameterListItem(context,
|
||||||
|
icon: Icons.percent,
|
||||||
|
label: "Oxygen Sat.",
|
||||||
|
unit: "%",
|
||||||
|
value: widget.data.oxygenSaturation,
|
||||||
|
isOutOfBounds: outOfBoundsKeys.contains('oxygenSaturation')),
|
||||||
|
_buildParameterListItem(context,
|
||||||
|
icon: Icons.science_outlined,
|
||||||
|
label: "pH",
|
||||||
|
unit: "",
|
||||||
|
value: widget.data.ph,
|
||||||
|
isOutOfBounds: outOfBoundsKeys.contains('ph')),
|
||||||
|
_buildParameterListItem(context,
|
||||||
|
icon: Icons.waves,
|
||||||
|
label: "Salinity",
|
||||||
|
unit: "ppt",
|
||||||
|
value: widget.data.salinity,
|
||||||
|
isOutOfBounds: outOfBoundsKeys.contains('salinity')),
|
||||||
|
_buildParameterListItem(context,
|
||||||
|
icon: Icons.flash_on,
|
||||||
|
label: "Conductivity",
|
||||||
|
unit: "µS/cm",
|
||||||
|
value: widget.data.electricalConductivity,
|
||||||
|
isOutOfBounds:
|
||||||
|
outOfBoundsKeys.contains('electricalConductivity')),
|
||||||
|
_buildParameterListItem(context,
|
||||||
|
icon: Icons.thermostat,
|
||||||
|
label: "Temperature",
|
||||||
|
unit: "°C",
|
||||||
|
value: widget.data.temperature,
|
||||||
|
isOutOfBounds: outOfBoundsKeys.contains('temperature')),
|
||||||
|
_buildParameterListItem(context,
|
||||||
|
icon: Icons.grain,
|
||||||
|
label: "TDS",
|
||||||
|
unit: "mg/L",
|
||||||
|
value: widget.data.tds,
|
||||||
|
isOutOfBounds: outOfBoundsKeys.contains('tds')),
|
||||||
|
_buildParameterListItem(context,
|
||||||
|
icon: Icons.opacity,
|
||||||
|
label: "Turbidity",
|
||||||
|
unit: "NTU",
|
||||||
|
value: widget.data.turbidity,
|
||||||
|
isOutOfBounds: outOfBoundsKeys.contains('turbidity')),
|
||||||
|
_buildParameterListItem(context,
|
||||||
|
icon: Icons.filter_alt_outlined,
|
||||||
|
label: "TSS",
|
||||||
|
unit: "mg/L",
|
||||||
|
value: widget.data.tss,
|
||||||
|
isOutOfBounds: outOfBoundsKeys.contains('tss')),
|
||||||
|
_buildParameterListItem(context,
|
||||||
|
icon: Icons.battery_charging_full,
|
||||||
|
label: "Battery",
|
||||||
|
unit: "V",
|
||||||
|
value: widget.data.batteryVoltage,
|
||||||
|
isOutOfBounds: outOfBoundsKeys.contains('batteryVoltage')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
(widget.isLoading || _isHandlingSubmit)
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: ElevatedButton.icon(
|
||||||
|
onPressed: () => _handleSubmit(context), // Simplified call
|
||||||
|
icon: const Icon(Icons.cloud_upload),
|
||||||
|
label: const Text('Confirm & Submit'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper widgets ---
|
||||||
|
Widget _buildSectionCard(
|
||||||
|
BuildContext context, String title, List<Widget> children) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 20, thickness: 1),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDetailRow(String label, String? value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6.0),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: Text(value != null && value.isNotEmpty ? value : 'N/A',
|
||||||
|
style: const TextStyle(fontSize: 16)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildParameterListItem(BuildContext context,
|
||||||
|
{required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required String unit,
|
||||||
|
required double? value,
|
||||||
|
bool isOutOfBounds = false}) {
|
||||||
|
final bool isMissing = value == null || value == -999.0;
|
||||||
|
final String displayValue =
|
||||||
|
isMissing ? 'N/A' : '${value.toStringAsFixed(5)} ${unit}'.trim();
|
||||||
|
final Color? defaultTextColor =
|
||||||
|
Theme.of(context).textTheme.bodyLarge?.color;
|
||||||
|
final Color valueColor = isOutOfBounds // Still highlight if outside station limits
|
||||||
|
? Colors.red
|
||||||
|
: (isMissing ? Colors.grey : defaultTextColor ?? Colors.black);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Icon(icon, color: Theme.of(context).primaryColor, size: 28),
|
||||||
|
title: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
trailing: Text(
|
||||||
|
displayValue,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: valueColor,
|
||||||
|
fontWeight: isOutOfBounds ? FontWeight.bold : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImageCard(String title, File? image, {String? remark}) {
|
||||||
|
final bool hasRemark = remark != null && remark.isNotEmpty;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title,
|
||||||
|
style:
|
||||||
|
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (image != null)
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8.0),
|
||||||
|
child: Image.file(image,
|
||||||
|
key: UniqueKey(),
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
fit: BoxFit.cover),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
height: 100,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[200],
|
||||||
|
borderRadius: BorderRadius.circular(8.0),
|
||||||
|
border: Border.all(color: Colors.grey[300]!)),
|
||||||
|
child: const Center(
|
||||||
|
child: Text('No Image Attached',
|
||||||
|
style: TextStyle(color: Colors.grey))),
|
||||||
|
),
|
||||||
|
if (hasRemark)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: Text('Remark: $remark',
|
||||||
|
style: const TextStyle(fontStyle: FontStyle.italic)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1 +1,125 @@
|
|||||||
// lib/screens/marine/investigative/marine_investigative_manual_sampling.dart
|
// lib/screens/marine/investigative/marine_investigative_manual_sampling.dart
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../../models/marine_inves_manual_sampling_data.dart';
|
||||||
|
import '../../../services/marine_investigative_sampling_service.dart';
|
||||||
|
import '../../../auth_provider.dart';
|
||||||
|
|
||||||
|
import 'manual_sampling/marine_inves_manual_step_1_sampling_info.dart';
|
||||||
|
import 'manual_sampling/marine_inves_manual_step_2_site_info.dart';
|
||||||
|
import 'manual_sampling/marine_inves_manual_step_3_data_capture.dart';
|
||||||
|
import 'manual_sampling/marine_inves_manual_step_4_summary.dart';
|
||||||
|
|
||||||
|
class MarineInvestigativeManualSampling extends StatefulWidget {
|
||||||
|
const MarineInvestigativeManualSampling({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MarineInvestigativeManualSampling> createState() => _MarineInvestigativeManualSamplingState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MarineInvestigativeManualSamplingState extends State<MarineInvestigativeManualSampling> {
|
||||||
|
final PageController _pageController = PageController();
|
||||||
|
final MarineInvesManualSamplingData _data = MarineInvesManualSamplingData();
|
||||||
|
int _currentStep = 0;
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Pre-fill sampling date and time when the form is first created
|
||||||
|
final now = DateTime.now();
|
||||||
|
_data.samplingDate = "${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}";
|
||||||
|
_data.samplingTime = "${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}";
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pageController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _nextPage() {
|
||||||
|
if (_currentStep < 3) {
|
||||||
|
_pageController.nextPage(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _previousPage() {
|
||||||
|
if (_currentStep > 0) {
|
||||||
|
_pageController.previousPage(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> _submitData() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
final service = context.read<MarineInvestigativeSamplingService>();
|
||||||
|
final authProvider = context.read<AuthProvider>();
|
||||||
|
final appSettings = authProvider.appSettings;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await service.submitInvestigativeSample(
|
||||||
|
data: _data,
|
||||||
|
appSettings: appSettings,
|
||||||
|
authProvider: authProvider,
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
return {'success': false, 'message': 'An unexpected error occurred: $e'};
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final List<Widget> pages = [
|
||||||
|
MarineInvesManualStep1SamplingInfo(data: _data, onNext: _nextPage),
|
||||||
|
MarineInvesManualStep2SiteInfo(data: _data, onNext: _nextPage),
|
||||||
|
MarineInvesManualStep3DataCapture(data: _data, onNext: _nextPage),
|
||||||
|
MarineInvesManualStep4Summary(
|
||||||
|
data: _data,
|
||||||
|
onSubmit: _submitData,
|
||||||
|
isLoading: _isLoading,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('Marine Investigative Sampling (Step ${_currentStep + 1} of 4)'),
|
||||||
|
leading: _currentStep == 0
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: _previousPage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: PageView(
|
||||||
|
controller: _pageController,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
onPageChanged: (index) {
|
||||||
|
setState(() {
|
||||||
|
_currentStep = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
children: pages,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
// lib/screens/marine/marine_home_page.dart
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
// Re-defining SidebarItem here for self-containment,
|
// Re-defining SidebarItem here for self-containment,
|
||||||
@ -60,6 +62,8 @@ class MarineHomePage extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// MODIFIED: Updated label, icon, and route for the new Info Centre screen
|
// MODIFIED: Updated label, icon, and route for the new Info Centre screen
|
||||||
SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/investigative/info'),
|
SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/investigative/info'),
|
||||||
|
// *** ADDED: New menu item for Investigative Manual Sampling ***
|
||||||
|
SidebarItem(icon: Icons.science_outlined, label: "Investigative Sampling", route: '/marine/investigative/manual-sampling'),
|
||||||
//SidebarItem(icon: Icons.info, label: "Overview", route: '/marine/investigative/overview'),
|
//SidebarItem(icon: Icons.info, label: "Overview", route: '/marine/investigative/overview'),
|
||||||
//SidebarItem(icon: Icons.input, label: "Entry", route: '/marine/investigative/entry'),
|
//SidebarItem(icon: Icons.input, label: "Entry", route: '/marine/investigative/entry'),
|
||||||
//SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/investigative/report'),
|
//SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/investigative/report'),
|
||||||
|
|||||||
@ -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: [
|
children: [
|
||||||
// MODIFIED: Updated to point to the new Info Centre screen
|
// MODIFIED: Updated to point to the new Info Centre screen
|
||||||
SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/river/investigative/info'),
|
SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/river/investigative/info'),
|
||||||
// SidebarItem(icon: Icons.info, label: "Overview", route: '/river/investigative/overview'),
|
// *** ADDED: Link to River Investigative Manual Sampling ***
|
||||||
//SidebarItem(icon: Icons.input, label: "Entry", route: '/river/investigative/entry'),
|
SidebarItem(icon: Icons.biotech, label: "Investigative Sampling", route: '/river/investigative/manual-sampling'), // Added Icon
|
||||||
//SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/investigative/report'),
|
// SidebarItem(icon: Icons.info, label: "Overview", route: '/river/investigative/overview'), // Keep placeholder/future items commented
|
||||||
|
//SidebarItem(icon: Icons.input, label: "Entry", route: '/river/investigative/entry'), // Keep placeholder/future items commented
|
||||||
|
//SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/investigative/report'), // Keep placeholder/future items commented
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@ -136,30 +138,31 @@ class RiverHomePage extends StatelessWidget {
|
|||||||
Navigator.pushNamed(context, subItem.route!);
|
Navigator.pushNamed(context, subItem.route!);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(0),
|
borderRadius: BorderRadius.circular(0), // No rounded corners for grid items
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.all(4.0), // Added margin for better spacing
|
margin: const EdgeInsets.all(4.0), // Added margin for better spacing
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: Colors.white24, width: 0.5), // Optional: subtle border
|
border: Border.all(color: Colors.white24, width: 0.5), // Optional: subtle border
|
||||||
|
// No background color unless desired
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.start, // Align content to start
|
||||||
children: [
|
children: [
|
||||||
subItem.icon != null
|
subItem.icon != null
|
||||||
? Icon(subItem.icon, color: Colors.white70, size: 24)
|
? Icon(subItem.icon, color: Colors.white70, size: 24) // Adjusted icon size
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox.shrink(), // Or provide a placeholder
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded( // Allow text to take remaining space
|
||||||
child: Text(
|
child: Text(
|
||||||
subItem.label,
|
subItem.label,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: Colors.white70,
|
color: Colors.white70, // Slightly lighter text
|
||||||
fontSize: 12, // Slightly increased font size
|
fontSize: 12, // Slightly increased font size
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.left,
|
textAlign: TextAlign.left, // Left align text
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis, // Prevent overflow
|
||||||
maxLines: 2, // Allow for two lines if needed
|
maxLines: 2, // Allow for two lines if needed
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -170,7 +173,7 @@ class RiverHomePage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16), // Spacing between categories
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,11 @@ import '../models/in_situ_sampling_data.dart';
|
|||||||
import '../models/marine_manual_npe_report_data.dart';
|
import '../models/marine_manual_npe_report_data.dart';
|
||||||
import '../models/river_in_situ_sampling_data.dart';
|
import '../models/river_in_situ_sampling_data.dart';
|
||||||
import '../models/river_manual_triennial_sampling_data.dart';
|
import '../models/river_manual_triennial_sampling_data.dart';
|
||||||
|
// --- ADDED IMPORT ---
|
||||||
|
import '../models/marine_inves_manual_sampling_data.dart';
|
||||||
|
// --- ADDED IMPORT FOR RIVER INVESTIGATIVE ---
|
||||||
|
import '../models/river_inves_manual_sampling_data.dart';
|
||||||
|
// --- END ADDED IMPORT ---
|
||||||
|
|
||||||
class LocalStorageService {
|
class LocalStorageService {
|
||||||
|
|
||||||
@ -803,6 +808,263 @@ class LocalStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// --- ADDED: Part 6.5: Marine Investigative Specific Methods ---
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
Future<Directory?> _getInvestigativeBaseDir({required String serverName}) async {
|
||||||
|
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
|
||||||
|
if (mmsv4Dir == null) return null;
|
||||||
|
|
||||||
|
// Use a new subModule path for investigative logs
|
||||||
|
final inSituDir = Directory(p.join(mmsv4Dir.path, 'marine', 'marine_investigative_sampling'));
|
||||||
|
if (!await inSituDir.exists()) {
|
||||||
|
await inSituDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
return inSituDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves Marine Investigative sampling data to the local log
|
||||||
|
Future<String?> saveInvestigativeSamplingData(MarineInvesManualSamplingData data, {required String serverName}) async {
|
||||||
|
final baseDir = await _getInvestigativeBaseDir(serverName: serverName);
|
||||||
|
if (baseDir == null) {
|
||||||
|
debugPrint("Could not get public storage directory for Investigative. Check permissions.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// --- Generate folder name based on station type ---
|
||||||
|
String stationCode = 'NA';
|
||||||
|
if (data.stationTypeSelection == 'Existing Manual Station') {
|
||||||
|
stationCode = data.selectedStation?['man_station_code'] ?? 'MANUAL_NA';
|
||||||
|
} else if (data.stationTypeSelection == 'Existing Tarball Station') {
|
||||||
|
stationCode = data.selectedTarballStation?['tbl_station_code'] ?? 'TARBALL_NA';
|
||||||
|
} else if (data.stationTypeSelection == 'New Location') {
|
||||||
|
stationCode = data.newStationCode ?? 'NEW_NA';
|
||||||
|
}
|
||||||
|
|
||||||
|
final timestamp = "${data.samplingDate}_${data.samplingTime?.replaceAll(':', '-')}";
|
||||||
|
final eventFolderName = "${stationCode}_$timestamp";
|
||||||
|
final eventDir = Directory(p.join(baseDir.path, eventFolderName));
|
||||||
|
|
||||||
|
if (!await eventDir.exists()) {
|
||||||
|
await eventDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> jsonData = data.toDbJson(); //
|
||||||
|
jsonData['submissionStatus'] = data.submissionStatus;
|
||||||
|
jsonData['submissionMessage'] = data.submissionMessage;
|
||||||
|
jsonData['reportId'] = data.reportId;
|
||||||
|
jsonData['serverConfigName'] = serverName;
|
||||||
|
|
||||||
|
final imageFiles = data.toApiImageFiles(); //
|
||||||
|
for (var entry in imageFiles.entries) {
|
||||||
|
final File? imageFile = entry.value;
|
||||||
|
if (imageFile != null && imageFile.path.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
// Check if file is already in the correct directory (e.g., from a retry)
|
||||||
|
if (p.dirname(imageFile.path) == eventDir.path) {
|
||||||
|
jsonData[entry.key] = imageFile.path;
|
||||||
|
} else {
|
||||||
|
// Copy file from temp cache to persistent log directory
|
||||||
|
final String originalFileName = p.basename(imageFile.path);
|
||||||
|
final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName));
|
||||||
|
jsonData[entry.key] = newFile.path;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error processing Investigative image file ${imageFile.path}: $e");
|
||||||
|
jsonData[entry.key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonFile = File(p.join(eventDir.path, 'data.json'));
|
||||||
|
await jsonFile.writeAsString(jsonEncode(jsonData));
|
||||||
|
debugPrint("Investigative log saved to: ${jsonFile.path}");
|
||||||
|
|
||||||
|
return eventDir.path; // Return the path to the saved log directory
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error saving Investigative log to local storage: $e");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches all saved Marine Investigative logs
|
||||||
|
Future<List<Map<String, dynamic>>> getAllInvestigativeLogs() async {
|
||||||
|
final mmsv4Root = await _getPublicMMSV4Directory(serverName: '');
|
||||||
|
if (mmsv4Root == null || !await mmsv4Root.exists()) return [];
|
||||||
|
|
||||||
|
final List<Map<String, dynamic>> allLogs = [];
|
||||||
|
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
|
||||||
|
|
||||||
|
for (var serverDir in serverDirs) {
|
||||||
|
final baseDir = Directory(p.join(serverDir.path, 'marine', 'marine_investigative_sampling'));
|
||||||
|
if (!await baseDir.exists()) continue;
|
||||||
|
try {
|
||||||
|
final entities = baseDir.listSync();
|
||||||
|
for (var entity in entities) {
|
||||||
|
if (entity is Directory) {
|
||||||
|
final jsonFile = File(p.join(entity.path, 'data.json'));
|
||||||
|
if (await jsonFile.exists()) {
|
||||||
|
final content = await jsonFile.readAsString();
|
||||||
|
final data = jsonDecode(content) as Map<String, dynamic>;
|
||||||
|
data['logDirectory'] = entity.path;
|
||||||
|
allLogs.add(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error reading investigative logs from ${baseDir.path}: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allLogs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates an existing Marine Investigative log file (e.g., after a retry)
|
||||||
|
Future<void> updateInvestigativeLog(Map<String, dynamic> updatedLogData) async {
|
||||||
|
final logDir = updatedLogData['logDirectory'];
|
||||||
|
if (logDir == null) {
|
||||||
|
debugPrint("Cannot update investigative log: logDirectory key is missing.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final jsonFile = File(p.join(logDir, 'data.json'));
|
||||||
|
if (await jsonFile.exists()) {
|
||||||
|
updatedLogData.remove('isResubmitting'); // Clean up temporary flags
|
||||||
|
await jsonFile.writeAsString(jsonEncode(updatedLogData));
|
||||||
|
debugPrint("Investigative log updated successfully at: ${jsonFile.path}");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error updating investigative log: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// --- ADDED: Part 6.6: River Investigative Specific Methods ---
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
/// Gets the base directory for River Investigative logs.
|
||||||
|
Future<Directory?> getRiverInvestigativeBaseDir({required String serverName}) async {
|
||||||
|
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
|
||||||
|
if (mmsv4Dir == null) return null;
|
||||||
|
|
||||||
|
final invesDir = Directory(p.join(mmsv4Dir.path, 'river', 'river_investigative_sampling'));
|
||||||
|
if (!await invesDir.exists()) {
|
||||||
|
await invesDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
return invesDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves River Investigative sampling data to the local log.
|
||||||
|
Future<String?> saveRiverInvestigativeSamplingData(RiverInvesManualSamplingData data, {required String serverName}) async {
|
||||||
|
final baseDir = await getRiverInvestigativeBaseDir(serverName: serverName);
|
||||||
|
if (baseDir == null) {
|
||||||
|
debugPrint("Could not get public storage directory for River Investigative. Check permissions.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final stationCode = data.getDeterminedStationCode() ?? 'UNKNOWN';
|
||||||
|
final timestamp = "${data.samplingDate}_${data.samplingTime?.replaceAll(':', '-')}";
|
||||||
|
final eventFolderName = "${stationCode}_$timestamp";
|
||||||
|
final eventDir = Directory(p.join(baseDir.path, eventFolderName));
|
||||||
|
|
||||||
|
if (!await eventDir.exists()) {
|
||||||
|
await eventDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the .toMap() method from the data model, which is designed for local logging
|
||||||
|
final Map<String, dynamic> jsonData = data.toMap();
|
||||||
|
jsonData['serverConfigName'] = serverName;
|
||||||
|
// Status, message, and reportId are already included by .toMap()
|
||||||
|
|
||||||
|
final imageFiles = data.toApiImageFiles();
|
||||||
|
for (var entry in imageFiles.entries) {
|
||||||
|
final File? imageFile = entry.value;
|
||||||
|
if (imageFile != null && imageFile.path.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
if (p.dirname(imageFile.path) == eventDir.path) {
|
||||||
|
jsonData[entry.key] = imageFile.path; // Already in log dir
|
||||||
|
} else {
|
||||||
|
final String originalFileName = p.basename(imageFile.path);
|
||||||
|
final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName));
|
||||||
|
jsonData[entry.key] = newFile.path; // Store the new persistent path
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error processing River Investigative image file ${imageFile.path}: $e");
|
||||||
|
jsonData[entry.key] = null; // Store null if copy failed
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ensure keys for null images are also present if needed, though .toMap() handles this
|
||||||
|
jsonData[entry.key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonFile = File(p.join(eventDir.path, 'data.json'));
|
||||||
|
await jsonFile.writeAsString(jsonEncode(jsonData));
|
||||||
|
debugPrint("River Investigative log saved to: ${jsonFile.path}");
|
||||||
|
|
||||||
|
return eventDir.path;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error saving River Investigative log to local storage: $e");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches all saved River Investigative logs.
|
||||||
|
Future<List<Map<String, dynamic>>> getAllRiverInvestigativeLogs() async {
|
||||||
|
final mmsv4Root = await _getPublicMMSV4Directory(serverName: '');
|
||||||
|
if (mmsv4Root == null || !await mmsv4Root.exists()) return [];
|
||||||
|
|
||||||
|
final List<Map<String, dynamic>> allLogs = [];
|
||||||
|
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
|
||||||
|
|
||||||
|
for (var serverDir in serverDirs) {
|
||||||
|
final baseDir = Directory(p.join(serverDir.path, 'river', 'river_investigative_sampling'));
|
||||||
|
if (!await baseDir.exists()) continue;
|
||||||
|
try {
|
||||||
|
final entities = baseDir.listSync();
|
||||||
|
for (var entity in entities) {
|
||||||
|
if (entity is Directory) {
|
||||||
|
final jsonFile = File(p.join(entity.path, 'data.json'));
|
||||||
|
if (await jsonFile.exists()) {
|
||||||
|
final content = await jsonFile.readAsString();
|
||||||
|
final data = jsonDecode(content) as Map<String, dynamic>;
|
||||||
|
data['logDirectory'] = entity.path;
|
||||||
|
allLogs.add(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error reading river investigative logs from ${baseDir.path}: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allLogs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates an existing River Investigative log file (e.g., after a retry).
|
||||||
|
Future<void> updateRiverInvestigativeLog(Map<String, dynamic> updatedLogData) async {
|
||||||
|
final logDir = updatedLogData['logDirectory'];
|
||||||
|
if (logDir == null) {
|
||||||
|
debugPrint("Cannot update river investigative log: logDirectory key is missing.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final jsonFile = File(p.join(logDir, 'data.json'));
|
||||||
|
if (await jsonFile.exists()) {
|
||||||
|
updatedLogData.remove('isResubmitting'); // Clean up temporary flags
|
||||||
|
await jsonFile.writeAsString(jsonEncode(updatedLogData));
|
||||||
|
debugPrint("River Investigative log updated successfully at: ${jsonFile.path}");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error updating river investigative log: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
// --- ADDED: Part 7: Info Centre Document Management ---
|
// --- ADDED: Part 7: Info Centre Document Management ---
|
||||||
|
|||||||
@ -166,21 +166,26 @@ class MarineInSituSamplingService {
|
|||||||
required InSituSamplingData data,
|
required InSituSamplingData data,
|
||||||
required List<Map<String, dynamic>>? appSettings,
|
required List<Map<String, dynamic>>? appSettings,
|
||||||
required AuthProvider authProvider,
|
required AuthProvider authProvider,
|
||||||
BuildContext? context,
|
BuildContext? context, // Context no longer needed here, but kept for signature consistency
|
||||||
String? logDirectory,
|
String? logDirectory,
|
||||||
}) async {
|
}) async {
|
||||||
const String moduleName = 'marine_in_situ';
|
const String moduleName = 'marine_in_situ';
|
||||||
|
|
||||||
final connectivityResult = await Connectivity().checkConnectivity();
|
final connectivityResult = await Connectivity().checkConnectivity();
|
||||||
bool isOnline = connectivityResult != ConnectivityResult.none;
|
bool isOnline = !connectivityResult.contains(ConnectivityResult.none);
|
||||||
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
|
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
|
||||||
|
|
||||||
if (isOnline && isOfflineSession) {
|
if (isOnline && isOfflineSession) {
|
||||||
debugPrint("In-Situ submission online during offline session. Attempting auto-relogin...");
|
debugPrint("In-Situ submission online during offline session. Attempting auto-relogin...");
|
||||||
|
try {
|
||||||
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
|
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
|
||||||
if (transitionSuccess) {
|
if (transitionSuccess) {
|
||||||
isOfflineSession = false;
|
isOfflineSession = false;
|
||||||
} else {
|
} else {
|
||||||
|
isOnline = false; // Auto-relogin failed, treat as offline
|
||||||
|
}
|
||||||
|
} on SessionExpiredException catch (_) {
|
||||||
|
debugPrint("Session expired during auto-relogin check. Treating as offline.");
|
||||||
isOnline = false;
|
isOnline = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -199,15 +204,17 @@ class MarineInSituSamplingService {
|
|||||||
return await _performOfflineQueuing(
|
return await _performOfflineQueuing(
|
||||||
data: data,
|
data: data,
|
||||||
moduleName: moduleName,
|
moduleName: moduleName,
|
||||||
|
logDirectory: logDirectory, // Pass for potential update
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles the online submission flow using generic services.
|
||||||
Future<Map<String, dynamic>> _performOnlineSubmission({
|
Future<Map<String, dynamic>> _performOnlineSubmission({
|
||||||
required InSituSamplingData data,
|
required InSituSamplingData data,
|
||||||
required List<Map<String, dynamic>>? appSettings,
|
required List<Map<String, dynamic>>? appSettings,
|
||||||
required String moduleName,
|
required String moduleName,
|
||||||
required AuthProvider authProvider,
|
required AuthProvider authProvider, // Still needed for session check inside this method
|
||||||
String? logDirectory,
|
String? logDirectory,
|
||||||
}) async {
|
}) async {
|
||||||
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
|
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
|
||||||
@ -218,25 +225,29 @@ class MarineInSituSamplingService {
|
|||||||
bool anyApiSuccess = false;
|
bool anyApiSuccess = false;
|
||||||
Map<String, dynamic> apiDataResult = {};
|
Map<String, dynamic> apiDataResult = {};
|
||||||
Map<String, dynamic> apiImageResult = {};
|
Map<String, dynamic> apiImageResult = {};
|
||||||
|
String finalMessage = '';
|
||||||
|
String finalStatus = '';
|
||||||
bool isSessionKnownToBeExpired = false;
|
bool isSessionKnownToBeExpired = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 1. Submit Form Data
|
||||||
apiDataResult = await _submissionApiService.submitPost(
|
apiDataResult = await _submissionApiService.submitPost(
|
||||||
moduleName: moduleName,
|
moduleName: moduleName,
|
||||||
endpoint: 'marine/manual/sample',
|
endpoint: 'marine/manual/sample', // Correct endpoint for In-Situ data
|
||||||
body: data.toApiFormData(),
|
body: data.toApiFormData(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (apiDataResult['success'] == true) {
|
if (apiDataResult['success'] == true) {
|
||||||
anyApiSuccess = true;
|
anyApiSuccess = true;
|
||||||
data.reportId = apiDataResult['data']?['man_id']?.toString();
|
data.reportId = apiDataResult['data']?['man_id']?.toString(); // Correct ID key for In-Situ
|
||||||
|
|
||||||
if (data.reportId != null) {
|
if (data.reportId != null) {
|
||||||
if (finalImageFiles.isNotEmpty) {
|
if (finalImageFiles.isNotEmpty) {
|
||||||
|
// 2. Submit Images
|
||||||
apiImageResult = await _submissionApiService.submitMultipart(
|
apiImageResult = await _submissionApiService.submitMultipart(
|
||||||
moduleName: moduleName,
|
moduleName: moduleName,
|
||||||
endpoint: 'marine/manual/images',
|
endpoint: 'marine/manual/images', // Correct endpoint for In-Situ images
|
||||||
fields: {'man_id': data.reportId!},
|
fields: {'man_id': data.reportId!}, // Correct field key for In-Situ
|
||||||
files: finalImageFiles,
|
files: finalImageFiles,
|
||||||
);
|
);
|
||||||
if (apiImageResult['success'] != true) {
|
if (apiImageResult['success'] != true) {
|
||||||
@ -248,65 +259,92 @@ class MarineInSituSamplingService {
|
|||||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} on SessionExpiredException catch (_) {
|
// If apiDataResult['success'] is false, SubmissionApiService queued it.
|
||||||
debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin...");
|
|
||||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
|
||||||
|
|
||||||
if (reloginSuccess) {
|
} on SessionExpiredException catch (_) {
|
||||||
debugPrint("Silent relogin successful. Retrying entire online submission process...");
|
debugPrint("Online submission failed due to session expiry that could not be refreshed.");
|
||||||
return await _performOnlineSubmission(
|
|
||||||
data: data,
|
|
||||||
appSettings: appSettings,
|
|
||||||
moduleName: moduleName,
|
|
||||||
authProvider: authProvider,
|
|
||||||
logDirectory: logDirectory,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP.");
|
|
||||||
isSessionKnownToBeExpired = true;
|
isSessionKnownToBeExpired = true;
|
||||||
anyApiSuccess = false;
|
anyApiSuccess = false;
|
||||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
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());
|
// Manually queue API calls
|
||||||
}
|
|
||||||
} on SocketException catch (e) {
|
|
||||||
final errorMessage = "API submission failed with network error: $e";
|
|
||||||
debugPrint(errorMessage);
|
|
||||||
anyApiSuccess = false;
|
|
||||||
apiDataResult = {'success': false, 'message': errorMessage};
|
|
||||||
await _retryService.addApiToQueue(endpoint: 'marine/manual/sample', method: 'POST', body: data.toApiFormData());
|
await _retryService.addApiToQueue(endpoint: 'marine/manual/sample', method: 'POST', body: data.toApiFormData());
|
||||||
if (finalImageFiles.isNotEmpty && data.reportId != null) {
|
if (finalImageFiles.isNotEmpty && data.reportId != null) {
|
||||||
|
// Also queue images if data call might have partially succeeded before expiry
|
||||||
await _retryService.addApiToQueue(endpoint: 'marine/manual/images', method: 'POST_MULTIPART', fields: {'man_id': data.reportId!}, files: finalImageFiles);
|
await _retryService.addApiToQueue(endpoint: 'marine/manual/images', method: 'POST_MULTIPART', fields: {'man_id': data.reportId!}, files: finalImageFiles);
|
||||||
}
|
}
|
||||||
} on TimeoutException catch (e) {
|
|
||||||
final errorMessage = "API submission timed out: $e";
|
|
||||||
debugPrint(errorMessage);
|
|
||||||
anyApiSuccess = false;
|
|
||||||
apiDataResult = {'success': false, 'message': errorMessage};
|
|
||||||
await _retryService.addApiToQueue(endpoint: 'marine/manual/sample', method: 'POST', body: data.toApiFormData());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Submit FTP Files
|
||||||
Map<String, dynamic> ftpResults = {'statuses': []};
|
Map<String, dynamic> ftpResults = {'statuses': []};
|
||||||
bool anyFtpSuccess = false;
|
bool anyFtpSuccess = false;
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||||
} on SocketException catch (e) {
|
} catch (e) {
|
||||||
debugPrint("FTP submission failed with network error: $e");
|
debugPrint("Unexpected FTP submission error: $e");
|
||||||
anyFtpSuccess = false;
|
|
||||||
} on TimeoutException catch (e) {
|
|
||||||
debugPrint("FTP submission timed out: $e");
|
|
||||||
anyFtpSuccess = false;
|
anyFtpSuccess = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Determine Final Status
|
||||||
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
|
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
|
||||||
String finalMessage;
|
|
||||||
String finalStatus;
|
|
||||||
|
|
||||||
if (anyApiSuccess && anyFtpSuccess) {
|
if (anyApiSuccess && anyFtpSuccess) {
|
||||||
finalMessage = 'Data submitted successfully to all destinations.';
|
finalMessage = 'Data submitted successfully to all destinations.';
|
||||||
finalStatus = 'S4';
|
finalStatus = 'S4';
|
||||||
} else if (anyApiSuccess && !anyFtpSuccess) {
|
} else if (anyApiSuccess && !anyFtpSuccess) {
|
||||||
finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.';
|
finalMessage = 'Data sent to API, but some FTP uploads failed or were queued.';
|
||||||
finalStatus = 'S3';
|
finalStatus = 'S3';
|
||||||
} else if (!anyApiSuccess && anyFtpSuccess) {
|
} else if (!anyApiSuccess && anyFtpSuccess) {
|
||||||
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
|
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
|
||||||
@ -316,6 +354,7 @@ class MarineInSituSamplingService {
|
|||||||
finalStatus = 'L1';
|
finalStatus = 'L1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. Log Locally
|
||||||
await _logAndSave(
|
await _logAndSave(
|
||||||
data: data,
|
data: data,
|
||||||
status: finalStatus,
|
status: finalStatus,
|
||||||
@ -323,10 +362,11 @@ class MarineInSituSamplingService {
|
|||||||
apiResults: [apiDataResult, apiImageResult],
|
apiResults: [apiDataResult, apiImageResult],
|
||||||
ftpStatuses: ftpResults['statuses'],
|
ftpStatuses: ftpResults['statuses'],
|
||||||
serverName: serverName,
|
serverName: serverName,
|
||||||
finalImageFiles: finalImageFiles,
|
finalImageFiles: finalImageFiles, // Pass the map of actual files
|
||||||
logDirectory: logDirectory,
|
logDirectory: logDirectory,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 6. Send Alert
|
||||||
if (overallSuccess) {
|
if (overallSuccess) {
|
||||||
_handleInSituSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
_handleInSituSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
||||||
}
|
}
|
||||||
@ -334,55 +374,88 @@ class MarineInSituSamplingService {
|
|||||||
return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId};
|
return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handles queuing the submission data when the device is offline.
|
||||||
Future<Map<String, dynamic>> _performOfflineQueuing({
|
Future<Map<String, dynamic>> _performOfflineQueuing({
|
||||||
required InSituSamplingData data,
|
required InSituSamplingData data,
|
||||||
required String moduleName,
|
required String moduleName,
|
||||||
|
String? logDirectory, // Added for potential update
|
||||||
}) async {
|
}) async {
|
||||||
final serverConfig = await _serverConfigService.getActiveApiConfig();
|
final serverConfig = await _serverConfigService.getActiveApiConfig();
|
||||||
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
|
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
|
||||||
|
|
||||||
// Set initial status before first save
|
|
||||||
data.submissionStatus = 'L1';
|
data.submissionStatus = 'L1';
|
||||||
data.submissionMessage = 'Submission queued for later retry.';
|
data.submissionMessage = 'Submission queued for later retry.';
|
||||||
|
|
||||||
final String? localLogPath = await _localStorageService.saveInSituSamplingData(data, serverName: serverName);
|
String? savedLogPath = logDirectory; // Use existing path if provided
|
||||||
|
|
||||||
if (localLogPath == null) {
|
// Save/Update local log first
|
||||||
|
if (savedLogPath != null && savedLogPath.isNotEmpty) {
|
||||||
|
// Need to reconstruct the map with file paths for updating
|
||||||
|
Map<String, dynamic> logUpdateData = data.toDbJson();
|
||||||
|
final imageFiles = data.toApiImageFiles();
|
||||||
|
imageFiles.forEach((key, file) {
|
||||||
|
logUpdateData[key] = file?.path; // Add paths back
|
||||||
|
});
|
||||||
|
logUpdateData['logDirectory'] = savedLogPath;
|
||||||
|
await _localStorageService.updateInSituLog(logUpdateData);
|
||||||
|
debugPrint("Updated existing In-Situ log for queuing: $savedLogPath");
|
||||||
|
} else {
|
||||||
|
savedLogPath = await _localStorageService.saveInSituSamplingData(data, serverName: serverName);
|
||||||
|
debugPrint("Saved new In-Situ log for queuing: $savedLogPath");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (savedLogPath == null) {
|
||||||
const message = "Failed to save submission to local device storage.";
|
const message = "Failed to save submission to local device storage.";
|
||||||
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {});
|
// Use empty map for finalImageFiles as saving failed
|
||||||
|
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}, logDirectory: logDirectory);
|
||||||
return {'success': false, 'message': message};
|
return {'success': false, 'message': message};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the correct task type for In-Situ retry
|
||||||
await _retryService.queueTask(
|
await _retryService.queueTask(
|
||||||
type: 'insitu_submission',
|
type: 'insitu_submission',
|
||||||
payload: {
|
payload: {
|
||||||
'module': moduleName,
|
'module': moduleName,
|
||||||
'localLogPath': localLogPath,
|
'localLogPath': savedLogPath, // Pass directory path
|
||||||
'serverConfig': serverConfig,
|
'serverConfig': serverConfig,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const successMessage = "Submission failed to send and has been queued for later retry.";
|
const successMessage = "Device offline. Submission has been saved locally and queued for automatic retry when connection is restored.";
|
||||||
return {'success': true, 'message': successMessage};
|
// Log final queued state to central DB
|
||||||
|
// await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}, logDirectory: savedLogPath);
|
||||||
|
|
||||||
|
return {'success': true, 'message': successMessage, 'reportId': null}; // No report ID yet
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(InSituSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
|
/// Helper to generate the base filename for ZIP files.
|
||||||
|
String _generateBaseFileName(InSituSamplingData data) {
|
||||||
final stationCode = data.selectedStation?['man_station_code'] ?? 'NA';
|
final stationCode = data.selectedStation?['man_station_code'] ?? 'NA';
|
||||||
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
|
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
|
||||||
final baseFileName = '${stationCode}_$fileTimestamp';
|
return '${stationCode}_$fileTimestamp';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates data and image ZIP files and uploads them using SubmissionFtpService.
|
||||||
|
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(InSituSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
|
||||||
|
final baseFileName = _generateBaseFileName(data);
|
||||||
|
|
||||||
final Directory? logDirectory = await _localStorageService.getLogDirectory(
|
final Directory? logDirectory = await _localStorageService.getLogDirectory(
|
||||||
serverName: serverName,
|
serverName: serverName,
|
||||||
module: 'marine',
|
module: 'marine',
|
||||||
subModule: 'marine_in_situ_sampling',
|
subModule: 'marine_in_situ_sampling', // Correct sub-module path
|
||||||
);
|
);
|
||||||
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null;
|
final folderName = data.reportId ?? baseFileName;
|
||||||
|
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null;
|
||||||
|
|
||||||
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
|
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
|
||||||
await localSubmissionDir.create(recursive: true);
|
await localSubmissionDir.create(recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create and upload data ZIP
|
||||||
final dataZip = await _zippingService.createDataZip(
|
final dataZip = await _zippingService.createDataZip(
|
||||||
jsonDataMap: {'db.json': jsonEncode(data.toDbJson())},
|
jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, // Use toDbJson for FTP
|
||||||
baseFileName: baseFileName,
|
baseFileName: baseFileName,
|
||||||
destinationDir: localSubmissionDir,
|
destinationDir: localSubmissionDir,
|
||||||
);
|
);
|
||||||
@ -395,6 +468,7 @@ class MarineInSituSamplingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create and upload image ZIP
|
||||||
final imageZip = await _zippingService.createImageZip(
|
final imageZip = await _zippingService.createImageZip(
|
||||||
imageFiles: imageFiles.values.toList(),
|
imageFiles: imageFiles.values.toList(),
|
||||||
baseFileName: baseFileName,
|
baseFileName: baseFileName,
|
||||||
@ -411,12 +485,13 @@ class MarineInSituSamplingService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'statuses': <Map<String, dynamic>>[
|
'statuses': <Map<String, dynamic>>[
|
||||||
...(ftpDataResult['statuses'] as List<dynamic>? ?? []),
|
...(ftpDataResult['statuses'] as List? ?? []), // Use null-aware spread
|
||||||
...(ftpImageResult['statuses'] as List<dynamic>? ?? []),
|
...(ftpImageResult['statuses'] as List? ?? []), // Use null-aware spread
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Saves or updates the local log file and saves a record to the central DB log.
|
||||||
Future<void> _logAndSave({
|
Future<void> _logAndSave({
|
||||||
required InSituSamplingData data,
|
required InSituSamplingData data,
|
||||||
required String status,
|
required String status,
|
||||||
@ -424,64 +499,110 @@ class MarineInSituSamplingService {
|
|||||||
required List<Map<String, dynamic>> apiResults,
|
required List<Map<String, dynamic>> apiResults,
|
||||||
required List<Map<String, dynamic>> ftpStatuses,
|
required List<Map<String, dynamic>> ftpStatuses,
|
||||||
required String serverName,
|
required String serverName,
|
||||||
required Map<String, File> finalImageFiles,
|
required Map<String, File> finalImageFiles, // Changed to Map<String, File>
|
||||||
String? logDirectory,
|
String? logDirectory,
|
||||||
}) async {
|
}) async {
|
||||||
data.submissionStatus = status;
|
data.submissionStatus = status;
|
||||||
data.submissionMessage = message;
|
data.submissionMessage = message;
|
||||||
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
|
final baseFileName = _generateBaseFileName(data); // Use helper
|
||||||
|
|
||||||
if (logDirectory != null) {
|
// Prepare log data map including file paths
|
||||||
final Map<String, dynamic> updatedLogData = data.toDbJson();
|
Map<String, dynamic> logMapData = data.toDbJson();
|
||||||
|
final imageFileMap = data.toApiImageFiles();
|
||||||
updatedLogData['submissionStatus'] = status;
|
imageFileMap.forEach((key, file) {
|
||||||
updatedLogData['submissionMessage'] = message;
|
logMapData[key] = file?.path; // Store path or null
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
// 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 {
|
} else {
|
||||||
|
// Save new log - saveInSituSamplingData handles adding file paths
|
||||||
await _localStorageService.saveInSituSamplingData(data, serverName: serverName);
|
await _localStorageService.saveInSituSamplingData(data, serverName: serverName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Save to central DB log
|
||||||
final logData = {
|
final logData = {
|
||||||
'submission_id': data.reportId ?? fileTimestamp,
|
'submission_id': data.reportId ?? baseFileName, // Use helper result
|
||||||
'module': 'marine',
|
'module': 'marine',
|
||||||
'type': 'In-Situ',
|
'type': 'In-Situ', // Correct type
|
||||||
'status': data.submissionStatus,
|
'status': status,
|
||||||
'message': data.submissionMessage,
|
'message': message,
|
||||||
'report_id': data.reportId,
|
'report_id': data.reportId,
|
||||||
'created_at': DateTime.now().toIso8601String(),
|
'created_at': DateTime.now().toIso8601String(),
|
||||||
'form_data': jsonEncode(data.toDbJson()),
|
'form_data': jsonEncode(logMapData), // Log the comprehensive map with paths
|
||||||
'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()),
|
'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()), // List of paths for files actually submitted/zipped
|
||||||
'server_name': serverName,
|
'server_name': serverName,
|
||||||
'api_status': jsonEncode(apiResults),
|
'api_status': jsonEncode(apiResults),
|
||||||
'ftp_status': jsonEncode(ftpStatuses),
|
'ftp_status': jsonEncode(ftpStatuses),
|
||||||
};
|
};
|
||||||
|
try {
|
||||||
await _dbHelper.saveSubmissionLog(logData);
|
await _dbHelper.saveSubmissionLog(logData);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error saving In-Situ submission log to DB: $e");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handles sending or queuing the Telegram alert for In-Situ submissions.
|
||||||
Future<void> _handleInSituSuccessAlert(InSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
|
Future<void> _handleInSituSuccessAlert(InSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
|
||||||
|
|
||||||
|
// --- START: Logic moved from data model ---
|
||||||
|
String generateInSituTelegramAlertMessage(InSituSamplingData data, {required bool isDataOnly}) {
|
||||||
|
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||||
|
final stationName = data.selectedStation?['man_station_name'] ?? 'N/A';
|
||||||
|
final stationCode = data.selectedStation?['man_station_code'] ?? 'N/A';
|
||||||
|
|
||||||
|
final buffer = StringBuffer()
|
||||||
|
..writeln('✅ *In-Situ Sample $submissionType Submitted:*')
|
||||||
|
..writeln()
|
||||||
|
..writeln('*Station Name & Code:* $stationName ($stationCode)')
|
||||||
|
..writeln('*Date of Submission:* ${data.samplingDate}')
|
||||||
|
..writeln('*Submitted by User:* ${data.firstSamplerName}')
|
||||||
|
..writeln('*Sonde ID:* ${data.sondeId ?? "N/A"}')
|
||||||
|
..writeln('*Status of Submission:* Successful');
|
||||||
|
|
||||||
|
final distanceKm = data.distanceDifferenceInKm ?? 0;
|
||||||
|
final distanceRemarks = data.distanceDifferenceRemarks ?? '';
|
||||||
|
if (distanceKm * 1000 > 50) { // Check distance > 50m
|
||||||
|
buffer
|
||||||
|
..writeln()
|
||||||
|
..writeln('🔔 *Distance Alert:*')
|
||||||
|
..writeln('*Distance from station:* ${(distanceKm * 1000).toStringAsFixed(0)} meters');
|
||||||
|
|
||||||
|
if (distanceRemarks.isNotEmpty) {
|
||||||
|
buffer.writeln('*Remarks for distance:* $distanceRemarks');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: The logic to check parameter limits requires async DB access,
|
||||||
|
// which cannot be done directly here without further refactoring.
|
||||||
|
// This part is omitted for now as per the previous refactor.
|
||||||
|
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
// --- END: Logic moved from data model ---
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly);
|
final message = generateInSituTelegramAlertMessage(data, isDataOnly: isDataOnly); // Call local function
|
||||||
|
final alertKey = 'marine_in_situ'; // Correct key
|
||||||
|
|
||||||
if (isSessionExpired) {
|
if (isSessionExpired) {
|
||||||
debugPrint("Session is expired; queuing Telegram alert directly.");
|
debugPrint("Session is expired; queuing Telegram alert directly for $alertKey.");
|
||||||
await _telegramService.queueMessage('marine_in_situ', message, appSettings);
|
await _telegramService.queueMessage(alertKey, message, appSettings);
|
||||||
} else {
|
} else {
|
||||||
final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings);
|
final bool wasSent = await _telegramService.sendAlertImmediately(alertKey, message, appSettings);
|
||||||
if (!wasSent) {
|
if (!wasSent) {
|
||||||
await _telegramService.queueMessage('marine_in_situ', message, appSettings);
|
await _telegramService.queueMessage(alertKey, message, appSettings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
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({
|
Future<Map<String, dynamic>> submitTarballSample({
|
||||||
required TarballSamplingData data,
|
required TarballSamplingData data,
|
||||||
required List<Map<String, dynamic>>? appSettings,
|
required List<Map<String, dynamic>>? appSettings,
|
||||||
required BuildContext context,
|
// --- START FIX: Make BuildContext nullable ---
|
||||||
|
required BuildContext? context,
|
||||||
|
// --- END FIX ---
|
||||||
|
String? logDirectory, // Added for retry consistency
|
||||||
}) async {
|
}) async {
|
||||||
const String moduleName = 'marine_tarball';
|
const String moduleName = 'marine_tarball';
|
||||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
// --- START FIX: Handle nullable context ---
|
||||||
|
final authProvider = context != null ? Provider.of<AuthProvider>(context, listen: false) : null;
|
||||||
|
// Need a fallback mechanism if context is null (e.g., during retry)
|
||||||
|
// One option is to ensure AuthProvider is always accessible, maybe via a singleton or passed differently.
|
||||||
|
// For now, we'll proceed assuming authProvider might be null during retry,
|
||||||
|
// which could affect session checks. Consider injecting AuthProvider if needed globally.
|
||||||
|
if (authProvider == null && context != null) {
|
||||||
|
// If context was provided but provider failed, log error
|
||||||
|
debugPrint("Error: AuthProvider not found in context for Tarball submission.");
|
||||||
|
return {'success': false, 'message': 'Internal error: AuthProvider not available.'};
|
||||||
|
}
|
||||||
|
// --- END FIX ---
|
||||||
|
|
||||||
final connectivityResult = await Connectivity().checkConnectivity();
|
final connectivityResult = await Connectivity().checkConnectivity();
|
||||||
bool isOnline = connectivityResult != ConnectivityResult.none;
|
bool isOnline = !connectivityResult.contains(ConnectivityResult.none);
|
||||||
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
|
// --- START FIX: Handle potentially null authProvider ---
|
||||||
|
bool isOfflineSession = authProvider != null && authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
|
||||||
|
|
||||||
if (isOnline && isOfflineSession) {
|
if (isOnline && isOfflineSession && authProvider != null) {
|
||||||
debugPrint("Submission initiated online during an offline session. Attempting auto-relogin...");
|
debugPrint("Tarball submission online during an offline session. Attempting auto-relogin...");
|
||||||
|
try {
|
||||||
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
|
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
|
||||||
if (transitionSuccess) {
|
if (transitionSuccess) {
|
||||||
isOfflineSession = false;
|
isOfflineSession = false;
|
||||||
} else {
|
} else {
|
||||||
|
isOnline = false; // Auto-relogin failed, treat as offline
|
||||||
|
}
|
||||||
|
} on SessionExpiredException catch (_) {
|
||||||
|
debugPrint("Session expired during auto-relogin check. Treating as offline.");
|
||||||
isOnline = false;
|
isOnline = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// --- END FIX ---
|
||||||
|
|
||||||
|
|
||||||
if (isOnline && !isOfflineSession) {
|
if (isOnline && !isOfflineSession) {
|
||||||
debugPrint("Proceeding with direct ONLINE submission...");
|
debugPrint("Proceeding with direct ONLINE Tarball submission...");
|
||||||
return await _performOnlineSubmission(
|
return await _performOnlineSubmission(
|
||||||
data: data,
|
data: data,
|
||||||
appSettings: appSettings,
|
appSettings: appSettings,
|
||||||
moduleName: moduleName,
|
moduleName: moduleName,
|
||||||
authProvider: authProvider,
|
authProvider: authProvider, // Pass potentially null provider
|
||||||
|
logDirectory: logDirectory,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
debugPrint("Proceeding with OFFLINE queuing mechanism...");
|
debugPrint("Proceeding with OFFLINE Tarball queuing mechanism...");
|
||||||
return await _performOfflineQueuing(
|
return await _performOfflineQueuing(
|
||||||
data: data,
|
data: data,
|
||||||
moduleName: moduleName,
|
moduleName: moduleName,
|
||||||
|
logDirectory: logDirectory, // Pass logDirectory for potential update
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -77,7 +101,8 @@ class MarineTarballSamplingService {
|
|||||||
required TarballSamplingData data,
|
required TarballSamplingData data,
|
||||||
required List<Map<String, dynamic>>? appSettings,
|
required List<Map<String, dynamic>>? appSettings,
|
||||||
required String moduleName,
|
required String moduleName,
|
||||||
required AuthProvider authProvider,
|
required AuthProvider? authProvider, // Accept potentially null provider
|
||||||
|
String? logDirectory, // Added for retry consistency
|
||||||
}) async {
|
}) async {
|
||||||
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
|
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
|
||||||
final imageFiles = data.toImageFiles()..removeWhere((key, value) => value == null);
|
final imageFiles = data.toImageFiles()..removeWhere((key, value) => value == null);
|
||||||
@ -86,92 +111,133 @@ class MarineTarballSamplingService {
|
|||||||
bool anyApiSuccess = false;
|
bool anyApiSuccess = false;
|
||||||
Map<String, dynamic> apiDataResult = {};
|
Map<String, dynamic> apiDataResult = {};
|
||||||
Map<String, dynamic> apiImageResult = {};
|
Map<String, dynamic> apiImageResult = {};
|
||||||
|
String finalMessage = '';
|
||||||
|
String finalStatus = '';
|
||||||
bool isSessionKnownToBeExpired = false;
|
bool isSessionKnownToBeExpired = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 1. Submit Form Data
|
||||||
apiDataResult = await _submissionApiService.submitPost(
|
apiDataResult = await _submissionApiService.submitPost(
|
||||||
moduleName: moduleName,
|
moduleName: moduleName,
|
||||||
endpoint: 'marine/tarball/sample',
|
endpoint: 'marine/tarball/sample', // Correct endpoint
|
||||||
body: data.toFormData(),
|
body: data.toFormData(), // Use specific method for tarball form data
|
||||||
);
|
);
|
||||||
|
|
||||||
if (apiDataResult['success'] == true) {
|
if (apiDataResult['success'] == true) {
|
||||||
anyApiSuccess = true;
|
anyApiSuccess = true;
|
||||||
data.reportId = apiDataResult['data']?['autoid']?.toString();
|
data.reportId = apiDataResult['data']?['autoid']?.toString(); // Correct ID key
|
||||||
|
|
||||||
if (data.reportId != null) {
|
if (data.reportId != null) {
|
||||||
|
if (finalImageFiles.isNotEmpty) {
|
||||||
|
// 2. Submit Images
|
||||||
apiImageResult = await _submissionApiService.submitMultipart(
|
apiImageResult = await _submissionApiService.submitMultipart(
|
||||||
moduleName: moduleName,
|
moduleName: moduleName,
|
||||||
endpoint: 'marine/tarball/images',
|
endpoint: 'marine/tarball/images', // Correct endpoint
|
||||||
fields: {'autoid': data.reportId!},
|
fields: {'autoid': data.reportId!}, // Correct field key
|
||||||
files: finalImageFiles,
|
files: finalImageFiles,
|
||||||
);
|
);
|
||||||
if (apiImageResult['success'] != true) {
|
if (apiImageResult['success'] != true) {
|
||||||
anyApiSuccess = false; // Downgrade success if images fail
|
anyApiSuccess = false; // Downgrade success if images fail
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// If data succeeded but no images, API part is still successful
|
||||||
} else {
|
} else {
|
||||||
anyApiSuccess = false;
|
anyApiSuccess = false;
|
||||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} on SessionExpiredException catch (_) {
|
// If apiDataResult['success'] is false, SubmissionApiService queued it.
|
||||||
debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin...");
|
|
||||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
|
||||||
|
|
||||||
if (reloginSuccess) {
|
} on SessionExpiredException catch (_) {
|
||||||
debugPrint("Silent relogin successful. Retrying entire online submission process...");
|
debugPrint("API submission failed with SessionExpiredException during online submission.");
|
||||||
return await _performOnlineSubmission(
|
|
||||||
data: data,
|
|
||||||
appSettings: appSettings,
|
|
||||||
moduleName: moduleName,
|
|
||||||
authProvider: authProvider,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP.");
|
|
||||||
isSessionKnownToBeExpired = true;
|
isSessionKnownToBeExpired = true;
|
||||||
anyApiSuccess = false;
|
anyApiSuccess = false;
|
||||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
apiDataResult = {'success': false, 'message': 'Session expired. API submission queued.'};
|
||||||
await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData());
|
// Manually queue API calls
|
||||||
}
|
|
||||||
} on SocketException catch (e) {
|
|
||||||
final errorMessage = "API submission failed with network error: $e";
|
|
||||||
debugPrint(errorMessage);
|
|
||||||
anyApiSuccess = false;
|
|
||||||
apiDataResult = {'success': false, 'message': errorMessage};
|
|
||||||
await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData());
|
await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData());
|
||||||
if (finalImageFiles.isNotEmpty && data.reportId != null) {
|
if (finalImageFiles.isNotEmpty && data.reportId != null) {
|
||||||
|
// Queue images if data might have partially succeeded
|
||||||
await _retryService.addApiToQueue(endpoint: 'marine/tarball/images', method: 'POST_MULTIPART', fields: {'autoid': data.reportId!}, files: finalImageFiles);
|
await _retryService.addApiToQueue(endpoint: 'marine/tarball/images', method: 'POST_MULTIPART', fields: {'autoid': data.reportId!}, files: finalImageFiles);
|
||||||
}
|
}
|
||||||
} on TimeoutException catch (e) {
|
|
||||||
final errorMessage = "API submission timed out: $e";
|
|
||||||
debugPrint(errorMessage);
|
|
||||||
anyApiSuccess = false;
|
|
||||||
apiDataResult = {'success': false, 'message': errorMessage};
|
|
||||||
await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Submit FTP Files
|
||||||
Map<String, dynamic> ftpResults = {'statuses': []};
|
Map<String, dynamic> ftpResults = {'statuses': []};
|
||||||
bool anyFtpSuccess = false;
|
bool anyFtpSuccess = false;
|
||||||
try {
|
|
||||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
if (isSessionKnownToBeExpired) {
|
||||||
anyFtpSuccess = !ftpResults['statuses'].any((status) => status['success'] == false);
|
debugPrint("Skipping FTP attempt due to known expired session. Manually queuing FTP tasks.");
|
||||||
} on SocketException catch (e) {
|
final baseFileNameForQueue = _generateBaseFileName(data); // Use helper
|
||||||
debugPrint("FTP submission failed with network error: $e");
|
|
||||||
anyFtpSuccess = false;
|
// --- START FIX: Add ftpConfigId when queuing ---
|
||||||
} on TimeoutException catch (e) {
|
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||||
debugPrint("FTP submission timed out: $e");
|
|
||||||
anyFtpSuccess = false;
|
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;
|
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
|
||||||
String finalMessage;
|
|
||||||
String finalStatus;
|
|
||||||
|
|
||||||
if (anyApiSuccess && anyFtpSuccess) {
|
if (anyApiSuccess && anyFtpSuccess) {
|
||||||
finalMessage = 'Data submitted successfully to all destinations.';
|
finalMessage = 'Data submitted successfully to all destinations.';
|
||||||
finalStatus = 'S4';
|
finalStatus = 'S4';
|
||||||
} else if (anyApiSuccess && !anyFtpSuccess) {
|
} else if (anyApiSuccess && !anyFtpSuccess) {
|
||||||
finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.';
|
finalMessage = 'Data sent to API, but some FTP uploads failed or were queued.';
|
||||||
finalStatus = 'S3';
|
finalStatus = 'S3';
|
||||||
} else if (!anyApiSuccess && anyFtpSuccess) {
|
} else if (!anyApiSuccess && anyFtpSuccess) {
|
||||||
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
|
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
|
||||||
@ -181,6 +247,7 @@ class MarineTarballSamplingService {
|
|||||||
finalStatus = 'L1';
|
finalStatus = 'L1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. Log Locally
|
||||||
await _logAndSave(
|
await _logAndSave(
|
||||||
data: data,
|
data: data,
|
||||||
status: finalStatus,
|
status: finalStatus,
|
||||||
@ -189,8 +256,10 @@ class MarineTarballSamplingService {
|
|||||||
ftpStatuses: ftpResults['statuses'],
|
ftpStatuses: ftpResults['statuses'],
|
||||||
serverName: serverName,
|
serverName: serverName,
|
||||||
finalImageFiles: finalImageFiles,
|
finalImageFiles: finalImageFiles,
|
||||||
|
logDirectory: logDirectory, // Pass logDirectory for potential update
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 6. Send Alert
|
||||||
if (overallSuccess) {
|
if (overallSuccess) {
|
||||||
_handleTarballSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
_handleTarballSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
||||||
}
|
}
|
||||||
@ -201,50 +270,82 @@ class MarineTarballSamplingService {
|
|||||||
Future<Map<String, dynamic>> _performOfflineQueuing({
|
Future<Map<String, dynamic>> _performOfflineQueuing({
|
||||||
required TarballSamplingData data,
|
required TarballSamplingData data,
|
||||||
required String moduleName,
|
required String moduleName,
|
||||||
|
String? logDirectory, // Added for potential update
|
||||||
}) async {
|
}) async {
|
||||||
final serverConfig = await _serverConfigService.getActiveApiConfig();
|
final serverConfig = await _serverConfigService.getActiveApiConfig();
|
||||||
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
|
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
|
||||||
|
|
||||||
final String? localLogPath = await _localStorageService.saveTarballSamplingData(data, serverName: serverName);
|
// Set status before saving/updating
|
||||||
|
data.submissionStatus = 'L1'; // Logged locally or Queued
|
||||||
|
data.submissionMessage = 'Submission queued for later retry.';
|
||||||
|
|
||||||
if (localLogPath == null) {
|
String? savedLogPath = logDirectory; // Use existing path if provided
|
||||||
|
|
||||||
|
// Save/Update local log first
|
||||||
|
if (savedLogPath != null && savedLogPath.isNotEmpty) {
|
||||||
|
await _localStorageService.updateTarballLog(data.toDbJson()..['logDirectory'] = savedLogPath);
|
||||||
|
debugPrint("Updated existing Tarball log for queuing: $savedLogPath");
|
||||||
|
} else {
|
||||||
|
savedLogPath = await _localStorageService.saveTarballSamplingData(data, serverName: serverName);
|
||||||
|
debugPrint("Saved new Tarball log for queuing: $savedLogPath");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (savedLogPath == null) {
|
||||||
const message = "Failed to save submission to local device storage.";
|
const message = "Failed to save submission to local device storage.";
|
||||||
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {});
|
// Log failure state if saving fails
|
||||||
|
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}, logDirectory: logDirectory);
|
||||||
return {'success': false, 'message': message};
|
return {'success': false, 'message': message};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Queue a single task for the RetryService
|
||||||
await _retryService.queueTask(
|
await _retryService.queueTask(
|
||||||
type: 'tarball_submission',
|
type: 'tarball_submission', // Use specific type
|
||||||
payload: {
|
payload: {
|
||||||
'module': moduleName,
|
'module': moduleName,
|
||||||
'localLogPath': localLogPath,
|
'localLogPath': savedLogPath, // Point retry service to the saved log *directory*
|
||||||
'serverConfig': serverConfig,
|
'serverConfig': serverConfig,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const successMessage = "Submission failed to send and has been queued for later retry.";
|
const successMessage = "Device offline. Submission has been saved locally and queued for automatic retry when connection is restored.";
|
||||||
await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {});
|
// Log final queued state to central DB
|
||||||
|
// await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {}, logDirectory: savedLogPath);
|
||||||
|
|
||||||
return {'success': true, 'message': successMessage};
|
return {'success': true, 'message': successMessage, 'reportId': null}; // No report ID yet
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(TarballSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
|
/// Helper to generate the base filename for ZIP files.
|
||||||
|
String _generateBaseFileName(TarballSamplingData data) {
|
||||||
final stationCode = data.selectedStation?['tbl_station_code'] ?? 'NA';
|
final stationCode = data.selectedStation?['tbl_station_code'] ?? 'NA';
|
||||||
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
|
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
|
||||||
final baseFileName = '${stationCode}_$fileTimestamp';
|
return '${stationCode}_$fileTimestamp';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates data and image ZIP files and uploads them using SubmissionFtpService.
|
||||||
|
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(TarballSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
|
||||||
|
final baseFileName = _generateBaseFileName(data);
|
||||||
|
|
||||||
final Directory? logDirectory = await _localStorageService.getLogDirectory(
|
final Directory? logDirectory = await _localStorageService.getLogDirectory(
|
||||||
serverName: serverName,
|
serverName: serverName,
|
||||||
module: 'marine',
|
module: 'marine',
|
||||||
subModule: 'marine_tarball_sampling',
|
subModule: 'marine_tarball_sampling', // Correct sub-module
|
||||||
);
|
);
|
||||||
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null;
|
final folderName = data.reportId ?? baseFileName;
|
||||||
|
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null;
|
||||||
|
|
||||||
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
|
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
|
||||||
await localSubmissionDir.create(recursive: true);
|
await localSubmissionDir.create(recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create and upload data ZIP (with multiple JSON files specific to Tarball)
|
||||||
final dataZip = await _zippingService.createDataZip(
|
final dataZip = await _zippingService.createDataZip(
|
||||||
jsonDataMap: {'data.json': jsonEncode(data.toDbJson())},
|
jsonDataMap: {
|
||||||
|
'data.json': jsonEncode(data.toDbJson()), // Use specific method
|
||||||
|
'basic_form.json': jsonEncode(data.toBasicFormJson()), // Use specific method
|
||||||
|
'reading.json': jsonEncode(data.toReadingJson()), // Use specific method
|
||||||
|
'manual_info.json': jsonEncode(data.toManualInfoJson()), // Use specific method
|
||||||
|
},
|
||||||
baseFileName: baseFileName,
|
baseFileName: baseFileName,
|
||||||
destinationDir: localSubmissionDir,
|
destinationDir: localSubmissionDir,
|
||||||
);
|
);
|
||||||
@ -257,6 +358,7 @@ class MarineTarballSamplingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create and upload image ZIP
|
||||||
final imageZip = await _zippingService.createImageZip(
|
final imageZip = await _zippingService.createImageZip(
|
||||||
imageFiles: imageFiles.values.toList(),
|
imageFiles: imageFiles.values.toList(),
|
||||||
baseFileName: baseFileName,
|
baseFileName: baseFileName,
|
||||||
@ -273,12 +375,13 @@ class MarineTarballSamplingService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'statuses': <Map<String, dynamic>>[
|
'statuses': <Map<String, dynamic>>[
|
||||||
...?(ftpDataResult['statuses'] as List?),
|
...(ftpDataResult['statuses'] as List? ?? []),
|
||||||
...?(ftpImageResult['statuses'] as List?),
|
...(ftpImageResult['statuses'] as List? ?? []),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Saves or updates the local log file and saves a record to the central DB log.
|
||||||
Future<void> _logAndSave({
|
Future<void> _logAndSave({
|
||||||
required TarballSamplingData data,
|
required TarballSamplingData data,
|
||||||
required String status,
|
required String status,
|
||||||
@ -287,40 +390,103 @@ class MarineTarballSamplingService {
|
|||||||
required List<Map<String, dynamic>> ftpStatuses,
|
required List<Map<String, dynamic>> ftpStatuses,
|
||||||
required String serverName,
|
required String serverName,
|
||||||
required Map<String, File> finalImageFiles,
|
required Map<String, File> finalImageFiles,
|
||||||
|
String? logDirectory, // Added for potential update
|
||||||
}) async {
|
}) async {
|
||||||
data.submissionStatus = status;
|
data.submissionStatus = status;
|
||||||
data.submissionMessage = message;
|
data.submissionMessage = message;
|
||||||
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
|
final baseFileName = _generateBaseFileName(data); // Use helper
|
||||||
|
|
||||||
await _localStorageService.saveTarballSamplingData(data, serverName: serverName);
|
// Prepare log data map including file paths
|
||||||
|
Map<String, dynamic> logMapData = data.toDbJson();
|
||||||
|
final imageFileMap = data.toImageFiles();
|
||||||
|
imageFileMap.forEach((key, file) {
|
||||||
|
logMapData[key] = file?.path; // Store path or null
|
||||||
|
});
|
||||||
|
// Add submission metadata
|
||||||
|
logMapData['submissionStatus'] = status;
|
||||||
|
logMapData['submissionMessage'] = message;
|
||||||
|
logMapData['reportId'] = data.reportId;
|
||||||
|
logMapData['serverConfigName'] = serverName;
|
||||||
|
logMapData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
|
||||||
|
logMapData['ftp_status'] = jsonEncode(ftpStatuses);
|
||||||
|
|
||||||
|
// Update or save the specific local JSON log file
|
||||||
|
if (logDirectory != null && logDirectory.isNotEmpty) {
|
||||||
|
logMapData['logDirectory'] = logDirectory; // Ensure logDirectory path is in the map
|
||||||
|
await _localStorageService.updateTarballLog(logMapData); // Use specific update method
|
||||||
|
} else {
|
||||||
|
await _localStorageService.saveTarballSamplingData(data, serverName: serverName); // Use specific save method
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save a record to the central SQLite submission log table
|
||||||
final logData = {
|
final logData = {
|
||||||
'submission_id': data.reportId ?? fileTimestamp,
|
'submission_id': data.reportId ?? baseFileName, // Use helper result
|
||||||
'module': 'marine',
|
'module': 'marine', // Correct module
|
||||||
'type': 'Tarball',
|
'type': 'Tarball', // Correct type
|
||||||
'status': status,
|
'status': status,
|
||||||
'message': message,
|
'message': message,
|
||||||
'report_id': data.reportId,
|
'report_id': data.reportId,
|
||||||
'created_at': DateTime.now().toIso8601String(),
|
'created_at': DateTime.now().toIso8601String(),
|
||||||
'form_data': jsonEncode(data.toDbJson()),
|
'form_data': jsonEncode(logMapData), // Log the comprehensive map with paths
|
||||||
'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()),
|
'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()),
|
||||||
'server_name': serverName,
|
'server_name': serverName,
|
||||||
'api_status': jsonEncode(apiResults),
|
'api_status': jsonEncode(apiResults),
|
||||||
'ftp_status': jsonEncode(ftpStatuses),
|
'ftp_status': jsonEncode(ftpStatuses),
|
||||||
};
|
};
|
||||||
|
try {
|
||||||
await _dbHelper.saveSubmissionLog(logData);
|
await _dbHelper.saveSubmissionLog(logData);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error saving Tarball submission log to DB: $e");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles sending or queuing the Telegram alert for Tarball submissions.
|
||||||
Future<void> _handleTarballSuccessAlert(TarballSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
|
Future<void> _handleTarballSuccessAlert(TarballSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
|
||||||
|
|
||||||
|
// --- START: Logic moved from data model ---
|
||||||
|
String generateTarballTelegramAlertMessage(TarballSamplingData data, {required bool isDataOnly}) {
|
||||||
|
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||||
|
final stationName = data.selectedStation?['tbl_station_name'] ?? 'N/A';
|
||||||
|
final stationCode = data.selectedStation?['tbl_station_code'] ?? 'N/A';
|
||||||
|
final classification = data.selectedClassification?['classification_name'] ?? data.classificationId?.toString() ?? 'N/A';
|
||||||
|
|
||||||
|
final buffer = StringBuffer()
|
||||||
|
..writeln('✅ *Tarball Sample $submissionType Submitted:*')
|
||||||
|
..writeln()
|
||||||
|
..writeln('*Station Name & Code:* $stationName ($stationCode)')
|
||||||
|
..writeln('*Date of Submission:* ${data.samplingDate}')
|
||||||
|
..writeln('*Submitted by User:* ${data.firstSampler}') // Use firstSampler from data model
|
||||||
|
..writeln('*Classification:* $classification')
|
||||||
|
..writeln('*Status of Submission:* Successful');
|
||||||
|
|
||||||
|
final distanceKm = data.distanceDifference ?? 0; // Use distanceDifference from data model
|
||||||
|
final distanceRemarks = data.distanceDifferenceRemarks ?? '';
|
||||||
|
if (distanceKm * 1000 > 50) { // Check distance > 50m
|
||||||
|
buffer
|
||||||
|
..writeln()
|
||||||
|
..writeln('🔔 *Distance Alert:*')
|
||||||
|
..writeln('*Distance from station:* ${(distanceKm * 1000).toStringAsFixed(0)} meters');
|
||||||
|
|
||||||
|
if (distanceRemarks.isNotEmpty) {
|
||||||
|
buffer.writeln('*Remarks for distance:* $distanceRemarks');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
// --- END: Logic moved from data model ---
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly);
|
final message = generateTarballTelegramAlertMessage(data, isDataOnly: isDataOnly); // Call local function
|
||||||
|
final alertKey = 'marine_tarball'; // Correct key
|
||||||
|
|
||||||
if (isSessionExpired) {
|
if (isSessionExpired) {
|
||||||
debugPrint("Session is expired; queuing Telegram alert directly.");
|
debugPrint("Session is expired; queuing Telegram alert directly for $alertKey.");
|
||||||
await _telegramService.queueMessage('marine_tarball', message, appSettings);
|
await _telegramService.queueMessage(alertKey, message, appSettings);
|
||||||
} else {
|
} else {
|
||||||
final bool wasSent = await _telegramService.sendAlertImmediately('marine_tarball', message, appSettings);
|
final bool wasSent = await _telegramService.sendAlertImmediately(alertKey, message, appSettings);
|
||||||
if (!wasSent) {
|
if (!wasSent) {
|
||||||
await _telegramService.queueMessage('marine_tarball', message, appSettings);
|
await _telegramService.queueMessage(alertKey, message, appSettings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@ -7,8 +7,16 @@ import 'package:path/path.dart' as p;
|
|||||||
|
|
||||||
import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart';
|
import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart';
|
||||||
import 'package:environment_monitoring_app/services/marine_in_situ_sampling_service.dart';
|
import 'package:environment_monitoring_app/services/marine_in_situ_sampling_service.dart';
|
||||||
import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.dart'; // ADDED
|
import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.dart';
|
||||||
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart'; // ADDED
|
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart';
|
||||||
|
// *** ADDED: Import River Investigative Model and Service ***
|
||||||
|
import 'package:environment_monitoring_app/models/river_inves_manual_sampling_data.dart';
|
||||||
|
import 'package:environment_monitoring_app/services/river_investigative_sampling_service.dart';
|
||||||
|
// *** END ADDED ***
|
||||||
|
import 'package:environment_monitoring_app/models/marine_inves_manual_sampling_data.dart';
|
||||||
|
import 'package:environment_monitoring_app/services/marine_investigative_sampling_service.dart';
|
||||||
|
import 'package:environment_monitoring_app/models/tarball_data.dart';
|
||||||
|
import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/api_service.dart';
|
import 'package:environment_monitoring_app/services/api_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/base_api_service.dart';
|
import 'package:environment_monitoring_app/services/base_api_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/ftp_service.dart';
|
import 'package:environment_monitoring_app/services/ftp_service.dart';
|
||||||
@ -23,23 +31,32 @@ class RetryService {
|
|||||||
final ServerConfigService _serverConfigService = ServerConfigService();
|
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||||
bool _isProcessing = false;
|
bool _isProcessing = false;
|
||||||
|
|
||||||
// --- START: MODIFICATION FOR HANDLING COMPLEX TASKS ---
|
|
||||||
// These services will be provided after the RetryService is created.
|
|
||||||
MarineInSituSamplingService? _marineInSituService;
|
MarineInSituSamplingService? _marineInSituService;
|
||||||
RiverInSituSamplingService? _riverInSituService; // ADDED
|
RiverInSituSamplingService? _riverInSituService;
|
||||||
|
MarineInvestigativeSamplingService? _marineInvestigativeService;
|
||||||
|
MarineTarballSamplingService? _marineTarballService;
|
||||||
|
// *** ADDED: River Investigative Service member ***
|
||||||
|
RiverInvestigativeSamplingService? _riverInvestigativeService;
|
||||||
|
// *** END ADDED ***
|
||||||
AuthProvider? _authProvider;
|
AuthProvider? _authProvider;
|
||||||
|
|
||||||
// Call this method from your main app setup to provide the necessary services.
|
// *** MODIFIED: Added riverInvestigativeService to initialize ***
|
||||||
void initialize({
|
void initialize({
|
||||||
required MarineInSituSamplingService marineInSituService,
|
required MarineInSituSamplingService marineInSituService,
|
||||||
required RiverInSituSamplingService riverInSituService, // ADDED
|
required RiverInSituSamplingService riverInSituService,
|
||||||
|
required MarineInvestigativeSamplingService marineInvestigativeService,
|
||||||
|
required RiverInvestigativeSamplingService riverInvestigativeService, // <-- Added parameter
|
||||||
|
required MarineTarballSamplingService marineTarballService,
|
||||||
required AuthProvider authProvider,
|
required AuthProvider authProvider,
|
||||||
}) {
|
}) {
|
||||||
_marineInSituService = marineInSituService;
|
_marineInSituService = marineInSituService;
|
||||||
_riverInSituService = riverInSituService; // ADDED
|
_riverInSituService = riverInSituService;
|
||||||
|
_marineInvestigativeService = marineInvestigativeService;
|
||||||
|
_riverInvestigativeService = riverInvestigativeService; // <-- Assign parameter
|
||||||
|
_marineTarballService = marineTarballService;
|
||||||
_authProvider = authProvider;
|
_authProvider = authProvider;
|
||||||
}
|
}
|
||||||
// --- END: MODIFICATION FOR HANDLING COMPLEX TASKS ---
|
// *** END MODIFIED ***
|
||||||
|
|
||||||
|
|
||||||
/// Adds a generic, complex task to the queue, to be handled by a background processor.
|
/// Adds a generic, complex task to the queue, to be handled by a background processor.
|
||||||
@ -49,7 +66,7 @@ class RetryService {
|
|||||||
}) async {
|
}) async {
|
||||||
await _dbHelper.queueFailedRequest({
|
await _dbHelper.queueFailedRequest({
|
||||||
'type': type,
|
'type': type,
|
||||||
'endpoint_or_path': 'N/A',
|
'endpoint_or_path': 'N/A', // Not applicable for complex tasks initially
|
||||||
'payload': jsonEncode(payload),
|
'payload': jsonEncode(payload),
|
||||||
'timestamp': DateTime.now().toIso8601String(),
|
'timestamp': DateTime.now().toIso8601String(),
|
||||||
'status': 'pending',
|
'status': 'pending',
|
||||||
@ -65,12 +82,13 @@ class RetryService {
|
|||||||
Map<String, String>? fields,
|
Map<String, String>? fields,
|
||||||
Map<String, File>? files,
|
Map<String, File>? files,
|
||||||
}) async {
|
}) async {
|
||||||
|
// Convert File objects to paths for JSON serialization
|
||||||
final serializableFiles = files?.map((key, value) => MapEntry(key, value.path));
|
final serializableFiles = files?.map((key, value) => MapEntry(key, value.path));
|
||||||
final payload = {
|
final payload = {
|
||||||
'method': method,
|
'method': method,
|
||||||
'body': body,
|
'body': body,
|
||||||
'fields': fields,
|
'fields': fields,
|
||||||
'files': serializableFiles,
|
'files': serializableFiles, // Store paths instead of File objects
|
||||||
};
|
};
|
||||||
await _dbHelper.queueFailedRequest({
|
await _dbHelper.queueFailedRequest({
|
||||||
'type': 'api',
|
'type': 'api',
|
||||||
@ -86,8 +104,12 @@ class RetryService {
|
|||||||
Future<void> addFtpToQueue({
|
Future<void> addFtpToQueue({
|
||||||
required String localFilePath,
|
required String localFilePath,
|
||||||
required String remotePath,
|
required String remotePath,
|
||||||
|
required int ftpConfigId, // Added to specify which destination failed
|
||||||
}) async {
|
}) async {
|
||||||
final payload = {'localFilePath': localFilePath};
|
final payload = {
|
||||||
|
'localFilePath': localFilePath,
|
||||||
|
'ftpConfigId': ftpConfigId, // Store the specific config ID
|
||||||
|
};
|
||||||
await _dbHelper.queueFailedRequest({
|
await _dbHelper.queueFailedRequest({
|
||||||
'type': 'ftp',
|
'type': 'ftp',
|
||||||
'endpoint_or_path': remotePath,
|
'endpoint_or_path': remotePath,
|
||||||
@ -95,9 +117,10 @@ class RetryService {
|
|||||||
'timestamp': DateTime.now().toIso8601String(),
|
'timestamp': DateTime.now().toIso8601String(),
|
||||||
'status': 'pending',
|
'status': 'pending',
|
||||||
});
|
});
|
||||||
debugPrint("FTP upload for file '$localFilePath' has been queued for retry.");
|
debugPrint("FTP upload for file '$localFilePath' to config ID $ftpConfigId has been queued for retry.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Retrieves all tasks currently in the 'pending' state from the queue.
|
/// Retrieves all tasks currently in the 'pending' state from the queue.
|
||||||
Future<List<Map<String, dynamic>>> getPendingTasks() {
|
Future<List<Map<String, dynamic>>> getPendingTasks() {
|
||||||
return _dbHelper.getPendingRequests();
|
return _dbHelper.getPendingRequests();
|
||||||
@ -119,6 +142,7 @@ class RetryService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check internet connection *before* processing
|
||||||
if (_authProvider == null || !await _authProvider!.isConnected()) {
|
if (_authProvider == null || !await _authProvider!.isConnected()) {
|
||||||
debugPrint("[RetryService] ❌ No internet connection. Aborting queue processing.");
|
debugPrint("[RetryService] ❌ No internet connection. Aborting queue processing.");
|
||||||
_isProcessing = false;
|
_isProcessing = false;
|
||||||
@ -126,9 +150,15 @@ class RetryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
debugPrint("[RetryService] 🔎 Found ${pendingTasks.length} pending tasks.");
|
debugPrint("[RetryService] 🔎 Found ${pendingTasks.length} pending tasks.");
|
||||||
|
// Process tasks one by one
|
||||||
for (final task in pendingTasks) {
|
for (final task in pendingTasks) {
|
||||||
|
// 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);
|
await retryTask(task['id'] as int);
|
||||||
}
|
}
|
||||||
|
// Optional: Add a small delay between tasks if needed
|
||||||
|
// await Future.delayed(Duration(seconds: 1));
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint("[RetryService] ⏹️ Finished processing retry queue.");
|
debugPrint("[RetryService] ⏹️ Finished processing retry queue.");
|
||||||
_isProcessing = false;
|
_isProcessing = false;
|
||||||
@ -139,28 +169,44 @@ class RetryService {
|
|||||||
Future<bool> retryTask(int taskId) async {
|
Future<bool> retryTask(int taskId) async {
|
||||||
final task = await _dbHelper.getRequestById(taskId);
|
final task = await _dbHelper.getRequestById(taskId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
debugPrint("Retry failed: Task with ID $taskId not found in the queue.");
|
debugPrint("Retry failed: Task with ID $taskId not found in the queue (might have been processed already).");
|
||||||
return false;
|
return false; // Task doesn't exist or was processed elsewhere
|
||||||
}
|
}
|
||||||
|
|
||||||
bool success = false;
|
bool success = false;
|
||||||
final payload = jsonDecode(task['payload'] as String);
|
Map<String, dynamic> payload; // Declare outside try-catch
|
||||||
|
final String taskType = task['type'] as String; // Get type early for logging
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
payload = jsonDecode(task['payload'] as String); // Decode payload inside try
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error decoding payload for task $taskId (Type: $taskType): $e. Removing invalid task.");
|
||||||
|
await _dbHelper.deleteRequestFromQueue(taskId);
|
||||||
|
return false; // Cannot process without valid payload
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure AuthProvider is initialized and we are online (checked in processRetryQueue)
|
||||||
if (_authProvider == null) {
|
if (_authProvider == null) {
|
||||||
debugPrint("RetryService has not been initialized. Cannot process task.");
|
debugPrint("RetryService has not been initialized. Cannot process task $taskId.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task['type'] == 'insitu_submission') {
|
// --- Complex Task Handlers ---
|
||||||
|
if (taskType == 'insitu_submission') {
|
||||||
debugPrint("Retrying complex task 'insitu_submission' with ID $taskId.");
|
debugPrint("Retrying complex task 'insitu_submission' with ID $taskId.");
|
||||||
if (_marineInSituService == null) return false;
|
if (_marineInSituService == null) {
|
||||||
|
debugPrint("Retry failed: MarineInSituSamplingService not initialized.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
final String logFilePath = payload['localLogPath'];
|
final String logDirectoryPath = payload['localLogPath']; // Path to the directory
|
||||||
final file = File(logFilePath);
|
final jsonFilePath = p.join(logDirectoryPath, 'data.json');
|
||||||
|
final file = File(jsonFilePath);
|
||||||
|
|
||||||
if (!await file.exists()) {
|
if (!await file.exists()) {
|
||||||
debugPrint("Retry failed: Source log file no longer exists at $logFilePath");
|
debugPrint("Retry failed: Source log file no longer exists at $jsonFilePath");
|
||||||
await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task
|
await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -168,34 +214,37 @@ class RetryService {
|
|||||||
final content = await file.readAsString();
|
final content = await file.readAsString();
|
||||||
final jsonData = jsonDecode(content) as Map<String, dynamic>;
|
final jsonData = jsonDecode(content) as Map<String, dynamic>;
|
||||||
final InSituSamplingData dataToResubmit = InSituSamplingData.fromJson(jsonData);
|
final InSituSamplingData dataToResubmit = InSituSamplingData.fromJson(jsonData);
|
||||||
final String logDirectoryPath = p.dirname(logFilePath);
|
|
||||||
|
|
||||||
|
// Re-run the original submission logic, passing the log directory
|
||||||
final result = await _marineInSituService!.submitInSituSample(
|
final result = await _marineInSituService!.submitInSituSample(
|
||||||
data: dataToResubmit,
|
data: dataToResubmit,
|
||||||
appSettings: _authProvider!.appSettings,
|
appSettings: _authProvider!.appSettings, // Get current settings
|
||||||
authProvider: _authProvider!,
|
authProvider: _authProvider!,
|
||||||
logDirectory: logDirectoryPath,
|
logDirectory: logDirectoryPath, // Pass directory to update log
|
||||||
);
|
);
|
||||||
success = result['success'];
|
success = result['success'];
|
||||||
|
|
||||||
// --- START: ADDED LOGIC FOR RIVER IN-SITU SUBMISSION ---
|
} else if (taskType == 'river_insitu_submission') {
|
||||||
} else if (task['type'] == 'river_insitu_submission') {
|
|
||||||
debugPrint("Retrying complex task 'river_insitu_submission' with ID $taskId.");
|
debugPrint("Retrying complex task 'river_insitu_submission' with ID $taskId.");
|
||||||
if (_riverInSituService == null) return false;
|
if (_riverInSituService == null) {
|
||||||
|
debugPrint("Retry failed: RiverInSituSamplingService not initialized.");
|
||||||
final String logFilePath = payload['localLogPath'];
|
|
||||||
final file = File(logFilePath);
|
|
||||||
|
|
||||||
if (!await file.exists()) {
|
|
||||||
debugPrint("Retry failed: Source log file no longer exists at $logFilePath");
|
|
||||||
await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final String jsonFilePath = payload['localLogPath']; // Path to the JSON file
|
||||||
|
final file = File(jsonFilePath);
|
||||||
|
|
||||||
|
if (!await file.exists()) {
|
||||||
|
debugPrint("Retry failed: Source log file no longer exists at $jsonFilePath");
|
||||||
|
await _dbHelper.deleteRequestFromQueue(taskId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final String logDirectoryPath = p.dirname(jsonFilePath); // Get directory from file path
|
||||||
|
|
||||||
|
|
||||||
final content = await file.readAsString();
|
final content = await file.readAsString();
|
||||||
final jsonData = jsonDecode(content) as Map<String, dynamic>;
|
final jsonData = jsonDecode(content) as Map<String, dynamic>;
|
||||||
final RiverInSituSamplingData dataToResubmit = RiverInSituSamplingData.fromJson(jsonData);
|
final RiverInSituSamplingData dataToResubmit = RiverInSituSamplingData.fromJson(jsonData);
|
||||||
final String logDirectoryPath = p.dirname(logFilePath);
|
|
||||||
|
|
||||||
final result = await _riverInSituService!.submitData(
|
final result = await _riverInSituService!.submitData(
|
||||||
data: dataToResubmit,
|
data: dataToResubmit,
|
||||||
@ -204,61 +253,292 @@ class RetryService {
|
|||||||
logDirectory: logDirectoryPath,
|
logDirectory: logDirectoryPath,
|
||||||
);
|
);
|
||||||
success = result['success'];
|
success = result['success'];
|
||||||
// --- END: ADDED LOGIC FOR RIVER IN-SITU SUBMISSION ---
|
|
||||||
|
|
||||||
} else if (task['type'] == 'api') {
|
// *** ADDED: Handler for river_investigative_submission ***
|
||||||
|
} else if (taskType == 'river_investigative_submission') {
|
||||||
|
debugPrint("Retrying complex task 'river_investigative_submission' with ID $taskId.");
|
||||||
|
if (_riverInvestigativeService == null) {
|
||||||
|
debugPrint("Retry failed: RiverInvestigativeSamplingService not initialized.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String jsonFilePath = payload['localLogPath']; // Path to the JSON file
|
||||||
|
final file = File(jsonFilePath);
|
||||||
|
|
||||||
|
if (!await file.exists()) {
|
||||||
|
debugPrint("Retry failed: Source log file no longer exists at $jsonFilePath");
|
||||||
|
await _dbHelper.deleteRequestFromQueue(taskId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final String logDirectoryPath = p.dirname(jsonFilePath); // Get directory from file path
|
||||||
|
|
||||||
|
final content = await file.readAsString();
|
||||||
|
final jsonData = jsonDecode(content) as Map<String, dynamic>;
|
||||||
|
// Use the correct Investigative data model
|
||||||
|
final RiverInvesManualSamplingData dataToResubmit = RiverInvesManualSamplingData.fromJson(jsonData);
|
||||||
|
|
||||||
|
// Call the submitData method from the Investigative service
|
||||||
|
final result = await _riverInvestigativeService!.submitData(
|
||||||
|
data: dataToResubmit,
|
||||||
|
appSettings: _authProvider!.appSettings,
|
||||||
|
authProvider: _authProvider!,
|
||||||
|
logDirectory: logDirectoryPath,
|
||||||
|
);
|
||||||
|
success = result['success'];
|
||||||
|
// *** END ADDED ***
|
||||||
|
|
||||||
|
} else if (taskType == 'investigative_submission') {
|
||||||
|
debugPrint("Retrying complex task 'investigative_submission' with ID $taskId.");
|
||||||
|
if (_marineInvestigativeService == null) {
|
||||||
|
debugPrint("Retry failed: MarineInvestigativeSamplingService not initialized.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String logDirectoryPath = payload['localLogPath']; // Path to the directory
|
||||||
|
final jsonFilePath = p.join(logDirectoryPath, 'data.json');
|
||||||
|
final file = File(jsonFilePath);
|
||||||
|
|
||||||
|
if (!await file.exists()) {
|
||||||
|
debugPrint("Retry failed: Source log file no longer exists at $jsonFilePath");
|
||||||
|
await _dbHelper.deleteRequestFromQueue(taskId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final content = await file.readAsString();
|
||||||
|
final jsonData = jsonDecode(content) as Map<String, dynamic>;
|
||||||
|
final MarineInvesManualSamplingData dataToResubmit = MarineInvesManualSamplingData.fromJson(jsonData);
|
||||||
|
|
||||||
|
final result = await _marineInvestigativeService!.submitInvestigativeSample(
|
||||||
|
data: dataToResubmit,
|
||||||
|
appSettings: _authProvider!.appSettings,
|
||||||
|
authProvider: _authProvider!,
|
||||||
|
logDirectory: logDirectoryPath,
|
||||||
|
);
|
||||||
|
success = result['success'];
|
||||||
|
|
||||||
|
} else if (taskType == 'tarball_submission') {
|
||||||
|
debugPrint("Retrying complex task 'tarball_submission' with ID $taskId.");
|
||||||
|
if (_marineTarballService == null) {
|
||||||
|
debugPrint("Retry failed: MarineTarballSamplingService not initialized.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String logDirectoryPath = payload['localLogPath']; // Path to the directory
|
||||||
|
final jsonFilePath = p.join(logDirectoryPath, 'data.json');
|
||||||
|
final file = File(jsonFilePath);
|
||||||
|
|
||||||
|
if (!await file.exists()) {
|
||||||
|
debugPrint("Retry failed: Source log file no longer exists at $jsonFilePath");
|
||||||
|
await _dbHelper.deleteRequestFromQueue(taskId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final content = await file.readAsString();
|
||||||
|
final jsonData = jsonDecode(content) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Recreate File objects from paths
|
||||||
|
File? fileFromJson(dynamic path) => (path is String && path.isNotEmpty) ? File(path) : null;
|
||||||
|
|
||||||
|
final TarballSamplingData dataToResubmit = TarballSamplingData()
|
||||||
|
// Reconstruct the object from JSON data
|
||||||
|
..firstSampler = jsonData['firstSampler']
|
||||||
|
..firstSamplerUserId = jsonData['firstSamplerUserId']
|
||||||
|
..secondSampler = jsonData['secondSampler']
|
||||||
|
..samplingDate = jsonData['samplingDate']
|
||||||
|
..samplingTime = jsonData['samplingTime']
|
||||||
|
..selectedStateName = jsonData['selectedStateName']
|
||||||
|
..selectedCategoryName = jsonData['selectedCategoryName']
|
||||||
|
..selectedStation = jsonData['selectedStation']
|
||||||
|
..stationLatitude = jsonData['stationLatitude']
|
||||||
|
..stationLongitude = jsonData['stationLongitude']
|
||||||
|
..currentLatitude = jsonData['currentLatitude']
|
||||||
|
..currentLongitude = jsonData['currentLongitude']
|
||||||
|
..distanceDifference = jsonData['distanceDifference'] is num ? (jsonData['distanceDifference'] as num).toDouble() : null // Safe cast
|
||||||
|
..distanceDifferenceRemarks = jsonData['distanceDifferenceRemarks']
|
||||||
|
..classificationId = jsonData['classificationId'] is num ? (jsonData['classificationId'] as num).toInt() : null // Safe cast
|
||||||
|
..selectedClassification = jsonData['selectedClassification']
|
||||||
|
..leftCoastalViewImage = fileFromJson(jsonData['leftCoastalViewImage'])
|
||||||
|
..rightCoastalViewImage = fileFromJson(jsonData['rightCoastalViewImage'])
|
||||||
|
..verticalLinesImage = fileFromJson(jsonData['verticalLinesImage'])
|
||||||
|
..horizontalLineImage = fileFromJson(jsonData['horizontalLineImage'])
|
||||||
|
..optionalImage1 = fileFromJson(jsonData['optionalImage1'])
|
||||||
|
..optionalRemark1 = jsonData['optionalRemark1']
|
||||||
|
..optionalImage2 = fileFromJson(jsonData['optionalImage2'])
|
||||||
|
..optionalRemark2 = jsonData['optionalRemark2']
|
||||||
|
..optionalImage3 = fileFromJson(jsonData['optionalImage3'])
|
||||||
|
..optionalRemark3 = jsonData['optionalRemark3']
|
||||||
|
..optionalImage4 = fileFromJson(jsonData['optionalImage4'])
|
||||||
|
..optionalRemark4 = jsonData['optionalRemark4']
|
||||||
|
..reportId = jsonData['reportId'] // Preserve reportId if it exists
|
||||||
|
..submissionStatus = jsonData['submissionStatus'] // Preserve status info
|
||||||
|
..submissionMessage = jsonData['submissionMessage'];
|
||||||
|
|
||||||
|
|
||||||
|
debugPrint("Retrying Tarball submission...");
|
||||||
|
// Pass null for BuildContext, and the logDirectory path
|
||||||
|
final result = await _marineTarballService!.submitTarballSample(
|
||||||
|
data: dataToResubmit,
|
||||||
|
appSettings: _authProvider!.appSettings,
|
||||||
|
context: null, // Pass null for BuildContext during retry
|
||||||
|
logDirectory: logDirectoryPath, // Pass the directory path for potential update
|
||||||
|
);
|
||||||
|
success = result['success'];
|
||||||
|
|
||||||
|
// --- Simple Task Handlers ---
|
||||||
|
} else if (taskType == 'api') {
|
||||||
final endpoint = task['endpoint_or_path'] as String;
|
final endpoint = task['endpoint_or_path'] as String;
|
||||||
final method = payload['method'] as String;
|
final method = payload['method'] as String;
|
||||||
final baseUrl = await _serverConfigService.getActiveApiUrl();
|
final baseUrl = await _serverConfigService.getActiveApiUrl(); // Get current active URL
|
||||||
debugPrint("Retrying API task $taskId: $method to $baseUrl/$endpoint");
|
debugPrint("Retrying API task $taskId: $method to $baseUrl/$endpoint");
|
||||||
Map<String, dynamic> result;
|
Map<String, dynamic> result;
|
||||||
|
|
||||||
if (method == 'POST_MULTIPART') {
|
if (method == 'POST_MULTIPART') {
|
||||||
final Map<String, String> fields = Map<String, String>.from(payload['fields'] ?? {});
|
final Map<String, String> fields = Map<String, String>.from(payload['fields'] ?? {});
|
||||||
|
// Recreate File objects from paths stored in the payload
|
||||||
final Map<String, File> files = (payload['files'] as Map<String, dynamic>?)
|
final Map<String, File> files = (payload['files'] as Map<String, dynamic>?)
|
||||||
?.map((key, value) => MapEntry(key, File(value as String))) ?? {};
|
?.map((key, value) => MapEntry(key, File(value as String))) ?? {};
|
||||||
|
|
||||||
|
// Check if files still exist before attempting upload
|
||||||
|
bool allFilesExist = true;
|
||||||
|
List<String> missingFiles = []; // Keep track of missing files
|
||||||
|
for (var entry in files.entries) {
|
||||||
|
File file = entry.value;
|
||||||
|
if (!await file.exists()) {
|
||||||
|
debugPrint("Retry failed for API task $taskId: File ${file.path} (key: ${entry.key}) no longer exists.");
|
||||||
|
allFilesExist = false;
|
||||||
|
missingFiles.add(entry.key);
|
||||||
|
// break; // Stop checking further if one is missing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If some files are missing, fail the entire task.
|
||||||
|
if (!allFilesExist) {
|
||||||
|
debugPrint("API Multipart retry failed for task $taskId because files are missing: ${missingFiles.join(', ')}. Removing task.");
|
||||||
|
await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
result = await _baseApiService.postMultipart(baseUrl: baseUrl, endpoint: endpoint, fields: fields, files: files);
|
result = await _baseApiService.postMultipart(baseUrl: baseUrl, endpoint: endpoint, fields: fields, files: files);
|
||||||
} else {
|
} else { // Standard POST
|
||||||
final Map<String, dynamic> body = Map<String, dynamic>.from(payload['body'] ?? {});
|
final Map<String, dynamic> body = Map<String, dynamic>.from(payload['body'] ?? {});
|
||||||
result = await _baseApiService.post(baseUrl, endpoint, body);
|
result = await _baseApiService.post(baseUrl, endpoint, body);
|
||||||
}
|
}
|
||||||
success = result['success'];
|
success = result['success'];
|
||||||
|
|
||||||
} else if (task['type'] == 'ftp') {
|
} else if (taskType == 'ftp') {
|
||||||
final remotePath = task['endpoint_or_path'] as String;
|
final remotePath = task['endpoint_or_path'] as String;
|
||||||
final localFile = File(payload['localFilePath'] as String);
|
final localFile = File(payload['localFilePath'] as String);
|
||||||
debugPrint("Retrying FTP task $taskId: Uploading ${localFile.path} to $remotePath");
|
final int? ftpConfigId = payload['ftpConfigId'] as int?;
|
||||||
|
|
||||||
|
debugPrint("Retrying FTP task $taskId: Uploading ${localFile.path} to $remotePath using config ID $ftpConfigId");
|
||||||
|
|
||||||
|
if (ftpConfigId == null) {
|
||||||
|
debugPrint("Retry failed for FTP task $taskId: Missing FTP configuration ID in payload.");
|
||||||
|
await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (await localFile.exists()) {
|
if (await localFile.exists()) {
|
||||||
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||||
if (ftpConfigs.isEmpty) {
|
final config = ftpConfigs.firstWhere((c) => c['ftp_config_id'] == ftpConfigId, orElse: () => <String, dynamic>{}); // Use explicit type
|
||||||
debugPrint("Retry failed for FTP task $taskId: No FTP configurations found.");
|
|
||||||
return false;
|
|
||||||
}
|
if (config.isEmpty) {
|
||||||
for (final config in ftpConfigs) {
|
debugPrint("Retry failed for FTP task $taskId: FTP configuration with ID $ftpConfigId not found.");
|
||||||
final result = await _ftpService.uploadFile(config: config, fileToUpload: localFile, remotePath: remotePath);
|
return false; // Fail the retry attempt, keep in queue
|
||||||
if (result['success']) {
|
|
||||||
success = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (success) {
|
// Attempt upload using the specific config
|
||||||
debugPrint("Task $taskId completed successfully. Removing from queue.");
|
final result = await _ftpService.uploadFile(config: config, fileToUpload: localFile, remotePath: remotePath);
|
||||||
await _dbHelper.deleteRequestFromQueue(taskId);
|
success = result['success'];
|
||||||
|
|
||||||
} else {
|
} 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;
|
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';
|
const String moduleName = 'river_in_situ';
|
||||||
|
|
||||||
final connectivityResult = await Connectivity().checkConnectivity();
|
final connectivityResult = await Connectivity().checkConnectivity();
|
||||||
bool isOnline = connectivityResult != ConnectivityResult.none;
|
bool isOnline = !connectivityResult.contains(ConnectivityResult.none);
|
||||||
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
|
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
|
||||||
|
|
||||||
if (isOnline && isOfflineSession) {
|
if (isOnline && isOfflineSession) {
|
||||||
debugPrint("River In-Situ submission online during offline session. Attempting auto-relogin...");
|
debugPrint("River In-Situ submission online during offline session. Attempting auto-relogin...");
|
||||||
|
try {
|
||||||
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
|
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
|
||||||
if (transitionSuccess) {
|
if (transitionSuccess) {
|
||||||
isOfflineSession = false;
|
isOfflineSession = false;
|
||||||
} else {
|
} else {
|
||||||
|
isOnline = false; // Auto-relogin failed, treat as offline
|
||||||
|
}
|
||||||
|
} on SessionExpiredException catch (_) {
|
||||||
|
debugPrint("Session expired during auto-relogin check. Treating as offline.");
|
||||||
isOnline = false;
|
isOnline = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -196,6 +201,7 @@ class RiverInSituSamplingService {
|
|||||||
return await _performOfflineQueuing(
|
return await _performOfflineQueuing(
|
||||||
data: data,
|
data: data,
|
||||||
moduleName: moduleName,
|
moduleName: moduleName,
|
||||||
|
logDirectory: logDirectory, // Pass for potential update
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -215,25 +221,29 @@ class RiverInSituSamplingService {
|
|||||||
bool anyApiSuccess = false;
|
bool anyApiSuccess = false;
|
||||||
Map<String, dynamic> apiDataResult = {};
|
Map<String, dynamic> apiDataResult = {};
|
||||||
Map<String, dynamic> apiImageResult = {};
|
Map<String, dynamic> apiImageResult = {};
|
||||||
|
String finalMessage = '';
|
||||||
|
String finalStatus = '';
|
||||||
bool isSessionKnownToBeExpired = false;
|
bool isSessionKnownToBeExpired = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 1. Submit Form Data
|
||||||
apiDataResult = await _submissionApiService.submitPost(
|
apiDataResult = await _submissionApiService.submitPost(
|
||||||
moduleName: moduleName,
|
moduleName: moduleName,
|
||||||
endpoint: 'river/manual/sample',
|
endpoint: 'river/manual/sample', // Correct endpoint
|
||||||
body: data.toApiFormData(),
|
body: data.toApiFormData(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (apiDataResult['success'] == true) {
|
if (apiDataResult['success'] == true) {
|
||||||
anyApiSuccess = true;
|
anyApiSuccess = true;
|
||||||
data.reportId = apiDataResult['data']?['r_man_id']?.toString();
|
data.reportId = apiDataResult['data']?['r_man_id']?.toString(); // Correct ID key
|
||||||
|
|
||||||
if (data.reportId != null) {
|
if (data.reportId != null) {
|
||||||
if (finalImageFiles.isNotEmpty) {
|
if (finalImageFiles.isNotEmpty) {
|
||||||
|
// 2. Submit Images
|
||||||
apiImageResult = await _submissionApiService.submitMultipart(
|
apiImageResult = await _submissionApiService.submitMultipart(
|
||||||
moduleName: moduleName,
|
moduleName: moduleName,
|
||||||
endpoint: 'river/manual/images',
|
endpoint: 'river/manual/images', // Correct endpoint
|
||||||
fields: {'r_man_id': data.reportId!},
|
fields: {'r_man_id': data.reportId!}, // Correct field key
|
||||||
files: finalImageFiles,
|
files: finalImageFiles,
|
||||||
);
|
);
|
||||||
if (apiImageResult['success'] != true) {
|
if (apiImageResult['success'] != true) {
|
||||||
@ -245,65 +255,98 @@ class RiverInSituSamplingService {
|
|||||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} on SessionExpiredException catch (_) {
|
// If apiDataResult['success'] is false, SubmissionApiService queued it.
|
||||||
debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin...");
|
|
||||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
|
||||||
|
|
||||||
if (reloginSuccess) {
|
} on SessionExpiredException catch (_) {
|
||||||
debugPrint("Silent relogin successful. Retrying entire online submission process...");
|
debugPrint("Online submission failed due to session expiry that could not be refreshed.");
|
||||||
return await _performOnlineSubmission(
|
|
||||||
data: data,
|
|
||||||
appSettings: appSettings,
|
|
||||||
moduleName: moduleName,
|
|
||||||
authProvider: authProvider,
|
|
||||||
logDirectory: logDirectory,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP.");
|
|
||||||
isSessionKnownToBeExpired = true;
|
isSessionKnownToBeExpired = true;
|
||||||
anyApiSuccess = false;
|
anyApiSuccess = false;
|
||||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
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());
|
// Manually queue API calls
|
||||||
}
|
|
||||||
} on SocketException catch (e) {
|
|
||||||
final errorMessage = "API submission failed with network error: $e";
|
|
||||||
debugPrint(errorMessage);
|
|
||||||
anyApiSuccess = false;
|
|
||||||
apiDataResult = {'success': false, 'message': errorMessage};
|
|
||||||
await _retryService.addApiToQueue(endpoint: 'river/manual/sample', method: 'POST', body: data.toApiFormData());
|
await _retryService.addApiToQueue(endpoint: 'river/manual/sample', method: 'POST', body: data.toApiFormData());
|
||||||
if (finalImageFiles.isNotEmpty && data.reportId != null) {
|
if (finalImageFiles.isNotEmpty && data.reportId != null) {
|
||||||
|
// Also queue images if data call might have partially succeeded before expiry
|
||||||
await _retryService.addApiToQueue(endpoint: 'river/manual/images', method: 'POST_MULTIPART', fields: {'r_man_id': data.reportId!}, files: finalImageFiles);
|
await _retryService.addApiToQueue(endpoint: 'river/manual/images', method: 'POST_MULTIPART', fields: {'r_man_id': data.reportId!}, files: finalImageFiles);
|
||||||
}
|
}
|
||||||
} on TimeoutException catch (e) {
|
|
||||||
final errorMessage = "API submission timed out: $e";
|
|
||||||
debugPrint(errorMessage);
|
|
||||||
anyApiSuccess = false;
|
|
||||||
apiDataResult = {'success': false, 'message': errorMessage};
|
|
||||||
await _retryService.addApiToQueue(endpoint: 'river/manual/sample', method: 'POST', body: data.toApiFormData());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Submit FTP Files
|
||||||
Map<String, dynamic> ftpResults = {'statuses': []};
|
Map<String, dynamic> ftpResults = {'statuses': []};
|
||||||
bool anyFtpSuccess = false;
|
bool anyFtpSuccess = false;
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||||
} on SocketException catch (e) {
|
} catch (e) {
|
||||||
debugPrint("FTP submission failed with network error: $e");
|
debugPrint("Unexpected FTP submission error: $e");
|
||||||
anyFtpSuccess = false;
|
|
||||||
} on TimeoutException catch (e) {
|
|
||||||
debugPrint("FTP submission timed out: $e");
|
|
||||||
anyFtpSuccess = false;
|
anyFtpSuccess = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Determine Final Status
|
||||||
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
|
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
|
||||||
String finalMessage;
|
|
||||||
String finalStatus;
|
|
||||||
|
|
||||||
if (anyApiSuccess && anyFtpSuccess) {
|
if (anyApiSuccess && anyFtpSuccess) {
|
||||||
finalMessage = 'Data submitted successfully to all destinations.';
|
finalMessage = 'Data submitted successfully to all destinations.';
|
||||||
finalStatus = 'S4';
|
finalStatus = 'S4';
|
||||||
} else if (anyApiSuccess && !anyFtpSuccess) {
|
} else if (anyApiSuccess && !anyFtpSuccess) {
|
||||||
finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.';
|
finalMessage = 'Data sent to API, but some FTP uploads failed or were queued.';
|
||||||
finalStatus = 'S3';
|
finalStatus = 'S3';
|
||||||
} else if (!anyApiSuccess && anyFtpSuccess) {
|
} else if (!anyApiSuccess && anyFtpSuccess) {
|
||||||
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
|
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
|
||||||
@ -313,6 +356,7 @@ class RiverInSituSamplingService {
|
|||||||
finalStatus = 'L1';
|
finalStatus = 'L1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. Log Locally
|
||||||
await _logAndSave(
|
await _logAndSave(
|
||||||
data: data,
|
data: data,
|
||||||
status: finalStatus,
|
status: finalStatus,
|
||||||
@ -323,10 +367,12 @@ class RiverInSituSamplingService {
|
|||||||
logDirectory: logDirectory,
|
logDirectory: logDirectory,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 6. Send Alert
|
||||||
if (overallSuccess) {
|
if (overallSuccess) {
|
||||||
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return consistent format
|
||||||
return {
|
return {
|
||||||
'status': finalStatus,
|
'status': finalStatus,
|
||||||
'success': overallSuccess,
|
'success': overallSuccess,
|
||||||
@ -335,9 +381,12 @@ class RiverInSituSamplingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handles queuing the submission data when the device is offline.
|
||||||
Future<Map<String, dynamic>> _performOfflineQueuing({
|
Future<Map<String, dynamic>> _performOfflineQueuing({
|
||||||
required RiverInSituSamplingData data,
|
required RiverInSituSamplingData data,
|
||||||
required String moduleName,
|
required String moduleName,
|
||||||
|
String? logDirectory, // Added for potential update
|
||||||
}) async {
|
}) async {
|
||||||
final serverConfig = await _serverConfigService.getActiveApiConfig();
|
final serverConfig = await _serverConfigService.getActiveApiConfig();
|
||||||
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
|
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
|
||||||
@ -345,45 +394,66 @@ class RiverInSituSamplingService {
|
|||||||
data.submissionStatus = 'L1';
|
data.submissionStatus = 'L1';
|
||||||
data.submissionMessage = 'Submission queued for later retry.';
|
data.submissionMessage = 'Submission queued for later retry.';
|
||||||
|
|
||||||
final String? localLogPath = await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName);
|
String? savedLogPath = logDirectory; // Use existing path if provided
|
||||||
|
|
||||||
if (localLogPath == null) {
|
// Save/Update local log first
|
||||||
|
if (savedLogPath != null && savedLogPath.isNotEmpty) {
|
||||||
|
await _localStorageService.updateRiverInSituLog(data.toMap()..['logDirectory'] = savedLogPath);
|
||||||
|
debugPrint("Updated existing River In-Situ log for queuing: $savedLogPath");
|
||||||
|
} else {
|
||||||
|
savedLogPath = await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName);
|
||||||
|
debugPrint("Saved new River In-Situ log for queuing: $savedLogPath");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedLogPath == null) {
|
||||||
const message = "Failed to save submission to local device storage.";
|
const message = "Failed to save submission to local device storage.";
|
||||||
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName);
|
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, logDirectory: logDirectory);
|
||||||
return {'status': 'Error', 'success': false, 'message': message};
|
return {'status': 'Error', 'success': false, 'message': message};
|
||||||
}
|
}
|
||||||
|
|
||||||
await _retryService.queueTask(
|
await _retryService.queueTask(
|
||||||
type: 'river_insitu_submission',
|
type: 'river_insitu_submission', // Correct type
|
||||||
payload: {
|
payload: {
|
||||||
'module': moduleName,
|
'module': moduleName,
|
||||||
'localLogPath': p.join(localLogPath, 'data.json'),
|
'localLogPath': p.join(savedLogPath, 'data.json'), // Point to the json file
|
||||||
'serverConfig': serverConfig,
|
'serverConfig': serverConfig,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const successMessage = "Submission failed to send and has been queued for later retry.";
|
const successMessage = "Device offline. Submission has been saved locally and queued for automatic retry when connection is restored.";
|
||||||
return {'status': 'Queued', 'success': true, 'message': successMessage};
|
// Log final queued state to central DB
|
||||||
|
// await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, logDirectory: savedLogPath);
|
||||||
|
|
||||||
|
return {'status': 'Queued', 'success': true, 'message': successMessage, 'reportId': null};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(RiverInSituSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
|
/// Helper to generate the base filename for ZIP files.
|
||||||
|
String _generateBaseFileName(RiverInSituSamplingData data) {
|
||||||
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN';
|
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN';
|
||||||
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
|
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
|
||||||
final baseFileName = "${stationCode}_$fileTimestamp";
|
return "${stationCode}_$fileTimestamp";
|
||||||
|
}
|
||||||
|
|
||||||
final Directory? logDirectory = await _localStorageService.getLogDirectory(
|
/// Generates data and image ZIP files and uploads them using SubmissionFtpService.
|
||||||
serverName: serverName,
|
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(RiverInSituSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
|
||||||
module: 'river',
|
final baseFileName = _generateBaseFileName(data);
|
||||||
subModule: 'river_in_situ_sampling',
|
|
||||||
);
|
|
||||||
|
|
||||||
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null;
|
final Directory? logDirectory = await _localStorageService.getRiverInSituBaseDir(data.samplingType, serverName: serverName); // Use correct base dir getter
|
||||||
|
|
||||||
|
final folderName = data.reportId ?? baseFileName;
|
||||||
|
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null;
|
||||||
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
|
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
|
||||||
await localSubmissionDir.create(recursive: true);
|
await localSubmissionDir.create(recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create and upload data ZIP (with multiple JSON files specific to River In-Situ)
|
||||||
final dataZip = await _zippingService.createDataZip(
|
final dataZip = await _zippingService.createDataZip(
|
||||||
jsonDataMap: {'db.json': jsonEncode(data.toApiFormData())},
|
jsonDataMap: {
|
||||||
|
'db.json': data.toDbJson(),
|
||||||
|
'river_insitu_basic_form.json': data.toBasicFormJson(),
|
||||||
|
'river_sampling_reading.json': data.toReadingJson(),
|
||||||
|
'river_manual_info.json': data.toManualInfoJson(),
|
||||||
|
},
|
||||||
baseFileName: baseFileName,
|
baseFileName: baseFileName,
|
||||||
destinationDir: localSubmissionDir,
|
destinationDir: localSubmissionDir,
|
||||||
);
|
);
|
||||||
@ -393,6 +463,7 @@ class RiverInSituSamplingService {
|
|||||||
moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${p.basename(dataZip.path)}');
|
moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${p.basename(dataZip.path)}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create and upload image ZIP
|
||||||
final imageZip = await _zippingService.createImageZip(
|
final imageZip = await _zippingService.createImageZip(
|
||||||
imageFiles: imageFiles.values.toList(),
|
imageFiles: imageFiles.values.toList(),
|
||||||
baseFileName: baseFileName,
|
baseFileName: baseFileName,
|
||||||
@ -406,12 +477,13 @@ class RiverInSituSamplingService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'statuses': <Map<String, dynamic>>[
|
'statuses': <Map<String, dynamic>>[
|
||||||
...(ftpDataResult['statuses'] as List),
|
...(ftpDataResult['statuses'] as List? ?? []), // Use null-aware spread
|
||||||
...(ftpImageResult['statuses'] as List),
|
...(ftpImageResult['statuses'] as List? ?? []), // Use null-aware spread
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Saves or updates the local log file and saves a record to the central DB log.
|
||||||
Future<void> _logAndSave({
|
Future<void> _logAndSave({
|
||||||
required RiverInSituSamplingData data,
|
required RiverInSituSamplingData data,
|
||||||
required String status,
|
required String status,
|
||||||
@ -423,47 +495,64 @@ class RiverInSituSamplingService {
|
|||||||
}) async {
|
}) async {
|
||||||
data.submissionStatus = status;
|
data.submissionStatus = status;
|
||||||
data.submissionMessage = message;
|
data.submissionMessage = message;
|
||||||
|
final baseFileName = _generateBaseFileName(data); // Use helper
|
||||||
|
|
||||||
if (logDirectory != null) {
|
// Prepare log data map using toMap()
|
||||||
final Map<String, dynamic> updatedLogData = data.toMap();
|
final Map<String, dynamic> logMapData = data.toMap();
|
||||||
updatedLogData['logDirectory'] = logDirectory;
|
// Add submission metadata
|
||||||
updatedLogData['serverConfigName'] = serverName;
|
logMapData['submissionStatus'] = status;
|
||||||
updatedLogData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
|
logMapData['submissionMessage'] = message;
|
||||||
updatedLogData['ftp_status'] = jsonEncode(ftpStatuses);
|
logMapData['reportId'] = data.reportId;
|
||||||
|
logMapData['serverConfigName'] = serverName;
|
||||||
|
logMapData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
|
||||||
|
logMapData['ftp_status'] = jsonEncode(ftpStatuses);
|
||||||
|
|
||||||
final imageFilePaths = data.toApiImageFiles();
|
if (logDirectory != null && logDirectory.isNotEmpty) {
|
||||||
imageFilePaths.forEach((key, file) {
|
// Update existing log
|
||||||
if (file != null) {
|
logMapData['logDirectory'] = logDirectory; // Ensure logDirectory path is in the map
|
||||||
updatedLogData[key] = file.path;
|
await _localStorageService.updateRiverInSituLog(logMapData);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await _localStorageService.updateRiverInSituLog(updatedLogData);
|
|
||||||
} else {
|
} else {
|
||||||
|
// Save new log
|
||||||
await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName);
|
await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save to central DB log
|
||||||
final imagePaths = data.toApiImageFiles().values.whereType<File>().map((f) => f.path).toList();
|
final imagePaths = data.toApiImageFiles().values.whereType<File>().map((f) => f.path).toList();
|
||||||
final logData = {
|
final centralLogData = {
|
||||||
'submission_id': data.reportId ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
'submission_id': data.reportId ?? baseFileName, // Use helper result
|
||||||
'module': 'river', 'type': data.samplingType ?? 'In-Situ', 'status': status,
|
'module': 'river',
|
||||||
'message': message, 'report_id': data.reportId, 'created_at': DateTime.now().toIso8601String(),
|
'type': data.samplingType ?? 'In-Situ', // Correct type
|
||||||
'form_data': jsonEncode(data.toMap()), 'image_data': jsonEncode(imagePaths),
|
'status': status,
|
||||||
'server_name': serverName, 'api_status': jsonEncode(apiResults), 'ftp_status': jsonEncode(ftpStatuses),
|
'message': message,
|
||||||
|
'report_id': data.reportId,
|
||||||
|
'created_at': DateTime.now().toIso8601String(),
|
||||||
|
'form_data': jsonEncode(logMapData), // Log the comprehensive map
|
||||||
|
'image_data': jsonEncode(imagePaths),
|
||||||
|
'server_name': serverName,
|
||||||
|
'api_status': jsonEncode(apiResults),
|
||||||
|
'ftp_status': jsonEncode(ftpStatuses),
|
||||||
};
|
};
|
||||||
await _dbHelper.saveSubmissionLog(logData);
|
try {
|
||||||
|
await _dbHelper.saveSubmissionLog(centralLogData);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error saving River In-Situ submission log to DB: $e");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handles sending or queuing the Telegram alert for River In-Situ submissions.
|
||||||
Future<void> _handleSuccessAlert(RiverInSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
|
Future<void> _handleSuccessAlert(RiverInSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
|
||||||
try {
|
try {
|
||||||
final message = await _generateInSituAlertMessage(data, isDataOnly: isDataOnly);
|
final message = await _generateInSituAlertMessage(data, isDataOnly: isDataOnly); // Call local helper
|
||||||
|
final alertKey = 'river_in_situ'; // Correct key
|
||||||
|
|
||||||
if (isSessionExpired) {
|
if (isSessionExpired) {
|
||||||
debugPrint("Session is expired; queuing Telegram alert directly.");
|
debugPrint("Session is expired; queuing Telegram alert directly for $alertKey.");
|
||||||
await _telegramService.queueMessage('river_in_situ', message, appSettings);
|
await _telegramService.queueMessage(alertKey, message, appSettings);
|
||||||
} else {
|
} else {
|
||||||
final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings);
|
final bool wasSent = await _telegramService.sendAlertImmediately(alertKey, message, appSettings);
|
||||||
if (!wasSent) {
|
if (!wasSent) {
|
||||||
await _telegramService.queueMessage('river_in_situ', message, appSettings);
|
await _telegramService.queueMessage(alertKey, message, appSettings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -471,6 +560,7 @@ class RiverInSituSamplingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generates the specific Telegram alert message content for River In-Situ.
|
||||||
Future<String> _generateInSituAlertMessage(RiverInSituSamplingData data, {required bool isDataOnly}) async {
|
Future<String> _generateInSituAlertMessage(RiverInSituSamplingData data, {required bool isDataOnly}) async {
|
||||||
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||||
final stationName = data.selectedStation?['sampling_river'] ?? 'N/A';
|
final stationName = data.selectedStation?['sampling_river'] ?? 'N/A';
|
||||||
@ -491,7 +581,7 @@ class RiverInSituSamplingService {
|
|||||||
..writeln('*Sonde ID:* $sondeID')
|
..writeln('*Sonde ID:* $sondeID')
|
||||||
..writeln('*Status of Submission:* Successful');
|
..writeln('*Status of Submission:* Successful');
|
||||||
|
|
||||||
if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) {
|
if (distanceKm * 1000 > 50 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { // Check if distance > 50m
|
||||||
buffer
|
buffer
|
||||||
..writeln()
|
..writeln()
|
||||||
..writeln('🔔 *Distance Alert:*')
|
..writeln('🔔 *Distance Alert:*')
|
||||||
@ -500,6 +590,79 @@ class RiverInSituSamplingService {
|
|||||||
buffer.writeln('*Remarks for distance:* $distanceRemarks');
|
buffer.writeln('*Remarks for distance:* $distanceRemarks');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add parameter limit check section if needed
|
||||||
|
final outOfBoundsAlert = await _getOutOfBoundsAlertSection(data);
|
||||||
|
if (outOfBoundsAlert.isNotEmpty) {
|
||||||
|
buffer.write(outOfBoundsAlert);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to generate the parameter limit alert section for Telegram.
|
||||||
|
Future<String> _getOutOfBoundsAlertSection(RiverInSituSamplingData data) async {
|
||||||
|
// Define mapping from data model keys to parameter names used in limits table
|
||||||
|
const Map<String, String> _parameterKeyToLimitName = {
|
||||||
|
'oxygenConcentration': 'Oxygen Conc', 'oxygenSaturation': 'Oxygen Sat', 'ph': 'pH',
|
||||||
|
'salinity': 'Salinity', 'electricalConductivity': 'Conductivity', 'temperature': 'Temperature',
|
||||||
|
'tds': 'TDS', 'turbidity': 'Turbidity', 'ammonia': 'Ammonia', 'batteryVoltage': 'Battery',
|
||||||
|
};
|
||||||
|
|
||||||
|
final allLimits = await _dbHelper.loadRiverParameterLimits() ?? []; // Load river limits
|
||||||
|
if (allLimits.isEmpty) return "";
|
||||||
|
|
||||||
|
final readings = {
|
||||||
|
'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation,
|
||||||
|
'ph': data.ph, 'salinity': data.salinity, 'electricalConductivity': data.electricalConductivity,
|
||||||
|
'temperature': data.temperature, 'tds': data.tds, 'turbidity': data.turbidity,
|
||||||
|
'ammonia': data.ammonia, 'batteryVoltage': data.batteryVoltage,
|
||||||
|
};
|
||||||
|
|
||||||
|
final List<String> outOfBoundsMessages = [];
|
||||||
|
|
||||||
|
double? parseLimitValue(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is num) return value.toDouble();
|
||||||
|
if (value is String) return double.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
readings.forEach((key, value) {
|
||||||
|
if (value == null || value == -999.0) return;
|
||||||
|
|
||||||
|
final limitName = _parameterKeyToLimitName[key];
|
||||||
|
if (limitName == null) return;
|
||||||
|
|
||||||
|
// Find the limit data for this parameter (river limits are not station-specific in the current DB structure)
|
||||||
|
final limitData = allLimits.firstWhere(
|
||||||
|
(l) => l['param_parameter_list'] == limitName,
|
||||||
|
orElse: () => <String, dynamic>{}, // Use explicit type
|
||||||
|
);
|
||||||
|
|
||||||
|
if (limitData.isNotEmpty) {
|
||||||
|
final lowerLimit = parseLimitValue(limitData['param_lower_limit']);
|
||||||
|
final upperLimit = parseLimitValue(limitData['param_upper_limit']);
|
||||||
|
|
||||||
|
if ((lowerLimit != null && value < lowerLimit) || (upperLimit != null && value > upperLimit)) {
|
||||||
|
final valueStr = value.toStringAsFixed(5);
|
||||||
|
final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A';
|
||||||
|
final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/A';
|
||||||
|
outOfBoundsMessages.add('- *$limitName*: `$valueStr` (Limit: `$lowerStr` - `$upperStr`)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (outOfBoundsMessages.isEmpty) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
final buffer = StringBuffer()
|
||||||
|
..writeln()
|
||||||
|
..writeln('⚠️ *Parameter Limit Alert:*')
|
||||||
|
..writeln('The following parameters were outside their defined limits:');
|
||||||
|
buffer.writeAll(outOfBoundsMessages, '\n');
|
||||||
|
|
||||||
return buffer.toString();
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart'; // Keep material import
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
@ -15,14 +15,14 @@ import 'package:usb_serial/usb_serial.dart';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart'; // Keep provider import
|
||||||
|
|
||||||
import '../auth_provider.dart';
|
import '../auth_provider.dart';
|
||||||
import 'location_service.dart';
|
import 'location_service.dart';
|
||||||
import '../models/river_manual_triennial_sampling_data.dart';
|
import '../models/river_manual_triennial_sampling_data.dart';
|
||||||
import '../bluetooth/bluetooth_manager.dart';
|
import '../bluetooth/bluetooth_manager.dart';
|
||||||
import '../serial/serial_manager.dart';
|
import '../serial/serial_manager.dart';
|
||||||
import 'api_service.dart';
|
import 'api_service.dart'; // Keep DatabaseHelper import
|
||||||
import 'local_storage_service.dart';
|
import 'local_storage_service.dart';
|
||||||
import 'server_config_service.dart';
|
import 'server_config_service.dart';
|
||||||
import 'zipping_service.dart';
|
import 'zipping_service.dart';
|
||||||
@ -166,18 +166,23 @@ class RiverManualTriennialSamplingService {
|
|||||||
required AuthProvider authProvider,
|
required AuthProvider authProvider,
|
||||||
String? logDirectory,
|
String? logDirectory,
|
||||||
}) async {
|
}) async {
|
||||||
const String moduleName = 'river_triennial';
|
const String moduleName = 'river_triennial'; // Correct module name
|
||||||
|
|
||||||
final connectivityResult = await Connectivity().checkConnectivity();
|
final connectivityResult = await Connectivity().checkConnectivity();
|
||||||
bool isOnline = connectivityResult != ConnectivityResult.none;
|
bool isOnline = !connectivityResult.contains(ConnectivityResult.none);
|
||||||
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
|
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
|
||||||
|
|
||||||
if (isOnline && isOfflineSession) {
|
if (isOnline && isOfflineSession) {
|
||||||
debugPrint("River Triennial submission online during offline session. Attempting auto-relogin...");
|
debugPrint("River Triennial submission online during offline session. Attempting auto-relogin...");
|
||||||
|
try {
|
||||||
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
|
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
|
||||||
if (transitionSuccess) {
|
if (transitionSuccess) {
|
||||||
isOfflineSession = false;
|
isOfflineSession = false;
|
||||||
} else {
|
} else {
|
||||||
|
isOnline = false; // Auto-relogin failed, treat as offline
|
||||||
|
}
|
||||||
|
} on SessionExpiredException catch (_) {
|
||||||
|
debugPrint("Session expired during auto-relogin check. Treating as offline.");
|
||||||
isOnline = false;
|
isOnline = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -196,6 +201,7 @@ class RiverManualTriennialSamplingService {
|
|||||||
return await _performOfflineQueuing(
|
return await _performOfflineQueuing(
|
||||||
data: data,
|
data: data,
|
||||||
moduleName: moduleName,
|
moduleName: moduleName,
|
||||||
|
logDirectory: logDirectory, // Pass for potential update
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -215,25 +221,29 @@ class RiverManualTriennialSamplingService {
|
|||||||
bool anyApiSuccess = false;
|
bool anyApiSuccess = false;
|
||||||
Map<String, dynamic> apiDataResult = {};
|
Map<String, dynamic> apiDataResult = {};
|
||||||
Map<String, dynamic> apiImageResult = {};
|
Map<String, dynamic> apiImageResult = {};
|
||||||
|
String finalMessage = '';
|
||||||
|
String finalStatus = '';
|
||||||
bool isSessionKnownToBeExpired = false;
|
bool isSessionKnownToBeExpired = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 1. Submit Form Data
|
||||||
apiDataResult = await _submissionApiService.submitPost(
|
apiDataResult = await _submissionApiService.submitPost(
|
||||||
moduleName: moduleName,
|
moduleName: moduleName,
|
||||||
endpoint: 'river/triennial/sample',
|
endpoint: 'river/triennial/sample', // Correct endpoint
|
||||||
body: data.toApiFormData(),
|
body: data.toApiFormData(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (apiDataResult['success'] == true) {
|
if (apiDataResult['success'] == true) {
|
||||||
anyApiSuccess = true;
|
anyApiSuccess = true;
|
||||||
data.reportId = apiDataResult['data']?['r_tri_id']?.toString();
|
data.reportId = apiDataResult['data']?['r_tri_id']?.toString(); // Correct ID key
|
||||||
|
|
||||||
if (data.reportId != null) {
|
if (data.reportId != null) {
|
||||||
if (finalImageFiles.isNotEmpty) {
|
if (finalImageFiles.isNotEmpty) {
|
||||||
|
// 2. Submit Images
|
||||||
apiImageResult = await _submissionApiService.submitMultipart(
|
apiImageResult = await _submissionApiService.submitMultipart(
|
||||||
moduleName: moduleName,
|
moduleName: moduleName,
|
||||||
endpoint: 'river/triennial/images',
|
endpoint: 'river/triennial/images', // Correct endpoint
|
||||||
fields: {'r_tri_id': data.reportId!},
|
fields: {'r_tri_id': data.reportId!}, // Correct field key
|
||||||
files: finalImageFiles,
|
files: finalImageFiles,
|
||||||
);
|
);
|
||||||
if (apiImageResult['success'] != true) {
|
if (apiImageResult['success'] != true) {
|
||||||
@ -245,65 +255,96 @@ class RiverManualTriennialSamplingService {
|
|||||||
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} on SessionExpiredException catch (_) {
|
// If apiDataResult['success'] is false, SubmissionApiService queued it.
|
||||||
debugPrint("API submission failed with SessionExpiredException. Attempting silent relogin...");
|
|
||||||
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
|
|
||||||
|
|
||||||
if (reloginSuccess) {
|
} on SessionExpiredException catch (_) {
|
||||||
debugPrint("Silent relogin successful. Retrying entire online submission process...");
|
debugPrint("Online submission failed due to session expiry that could not be refreshed.");
|
||||||
return await _performOnlineSubmission(
|
|
||||||
data: data,
|
|
||||||
appSettings: appSettings,
|
|
||||||
moduleName: moduleName,
|
|
||||||
authProvider: authProvider,
|
|
||||||
logDirectory: logDirectory,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
debugPrint("Silent relogin failed. API part will be queued, proceeding with FTP.");
|
|
||||||
isSessionKnownToBeExpired = true;
|
isSessionKnownToBeExpired = true;
|
||||||
anyApiSuccess = false;
|
anyApiSuccess = false;
|
||||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
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());
|
// Manually queue API calls
|
||||||
}
|
|
||||||
} on SocketException catch (e) {
|
|
||||||
final errorMessage = "API submission failed with network error: $e";
|
|
||||||
debugPrint(errorMessage);
|
|
||||||
anyApiSuccess = false;
|
|
||||||
apiDataResult = {'success': false, 'message': errorMessage};
|
|
||||||
await _retryService.addApiToQueue(endpoint: 'river/triennial/sample', method: 'POST', body: data.toApiFormData());
|
await _retryService.addApiToQueue(endpoint: 'river/triennial/sample', method: 'POST', body: data.toApiFormData());
|
||||||
if (finalImageFiles.isNotEmpty && data.reportId != null) {
|
if (finalImageFiles.isNotEmpty && data.reportId != null) {
|
||||||
|
// Also queue images if data call might have partially succeeded before expiry
|
||||||
await _retryService.addApiToQueue(endpoint: 'river/triennial/images', method: 'POST_MULTIPART', fields: {'r_tri_id': data.reportId!}, files: finalImageFiles);
|
await _retryService.addApiToQueue(endpoint: 'river/triennial/images', method: 'POST_MULTIPART', fields: {'r_tri_id': data.reportId!}, files: finalImageFiles);
|
||||||
}
|
}
|
||||||
} on TimeoutException catch (e) {
|
|
||||||
final errorMessage = "API submission timed out: $e";
|
|
||||||
debugPrint(errorMessage);
|
|
||||||
anyApiSuccess = false;
|
|
||||||
apiDataResult = {'success': false, 'message': errorMessage};
|
|
||||||
await _retryService.addApiToQueue(endpoint: 'river/triennial/sample', method: 'POST', body: data.toApiFormData());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Submit FTP Files
|
||||||
Map<String, dynamic> ftpResults = {'statuses': []};
|
Map<String, dynamic> ftpResults = {'statuses': []};
|
||||||
bool anyFtpSuccess = false;
|
bool anyFtpSuccess = false;
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
|
||||||
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
|
||||||
} on SocketException catch (e) {
|
} catch (e) {
|
||||||
debugPrint("FTP submission failed with network error: $e");
|
debugPrint("Unexpected FTP submission error: $e");
|
||||||
anyFtpSuccess = false;
|
|
||||||
} on TimeoutException catch (e) {
|
|
||||||
debugPrint("FTP submission timed out: $e");
|
|
||||||
anyFtpSuccess = false;
|
anyFtpSuccess = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Determine Final Status
|
||||||
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
|
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
|
||||||
String finalMessage;
|
|
||||||
String finalStatus;
|
|
||||||
|
|
||||||
if (anyApiSuccess && anyFtpSuccess) {
|
if (anyApiSuccess && anyFtpSuccess) {
|
||||||
finalMessage = 'Data submitted successfully to all destinations.';
|
finalMessage = 'Data submitted successfully to all destinations.';
|
||||||
finalStatus = 'S4';
|
finalStatus = 'S4';
|
||||||
} else if (anyApiSuccess && !anyFtpSuccess) {
|
} else if (anyApiSuccess && !anyFtpSuccess) {
|
||||||
finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.';
|
finalMessage = 'Data sent to API, but some FTP uploads failed or were queued.';
|
||||||
finalStatus = 'S3';
|
finalStatus = 'S3';
|
||||||
} else if (!anyApiSuccess && anyFtpSuccess) {
|
} else if (!anyApiSuccess && anyFtpSuccess) {
|
||||||
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
|
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
|
||||||
@ -313,6 +354,7 @@ class RiverManualTriennialSamplingService {
|
|||||||
finalStatus = 'L1';
|
finalStatus = 'L1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. Log Locally
|
||||||
await _logAndSave(
|
await _logAndSave(
|
||||||
data: data,
|
data: data,
|
||||||
status: finalStatus,
|
status: finalStatus,
|
||||||
@ -323,10 +365,12 @@ class RiverManualTriennialSamplingService {
|
|||||||
logDirectory: logDirectory,
|
logDirectory: logDirectory,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 6. Send Alert
|
||||||
if (overallSuccess) {
|
if (overallSuccess) {
|
||||||
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return consistent format
|
||||||
return {
|
return {
|
||||||
'status': finalStatus,
|
'status': finalStatus,
|
||||||
'success': overallSuccess,
|
'success': overallSuccess,
|
||||||
@ -335,9 +379,11 @@ class RiverManualTriennialSamplingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles queuing the submission data when the device is offline.
|
||||||
Future<Map<String, dynamic>> _performOfflineQueuing({
|
Future<Map<String, dynamic>> _performOfflineQueuing({
|
||||||
required RiverManualTriennialSamplingData data,
|
required RiverManualTriennialSamplingData data,
|
||||||
required String moduleName,
|
required String moduleName,
|
||||||
|
String? logDirectory, // Added for potential update
|
||||||
}) async {
|
}) async {
|
||||||
final serverConfig = await _serverConfigService.getActiveApiConfig();
|
final serverConfig = await _serverConfigService.getActiveApiConfig();
|
||||||
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
|
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
|
||||||
@ -345,45 +391,67 @@ class RiverManualTriennialSamplingService {
|
|||||||
data.submissionStatus = 'L1';
|
data.submissionStatus = 'L1';
|
||||||
data.submissionMessage = 'Submission queued for later retry.';
|
data.submissionMessage = 'Submission queued for later retry.';
|
||||||
|
|
||||||
final String? localLogPath = await _localStorageService.saveRiverManualTriennialSamplingData(data, serverName: serverName);
|
String? savedLogPath = logDirectory; // Use existing path if provided
|
||||||
|
|
||||||
if (localLogPath == null) {
|
// Save/Update local log first
|
||||||
|
if (savedLogPath != null && savedLogPath.isNotEmpty) {
|
||||||
|
await _localStorageService.updateRiverManualTriennialLog(data.toMap()..['logDirectory'] = savedLogPath);
|
||||||
|
debugPrint("Updated existing River Triennial log for queuing: $savedLogPath");
|
||||||
|
} else {
|
||||||
|
savedLogPath = await _localStorageService.saveRiverManualTriennialSamplingData(data, serverName: serverName);
|
||||||
|
debugPrint("Saved new River Triennial log for queuing: $savedLogPath");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (savedLogPath == null) {
|
||||||
const message = "Failed to save submission to local device storage.";
|
const message = "Failed to save submission to local device storage.";
|
||||||
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName);
|
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, logDirectory: logDirectory);
|
||||||
return {'status': 'Error', 'success': false, 'message': message};
|
return {'status': 'Error', 'success': false, 'message': message};
|
||||||
}
|
}
|
||||||
|
|
||||||
await _retryService.queueTask(
|
await _retryService.queueTask(
|
||||||
type: 'river_triennial_submission',
|
type: 'river_triennial_submission', // Correct type
|
||||||
payload: {
|
payload: {
|
||||||
'module': moduleName,
|
'module': moduleName,
|
||||||
'localLogPath': p.join(localLogPath, 'data.json'),
|
'localLogPath': p.join(savedLogPath, 'data.json'), // Point to the json file
|
||||||
'serverConfig': serverConfig,
|
'serverConfig': serverConfig,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const successMessage = "Submission failed to send and has been queued for later retry.";
|
const successMessage = "Device offline. Submission has been saved locally and queued for automatic retry when connection is restored.";
|
||||||
return {'status': 'Queued', 'success': true, 'message': successMessage};
|
// Log final queued state to central DB
|
||||||
|
// await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, logDirectory: savedLogPath);
|
||||||
|
|
||||||
|
return {'status': 'Queued', 'success': true, 'message': successMessage, 'reportId': null};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(RiverManualTriennialSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
|
/// Helper to generate the base filename for ZIP files.
|
||||||
|
String _generateBaseFileName(RiverManualTriennialSamplingData data) {
|
||||||
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN';
|
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN';
|
||||||
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
|
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
|
||||||
final baseFileName = "${stationCode}_$fileTimestamp";
|
return "${stationCode}_$fileTimestamp";
|
||||||
|
}
|
||||||
|
|
||||||
final Directory? logDirectory = await _localStorageService.getLogDirectory(
|
|
||||||
|
/// Generates data and image ZIP files and uploads them using SubmissionFtpService.
|
||||||
|
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(RiverManualTriennialSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
|
||||||
|
final baseFileName = _generateBaseFileName(data);
|
||||||
|
|
||||||
|
final Directory? logDirectory = await _localStorageService.getLogDirectory( // Use generic getter
|
||||||
serverName: serverName,
|
serverName: serverName,
|
||||||
module: 'river',
|
module: 'river',
|
||||||
subModule: 'river_triennial_sampling',
|
subModule: 'river_triennial_sampling', // Correct sub-module path
|
||||||
);
|
);
|
||||||
|
|
||||||
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null;
|
final folderName = data.reportId ?? baseFileName;
|
||||||
|
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, folderName)) : null;
|
||||||
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
|
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
|
||||||
await localSubmissionDir.create(recursive: true);
|
await localSubmissionDir.create(recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create and upload data ZIP
|
||||||
final dataZip = await _zippingService.createDataZip(
|
final dataZip = await _zippingService.createDataZip(
|
||||||
jsonDataMap: {'db.json': jsonEncode(data.toApiFormData())}, // Assuming similar structure is needed
|
jsonDataMap: {'db.json': data.toDbJson()}, // Assuming similar structure, adjust if needed
|
||||||
baseFileName: baseFileName,
|
baseFileName: baseFileName,
|
||||||
destinationDir: localSubmissionDir,
|
destinationDir: localSubmissionDir,
|
||||||
);
|
);
|
||||||
@ -393,6 +461,7 @@ class RiverManualTriennialSamplingService {
|
|||||||
moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${p.basename(dataZip.path)}');
|
moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${p.basename(dataZip.path)}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create and upload image ZIP
|
||||||
final imageZip = await _zippingService.createImageZip(
|
final imageZip = await _zippingService.createImageZip(
|
||||||
imageFiles: imageFiles.values.toList(),
|
imageFiles: imageFiles.values.toList(),
|
||||||
baseFileName: baseFileName,
|
baseFileName: baseFileName,
|
||||||
@ -406,12 +475,13 @@ class RiverManualTriennialSamplingService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'statuses': <Map<String, dynamic>>[
|
'statuses': <Map<String, dynamic>>[
|
||||||
...(ftpDataResult['statuses'] as List),
|
...(ftpDataResult['statuses'] as List? ?? []), // Use null-aware spread
|
||||||
...(ftpImageResult['statuses'] as List),
|
...(ftpImageResult['statuses'] as List? ?? []), // Use null-aware spread
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Saves or updates the local log file and saves a record to the central DB log.
|
||||||
Future<void> _logAndSave({
|
Future<void> _logAndSave({
|
||||||
required RiverManualTriennialSamplingData data,
|
required RiverManualTriennialSamplingData data,
|
||||||
required String status,
|
required String status,
|
||||||
@ -423,47 +493,65 @@ class RiverManualTriennialSamplingService {
|
|||||||
}) async {
|
}) async {
|
||||||
data.submissionStatus = status;
|
data.submissionStatus = status;
|
||||||
data.submissionMessage = message;
|
data.submissionMessage = message;
|
||||||
|
final baseFileName = _generateBaseFileName(data); // Use helper
|
||||||
|
|
||||||
if (logDirectory != null) {
|
// Prepare log data map using toMap()
|
||||||
final Map<String, dynamic> updatedLogData = data.toMap();
|
final Map<String, dynamic> logMapData = data.toMap();
|
||||||
updatedLogData['logDirectory'] = logDirectory;
|
// Add submission metadata
|
||||||
updatedLogData['serverConfigName'] = serverName;
|
logMapData['submissionStatus'] = status;
|
||||||
updatedLogData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
|
logMapData['submissionMessage'] = message;
|
||||||
updatedLogData['ftp_status'] = jsonEncode(ftpStatuses);
|
logMapData['reportId'] = data.reportId;
|
||||||
|
logMapData['serverConfigName'] = serverName;
|
||||||
|
logMapData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
|
||||||
|
logMapData['ftp_status'] = jsonEncode(ftpStatuses);
|
||||||
|
|
||||||
final imageFilePaths = data.toApiImageFiles();
|
|
||||||
imageFilePaths.forEach((key, file) {
|
|
||||||
if (file != null) {
|
|
||||||
updatedLogData[key] = file.path;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await _localStorageService.updateRiverManualTriennialLog(updatedLogData);
|
if (logDirectory != null && logDirectory.isNotEmpty) {
|
||||||
|
// Update existing log
|
||||||
|
logMapData['logDirectory'] = logDirectory; // Ensure logDirectory path is in the map
|
||||||
|
await _localStorageService.updateRiverManualTriennialLog(logMapData); // Use specific update method
|
||||||
} else {
|
} else {
|
||||||
await _localStorageService.saveRiverManualTriennialSamplingData(data, serverName: serverName);
|
// Save new log
|
||||||
|
await _localStorageService.saveRiverManualTriennialSamplingData(data, serverName: serverName); // Use specific save method
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save to central DB log
|
||||||
final imagePaths = data.toApiImageFiles().values.whereType<File>().map((f) => f.path).toList();
|
final imagePaths = data.toApiImageFiles().values.whereType<File>().map((f) => f.path).toList();
|
||||||
final logData = {
|
final centralLogData = {
|
||||||
'submission_id': data.reportId ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
'submission_id': data.reportId ?? baseFileName, // Use helper result
|
||||||
'module': 'river', 'type': data.samplingType ?? 'Triennial', 'status': status,
|
'module': 'river',
|
||||||
'message': message, 'report_id': data.reportId, 'created_at': DateTime.now().toIso8601String(),
|
'type': data.samplingType ?? 'Triennial', // Correct type
|
||||||
'form_data': jsonEncode(data.toMap()), 'image_data': jsonEncode(imagePaths),
|
'status': status,
|
||||||
'server_name': serverName, 'api_status': jsonEncode(apiResults), 'ftp_status': jsonEncode(ftpStatuses),
|
'message': message,
|
||||||
|
'report_id': data.reportId,
|
||||||
|
'created_at': DateTime.now().toIso8601String(),
|
||||||
|
'form_data': jsonEncode(logMapData), // Log the comprehensive map
|
||||||
|
'image_data': jsonEncode(imagePaths),
|
||||||
|
'server_name': serverName,
|
||||||
|
'api_status': jsonEncode(apiResults),
|
||||||
|
'ftp_status': jsonEncode(ftpStatuses),
|
||||||
};
|
};
|
||||||
await _dbHelper.saveSubmissionLog(logData);
|
try {
|
||||||
|
await _dbHelper.saveSubmissionLog(centralLogData);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error saving River Triennial submission log to DB: $e");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handles sending or queuing the Telegram alert for River Triennial submissions.
|
||||||
Future<void> _handleSuccessAlert(RiverManualTriennialSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
|
Future<void> _handleSuccessAlert(RiverManualTriennialSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
|
||||||
try {
|
try {
|
||||||
final message = await _generateSuccessAlertMessage(data, isDataOnly: isDataOnly);
|
final message = await _generateSuccessAlertMessage(data, isDataOnly: isDataOnly); // Call local helper
|
||||||
|
final alertKey = 'river_triennial'; // Correct key
|
||||||
|
|
||||||
if (isSessionExpired) {
|
if (isSessionExpired) {
|
||||||
debugPrint("Session is expired; queuing Telegram alert directly.");
|
debugPrint("Session is expired; queuing Telegram alert directly for $alertKey.");
|
||||||
await _telegramService.queueMessage('river_triennial', message, appSettings);
|
await _telegramService.queueMessage(alertKey, message, appSettings);
|
||||||
} else {
|
} else {
|
||||||
final bool wasSent = await _telegramService.sendAlertImmediately('river_triennial', message, appSettings);
|
final bool wasSent = await _telegramService.sendAlertImmediately(alertKey, message, appSettings);
|
||||||
if (!wasSent) {
|
if (!wasSent) {
|
||||||
await _telegramService.queueMessage('river_triennial', message, appSettings);
|
await _telegramService.queueMessage(alertKey, message, appSettings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -471,6 +559,7 @@ class RiverManualTriennialSamplingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generates the specific Telegram alert message content for River Triennial.
|
||||||
Future<String> _generateSuccessAlertMessage(RiverManualTriennialSamplingData data, {required bool isDataOnly}) async {
|
Future<String> _generateSuccessAlertMessage(RiverManualTriennialSamplingData data, {required bool isDataOnly}) async {
|
||||||
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||||
final stationName = data.selectedStation?['sampling_river'] ?? 'N/A';
|
final stationName = data.selectedStation?['sampling_river'] ?? 'N/A';
|
||||||
@ -491,15 +580,20 @@ class RiverManualTriennialSamplingService {
|
|||||||
..writeln('*Sonde ID:* $sondeID')
|
..writeln('*Sonde ID:* $sondeID')
|
||||||
..writeln('*Status of Submission:* Successful');
|
..writeln('*Status of Submission:* Successful');
|
||||||
|
|
||||||
if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) {
|
if (distanceKm * 1000 > 50 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { // Check if distance > 50m
|
||||||
buffer
|
buffer
|
||||||
..writeln()
|
..writeln()
|
||||||
..writeln('🔔 *Alert:*')
|
..writeln('🔔 *Distance Alert:*')
|
||||||
..writeln('*Distance from station:* $distanceMeters meters');
|
..writeln('*Distance from station:* $distanceMeters meters');
|
||||||
if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
|
if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
|
||||||
buffer.writeln('*Remarks for distance:* $distanceRemarks');
|
buffer.writeln('*Remarks for distance:* $distanceRemarks');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: Parameter limit checks are not typically done for Triennial in the same way as In-Situ.
|
||||||
|
// If needed, similar logic to _getOutOfBoundsAlertSection in RiverInSituSamplingService
|
||||||
|
// would need to be adapted here, potentially using riverParameterLimits from the DB.
|
||||||
|
|
||||||
return buffer.toString();
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2,10 +2,15 @@
|
|||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'dart:convert'; // Added for jsonEncode
|
||||||
|
import 'package:path/path.dart' as p; // Added for basename
|
||||||
|
|
||||||
import 'package:environment_monitoring_app/services/user_preferences_service.dart';
|
import 'package:environment_monitoring_app/services/user_preferences_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/ftp_service.dart';
|
import 'package:environment_monitoring_app/services/ftp_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/retry_service.dart';
|
import 'package:environment_monitoring_app/services/retry_service.dart';
|
||||||
|
// Import necessary services and models if needed for queueFtpTasksForSkippedAttempt
|
||||||
|
import 'package:environment_monitoring_app/services/zipping_service.dart';
|
||||||
|
import 'package:environment_monitoring_app/services/api_service.dart'; // For DatabaseHelper
|
||||||
|
|
||||||
/// A generic, reusable service for handling the FTP submission process.
|
/// A generic, reusable service for handling the FTP submission process.
|
||||||
/// It respects user preferences for enabled destinations for any given module.
|
/// It respects user preferences for enabled destinations for any given module.
|
||||||
@ -13,6 +18,10 @@ class SubmissionFtpService {
|
|||||||
final UserPreferencesService _userPreferencesService = UserPreferencesService();
|
final UserPreferencesService _userPreferencesService = UserPreferencesService();
|
||||||
final FtpService _ftpService = FtpService();
|
final FtpService _ftpService = FtpService();
|
||||||
final RetryService _retryService = RetryService();
|
final RetryService _retryService = RetryService();
|
||||||
|
// Add ZippingService and DatabaseHelper if queueFtpTasksForSkippedAttempt needs them
|
||||||
|
final ZippingService _zippingService = ZippingService();
|
||||||
|
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||||
|
|
||||||
|
|
||||||
/// Submits a file to all enabled FTP destinations for a given module.
|
/// Submits a file to all enabled FTP destinations for a given module.
|
||||||
///
|
///
|
||||||
@ -30,15 +39,35 @@ class SubmissionFtpService {
|
|||||||
|
|
||||||
if (destinations.isEmpty) {
|
if (destinations.isEmpty) {
|
||||||
debugPrint("SubmissionFtpService: No enabled FTP destinations for module '$moduleName'. Skipping.");
|
debugPrint("SubmissionFtpService: No enabled FTP destinations for module '$moduleName'. Skipping.");
|
||||||
return {'success': true, 'message': 'No FTP destinations enabled for this module.'};
|
// Return success with a specific status indicating no config
|
||||||
|
return {
|
||||||
|
'success': true, // Process succeeded because there was nothing to do
|
||||||
|
'message': 'No FTP destinations enabled for this module.',
|
||||||
|
'statuses': [{'status': 'Not Configured', 'message': 'No destinations enabled.', 'success': true}]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<Map<String, dynamic>> statuses = [];
|
final List<Map<String, dynamic>> statuses = [];
|
||||||
bool allSucceeded = true;
|
bool allSucceededOrNotConfigured = true; // Track if all attempts either succeeded or weren't configured
|
||||||
|
|
||||||
for (final dest in destinations) {
|
for (final dest in destinations) {
|
||||||
final configName = dest['config_name'] as String? ?? 'Unknown FTP';
|
final configName = dest['config_name'] as String? ?? 'Unknown FTP';
|
||||||
debugPrint("SubmissionFtpService: Attempting to upload to '$configName'");
|
final int? configId = dest['ftp_config_id'] as int?; // Get the config ID
|
||||||
|
|
||||||
|
// Skip if config ID is missing (should not happen with DB data)
|
||||||
|
if (configId == null) {
|
||||||
|
debugPrint("SubmissionFtpService: Skipping destination '$configName' due to missing config ID.");
|
||||||
|
statuses.add({
|
||||||
|
'config_name': configName,
|
||||||
|
'status': 'Error',
|
||||||
|
'success': false,
|
||||||
|
'message': 'Configuration ID missing.',
|
||||||
|
});
|
||||||
|
allSucceededOrNotConfigured = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint("SubmissionFtpService: Attempting to upload to '$configName' (ID: $configId)");
|
||||||
|
|
||||||
final result = await _ftpService.uploadFile(
|
final result = await _ftpService.uploadFile(
|
||||||
config: dest,
|
config: dest,
|
||||||
@ -48,22 +77,27 @@ class SubmissionFtpService {
|
|||||||
|
|
||||||
statuses.add({
|
statuses.add({
|
||||||
'config_name': configName,
|
'config_name': configName,
|
||||||
|
'ftp_config_id': configId, // Include ID in status
|
||||||
'success': result['success'],
|
'success': result['success'],
|
||||||
'message': result['message'],
|
'message': result['message'],
|
||||||
|
'status': result['success'] ? 'Success' : 'Failed', // Add status text
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result['success'] != true) {
|
if (result['success'] != true) {
|
||||||
allSucceeded = false;
|
allSucceededOrNotConfigured = false;
|
||||||
// If an individual upload fails, queue it for manual retry.
|
// If an individual upload fails, queue it for manual retry.
|
||||||
debugPrint("SubmissionFtpService: Upload to '$configName' failed. Queuing for retry.");
|
debugPrint("SubmissionFtpService: Upload to '$configName' (ID: $configId) failed. Queuing for retry.");
|
||||||
|
// --- START FIX: Add ftpConfigId ---
|
||||||
await _retryService.addFtpToQueue(
|
await _retryService.addFtpToQueue(
|
||||||
localFilePath: fileToUpload.path,
|
localFilePath: fileToUpload.path,
|
||||||
remotePath: remotePath,
|
remotePath: remotePath,
|
||||||
|
ftpConfigId: configId, // Pass the specific config ID
|
||||||
);
|
);
|
||||||
|
// --- END FIX ---
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allSucceeded) {
|
if (allSucceededOrNotConfigured) {
|
||||||
return {
|
return {
|
||||||
'success': true,
|
'success': true,
|
||||||
'message': 'File successfully uploaded to all enabled FTP destinations.',
|
'message': 'File successfully uploaded to all enabled FTP destinations.',
|
||||||
@ -71,10 +105,70 @@ class SubmissionFtpService {
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
'success': true, // The process itself succeeded, even if some uploads were queued.
|
'success': true, // The process itself succeeded (attempted all), even if some uploads were queued.
|
||||||
'message': 'One or more FTP uploads failed and have been queued for retry.',
|
'message': 'One or more FTP uploads failed and have been queued for retry.',
|
||||||
'statuses': statuses,
|
'statuses': statuses,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Manually queues FTP tasks when the initial FTP attempt is skipped (e.g., due to session expiry).
|
||||||
|
Future<void> queueFtpTasksForSkippedAttempt({
|
||||||
|
required String moduleName,
|
||||||
|
required Map<String, dynamic> dataJson, // The data model converted to JSON (toDbJson)
|
||||||
|
required Map<String, File> imageFiles,
|
||||||
|
required String baseFileName, // Base name for zip files
|
||||||
|
}) async {
|
||||||
|
debugPrint("Manually queuing FTP tasks for skipped attempt (Module: $moduleName).");
|
||||||
|
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
|
||||||
|
if (ftpConfigs.isEmpty) {
|
||||||
|
debugPrint("Cannot queue skipped FTP tasks: No FTP configurations found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Create Data ZIP (in temp directory)
|
||||||
|
final dataZip = await _zippingService.createDataZip(
|
||||||
|
// Adapt jsonDataMap based on module if needed, using dataJson
|
||||||
|
jsonDataMap: {'db.json': jsonEncode(dataJson)}, // Default, adjust per module if needed
|
||||||
|
baseFileName: baseFileName,
|
||||||
|
destinationDir: null, // Save to temp dir
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Create Image ZIP (in temp directory)
|
||||||
|
File? imageZip;
|
||||||
|
if (imageFiles.isNotEmpty) {
|
||||||
|
imageZip = await _zippingService.createImageZip(
|
||||||
|
imageFiles: imageFiles.values.toList(),
|
||||||
|
baseFileName: baseFileName,
|
||||||
|
destinationDir: null, // Save to temp dir
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Queue uploads for each config
|
||||||
|
for (final config in ftpConfigs) {
|
||||||
|
final configId = config['ftp_config_id'];
|
||||||
|
if (configId != null) {
|
||||||
|
// Queue data zip upload
|
||||||
|
if (dataZip != null) {
|
||||||
|
await _retryService.addFtpToQueue(
|
||||||
|
localFilePath: dataZip.path,
|
||||||
|
remotePath: '/${p.basename(dataZip.path)}',
|
||||||
|
ftpConfigId: configId
|
||||||
|
);
|
||||||
|
debugPrint("Queued skipped data ZIP upload for FTP config ID $configId");
|
||||||
|
}
|
||||||
|
// Queue image zip upload
|
||||||
|
if (imageZip != null) {
|
||||||
|
await _retryService.addFtpToQueue(
|
||||||
|
localFilePath: imageZip.path,
|
||||||
|
remotePath: '/${p.basename(imageZip.path)}',
|
||||||
|
ftpConfigId: configId
|
||||||
|
);
|
||||||
|
debugPrint("Queued skipped image ZIP upload for FTP config ID $configId");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Temporary ZIP files will be cleaned up by OS eventually, or handled by retry logic upon success/failure.
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
16
pubspec.lock
@ -854,6 +854,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.5"
|
version: "2.5.5"
|
||||||
|
sqflite_common_ffi:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sqflite_common_ffi
|
||||||
|
sha256: "9faa2fedc5385ef238ce772589f7718c24cdddd27419b609bb9c6f703ea27988"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.6"
|
||||||
sqflite_darwin:
|
sqflite_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -870,6 +878,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.0"
|
version: "2.4.0"
|
||||||
|
sqlite3:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqlite3
|
||||||
|
sha256: f18fd9a72d7a1ad2920db61368f2a69368f1cc9b56b8233e9d83b47b0a8435aa
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.9.3"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -23,6 +23,7 @@ dependencies:
|
|||||||
# --- Local Storage & Offline Capabilities ---
|
# --- Local Storage & Offline Capabilities ---
|
||||||
shared_preferences: ^2.2.3
|
shared_preferences: ^2.2.3
|
||||||
sqflite: ^2.3.3
|
sqflite: ^2.3.3
|
||||||
|
sqflite_common_ffi: ^2.3.3
|
||||||
path_provider: ^2.1.3
|
path_provider: ^2.1.3
|
||||||
path: ^1.8.3 # Explicitly added for path manipulation
|
path: ^1.8.3 # Explicitly added for path manipulation
|
||||||
connectivity_plus: ^6.0.1
|
connectivity_plus: ^6.0.1
|
||||||
@ -73,4 +74,4 @@ flutter:
|
|||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: true
|
android: true
|
||||||
ios: true
|
ios: true
|
||||||
image_path: "assets/icon_2_512x512.png"
|
image_path: "assets/icon_3_512x512.png"
|
||||||