update to support offline save and retry function
This commit is contained in:
parent
c2a95c53cc
commit
f44245fb5a
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -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 ---
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}) {
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)'),
|
||||||
|
|||||||
@ -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'],
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
}
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user