update to support offline save and retry function

This commit is contained in:
ALim Aidrus 2025-09-30 14:40:57 +08:00
parent c2a95c53cc
commit f44245fb5a
26 changed files with 2232 additions and 1114 deletions

View File

@ -1,27 +1,22 @@
// lib/auth_provider.dart
import 'package:flutter/foundation.dart'; // Import for compute function
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
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:environment_monitoring_app/services/api_service.dart'; import 'package:environment_monitoring_app/services/api_service.dart';
// --- ADDED: Import for the service that manages active server configurations ---
import 'package:environment_monitoring_app/services/server_config_service.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart';
// --- ADDED: Import for the new service that manages the retry queue ---
import 'package:environment_monitoring_app/services/retry_service.dart'; import 'package:environment_monitoring_app/services/retry_service.dart';
/// A comprehensive provider to manage user authentication, session state,
/// and cached master data for offline use.
class AuthProvider with ChangeNotifier { class AuthProvider with ChangeNotifier {
// FIX: Change to late final and remove direct instantiation.
late final ApiService _apiService; late final ApiService _apiService;
late final DatabaseHelper _dbHelper; late final DatabaseHelper _dbHelper;
// --- ADDED: Instance of the ServerConfigService to set the initial URL ---
late final ServerConfigService _serverConfigService; late final ServerConfigService _serverConfigService;
// --- ADDED: Instance of the RetryService to manage pending tasks ---
late final RetryService _retryService; late final RetryService _retryService;
// --- Session & Profile State --- // --- Session & Profile State ---
String? _jwtToken; String? _jwtToken;
String? _userEmail; String? _userEmail;
@ -30,6 +25,9 @@ class AuthProvider with ChangeNotifier {
String? get userEmail => _userEmail; String? get userEmail => _userEmail;
Map<String, dynamic>? get profileData => _profileData; Map<String, dynamic>? get profileData => _profileData;
// --- ADDED: Temporary password cache for auto-relogin ---
String? _tempOfflinePassword;
// --- App State --- // --- App State ---
bool _isLoading = true; bool _isLoading = true;
bool _isFirstLogin = true; bool _isFirstLogin = true;
@ -55,12 +53,9 @@ class AuthProvider with ChangeNotifier {
List<Map<String, dynamic>>? _parameterLimits; List<Map<String, dynamic>>? _parameterLimits;
List<Map<String, dynamic>>? _apiConfigs; List<Map<String, dynamic>>? _apiConfigs;
List<Map<String, dynamic>>? _ftpConfigs; List<Map<String, dynamic>>? _ftpConfigs;
// --- ADDED: State variable for the list of documents ---
List<Map<String, dynamic>>? _documents; List<Map<String, dynamic>>? _documents;
// --- ADDED: State variable for the list of tasks pending manual retry ---
List<Map<String, dynamic>>? _pendingRetries; List<Map<String, dynamic>>? _pendingRetries;
// --- Getters for UI access --- // --- Getters for UI access ---
List<Map<String, dynamic>>? get allUsers => _allUsers; List<Map<String, dynamic>>? get allUsers => _allUsers;
List<Map<String, dynamic>>? get tarballStations => _tarballStations; List<Map<String, dynamic>>? get tarballStations => _tarballStations;
@ -78,12 +73,9 @@ class AuthProvider with ChangeNotifier {
List<Map<String, dynamic>>? get parameterLimits => _parameterLimits; List<Map<String, dynamic>>? get parameterLimits => _parameterLimits;
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;
// --- ADDED: Getter for the list of documents ---
List<Map<String, dynamic>>? get documents => _documents; List<Map<String, dynamic>>? get documents => _documents;
// --- ADDED: Getter for the list of tasks pending manual retry ---
List<Map<String, dynamic>>? get pendingRetries => _pendingRetries; List<Map<String, dynamic>>? get pendingRetries => _pendingRetries;
// --- SharedPreferences Keys --- // --- SharedPreferences Keys ---
static const String tokenKey = 'jwt_token'; static const String tokenKey = 'jwt_token';
static const String userEmailKey = 'user_email'; static const String userEmailKey = 'user_email';
@ -91,7 +83,6 @@ class AuthProvider with ChangeNotifier {
static const String lastSyncTimestampKey = 'last_sync_timestamp'; static const String lastSyncTimestampKey = 'last_sync_timestamp';
static const String isFirstLoginKey = 'is_first_login'; static const String isFirstLoginKey = 'is_first_login';
// FIX: Constructor now accepts dependencies.
AuthProvider({ AuthProvider({
required ApiService apiService, required ApiService apiService,
required DatabaseHelper dbHelper, required DatabaseHelper dbHelper,
@ -105,7 +96,6 @@ class AuthProvider with ChangeNotifier {
_loadSessionAndSyncData(); _loadSessionAndSyncData();
} }
/// Loads the user session from storage and then triggers a data sync.
Future<void> _loadSessionAndSyncData() async { Future<void> _loadSessionAndSyncData() async {
_isLoading = true; _isLoading = true;
notifyListeners(); notifyListeners();
@ -115,36 +105,32 @@ class AuthProvider with ChangeNotifier {
_userEmail = prefs.getString(userEmailKey); _userEmail = prefs.getString(userEmailKey);
_isFirstLogin = prefs.getBool(isFirstLoginKey) ?? true; _isFirstLogin = prefs.getBool(isFirstLoginKey) ?? true;
// --- MODIFIED: Logic to insert a default URL on the first ever run ---
// Check if there is an active API configuration.
final activeApiConfig = await _serverConfigService.getActiveApiConfig(); final activeApiConfig = await _serverConfigService.getActiveApiConfig();
if (activeApiConfig == null) { if (activeApiConfig == null) {
// If no config is set (which will be true on the very first launch),
// we programmatically create and set a default one.
debugPrint("AuthProvider: No active API config found. Setting default bootstrap URL."); 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://dev14.pstw.com.my/v1', 'api_url': 'https://mms-apiv4.pstw.com.my/v1',
}; };
// Save this default config as the active one.
await _serverConfigService.setActiveApiConfig(initialConfig); await _serverConfigService.setActiveApiConfig(initialConfig);
} }
// --- END OF MODIFICATION ---
// MODIFIED: Switched to getting a string for the ISO8601 timestamp
final lastSyncString = prefs.getString(lastSyncTimestampKey); final lastSyncString = prefs.getString(lastSyncTimestampKey);
if (lastSyncString != null) { if (lastSyncString != null) {
_lastSyncTimestamp = DateTime.parse(lastSyncString); try {
_lastSyncTimestamp = DateTime.parse(lastSyncString);
} catch (e) {
debugPrint("Error parsing last sync timestamp: $e");
prefs.remove(lastSyncTimestampKey);
}
} }
// Always load from local DB first for instant startup
await _loadDataFromCache(); await _loadDataFromCache();
if (_jwtToken != null) { if (_jwtToken != null) {
debugPrint('AuthProvider: Session loaded. Triggering online sync.'); debugPrint('AuthProvider: Session loaded.');
// Don't await here to allow the UI to build instantly with cached data // Sync logic moved to checkAndTransitionToOnlineSession to handle transitions correctly
syncAllData();
} else { } else {
debugPrint('AuthProvider: No active session. App is in offline mode.'); debugPrint('AuthProvider: No active session. App is in offline mode.');
} }
@ -153,40 +139,127 @@ class AuthProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// The main function to sync all app data using the delta-sync strategy. /// Checks if the session is offline and attempts to transition to an online session by performing a silent re-login.
/// Returns true if a successful transition occurred.
Future<bool> checkAndTransitionToOnlineSession() async {
// Condition 1: Check connectivity
final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult.contains(ConnectivityResult.none)) {
debugPrint("AuthProvider: No internet connection. Skipping transition check.");
return false;
}
// Condition 2: Check if currently in an offline session state
final bool inOfflineSession = _jwtToken != null && _jwtToken!.startsWith("offline-session-");
if (!inOfflineSession) {
// Already online or logged out, no transition needed.
// If online, trigger a normal sync to ensure data freshness on connection restoration.
if(_jwtToken != null) {
debugPrint("AuthProvider: Session is already online. Triggering standard sync.");
syncAllData();
}
return false;
}
// Condition 3: Check if we have the temporary password to attempt re-login.
if (_tempOfflinePassword == null || _userEmail == null) {
debugPrint("AuthProvider: In offline session, but no temporary password available for auto-relogin. Manual login required.");
return false;
}
debugPrint("AuthProvider: Internet detected in offline session. Attempting silent re-login for $_userEmail...");
try {
final result = await _apiService.login(_userEmail!, _tempOfflinePassword!);
if (result['success'] == true) {
debugPrint("AuthProvider: Silent re-login successful. Transitioning to online session.");
final String token = result['data']['token'];
final Map<String, dynamic> profile = result['data']['profile'];
// Use existing login method to set up session and trigger sync.
// Re-pass the password to ensure credentials are fully cached after transition.
await login(token, profile, _tempOfflinePassword!);
// Clear temporary password after successful transition
_tempOfflinePassword = null;
notifyListeners(); // Ensure UI updates after state change
return true;
} else {
// Silent login failed (e.g., password changed on another device).
// Keep user in offline mode for now. They will need to log out and log back in manually.
debugPrint("AuthProvider: Silent re-login failed: ${result['message']}");
return false;
}
} catch (e) {
debugPrint("AuthProvider: Silent re-login exception: $e");
return false;
}
}
/// Attempts to silently re-login to get a new token.
/// This can be called when a 401 Unauthorized error is detected.
Future<bool> attemptSilentRelogin() async {
final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult.contains(ConnectivityResult.none)) {
debugPrint("AuthProvider: No internet for silent relogin.");
return false;
}
if (_tempOfflinePassword == null || _userEmail == null) {
debugPrint("AuthProvider: No cached credentials for silent relogin.");
return false;
}
debugPrint("AuthProvider: Session may be expired. Attempting silent re-login for $_userEmail...");
try {
final result = await _apiService.login(_userEmail!, _tempOfflinePassword!);
if (result['success'] == true) {
debugPrint("AuthProvider: Silent re-login successful.");
final String token = result['data']['token'];
final Map<String, dynamic> profile = result['data']['profile'];
await login(token, profile, _tempOfflinePassword!);
return true;
} else {
debugPrint("AuthProvider: Silent re-login failed: ${result['message']}");
return false;
}
} catch (e) {
debugPrint("AuthProvider: Silent re-login exception: $e");
return false;
}
}
Future<void> syncAllData({bool forceRefresh = false}) async { Future<void> syncAllData({bool forceRefresh = false}) async {
final connectivityResult = await Connectivity().checkConnectivity(); final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult == ConnectivityResult.none) { if (connectivityResult.contains(ConnectivityResult.none)) {
debugPrint("AuthProvider: Device is OFFLINE. Skipping sync."); debugPrint("AuthProvider: Device is OFFLINE. Skipping sync.");
return; return;
} }
// Prevent sync attempts if token is an offline placeholder
if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) {
debugPrint("AuthProvider: Skipping sync, session is offline or null.");
return;
}
debugPrint("AuthProvider: Device is ONLINE. Starting delta sync."); debugPrint("AuthProvider: Device is ONLINE. Starting delta sync.");
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
// If 'forceRefresh' is true, sync all data by passing a null timestamp.
final String? lastSync = forceRefresh ? null : prefs.getString(lastSyncTimestampKey); final String? lastSync = forceRefresh ? null : prefs.getString(lastSyncTimestampKey);
// Record the time BEFORE the sync starts. This will be our new timestamp on success.
// Use UTC for consistency across timezones.
final newSyncTimestamp = DateTime.now().toUtc().toIso8601String(); final newSyncTimestamp = DateTime.now().toUtc().toIso8601String();
final result = await _apiService.syncAllData(lastSyncTimestamp: lastSync); final result = await _apiService.syncAllData(lastSyncTimestamp: lastSync);
if (result['success']) { if (result['success']) {
debugPrint("AuthProvider: Delta sync successful. Updating last sync timestamp."); debugPrint("AuthProvider: Delta sync successful. Updating last sync timestamp.");
// On success, save the new timestamp for the next run.
await prefs.setString(lastSyncTimestampKey, newSyncTimestamp); await prefs.setString(lastSyncTimestampKey, newSyncTimestamp);
_lastSyncTimestamp = DateTime.parse(newSyncTimestamp); _lastSyncTimestamp = DateTime.parse(newSyncTimestamp);
// --- ADDED: After the first successful sync, set isFirstLogin to false ---
if (_isFirstLogin) { if (_isFirstLogin) {
await setIsFirstLogin(false); await setIsFirstLogin(false);
debugPrint("AuthProvider: First successful sync complete. isFirstLogin flag set to false."); debugPrint("AuthProvider: First successful sync complete. isFirstLogin flag set to false.");
} }
// --- END ---
// After updating the DB, reload data from the cache into memory to update the UI.
await _loadDataFromCache(); await _loadDataFromCache();
notifyListeners(); notifyListeners();
} else { } else {
@ -194,22 +267,31 @@ class AuthProvider with ChangeNotifier {
} }
} }
/// A dedicated method to refresh only the profile.
Future<void> refreshProfile() async { Future<void> refreshProfile() async {
final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult.contains(ConnectivityResult.none)) {
debugPrint("AuthProvider: Device is OFFLINE. Skipping profile refresh.");
return;
}
if (_jwtToken == null || _jwtToken!.startsWith("offline-session-")) {
debugPrint("AuthProvider: Skipping profile refresh, session is offline or null.");
return;
}
final result = await _apiService.refreshProfile(); final result = await _apiService.refreshProfile();
if (result['success']) { if (result['success']) {
_profileData = result['data']; await setProfileData(result['data']);
// Persist the updated profile data
await _dbHelper.saveProfile(_profileData!);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(profileDataKey, jsonEncode(_profileData));
notifyListeners();
} }
} }
/// Loads all master data from the local cache using DatabaseHelper.
Future<void> _loadDataFromCache() async { Future<void> _loadDataFromCache() async {
_profileData = await _dbHelper.loadProfile(); final prefs = await SharedPreferences.getInstance();
final profileJson = prefs.getString(profileDataKey);
if (profileJson != null) {
_profileData = jsonDecode(profileJson);
} else {
_profileData = await _dbHelper.loadProfile();
}
_allUsers = await _dbHelper.loadUsers(); _allUsers = await _dbHelper.loadUsers();
_tarballStations = await _dbHelper.loadTarballStations(); _tarballStations = await _dbHelper.loadTarballStations();
_manualStations = await _dbHelper.loadManualStations(); _manualStations = await _dbHelper.loadManualStations();
@ -222,49 +304,116 @@ class AuthProvider with ChangeNotifier {
_airClients = await _dbHelper.loadAirClients(); _airClients = await _dbHelper.loadAirClients();
_airManualStations = await _dbHelper.loadAirManualStations(); _airManualStations = await _dbHelper.loadAirManualStations();
_states = await _dbHelper.loadStates(); _states = await _dbHelper.loadStates();
// ADDED: Load new data types from the local database
_appSettings = await _dbHelper.loadAppSettings(); _appSettings = await _dbHelper.loadAppSettings();
_parameterLimits = await _dbHelper.loadParameterLimits(); _parameterLimits = await _dbHelper.loadParameterLimits();
// --- ADDED: Load documents from the local database cache ---
_documents = await _dbHelper.loadDocuments(); _documents = await _dbHelper.loadDocuments();
_apiConfigs = await _dbHelper.loadApiConfigs(); _apiConfigs = await _dbHelper.loadApiConfigs();
_ftpConfigs = await _dbHelper.loadFtpConfigs(); _ftpConfigs = await _dbHelper.loadFtpConfigs();
// --- ADDED: Load pending retry tasks from the database ---
_pendingRetries = await _retryService.getPendingTasks(); _pendingRetries = await _retryService.getPendingTasks();
debugPrint("AuthProvider: All master data loaded from local DB cache."); debugPrint("AuthProvider: All master data loaded from local DB cache.");
} }
// --- ADDED: A public method to allow the UI to refresh the pending tasks list ---
/// Refreshes the list of pending retry tasks from the local database.
Future<void> refreshPendingTasks() async { Future<void> refreshPendingTasks() async {
_pendingRetries = await _retryService.getPendingTasks(); _pendingRetries = await _retryService.getPendingTasks();
notifyListeners(); notifyListeners();
} }
// --- Methods for UI interaction --- Future<void> login(String token, Map<String, dynamic> profile, String password) async {
/// Handles the login process, saving session data and triggering a full data sync.
Future<void> login(String token, Map<String, dynamic> profile) async {
_jwtToken = token; _jwtToken = token;
_userEmail = profile['email']; _userEmail = profile['email'];
_profileData = profile; // --- MODIFIED: Cache password on successful ONLINE login ---
_tempOfflinePassword = password;
final Map<String, dynamic> profileWithToken = Map.from(profile);
profileWithToken['token'] = token;
_profileData = profileWithToken;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(tokenKey, token); await prefs.setString(tokenKey, token);
await prefs.setString(userEmailKey, _userEmail!); await prefs.setString(userEmailKey, _userEmail!);
await prefs.setString(profileDataKey, jsonEncode(profile)); await prefs.setString(profileDataKey, jsonEncode(_profileData));
await _dbHelper.saveProfile(profile); await _dbHelper.saveProfile(_profileData!);
try {
debugPrint("AuthProvider: Hashing and caching password for offline login.");
final String hashedPassword = await compute(hashPassword, password);
await _dbHelper.upsertUserWithCredentials(
profile: profile,
passwordHash: hashedPassword,
);
debugPrint("AuthProvider: Credentials cached successfully.");
} catch (e) {
debugPrint("AuthProvider: Failed to cache password hash: $e");
}
debugPrint('AuthProvider: Login successful. Session and profile persisted.'); debugPrint('AuthProvider: Login successful. Session and profile persisted.');
// Perform a full refresh on login to ensure data is pristine.
await syncAllData(forceRefresh: true); await syncAllData(forceRefresh: true);
} }
Future<bool> loginOffline(String email, String password) async {
debugPrint("AuthProvider: Attempting offline login for user $email.");
try {
// 1. Retrieve stored hash from the local database based on email.
final String? storedHash = await _dbHelper.getUserPasswordHashByEmail(email);
if (storedHash == null || storedHash.isEmpty) {
debugPrint("AuthProvider DEBUG: Offline login failed for $email because NO HASH was found in local storage.");
return false;
}
// 2. Verify the provided password against the stored hash.
debugPrint("AuthProvider: Verifying password against stored hash...");
final bool passwordMatches = await compute(verifyPassword, CheckPasswordParams(password, storedHash));
if (passwordMatches) {
debugPrint("AuthProvider: Offline password verification successful.");
// 3. Load profile data from local storage.
final Map<String, dynamic>? cachedProfile = await _dbHelper.loadProfileByEmail(email);
if (cachedProfile == null) {
debugPrint("AuthProvider DEBUG: Offline login failed because profile data was missing, even though password matched.");
return false;
}
// 4. Initialize session state from cached profile data.
_jwtToken = "offline-session-${DateTime.now().millisecondsSinceEpoch}";
_userEmail = email;
// --- MODIFIED: Cache the password on successful OFFLINE login ---
_tempOfflinePassword = password;
final Map<String, dynamic> profileWithToken = Map.from(cachedProfile);
profileWithToken['token'] = _jwtToken;
_profileData = profileWithToken;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(tokenKey, _jwtToken!);
await prefs.setString(userEmailKey, _userEmail!);
await prefs.setString(profileDataKey, jsonEncode(_profileData));
await _loadDataFromCache();
notifyListeners();
return true;
} else {
debugPrint("AuthProvider DEBUG: Offline login failed because password did not match stored hash (Hash Mismatch).");
return false;
}
} catch (e) {
debugPrint("AuthProvider DEBUG: An unexpected error occurred during offline login process: $e");
return false;
}
}
Future<void> setProfileData(Map<String, dynamic> data) async { Future<void> setProfileData(Map<String, dynamic> data) async {
final String? existingToken = _profileData?['token'];
_profileData = data; _profileData = data;
if (_profileData != null && existingToken != null) {
_profileData!['token'] = existingToken;
}
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(profileDataKey, jsonEncode(data)); await prefs.setString(profileDataKey, jsonEncode(_profileData));
await _dbHelper.saveProfile(data); // Also save to local DB await _dbHelper.saveProfile(_profileData!); // Also save to local DB
notifyListeners(); notifyListeners();
} }
@ -282,6 +431,9 @@ class AuthProvider with ChangeNotifier {
_profileData = null; _profileData = null;
_lastSyncTimestamp = null; _lastSyncTimestamp = null;
_isFirstLogin = true; _isFirstLogin = true;
// --- MODIFIED: Clear temp password on logout ---
_tempOfflinePassword = null;
_allUsers = null; _allUsers = null;
_tarballStations = null; _tarballStations = null;
_manualStations = null; _manualStations = null;
@ -294,18 +446,14 @@ class AuthProvider with ChangeNotifier {
_airClients = null; _airClients = null;
_airManualStations = null; _airManualStations = null;
_states = null; _states = null;
// ADDED: Clear new data on logout
_appSettings = null; _appSettings = null;
_parameterLimits = null; _parameterLimits = null;
// --- ADDED: Clear documents list on logout ---
_documents = null; _documents = null;
_apiConfigs = null; _apiConfigs = null;
_ftpConfigs = null; _ftpConfigs = null;
// --- ADDED: Clear pending retry tasks on logout ---
_pendingRetries = null; _pendingRetries = null;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
// MODIFIED: Removed keys individually for safer logout
await prefs.remove(tokenKey); await prefs.remove(tokenKey);
await prefs.remove(userEmailKey); await prefs.remove(userEmailKey);
await prefs.remove(profileDataKey); await prefs.remove(profileDataKey);
@ -320,3 +468,17 @@ class AuthProvider with ChangeNotifier {
return _apiService.post('auth/forgot-password', {'email': email}); return _apiService.post('auth/forgot-password', {'email': email});
} }
} }
class CheckPasswordParams {
final String password;
final String hash;
CheckPasswordParams(this.password, this.hash);
}
String hashPassword(String password) {
return BCrypt.hashpw(password, BCrypt.gensalt());
}
bool verifyPassword(CheckPasswordParams params) {
return BCrypt.checkpw(params.password, params.hash);
}

View File

@ -43,117 +43,91 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// --- MODIFIED: Menu items now match the home pages ---
_menuItems = [ _menuItems = [
// ====== AIR ======
SidebarItem( SidebarItem(
// Reverted to IconData for the top-level 'Air' category
icon: Icons.cloud, icon: Icons.cloud,
label: "Air", label: "Air",
isParent: true, isParent: true,
children: [ children: [
// Added Manual sub-category for Air
SidebarItem( SidebarItem(
icon: Icons.handshake, // Example icon for Manual icon: Icons.handshake,
label: "Manual", label: "Manual",
isParent: true, isParent: true,
children: [ children: [
SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/manual/dashboard'), SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/air/manual/info'),
SidebarItem(icon: Icons.edit_note, label: "Manual Sampling", route: '/air/manual/manual-sampling'), SidebarItem(icon: Icons.construction, label: "Installation", route: '/air/manual/installation'),
SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/manual/report'), SidebarItem(icon: Icons.inventory_2, label: "Collection", route: '/air/manual/collection'),
SidebarItem(icon: Icons.article, label: "Data Log", route: '/air/manual/data-log'), SidebarItem(icon: Icons.article, label: "Data Log", route: '/air/manual/data-log'),
SidebarItem(icon: Icons.image, label: "Image Request", route: '/air/manual/image-request'),
], ],
), ),
SidebarItem( SidebarItem(
icon: Icons.trending_up, label: "Continuous", isParent: true, children: [ icon: Icons.trending_up, label: "Continuous", isParent: true, children: [
SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/continuous/dashboard'), SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/air/continuous/info'),
SidebarItem(icon: Icons.info, label: "Overview", route: '/air/continuous/overview'),
SidebarItem(icon: Icons.input, label: "Entry", route: '/air/continuous/entry'),
SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/continuous/report'),
]), ]),
SidebarItem( SidebarItem(
icon: Icons.search, label: "Investigative", isParent: true, children: [ icon: Icons.search, label: "Investigative", isParent: true, children: [
SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/investigative/dashboard'), SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/air/investigative/info'),
SidebarItem(icon: Icons.info, label: "Overview", route: '/air/investigative/overview'),
SidebarItem(icon: Icons.input, label: "Entry", route: '/air/investigative/entry'),
SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/investigative/report'),
]), ]),
], ],
), ),
// ====== RIVER ======
SidebarItem( SidebarItem(
// Reverted to IconData for the top-level 'River' category
icon: Icons.water, icon: Icons.water,
label: "River", label: "River",
isParent: true, isParent: true,
children: [ children: [
// Added Manual sub-category for River
SidebarItem( SidebarItem(
icon: Icons.handshake, // Example icon for Manual icon: Icons.handshake,
label: "Manual", label: "Manual",
isParent: true, isParent: true,
children: [ children: [
SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/river/manual/dashboard'), SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/river/manual/info'),
SidebarItem(icon: Icons.pin_drop, label: "In-Situ Sampling", route: '/river/manual/in-situ'), SidebarItem(icon: Icons.pin_drop, label: "In-Situ Sampling", route: '/river/manual/in-situ'),
SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/manual/report'),
SidebarItem(icon: Icons.date_range, label: "Triennial Sampling", route: '/river/manual/triennial'), SidebarItem(icon: Icons.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'),
SidebarItem(icon: Icons.image, label: "Image Request", route: '/river/manual/image-request'),
], ],
), ),
SidebarItem( SidebarItem(
icon: Icons.trending_up, label: "Continuous", isParent: true, children: [ icon: Icons.trending_up, label: "Continuous", isParent: true, children: [
SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/river/continuous/dashboard'), SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/river/continuous/info'),
SidebarItem(icon: Icons.info, label: "Overview", route: '/river/continuous/overview'),
SidebarItem(icon: Icons.input, label: "Entry", route: '/river/continuous/entry'),
SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/continuous/report'),
]), ]),
SidebarItem( SidebarItem(
icon: Icons.search, label: "Investigative", isParent: true, children: [ icon: Icons.search, label: "Investigative", isParent: true, children: [
SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/river/investigative/dashboard'), SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/river/investigative/info'),
SidebarItem(icon: Icons.info, label: "Overview", route: '/river/investigative/overview'),
SidebarItem(icon: Icons.input, label: "Entry", route: '/river/investigative/entry'),
SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/investigative/report'),
]), ]),
], ],
), ),
// ====== MARINE ======
SidebarItem( SidebarItem(
// Reverted to IconData for the top-level 'Marine' category
icon: Icons.sailing, icon: Icons.sailing,
label: "Marine", label: "Marine",
isParent: true, isParent: true,
children: [ children: [
// Added Manual sub-category for Marine
SidebarItem( SidebarItem(
icon: Icons.handshake, // Example icon for Manual icon: Icons.handshake,
label: "Manual", label: "Manual",
isParent: true, isParent: true,
children: [ children: [
SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/marine/manual/dashboard'),
SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/manual/info'), SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/manual/info'),
SidebarItem(icon: Icons.assignment, label: "Pre-Sampling", route: '/marine/manual/pre-sampling'), SidebarItem(icon: Icons.assignment, label: "Pre-Sampling", route: '/marine/manual/pre-sampling'),
SidebarItem(icon: Icons.pin_drop, label: "In-Situ Sampling", route: '/marine/manual/in-situ'), SidebarItem(icon: Icons.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.receipt_long, label: "Report", route: '/marine/manual/report'),
SidebarItem(icon: Icons.article, label: "Data Log", route: '/marine/manual/data-log'), SidebarItem(icon: Icons.article, label: "Data Log", route: '/marine/manual/data-log'),
SidebarItem(icon: Icons.image, label: "Image Request", route: '/marine/manual/image-request'),
], ],
), ),
SidebarItem( SidebarItem(
icon: Icons.trending_up, label: "Continuous", isParent: true, children: [ icon: Icons.trending_up, label: "Continuous", isParent: true, children: [
SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/marine/continuous/dashboard'), SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/continuous/info'),
SidebarItem(icon: Icons.info, label: "Overview", route: '/marine/continuous/overview'),
SidebarItem(icon: Icons.input, label: "Entry", route: '/marine/continuous/entry'),
SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/continuous/report'),
]), ]),
SidebarItem( SidebarItem(
icon: Icons.search, label: "Investigative", isParent: true, children: [ icon: Icons.search, label: "Investigative", isParent: true, children: [
SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/marine/investigative/dashboard'), SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/investigative/info'),
SidebarItem(icon: Icons.info, label: "Overview", route: '/marine/investigative/overview'),
SidebarItem(icon: Icons.input, label: "Entry", route: '/marine/investigative/entry'),
SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/investigative/report'),
]), ]),
], ],
), ),
// Added Settings menu item // ====== SETTINGS ======
SidebarItem(icon: Icons.settings, label: "Settings", route: '/settings'), SidebarItem(icon: Icons.settings, label: "Settings", route: '/settings'),
]; ];
} }
@ -202,8 +176,7 @@ class _CollapsibleSidebarState extends State<CollapsibleSidebar> {
Widget _buildExpandableNavItem(SidebarItem item) { Widget _buildExpandableNavItem(SidebarItem item) {
if (item.children == null || item.children!.isEmpty) { if (item.children == null || item.children!.isEmpty) {
// This case handles a top-level item that is NOT a parent, // This case handles a top-level item that is NOT a parent,
// but if you have such an item, it should probably go through _buildNavItem directly. // like the "Settings" item.
// For this structure, all top-level items are parents.
return _buildNavItem(item.icon, item.label, item.route ?? '', isTopLevel: true, imagePath: item.imagePath); return _buildNavItem(item.icon, item.label, item.route ?? '', isTopLevel: true, imagePath: item.imagePath);
} }

View File

@ -124,24 +124,63 @@ void main() async {
} }
void setupServices(TelegramService telegramService) { void setupServices(TelegramService telegramService) {
// Initial alert processing on startup (delayed)
Future.delayed(const Duration(seconds: 5), () { Future.delayed(const Duration(seconds: 5), () {
debugPrint("[Main] Performing initial alert queue processing on app start."); debugPrint("[Main] Performing initial alert queue processing on app start.");
telegramService.processAlertQueue(); telegramService.processAlertQueue();
}); });
Connectivity().onConnectivityChanged.listen((List<ConnectivityResult> results) { // Connectivity listener moved to RootApp to access AuthProvider context.
if (results.contains(ConnectivityResult.mobile) || results.contains(ConnectivityResult.wifi)) {
debugPrint("[Main] Internet connection detected. Triggering alert queue processing.");
telegramService.processAlertQueue();
} else {
debugPrint("[Main] Internet connection lost.");
}
});
} }
class RootApp extends StatelessWidget { // --- START: MODIFIED RootApp ---
class RootApp extends StatefulWidget {
const RootApp({super.key}); const RootApp({super.key});
@override
State<RootApp> createState() => _RootAppState();
}
class _RootAppState extends State<RootApp> {
@override
void initState() {
super.initState();
_initializeConnectivityListener();
_performInitialSessionCheck();
}
/// Initial check when app loads to see if we need to transition from offline to online.
void _performInitialSessionCheck() async {
// Wait a moment for providers to be fully available.
await Future.delayed(const Duration(milliseconds: 100));
if (mounted) {
Provider.of<AuthProvider>(context, listen: false).checkAndTransitionToOnlineSession();
}
}
/// Listens for connectivity changes to trigger auto-relogin or queue processing.
void _initializeConnectivityListener() {
Connectivity().onConnectivityChanged.listen((List<ConnectivityResult> results) {
if (!results.contains(ConnectivityResult.none)) {
debugPrint("[Main] Internet connection detected.");
if (mounted) {
// Access services from provider context
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final telegramService = Provider.of<TelegramService>(context, listen: false);
// Attempt to auto-relogin if necessary
authProvider.checkAndTransitionToOnlineSession();
// Process alert queue
telegramService.processAlertQueue();
}
} else {
debugPrint("[Main] Internet connection lost.");
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<AuthProvider>( return Consumer<AuthProvider>(
@ -241,7 +280,7 @@ class RootApp extends StatelessWidget {
'/marine/manual/in-situ': (context) => marineManualInSituSampling.MarineInSituSampling(), '/marine/manual/in-situ': (context) => marineManualInSituSampling.MarineInSituSampling(),
'/marine/manual/tarball': (context) => const TarballSamplingStep1(), '/marine/manual/tarball': (context) => const TarballSamplingStep1(),
'/marine/manual/report': (context) => marineManualReport.MarineManualReport(), '/marine/manual/report': (context) => marineManualReport.MarineManualReport(),
'/marine/manual/data-log': (context) => marineManualDataStatusLog.MarineManualDataStatusLog(), //'/marine/manual/data-log': (context) => marineManualDataStatusLog.MarineManualDataStatusLog(), // This is handled in onGenerateRoute
'/marine/manual/image-request': (context) => marineManualImageRequest.MarineManualImageRequest(), '/marine/manual/image-request': (context) => marineManualImageRequest.MarineManualImageRequest(),
// Marine Continuous // Marine Continuous
@ -261,6 +300,7 @@ class RootApp extends StatelessWidget {
); );
} }
} }
// --- END: MODIFIED RootApp ---
class SplashScreen extends StatelessWidget { class SplashScreen extends StatelessWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@ -273,7 +313,7 @@ class SplashScreen extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Image.asset( Image.asset(
'assets/icon4.png', 'assets/icon4.png', // Ensure this asset exists
height: 360, height: 360,
width: 360, width: 360,
), ),

View File

@ -68,13 +68,14 @@ class InSituSamplingData {
String? submissionMessage; String? submissionMessage;
String? reportId; String? reportId;
// REPAIRED: Added a constructor to accept initial values.
InSituSamplingData({ InSituSamplingData({
this.samplingDate, this.samplingDate,
this.samplingTime, this.samplingTime,
}); });
// --- ADDED: Factory constructor to create a new instance from a map --- /// Creates an InSituSamplingData object from a JSON map.
/// This is critical for the offline retry mechanism. The keys used here MUST perfectly
/// match the keys used in the `toDbJson()` method to ensure data integrity.
factory InSituSamplingData.fromJson(Map<String, dynamic> json) { factory InSituSamplingData.fromJson(Map<String, dynamic> json) {
double? doubleFromJson(dynamic value) { double? doubleFromJson(dynamic value) {
if (value is num) return value.toDouble(); if (value is num) return value.toDouble();
@ -88,62 +89,70 @@ class InSituSamplingData {
return null; return null;
} }
// Helper to create a File object from a path string
File? fileFromPath(dynamic path) { File? fileFromPath(dynamic path) {
return (path is String && path.isNotEmpty) ? File(path) : null; return (path is String && path.isNotEmpty) ? File(path) : null;
} }
return InSituSamplingData() final data = InSituSamplingData();
..firstSamplerName = json['first_sampler_name']
..firstSamplerUserId = intFromJson(json['first_sampler_user_id']) // START FIX: Aligned all keys to perfectly match the toDbJson() method and added backward compatibility.
..secondSampler = json['secondSampler'] data.firstSamplerName = json['first_sampler_name'];
..samplingDate = json['man_date'] data.firstSamplerUserId = intFromJson(json['first_sampler_user_id']);
..samplingTime = json['man_time'] data.secondSampler = json['secondSampler'] ?? json['second_sampler'];
..samplingType = json['man_type'] data.samplingDate = json['sampling_date'] ?? json['man_date'];
..sampleIdCode = json['man_sample_id_code'] data.samplingTime = json['sampling_time'] ?? json['man_time'];
..selectedStateName = json['selectedStateName'] data.samplingType = json['sampling_type'];
..selectedCategoryName = json['selectedCategoryName'] data.sampleIdCode = json['sample_id_code'];
..selectedStation = json['selectedStation'] data.selectedStateName = json['selected_state_name'];
..stationLatitude = json['stationLatitude'] data.selectedCategoryName = json['selected_category_name'];
..stationLongitude = json['stationLongitude'] data.selectedStation = json['selectedStation'];
..currentLatitude = json['man_current_latitude']?.toString() data.stationLatitude = json['station_latitude'];
..currentLongitude = json['man_current_longitude']?.toString() data.stationLongitude = json['station_longitude'];
..distanceDifferenceInKm = doubleFromJson(json['man_distance_difference']) data.currentLatitude = json['current_latitude']?.toString();
..distanceDifferenceRemarks = json['man_distance_difference_remarks'] data.currentLongitude = json['current_longitude']?.toString();
..weather = json['man_weather'] data.distanceDifferenceInKm = doubleFromJson(json['distance_difference_in_km']);
..tideLevel = json['man_tide_level'] data.distanceDifferenceRemarks = json['distance_difference_remarks'];
..seaCondition = json['man_sea_condition'] data.weather = json['weather'];
..eventRemarks = json['man_event_remark'] data.tideLevel = json['tide_level'];
..labRemarks = json['man_lab_remark'] data.seaCondition = json['sea_condition'];
..sondeId = json['man_sondeID'] data.eventRemarks = json['event_remarks'];
..dataCaptureDate = json['data_capture_date'] data.labRemarks = json['lab_remarks'];
..dataCaptureTime = json['data_capture_time'] data.optionalRemark1 = json['man_optional_photo_01_remarks'];
..oxygenConcentration = doubleFromJson(json['man_oxygen_conc']) data.optionalRemark2 = json['man_optional_photo_02_remarks'];
..oxygenSaturation = doubleFromJson(json['man_oxygen_sat']) data.optionalRemark3 = json['man_optional_photo_03_remarks'];
..ph = doubleFromJson(json['man_ph']) data.optionalRemark4 = json['man_optional_photo_04_remarks'];
..salinity = doubleFromJson(json['man_salinity']) data.sondeId = json['sonde_id'];
..electricalConductivity = doubleFromJson(json['man_conductivity']) data.dataCaptureDate = json['data_capture_date'];
..temperature = doubleFromJson(json['man_temperature']) data.dataCaptureTime = json['data_capture_time'];
..tds = doubleFromJson(json['man_tds']) data.oxygenConcentration = doubleFromJson(json['oxygen_concentration']);
..turbidity = doubleFromJson(json['man_turbidity']) data.oxygenSaturation = doubleFromJson(json['oxygen_saturation']);
..tss = doubleFromJson(json['man_tss']) data.ph = doubleFromJson(json['ph']);
..batteryVoltage = doubleFromJson(json['man_battery_volt']) data.salinity = doubleFromJson(json['salinity']);
..optionalRemark1 = json['man_optional_photo_01_remarks'] data.electricalConductivity = doubleFromJson(json['electrical_conductivity']);
..optionalRemark2 = json['man_optional_photo_02_remarks'] data.temperature = doubleFromJson(json['temperature']);
..optionalRemark3 = json['man_optional_photo_03_remarks'] data.tds = doubleFromJson(json['tds']);
..optionalRemark4 = json['man_optional_photo_04_remarks'] data.turbidity = doubleFromJson(json['turbidity']);
..leftLandViewImage = fileFromPath(json['man_left_side_land_view']) data.tss = doubleFromJson(json['tss']);
..rightLandViewImage = fileFromPath(json['man_right_side_land_view']) data.batteryVoltage = doubleFromJson(json['battery_voltage']);
..waterFillingImage = fileFromPath(json['man_filling_water_into_sample_bottle']) data.submissionStatus = json['submission_status'];
..seawaterColorImage = fileFromPath(json['man_seawater_in_clear_glass_bottle']) data.submissionMessage = json['submission_message'];
..phPaperImage = fileFromPath(json['man_examine_preservative_ph_paper']) data.reportId = json['report_id']?.toString();
..optionalImage1 = fileFromPath(json['man_optional_photo_01'])
..optionalImage2 = fileFromPath(json['man_optional_photo_02']) // Image paths are added by LocalStorageService, not toDbJson, so they are read separately.
..optionalImage3 = fileFromPath(json['man_optional_photo_03']) data.leftLandViewImage = fileFromPath(json['man_left_side_land_view']);
..optionalImage4 = fileFromPath(json['man_optional_photo_04']); data.rightLandViewImage = fileFromPath(json['man_right_side_land_view']);
data.waterFillingImage = fileFromPath(json['man_filling_water_into_sample_bottle']);
data.seawaterColorImage = fileFromPath(json['man_seawater_in_clear_glass_bottle']);
data.phPaperImage = fileFromPath(json['man_examine_preservative_ph_paper']);
data.optionalImage1 = fileFromPath(json['man_optional_photo_01']);
data.optionalImage2 = fileFromPath(json['man_optional_photo_02']);
data.optionalImage3 = fileFromPath(json['man_optional_photo_03']);
data.optionalImage4 = fileFromPath(json['man_optional_photo_04']);
// END FIX
return data;
} }
/// Generates a formatted Telegram alert message for successful submissions.
String generateTelegramAlertMessage({required bool isDataOnly}) { String generateTelegramAlertMessage({required bool isDataOnly}) {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = selectedStation?['man_station_name'] ?? 'N/A'; final stationName = selectedStation?['man_station_name'] ?? 'N/A';
@ -172,24 +181,32 @@ class InSituSamplingData {
return buffer.toString(); return buffer.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 = {};
// Helper to add non-null values to the map
void add(String key, dynamic value) { void add(String key, dynamic value) {
if (value != null && value.toString().isNotEmpty) { 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;
}
} }
} }
// --- Required fields that were missing or incorrect ---
add('station_id', selectedStation?['station_id']); add('station_id', selectedStation?['station_id']);
add('man_date', samplingDate); add('man_date', samplingDate);
add('man_time', samplingTime); add('man_time', samplingTime);
add('first_sampler_user_id', firstSamplerUserId); add('first_sampler_user_id', firstSamplerUserId);
// --- Other Step 1 Data ---
add('man_second_sampler_id', secondSampler?['user_id']); add('man_second_sampler_id', secondSampler?['user_id']);
add('man_type', samplingType); add('man_type', samplingType);
add('man_sample_id_code', sampleIdCode); add('man_sample_id_code', sampleIdCode);
@ -197,8 +214,6 @@ class InSituSamplingData {
add('man_current_longitude', currentLongitude); add('man_current_longitude', currentLongitude);
add('man_distance_difference', distanceDifferenceInKm); add('man_distance_difference', distanceDifferenceInKm);
add('man_distance_difference_remarks', distanceDifferenceRemarks); add('man_distance_difference_remarks', distanceDifferenceRemarks);
// --- Step 2 Data ---
add('man_weather', weather); add('man_weather', weather);
add('man_tide_level', tideLevel); add('man_tide_level', tideLevel);
add('man_sea_condition', seaCondition); add('man_sea_condition', seaCondition);
@ -208,8 +223,6 @@ class InSituSamplingData {
add('man_optional_photo_02_remarks', optionalRemark2); add('man_optional_photo_02_remarks', optionalRemark2);
add('man_optional_photo_03_remarks', optionalRemark3); add('man_optional_photo_03_remarks', optionalRemark3);
add('man_optional_photo_04_remarks', optionalRemark4); add('man_optional_photo_04_remarks', optionalRemark4);
// --- Step 3 Data ---
add('man_sondeID', sondeId); add('man_sondeID', sondeId);
add('data_capture_date', dataCaptureDate); add('data_capture_date', dataCaptureDate);
add('data_capture_time', dataCaptureTime); add('data_capture_time', dataCaptureTime);
@ -223,8 +236,6 @@ class InSituSamplingData {
add('man_turbidity', turbidity); add('man_turbidity', turbidity);
add('man_tss', tss); add('man_tss', tss);
add('man_battery_volt', batteryVoltage); add('man_battery_volt', batteryVoltage);
// --- Human-readable fields for server-side alerts ---
add('first_sampler_name', firstSamplerName); add('first_sampler_name', firstSamplerName);
add('man_station_code', selectedStation?['man_station_code']); add('man_station_code', selectedStation?['man_station_code']);
add('man_station_name', selectedStation?['man_station_name']); add('man_station_name', selectedStation?['man_station_name']);
@ -232,7 +243,6 @@ class InSituSamplingData {
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 {
'man_left_side_land_view': leftLandViewImage, 'man_left_side_land_view': leftLandViewImage,
@ -247,19 +257,20 @@ class InSituSamplingData {
}; };
} }
/// Creates a single JSON object with all submission data, mimicking 'db.json'. /// Creates a single JSON object with all submission data for offline storage.
/// The keys here are the single source of truth for the offline data format.
Map<String, dynamic> toDbJson() { Map<String, dynamic> toDbJson() {
return { return {
'first_sampler_name': firstSamplerName, 'first_sampler_name': firstSamplerName,
'first_sampler_user_id': firstSamplerUserId, 'first_sampler_user_id': firstSamplerUserId,
'second_sampler': secondSampler, 'secondSampler': secondSampler,
'sampling_date': samplingDate, 'sampling_date': samplingDate,
'sampling_time': samplingTime, 'sampling_time': samplingTime,
'sampling_type': samplingType, 'sampling_type': samplingType,
'sample_id_code': sampleIdCode, 'sample_id_code': sampleIdCode,
'selected_state_name': selectedStateName, 'selected_state_name': selectedStateName,
'selected_category_name': selectedCategoryName, 'selected_category_name': selectedCategoryName,
'selected_station': selectedStation, 'selectedStation': selectedStation,
'station_latitude': stationLatitude, 'station_latitude': stationLatitude,
'station_longitude': stationLongitude, 'station_longitude': stationLongitude,
'current_latitude': currentLatitude, 'current_latitude': currentLatitude,
@ -271,6 +282,10 @@ class InSituSamplingData {
'sea_condition': seaCondition, 'sea_condition': seaCondition,
'event_remarks': eventRemarks, 'event_remarks': eventRemarks,
'lab_remarks': labRemarks, 'lab_remarks': labRemarks,
'man_optional_photo_01_remarks': optionalRemark1,
'man_optional_photo_02_remarks': optionalRemark2,
'man_optional_photo_03_remarks': optionalRemark3,
'man_optional_photo_04_remarks': optionalRemark4,
'sonde_id': sondeId, 'sonde_id': sondeId,
'data_capture_date': dataCaptureDate, 'data_capture_date': dataCaptureDate,
'data_capture_time': dataCaptureTime, 'data_capture_time': dataCaptureTime,
@ -289,48 +304,4 @@ class InSituSamplingData {
'report_id': reportId, 'report_id': reportId,
}; };
} }
/// Creates a JSON object for basic form info, mimicking 'basic_form.json'.
Map<String, dynamic> toBasicFormJson() {
return {
'tech_name': firstSamplerName,
'sampler_2ndname': secondSampler?['user_name'],
'sample_date': samplingDate,
'sample_time': samplingTime,
'sampling_type': samplingType,
'sample_state': selectedStateName,
'station_id': selectedStation?['man_station_code'],
'station_latitude': stationLatitude,
'station_longitude': stationLongitude,
'latitude': currentLatitude,
'longitude': currentLongitude,
'sample_id': sampleIdCode,
};
}
/// Creates a JSON object for sensor readings, mimicking 'reading.json'.
Map<String, dynamic> toReadingJson() {
return {
'do_mgl': oxygenConcentration,
'do_sat': oxygenSaturation,
'ph': ph,
'salinity': salinity,
'temperature': temperature,
'turbidity': turbidity,
'tds': tds,
'electric_conductivity': electricalConductivity,
'flowrate': null, // This is not collected in marine in-situ
'date_sampling_reading': dataCaptureDate,
'time_sampling_reading': dataCaptureTime,
};
}
/// Creates a JSON object for manual info, mimicking 'manual_info.json'.
Map<String, dynamic> toManualInfoJson() {
return {
'weather': weather,
'remarks_event': eventRemarks,
'remarks_lab': labRemarks,
};
}
} }

View File

@ -86,7 +86,6 @@ class RiverInSituSamplingData {
return (path is String && path.isNotEmpty) ? File(path) : null; return (path is String && path.isNotEmpty) ? File(path) : null;
} }
// FIX: Robust helper functions for parsing numerical values
double? doubleFromJson(dynamic value) { double? doubleFromJson(dynamic value) {
if (value is num) return value.toDouble(); if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value); if (value is String) return double.tryParse(value);
@ -99,62 +98,63 @@ class RiverInSituSamplingData {
return null; return null;
} }
// --- START: MODIFIED FOR CONSISTENT SERIALIZATION ---
// Keys now match toMap() for reliability, with fallback to old API keys for backward compatibility.
return RiverInSituSamplingData() return RiverInSituSamplingData()
..firstSamplerName = json['first_sampler_name'] ..firstSamplerName = json['firstSamplerName'] ?? json['first_sampler_name']
// MODIFIED THIS LINE TO USE THE HELPER FUNCTION ..firstSamplerUserId = intFromJson(json['firstSamplerUserId'] ?? json['first_sampler_user_id'])
..firstSamplerUserId = intFromJson(json['first_sampler_user_id'])
..secondSampler = json['secondSampler'] ..secondSampler = json['secondSampler']
..samplingDate = json['r_man_date'] ..samplingDate = json['samplingDate'] ?? json['r_man_date']
..samplingTime = json['r_man_time'] ..samplingTime = json['samplingTime'] ?? json['r_man_time']
..samplingType = json['r_man_type'] ..samplingType = json['samplingType'] ?? json['r_man_type']
..sampleIdCode = json['r_man_sample_id_code'] ..sampleIdCode = json['sampleIdCode'] ?? json['r_man_sample_id_code']
..selectedStateName = json['selectedStateName'] ..selectedStateName = json['selectedStateName']
..selectedCategoryName = json['selectedCategoryName'] ..selectedCategoryName = json['selectedCategoryName']
..selectedStation = json['selectedStation'] ..selectedStation = json['selectedStation']
..stationLatitude = json['stationLatitude'] ..stationLatitude = json['stationLatitude']
..stationLongitude = json['stationLongitude'] ..stationLongitude = json['stationLongitude']
..currentLatitude = json['r_man_current_latitude']?.toString() ..currentLatitude = (json['currentLatitude'] ?? json['r_man_current_latitude'])?.toString()
..currentLongitude = json['r_man_current_longitude']?.toString() ..currentLongitude = (json['currentLongitude'] ?? json['r_man_current_longitude'])?.toString()
..distanceDifferenceInKm = doubleFromJson(json['r_man_distance_difference']) ..distanceDifferenceInKm = doubleFromJson(json['distanceDifferenceInKm'] ?? json['r_man_distance_difference'])
..distanceDifferenceRemarks = json['r_man_distance_difference_remarks'] ..distanceDifferenceRemarks = json['distanceDifferenceRemarks'] ?? json['r_man_distance_difference_remarks']
..weather = json['r_man_weather'] ..weather = json['weather'] ?? json['r_man_weather']
..eventRemarks = json['r_man_event_remark'] ..eventRemarks = json['eventRemarks'] ?? json['r_man_event_remark']
..labRemarks = json['r_man_lab_remark'] ..labRemarks = json['labRemarks'] ?? json['r_man_lab_remark']
..sondeId = json['r_man_sondeID'] ..sondeId = json['sondeId'] ?? json['r_man_sondeID']
..dataCaptureDate = json['data_capture_date'] ..dataCaptureDate = json['dataCaptureDate'] ?? json['data_capture_date']
..dataCaptureTime = json['data_capture_time'] ..dataCaptureTime = json['dataCaptureTime'] ?? json['data_capture_time']
// FIX: Apply doubleFromJson helper to all numerical fields ..oxygenConcentration = doubleFromJson(json['oxygenConcentration'] ?? json['r_man_oxygen_conc'])
..oxygenConcentration = doubleFromJson(json['r_man_oxygen_conc']) ..oxygenSaturation = doubleFromJson(json['oxygenSaturation'] ?? json['r_man_oxygen_sat'])
..oxygenSaturation = doubleFromJson(json['r_man_oxygen_sat']) ..ph = doubleFromJson(json['ph'] ?? json['r_man_ph'])
..ph = doubleFromJson(json['r_man_ph']) ..salinity = doubleFromJson(json['salinity'] ?? json['r_man_salinity'])
..salinity = doubleFromJson(json['r_man_salinity']) ..electricalConductivity = doubleFromJson(json['electricalConductivity'] ?? json['r_man_conductivity'])
..electricalConductivity = doubleFromJson(json['r_man_conductivity']) ..temperature = doubleFromJson(json['temperature'] ?? json['r_man_temperature'])
..temperature = doubleFromJson(json['r_man_temperature']) ..tds = doubleFromJson(json['tds'] ?? json['r_man_tds'])
..tds = doubleFromJson(json['r_man_tds']) ..turbidity = doubleFromJson(json['turbidity'] ?? json['r_man_turbidity'])
..turbidity = doubleFromJson(json['r_man_turbidity']) ..ammonia = doubleFromJson(json['ammonia'] ?? json['r_man_ammonia'])
..ammonia = doubleFromJson(json['r_man_ammonia']) // MODIFIED: Replaced tss with ammonia ..batteryVoltage = doubleFromJson(json['batteryVoltage'] ?? json['r_man_battery_volt'])
..batteryVoltage = doubleFromJson(json['r_man_battery_volt']) ..optionalRemark1 = json['optionalRemark1'] ?? json['r_man_optional_photo_01_remarks']
// END FIX ..optionalRemark2 = json['optionalRemark2'] ?? json['r_man_optional_photo_02_remarks']
..optionalRemark1 = json['r_man_optional_photo_01_remarks'] ..optionalRemark3 = json['optionalRemark3'] ?? json['r_man_optional_photo_03_remarks']
..optionalRemark2 = json['r_man_optional_photo_02_remarks'] ..optionalRemark4 = json['optionalRemark4'] ?? json['r_man_optional_photo_04_remarks']
..optionalRemark3 = json['r_man_optional_photo_03_remarks'] ..backgroundStationImage = fileFromJson(json['backgroundStationImage'] ?? json['r_man_background_station'])
..optionalRemark4 = json['r_man_optional_photo_04_remarks'] ..upstreamRiverImage = fileFromJson(json['upstreamRiverImage'] ?? json['r_man_upstream_river'])
..backgroundStationImage = fileFromJson(json['r_man_background_station']) ..downstreamRiverImage = fileFromJson(json['downstreamRiverImage'] ?? json['r_man_downstream_river'])
..upstreamRiverImage = fileFromJson(json['r_man_upstream_river']) ..sampleTurbidityImage = fileFromJson(json['sampleTurbidityImage'] ?? json['r_man_sample_turbidity'])
..downstreamRiverImage = fileFromJson(json['r_man_downstream_river']) ..optionalImage1 = fileFromJson(json['optionalImage1'] ?? json['r_man_optional_photo_01'])
..sampleTurbidityImage = fileFromJson(json['r_man_sample_turbidity']) ..optionalImage2 = fileFromJson(json['optionalImage2'] ?? json['r_man_optional_photo_02'])
..optionalImage1 = fileFromJson(json['r_man_optional_photo_01']) ..optionalImage3 = fileFromJson(json['optionalImage3'] ?? json['r_man_optional_photo_03'])
..optionalImage2 = fileFromJson(json['r_man_optional_photo_02']) ..optionalImage4 = fileFromJson(json['optionalImage4'] ?? json['r_man_optional_photo_04'])
..optionalImage3 = fileFromJson(json['r_man_optional_photo_03']) ..flowrateMethod = json['flowrateMethod'] ?? json['r_man_flowrate_method']
..optionalImage4 = fileFromJson(json['r_man_optional_photo_04']) ..flowrateSurfaceDrifterHeight = doubleFromJson(json['flowrateSurfaceDrifterHeight'] ?? json['r_man_flowrate_sd_height'])
// ADDED: Flowrate fields from JSON ..flowrateSurfaceDrifterDistance = doubleFromJson(json['flowrateSurfaceDrifterDistance'] ?? json['r_man_flowrate_sd_distance'])
..flowrateMethod = json['r_man_flowrate_method'] ..flowrateSurfaceDrifterTimeFirst = json['flowrateSurfaceDrifterTimeFirst'] ?? json['r_man_flowrate_sd_time_first']
// FIX: Apply doubleFromJson helper to all new numerical flowrate fields ..flowrateSurfaceDrifterTimeLast = json['flowrateSurfaceDrifterTimeLast'] ?? json['r_man_flowrate_sd_time_last']
..flowrateSurfaceDrifterHeight = doubleFromJson(json['r_man_flowrate_sd_height']) ..flowrateValue = doubleFromJson(json['flowrateValue'] ?? json['r_man_flowrate_value'])
..flowrateSurfaceDrifterDistance = doubleFromJson(json['r_man_flowrate_sd_distance']) ..submissionStatus = json['submissionStatus']
..flowrateSurfaceDrifterTimeFirst = json['r_man_flowrate_sd_time_first'] ..submissionMessage = json['submissionMessage']
..flowrateSurfaceDrifterTimeLast = json['r_man_flowrate_sd_time_last'] ..reportId = json['reportId']?.toString();
..flowrateValue = doubleFromJson(json['r_man_flowrate_value']); // --- END: MODIFIED FOR CONSISTENT SERIALIZATION ---
} }

View File

@ -1,6 +1,10 @@
// lib/screens/login.dart
import 'dart:async'; // Import for TimeoutException
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; // Keep for potential future use, though not strictly necessary for the new logic
import 'package:environment_monitoring_app/services/api_service.dart'; import 'package:environment_monitoring_app/services/api_service.dart';
import 'package:environment_monitoring_app/auth_provider.dart'; import 'package:environment_monitoring_app/auth_provider.dart';
@ -17,7 +21,6 @@ class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final TextEditingController _emailController = TextEditingController(); final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
// FIX: Removed direct instantiation of ApiService
bool _isLoading = false; bool _isLoading = false;
String _errorMessage = ''; String _errorMessage = '';
@ -39,60 +42,75 @@ class _LoginScreenState extends State<LoginScreen> {
}); });
final auth = Provider.of<AuthProvider>(context, listen: false); final auth = Provider.of<AuthProvider>(context, listen: false);
// FIX: Retrieve ApiService from the Provider tree
final apiService = Provider.of<ApiService>(context, listen: false); final apiService = Provider.of<ApiService>(context, listen: false);
final String email = _emailController.text.trim();
final String password = _passwordController.text.trim();
// --- START: MODIFIED Internet-First Strategy with Improved Fallback ---
try {
// --- Attempt 1: Online Login ---
debugPrint("Login attempt: Trying online authentication...");
final Map<String, dynamic> result = await apiService
.login(email, password)
.timeout(const Duration(seconds: 10));
if (result['success'] == true) {
// --- Online Success ---
final String token = result['data']['token'];
final Map<String, dynamic> profile = result['data']['profile'];
await auth.login(token, profile, password);
if (auth.isFirstLogin) {
await auth.setIsFirstLogin(false);
}
// --- Offline Check for First Login ---
if (auth.isFirstLogin) {
final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult == ConnectivityResult.none) {
if (!mounted) return; if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const HomePage()),
);
} else {
// --- Online Failure (API Error) ---
setState(() { setState(() {
_isLoading = false; _errorMessage = result['message'] ?? 'Invalid email or password.';
_errorMessage = 'An internet connection is required for the first login to sync initial data.';
}); });
_showSnackBar(_errorMessage, isError: true); _showSnackBar(_errorMessage, isError: true);
return; }
} on TimeoutException catch (_) {
// --- Online Failure (Timeout) ---
debugPrint("Login attempt: Online request timed out after 10 seconds. Triggering offline fallback.");
_showSnackBar("Slow connection detected. Trying offline login...", isError: true);
await _attemptOfflineLogin(auth, email, password);
} catch (e) {
// --- Online Failure (Other Network Error, e.g., SocketException) ---
debugPrint("Login attempt: Network error ($e). Triggering offline fallback immediately.");
_showSnackBar("Connection failed. Trying offline login...", isError: true);
// FIX: Removed the unreliable connectivity check. Treat all exceptions here as a reason to try offline.
await _attemptOfflineLogin(auth, email, password);
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
} }
} }
// --- END: MODIFIED Internet-First Strategy ---
}
// --- API Call --- /// Helper function to perform offline validation and update UI.
final Map<String, dynamic> result = await apiService.login( // FIX: Use retrieved instance Future<void> _attemptOfflineLogin(AuthProvider auth, String email, String password) async {
_emailController.text.trim(), final bool offlineSuccess = await auth.loginOffline(email, password);
_passwordController.text.trim(),
);
if (!mounted) return; if (mounted) {
if (offlineSuccess) {
if (result['success'] == true) { Navigator.of(context).pushReplacement(
// --- Update AuthProvider --- MaterialPageRoute(builder: (context) => const HomePage()),
final String token = result['data']['token']; );
// CORRECTED: The API now returns a 'profile' object on login, not 'user'. } else {
final Map<String, dynamic> profile = result['data']['profile']; setState(() {
_errorMessage = "Offline login failed. Check credentials or connect to internet to sync.";
// The login method in AuthProvider now handles setting the token, profile, });
// and triggering the full data sync. _showSnackBar(_errorMessage, isError: true);
await auth.login(token, profile);
if (auth.isFirstLogin) {
await auth.setIsFirstLogin(false);
} }
if (!mounted) return;
// Navigate to the home screen
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const HomePage()),
);
} else {
// Login failed, show error message
setState(() {
_isLoading = false;
_errorMessage = result['message'] ?? 'An unknown error occurred.';
});
_showSnackBar(_errorMessage, isError: true);
} }
} }

View File

@ -107,14 +107,22 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
final List<SubmissionLogEntry> tempTarball = []; final List<SubmissionLogEntry> tempTarball = [];
for (var log in inSituLogs) { for (var log in inSituLogs) {
final String dateStr = log['samplingDate'] ?? ''; // START FIX: Use backward-compatible keys to read the timestamp
final String timeStr = log['samplingTime'] ?? ''; final String dateStr = log['sampling_date'] ?? log['man_date'] ?? '';
final String timeStr = log['sampling_time'] ?? log['man_time'] ?? '';
// END FIX
// --- START FIX: Prevent fallback to DateTime.now() to make errors visible ---
final dt = DateTime.tryParse('$dateStr $timeStr');
// --- END FIX ---
tempManual.add(SubmissionLogEntry( tempManual.add(SubmissionLogEntry(
type: 'Manual Sampling', type: 'Manual Sampling',
title: log['selectedStation']?['man_station_name'] ?? 'Unknown Station', title: log['selectedStation']?['man_station_name'] ?? 'Unknown Station',
stationCode: log['selectedStation']?['man_station_code'] ?? 'N/A', stationCode: log['selectedStation']?['man_station_code'] ?? 'N/A',
submissionDateTime: DateTime.tryParse('$dateStr $timeStr') ?? DateTime.now(), // --- START FIX: Use the parsed date or a placeholder for invalid entries ---
submissionDateTime: dt ?? DateTime.fromMillisecondsSinceEpoch(0),
// --- END FIX ---
reportId: log['reportId']?.toString(), reportId: log['reportId']?.toString(),
status: log['submissionStatus'] ?? 'L1', status: log['submissionStatus'] ?? 'L1',
message: log['submissionMessage'] ?? 'No status message.', message: log['submissionMessage'] ?? 'No status message.',
@ -126,11 +134,14 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
} }
for (var log in tarballLogs) { for (var log in tarballLogs) {
final dateStr = log['sampling_date'] ?? '';
final timeStr = log['sampling_time'] ?? '';
tempTarball.add(SubmissionLogEntry( tempTarball.add(SubmissionLogEntry(
type: 'Tarball Sampling', type: 'Tarball Sampling',
title: log['selectedStation']?['tbl_station_name'] ?? 'Unknown Station', title: log['selectedStation']?['tbl_station_name'] ?? 'Unknown Station',
stationCode: log['selectedStation']?['tbl_station_code'] ?? 'N/A', stationCode: log['selectedStation']?['tbl_station_code'] ?? 'N/A',
submissionDateTime: DateTime.tryParse('${log['sampling_date']} ${log['sampling_time']}') ?? DateTime.now(), submissionDateTime: DateTime.tryParse('$dateStr $timeStr') ?? DateTime.fromMillisecondsSinceEpoch(0),
reportId: log['reportId']?.toString(), reportId: log['reportId']?.toString(),
status: log['submissionStatus'] ?? 'L1', status: log['submissionStatus'] ?? 'L1',
message: log['submissionMessage'] ?? 'No status message.', message: log['submissionMessage'] ?? 'No status message.',
@ -172,6 +183,13 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
(log.reportId?.toLowerCase() ?? '').contains(query); (log.reportId?.toLowerCase() ?? '').contains(query);
} }
File? _createFileFromPath(String? path) {
if (path != null && path.isNotEmpty) {
return File(path);
}
return null;
}
Future<void> _resubmitData(SubmissionLogEntry log) async { Future<void> _resubmitData(SubmissionLogEntry log) async {
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
if (mounted) { if (mounted) {
@ -187,66 +205,55 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
Map<String, dynamic> result = {}; Map<String, dynamic> result = {};
if (log.type == 'Manual Sampling') { if (log.type == 'Manual Sampling') {
// START CHANGE: Reconstruct data object and call the new service // --- START FIX: Rely on the now-robust fromJson factory for cleaner reconstruction ---
final dataToResubmit = InSituSamplingData.fromJson(log.rawData); final dataToResubmit = InSituSamplingData.fromJson(log.rawData);
// Re-attach File objects from paths // --- END FIX ---
dataToResubmit.leftLandViewImage = File(log.rawData['man_left_side_land_view'] ?? '');
dataToResubmit.rightLandViewImage = File(log.rawData['man_right_side_land_view'] ?? '');
dataToResubmit.waterFillingImage = File(log.rawData['man_filling_water_into_sample_bottle'] ?? '');
dataToResubmit.seawaterColorImage = File(log.rawData['man_seawater_in_clear_glass_bottle'] ?? '');
dataToResubmit.phPaperImage = File(log.rawData['man_examine_preservative_ph_paper'] ?? '');
dataToResubmit.optionalImage1 = File(log.rawData['man_optional_photo_01'] ?? '');
dataToResubmit.optionalImage2 = File(log.rawData['man_optional_photo_02'] ?? '');
dataToResubmit.optionalImage3 = File(log.rawData['man_optional_photo_03'] ?? '');
dataToResubmit.optionalImage4 = File(log.rawData['man_optional_photo_04'] ?? '');
result = await _marineInSituService.submitInSituSample( result = await _marineInSituService.submitInSituSample(
data: dataToResubmit, data: dataToResubmit,
appSettings: appSettings, appSettings: appSettings,
context: context,
authProvider: authProvider,
logDirectory: log.rawData['logDirectory'] as String?,
); );
// END CHANGE
} else if (log.type == 'Tarball Sampling') { } else if (log.type == 'Tarball Sampling') {
// START CHANGE: Reconstruct data object and call the new service final dataToResubmit = TarballSamplingData();
final dataToResubmit = TarballSamplingData(); // Create a fresh instance
final logData = log.rawData; final logData = log.rawData;
// Manually map fields from the raw log data to the new object dataToResubmit.firstSampler = logData['first_sampler_name'];
dataToResubmit.firstSampler = logData['firstSampler']; dataToResubmit.firstSamplerUserId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? '');
dataToResubmit.firstSamplerUserId = logData['firstSamplerUserId'];
dataToResubmit.secondSampler = logData['secondSampler']; dataToResubmit.secondSampler = logData['secondSampler'];
dataToResubmit.samplingDate = logData['samplingDate']; dataToResubmit.samplingDate = logData['sampling_date'];
dataToResubmit.samplingTime = logData['samplingTime']; dataToResubmit.samplingTime = logData['sampling_time'];
dataToResubmit.selectedStateName = logData['selectedStateName']; dataToResubmit.selectedStateName = logData['selectedStation']?['state_name'];
dataToResubmit.selectedCategoryName = logData['selectedCategoryName']; dataToResubmit.selectedCategoryName = logData['selectedStation']?['category_name'];
dataToResubmit.selectedStation = logData['selectedStation']; dataToResubmit.selectedStation = logData['selectedStation'];
dataToResubmit.stationLatitude = logData['stationLatitude']; dataToResubmit.stationLatitude = logData['selectedStation']?['tbl_latitude']?.toString();
dataToResubmit.stationLongitude = logData['stationLongitude']; dataToResubmit.stationLongitude = logData['selectedStation']?['tbl_longitude']?.toString();
dataToResubmit.currentLatitude = logData['currentLatitude']; dataToResubmit.currentLatitude = logData['current_latitude'];
dataToResubmit.currentLongitude = logData['currentLongitude']; dataToResubmit.currentLongitude = logData['current_longitude'];
dataToResubmit.distanceDifference = logData['distanceDifference']; dataToResubmit.distanceDifference = double.tryParse(logData['distance_difference']?.toString() ?? '');
dataToResubmit.distanceDifferenceRemarks = logData['distanceDifferenceRemarks']; dataToResubmit.distanceDifferenceRemarks = logData['distance_remarks'];
dataToResubmit.classificationId = logData['classificationId']; dataToResubmit.classificationId = int.tryParse(logData['classification_id']?.toString() ?? '');
dataToResubmit.selectedClassification = logData['selectedClassification']; dataToResubmit.selectedClassification = logData['selectedClassification'];
dataToResubmit.optionalRemark1 = logData['optionalRemark1']; dataToResubmit.optionalRemark1 = logData['optional_photo_remark_01'];
dataToResubmit.optionalRemark2 = logData['optionalRemark2']; dataToResubmit.optionalRemark2 = logData['optional_photo_remark_02'];
dataToResubmit.optionalRemark3 = logData['optionalRemark3']; dataToResubmit.optionalRemark3 = logData['optional_photo_remark_03'];
dataToResubmit.optionalRemark4 = logData['optionalRemark4']; dataToResubmit.optionalRemark4 = logData['optional_photo_remark_04'];
dataToResubmit.leftCoastalViewImage = _createFileFromPath(logData['left_side_coastal_view']);
// Re-attach File objects from paths dataToResubmit.rightCoastalViewImage = _createFileFromPath(logData['right_side_coastal_view']);
dataToResubmit.leftCoastalViewImage = File(logData['left_side_coastal_view'] ?? ''); dataToResubmit.verticalLinesImage = _createFileFromPath(logData['drawing_vertical_lines']);
dataToResubmit.rightCoastalViewImage = File(logData['right_side_coastal_view'] ?? ''); dataToResubmit.horizontalLineImage = _createFileFromPath(logData['drawing_horizontal_line']);
dataToResubmit.verticalLinesImage = File(logData['drawing_vertical_lines'] ?? ''); dataToResubmit.optionalImage1 = _createFileFromPath(logData['optional_photo_01']);
dataToResubmit.horizontalLineImage = File(logData['drawing_horizontal_line'] ?? ''); dataToResubmit.optionalImage2 = _createFileFromPath(logData['optional_photo_02']);
dataToResubmit.optionalImage1 = File(logData['optional_photo_01'] ?? ''); dataToResubmit.optionalImage3 = _createFileFromPath(logData['optional_photo_03']);
dataToResubmit.optionalImage2 = File(logData['optional_photo_02'] ?? ''); dataToResubmit.optionalImage4 = _createFileFromPath(logData['optional_photo_04']);
dataToResubmit.optionalImage3 = File(logData['optional_photo_03'] ?? '');
dataToResubmit.optionalImage4 = File(logData['optional_photo_04'] ?? '');
result = await _marineTarballService.submitTarballSample( result = await _marineTarballService.submitTarballSample(
data: dataToResubmit, data: dataToResubmit,
appSettings: appSettings, appSettings: appSettings,
context: context,
); );
// END CHANGE
} }
if (mounted) { if (mounted) {
@ -341,10 +348,31 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
} }
Widget _buildLogListItem(SubmissionLogEntry log) { Widget _buildLogListItem(SubmissionLogEntry log) {
final isFailed = !log.status.startsWith('S') && !log.status.startsWith('L4');
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
final isResubmitting = _isResubmitting[logKey] ?? false; final isResubmitting = _isResubmitting[logKey] ?? false;
// --- START: MODIFICATION FOR GRANULAR STATUS ICONS ---
// Define the different states based on the detailed status code.
final bool isFullSuccess = log.status == 'S4';
final bool isPartialSuccess = log.status == 'S3' || log.status == 'L4';
final bool canResubmit = !isFullSuccess; // Allow resubmission for partial success or failure.
// Determine the icon and color based on the state.
IconData statusIcon;
Color statusColor;
if (isFullSuccess) {
statusIcon = Icons.check_circle_outline;
statusColor = Colors.green;
} else if (isPartialSuccess) {
statusIcon = Icons.warning_amber_rounded;
statusColor = Colors.orange;
} else {
statusIcon = Icons.error_outline;
statusColor = Colors.red;
}
// --- END: MODIFICATION FOR GRANULAR STATUS ICONS ---
final titleWidget = RichText( final titleWidget = RichText(
text: TextSpan( text: TextSpan(
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
@ -357,17 +385,18 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
], ],
), ),
); );
final subtitle = '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}';
final bool isDateValid = !log.submissionDateTime.isAtSameMomentAs(DateTime.fromMillisecondsSinceEpoch(0));
final subtitle = isDateValid
? '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}'
: '${log.serverName} - Invalid Date';
return ExpansionTile( return ExpansionTile(
key: PageStorageKey(logKey), key: PageStorageKey(logKey),
leading: Icon( leading: Icon(statusIcon, color: statusColor),
isFailed ? Icons.error_outline : Icons.check_circle_outline,
color: isFailed ? Colors.red : Colors.green,
),
title: titleWidget, title: titleWidget,
subtitle: Text(subtitle), subtitle: Text(subtitle),
trailing: isFailed trailing: canResubmit
? (isResubmitting ? (isResubmitting
? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 3)) ? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 3))
: IconButton(icon: const Icon(Icons.sync, color: Colors.blue), tooltip: 'Resubmit', onPressed: () => _resubmitData(log))) : IconButton(icon: const Icon(Icons.sync, color: Colors.blue), tooltip: 'Resubmit', onPressed: () => _resubmitData(log)))
@ -382,9 +411,6 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
_buildDetailRow('Server:', log.serverName), _buildDetailRow('Server:', log.serverName),
_buildDetailRow('Report ID:', log.reportId ?? 'N/A'), _buildDetailRow('Report ID:', log.reportId ?? 'N/A'),
_buildDetailRow('Submission Type:', log.type), _buildDetailRow('Submission Type:', log.type),
const Divider(height: 10),
_buildGranularStatus('API', log.apiStatusRaw),
_buildGranularStatus('FTP', log.ftpStatusRaw),
], ],
), ),
) )
@ -392,52 +418,6 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
); );
} }
Widget _buildGranularStatus(String type, String? jsonStatus) {
if (jsonStatus == null || jsonStatus.isEmpty) {
return Container();
}
List<dynamic> statuses;
try {
statuses = jsonDecode(jsonStatus);
} catch (_) {
return _buildDetailRow('$type Status:', jsonStatus);
}
if (statuses.isEmpty) {
return Container();
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$type Status:', style: const TextStyle(fontWeight: FontWeight.bold)),
...statuses.map((s) {
final serverName = s['server_name'] ?? s['config_name'] ?? 'Server N/A';
final status = s['status'] ?? 'N/A';
final bool isSuccess = status.toLowerCase().contains('success') || status.toLowerCase().contains('queued') || status.toLowerCase().contains('not_configured') || status.toLowerCase().contains('not_applicable') || status.toLowerCase().contains('not_required');
final IconData icon = isSuccess ? Icons.check_circle_outline : (status.toLowerCase().contains('failed') ? Icons.error_outline : Icons.sync);
final Color color = isSuccess ? Colors.green : (status.toLowerCase().contains('failed') ? Colors.red : Colors.grey);
String detailLabel = (s['type'] != null) ? '(${s['type']})' : '';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 8.0),
child: Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 5),
Expanded(child: Text('$serverName $detailLabel: $status')),
],
),
);
}).toList(),
],
),
);
}
Widget _buildDetailRow(String label, String value) { Widget _buildDetailRow(String label, String value) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0), padding: const EdgeInsets.symmetric(vertical: 4.0),

View File

@ -6,17 +6,12 @@ import 'package:provider/provider.dart';
import 'package:environment_monitoring_app/auth_provider.dart'; import 'package:environment_monitoring_app/auth_provider.dart';
import '../../../models/in_situ_sampling_data.dart'; import '../../../models/in_situ_sampling_data.dart';
// START CHANGE: Import the new, consolidated MarineInSituSamplingService
import '../../../services/marine_in_situ_sampling_service.dart'; import '../../../services/marine_in_situ_sampling_service.dart';
// END CHANGE
import 'widgets/in_situ_step_1_sampling_info.dart'; import 'widgets/in_situ_step_1_sampling_info.dart';
import 'widgets/in_situ_step_2_site_info.dart'; import 'widgets/in_situ_step_2_site_info.dart';
import 'widgets/in_situ_step_3_data_capture.dart'; import 'widgets/in_situ_step_3_data_capture.dart';
import 'widgets/in_situ_step_4_summary.dart'; import 'widgets/in_situ_step_4_summary.dart';
/// The main screen for the In-Situ Sampling feature.
/// This stateful widget orchestrates the multi-step process using a PageView.
/// It manages the overall data model and the service layer for the entire workflow.
class MarineInSituSampling extends StatefulWidget { class MarineInSituSampling extends StatefulWidget {
const MarineInSituSampling({super.key}); const MarineInSituSampling({super.key});
@ -26,11 +21,7 @@ class MarineInSituSampling extends StatefulWidget {
class _MarineInSituSamplingState extends State<MarineInSituSampling> { class _MarineInSituSamplingState extends State<MarineInSituSampling> {
final PageController _pageController = PageController(); final PageController _pageController = PageController();
late InSituSamplingData _data; late InSituSamplingData _data;
// MODIFIED: Declare the service variable but do not instantiate it here.
// It will be initialized from the Provider.
late MarineInSituSamplingService _samplingService; late MarineInSituSamplingService _samplingService;
int _currentPage = 0; int _currentPage = 0;
@ -45,28 +36,24 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
); );
} }
// ADDED: didChangeDependencies to safely get the service from the Provider.
// This is the correct lifecycle method to access inherited widgets like Provider.
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
// Fetch the single, global instance of the service from the Provider tree.
_samplingService = Provider.of<MarineInSituSamplingService>(context); _samplingService = Provider.of<MarineInSituSamplingService>(context);
} }
@override @override
void dispose() { void dispose() {
_pageController.dispose(); _pageController.dispose();
// START FIX // START FIX: Do not dispose the service here.
// REMOVED: _samplingService.dispose(); // The service is managed by the Provider at a higher level in the app. Disposing it
// The service is managed by a higher-level Provider and should not be disposed of // here can cause the "deactivated widget's ancestor" error if other widgets
// here, as other widgets might still be listening to it. This prevents the // are still listening to it during screen transitions.
// "ValueNotifier was used after being disposed" error. // _samplingService.dispose();
// END FIX // END FIX
super.dispose(); super.dispose();
} }
/// Navigates to the next page in the form.
void _nextPage() { void _nextPage() {
if (_currentPage < 3) { if (_currentPage < 3) {
_pageController.nextPage( _pageController.nextPage(
@ -76,7 +63,6 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
} }
} }
/// Navigates to the previous page in the form.
void _previousPage() { void _previousPage() {
if (_currentPage > 0) { if (_currentPage > 0) {
_pageController.previousPage( _pageController.previousPage(
@ -86,25 +72,23 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
} }
} }
// START CHANGE: The _submitForm method is now greatly simplified.
Future<void> _submitForm() async { Future<void> _submitForm() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings; final appSettings = authProvider.appSettings;
// Delegate the entire submission process to the new dedicated service.
// The service handles API calls, zipping, FTP queuing, logging, and alerts.
final result = await _samplingService.submitInSituSample( final result = await _samplingService.submitInSituSample(
data: _data, data: _data,
appSettings: appSettings, appSettings: appSettings,
context: context,
authProvider: authProvider,
); );
if (!mounted) return; if (!mounted) return;
setState(() => _isLoading = false); setState(() => _isLoading = false);
// Display the final result to the user
final message = result['message'] ?? 'An unknown error occurred.'; final message = result['message'] ?? 'An unknown error occurred.';
final color = (result['success'] == true) ? Colors.green : Colors.red; final color = (result['success'] == true) ? Colors.green : Colors.red;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -113,17 +97,12 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
Navigator.of(context).popUntil((route) => route.isFirst); Navigator.of(context).popUntil((route) => route.isFirst);
} }
// END CHANGE
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// START CHANGE: Provide the new MarineInSituSamplingService to all child widgets.
// The child widgets (Step 1, 2, 3) can now access all its methods (for location,
// image picking, and device connection) via Provider.
return Provider<MarineInSituSamplingService>.value( return Provider<MarineInSituSamplingService>.value(
value: _samplingService, value: _samplingService,
// END CHANGE
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('In-Situ Sampling (${_currentPage + 1}/4)'), title: Text('In-Situ Sampling (${_currentPage + 1}/4)'),
@ -143,7 +122,6 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
}); });
}, },
children: [ children: [
// Each step is a separate widget, receiving the data model and navigation callbacks.
InSituStep1SamplingInfo(data: _data, onNext: _nextPage), InSituStep1SamplingInfo(data: _data, onNext: _nextPage),
InSituStep2SiteInfo(data: _data, onNext: _nextPage), InSituStep2SiteInfo(data: _data, onNext: _nextPage),
InSituStep3DataCapture(data: _data, onNext: _nextPage), InSituStep3DataCapture(data: _data, onNext: _nextPage),

View File

@ -100,12 +100,13 @@ class _MarineTarballSamplingState extends State<MarineTarballSampling> {
// ADDED: Fetch the global service instance from Provider. // ADDED: Fetch the global service instance from Provider.
final tarballService = Provider.of<MarineTarballSamplingService>(context, listen: false); final tarballService = Provider.of<MarineTarballSamplingService>(context, listen: false);
// START CHANGE: Call the method on the new dedicated service // START FIX: Pass the required `context` argument to the function call.
final result = await tarballService.submitTarballSample( final result = await tarballService.submitTarballSample(
data: _data, data: _data,
appSettings: appSettings, appSettings: appSettings,
context: context,
); );
// END CHANGE // END FIX
if (!mounted) return; if (!mounted) return;
setState(() => _isLoading = false); setState(() => _isLoading = false);

View File

@ -1,3 +1,5 @@
// lib/screens/marine/manual/tarball_sampling_step2.dart
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -152,7 +154,13 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final filePath = path.join(tempDir.path, newFileName); final filePath = path.join(tempDir.path, newFileName);
final processedFile = await File(filePath).writeAsBytes(img.encodeJpg(originalImage));
// --- START: MODIFICATION TO FIX RACE CONDITION ---
// Changed from asynchronous `writeAsBytes` to synchronous `writeAsBytesSync`.
// This guarantees the file is fully written to disk before the function returns,
// preventing a 0-byte file from being copied during a fast offline save.
final File processedFile = File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
// --- END: MODIFICATION TO FIX RACE CONDITION ---
setState(() => _isPickingImage = false); setState(() => _isPickingImage = false);
return processedFile; return processedFile;

View File

@ -40,6 +40,7 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
final result = await tarballService.submitTarballSample( final result = await tarballService.submitTarballSample(
data: widget.data, data: widget.data,
appSettings: appSettings, appSettings: appSettings,
context: context,
); );
if (!mounted) return; if (!mounted) return;

View File

@ -34,7 +34,12 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
bool _isAutoReading = false; bool _isAutoReading = false;
StreamSubscription? _dataSubscription; StreamSubscription? _dataSubscription;
// --- START FIX: Declare service variable ---
late final MarineInSituSamplingService _samplingService;
// --- END FIX ---
Map<String, double>? _previousReadingsForComparison; Map<String, double>? _previousReadingsForComparison;
Set<String> _outOfBoundsKeys = {};
/// Maps the app's internal parameter keys to the names used in the /// Maps the app's internal parameter keys to the names used in the
/// 'param_parameter_list' column from the server. /// 'param_parameter_list' column from the server.
@ -70,6 +75,9 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// --- START FIX: Initialize service variable safely ---
_samplingService = Provider.of<MarineInSituSamplingService>(context, listen: false);
// --- END FIX ---
_initializeControllers(); _initializeControllers();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
} }
@ -77,6 +85,16 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
@override @override
void dispose() { void dispose() {
_dataSubscription?.cancel(); _dataSubscription?.cancel();
// --- START FIX: Use the pre-fetched service instance ---
if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_samplingService.disconnectFromBluetooth();
}
if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) {
_samplingService.disconnectFromSerial();
}
// --- END FIX ---
_disposeControllers(); _disposeControllers();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
@ -201,13 +219,38 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
} }
} }
} catch (e) { } catch (e) {
if (mounted) _showSnackBar('Connection failed: $e', isError: true); debugPrint("Connection failed: $e");
if (mounted) _showConnectionFailedDialog();
} finally { } finally {
if (mounted) setState(() => _isLoading = false); if (mounted) setState(() => _isLoading = false);
} }
return success; 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 _toggleAutoReading(String activeType) { void _toggleAutoReading(String activeType) {
final service = context.read<MarineInSituSamplingService>(); final service = context.read<MarineInSituSamplingService>();
setState(() { setState(() {
@ -222,16 +265,26 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
void _disconnect(String type) { void _disconnect(String type) {
final service = context.read<MarineInSituSamplingService>(); final service = context.read<MarineInSituSamplingService>();
if (type == 'bluetooth') service.disconnectFromBluetooth(); else service.disconnectFromSerial(); if (type == 'bluetooth') {
service.disconnectFromBluetooth();
} else {
service.disconnectFromSerial();
}
_dataSubscription?.cancel(); _dataSubscription?.cancel();
_dataSubscription = null; _dataSubscription = null;
if (mounted) setState(() => _isAutoReading = false); if (mounted) {
setState(() => _isAutoReading = false);
}
} }
void _disconnectFromAll() { void _disconnectFromAll() {
final service = context.read<MarineInSituSamplingService>(); final service = context.read<MarineInSituSamplingService>();
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) _disconnect('bluetooth'); if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
if (service.serialConnectionState.value != SerialConnectionState.disconnected) _disconnect('serial'); _disconnect('bluetooth');
}
if (service.serialConnectionState.value != SerialConnectionState.disconnected) {
_disconnect('serial');
}
} }
void _updateTextFields(Map<String, double> readings) { void _updateTextFields(Map<String, double> readings) {
@ -251,38 +304,29 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
} }
void _validateAndProceed() { void _validateAndProceed() {
debugPrint("--- Parameter Validation Triggered ---");
if (_isAutoReading) { if (_isAutoReading) {
_showStopReadingDialog(); _showStopReadingDialog();
return; return;
} }
if (!_formKey.currentState!.validate()) {
return;
}
_formKey.currentState!.save(); _formKey.currentState!.save();
final currentReadings = _captureReadingsToMap(); final currentReadings = _captureReadingsToMap();
debugPrint("Current Readings Captured: $currentReadings");
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final allLimits = authProvider.parameterLimits ?? []; final marineLimits = (authProvider.parameterLimits ?? []).where((limit) => limit['department_id'] == 4).toList();
debugPrint("Total parameter limits loaded from AuthProvider: ${allLimits.length} rules.");
if (allLimits.isNotEmpty) {
debugPrint("Sample limit rule from AuthProvider: ${allLimits.first}");
}
final marineLimits = allLimits.where((limit) => limit['department_id'] == 4).toList();
debugPrint("Found ${marineLimits.length} rules after filtering for Marine department (ID 4).");
final outOfBoundsParams = _validateParameters(currentReadings, marineLimits); final outOfBoundsParams = _validateParameters(currentReadings, marineLimits);
debugPrint("Validation check complete. Found ${outOfBoundsParams.length} out-of-bounds parameters.");
setState(() {
_outOfBoundsKeys = outOfBoundsParams.map((p) => _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet();
});
if (outOfBoundsParams.isNotEmpty) { if (outOfBoundsParams.isNotEmpty) {
debugPrint("Action: Displaying parameter limit warning dialog for: $outOfBoundsParams");
_showParameterLimitDialog(outOfBoundsParams, currentReadings); _showParameterLimitDialog(outOfBoundsParams, currentReadings);
} else { } else {
debugPrint("Action: Validation passed or no applicable limits found. Proceeding to the next step.");
_saveDataAndMoveOn(currentReadings); _saveDataAndMoveOn(currentReadings);
} }
debugPrint("--- Parameter Validation Finished ---");
} }
Map<String, double> _captureReadingsToMap() { Map<String, double> _captureReadingsToMap() {
@ -352,11 +396,12 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
return; return;
} }
if (_previousReadingsForComparison != null) { setState(() {
setState(() { _outOfBoundsKeys.clear();
if (_previousReadingsForComparison != null) {
_previousReadingsForComparison = null; _previousReadingsForComparison = null;
}); }
} });
widget.onNext(); widget.onNext();
} }
@ -431,10 +476,12 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
if (activeConnection != null) if (activeConnection != null)
_buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']), _buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']),
const SizedBox(height: 24), const SizedBox(height: 24),
// START FIX: Restored ValueListenableBuilder to listen for Sonde ID updates.
ValueListenableBuilder<String?>( ValueListenableBuilder<String?>(
valueListenable: service.sondeId, valueListenable: service.sondeId,
builder: (context, sondeId, child) { builder: (context, sondeId, child) {
final newSondeId = sondeId ?? ''; final newSondeId = sondeId ?? '';
// Use a post-frame callback to safely update the controller after the build.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _sondeIdController.text != newSondeId) { if (mounted && _sondeIdController.text != newSondeId) {
_sondeIdController.text = newSondeId; _sondeIdController.text = newSondeId;
@ -444,12 +491,13 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
return TextFormField( return TextFormField(
controller: _sondeIdController, controller: _sondeIdController,
decoration: const InputDecoration(labelText: 'Sonde ID *', hintText: 'Connect device or enter manually'), decoration: const InputDecoration(labelText: 'Sonde ID *', hintText: 'Connect device or enter manually'),
validator: (v) => v!.isEmpty ? 'Sonde ID is required' : null, validator: (v) => v == null || v.isEmpty ? 'Sonde ID is required' : null,
onChanged: (value) => widget.data.sondeId = value, onChanged: (value) => widget.data.sondeId = value,
onSaved: (v) => widget.data.sondeId = v, onSaved: (v) => widget.data.sondeId = v,
); );
}, },
), ),
// END FIX
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
children: [ children: [
@ -470,6 +518,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
label: param['label'] as String, label: param['label'] as String,
unit: param['unit'] as String, unit: param['unit'] as String,
controller: param['controller'] as TextEditingController, controller: param['controller'] as TextEditingController,
isOutOfBounds: _outOfBoundsKeys.contains(param['key']),
); );
}).toList(), }).toList(),
), ),
@ -490,10 +539,10 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
return Card( return Card(
margin: const EdgeInsets.only(top: 24.0), margin: const EdgeInsets.only(top: 24.0),
color: Theme.of(context).cardColor, // Adapts to theme's card color color: Theme.of(context).cardColor,
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: DefaultTextStyle( // Ensure text adapts to theme child: DefaultTextStyle(
style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color), style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -529,6 +578,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
final label = param['label'] as String; final label = param['label'] as String;
final controller = param['controller'] as TextEditingController; final controller = param['controller'] as TextEditingController;
final previousValue = previousReadings[key]; final previousValue = previousReadings[key];
final bool isCurrentValueOutOfBounds = _outOfBoundsKeys.contains(key);
return TableRow( return TableRow(
children: [ children: [
@ -536,15 +586,20 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Text( child: Text(
previousValue == -999.0 ? '-.--' : previousValue!.toStringAsFixed(2), previousValue == -999.0 ? '-.--' : previousValue!.toStringAsFixed(5),
style: TextStyle(color: isDarkTheme ? Colors.orange.shade200 : Colors.orange.shade700), style: TextStyle(color: isDarkTheme ? Colors.orange.shade200 : Colors.orange.shade700),
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Text( child: Text(
controller.text.contains('-999') ? '-.--' : (double.tryParse(controller.text) ?? 0).toStringAsFixed(2), controller.text.contains('-999') ? '-.--' : (double.tryParse(controller.text) ?? 0).toStringAsFixed(5),
style: TextStyle(color: isDarkTheme ? Colors.green.shade200 : Colors.green.shade700, fontWeight: FontWeight.bold), style: TextStyle(
color: isCurrentValueOutOfBounds
? Colors.red
: (isDarkTheme ? Colors.green.shade200 : Colors.green.shade700),
fontWeight: FontWeight.bold
),
), ),
), ),
], ],
@ -569,7 +624,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
title: const Text('Parameter Limit Warning'), title: const Text('Parameter Limit Warning'),
content: SingleChildScrollView( content: SingleChildScrollView(
child: DefaultTextStyle( child: DefaultTextStyle(
style: TextStyle(color: Theme.of(context).textTheme.bodyMedium?.color), // Make sure text is visible style: TextStyle(color: Theme.of(context).textTheme.bodyMedium?.color),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -600,12 +655,12 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
...invalidParams.map((p) => TableRow( ...invalidParams.map((p) => TableRow(
children: [ children: [
Padding(padding: const EdgeInsets.all(6.0), child: Text(p['label'])), Padding(padding: const EdgeInsets.all(6.0), child: Text(p['label'])),
Padding(padding: const EdgeInsets.all(6.0), child: Text('${p['lower_limit']?.toStringAsFixed(1) ?? 'N/A'} - ${p['upper_limit']?.toStringAsFixed(1) ?? 'N/A'}')), Padding(padding: const EdgeInsets.all(6.0), child: Text('${p['lower_limit']?.toStringAsFixed(5) ?? 'N/A'} - ${p['upper_limit']?.toStringAsFixed(5) ?? 'N/A'}')),
Padding( Padding(
padding: const EdgeInsets.all(6.0), padding: const EdgeInsets.all(6.0),
child: Text( child: Text(
p['value'].toStringAsFixed(2), p['value'].toStringAsFixed(5),
style: const TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold), // Highlight the out-of-bounds value style: const TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold),
), ),
), ),
], ],
@ -641,10 +696,15 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
); );
} }
Widget _buildParameterListItem({required IconData icon, required String label, required String unit, required TextEditingController controller}) { Widget _buildParameterListItem({required IconData icon, required String label, required String unit, required TextEditingController controller, bool isOutOfBounds = false}) {
final bool isMissing = controller.text.isEmpty || controller.text.contains('-999'); final bool isMissing = controller.text.isEmpty || controller.text.contains('-999');
final String displayValue = isMissing ? '-.--' : controller.text; final String displayValue = isMissing ? '-.--' : controller.text;
final String displayLabel = unit.isEmpty ? label : '$label ($unit)'; final String displayLabel = unit.isEmpty ? label : '$label ($unit)';
final Color valueColor = isOutOfBounds
? Colors.red
: (isMissing ? Colors.grey : Theme.of(context).colorScheme.primary);
return Card( return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0), margin: const EdgeInsets.symmetric(vertical: 4.0),
child: ListTile( child: ListTile(
@ -654,7 +714,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
displayValue, displayValue,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isMissing ? Colors.grey : Theme.of(context).colorScheme.primary), color: valueColor),
), ),
), ),
); );

View File

@ -2,7 +2,9 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../../auth_provider.dart';
import '../../../../models/in_situ_sampling_data.dart'; import '../../../../models/in_situ_sampling_data.dart';
class InSituStep4Summary extends StatelessWidget { class InSituStep4Summary extends StatelessWidget {
@ -17,8 +19,72 @@ class InSituStep4Summary extends StatelessWidget {
required this.isLoading, required this.isLoading,
}); });
// --- START: MODIFICATION FOR HIGHLIGHTING ---
// Added helper logic to re-validate parameters on the summary screen.
/// Maps the app's internal parameter keys to the names used in the database.
static const Map<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',
};
/// Re-validates the final parameters against the defined limits.
Set<String> _getOutOfBoundsKeys(BuildContext context) {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final marineLimits = (authProvider.parameterLimits ?? []).where((limit) => limit['department_id'] == 4).toList();
final Set<String> invalidKeys = {};
final readings = {
'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation,
'ph': data.ph, 'salinity': data.salinity, 'electricalConductivity': data.electricalConductivity,
'temperature': data.temperature, 'tds': data.tds, 'turbidity': data.turbidity,
'tss': data.tss, 'batteryVoltage': data.batteryVoltage,
};
double? parseLimitValue(dynamic value) {
if (value == null) return null;
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
readings.forEach((key, value) {
if (value == null || value == -999.0) return;
final limitName = _parameterKeyToLimitName[key];
if (limitName == null) return;
final limitData = marineLimits.firstWhere((l) => l['param_parameter_list'] == limitName, orElse: () => {});
if (limitData.isNotEmpty) {
final lowerLimit = parseLimitValue(limitData['param_lower_limit']);
final upperLimit = parseLimitValue(limitData['param_upper_limit']);
if ((lowerLimit != null && value < lowerLimit) || (upperLimit != null && value > upperLimit)) {
invalidKeys.add(key);
}
}
});
return invalidKeys;
}
// --- END: MODIFICATION FOR HIGHLIGHTING ---
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// --- START: MODIFICATION FOR HIGHLIGHTING ---
// Get the set of out-of-bounds keys before building the list.
final outOfBoundsKeys = _getOutOfBoundsKeys(context);
// --- END: MODIFICATION FOR HIGHLIGHTING ---
return ListView( return ListView(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
children: [ children: [
@ -91,16 +157,18 @@ class InSituStep4Summary extends StatelessWidget {
_buildDetailRow("Sonde ID:", data.sondeId), _buildDetailRow("Sonde ID:", data.sondeId),
_buildDetailRow("Capture Time:", "${data.dataCaptureDate} ${data.dataCaptureTime}"), _buildDetailRow("Capture Time:", "${data.dataCaptureDate} ${data.dataCaptureTime}"),
const Divider(height: 20), const Divider(height: 20),
_buildParameterListItem(context, icon: Icons.air, label: "Oxygen Conc.", unit: "mg/L", value: data.oxygenConcentration?.toStringAsFixed(2)), // --- START: MODIFICATION FOR 5 DECIMALS & HIGHLIGHTING ---
_buildParameterListItem(context, icon: Icons.percent, label: "Oxygen Sat.", unit: "%", value: data.oxygenSaturation?.toStringAsFixed(2)), _buildParameterListItem(context, icon: Icons.air, label: "Oxygen Conc.", unit: "mg/L", value: data.oxygenConcentration, isOutOfBounds: outOfBoundsKeys.contains('oxygenConcentration')),
_buildParameterListItem(context, icon: Icons.science_outlined, label: "pH", unit: "", value: data.ph?.toStringAsFixed(2)), _buildParameterListItem(context, icon: Icons.percent, label: "Oxygen Sat.", unit: "%", value: data.oxygenSaturation, isOutOfBounds: outOfBoundsKeys.contains('oxygenSaturation')),
_buildParameterListItem(context, icon: Icons.waves, label: "Salinity", unit: "ppt", value: data.salinity?.toStringAsFixed(2)), _buildParameterListItem(context, icon: Icons.science_outlined, label: "pH", unit: "", value: data.ph, isOutOfBounds: outOfBoundsKeys.contains('ph')),
_buildParameterListItem(context, icon: Icons.flash_on, label: "Conductivity", unit: "µS/cm", value: data.electricalConductivity?.toStringAsFixed(0)), _buildParameterListItem(context, icon: Icons.waves, label: "Salinity", unit: "ppt", value: data.salinity, isOutOfBounds: outOfBoundsKeys.contains('salinity')),
_buildParameterListItem(context, icon: Icons.thermostat, label: "Temperature", unit: "°C", value: data.temperature?.toStringAsFixed(2)), _buildParameterListItem(context, icon: Icons.flash_on, label: "Conductivity", unit: "µS/cm", value: data.electricalConductivity, isOutOfBounds: outOfBoundsKeys.contains('electricalConductivity')),
_buildParameterListItem(context, icon: Icons.grain, label: "TDS", unit: "mg/L", value: data.tds?.toStringAsFixed(2)), _buildParameterListItem(context, icon: Icons.thermostat, label: "Temperature", unit: "°C", value: data.temperature, isOutOfBounds: outOfBoundsKeys.contains('temperature')),
_buildParameterListItem(context, icon: Icons.opacity, label: "Turbidity", unit: "NTU", value: data.turbidity?.toStringAsFixed(2)), _buildParameterListItem(context, icon: Icons.grain, label: "TDS", unit: "mg/L", value: data.tds, isOutOfBounds: outOfBoundsKeys.contains('tds')),
_buildParameterListItem(context, icon: Icons.filter_alt_outlined, label: "TSS", unit: "mg/L", value: data.tss?.toStringAsFixed(2)), _buildParameterListItem(context, icon: Icons.opacity, label: "Turbidity", unit: "NTU", value: data.turbidity, isOutOfBounds: outOfBoundsKeys.contains('turbidity')),
_buildParameterListItem(context, icon: Icons.battery_charging_full, label: "Battery", unit: "V", value: data.batteryVoltage?.toStringAsFixed(2)), _buildParameterListItem(context, icon: Icons.filter_alt_outlined, label: "TSS", unit: "mg/L", value: data.tss, isOutOfBounds: outOfBoundsKeys.contains('tss')),
_buildParameterListItem(context, icon: Icons.battery_charging_full, label: "Battery", unit: "V", value: data.batteryVoltage, isOutOfBounds: outOfBoundsKeys.contains('batteryVoltage')),
// --- END: MODIFICATION ---
], ],
), ),
@ -169,11 +237,19 @@ class InSituStep4Summary extends StatelessWidget {
); );
} }
Widget _buildParameterListItem(BuildContext context, {required IconData icon, required String label, required String unit, required String? value}) { // --- START: MODIFICATION FOR 5 DECIMALS & HIGHLIGHTING ---
// REPAIRED: Only check if the value is null. It will now display numerical Widget _buildParameterListItem(BuildContext context, {required IconData icon, required String label, required String unit, required double? value, bool isOutOfBounds = false}) {
// values like -999.00 instead of converting them to 'N/A'. final bool isMissing = value == null || value == -999.0;
final bool isMissing = value == null; // Format the value to 5 decimal places if it's a valid number.
final String displayValue = isMissing ? 'N/A' : '$value ${unit}'.trim(); final String displayValue = isMissing ? 'N/A' : '${value.toStringAsFixed(5)} ${unit}'.trim();
// START CHANGE: Use theme-aware color for normal values
// Determine the color for the value based on theme and status.
final Color? defaultTextColor = Theme.of(context).textTheme.bodyLarge?.color;
final Color valueColor = isOutOfBounds
? Colors.red
: (isMissing ? Colors.grey : defaultTextColor ?? Colors.black);
// END CHANGE
return ListTile( return ListTile(
dense: true, dense: true,
@ -183,12 +259,13 @@ class InSituStep4Summary extends StatelessWidget {
trailing: Text( trailing: Text(
displayValue, displayValue,
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: isMissing ? Colors.grey : null, color: valueColor,
fontWeight: isMissing ? null : FontWeight.bold, fontWeight: isOutOfBounds ? FontWeight.bold : null,
), ),
), ),
); );
} }
// --- END: MODIFICATION ---
/// A reusable widget to display an attached image or a placeholder. /// A reusable widget to display an attached image or a placeholder.
Widget _buildImageCard(String title, File? image, {String? remark}) { Widget _buildImageCard(String title, File? image, {String? remark}) {

View File

@ -80,12 +80,10 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
final riverLogs = await _localStorageService.getAllRiverInSituLogs(); final riverLogs = await _localStorageService.getAllRiverInSituLogs();
final List<SubmissionLogEntry> tempLogs = []; final List<SubmissionLogEntry> tempLogs = [];
if (riverLogs != null) { for (var log in riverLogs) {
for (var log in riverLogs) { final entry = _createLogEntry(log);
final entry = _createLogEntry(log); if (entry != null) {
if (entry != null) { tempLogs.add(entry);
tempLogs.add(entry);
}
} }
} }
@ -99,33 +97,25 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
} }
} }
// --- START: MODIFIED TO FIX NULL SAFETY ERRORS ---
SubmissionLogEntry? _createLogEntry(Map<String, dynamic> log) { SubmissionLogEntry? _createLogEntry(Map<String, dynamic> log) {
String? type; final String type = log['samplingType'] ?? 'In-Situ Sampling';
String? title; final String title = log['selectedStation']?['sampling_river'] ?? 'Unknown River';
String? stationCode; final String stationCode = log['selectedStation']?['sampling_station_code'] ?? 'N/A';
DateTime submissionDateTime = DateTime.now(); DateTime submissionDateTime = DateTime.now();
String? dateStr; final String? dateStr = log['samplingDate'] ?? log['r_man_date'];
String? timeStr; final String? timeStr = log['samplingTime'] ?? log['r_man_time'];
if (log.containsKey('samplingType')) {
type = log['samplingType'] ?? 'In-Situ Sampling';
title = log['selectedStation']?['sampling_river'] ?? 'Unknown River';
stationCode = log['selectedStation']?['sampling_station_code'] ?? 'N/A';
dateStr = log['r_man_date'] ?? log['samplingDate'] ?? '';
timeStr = log['r_man_time'] ?? log['samplingTime'] ?? '';
} else {
return null;
}
// FIX: Safely parse date and time by providing default values
try { try {
final String fullDateString = '$dateStr ${timeStr!.length == 5 ? timeStr + ':00' : timeStr}'; if (dateStr != null && timeStr != null && dateStr.isNotEmpty && timeStr.isNotEmpty) {
submissionDateTime = DateTime.tryParse(fullDateString) ?? DateTime.now(); final String fullDateString = '$dateStr ${timeStr.length == 5 ? "$timeStr:00" : timeStr}';
submissionDateTime = DateTime.tryParse(fullDateString) ?? DateTime.now();
}
} catch (_) { } catch (_) {
submissionDateTime = DateTime.now(); submissionDateTime = DateTime.now();
} }
// --- END: MODIFIED TO FIX NULL SAFETY ERRORS ---
// FIX: Safely handle apiStatusRaw and ftpStatusRaw to prevent null access
String? apiStatusRaw; String? apiStatusRaw;
if (log['api_status'] != null) { if (log['api_status'] != null) {
apiStatusRaw = log['api_status'] is String ? log['api_status'] : jsonEncode(log['api_status']); apiStatusRaw = log['api_status'] is String ? log['api_status'] : jsonEncode(log['api_status']);
@ -137,9 +127,9 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
} }
return SubmissionLogEntry( return SubmissionLogEntry(
type: type!, type: type,
title: title!, title: title,
stationCode: stationCode!, stationCode: stationCode,
submissionDateTime: submissionDateTime, submissionDateTime: submissionDateTime,
reportId: log['reportId']?.toString(), reportId: log['reportId']?.toString(),
status: log['submissionStatus'] ?? 'L1', status: log['submissionStatus'] ?? 'L1',
@ -178,34 +168,24 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
try { try {
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings; final appSettings = authProvider.appSettings;
final logData = log.rawData;
final dataToResubmit = RiverInSituSamplingData.fromJson(logData); final dataToResubmit = RiverInSituSamplingData.fromJson(log.rawData);
final Map<String, File?> imageFiles = {};
dataToResubmit.toApiImageFiles().keys.forEach((key) { final result = await _riverInSituService.submitData(
final imagePath = logData[key]; data: dataToResubmit,
if (imagePath is String && imagePath.isNotEmpty) {
imageFiles[key] = File(imagePath);
}
});
final result = await _apiService.river.submitInSituSample(
formData: dataToResubmit.toApiFormData(),
imageFiles: imageFiles,
appSettings: appSettings, appSettings: appSettings,
authProvider: authProvider,
logDirectory: log.rawData['logDirectory'], // Pass the log directory for updating
); );
final updatedLogData = log.rawData;
updatedLogData['submissionStatus'] = result['status'];
updatedLogData['submissionMessage'] = result['message'];
updatedLogData['reportId'] = result['reportId']?.toString() ?? updatedLogData['reportId'];
updatedLogData['api_status'] = jsonEncode(result['api_status']);
updatedLogData['ftp_status'] = jsonEncode(result['ftp_status']);
await _localStorageService.updateRiverInSituLog(updatedLogData);
if (mounted) { if (mounted) {
final message = result['message'] ?? 'Resubmission process completed.';
final isSuccess = result['success'] as bool? ?? false;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Resubmission successful!')), SnackBar(
content: Text(message),
backgroundColor: isSuccess ? Colors.green : Colors.orange,
),
); );
} }
} catch (e) { } catch (e) {
@ -371,7 +351,7 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
try { try {
statuses = jsonDecode(jsonStatus); statuses = jsonDecode(jsonStatus);
} catch (_) { } catch (_) {
return _buildDetailRow('$type Status:', jsonStatus!); return _buildDetailRow('$type Status:', jsonStatus);
} }
if (statuses.isEmpty) { if (statuses.isEmpty) {
@ -385,11 +365,11 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
children: [ children: [
Text('$type Status:', style: const TextStyle(fontWeight: FontWeight.bold)), Text('$type Status:', style: const TextStyle(fontWeight: FontWeight.bold)),
...statuses.map((s) { ...statuses.map((s) {
final serverName = s['server_name'] ?? 'Server N/A'; final serverName = s['server_name'] ?? s['config_name'] ?? 'Server N/A';
final status = s['status'] ?? 'N/A'; final status = s['message'] ?? 'N/A';
final bool isSuccess = status.toLowerCase().contains('success') || status.toLowerCase().contains('queued') || status.toLowerCase().contains('not_configured') || status.toLowerCase().contains('not_applicable') || status.toLowerCase().contains('not_required'); final bool isSuccess = s['success'] as bool? ?? false;
final IconData icon = isSuccess ? Icons.check_circle_outline : (status.toLowerCase().contains('failed') ? Icons.error_outline : Icons.sync); final IconData icon = isSuccess ? Icons.check_circle_outline : Icons.error_outline;
final Color color = isSuccess ? Colors.green : (status.toLowerCase().contains('failed') ? Colors.red : Colors.grey); final Color color = isSuccess ? Colors.green : Colors.red;
String detailLabel = (s['type'] != null) ? '(${s['type']})' : ''; String detailLabel = (s['type'] != null) ? '(${s['type']})' : '';
return Padding( return Padding(

View File

@ -43,9 +43,6 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
int _currentPage = 0; int _currentPage = 0;
bool _isLoading = false; bool _isLoading = false;
// FIX: Removed the late variable, it will be fetched from context directly.
// late RiverInSituSamplingService _samplingService;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -53,7 +50,6 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()), samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()),
samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()), samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()),
); );
// FIX: Removed the problematic post-frame callback initialization.
} }
@override @override
@ -83,30 +79,26 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
Future<void> _submitForm() async { Future<void> _submitForm() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
// FIX: Get the sampling service directly from the context here.
final samplingService = Provider.of<RiverInSituSamplingService>(context, listen: false); final samplingService = Provider.of<RiverInSituSamplingService>(context, listen: false);
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings; final appSettings = authProvider.appSettings;
final result = await samplingService.submitData(_data, appSettings); // --- START: MODIFIED SUBMISSION LOGIC ---
// The service call is updated to the new signature (no context).
_data.submissionStatus = result['status']; // The service now handles all local saving, so the manual save call is removed.
_data.submissionMessage = result['message']; final result = await samplingService.submitData(
_data.reportId = result['reportId']?.toString(); data: _data,
appSettings: appSettings,
final activeApiConfig = await _serverConfigService.getActiveApiConfig(); authProvider: authProvider,
final serverName = activeApiConfig?['config_name'] as String? ?? 'Default'; );
await _localStorageService.saveRiverInSituSamplingData(_data, serverName: serverName);
if (!mounted) return; if (!mounted) return;
setState(() => _isLoading = false); setState(() => _isLoading = false);
final message = _data.submissionMessage ?? 'An unknown error occurred.'; final message = result['message'] ?? 'An unknown error occurred.';
final highLevelStatus = result['status'] as String? ?? 'L1'; final highLevelStatus = result['status'] as String? ?? 'L1';
final bool isSuccess = highLevelStatus.startsWith('S') || highLevelStatus.startsWith('L4') || highLevelStatus == 'Queued';
final bool isSuccess = highLevelStatus.startsWith('S') || highLevelStatus.startsWith('L4');
final color = isSuccess ? Colors.green : Colors.red; final color = isSuccess ? Colors.green : Colors.red;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -114,13 +106,11 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
); );
Navigator.of(context).popUntil((route) => route.isFirst); Navigator.of(context).popUntil((route) => route.isFirst);
// --- END: MODIFIED SUBMISSION LOGIC ---
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// FIX: The Provider.value wrapper is removed. The service is already
// available in the widget tree from a higher-level provider, and child
// widgets can access it using Provider.of or context.read.
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('In-Situ Sampling (${_currentPage + 1}/5)'), title: Text('In-Situ Sampling (${_currentPage + 1}/5)'),

View File

@ -136,6 +136,67 @@ class _SettingsScreenState extends State<SettingsScreen> {
final apiConfigsWithPrefs = await _preferencesService.getAllApiConfigsWithModulePreferences(moduleKey); final apiConfigsWithPrefs = await _preferencesService.getAllApiConfigsWithModulePreferences(moduleKey);
final ftpConfigsWithPrefs = await _preferencesService.getAllFtpConfigsWithModulePreferences(moduleKey); final ftpConfigsWithPrefs = await _preferencesService.getAllFtpConfigsWithModulePreferences(moduleKey);
// START MODIFICATION: Apply default settings for submission preferences
// This logic checks if the main toggle for a submission type is on but no
// specific destination is checked. If so, it applies a default selection.
// This ensures a default configuration without overriding saved user choices.
// Check if any API config is already enabled from preferences.
final bool isAnyApiConfigEnabled = apiConfigsWithPrefs.any((c) => c['is_enabled'] == true);
// If the main API toggle is on but no specific API is selected, apply the default.
if (prefs['is_api_enabled'] == true && !isAnyApiConfigEnabled) {
final pstwHqApi = apiConfigsWithPrefs.firstWhere((c) => c['config_name'] == 'pstw_hq', orElse: () => {});
if (pstwHqApi.isNotEmpty) {
pstwHqApi['is_enabled'] = true;
}
}
// Check if any FTP config is already enabled from preferences.
final bool isAnyFtpConfigEnabled = ftpConfigsWithPrefs.any((c) => c['is_enabled'] == true);
// If the main FTP toggle is on but no specific FTP is selected, apply the defaults for the module.
if (prefs['is_ftp_enabled'] == true && !isAnyFtpConfigEnabled) {
switch (moduleKey) {
case 'marine_tarball':
for (var config in ftpConfigsWithPrefs) {
if (config['config_name'] == 'pstw_marine_tarball' || config['config_name'] == 'tes_marine_tarball') {
config['is_enabled'] = true;
}
}
break;
case 'marine_in_situ':
for (var config in ftpConfigsWithPrefs) {
if (config['config_name'] == 'pstw_marine_manual' || config['config_name'] == 'tes_marine_manual') {
config['is_enabled'] = true;
}
}
break;
case 'river_in_situ':
for (var config in ftpConfigsWithPrefs) {
if (config['config_name'] == 'pstw_river_manual' || config['config_name'] == 'tes_river_manual') {
config['is_enabled'] = true;
}
}
break;
case 'air_collection':
for (var config in ftpConfigsWithPrefs) {
if (config['config_name'] == 'pstw_air_collect' || config['config_name'] == 'tes_air_collect') {
config['is_enabled'] = true;
}
}
break;
case 'air_installation':
for (var config in ftpConfigsWithPrefs) {
if (config['config_name'] == 'pstw_air_install' || config['config_name'] == 'tes_air_install') {
config['is_enabled'] = true;
}
}
break;
}
}
// END MODIFICATION
_moduleSettings[moduleKey] = _ModuleSettings( _moduleSettings[moduleKey] = _ModuleSettings(
isApiEnabled: prefs['is_api_enabled'], isApiEnabled: prefs['is_api_enabled'],
isFtpEnabled: prefs['is_ftp_enabled'], isFtpEnabled: prefs['is_ftp_enabled'],

View File

@ -1,6 +1,7 @@
// lib/services/air_sampling_service.dart // lib/services/air_sampling_service.dart
import 'dart:io'; import 'dart:io';
import 'dart:async'; // Added for TimeoutException
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.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';
@ -16,15 +17,12 @@ import 'local_storage_service.dart';
import 'telegram_service.dart'; import 'telegram_service.dart';
import 'server_config_service.dart'; import 'server_config_service.dart';
import 'zipping_service.dart'; import 'zipping_service.dart';
// START CHANGE: Import the new common submission services
import 'submission_api_service.dart'; import 'submission_api_service.dart';
import 'submission_ftp_service.dart'; import 'submission_ftp_service.dart';
// END CHANGE import 'retry_service.dart'; // Added for queuing failed tasks
/// A dedicated service for handling all business logic for the Air Manual Sampling feature. /// A dedicated service for handling all business logic for the Air Manual Sampling feature.
class AirSamplingService { class AirSamplingService {
// START CHANGE: Instantiate new services and remove ApiService
final DatabaseHelper _dbHelper; final DatabaseHelper _dbHelper;
final TelegramService _telegramService; final TelegramService _telegramService;
final SubmissionApiService _submissionApiService = SubmissionApiService(); final SubmissionApiService _submissionApiService = SubmissionApiService();
@ -32,9 +30,8 @@ class AirSamplingService {
final ServerConfigService _serverConfigService = ServerConfigService(); final ServerConfigService _serverConfigService = ServerConfigService();
final ZippingService _zippingService = ZippingService(); final ZippingService _zippingService = ZippingService();
final LocalStorageService _localStorageService = LocalStorageService(); final LocalStorageService _localStorageService = LocalStorageService();
// END CHANGE final RetryService _retryService = RetryService(); // Added
// MODIFIED: Constructor no longer needs ApiService
AirSamplingService(this._dbHelper, this._telegramService); AirSamplingService(this._dbHelper, this._telegramService);
// This helper method remains unchanged as it's for local saving logic // This helper method remains unchanged as it's for local saving logic
@ -118,149 +115,199 @@ class AirSamplingService {
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
} }
// --- REFACTORED submitInstallation method --- // --- REFACTORED submitInstallation method with granular error handling ---
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data, List<Map<String, dynamic>>? appSettings) async { Future<Map<String, dynamic>> submitInstallation(AirInstallationData data, List<Map<String, dynamic>>? appSettings) async {
const String moduleName = 'air_installation'; const String moduleName = 'air_installation';
final activeConfig = await _serverConfigService.getActiveApiConfig(); final activeConfig = await _serverConfigService.getActiveApiConfig();
final serverName = activeConfig?['config_name'] as String? ?? 'Default'; final serverName = activeConfig?['config_name'] as String? ?? 'Default';
// --- 1. API SUBMISSION (DATA) --- bool anyApiSuccess = false;
debugPrint("Step 1: Delegating Installation Data submission to SubmissionApiService..."); Map<String, dynamic> apiDataResult = {};
final dataResult = await _submissionApiService.submitPost( Map<String, dynamic> apiImageResult = {};
moduleName: moduleName,
endpoint: 'air/manual/installation',
body: data.toJsonForApi(),
);
if (dataResult['success'] != true) {
await _logAndSave(data: data, status: 'L1', message: dataResult['message']!, apiResults: [dataResult], ftpStatuses: [], serverName: serverName, type: 'Installation');
return {'status': 'L1', 'message': dataResult['message']};
}
final recordId = dataResult['data']?['air_man_id']?.toString();
if (recordId == null) {
await _logAndSave(data: data, status: 'L1', message: 'API Error: Missing record ID.', apiResults: [dataResult], ftpStatuses: [], serverName: serverName, type: 'Installation');
return {'status': 'L1', 'message': 'API Error: Missing record ID.'};
}
data.airManId = int.tryParse(recordId);
// --- 2. API SUBMISSION (IMAGES) ---
debugPrint("Step 2: Delegating Installation Image submission to SubmissionApiService...");
final imageFiles = data.getImagesForUpload(); final imageFiles = data.getImagesForUpload();
final imageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName,
endpoint: 'air/manual/installation-images',
fields: {'air_man_id': recordId},
files: imageFiles,
);
final bool apiImagesSuccess = imageResult['success'] == true;
// --- 3. FTP SUBMISSION --- // Step 1: Attempt API Submission
debugPrint("Step 3: Delegating Installation FTP submission to SubmissionFtpService..."); try {
final stationCode = data.stationID ?? 'UNKNOWN'; apiDataResult = await _submissionApiService.submitPost(
final samplingDateTime = "${data.installationDate}_${data.installationTime}".replaceAll(':', '-').replaceAll(' ', '_'); moduleName: moduleName,
final baseFileName = "${stationCode}_INSTALLATION_${samplingDateTime}"; endpoint: 'air/manual/installation',
body: data.toJsonForApi(),
);
// Zip and submit data if (apiDataResult['success'] == true) {
final dataZip = await _zippingService.createDataZip(jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, baseFileName: baseFileName); final recordId = apiDataResult['data']?['air_man_id']?.toString();
if (recordId != null) {
data.airManId = int.tryParse(recordId);
apiImageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName,
endpoint: 'air/manual/installation-images',
fields: {'air_man_id': recordId},
files: imageFiles,
);
anyApiSuccess = apiImageResult['success'] == true;
} else {
anyApiSuccess = false;
apiDataResult['message'] = 'API Error: Missing record ID.';
}
}
} on SocketException catch (e) {
final errorMessage = "API submission failed with network error: $e";
debugPrint(errorMessage);
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': errorMessage};
await _retryService.addApiToQueue(endpoint: 'air/manual/installation', method: 'POST', body: data.toJsonForApi());
} on TimeoutException catch (e) {
final errorMessage = "API submission timed out: $e";
debugPrint(errorMessage);
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': errorMessage};
await _retryService.addApiToQueue(endpoint: 'air/manual/installation', method: 'POST', body: data.toJsonForApi());
}
// Step 2: Attempt FTP Submission
Map<String, dynamic> ftpDataResult = {'statuses': []}; Map<String, dynamic> ftpDataResult = {'statuses': []};
if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: dataZip, remotePath: '/air/data/${path.basename(dataZip.path)}');
}
// Zip and submit images
final imageZip = await _zippingService.createImageZip(imageFiles: imageFiles.values.toList(), baseFileName: baseFileName);
Map<String, dynamic> ftpImageResult = {'statuses': []}; Map<String, dynamic> ftpImageResult = {'statuses': []};
if (imageZip != null) { bool anyFtpSuccess = false;
ftpImageResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: imageZip, remotePath: '/air/images/${path.basename(imageZip.path)}'); try {
final stationCode = data.stationID ?? 'UNKNOWN';
final samplingDateTime = "${data.installationDate}_${data.installationTime}".replaceAll(':', '-').replaceAll(' ', '_');
final baseFileName = "${stationCode}_INSTALLATION_${samplingDateTime}";
final dataZip = await _zippingService.createDataZip(jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, baseFileName: baseFileName);
if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: dataZip, remotePath: '/air/data/${path.basename(dataZip.path)}');
}
final imageZip = await _zippingService.createImageZip(imageFiles: imageFiles.values.toList(), baseFileName: baseFileName);
if (imageZip != null) {
ftpImageResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: imageZip, remotePath: '/air/images/${path.basename(imageZip.path)}');
}
anyFtpSuccess = !(ftpDataResult['statuses'] as List).any((s) => s['success'] == false) && !(ftpImageResult['statuses'] as List).any((s) => s['success'] == false);
} on SocketException catch (e) {
debugPrint("FTP submission failed with network error: $e");
anyFtpSuccess = false;
} on TimeoutException catch (e) {
debugPrint("FTP submission timed out: $e");
anyFtpSuccess = false;
} }
final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true); // Step 3: Determine Final Status
// --- 4. DETERMINE FINAL STATUS, LOG, AND ALERT ---
String finalStatus; String finalStatus;
String finalMessage; String finalMessage;
if (apiImagesSuccess) { if (anyApiSuccess && anyFtpSuccess) {
finalStatus = ftpSuccess ? 'S4' : 'S2'; finalStatus = 'S4';
finalMessage = ftpSuccess ? 'Data and files submitted successfully.' : 'Data submitted to API. FTP upload failed or was queued.'; finalMessage = 'Data and files submitted successfully.';
} else if (anyApiSuccess && !anyFtpSuccess) {
finalStatus = 'S3';
finalMessage = 'Data submitted to API, but FTP upload failed and was queued.';
} else if (!anyApiSuccess && anyFtpSuccess) {
finalStatus = 'L4';
finalMessage = 'API submission failed, but files were sent via FTP.';
} else { } else {
finalStatus = ftpSuccess ? 'L2_FTP_ONLY' : 'L2_PENDING_IMAGES'; finalStatus = 'L1';
finalMessage = ftpSuccess ? 'API image upload failed, but files were sent via FTP.' : 'Data submitted, but API image and FTP uploads failed.'; finalMessage = 'Both API and FTP submissions failed and were queued.';
} }
await _logAndSave(data: data, status: finalStatus, message: finalMessage, apiResults: [dataResult, imageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Installation'); await _logAndSave(data: data, status: finalStatus, message: finalMessage, apiResults: [apiDataResult, apiImageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Installation');
_handleInstallationSuccessAlert(data, appSettings, isDataOnly: !apiImagesSuccess); if (anyApiSuccess || anyFtpSuccess) {
_handleInstallationSuccessAlert(data, appSettings, isDataOnly: imageFiles.isEmpty);
}
return {'status': finalStatus, 'message': finalMessage}; return {'status': finalStatus, 'message': finalMessage};
} }
// --- REFACTORED submitCollection method --- // --- REFACTORED submitCollection method with granular error handling ---
Future<Map<String, dynamic>> submitCollection(AirCollectionData data, AirInstallationData installationData, List<Map<String, dynamic>>? appSettings) async { Future<Map<String, dynamic>> submitCollection(AirCollectionData data, AirInstallationData installationData, List<Map<String, dynamic>>? appSettings) async {
const String moduleName = 'air_collection'; const String moduleName = 'air_collection';
final activeConfig = await _serverConfigService.getActiveApiConfig(); final activeConfig = await _serverConfigService.getActiveApiConfig();
final serverName = activeConfig?['config_name'] as String? ?? 'Default'; final serverName = activeConfig?['config_name'] as String? ?? 'Default';
// --- 1. API SUBMISSION (DATA) --- bool anyApiSuccess = false;
debugPrint("Step 1: Delegating Collection Data submission to SubmissionApiService..."); Map<String, dynamic> apiDataResult = {};
data.airManId = installationData.airManId; // Ensure collection is linked to installation Map<String, dynamic> apiImageResult = {};
final dataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'air/manual/collection',
body: data.toJson(),
);
if (dataResult['success'] != true) {
await _logAndSave(data: data, installationData: installationData, status: 'L3', message: dataResult['message']!, apiResults: [dataResult], ftpStatuses: [], serverName: serverName, type: 'Collection');
return {'status': 'L3', 'message': dataResult['message']};
}
// --- 2. API SUBMISSION (IMAGES) ---
debugPrint("Step 2: Delegating Collection Image submission to SubmissionApiService...");
final imageFiles = data.getImagesForUpload(); final imageFiles = data.getImagesForUpload();
final imageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName,
endpoint: 'air/manual/collection-images',
fields: {'air_man_id': data.airManId.toString()},
files: imageFiles,
);
final bool apiImagesSuccess = imageResult['success'] == true;
// --- 3. FTP SUBMISSION --- // Step 1: Attempt API Submission
debugPrint("Step 3: Delegating Collection FTP submission to SubmissionFtpService..."); try {
final stationCode = installationData.stationID ?? 'UNKNOWN'; data.airManId = installationData.airManId;
final samplingDateTime = "${data.collectionDate}_${data.collectionTime}".replaceAll(':', '-').replaceAll(' ', '_'); apiDataResult = await _submissionApiService.submitPost(
final baseFileName = "${stationCode}_COLLECTION_${samplingDateTime}"; moduleName: moduleName,
endpoint: 'air/manual/collection',
body: data.toJson(),
);
// Zip and submit data (includes both installation and collection data) if (apiDataResult['success'] == true) {
final combinedJson = jsonEncode({"installation": installationData.toDbJson(), "collection": data.toMap()}); apiImageResult = await _submissionApiService.submitMultipart(
final dataZip = await _zippingService.createDataZip(jsonDataMap: {'db.json': combinedJson}, baseFileName: baseFileName); moduleName: moduleName,
endpoint: 'air/manual/collection-images',
fields: {'air_man_id': data.airManId.toString()},
files: imageFiles,
);
anyApiSuccess = apiImageResult['success'] == true;
}
} on SocketException catch (e) {
final errorMessage = "API submission failed with network error: $e";
debugPrint(errorMessage);
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': errorMessage};
await _retryService.addApiToQueue(endpoint: 'air/manual/collection', method: 'POST', body: data.toJson());
} on TimeoutException catch (e) {
final errorMessage = "API submission timed out: $e";
debugPrint(errorMessage);
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': errorMessage};
await _retryService.addApiToQueue(endpoint: 'air/manual/collection', method: 'POST', body: data.toJson());
}
// Step 2: Attempt FTP Submission
Map<String, dynamic> ftpDataResult = {'statuses': []}; Map<String, dynamic> ftpDataResult = {'statuses': []};
if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: dataZip, remotePath: '/air/data/${path.basename(dataZip.path)}');
}
// Zip and submit images
final imageZip = await _zippingService.createImageZip(imageFiles: imageFiles.values.toList(), baseFileName: baseFileName);
Map<String, dynamic> ftpImageResult = {'statuses': []}; Map<String, dynamic> ftpImageResult = {'statuses': []};
if (imageZip != null) { bool anyFtpSuccess = false;
ftpImageResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: imageZip, remotePath: '/air/images/${path.basename(imageZip.path)}'); try {
final stationCode = installationData.stationID ?? 'UNKNOWN';
final samplingDateTime = "${data.collectionDate}_${data.collectionTime}".replaceAll(':', '-').replaceAll(' ', '_');
final baseFileName = "${stationCode}_COLLECTION_${samplingDateTime}";
final combinedJson = jsonEncode({"installation": installationData.toDbJson(), "collection": data.toMap()});
final dataZip = await _zippingService.createDataZip(jsonDataMap: {'db.json': combinedJson}, baseFileName: baseFileName);
if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: dataZip, remotePath: '/air/data/${path.basename(dataZip.path)}');
}
final imageZip = await _zippingService.createImageZip(imageFiles: imageFiles.values.toList(), baseFileName: baseFileName);
if (imageZip != null) {
ftpImageResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: imageZip, remotePath: '/air/images/${path.basename(imageZip.path)}');
}
anyFtpSuccess = !(ftpDataResult['statuses'] as List).any((s) => s['success'] == false) && !(ftpImageResult['statuses'] as List).any((s) => s['success'] == false);
} on SocketException catch (e) {
debugPrint("FTP submission failed with network error: $e");
anyFtpSuccess = false;
} on TimeoutException catch (e) {
debugPrint("FTP submission timed out: $e");
anyFtpSuccess = false;
} }
final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true); // Step 3: Determine Final Status
// --- 4. DETERMINE FINAL STATUS, LOG, AND ALERT ---
String finalStatus; String finalStatus;
String finalMessage; String finalMessage;
if (apiImagesSuccess) { if (anyApiSuccess && anyFtpSuccess) {
finalStatus = ftpSuccess ? 'S4_API_FTP' : 'S3'; finalStatus = 'S4';
finalMessage = ftpSuccess ? 'Data and files submitted successfully.' : 'Data submitted to API. FTP upload failed or was queued.'; finalMessage = 'Data and files submitted successfully.';
} else if (anyApiSuccess && !anyFtpSuccess) {
finalStatus = 'S3';
finalMessage = 'Data submitted to API, but FTP upload failed and was queued.';
} else if (!anyApiSuccess && anyFtpSuccess) {
finalStatus = 'L4';
finalMessage = 'API submission failed, but files were sent via FTP.';
} else { } else {
finalStatus = ftpSuccess ? 'L4_FTP_ONLY' : 'L4_PENDING_IMAGES'; finalStatus = 'L1';
finalMessage = ftpSuccess ? 'API image upload failed, but files were sent via FTP.' : 'Data submitted, but API image and FTP uploads failed.'; finalMessage = 'Both API and FTP submissions failed and were queued.';
} }
await _logAndSave(data: data, installationData: installationData, status: finalStatus, message: finalMessage, apiResults: [dataResult, imageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Collection'); await _logAndSave(data: data, installationData: installationData, status: finalStatus, message: finalMessage, apiResults: [apiDataResult, apiImageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Collection');
_handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: !apiImagesSuccess); if(anyApiSuccess || anyFtpSuccess) {
_handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: imageFiles.isEmpty);
}
return {'status': finalStatus, 'message': finalMessage}; return {'status': finalStatus, 'message': finalMessage};
} }
@ -303,7 +350,7 @@ class AirSamplingService {
'status': status, 'status': status,
'message': message, 'message': message,
'report_id': (data.airManId ?? installationData?.airManId)?.toString(), 'report_id': (data.airManId ?? installationData?.airManId)?.toString(),
'created_at': DateTime.now(), 'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(formData), 'form_data': jsonEncode(formData),
'image_data': jsonEncode(imagePaths), 'image_data': jsonEncode(imagePaths),
'server_name': serverName, 'server_name': serverName,

View File

@ -16,41 +16,31 @@ import 'package:environment_monitoring_app/models/tarball_data.dart';
import 'package:environment_monitoring_app/models/air_collection_data.dart'; import 'package:environment_monitoring_app/models/air_collection_data.dart';
import 'package:environment_monitoring_app/models/air_installation_data.dart'; import 'package:environment_monitoring_app/models/air_installation_data.dart';
import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.dart'; import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.dart';
// START CHANGE: Added import for ServerConfigService to get the base URL
import 'package:environment_monitoring_app/services/server_config_service.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart';
// END CHANGE
// ======================================================================= // =======================================================================
// Part 1: Unified API Service // Part 1: Unified API Service
// ======================================================================= // =======================================================================
/// A unified service that consolidates all API interactions for the application.
// ... (ApiService class definition remains the same)
class ApiService { class ApiService {
final BaseApiService _baseService = BaseApiService(); final BaseApiService _baseService = BaseApiService();
final DatabaseHelper dbHelper = DatabaseHelper(); final DatabaseHelper dbHelper = DatabaseHelper();
// START CHANGE: Added ServerConfigService to provide the base URL for API calls
final ServerConfigService _serverConfigService = ServerConfigService(); final ServerConfigService _serverConfigService = ServerConfigService();
// END CHANGE
late final MarineApiService marine; late final MarineApiService marine;
late final RiverApiService river; late final RiverApiService river;
late final AirApiService air; late final AirApiService air;
static const String imageBaseUrl = 'https://dev14.pstw.com.my/'; static const String imageBaseUrl = 'https://mms-apiv4.pstw.com.my/';
ApiService({required TelegramService telegramService}) { ApiService({required TelegramService telegramService}) {
// START CHANGE: Pass the ServerConfigService to the sub-services
marine = MarineApiService(_baseService, telegramService, _serverConfigService); marine = MarineApiService(_baseService, telegramService, _serverConfigService);
river = RiverApiService(_baseService, telegramService, _serverConfigService); river = RiverApiService(_baseService, telegramService, _serverConfigService);
air = AirApiService(_baseService, telegramService, _serverConfigService); air = AirApiService(_baseService, telegramService, _serverConfigService);
// END CHANGE
} }
// --- Core API Methods (Unchanged) --- // --- Core API Methods ---
// START CHANGE: Update all calls to _baseService to pass the required baseUrl
Future<Map<String, dynamic>> login(String email, String password) async { Future<Map<String, dynamic>> login(String email, String password) async {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.post(baseUrl, 'auth/login', {'email': email, 'password': password}); return _baseService.post(baseUrl, 'auth/login', {'email': email, 'password': password});
@ -92,6 +82,7 @@ class ApiService {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'profile'); return _baseService.get(baseUrl, 'profile');
} }
Future<Map<String, dynamic>> getAllUsers() async { Future<Map<String, dynamic>> getAllUsers() async {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'users'); return _baseService.get(baseUrl, 'users');
@ -101,20 +92,22 @@ class ApiService {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'departments'); return _baseService.get(baseUrl, 'departments');
} }
Future<Map<String, dynamic>> getAllCompanies() async { Future<Map<String, dynamic>> getAllCompanies() async {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'companies'); return _baseService.get(baseUrl, 'companies');
} }
Future<Map<String, dynamic>> getAllPositions() async { Future<Map<String, dynamic>> getAllPositions() async {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'positions'); return _baseService.get(baseUrl, 'positions');
} }
Future<Map<String, dynamic>> getAllStates() async { Future<Map<String, dynamic>> getAllStates() async {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'states'); return _baseService.get(baseUrl, 'states');
} }
Future<Map<String, dynamic>> sendTelegramAlert({ Future<Map<String, dynamic>> sendTelegramAlert({
required String chatId, required String chatId,
required String message, required String message,
@ -147,10 +140,8 @@ class ApiService {
baseUrl: baseUrl, baseUrl: baseUrl,
endpoint: 'profile/upload-picture', endpoint: 'profile/upload-picture',
fields: {}, fields: {},
files: {'profile_picture': imageFile} files: {'profile_picture': imageFile});
);
} }
// END CHANGE
Future<Map<String, dynamic>> refreshProfile() async { Future<Map<String, dynamic>> refreshProfile() async {
debugPrint('ApiService: Refreshing profile data from server...'); debugPrint('ApiService: Refreshing profile data from server...');
@ -166,47 +157,151 @@ class ApiService {
/// Helper method to make a delta-sync API call. /// Helper method to make a delta-sync API call.
Future<Map<String, dynamic>> _fetchDelta(String endpoint, String? lastSyncTimestamp) async { Future<Map<String, dynamic>> _fetchDelta(String endpoint, String? lastSyncTimestamp) async {
// START CHANGE: Get baseUrl and pass it to the get method
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
String url = endpoint; String url = endpoint;
if (lastSyncTimestamp != null) { if (lastSyncTimestamp != null) {
// Append the 'since' parameter to the URL for delta requests
url += '?since=$lastSyncTimestamp'; url += '?since=$lastSyncTimestamp';
} }
return _baseService.get(baseUrl, url); return _baseService.get(baseUrl, url);
// END CHANGE
} }
/// Orchestrates a full DELTA sync from the server to the local database. /// Orchestrates a full DELTA sync from the server to the local database.
Future<Map<String, dynamic>> syncAllData({String? lastSyncTimestamp}) async { Future<Map<String, dynamic>> syncAllData({String? lastSyncTimestamp}) async {
debugPrint('ApiService: Starting DELTA data sync. Since: $lastSyncTimestamp'); debugPrint('ApiService: Starting DELTA data sync. Since: $lastSyncTimestamp');
try { try {
// Defines all data types to sync, their endpoints, and their DB handlers.
final syncTasks = { final syncTasks = {
'profile': {'endpoint': 'profile', 'handler': (d, id) async { if (d.isNotEmpty) await dbHelper.saveProfile(d.first); }}, 'profile': {
'allUsers': {'endpoint': 'users', 'handler': (d, id) async { await dbHelper.upsertUsers(d); await dbHelper.deleteUsers(id); }}, 'endpoint': 'profile',
// --- ADDED: New sync task for documents --- 'handler': (d, id) async {
'documents': {'endpoint': 'documents', 'handler': (d, id) async { await dbHelper.upsertDocuments(d); await dbHelper.deleteDocuments(id); }}, if (d.isNotEmpty) await dbHelper.saveProfile(d.first);
// --- END ADDED --- }
'tarballStations': {'endpoint': 'marine/tarball/stations', 'handler': (d, id) async { await dbHelper.upsertTarballStations(d); await dbHelper.deleteTarballStations(id); }}, },
'manualStations': {'endpoint': 'marine/manual/stations', 'handler': (d, id) async { await dbHelper.upsertManualStations(d); await dbHelper.deleteManualStations(id); }}, 'allUsers': {
'tarballClassifications': {'endpoint': 'marine/tarball/classifications', 'handler': (d, id) async { await dbHelper.upsertTarballClassifications(d); await dbHelper.deleteTarballClassifications(id); }}, 'endpoint': 'users',
'riverManualStations': {'endpoint': 'river/manual-stations', 'handler': (d, id) async { await dbHelper.upsertRiverManualStations(d); await dbHelper.deleteRiverManualStations(id); }}, 'handler': (d, id) async {
'riverTriennialStations': {'endpoint': 'river/triennial-stations', 'handler': (d, id) async { await dbHelper.upsertRiverTriennialStations(d); await dbHelper.deleteRiverTriennialStations(id); }}, // START CHANGE: Use custom upsert method for users
'departments': {'endpoint': 'departments', 'handler': (d, id) async { await dbHelper.upsertDepartments(d); await dbHelper.deleteDepartments(id); }}, await dbHelper.upsertUsers(d);
'companies': {'endpoint': 'companies', 'handler': (d, id) async { await dbHelper.upsertCompanies(d); await dbHelper.deleteCompanies(id); }}, await dbHelper.deleteUsers(id);
'positions': {'endpoint': 'positions', 'handler': (d, id) async { await dbHelper.upsertPositions(d); await dbHelper.deletePositions(id); }}, // END CHANGE
'airManualStations': {'endpoint': 'air/manual-stations', 'handler': (d, id) async { await dbHelper.upsertAirManualStations(d); await dbHelper.deleteAirManualStations(id); }}, }
'airClients': {'endpoint': 'air/clients', 'handler': (d, id) async { await dbHelper.upsertAirClients(d); await dbHelper.deleteAirClients(id); }}, },
'states': {'endpoint': 'states', 'handler': (d, id) async { await dbHelper.upsertStates(d); await dbHelper.deleteStates(id); }}, 'documents': {
'appSettings': {'endpoint': 'settings', 'handler': (d, id) async { await dbHelper.upsertAppSettings(d); await dbHelper.deleteAppSettings(id); }}, 'endpoint': 'documents',
'parameterLimits': {'endpoint': 'parameter-limits', 'handler': (d, id) async { await dbHelper.upsertParameterLimits(d); await dbHelper.deleteParameterLimits(id); }}, 'handler': (d, id) async {
'apiConfigs': {'endpoint': 'api-configs', 'handler': (d, id) async { await dbHelper.upsertApiConfigs(d); await dbHelper.deleteApiConfigs(id); }}, await dbHelper.upsertDocuments(d);
'ftpConfigs': {'endpoint': 'ftp-configs', 'handler': (d, id) async { await dbHelper.upsertFtpConfigs(d); await dbHelper.deleteFtpConfigs(id); }}, await dbHelper.deleteDocuments(id);
}
},
'tarballStations': {
'endpoint': 'marine/tarball/stations',
'handler': (d, id) async {
await dbHelper.upsertTarballStations(d);
await dbHelper.deleteTarballStations(id);
}
},
'manualStations': {
'endpoint': 'marine/manual/stations',
'handler': (d, id) async {
await dbHelper.upsertManualStations(d);
await dbHelper.deleteManualStations(id);
}
},
'tarballClassifications': {
'endpoint': 'marine/tarball/classifications',
'handler': (d, id) async {
await dbHelper.upsertTarballClassifications(d);
await dbHelper.deleteTarballClassifications(id);
}
},
'riverManualStations': {
'endpoint': 'river/manual-stations',
'handler': (d, id) async {
await dbHelper.upsertRiverManualStations(d);
await dbHelper.deleteRiverManualStations(id);
}
},
'riverTriennialStations': {
'endpoint': 'river/triennial-stations',
'handler': (d, id) async {
await dbHelper.upsertRiverTriennialStations(d);
await dbHelper.deleteRiverTriennialStations(id);
}
},
'departments': {
'endpoint': 'departments',
'handler': (d, id) async {
await dbHelper.upsertDepartments(d);
await dbHelper.deleteDepartments(id);
}
},
'companies': {
'endpoint': 'companies',
'handler': (d, id) async {
await dbHelper.upsertCompanies(d);
await dbHelper.deleteCompanies(id);
}
},
'positions': {
'endpoint': 'positions',
'handler': (d, id) async {
await dbHelper.upsertPositions(d);
await dbHelper.deletePositions(id);
}
},
'airManualStations': {
'endpoint': 'air/manual-stations',
'handler': (d, id) async {
await dbHelper.upsertAirManualStations(d);
await dbHelper.deleteAirManualStations(id);
}
},
'airClients': {
'endpoint': 'air/clients',
'handler': (d, id) async {
await dbHelper.upsertAirClients(d);
await dbHelper.deleteAirClients(id);
}
},
'states': {
'endpoint': 'states',
'handler': (d, id) async {
await dbHelper.upsertStates(d);
await dbHelper.deleteStates(id);
}
},
'appSettings': {
'endpoint': 'settings',
'handler': (d, id) async {
await dbHelper.upsertAppSettings(d);
await dbHelper.deleteAppSettings(id);
}
},
'parameterLimits': {
'endpoint': 'parameter-limits',
'handler': (d, id) async {
await dbHelper.upsertParameterLimits(d);
await dbHelper.deleteParameterLimits(id);
}
},
'apiConfigs': {
'endpoint': 'api-configs',
'handler': (d, id) async {
await dbHelper.upsertApiConfigs(d);
await dbHelper.deleteApiConfigs(id);
}
},
'ftpConfigs': {
'endpoint': 'ftp-configs',
'handler': (d, id) async {
await dbHelper.upsertFtpConfigs(d);
await dbHelper.deleteFtpConfigs(id);
}
},
}; };
// Fetch all deltas in parallel // Fetch all deltas in parallel
final fetchFutures = syncTasks.map((key, value) => MapEntry(key, _fetchDelta(value['endpoint'] as String, lastSyncTimestamp))); final fetchFutures = syncTasks.map((key, value) =>
MapEntry(key, _fetchDelta(value['endpoint'] as String, lastSyncTimestamp)));
final results = await Future.wait(fetchFutures.values); final results = await Future.wait(fetchFutures.values);
final resultData = Map.fromIterables(fetchFutures.keys, results); final resultData = Map.fromIterables(fetchFutures.keys, results);
@ -216,7 +311,6 @@ class ApiService {
final result = entry.value; final result = entry.value;
if (result['success'] == true && result['data'] != null) { if (result['success'] == true && result['data'] != null) {
// The profile endpoint has a different structure, handle it separately.
if (key == 'profile') { if (key == 'profile') {
await (syncTasks[key]!['handler'] as Function)([result['data']], []); await (syncTasks[key]!['handler'] as Function)([result['data']], []);
} else { } else {
@ -231,7 +325,6 @@ class ApiService {
debugPrint('ApiService: Delta sync complete.'); debugPrint('ApiService: Delta sync complete.');
return {'success': true, 'message': 'Delta sync successful.'}; return {'success': true, 'message': 'Delta sync successful.'};
} catch (e) { } catch (e) {
debugPrint('ApiService: Delta data sync failed: $e'); debugPrint('ApiService: Delta data sync failed: $e');
return {'success': false, 'message': 'Data sync failed: $e'}; return {'success': false, 'message': 'Data sync failed: $e'};
@ -246,16 +339,15 @@ class ApiService {
class AirApiService { class AirApiService {
final BaseApiService _baseService; final BaseApiService _baseService;
final TelegramService? _telegramService; final TelegramService? _telegramService;
// START CHANGE: Add ServerConfigService dependency
final ServerConfigService _serverConfigService; final ServerConfigService _serverConfigService;
AirApiService(this._baseService, this._telegramService, this._serverConfigService);
// END CHANGE
// START CHANGE: Update all calls to _baseService to pass the required baseUrl AirApiService(this._baseService, this._telegramService, this._serverConfigService);
Future<Map<String, dynamic>> getManualStations() async { Future<Map<String, dynamic>> getManualStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'air/manual-stations'); return _baseService.get(baseUrl, 'air/manual-stations');
} }
Future<Map<String, dynamic>> getClients() async { Future<Map<String, dynamic>> getClients() async {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'air/clients'); return _baseService.get(baseUrl, 'air/clients');
@ -296,27 +388,25 @@ class AirApiService {
files: files, files: files,
); );
} }
// END CHANGE
} }
class MarineApiService { class MarineApiService {
final BaseApiService _baseService; final BaseApiService _baseService;
final TelegramService _telegramService; final TelegramService _telegramService;
// START CHANGE: Add ServerConfigService dependency
final ServerConfigService _serverConfigService; final ServerConfigService _serverConfigService;
MarineApiService(this._baseService, this._telegramService, this._serverConfigService);
// END CHANGE
// START CHANGE: Update all calls to _baseService to pass the required baseUrl MarineApiService(this._baseService, this._telegramService, this._serverConfigService);
Future<Map<String, dynamic>> getTarballStations() async { Future<Map<String, dynamic>> getTarballStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/tarball/stations'); return _baseService.get(baseUrl, 'marine/tarball/stations');
} }
Future<Map<String, dynamic>> getManualStations() async { Future<Map<String, dynamic>> getManualStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/manual/stations'); return _baseService.get(baseUrl, 'marine/manual/stations');
} }
Future<Map<String, dynamic>> getTarballClassifications() async { Future<Map<String, dynamic>> getTarballClassifications() async {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/tarball/classifications'); return _baseService.get(baseUrl, 'marine/tarball/classifications');
@ -398,7 +488,8 @@ class MarineApiService {
}; };
} }
Future<void> _handleInSituSuccessAlert(InSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async { Future<void> _handleInSituSuccessAlert(InSituSamplingData data,
List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try { try {
final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly); final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly);
final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings); final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings);
@ -417,31 +508,40 @@ class MarineApiService {
}) async { }) async {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
final dataResult = await _baseService.post(baseUrl, 'marine/tarball/sample', formData); final dataResult = await _baseService.post(baseUrl, 'marine/tarball/sample', formData);
if (dataResult['success'] != true) return {'status': 'L1', 'success': false, 'message': 'Failed to submit data: ${dataResult['message']}'}; if (dataResult['success'] != true)
return {'status': 'L1', 'success': false, 'message': 'Failed to submit data: ${dataResult['message']}'};
final recordId = dataResult['data']?['autoid']; final recordId = dataResult['data']?['autoid'];
if (recordId == null) return {'status': 'L2', 'success': false, 'message': 'Data submitted, but failed to get a record ID.'}; if (recordId == null) return {'status': 'L2', 'success': false, 'message': 'Data submitted, but failed to get a record ID.'};
final filesToUpload = <String, File>{}; final filesToUpload = <String, File>{};
imageFiles.forEach((key, value) { if (value != null) filesToUpload[key] = value; }); imageFiles.forEach((key, value) {
if (value != null) filesToUpload[key] = value;
});
if (filesToUpload.isEmpty) { if (filesToUpload.isEmpty) {
_handleTarballSuccessAlert(formData, appSettings, isDataOnly: true); _handleTarballSuccessAlert(formData, appSettings, isDataOnly: true);
return {'status': 'L3', 'success': true, 'message': 'Data submitted successfully.', 'reportId': recordId}; return {'status': 'L3', 'success': true, 'message': 'Data submitted successfully.', 'reportId': recordId};
} }
final imageResult = await _baseService.postMultipart(baseUrl: baseUrl, endpoint: 'marine/tarball/images', fields: {'autoid': recordId.toString()}, files: filesToUpload); final imageResult = await _baseService.postMultipart(
baseUrl: baseUrl, endpoint: 'marine/tarball/images', fields: {'autoid': recordId.toString()}, files: filesToUpload);
if (imageResult['success'] != true) { if (imageResult['success'] != true) {
_handleTarballSuccessAlert(formData, appSettings, isDataOnly: true); _handleTarballSuccessAlert(formData, appSettings, isDataOnly: true);
return {'status': 'L2', 'success': false, 'message': 'Data submitted, but image upload failed: ${imageResult['message']}', 'reportId': recordId}; return {
'status': 'L2',
'success': false,
'message': 'Data submitted, but image upload failed: ${imageResult['message']}',
'reportId': recordId
};
} }
_handleTarballSuccessAlert(formData, appSettings, isDataOnly: false); _handleTarballSuccessAlert(formData, appSettings, isDataOnly: false);
return {'status': 'L3', 'success': true, 'message': 'Data and images submitted successfully.', 'reportId': recordId}; return {'status': 'L3', 'success': true, 'message': 'Data and images submitted successfully.', 'reportId': recordId};
} }
// END CHANGE
Future<void> _handleTarballSuccessAlert(Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async { Future<void> _handleTarballSuccessAlert(
Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
debugPrint("Triggering Telegram alert logic..."); debugPrint("Triggering Telegram alert logic...");
try { try {
final message = _generateTarballAlertMessage(formData, isDataOnly: isDataOnly); final message = _generateTarballAlertMessage(formData, isDataOnly: isDataOnly);
@ -489,16 +589,15 @@ class MarineApiService {
class RiverApiService { class RiverApiService {
final BaseApiService _baseService; final BaseApiService _baseService;
final TelegramService _telegramService; final TelegramService _telegramService;
// START CHANGE: Add ServerConfigService dependency
final ServerConfigService _serverConfigService; final ServerConfigService _serverConfigService;
RiverApiService(this._baseService, this._telegramService, this._serverConfigService);
// END CHANGE
// START CHANGE: Update all calls to _baseService to pass the required baseUrl RiverApiService(this._baseService, this._telegramService, this._serverConfigService);
Future<Map<String, dynamic>> getManualStations() async { Future<Map<String, dynamic>> getManualStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'river/manual-stations'); return _baseService.get(baseUrl, 'river/manual-stations');
} }
Future<Map<String, dynamic>> getTriennialStations() async { Future<Map<String, dynamic>> getTriennialStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'river/triennial-stations'); return _baseService.get(baseUrl, 'river/triennial-stations');
@ -570,9 +669,9 @@ class RiverApiService {
'reportId': recordId.toString() 'reportId': recordId.toString()
}; };
} }
// END CHANGE
Future<void> _handleInSituSuccessAlert(Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async { Future<void> _handleInSituSuccessAlert(
Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try { try {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = formData['r_man_station_name'] ?? 'N/A'; final stationName = formData['r_man_station_name'] ?? 'N/A';
@ -622,8 +721,7 @@ class RiverApiService {
class DatabaseHelper { class DatabaseHelper {
static Database? _database; static Database? _database;
static const String _dbName = 'app_data.db'; static const String _dbName = 'app_data.db';
// --- ADDED: Incremented DB version for the new table --- static const int _dbVersion = 21;
static const int _dbVersion = 20;
static const String _profileTable = 'user_profile'; static const String _profileTable = 'user_profile';
static const String _usersTable = 'all_users'; static const String _usersTable = 'all_users';
@ -645,14 +743,12 @@ class DatabaseHelper {
static const String _ftpConfigsTable = 'ftp_configurations'; static const String _ftpConfigsTable = 'ftp_configurations';
static const String _retryQueueTable = 'retry_queue'; static const String _retryQueueTable = 'retry_queue';
static const String _submissionLogTable = 'submission_log'; static const String _submissionLogTable = 'submission_log';
// --- ADDED: New table name for documents ---
static const String _documentsTable = 'documents'; static const String _documentsTable = 'documents';
static const String _modulePreferencesTable = 'module_preferences'; static const String _modulePreferencesTable = 'module_preferences';
static const String _moduleApiLinksTable = 'module_api_links'; static const String _moduleApiLinksTable = 'module_api_links';
static const String _moduleFtpLinksTable = 'module_ftp_links'; static const String _moduleFtpLinksTable = 'module_ftp_links';
Future<Database> get database async { Future<Database> get database async {
if (_database != null) return _database!; if (_database != null) return _database!;
_database = await _initDB(); _database = await _initDB();
@ -666,7 +762,14 @@ class DatabaseHelper {
Future _onCreate(Database db, int version) async { Future _onCreate(Database db, int version) async {
await db.execute('CREATE TABLE $_profileTable(user_id INTEGER PRIMARY KEY, profile_json TEXT)'); await db.execute('CREATE TABLE $_profileTable(user_id INTEGER PRIMARY KEY, profile_json TEXT)');
await db.execute('CREATE TABLE $_usersTable(user_id INTEGER PRIMARY KEY, user_json TEXT)'); await db.execute('''
CREATE TABLE $_usersTable(
user_id INTEGER PRIMARY KEY,
email TEXT UNIQUE,
password_hash TEXT,
user_json TEXT
)
''');
await db.execute('CREATE TABLE $_tarballStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); await db.execute('CREATE TABLE $_tarballStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)');
await db.execute('CREATE TABLE $_manualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); await db.execute('CREATE TABLE $_manualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)');
await db.execute('CREATE TABLE $_riverManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); await db.execute('CREATE TABLE $_riverManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)');
@ -693,7 +796,6 @@ class DatabaseHelper {
status TEXT NOT NULL status TEXT NOT NULL
) )
'''); ''');
// FIX: Updated CREATE TABLE statement for _submissionLogTable to include api_status and ftp_status
await db.execute(''' await db.execute('''
CREATE TABLE $_submissionLogTable ( CREATE TABLE $_submissionLogTable (
submission_id TEXT PRIMARY KEY, submission_id TEXT PRIMARY KEY,
@ -710,8 +812,6 @@ class DatabaseHelper {
ftp_status TEXT ftp_status TEXT
) )
'''); ''');
// START CHANGE: Added CREATE TABLE statements for the new tables.
await db.execute(''' await db.execute('''
CREATE TABLE $_modulePreferencesTable ( CREATE TABLE $_modulePreferencesTable (
module_name TEXT PRIMARY KEY, module_name TEXT PRIMARY KEY,
@ -735,9 +835,6 @@ class DatabaseHelper {
is_enabled INTEGER NOT NULL DEFAULT 1 is_enabled INTEGER NOT NULL DEFAULT 1
) )
'''); ''');
// END CHANGE
// --- ADDED: Create the documents table on initial creation ---
await db.execute('CREATE TABLE $_documentsTable(id INTEGER PRIMARY KEY, document_json TEXT)'); await db.execute('CREATE TABLE $_documentsTable(id INTEGER PRIMARY KEY, document_json TEXT)');
} }
@ -770,7 +867,6 @@ class DatabaseHelper {
'''); ''');
} }
if (oldVersion < 18) { if (oldVersion < 18) {
// FIX: Updated UPGRADE TABLE statement for _submissionLogTable to include api_status and ftp_status
await db.execute(''' await db.execute('''
CREATE TABLE IF NOT EXISTS $_submissionLogTable ( CREATE TABLE IF NOT EXISTS $_submissionLogTable (
submission_id TEXT PRIMARY KEY, submission_id TEXT PRIMARY KEY,
@ -786,18 +882,11 @@ class DatabaseHelper {
) )
'''); ''');
} }
// Add columns if upgrading from < 18 or if columns were manually dropped (for testing)
// NOTE: In a real migration, you'd check if the columns exist first.
if (oldVersion < 19) { if (oldVersion < 19) {
try { try {
await db.execute("ALTER TABLE $_submissionLogTable ADD COLUMN api_status TEXT"); await db.execute("ALTER TABLE $_submissionLogTable ADD COLUMN api_status TEXT");
await db.execute("ALTER TABLE $_submissionLogTable ADD COLUMN ftp_status TEXT"); await db.execute("ALTER TABLE $_submissionLogTable ADD COLUMN ftp_status TEXT");
} catch (_) { } catch (_) {}
// Ignore if columns already exist during a complex migration path
}
// START CHANGE: Add upgrade path for the new preference tables.
await db.execute(''' await db.execute('''
CREATE TABLE IF NOT EXISTS $_modulePreferencesTable ( CREATE TABLE IF NOT EXISTS $_modulePreferencesTable (
module_name TEXT PRIMARY KEY, module_name TEXT PRIMARY KEY,
@ -821,12 +910,23 @@ class DatabaseHelper {
is_enabled INTEGER NOT NULL DEFAULT 1 is_enabled INTEGER NOT NULL DEFAULT 1
) )
'''); ''');
// END CHANGE
} }
// --- ADDED: Upgrade path for the new documents table ---
if (oldVersion < 20) { if (oldVersion < 20) {
await db.execute('CREATE TABLE IF NOT EXISTS $_documentsTable(id INTEGER PRIMARY KEY, document_json TEXT)'); await db.execute('CREATE TABLE IF NOT EXISTS $_documentsTable(id INTEGER PRIMARY KEY, document_json TEXT)');
} }
if (oldVersion < 21) {
try {
await db.execute("ALTER TABLE $_usersTable ADD COLUMN email TEXT");
} catch (e) {
debugPrint("Upgrade warning: Failed to add email column to users table (may already exist): $e");
}
try {
await db.execute("ALTER TABLE $_usersTable ADD COLUMN password_hash TEXT");
} catch (e) {
debugPrint("Upgrade warning: Failed to add password_hash column to users table (may already exist): $e");
}
}
} }
/// Performs an "upsert": inserts new records or replaces existing ones. /// Performs an "upsert": inserts new records or replaces existing ones.
@ -869,43 +969,140 @@ class DatabaseHelper {
Future<void> saveProfile(Map<String, dynamic> profile) async { Future<void> saveProfile(Map<String, dynamic> profile) async {
final db = await database; final db = await database;
await db.insert(_profileTable, {'user_id': profile['user_id'], 'profile_json': jsonEncode(profile)}, conflictAlgorithm: ConflictAlgorithm.replace); await db.insert(_profileTable, {'user_id': profile['user_id'], 'profile_json': jsonEncode(profile)},
conflictAlgorithm: ConflictAlgorithm.replace);
} }
Future<Map<String, dynamic>?> loadProfile() async { Future<Map<String, dynamic>?> loadProfile() async {
final db = await database; final db = await database;
final List<Map<String, dynamic>> maps = await db.query(_profileTable); final List<Map<String, dynamic>> maps = await db.query(_profileTable);
if(maps.isNotEmpty) return jsonDecode(maps.first['profile_json']); if (maps.isNotEmpty) return jsonDecode(maps.first['profile_json']);
return null; return null;
} }
// --- Upsert/Delete/Load methods for all data types --- // --- START: Offline Authentication and User Upsert Methods ---
/// Retrieves a user's profile JSON from the users table by email.
Future<Map<String, dynamic>?> loadProfileByEmail(String email) async {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(
_usersTable,
columns: ['user_json'],
where: 'email = ?',
whereArgs: [email],
);
if (maps.isNotEmpty) {
try {
return jsonDecode(maps.first['user_json']) as Map<String, dynamic>;
} catch (e) {
debugPrint("Error decoding profile for email $email: $e");
return null;
}
}
return null;
}
/// Inserts or replaces a user's profile and credentials.
/// This ensures the record exists when caching credentials during login.
Future<void> upsertUserWithCredentials({
required Map<String, dynamic> profile,
required String passwordHash,
}) async {
final db = await database;
await db.insert(
_usersTable,
{
'user_id': profile['user_id'],
'email': profile['email'],
'password_hash': passwordHash,
'user_json': jsonEncode(profile)
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
debugPrint("Upserted user credentials for ${profile['email']}");
}
/// Retrieves the stored password hash for a user by email.
Future<String?> getUserPasswordHashByEmail(String email) async {
final db = await database;
final List<Map<String, dynamic>> result = await db.query(
_usersTable,
columns: ['password_hash'],
where: 'email = ?',
whereArgs: [email],
);
if (result.isNotEmpty && result.first['password_hash'] != null) {
return result.first['password_hash'] as String;
}
return null;
}
// --- START: Custom upsert method for user sync to prevent hash overwrite ---
/// Upserts user data from sync without overwriting the local password hash.
Future<void> upsertUsers(List<Map<String, dynamic>> data) async {
if (data.isEmpty) return;
final db = await database;
for (var item in data) {
final updateData = {
//'email': item['email'],
'user_json': jsonEncode(item),
};
// Try to update existing record first, preserving other columns like password_hash
int count = await db.update(
_usersTable,
updateData,
where: 'user_id = ?',
whereArgs: [item['user_id']],
);
// If no record was updated (count == 0), insert a new record.
if (count == 0) {
await db.insert(
_usersTable,
{
'user_id': item['user_id'],
'email': item['email'],
'user_json': jsonEncode(item),
// password_hash will be null for a new user until they log in on this device.
},
conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
}
debugPrint("Upserted ${data.length} user items in custom upsert method.");
}
// --- END: Custom upsert method ---
Future<void> upsertUsers(List<Map<String, dynamic>> data) => _upsertData(_usersTable, 'user_id', data, 'user');
Future<void> deleteUsers(List<dynamic> ids) => _deleteData(_usersTable, 'user_id', ids); Future<void> deleteUsers(List<dynamic> ids) => _deleteData(_usersTable, 'user_id', ids);
Future<List<Map<String, dynamic>>?> loadUsers() => _loadData(_usersTable, 'user'); Future<List<Map<String, dynamic>>?> loadUsers() => _loadData(_usersTable, 'user');
// --- ADDED: Handlers for the new documents table ---
Future<void> upsertDocuments(List<Map<String, dynamic>> data) => _upsertData(_documentsTable, 'id', data, 'document'); Future<void> upsertDocuments(List<Map<String, dynamic>> data) => _upsertData(_documentsTable, 'id', data, 'document');
Future<void> deleteDocuments(List<dynamic> ids) => _deleteData(_documentsTable, 'id', ids); Future<void> deleteDocuments(List<dynamic> ids) => _deleteData(_documentsTable, 'id', ids);
Future<List<Map<String, dynamic>>?> loadDocuments() => _loadData(_documentsTable, 'document'); Future<List<Map<String, dynamic>>?> loadDocuments() => _loadData(_documentsTable, 'document');
Future<void> upsertTarballStations(List<Map<String, dynamic>> data) => _upsertData(_tarballStationsTable, 'station_id', data, 'station'); Future<void> upsertTarballStations(List<Map<String, dynamic>> data) =>
_upsertData(_tarballStationsTable, 'station_id', data, 'station');
Future<void> deleteTarballStations(List<dynamic> ids) => _deleteData(_tarballStationsTable, 'station_id', ids); Future<void> deleteTarballStations(List<dynamic> ids) => _deleteData(_tarballStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadTarballStations() => _loadData(_tarballStationsTable, 'station'); Future<List<Map<String, dynamic>>?> loadTarballStations() => _loadData(_tarballStationsTable, 'station');
Future<void> upsertManualStations(List<Map<String, dynamic>> data) => _upsertData(_manualStationsTable, 'station_id', data, 'station'); Future<void> upsertManualStations(List<Map<String, dynamic>> data) =>
_upsertData(_manualStationsTable, 'station_id', data, 'station');
Future<void> deleteManualStations(List<dynamic> ids) => _deleteData(_manualStationsTable, 'station_id', ids); Future<void> deleteManualStations(List<dynamic> ids) => _deleteData(_manualStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadManualStations() => _loadData(_manualStationsTable, 'station'); Future<List<Map<String, dynamic>>?> loadManualStations() => _loadData(_manualStationsTable, 'station');
Future<void> upsertRiverManualStations(List<Map<String, dynamic>> data) => _upsertData(_riverManualStationsTable, 'station_id', data, 'station'); Future<void> upsertRiverManualStations(List<Map<String, dynamic>> data) =>
_upsertData(_riverManualStationsTable, 'station_id', data, 'station');
Future<void> deleteRiverManualStations(List<dynamic> ids) => _deleteData(_riverManualStationsTable, 'station_id', ids); Future<void> deleteRiverManualStations(List<dynamic> ids) => _deleteData(_riverManualStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadRiverManualStations() => _loadData(_riverManualStationsTable, 'station'); Future<List<Map<String, dynamic>>?> loadRiverManualStations() => _loadData(_riverManualStationsTable, 'station');
Future<void> upsertRiverTriennialStations(List<Map<String, dynamic>> data) => _upsertData(_riverTriennialStationsTable, 'station_id', data, 'station'); Future<void> upsertRiverTriennialStations(List<Map<String, dynamic>> data) =>
_upsertData(_riverTriennialStationsTable, 'station_id', data, 'station');
Future<void> deleteRiverTriennialStations(List<dynamic> ids) => _deleteData(_riverTriennialStationsTable, 'station_id', ids); Future<void> deleteRiverTriennialStations(List<dynamic> ids) => _deleteData(_riverTriennialStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadRiverTriennialStations() => _loadData(_riverTriennialStationsTable, 'station'); Future<List<Map<String, dynamic>>?> loadRiverTriennialStations() => _loadData(_riverTriennialStationsTable, 'station');
Future<void> upsertTarballClassifications(List<Map<String, dynamic>> data) => _upsertData(_tarballClassificationsTable, 'classification_id', data, 'classification'); Future<void> upsertTarballClassifications(List<Map<String, dynamic>> data) =>
_upsertData(_tarballClassificationsTable, 'classification_id', data, 'classification');
Future<void> deleteTarballClassifications(List<dynamic> ids) => _deleteData(_tarballClassificationsTable, 'classification_id', ids); Future<void> deleteTarballClassifications(List<dynamic> ids) => _deleteData(_tarballClassificationsTable, 'classification_id', ids);
Future<List<Map<String, dynamic>>?> loadTarballClassifications() => _loadData(_tarballClassificationsTable, 'classification'); Future<List<Map<String, dynamic>>?> loadTarballClassifications() => _loadData(_tarballClassificationsTable, 'classification');
@ -921,7 +1118,8 @@ class DatabaseHelper {
Future<void> deletePositions(List<dynamic> ids) => _deleteData(_positionsTable, 'position_id', ids); Future<void> deletePositions(List<dynamic> ids) => _deleteData(_positionsTable, 'position_id', ids);
Future<List<Map<String, dynamic>>?> loadPositions() => _loadData(_positionsTable, 'position'); Future<List<Map<String, dynamic>>?> loadPositions() => _loadData(_positionsTable, 'position');
Future<void> upsertAirManualStations(List<Map<String, dynamic>> data) => _upsertData(_airManualStationsTable, 'station_id', data, 'station'); Future<void> upsertAirManualStations(List<Map<String, dynamic>> data) =>
_upsertData(_airManualStationsTable, 'station_id', data, 'station');
Future<void> deleteAirManualStations(List<dynamic> ids) => _deleteData(_airManualStationsTable, 'station_id', ids); Future<void> deleteAirManualStations(List<dynamic> ids) => _deleteData(_airManualStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadAirManualStations() => _loadData(_airManualStationsTable, 'station'); Future<List<Map<String, dynamic>>?> loadAirManualStations() => _loadData(_airManualStationsTable, 'station');
@ -941,7 +1139,6 @@ class DatabaseHelper {
Future<void> deleteParameterLimits(List<dynamic> ids) => _deleteData(_parameterLimitsTable, 'param_autoid', ids); Future<void> deleteParameterLimits(List<dynamic> ids) => _deleteData(_parameterLimitsTable, 'param_autoid', ids);
Future<List<Map<String, dynamic>>?> loadParameterLimits() => _loadData(_parameterLimitsTable, 'limit'); Future<List<Map<String, dynamic>>?> loadParameterLimits() => _loadData(_parameterLimitsTable, 'limit');
// --- ADDED: Methods for independent API and FTP configurations ---
Future<void> upsertApiConfigs(List<Map<String, dynamic>> data) => _upsertData(_apiConfigsTable, 'api_config_id', data, 'config'); Future<void> upsertApiConfigs(List<Map<String, dynamic>> data) => _upsertData(_apiConfigsTable, 'api_config_id', data, 'config');
Future<void> deleteApiConfigs(List<dynamic> ids) => _deleteData(_apiConfigsTable, 'api_config_id', ids); Future<void> deleteApiConfigs(List<dynamic> ids) => _deleteData(_apiConfigsTable, 'api_config_id', ids);
Future<List<Map<String, dynamic>>?> loadApiConfigs() => _loadData(_apiConfigsTable, 'config'); Future<List<Map<String, dynamic>>?> loadApiConfigs() => _loadData(_apiConfigsTable, 'config');
@ -950,7 +1147,6 @@ class DatabaseHelper {
Future<void> deleteFtpConfigs(List<dynamic> ids) => _deleteData(_ftpConfigsTable, 'ftp_config_id', ids); Future<void> deleteFtpConfigs(List<dynamic> ids) => _deleteData(_ftpConfigsTable, 'ftp_config_id', ids);
Future<List<Map<String, dynamic>>?> loadFtpConfigs() => _loadData(_ftpConfigsTable, 'config'); Future<List<Map<String, dynamic>>?> loadFtpConfigs() => _loadData(_ftpConfigsTable, 'config');
// --- ADDED: Methods for the new retry queue ---
Future<int> queueFailedRequest(Map<String, dynamic> data) async { Future<int> queueFailedRequest(Map<String, dynamic> data) async {
final db = await database; final db = await database;
return await db.insert(_retryQueueTable, data, conflictAlgorithm: ConflictAlgorithm.replace); return await db.insert(_retryQueueTable, data, conflictAlgorithm: ConflictAlgorithm.replace);
@ -972,10 +1168,6 @@ class DatabaseHelper {
await db.delete(_retryQueueTable, where: 'id = ?', whereArgs: [id]); await db.delete(_retryQueueTable, where: 'id = ?', whereArgs: [id]);
} }
// --- ADDED: Methods for the centralized submission log ---
/// Saves a new submission log entry to the central database table.
// FIX: Updated signature to accept api_status and ftp_status
Future<void> saveSubmissionLog(Map<String, dynamic> data) async { Future<void> saveSubmissionLog(Map<String, dynamic> data) async {
final db = await database; final db = await database;
await db.insert( await db.insert(
@ -985,7 +1177,6 @@ class DatabaseHelper {
); );
} }
/// Retrieves all submission log entries, optionally filtered by module.
Future<List<Map<String, dynamic>>?> loadSubmissionLogs({String? module}) async { Future<List<Map<String, dynamic>>?> loadSubmissionLogs({String? module}) async {
final db = await database; final db = await database;
List<Map<String, dynamic>> maps; List<Map<String, dynamic>> maps;
@ -1008,9 +1199,6 @@ class DatabaseHelper {
return null; return null;
} }
// START CHANGE: Added helper methods for the new preference tables.
/// Saves or updates a module's master submission preferences.
Future<void> saveModulePreference({ Future<void> saveModulePreference({
required String moduleName, required String moduleName,
required bool isApiEnabled, required bool isApiEnabled,
@ -1028,7 +1216,6 @@ class DatabaseHelper {
); );
} }
/// Retrieves a module's master submission preferences.
Future<Map<String, dynamic>?> getModulePreference(String moduleName) async { Future<Map<String, dynamic>?> getModulePreference(String moduleName) async {
final db = await database; final db = await database;
final result = await db.query( final result = await db.query(
@ -1044,16 +1231,13 @@ class DatabaseHelper {
'is_ftp_enabled': (row['is_ftp_enabled'] as int) == 1, 'is_ftp_enabled': (row['is_ftp_enabled'] as int) == 1,
}; };
} }
return null; // Return null if no specific preference is set return null;
} }
/// Saves the complete set of API links for a specific module, replacing any old ones.
Future<void> saveApiLinksForModule(String moduleName, List<Map<String, dynamic>> links) async { Future<void> saveApiLinksForModule(String moduleName, List<Map<String, dynamic>> links) async {
final db = await database; final db = await database;
await db.transaction((txn) async { await db.transaction((txn) async {
// First, delete all existing links for this module
await txn.delete(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); await txn.delete(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]);
// Then, insert all the new links
for (final link in links) { for (final link in links) {
await txn.insert(_moduleApiLinksTable, { await txn.insert(_moduleApiLinksTable, {
'module_name': moduleName, 'module_name': moduleName,
@ -1064,7 +1248,6 @@ class DatabaseHelper {
}); });
} }
/// Saves the complete set of FTP links for a specific module, replacing any old ones.
Future<void> saveFtpLinksForModule(String moduleName, List<Map<String, dynamic>> links) async { Future<void> saveFtpLinksForModule(String moduleName, List<Map<String, dynamic>> links) async {
final db = await database; final db = await database;
await db.transaction((txn) async { await db.transaction((txn) async {
@ -1079,7 +1262,6 @@ class DatabaseHelper {
}); });
} }
/// Retrieves all API links for a specific module, regardless of enabled status.
Future<List<Map<String, dynamic>>> getAllApiLinksForModule(String moduleName) async { Future<List<Map<String, dynamic>>> getAllApiLinksForModule(String moduleName) async {
final db = await database; final db = await database;
final result = await db.query(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); final result = await db.query(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]);
@ -1089,7 +1271,6 @@ class DatabaseHelper {
}).toList(); }).toList();
} }
/// Retrieves all FTP links for a specific module, regardless of enabled status.
Future<List<Map<String, dynamic>>> getAllFtpLinksForModule(String moduleName) async { Future<List<Map<String, dynamic>>> getAllFtpLinksForModule(String moduleName) async {
final db = await database; final db = await database;
final result = await db.query(_moduleFtpLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); final result = await db.query(_moduleFtpLinksTable, where: 'module_name = ?', whereArgs: [moduleName]);
@ -1098,6 +1279,4 @@ class DatabaseHelper {
'is_enabled': (row['is_enabled'] as int) == 1, 'is_enabled': (row['is_enabled'] as int) == 1,
}).toList(); }).toList();
} }
// END CHANGE
} }

View File

@ -1,7 +1,9 @@
// lib/services/base_api_service.dart // lib/services/base_api_service.dart
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io'; // Import for SocketException check
import 'dart:async'; // Import for TimeoutException check
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -34,9 +36,15 @@ class BaseApiService {
final response = await http.get(url, headers: await _getJsonHeaders()) final response = await http.get(url, headers: await _getJsonHeaders())
.timeout(const Duration(seconds: 60)); .timeout(const Duration(seconds: 60));
return _handleResponse(response); return _handleResponse(response);
} on SocketException catch (e) {
debugPrint('BaseApiService GET network error: $e');
rethrow; // Re-throw network-related exceptions to be caught by higher-level logic (e.g., login screen)
} on TimeoutException catch (e) {
debugPrint('BaseApiService GET timeout error: $e');
rethrow; // Re-throw timeout exceptions
} catch (e) { } catch (e) {
debugPrint('GET request to $baseUrl failed: $e'); debugPrint('GET request to $baseUrl failed with general error: $e');
return {'success': false, 'message': 'Network error or timeout: $e'}; return {'success': false, 'message': 'An unexpected error occurred: $e'};
} }
} }
@ -49,10 +57,16 @@ class BaseApiService {
url, url,
headers: await _getJsonHeaders(), headers: await _getJsonHeaders(),
body: jsonEncode(body), body: jsonEncode(body),
).timeout(const Duration(seconds: 60)); ).timeout(const Duration(seconds: 60)); // Note: login.dart applies its own shorter timeout over this.
return _handleResponse(response); return _handleResponse(response);
} on SocketException catch (e) {
debugPrint('BaseApiService POST network error: $e');
rethrow; // Re-throw network-related exceptions to be caught by higher-level logic (e.g., login screen)
} on TimeoutException catch (e) {
debugPrint('BaseApiService POST timeout error: $e');
rethrow; // Re-throw timeout exceptions
} catch (e) { } catch (e) {
debugPrint('POST to $baseUrl failed. Error: $e'); debugPrint('POST to $baseUrl failed with general error: $e');
return {'success': false, 'message': 'API connection failed: $e'}; return {'success': false, 'message': 'API connection failed: $e'};
} }
} }
@ -92,6 +106,12 @@ class BaseApiService {
final responseBody = await streamedResponse.stream.bytesToString(); final responseBody = await streamedResponse.stream.bytesToString();
return _handleResponse(http.Response(responseBody, streamedResponse.statusCode)); return _handleResponse(http.Response(responseBody, streamedResponse.statusCode));
} on SocketException catch (e) {
debugPrint('BaseApiService Multipart network error: $e');
rethrow; // Re-throw network-related exceptions
} on TimeoutException catch (e) {
debugPrint('BaseApiService Multipart timeout error: $e');
rethrow; // Re-throw timeout exceptions
} catch (e, s) { } catch (e, s) {
debugPrint('Multipart upload to $baseUrl failed. Error: $e'); debugPrint('Multipart upload to $baseUrl failed. Error: $e');
debugPrint('Stack trace: $s'); debugPrint('Stack trace: $s');
@ -102,14 +122,26 @@ class BaseApiService {
Map<String, dynamic> _handleResponse(http.Response response) { Map<String, dynamic> _handleResponse(http.Response response) {
debugPrint('Handling response. Status: ${response.statusCode}, Body: ${response.body}'); debugPrint('Handling response. Status: ${response.statusCode}, Body: ${response.body}');
try { try {
// Try to parse the response body as JSON.
final Map<String, dynamic> responseData = jsonDecode(response.body); final Map<String, dynamic> responseData = jsonDecode(response.body);
if (response.statusCode >= 200 && response.statusCode < 300) { if (response.statusCode >= 200 && response.statusCode < 300) {
if (responseData['status'] == 'success' || responseData['success'] == true) { // Successful API call (2xx status code)
return {'success': true, 'data': responseData['data'], 'message': responseData['message']}; // Check application-level success flag if one exists in your API standard.
} else { // Assuming your API returns {'status': 'success', 'data': ...} or {'success': true, 'data': ...}
return {'success': false, 'message': responseData['message'] ?? 'An unknown API error occurred.'}; if (responseData.containsKey('success') && responseData['success'] == false) {
return {'success': false, 'message': responseData['message'] ?? 'API indicated failure.'};
} }
// If no explicit failure flag, or if success=true, return success.
// Adjust logic based on your API's specific response structure.
return {
'success': true,
'data': responseData['data'], // Assumes data is nested under 'data' key
'message': responseData['message'] ?? 'Success'
};
} else { } else {
// API returned an error code (4xx, 5xx).
// Return the error message provided by the server.
return {'success': false, 'message': responseData['message'] ?? 'Server error: ${response.statusCode}'}; return {'success': false, 'message': responseData['message'] ?? 'Server error: ${response.statusCode}'};
} }
} catch (e) { } catch (e) {

View File

@ -120,11 +120,6 @@ class LocalStorageService {
if (serializableData.containsKey(key) && serializableData[key] is File) { if (serializableData.containsKey(key) && serializableData[key] is File) {
final newPath = await copyImageToLocal(serializableData[key]); final newPath = await copyImageToLocal(serializableData[key]);
serializableData['${key}Path'] = newPath; // Creates 'imageFrontPath', etc. serializableData['${key}Path'] = newPath; // Creates 'imageFrontPath', etc.
// Note: DO NOT remove the original key here if it holds a File object.
// The copy is needed for local storage/DB logging, but the File object must stay
// on the key if other process calls `toMap()` again.
// However, based on the previous logic, we rely on the caller passing a map
// that separates File objects from paths for DB logging.
} }
} }
@ -142,8 +137,6 @@ class LocalStorageService {
serializableData['collectionData'] = collectionMap; serializableData['collectionData'] = collectionMap;
} }
// CRITICAL FIX: Ensure the JSON data only contains serializable (non-File) objects
// We must strip the File objects before encoding, as they have now been copied.
final Map<String, dynamic> finalData = Map.from(serializableData); final Map<String, dynamic> finalData = Map.from(serializableData);
// Recursive helper to remove File objects before JSON encoding // Recursive helper to remove File objects before JSON encoding
@ -154,12 +147,7 @@ class LocalStorageService {
}); });
} }
// Since the caller (_toMapForLocalSave) only passes a map with File objects on the image keys, cleanMap(finalData);
// and paths on the *Path keys*, simply removing the File keys and encoding is sufficient.
finalData.removeWhere((key, value) => value is File);
if (finalData.containsKey('collectionData') && finalData['collectionData'] is Map) {
cleanMap(finalData['collectionData'] as Map<String, dynamic>);
}
final jsonFile = File(p.join(eventDir.path, 'data.json')); final jsonFile = File(p.join(eventDir.path, 'data.json'));
@ -243,13 +231,25 @@ class LocalStorageService {
jsonData['serverConfigName'] = serverName; jsonData['serverConfigName'] = serverName;
jsonData['selectedStation'] = data.selectedStation; jsonData['selectedStation'] = data.selectedStation;
jsonData['selectedClassification'] = data.selectedClassification;
jsonData['secondSampler'] = data.secondSampler;
final imageFiles = data.toImageFiles(); final imageFiles = data.toImageFiles();
for (var entry in imageFiles.entries) { for (var entry in imageFiles.entries) {
final File? imageFile = entry.value; final File? imageFile = entry.value;
if (imageFile != null) { if (imageFile != null && imageFile.path.isNotEmpty) {
final String originalFileName = p.basename(imageFile.path); try {
final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName)); if (p.dirname(imageFile.path) == eventDir.path) {
jsonData[entry.key] = newFile.path; jsonData[entry.key] = imageFile.path;
} else {
final String originalFileName = p.basename(imageFile.path);
final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName));
jsonData[entry.key] = newFile.path;
}
} catch (e) {
debugPrint("Error processing image file ${imageFile.path}: $e");
jsonData[entry.key] = null;
}
} }
} }
@ -319,7 +319,6 @@ class LocalStorageService {
// Part 4: Marine In-Situ Specific Methods (LOGGING RESTORED) // Part 4: Marine In-Situ Specific Methods (LOGGING RESTORED)
// ======================================================================= // =======================================================================
// --- MODIFIED: Removed leading underscore to make the method public ---
Future<Directory?> getInSituBaseDir({required String serverName}) async { Future<Directory?> getInSituBaseDir({required String serverName}) async {
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName); final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
if (mmsv4Dir == null) return null; if (mmsv4Dir == null) return null;
@ -348,17 +347,31 @@ class LocalStorageService {
await eventDir.create(recursive: true); await eventDir.create(recursive: true);
} }
final Map<String, dynamic> jsonData = { ...data.toApiFormData(), 'submissionStatus': data.submissionStatus, 'submissionMessage': data.submissionMessage, 'reportId': data.reportId }; // --- START FIX: Explicitly include the final status and message ---
// This ensures the status calculated in the service layer is saved correctly.
final Map<String, dynamic> jsonData = data.toDbJson();
jsonData['submissionStatus'] = data.submissionStatus;
jsonData['submissionMessage'] = data.submissionMessage;
// --- END FIX ---
jsonData['serverConfigName'] = serverName; jsonData['serverConfigName'] = serverName;
jsonData['selectedStation'] = data.selectedStation;
final imageFiles = data.toApiImageFiles(); final imageFiles = data.toApiImageFiles();
for (var entry in imageFiles.entries) { for (var entry in imageFiles.entries) {
final File? imageFile = entry.value; final File? imageFile = entry.value;
if (imageFile != null) { if (imageFile != null && imageFile.path.isNotEmpty) {
final String originalFileName = p.basename(imageFile.path); try {
final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName)); if (p.dirname(imageFile.path) == eventDir.path) {
jsonData[entry.key] = newFile.path; jsonData[entry.key] = imageFile.path;
} else {
final String originalFileName = p.basename(imageFile.path);
final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName));
jsonData[entry.key] = newFile.path;
}
} catch (e) {
debugPrint("Error processing In-Situ image file ${imageFile.path}: $e");
jsonData[entry.key] = null;
}
} }
} }
@ -462,19 +475,26 @@ class LocalStorageService {
await eventDir.create(recursive: true); await eventDir.create(recursive: true);
} }
final Map<String, dynamic> jsonData = { ...data.toApiFormData(), 'submissionStatus': data.submissionStatus, 'submissionMessage': data.submissionMessage, 'reportId': data.reportId }; // --- START: MODIFIED TO USE toMap() FOR COMPLETE DATA SERIALIZATION ---
final Map<String, dynamic> jsonData = data.toMap();
jsonData['serverConfigName'] = serverName; jsonData['serverConfigName'] = serverName;
jsonData['selectedStation'] = data.selectedStation;
final imageFiles = data.toApiImageFiles(); final imageFiles = data.toApiImageFiles();
for (var entry in imageFiles.entries) { for (var entry in imageFiles.entries) {
final File? imageFile = entry.value; final File? imageFile = entry.value;
if (imageFile != null) { if (imageFile != null) {
final String originalFileName = p.basename(imageFile.path); final String originalFileName = p.basename(imageFile.path);
final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName)); if (p.dirname(imageFile.path) == eventDir.path) {
jsonData[entry.key] = newFile.path; // If file is already in the correct directory, just store the path
jsonData[entry.key] = imageFile.path;
} else {
// Otherwise, copy it to the permanent directory
final File newFile = await imageFile.copy(p.join(eventDir.path, originalFileName));
jsonData[entry.key] = newFile.path;
}
} }
} }
// --- END: MODIFIED TO USE toMap() FOR COMPLETE DATA SERIALIZATION ---
final jsonFile = File(p.join(eventDir.path, 'data.json')); final jsonFile = File(p.join(eventDir.path, 'data.json'));
await jsonFile.writeAsString(jsonEncode(jsonData)); await jsonFile.writeAsString(jsonEncode(jsonData));

View File

@ -2,18 +2,21 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
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';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as p;
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:usb_serial/usb_serial.dart'; import 'package:usb_serial/usb_serial.dart';
import 'dart:convert'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:provider/provider.dart';
import '../auth_provider.dart';
import 'location_service.dart'; import 'location_service.dart';
import '../models/in_situ_sampling_data.dart'; import '../models/in_situ_sampling_data.dart';
import '../bluetooth/bluetooth_manager.dart'; import '../bluetooth/bluetooth_manager.dart';
@ -25,6 +28,7 @@ import 'api_service.dart';
import 'submission_api_service.dart'; import 'submission_api_service.dart';
import 'submission_ftp_service.dart'; import 'submission_ftp_service.dart';
import 'telegram_service.dart'; import 'telegram_service.dart';
import 'retry_service.dart';
/// A dedicated service to handle all business logic for the Marine In-Situ Sampling feature. /// A dedicated service to handle all business logic for the Marine In-Situ Sampling feature.
@ -42,10 +46,9 @@ class MarineInSituSamplingService {
final LocalStorageService _localStorageService = LocalStorageService(); final LocalStorageService _localStorageService = LocalStorageService();
final ServerConfigService _serverConfigService = ServerConfigService(); final ServerConfigService _serverConfigService = ServerConfigService();
final DatabaseHelper _dbHelper = DatabaseHelper(); final DatabaseHelper _dbHelper = DatabaseHelper();
// MODIFIED: Declare the service, but do not initialize it here. final RetryService _retryService = RetryService();
final TelegramService _telegramService; final TelegramService _telegramService;
// ADDED: A constructor to accept the global TelegramService instance.
MarineInSituSamplingService(this._telegramService); MarineInSituSamplingService(this._telegramService);
static const platform = MethodChannel('com.example.environment_monitoring_app/usb'); static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
@ -83,7 +86,7 @@ class MarineInSituSamplingService {
final stationCode = data.selectedStation?['man_station_code'] ?? 'NA'; final stationCode = data.selectedStation?['man_station_code'] ?? 'NA';
final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-'); final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-');
final newFileName = "${stationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg"; final newFileName = "${stationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg";
final filePath = path.join(tempDir.path, newFileName); final filePath = p.join(tempDir.path, newFileName);
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
} }
@ -158,59 +161,206 @@ class MarineInSituSamplingService {
_serialManager.dispose(); _serialManager.dispose();
} }
// --- Data Submission ---
Future<Map<String, dynamic>> submitInSituSample({ Future<Map<String, dynamic>> submitInSituSample({
required InSituSamplingData data, required InSituSamplingData data,
required List<Map<String, dynamic>>? appSettings, required List<Map<String, dynamic>>? appSettings,
required AuthProvider authProvider,
BuildContext? context,
String? logDirectory,
}) async { }) async {
const String moduleName = 'marine_in_situ'; const String moduleName = 'marine_in_situ';
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
final connectivityResult = await Connectivity().checkConnectivity();
bool isOnline = connectivityResult != ConnectivityResult.none;
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
if (isOnline && isOfflineSession) {
debugPrint("In-Situ submission online during offline session. Attempting auto-relogin...");
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
if (transitionSuccess) {
isOfflineSession = false;
} else {
isOnline = false;
}
}
if (isOnline && !isOfflineSession) {
debugPrint("Proceeding with direct ONLINE In-Situ submission...");
return await _performOnlineSubmission(
data: data,
appSettings: appSettings,
moduleName: moduleName,
authProvider: authProvider,
logDirectory: logDirectory,
);
} else {
debugPrint("Proceeding with OFFLINE In-Situ queuing mechanism...");
return await _performOfflineQueuing(
data: data,
moduleName: moduleName,
);
}
}
Future<Map<String, dynamic>> _performOnlineSubmission({
required InSituSamplingData 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(); final imageFilesWithNulls = data.toApiImageFiles();
imageFilesWithNulls.removeWhere((key, value) => value == null); imageFilesWithNulls.removeWhere((key, value) => value == null);
final Map<String, File> finalImageFiles = imageFilesWithNulls.cast<String, File>(); final Map<String, File> finalImageFiles = imageFilesWithNulls.cast<String, File>();
// START CHANGE: Implement the correct two-step submission process. bool anyApiSuccess = false;
// Step 1A: Submit form data as JSON. Map<String, dynamic> apiDataResult = {};
debugPrint("Step 1A: Submitting In-Situ form data..."); Map<String, dynamic> apiImageResult = {};
final apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName, try {
endpoint: 'marine/manual/sample', apiDataResult = await _submissionApiService.submitPost(
body: data.toApiFormData(), moduleName: moduleName,
endpoint: 'marine/manual/sample',
body: data.toApiFormData(),
);
if (apiDataResult['success'] == false &&
(apiDataResult['message'] as String?)?.contains('Unauthorized') == true) {
debugPrint("API submission failed with Unauthorized. Attempting silent relogin...");
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
if (reloginSuccess) {
debugPrint("Silent relogin successful. Retrying data submission...");
apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/manual/sample',
body: data.toApiFormData(),
);
}
}
if (apiDataResult['success'] == true) {
anyApiSuccess = true;
data.reportId = apiDataResult['data']?['man_id']?.toString();
if (data.reportId != null) {
if (finalImageFiles.isNotEmpty) {
apiImageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName,
endpoint: 'marine/manual/images',
fields: {'man_id': data.reportId!},
files: finalImageFiles,
);
if (apiImageResult['success'] != true) {
anyApiSuccess = false;
}
}
} else {
anyApiSuccess = false;
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
}
}
} on SocketException catch (e) {
final errorMessage = "API submission failed with network error: $e";
debugPrint(errorMessage);
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': errorMessage};
await _retryService.addApiToQueue(endpoint: 'marine/manual/sample', method: 'POST', body: data.toApiFormData());
if (finalImageFiles.isNotEmpty && data.reportId != null) {
await _retryService.addApiToQueue(endpoint: 'marine/manual/images', method: 'POST_MULTIPART', fields: {'man_id': data.reportId!}, files: finalImageFiles);
}
} on TimeoutException catch (e) {
final errorMessage = "API submission timed out: $e";
debugPrint(errorMessage);
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': errorMessage};
await _retryService.addApiToQueue(endpoint: 'marine/manual/sample', method: 'POST', body: data.toApiFormData());
}
Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false;
try {
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
} on SocketException catch (e) {
debugPrint("FTP submission failed with network error: $e");
anyFtpSuccess = false;
} on TimeoutException catch (e) {
debugPrint("FTP submission timed out: $e");
anyFtpSuccess = false;
}
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
String finalMessage;
String finalStatus;
if (anyApiSuccess && anyFtpSuccess) {
finalMessage = 'Data submitted successfully to all destinations.';
finalStatus = 'S4';
} else if (anyApiSuccess && !anyFtpSuccess) {
finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.';
finalStatus = 'S3';
} else if (!anyApiSuccess && anyFtpSuccess) {
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
finalStatus = 'L4';
} else {
finalMessage = 'All submission attempts failed and have been queued for retry.';
finalStatus = 'L1';
}
await _logAndSave(
data: data,
status: finalStatus,
message: finalMessage,
apiResults: [apiDataResult, apiImageResult],
ftpStatuses: ftpResults['statuses'],
serverName: serverName,
finalImageFiles: finalImageFiles,
logDirectory: logDirectory,
); );
// If the initial data submission fails, log and exit early. if (overallSuccess) {
if (apiDataResult['success'] != true) { _handleInSituSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty);
data.submissionStatus = 'L1';
data.submissionMessage = apiDataResult['message'] ?? 'Failed to submit form data.';
await _logAndSave(data: data, apiResults: [apiDataResult], ftpStatuses: [], serverName: serverName, finalImageFiles: finalImageFiles);
return {'success': false, 'message': data.submissionMessage};
} }
final reportId = apiDataResult['data']?['man_id']?.toString(); return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId};
if (reportId == null) { }
data.submissionStatus = 'L1';
data.submissionMessage = 'API Error: Missing man_id in response.';
await _logAndSave(data: data, apiResults: [apiDataResult], ftpStatuses: [], serverName: serverName, finalImageFiles: finalImageFiles);
return {'success': false, 'message': data.submissionMessage};
}
data.reportId = reportId;
// Step 1B: Submit images as multipart/form-data. Future<Map<String, dynamic>> _performOfflineQueuing({
debugPrint("Step 1B: Submitting In-Situ images..."); required InSituSamplingData data,
Map<String, dynamic> apiImageResult = {'success': true, 'message': 'No images to upload.'}; required String moduleName,
if (finalImageFiles.isNotEmpty) { }) async {
apiImageResult = await _submissionApiService.submitMultipart( final serverConfig = await _serverConfigService.getActiveApiConfig();
moduleName: moduleName, final serverName = serverConfig?['config_name'] as String? ?? 'Default';
endpoint: 'marine/manual/images', // Assumed endpoint for uploadManualImages
fields: {'man_id': reportId},
files: finalImageFiles,
);
}
final bool apiSuccess = apiImageResult['success'] == true;
// END CHANGE
// Step 2: FTP Submission // Set initial status before first save
data.submissionStatus = 'L1';
data.submissionMessage = 'Submission queued due to being offline.';
final String? localLogPath = await _localStorageService.saveInSituSamplingData(data, serverName: serverName);
if (localLogPath == null) {
const message = "Failed to save submission to local device storage.";
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {});
return {'success': false, 'message': message};
}
await _retryService.queueTask(
type: 'insitu_submission',
payload: {
'module': moduleName,
'localLogPath': localLogPath,
'serverConfig': serverConfig,
},
);
// No need to save again, initial save already has the L1 status.
const successMessage = "No internet connection. Submission has been saved and queued for upload.";
return {'success': true, 'message': successMessage};
}
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(InSituSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
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'; final baseFileName = '${stationCode}_$fileTimestamp';
@ -220,77 +370,86 @@ class MarineInSituSamplingService {
module: 'marine', module: 'marine',
subModule: 'marine_in_situ_sampling', subModule: 'marine_in_situ_sampling',
); );
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null;
final Directory? localSubmissionDir = logDirectory != null ? Directory(path.join(logDirectory.path, data.reportId ?? baseFileName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) { if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
await localSubmissionDir.create(recursive: true); await localSubmissionDir.create(recursive: true);
} }
final dataZip = await _zippingService.createDataZip( final dataZip = await _zippingService.createDataZip(
jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, jsonDataMap: {'db.json': jsonEncode(data.toDbJson())},
baseFileName: baseFileName, baseFileName: baseFileName,
destinationDir: localSubmissionDir); destinationDir: localSubmissionDir,
);
Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []}; Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []};
if (dataZip != null) { if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit( ftpDataResult = await _submissionFtpService.submit(
moduleName: moduleName, moduleName: moduleName,
fileToUpload: dataZip, fileToUpload: dataZip,
remotePath: '/${path.basename(dataZip.path)}'); remotePath: '/${p.basename(dataZip.path)}',
);
} }
final imageZip = await _zippingService.createImageZip( final imageZip = await _zippingService.createImageZip(
imageFiles: finalImageFiles.values.toList(), imageFiles: imageFiles.values.toList(),
baseFileName: baseFileName, baseFileName: baseFileName,
destinationDir: localSubmissionDir); destinationDir: localSubmissionDir,
);
Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []}; Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []};
if (imageZip != null) { if (imageZip != null) {
ftpImageResult = await _submissionFtpService.submit( ftpImageResult = await _submissionFtpService.submit(
moduleName: moduleName, moduleName: moduleName,
fileToUpload: imageZip, fileToUpload: imageZip,
remotePath: '/${path.basename(imageZip.path)}'); remotePath: '/${p.basename(imageZip.path)}',
} );
final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true);
// Step 3: Finalize and Log
String finalStatus;
String finalMessage;
if (apiSuccess) {
finalStatus = ftpSuccess ? 'S4' : 'S3';
finalMessage = ftpSuccess ? 'Data submitted successfully.' : 'Data sent to API. FTP upload failed/queued.';
} else {
finalStatus = ftpSuccess ? 'L4' : 'L1';
finalMessage = ftpSuccess ? 'API failed, but files sent to FTP.' : 'All submission attempts failed.';
} }
data.submissionStatus = finalStatus; return {
data.submissionMessage = finalMessage; 'statuses': <Map<String, dynamic>>[
...(ftpDataResult['statuses'] as List),
await _logAndSave( ...(ftpImageResult['statuses'] as List),
data: data, ],
apiResults: [apiDataResult, apiImageResult], // Log both API steps };
ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']],
serverName: serverName,
finalImageFiles: finalImageFiles,
);
if (apiSuccess || ftpSuccess) {
_handleInSituSuccessAlert(data, appSettings, isDataOnly: !apiSuccess);
}
return {'success': apiSuccess || ftpSuccess, 'message': finalMessage};
} }
// Helper function to centralize logging and local saving.
Future<void> _logAndSave({ Future<void> _logAndSave({
required InSituSamplingData data, required InSituSamplingData data,
required String status,
required String message,
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,
String? logDirectory,
}) async { }) async {
data.submissionStatus = status;
data.submissionMessage = message;
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_'); final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
await _localStorageService.saveInSituSamplingData(data, serverName: serverName); if (logDirectory != null) {
final Map<String, dynamic> updatedLogData = data.toDbJson();
// --- START FIX: Explicitly add final status and message to the update map ---
updatedLogData['submissionStatus'] = status;
updatedLogData['submissionMessage'] = message;
// --- END FIX ---
updatedLogData['logDirectory'] = logDirectory;
updatedLogData['serverConfigName'] = serverName;
updatedLogData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
updatedLogData['ftp_status'] = jsonEncode(ftpStatuses);
final imageFilePaths = data.toApiImageFiles();
imageFilePaths.forEach((key, file) {
if (file != null) {
updatedLogData[key] = file.path;
}
});
await _localStorageService.updateInSituLog(updatedLogData);
} else {
await _localStorageService.saveInSituSamplingData(data, serverName: serverName);
}
final logData = { final logData = {
'submission_id': data.reportId ?? fileTimestamp, 'submission_id': data.reportId ?? fileTimestamp,

View File

@ -2,8 +2,12 @@
import 'dart:io'; import 'dart:io';
import 'dart:convert'; import 'dart:convert';
import 'dart:async'; // Added for TimeoutException
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:provider/provider.dart';
import 'package:flutter/material.dart';
import 'package:environment_monitoring_app/models/tarball_data.dart'; import 'package:environment_monitoring_app/models/tarball_data.dart';
import 'package:environment_monitoring_app/services/local_storage_service.dart'; import 'package:environment_monitoring_app/services/local_storage_service.dart';
@ -13,6 +17,8 @@ import 'package:environment_monitoring_app/services/api_service.dart';
import 'package:environment_monitoring_app/services/submission_api_service.dart'; import 'package:environment_monitoring_app/services/submission_api_service.dart';
import 'package:environment_monitoring_app/services/submission_ftp_service.dart'; import 'package:environment_monitoring_app/services/submission_ftp_service.dart';
import 'package:environment_monitoring_app/services/telegram_service.dart'; import 'package:environment_monitoring_app/services/telegram_service.dart';
import 'package:environment_monitoring_app/services/retry_service.dart';
import 'package:environment_monitoring_app/auth_provider.dart';
/// A dedicated service to handle all business logic for the Marine Tarball Sampling feature. /// A dedicated service to handle all business logic for the Marine Tarball Sampling feature.
class MarineTarballSamplingService { class MarineTarballSamplingService {
@ -22,57 +28,210 @@ class MarineTarballSamplingService {
final LocalStorageService _localStorageService = LocalStorageService(); final LocalStorageService _localStorageService = LocalStorageService();
final ServerConfigService _serverConfigService = ServerConfigService(); final ServerConfigService _serverConfigService = ServerConfigService();
final DatabaseHelper _dbHelper = DatabaseHelper(); final DatabaseHelper _dbHelper = DatabaseHelper();
// MODIFIED: Declare the service, but do not initialize it here. final RetryService _retryService = RetryService();
final TelegramService _telegramService; final TelegramService _telegramService;
// ADDED: A constructor to accept the global TelegramService instance.
MarineTarballSamplingService(this._telegramService); MarineTarballSamplingService(this._telegramService);
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,
}) async { }) async {
const String moduleName = 'marine_tarball'; const String moduleName = 'marine_tarball';
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final connectivityResult = await Connectivity().checkConnectivity();
bool isOnline = connectivityResult != ConnectivityResult.none;
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
if (isOnline && isOfflineSession) {
debugPrint("Submission initiated online during an offline session. Attempting auto-relogin...");
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
if (transitionSuccess) {
isOfflineSession = false;
} else {
isOnline = false;
}
}
if (isOnline && !isOfflineSession) {
debugPrint("Proceeding with direct ONLINE submission...");
return await _performOnlineSubmission(
data: data,
appSettings: appSettings,
moduleName: moduleName,
authProvider: authProvider,
);
} else {
debugPrint("Proceeding with OFFLINE queuing mechanism...");
return await _performOfflineQueuing(
data: data,
moduleName: moduleName,
);
}
}
Future<Map<String, dynamic>> _performOnlineSubmission({
required TarballSamplingData data,
required List<Map<String, dynamic>>? appSettings,
required String moduleName,
required AuthProvider authProvider,
}) 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 finalImageFiles = imageFiles.cast<String, File>();
final imageFilesWithNulls = data.toImageFiles(); bool anyApiSuccess = false;
imageFilesWithNulls.removeWhere((key, value) => value == null); Map<String, dynamic> apiDataResult = {};
final Map<String, File> finalImageFiles = imageFilesWithNulls.cast<String, File>(); Map<String, dynamic> apiImageResult = {};
// START CHANGE: Revert to the correct two-step API submission process // --- START: MODIFICATION FOR GRANULAR ERROR HANDLING ---
// --- Step 1A: API Data Submission --- // Step 1: Attempt API Submission in its own try-catch block.
debugPrint("Step 1A: Submitting Tarball form data..."); try {
final apiDataResult = await _submissionApiService.submitPost( apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName, moduleName: moduleName,
endpoint: 'marine/tarball/sample', endpoint: 'marine/tarball/sample',
body: data.toFormData(), body: data.toFormData(),
); );
if (apiDataResult['success'] != true) { if (apiDataResult['success'] == false &&
// If the initial data submission fails, log and exit early. (apiDataResult['message'] as String?)?.contains('Unauthorized') == true) {
await _logAndSave(data: data, status: 'L1', message: apiDataResult['message']!, apiResults: [apiDataResult], ftpStatuses: [], serverName: serverName, finalImageFiles: finalImageFiles); debugPrint("API submission failed with Unauthorized. Attempting silent relogin...");
return {'success': false, 'message': apiDataResult['message']}; final bool reloginSuccess = await authProvider.attemptSilentRelogin();
if (reloginSuccess) {
debugPrint("Silent relogin successful. Retrying data submission...");
apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/tarball/sample',
body: data.toFormData(),
);
}
}
if (apiDataResult['success'] == true) {
anyApiSuccess = true;
data.reportId = apiDataResult['data']?['autoid']?.toString();
if (data.reportId != null) {
apiImageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName,
endpoint: 'marine/tarball/images',
fields: {'autoid': data.reportId!},
files: finalImageFiles,
);
if (apiImageResult['success'] != true) {
anyApiSuccess = false; // Downgrade success if images fail
}
} else {
anyApiSuccess = false;
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
}
}
} on SocketException catch (e) {
final errorMessage = "API submission failed with network error: $e";
debugPrint(errorMessage);
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': errorMessage};
// Manually queue the failed API tasks since the service might not have been able to
await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData());
if(finalImageFiles.isNotEmpty && data.reportId != null) {
await _retryService.addApiToQueue(endpoint: 'marine/tarball/images', method: 'POST_MULTIPART', fields: {'autoid': data.reportId!}, files: finalImageFiles);
}
} on TimeoutException catch (e) {
final errorMessage = "API submission timed out: $e";
debugPrint(errorMessage);
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': errorMessage};
await _retryService.addApiToQueue(endpoint: 'marine/tarball/sample', method: 'POST', body: data.toFormData());
} }
final recordId = apiDataResult['data']?['autoid']?.toString(); // Step 2: Attempt FTP Submission in its own try-catch block.
if (recordId == null) { // This code will now run even if the API submission above failed.
await _logAndSave(data: data, status: 'L1', message: 'API Error: Missing record ID.', apiResults: [apiDataResult], ftpStatuses: [], serverName: serverName, finalImageFiles: finalImageFiles); Map<String, dynamic> ftpResults = {'statuses': []};
return {'success': false, 'message': 'API Error: Missing record ID.'}; bool anyFtpSuccess = false;
try {
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
anyFtpSuccess = !ftpResults['statuses'].any((status) => status['success'] == false);
} on SocketException catch (e) {
debugPrint("FTP submission failed with network error: $e");
anyFtpSuccess = false;
// Note: The underlying SubmissionFtpService already queues failed uploads,
// so we just need to catch the error to prevent a crash.
} on TimeoutException catch (e) {
debugPrint("FTP submission timed out: $e");
anyFtpSuccess = false;
} }
data.reportId = recordId; // --- END: MODIFICATION FOR GRANULAR ERROR HANDLING ---
// --- Step 1B: API Image Submission --- // Step 3: Determine final status based on the outcomes of the independent steps.
debugPrint("Step 1B: Submitting Tarball images..."); final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
final apiImageResult = await _submissionApiService.submitMultipart( String finalMessage;
moduleName: moduleName, String finalStatus;
endpoint: 'marine/tarball/images',
fields: {'autoid': recordId}, if (anyApiSuccess && anyFtpSuccess) {
files: finalImageFiles, finalMessage = 'Data submitted successfully to all destinations.';
finalStatus = 'S4';
} else if (anyApiSuccess && !anyFtpSuccess) {
finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.';
finalStatus = 'S3';
} else if (!anyApiSuccess && anyFtpSuccess) {
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
finalStatus = 'L4';
} else {
finalMessage = 'All submission attempts failed and have been queued for retry.';
finalStatus = 'L1';
}
await _logAndSave(
data: data,
status: finalStatus,
message: finalMessage,
apiResults: [apiDataResult, apiImageResult],
ftpStatuses: ftpResults['statuses'],
serverName: serverName,
finalImageFiles: finalImageFiles,
); );
final bool apiSuccess = apiImageResult['success'] == true;
// END CHANGE
// --- Step 2: FTP Submission --- if (overallSuccess) {
_handleTarballSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty);
}
return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId};
}
Future<Map<String, dynamic>> _performOfflineQueuing({
required TarballSamplingData data,
required String moduleName,
}) async {
final serverConfig = await _serverConfigService.getActiveApiConfig();
final serverName = serverConfig?['config_name'] as String? ?? 'Default';
final String? localLogPath = await _localStorageService.saveTarballSamplingData(data, serverName: serverName);
if (localLogPath == null) {
const message = "Failed to save submission to local device storage.";
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {});
return {'success': false, 'message': message};
}
await _retryService.queueTask(
type: 'tarball_submission',
payload: {
'module': moduleName,
'localLogPath': localLogPath,
'serverConfig': serverConfig,
},
);
const successMessage = "No internet connection. Submission has been saved and queued for upload.";
await _logAndSave(data: data, status: 'Queued', message: successMessage, apiResults: [], ftpStatuses: [], serverName: serverName, finalImageFiles: {});
return {'success': true, 'message': successMessage};
}
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(TarballSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
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'; final baseFileName = '${stationCode}_$fileTimestamp';
@ -82,7 +241,6 @@ class MarineTarballSamplingService {
module: 'marine', module: 'marine',
subModule: 'marine_tarball_sampling', subModule: 'marine_tarball_sampling',
); );
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null; final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) { if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
await localSubmissionDir.create(recursive: true); await localSubmissionDir.create(recursive: true);
@ -93,7 +251,6 @@ class MarineTarballSamplingService {
baseFileName: baseFileName, baseFileName: baseFileName,
destinationDir: localSubmissionDir, destinationDir: localSubmissionDir,
); );
Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []}; Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []};
if (dataZip != null) { if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit( ftpDataResult = await _submissionFtpService.submit(
@ -104,11 +261,10 @@ class MarineTarballSamplingService {
} }
final imageZip = await _zippingService.createImageZip( final imageZip = await _zippingService.createImageZip(
imageFiles: finalImageFiles.values.toList(), imageFiles: imageFiles.values.toList(),
baseFileName: baseFileName, baseFileName: baseFileName,
destinationDir: localSubmissionDir, destinationDir: localSubmissionDir,
); );
Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []}; Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []};
if (imageZip != null) { if (imageZip != null) {
ftpImageResult = await _submissionFtpService.submit( ftpImageResult = await _submissionFtpService.submit(
@ -117,37 +273,15 @@ class MarineTarballSamplingService {
remotePath: '/${p.basename(imageZip.path)}', remotePath: '/${p.basename(imageZip.path)}',
); );
} }
final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true);
// --- Step 3: Finalize and Log --- return {
String finalStatus; 'statuses': <Map<String, dynamic>>[
String finalMessage; ...(ftpDataResult['statuses'] as List),
if (apiSuccess) { ...(ftpImageResult['statuses'] as List),
finalStatus = ftpSuccess ? 'S4' : 'S3'; ],
finalMessage = ftpSuccess ? 'Data submitted successfully.' : 'Data sent to API. FTP upload failed/queued.'; };
} else {
finalStatus = ftpSuccess ? 'L4' : 'L1';
finalMessage = ftpSuccess ? 'API failed, but files sent to FTP.' : 'All submission attempts failed.';
}
await _logAndSave(
data: data,
status: finalStatus,
message: finalMessage,
apiResults: [apiDataResult, apiImageResult], // Log results from both API steps
ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']],
serverName: serverName,
finalImageFiles: finalImageFiles
);
if (apiSuccess || ftpSuccess) {
_handleTarballSuccessAlert(data, appSettings, isDataOnly: !apiSuccess);
}
return {'success': apiSuccess || ftpSuccess, 'message': finalMessage, 'reportId': data.reportId};
} }
// Added a helper to reduce code duplication in the main submit method
Future<void> _logAndSave({ Future<void> _logAndSave({
required TarballSamplingData data, required TarballSamplingData data,
required String status, required String status,

View File

@ -3,42 +3,74 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart';
import 'package:environment_monitoring_app/services/marine_in_situ_sampling_service.dart';
import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.dart'; // ADDED
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart'; // ADDED
import 'package:environment_monitoring_app/services/api_service.dart'; import 'package:environment_monitoring_app/services/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';
// START CHANGE: Added imports to get server configurations
import 'package:environment_monitoring_app/services/server_config_service.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart';
// END CHANGE import 'package:environment_monitoring_app/auth_provider.dart';
/// A dedicated service to manage the queue of failed API and FTP requests /// A dedicated service to manage the queue of failed API, FTP, and complex submission tasks.
/// for manual resubmission.
class RetryService { class RetryService {
// Use singleton instances to avoid re-creating services unnecessarily.
final DatabaseHelper _dbHelper = DatabaseHelper(); final DatabaseHelper _dbHelper = DatabaseHelper();
final BaseApiService _baseApiService = BaseApiService(); final BaseApiService _baseApiService = BaseApiService();
final FtpService _ftpService = FtpService(); final FtpService _ftpService = FtpService();
// START CHANGE: Add instance of ServerConfigService
final ServerConfigService _serverConfigService = ServerConfigService(); final ServerConfigService _serverConfigService = ServerConfigService();
// END CHANGE
// --- START: MODIFICATION FOR HANDLING COMPLEX TASKS ---
// These services will be provided after the RetryService is created.
MarineInSituSamplingService? _marineInSituService;
RiverInSituSamplingService? _riverInSituService; // ADDED
AuthProvider? _authProvider;
// Call this method from your main app setup to provide the necessary services.
void initialize({
required MarineInSituSamplingService marineInSituService,
required RiverInSituSamplingService riverInSituService, // ADDED
required AuthProvider authProvider,
}) {
_marineInSituService = marineInSituService;
_riverInSituService = riverInSituService; // ADDED
_authProvider = authProvider;
}
// --- END: MODIFICATION FOR HANDLING COMPLEX TASKS ---
/// Adds a generic, complex task to the queue, to be handled by a background processor.
Future<void> queueTask({
required String type,
required Map<String, dynamic> payload,
}) async {
await _dbHelper.queueFailedRequest({
'type': type,
'endpoint_or_path': 'N/A',
'payload': jsonEncode(payload),
'timestamp': DateTime.now().toIso8601String(),
'status': 'pending',
});
debugPrint("Task of type '$type' has been queued for background processing.");
}
/// Adds a failed API request to the local database queue. /// Adds a failed API request to the local database queue.
Future<void> addApiToQueue({ Future<void> addApiToQueue({
required String endpoint, required String endpoint,
required String method, // e.g., 'POST' or 'POST_MULTIPART' required String method,
Map<String, dynamic>? body, Map<String, dynamic>? body,
Map<String, String>? fields, Map<String, String>? fields,
Map<String, File>? files, Map<String, File>? files,
}) async { }) async {
// We must convert File objects to their string paths before saving to JSON.
final serializableFiles = files?.map((key, value) => MapEntry(key, value.path)); final 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,
}; };
await _dbHelper.queueFailedRequest({ await _dbHelper.queueFailedRequest({
'type': 'api', 'type': 'api',
'endpoint_or_path': endpoint, 'endpoint_or_path': endpoint,
@ -83,11 +115,68 @@ class RetryService {
final payload = jsonDecode(task['payload'] as String); final payload = jsonDecode(task['payload'] as String);
try { try {
if (task['type'] == 'api') { if (_authProvider == null) {
debugPrint("RetryService has not been initialized. Cannot process task.");
return false;
}
if (task['type'] == 'insitu_submission') {
debugPrint("Retrying complex task 'insitu_submission' with ID $taskId.");
if (_marineInSituService == null) return false;
final String logFilePath = payload['localLogPath'];
final file = File(logFilePath);
if (!await file.exists()) {
debugPrint("Retry failed: Source log file no longer exists at $logFilePath");
await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task
return false;
}
final content = await file.readAsString();
final jsonData = jsonDecode(content) as Map<String, dynamic>;
final InSituSamplingData dataToResubmit = InSituSamplingData.fromJson(jsonData);
final String logDirectoryPath = p.dirname(logFilePath);
final result = await _marineInSituService!.submitInSituSample(
data: dataToResubmit,
appSettings: _authProvider!.appSettings,
authProvider: _authProvider!,
logDirectory: logDirectoryPath,
);
success = result['success'];
// --- START: ADDED LOGIC FOR RIVER IN-SITU SUBMISSION ---
} else if (task['type'] == 'river_insitu_submission') {
debugPrint("Retrying complex task 'river_insitu_submission' with ID $taskId.");
if (_riverInSituService == null) return false;
final String logFilePath = payload['localLogPath'];
final file = File(logFilePath);
if (!await file.exists()) {
debugPrint("Retry failed: Source log file no longer exists at $logFilePath");
await _dbHelper.deleteRequestFromQueue(taskId); // Remove invalid task
return false;
}
final content = await file.readAsString();
final jsonData = jsonDecode(content) as Map<String, dynamic>;
final RiverInSituSamplingData dataToResubmit = RiverInSituSamplingData.fromJson(jsonData);
final String logDirectoryPath = p.dirname(logFilePath);
final result = await _riverInSituService!.submitData(
data: dataToResubmit,
appSettings: _authProvider!.appSettings,
authProvider: _authProvider!,
logDirectory: logDirectoryPath,
);
success = result['success'];
// --- END: ADDED LOGIC FOR RIVER IN-SITU SUBMISSION ---
} else if (task['type'] == 'api') {
final endpoint = task['endpoint_or_path'] as String; final endpoint = task['endpoint_or_path'] as String;
final method = payload['method'] as String; final method = payload['method'] as String;
// START CHANGE: Fetch the current active base URL to perform the retry
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
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;
@ -96,42 +185,31 @@ class RetryService {
final Map<String, String> fields = Map<String, String>.from(payload['fields'] ?? {}); final Map<String, String> fields = Map<String, String>.from(payload['fields'] ?? {});
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))) ?? {};
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 { // Assume 'POST' } else {
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);
} }
// END CHANGE
success = result['success']; success = result['success'];
} else if (task['type'] == 'ftp') { } else if (task['type'] == '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"); debugPrint("Retrying FTP task $taskId: Uploading ${localFile.path} to $remotePath");
if (await localFile.exists()) { if (await localFile.exists()) {
// START CHANGE: On retry, attempt to upload to ALL available FTP servers.
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? []; final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
if (ftpConfigs.isEmpty) { if (ftpConfigs.isEmpty) {
debugPrint("Retry failed for FTP task $taskId: No FTP configurations found."); debugPrint("Retry failed for FTP task $taskId: No FTP configurations found.");
return false; return false;
} }
for (final config in ftpConfigs) { for (final config in ftpConfigs) {
final result = await _ftpService.uploadFile( final result = await _ftpService.uploadFile(config: config, fileToUpload: localFile, remotePath: remotePath);
config: config,
fileToUpload: localFile,
remotePath: remotePath
);
if (result['success']) { if (result['success']) {
success = true; success = true;
break; // Stop on the first successful upload break;
} }
} }
// END CHANGE
} else { } else {
debugPrint("Retry failed for FTP task $taskId: Source file no longer exists at ${localFile.path}"); debugPrint("Retry failed for FTP task $taskId: Source file no longer exists at ${localFile.path}");
success = false; success = false;

View File

@ -6,7 +6,7 @@ import 'package:flutter/material.dart';
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';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as p;
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
@ -14,7 +14,10 @@ import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:usb_serial/usb_serial.dart'; import '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:provider/provider.dart';
import '../auth_provider.dart';
import 'location_service.dart'; import 'location_service.dart';
import '../models/river_in_situ_sampling_data.dart'; import '../models/river_in_situ_sampling_data.dart';
import '../bluetooth/bluetooth_manager.dart'; import '../bluetooth/bluetooth_manager.dart';
@ -26,6 +29,7 @@ import 'zipping_service.dart';
import 'submission_api_service.dart'; import 'submission_api_service.dart';
import 'submission_ftp_service.dart'; import 'submission_ftp_service.dart';
import 'telegram_service.dart'; import 'telegram_service.dart';
import 'retry_service.dart';
class RiverInSituSamplingService { class RiverInSituSamplingService {
@ -38,6 +42,7 @@ class RiverInSituSamplingService {
final LocalStorageService _localStorageService = LocalStorageService(); final LocalStorageService _localStorageService = LocalStorageService();
final ServerConfigService _serverConfigService = ServerConfigService(); final ServerConfigService _serverConfigService = ServerConfigService();
final ZippingService _zippingService = ZippingService(); final ZippingService _zippingService = ZippingService();
final RetryService _retryService = RetryService();
final TelegramService _telegramService; final TelegramService _telegramService;
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
@ -81,7 +86,7 @@ class RiverInSituSamplingService {
final finalStationCode = stationCode ?? 'NA'; final finalStationCode = stationCode ?? 'NA';
final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-'); final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-');
final newFileName = "${finalStationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg"; final newFileName = "${finalStationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg";
final filePath = path.join(tempDir.path, newFileName); final filePath = p.join(tempDir.path, newFileName);
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
@ -154,44 +159,207 @@ class RiverInSituSamplingService {
_serialManager.dispose(); _serialManager.dispose();
} }
Future<Map<String, dynamic>> submitData(RiverInSituSamplingData data, List<Map<String, dynamic>>? appSettings) async { Future<Map<String, dynamic>> submitData({
required RiverInSituSamplingData data,
required List<Map<String, dynamic>>? appSettings,
required AuthProvider authProvider,
String? logDirectory,
}) async {
const String moduleName = 'river_in_situ'; const String moduleName = 'river_in_situ';
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
final connectivityResult = await Connectivity().checkConnectivity();
bool isOnline = connectivityResult != ConnectivityResult.none;
bool isOfflineSession = authProvider.isLoggedIn && (authProvider.profileData?['token']?.startsWith("offline-session-") ?? false);
if (isOnline && isOfflineSession) {
debugPrint("River In-Situ submission online during offline session. Attempting auto-relogin...");
final bool transitionSuccess = await authProvider.checkAndTransitionToOnlineSession();
if (transitionSuccess) {
isOfflineSession = false;
} else {
isOnline = false;
}
}
if (isOnline && !isOfflineSession) {
debugPrint("Proceeding with direct ONLINE River In-Situ submission...");
return await _performOnlineSubmission(
data: data,
appSettings: appSettings,
moduleName: moduleName,
authProvider: authProvider,
logDirectory: logDirectory,
);
} else {
debugPrint("Proceeding with OFFLINE River In-Situ queuing mechanism...");
return await _performOfflineQueuing(
data: data,
moduleName: moduleName,
);
}
}
Future<Map<String, dynamic>> _performOnlineSubmission({
required RiverInSituSamplingData 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(); final imageFilesWithNulls = data.toApiImageFiles();
imageFilesWithNulls.removeWhere((key, value) => value == null); imageFilesWithNulls.removeWhere((key, value) => value == null);
final Map<String, File> finalImageFiles = imageFilesWithNulls.cast<String, File>(); final Map<String, File> finalImageFiles = imageFilesWithNulls.cast<String, File>();
final dataResult = await _submissionApiService.submitPost( bool anyApiSuccess = false;
moduleName: moduleName, Map<String, dynamic> apiDataResult = {};
endpoint: 'river/manual/sample', Map<String, dynamic> apiImageResult = {};
body: data.toApiFormData(),
try {
apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'river/manual/sample',
body: data.toApiFormData(),
);
if (apiDataResult['success'] == false &&
(apiDataResult['message'] as String?)?.contains('Unauthorized') == true) {
debugPrint("API submission failed with Unauthorized. Attempting silent relogin...");
final bool reloginSuccess = await authProvider.attemptSilentRelogin();
if (reloginSuccess) {
debugPrint("Silent relogin successful. Retrying data submission...");
apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'river/manual/sample',
body: data.toApiFormData(),
);
}
}
if (apiDataResult['success'] == true) {
anyApiSuccess = true;
data.reportId = apiDataResult['data']?['r_man_id']?.toString();
if (data.reportId != null) {
if (finalImageFiles.isNotEmpty) {
apiImageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName,
endpoint: 'river/manual/images',
fields: {'r_man_id': data.reportId!},
files: finalImageFiles,
);
if (apiImageResult['success'] != true) {
anyApiSuccess = false;
}
}
} else {
anyApiSuccess = false;
apiDataResult['message'] = 'API Error: Submission succeeded but did not return a record ID.';
}
}
} on SocketException catch (e) {
final errorMessage = "API submission failed with network error: $e";
debugPrint(errorMessage);
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': errorMessage};
await _retryService.addApiToQueue(endpoint: 'river/manual/sample', method: 'POST', body: data.toApiFormData());
if (finalImageFiles.isNotEmpty && data.reportId != null) {
await _retryService.addApiToQueue(endpoint: 'river/manual/images', method: 'POST_MULTIPART', fields: {'r_man_id': data.reportId!}, files: finalImageFiles);
}
} on TimeoutException catch (e) {
final errorMessage = "API submission timed out: $e";
debugPrint(errorMessage);
anyApiSuccess = false;
apiDataResult = {'success': false, 'message': errorMessage};
await _retryService.addApiToQueue(endpoint: 'river/manual/sample', method: 'POST', body: data.toApiFormData());
}
Map<String, dynamic> ftpResults = {'statuses': []};
bool anyFtpSuccess = false;
try {
ftpResults = await _generateAndUploadFtpFiles(data, finalImageFiles, serverName, moduleName);
anyFtpSuccess = !(ftpResults['statuses'] as List).any((status) => status['success'] == false && status['status'] != 'Not Configured');
} on SocketException catch (e) {
debugPrint("FTP submission failed with network error: $e");
anyFtpSuccess = false;
} on TimeoutException catch (e) {
debugPrint("FTP submission timed out: $e");
anyFtpSuccess = false;
}
final bool overallSuccess = anyApiSuccess || anyFtpSuccess;
String finalMessage;
String finalStatus;
if (anyApiSuccess && anyFtpSuccess) {
finalMessage = 'Data submitted successfully to all destinations.';
finalStatus = 'S4';
} else if (anyApiSuccess && !anyFtpSuccess) {
finalMessage = 'Data sent to API, but some FTP uploads failed and were queued.';
finalStatus = 'S3';
} else if (!anyApiSuccess && anyFtpSuccess) {
finalMessage = 'API submission failed and was queued, but files were sent to FTP successfully.';
finalStatus = 'L4';
} else {
finalMessage = 'All submission attempts failed and have been queued for retry.';
finalStatus = 'L1';
}
await _logAndSave(
data: data,
status: finalStatus,
message: finalMessage,
apiResults: [apiDataResult, apiImageResult],
ftpStatuses: ftpResults['statuses'],
serverName: serverName,
logDirectory: logDirectory,
); );
if (dataResult['success'] != true) { if (overallSuccess) {
await _logAndSave(data: data, status: 'L1', message: dataResult['message']!, apiResults: [dataResult], ftpStatuses: [], serverName: serverName); _handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty);
// FIX: Also return the status code for the UI
return {'status': 'L1', 'success': false, 'message': dataResult['message']};
} }
final recordId = dataResult['data']?['r_man_id']?.toString(); return {
if (recordId == null) { 'status': finalStatus,
await _logAndSave(data: data, status: 'L1', message: 'API Error: Missing record ID.', apiResults: [dataResult], ftpStatuses: [], serverName: serverName); 'success': overallSuccess,
return {'status': 'L1', 'success': false, 'message': 'API Error: Missing record ID.'}; 'message': finalMessage,
} 'reportId': data.reportId
data.reportId = recordId; };
}
Map<String, dynamic> imageResult = {'success': true, 'message': 'No images to upload.'}; Future<Map<String, dynamic>> _performOfflineQueuing({
if (finalImageFiles.isNotEmpty) { required RiverInSituSamplingData data,
imageResult = await _submissionApiService.submitMultipart( required String moduleName,
moduleName: moduleName, }) async {
endpoint: 'river/manual/images', final serverConfig = await _serverConfigService.getActiveApiConfig();
fields: {'r_man_id': recordId}, final serverName = serverConfig?['config_name'] as String? ?? 'Default';
files: finalImageFiles,
);
}
final bool apiSuccess = imageResult['success'] == true;
data.submissionStatus = 'L1';
data.submissionMessage = 'Submission queued due to being offline.';
final String? localLogPath = await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName);
if (localLogPath == null) {
const message = "Failed to save submission to local device storage.";
await _logAndSave(data: data, status: 'Error', message: message, apiResults: [], ftpStatuses: [], serverName: serverName);
return {'status': 'Error', 'success': false, 'message': message};
}
await _retryService.queueTask(
type: 'river_insitu_submission',
payload: {
'module': moduleName,
'localLogPath': p.join(localLogPath, 'data.json'),
'serverConfig': serverConfig,
},
);
const successMessage = "No internet connection. Submission has been saved and queued for upload.";
return {'status': 'Queued', 'success': true, 'message': successMessage};
}
Future<Map<String, dynamic>> _generateAndUploadFtpFiles(RiverInSituSamplingData data, Map<String, File> imageFiles, String serverName, String moduleName) async {
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"; final baseFileName = "${stationCode}_$fileTimestamp";
@ -202,7 +370,7 @@ class RiverInSituSamplingService {
subModule: 'river_in_situ_sampling', subModule: 'river_in_situ_sampling',
); );
final Directory? localSubmissionDir = logDirectory != null ? Directory(path.join(logDirectory.path, data.reportId ?? baseFileName)) : null; final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) { if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
await localSubmissionDir.create(recursive: true); await localSubmissionDir.create(recursive: true);
} }
@ -215,50 +383,25 @@ class RiverInSituSamplingService {
Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []}; Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []};
if (dataZip != null) { if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit( ftpDataResult = await _submissionFtpService.submit(
moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${path.basename(dataZip.path)}'); moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${p.basename(dataZip.path)}');
} }
final imageZip = await _zippingService.createImageZip( final imageZip = await _zippingService.createImageZip(
imageFiles: finalImageFiles.values.toList(), imageFiles: imageFiles.values.toList(),
baseFileName: baseFileName, baseFileName: baseFileName,
destinationDir: localSubmissionDir, destinationDir: localSubmissionDir,
); );
Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []}; Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []};
if (imageZip != null) { if (imageZip != null) {
ftpImageResult = await _submissionFtpService.submit( ftpImageResult = await _submissionFtpService.submit(
moduleName: moduleName, fileToUpload: imageZip, remotePath: '/${path.basename(imageZip.path)}'); moduleName: moduleName, fileToUpload: imageZip, remotePath: '/${p.basename(imageZip.path)}');
}
final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true);
String finalStatus;
String finalMessage;
if (apiSuccess) {
finalStatus = ftpSuccess ? 'S4' : 'S3';
finalMessage = ftpSuccess ? 'Data submitted successfully.' : 'Data sent to API. FTP upload failed/queued.';
} else {
finalStatus = ftpSuccess ? 'L4' : 'L1';
finalMessage = ftpSuccess ? 'API failed, but files sent to FTP.' : 'All submission attempts failed.';
} }
await _logAndSave(
data: data,
status: finalStatus,
message: finalMessage,
apiResults: [dataResult, imageResult],
ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']],
serverName: serverName
);
if (apiSuccess || ftpSuccess) {
_handleSuccessAlert(data, appSettings, isDataOnly: !apiSuccess);
}
// FIX: Add the 'status' and 'reportId' keys to the return map for the UI.
return { return {
'status': finalStatus, 'statuses': <Map<String, dynamic>>[
'success': apiSuccess || ftpSuccess, ...(ftpDataResult['statuses'] as List),
'message': finalMessage, ...(ftpImageResult['statuses'] as List),
'reportId': data.reportId ],
}; };
} }
@ -269,10 +412,29 @@ class RiverInSituSamplingService {
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,
String? logDirectory,
}) async { }) async {
data.submissionStatus = status; data.submissionStatus = status;
data.submissionMessage = message; data.submissionMessage = message;
await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName);
if (logDirectory != null) {
final Map<String, dynamic> updatedLogData = data.toMap();
updatedLogData['logDirectory'] = logDirectory;
updatedLogData['serverConfigName'] = serverName;
updatedLogData['api_status'] = jsonEncode(apiResults.where((r) => r.isNotEmpty).toList());
updatedLogData['ftp_status'] = jsonEncode(ftpStatuses);
final imageFilePaths = data.toApiImageFiles();
imageFilePaths.forEach((key, file) {
if (file != null) {
updatedLogData[key] = file.path;
}
});
await _localStorageService.updateRiverInSituLog(updatedLogData);
} else {
await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName);
}
final imagePaths = data.toApiImageFiles().values.whereType<File>().map((f) => f.path).toList(); final imagePaths = data.toApiImageFiles().values.whereType<File>().map((f) => f.path).toList();
final logData = { final logData = {

View File

@ -25,6 +25,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.0" version: "2.13.0"
bcrypt:
dependency: "direct main"
description:
name: bcrypt
sha256: "9dc3f234d5935a76917a6056613e1a6d9b53f7fa56f98e24cd49b8969307764b"
url: "https://pub.dev"
source: hosted
version: "1.1.3"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:

View File

@ -17,6 +17,9 @@ dependencies:
http: ^1.2.1 http: ^1.2.1
intl: ^0.18.1 intl: ^0.18.1
# --- ADDED: For secure password hashing ---
bcrypt: ^1.1.3
# --- 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
@ -29,7 +32,6 @@ dependencies:
flutter_svg: ^2.0.9 flutter_svg: ^2.0.9
google_fonts: ^6.1.0 google_fonts: ^6.1.0
dropdown_search: ^5.0.6 # For searchable dropdowns in forms dropdown_search: ^5.0.6 # For searchable dropdowns in forms
# --- ADDED: For opening document URLs ---
url_launcher: ^6.2.6 url_launcher: ^6.2.6
flutter_pdfview: ^1.3.2 flutter_pdfview: ^1.3.2
dio: ^5.4.3+1 dio: ^5.4.3+1
@ -46,13 +48,10 @@ dependencies:
# --- Added for In-Situ Sampling Module --- # --- Added for In-Situ Sampling Module ---
simple_barcode_scanner: ^0.3.0 # For scanning sample IDs simple_barcode_scanner: ^0.3.0 # For scanning sample IDs
#flutter_blue_classic: ^0.0.3 # For Bluetooth sonde connection
flutter_bluetooth_serial: flutter_bluetooth_serial:
git: git:
url: https://github.com/PSTPSYCO/flutter_bluetooth_serial.git url: https://github.com/PSTPSYCO/flutter_bluetooth_serial.git
ref: my-edits ref: my-edits
usb_serial: ^0.5.2 # For USB Serial sonde connection usb_serial: ^0.5.2 # For USB Serial sonde connection