create multiple submission process either using api or ftp
This commit is contained in:
parent
6d37abf141
commit
5930dd500e
@ -28,7 +28,7 @@
|
|||||||
<!-- END: STORAGE PERMISSIONS -->
|
<!-- END: STORAGE PERMISSIONS -->
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="MMS V4 Debug"
|
android:label="MMS V4 debug"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:requestLegacyExternalStorage="true">
|
android:requestLegacyExternalStorage="true">
|
||||||
|
|||||||
@ -4,12 +4,22 @@ import 'package:connectivity_plus/connectivity_plus.dart';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
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';
|
||||||
|
// --- ADDED: Import for the new service that manages the retry queue ---
|
||||||
|
import 'package:environment_monitoring_app/services/retry_service.dart';
|
||||||
|
|
||||||
|
|
||||||
/// A comprehensive provider to manage user authentication, session state,
|
/// A comprehensive provider to manage user authentication, session state,
|
||||||
/// and cached master data for offline use.
|
/// and cached master data for offline use.
|
||||||
class AuthProvider with ChangeNotifier {
|
class AuthProvider with ChangeNotifier {
|
||||||
final ApiService _apiService = ApiService();
|
final ApiService _apiService = ApiService();
|
||||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||||
|
// --- ADDED: Instance of the ServerConfigService to set the initial URL ---
|
||||||
|
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||||
|
// --- ADDED: Instance of the RetryService to manage pending tasks ---
|
||||||
|
final RetryService _retryService = RetryService();
|
||||||
|
|
||||||
|
|
||||||
// --- Session & Profile State ---
|
// --- Session & Profile State ---
|
||||||
String? _jwtToken;
|
String? _jwtToken;
|
||||||
@ -42,6 +52,11 @@ class AuthProvider with ChangeNotifier {
|
|||||||
List<Map<String, dynamic>>? _states;
|
List<Map<String, dynamic>>? _states;
|
||||||
List<Map<String, dynamic>>? _appSettings;
|
List<Map<String, dynamic>>? _appSettings;
|
||||||
List<Map<String, dynamic>>? _parameterLimits;
|
List<Map<String, dynamic>>? _parameterLimits;
|
||||||
|
List<Map<String, dynamic>>? _apiConfigs;
|
||||||
|
List<Map<String, dynamic>>? _ftpConfigs;
|
||||||
|
// --- ADDED: State variable for the list of tasks pending manual retry ---
|
||||||
|
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;
|
||||||
@ -58,6 +73,11 @@ class AuthProvider with ChangeNotifier {
|
|||||||
List<Map<String, dynamic>>? get states => _states;
|
List<Map<String, dynamic>>? get states => _states;
|
||||||
List<Map<String, dynamic>>? get appSettings => _appSettings;
|
List<Map<String, dynamic>>? get appSettings => _appSettings;
|
||||||
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 ftpConfigs => _ftpConfigs;
|
||||||
|
// --- ADDED: Getter for the list of tasks pending manual retry ---
|
||||||
|
List<Map<String, dynamic>>? get pendingRetries => _pendingRetries;
|
||||||
|
|
||||||
|
|
||||||
// --- SharedPreferences Keys ---
|
// --- SharedPreferences Keys ---
|
||||||
static const String tokenKey = 'jwt_token';
|
static const String tokenKey = 'jwt_token';
|
||||||
@ -81,6 +101,23 @@ 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();
|
||||||
|
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.");
|
||||||
|
final initialConfig = {
|
||||||
|
'api_config_id': 0,
|
||||||
|
'config_name': 'Default Server',
|
||||||
|
'api_url': 'https://dev14.pstw.com.my/v1',
|
||||||
|
};
|
||||||
|
// Save this default config as the active one.
|
||||||
|
await _serverConfigService.setActiveApiConfig(initialConfig);
|
||||||
|
}
|
||||||
|
// --- END OF MODIFICATION ---
|
||||||
|
|
||||||
// MODIFIED: Switched to getting a string for the ISO8601 timestamp
|
// 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) {
|
||||||
@ -128,6 +165,13 @@ class AuthProvider with ChangeNotifier {
|
|||||||
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) {
|
||||||
|
await setIsFirstLogin(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.
|
// After updating the DB, reload data from the cache into memory to update the UI.
|
||||||
await _loadDataFromCache();
|
await _loadDataFromCache();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@ -167,9 +211,20 @@ class AuthProvider with ChangeNotifier {
|
|||||||
// ADDED: Load new data types from the local database
|
// 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();
|
||||||
|
_apiConfigs = await _dbHelper.loadApiConfigs();
|
||||||
|
_ftpConfigs = await _dbHelper.loadFtpConfigs();
|
||||||
|
// --- ADDED: Load pending retry tasks from the database ---
|
||||||
|
_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 {
|
||||||
|
_pendingRetries = await _retryService.getPendingTasks();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Methods for UI interaction ---
|
// --- Methods for UI interaction ---
|
||||||
|
|
||||||
/// Handles the login process, saving session data and triggering a full data sync.
|
/// Handles the login process, saving session data and triggering a full data sync.
|
||||||
@ -226,6 +281,10 @@ class AuthProvider with ChangeNotifier {
|
|||||||
// ADDED: Clear new data on logout
|
// ADDED: Clear new data on logout
|
||||||
_appSettings = null;
|
_appSettings = null;
|
||||||
_parameterLimits = null;
|
_parameterLimits = null;
|
||||||
|
_apiConfigs = null;
|
||||||
|
_ftpConfigs = null;
|
||||||
|
// --- ADDED: Clear pending retry tasks on logout ---
|
||||||
|
_pendingRetries = null;
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
// MODIFIED: Removed keys individually for safer logout
|
// MODIFIED: Removed keys individually for safer logout
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../../../auth_provider.dart';
|
||||||
import '../../../../models/air_installation_data.dart';
|
import '../../../../models/air_installation_data.dart';
|
||||||
import '../../../../models/air_collection_data.dart';
|
import '../../../../models/air_collection_data.dart';
|
||||||
import '../../../../services/local_storage_service.dart';
|
import '../../../../services/local_storage_service.dart';
|
||||||
import '../../../../services/api_service.dart';
|
// --- MODIFIED: Import AirSamplingService to handle resubmissions correctly ---
|
||||||
|
import '../../../../services/air_sampling_service.dart';
|
||||||
|
|
||||||
|
// --- MODIFIED: Added serverName to the log entry model ---
|
||||||
class SubmissionLogEntry {
|
class SubmissionLogEntry {
|
||||||
final String type;
|
final String type;
|
||||||
final String title;
|
final String title;
|
||||||
@ -16,6 +20,7 @@ class SubmissionLogEntry {
|
|||||||
final String status;
|
final String status;
|
||||||
final String message;
|
final String message;
|
||||||
final Map<String, dynamic> rawData;
|
final Map<String, dynamic> rawData;
|
||||||
|
final String serverName; // ADDED
|
||||||
bool isResubmitting;
|
bool isResubmitting;
|
||||||
|
|
||||||
SubmissionLogEntry({
|
SubmissionLogEntry({
|
||||||
@ -27,6 +32,7 @@ class SubmissionLogEntry {
|
|||||||
required this.status,
|
required this.status,
|
||||||
required this.message,
|
required this.message,
|
||||||
required this.rawData,
|
required this.rawData,
|
||||||
|
required this.serverName, // ADDED
|
||||||
this.isResubmitting = false,
|
this.isResubmitting = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -40,18 +46,14 @@ class AirManualDataStatusLog extends StatefulWidget {
|
|||||||
|
|
||||||
class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
||||||
final LocalStorageService _localStorageService = LocalStorageService();
|
final LocalStorageService _localStorageService = LocalStorageService();
|
||||||
final ApiService _apiService = ApiService();
|
// --- MODIFIED: Use AirSamplingService for resubmission logic ---
|
||||||
|
final AirSamplingService _airSamplingService = AirSamplingService();
|
||||||
|
|
||||||
// Raw data lists
|
// --- MODIFIED: Simplified state management to a single source of truth ---
|
||||||
List<SubmissionLogEntry> _installationLogs = [];
|
List<SubmissionLogEntry> _allLogs = [];
|
||||||
List<SubmissionLogEntry> _collectionLogs = [];
|
List<SubmissionLogEntry> _filteredLogs = [];
|
||||||
|
|
||||||
// Filtered lists for the UI
|
final _searchController = TextEditingController();
|
||||||
List<SubmissionLogEntry> _filteredInstallationLogs = [];
|
|
||||||
List<SubmissionLogEntry> _filteredCollectionLogs = [];
|
|
||||||
|
|
||||||
// Per-category search controllers
|
|
||||||
final Map<String, TextEditingController> _searchControllers = {};
|
|
||||||
|
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
final Map<String, bool> _isResubmitting = {};
|
final Map<String, bool> _isResubmitting = {};
|
||||||
@ -59,15 +61,13 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_searchControllers['Installation'] = TextEditingController()..addListener(_filterLogs);
|
_searchController.addListener(_filterLogs);
|
||||||
_searchControllers['Collection'] = TextEditingController()..addListener(_filterLogs);
|
|
||||||
_loadAllLogs();
|
_loadAllLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchControllers['Installation']?.dispose();
|
_searchController.dispose();
|
||||||
_searchControllers['Collection']?.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,53 +75,48 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
final airLogs = await _localStorageService.getAllAirSamplingLogs();
|
final airLogs = await _localStorageService.getAllAirSamplingLogs();
|
||||||
|
|
||||||
final List<SubmissionLogEntry> tempInstallation = [];
|
final List<SubmissionLogEntry> tempLogs = [];
|
||||||
final List<SubmissionLogEntry> tempCollection = [];
|
|
||||||
|
|
||||||
for (var log in airLogs) {
|
for (var log in airLogs) {
|
||||||
try {
|
try {
|
||||||
final hasCollectionData = log['collectionData'] != null && (log['collectionData'] as Map).isNotEmpty;
|
final hasCollectionData = log['collectionData'] != null && (log['collectionData'] as Map).isNotEmpty;
|
||||||
final isInstallation = !hasCollectionData && log['air_man_id'] != null;
|
|
||||||
|
|
||||||
final stationInfo = isInstallation
|
// Determine if it's an Installation or Collection log
|
||||||
? log['stationInfo'] ?? {}
|
final logType = hasCollectionData ? 'Collection' : 'Installation';
|
||||||
: log['collectionData']?['stationInfo'] ?? {};
|
|
||||||
|
|
||||||
|
final stationInfo = log['stationInfo'] ?? {};
|
||||||
final stationName = stationInfo['station_name'] ?? 'Station ${log['stationID'] ?? 'Unknown'}';
|
final stationName = stationInfo['station_name'] ?? 'Station ${log['stationID'] ?? 'Unknown'}';
|
||||||
final stationCode = stationInfo['station_code'] ?? log['stationID'] ?? 'N/A';
|
final stationCode = stationInfo['station_code'] ?? log['stationID'] ?? 'N/A';
|
||||||
|
|
||||||
final submissionDateTime = isInstallation
|
final submissionDateTime = logType == 'Installation'
|
||||||
? _parseInstallationDateTime(log)
|
? _parseInstallationDateTime(log)
|
||||||
: _parseCollectionDateTime(log['collectionData']);
|
: _parseCollectionDateTime(log['collectionData']);
|
||||||
|
|
||||||
final entry = SubmissionLogEntry(
|
final entry = SubmissionLogEntry(
|
||||||
type: isInstallation ? 'Installation' : 'Collection',
|
type: logType,
|
||||||
title: stationName,
|
title: stationName,
|
||||||
stationCode: stationCode,
|
stationCode: stationCode,
|
||||||
submissionDateTime: submissionDateTime,
|
submissionDateTime: submissionDateTime,
|
||||||
reportId: isInstallation ? log['air_man_id']?.toString() : log['collectionData']?['air_man_id']?.toString(),
|
reportId: log['airManId']?.toString(),
|
||||||
status: log['status'] ?? 'L1',
|
status: log['status'] ?? 'L1',
|
||||||
message: _getStatusMessage(log),
|
message: _getStatusMessage(log),
|
||||||
rawData: log,
|
rawData: log,
|
||||||
|
// --- MODIFIED: Extract the server name from the log data ---
|
||||||
|
serverName: log['serverConfigName'] ?? 'Unknown Server',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isInstallation) {
|
tempLogs.add(entry);
|
||||||
tempInstallation.add(entry);
|
|
||||||
} else {
|
|
||||||
tempCollection.add(entry);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error processing log entry: $e');
|
debugPrint('Error processing log entry: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tempInstallation.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime));
|
tempLogs.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime));
|
||||||
tempCollection.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime));
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_installationLogs = tempInstallation;
|
_allLogs = tempLogs;
|
||||||
_collectionLogs = tempCollection;
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
_filterLogs();
|
_filterLogs();
|
||||||
@ -145,8 +140,8 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
DateTime _parseCollectionDateTime(Map<String, dynamic>? collectionData) {
|
DateTime _parseCollectionDateTime(Map<String, dynamic>? collectionData) {
|
||||||
try {
|
try {
|
||||||
if (collectionData == null) return DateTime.now();
|
if (collectionData == null) return DateTime.now();
|
||||||
final dateKey = 'air_man_collection_date';
|
final dateKey = 'collectionDate'; // Corrected key based on AirCollectionData model
|
||||||
final timeKey = 'air_man_collection_time';
|
final timeKey = 'collectionTime'; // Corrected key
|
||||||
|
|
||||||
if (collectionData[dateKey] != null) {
|
if (collectionData[dateKey] != null) {
|
||||||
final date = collectionData[dateKey];
|
final date = collectionData[dateKey];
|
||||||
@ -162,13 +157,15 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
|
|
||||||
String _getStatusMessage(Map<String, dynamic> log) {
|
String _getStatusMessage(Map<String, dynamic> log) {
|
||||||
switch (log['status']) {
|
switch (log['status']) {
|
||||||
|
case 'S1':
|
||||||
case 'S2':
|
case 'S2':
|
||||||
case 'S3':
|
case 'S3':
|
||||||
return 'Successfully submitted to server';
|
return 'Successfully submitted to server';
|
||||||
case 'L1':
|
case 'L1':
|
||||||
case 'L3':
|
case 'L3':
|
||||||
return 'Saved locally (pending submission)';
|
return 'Saved locally (pending submission)';
|
||||||
case 'L4':
|
case 'L2_PENDING_IMAGES':
|
||||||
|
case 'L4_PENDING_IMAGES':
|
||||||
return 'Partial submission (images failed)';
|
return 'Partial submission (images failed)';
|
||||||
default:
|
default:
|
||||||
return 'Submission status unknown';
|
return 'Submission status unknown';
|
||||||
@ -176,88 +173,70 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _filterLogs() {
|
void _filterLogs() {
|
||||||
final installationQuery = _searchControllers['Installation']?.text.toLowerCase() ?? '';
|
final query = _searchController.text.toLowerCase();
|
||||||
final collectionQuery = _searchControllers['Collection']?.text.toLowerCase() ?? '';
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_filteredInstallationLogs = _installationLogs.where((log) => _logMatchesQuery(log, installationQuery)).toList();
|
_filteredLogs = _allLogs.where((log) {
|
||||||
_filteredCollectionLogs = _collectionLogs.where((log) => _logMatchesQuery(log, collectionQuery)).toList();
|
if (query.isEmpty) return true;
|
||||||
|
// --- MODIFIED: Add serverName to search criteria ---
|
||||||
|
return log.title.toLowerCase().contains(query) ||
|
||||||
|
log.stationCode.toLowerCase().contains(query) ||
|
||||||
|
log.serverName.toLowerCase().contains(query) ||
|
||||||
|
(log.reportId?.toLowerCase() ?? '').contains(query);
|
||||||
|
}).toList();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _logMatchesQuery(SubmissionLogEntry log, String query) {
|
// --- MODIFIED: Complete overhaul of the resubmission logic ---
|
||||||
if (query.isEmpty) return true;
|
|
||||||
return log.title.toLowerCase().contains(query) ||
|
|
||||||
log.stationCode.toLowerCase().contains(query) ||
|
|
||||||
(log.reportId?.toLowerCase() ?? '').contains(query);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _resubmitData(SubmissionLogEntry log) async {
|
Future<void> _resubmitData(SubmissionLogEntry log) async {
|
||||||
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
|
final logKey = log.rawData['refID']?.toString() ?? log.submissionDateTime.toIso8601String();
|
||||||
if (mounted) setState(() => _isResubmitting[logKey] = true);
|
if (mounted) setState(() => _isResubmitting[logKey] = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final logData = log.rawData;
|
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||||
log.type == 'Installation'
|
final appSettings = authProvider.appSettings;
|
||||||
? await _submitInstallation(logData)
|
Map<String, dynamic> result;
|
||||||
: await _submitCollection(logData);
|
|
||||||
|
// Re-create the data models from the raw log data
|
||||||
|
final installationData = AirInstallationData.fromJson(log.rawData);
|
||||||
|
|
||||||
|
if (log.type == 'Installation') {
|
||||||
|
result = await _airSamplingService.submitInstallation(installationData, appSettings);
|
||||||
|
} else {
|
||||||
|
final collectionData = AirCollectionData.fromMap(log.rawData['collectionData']);
|
||||||
|
result = await _airSamplingService.submitCollection(collectionData, installationData, appSettings);
|
||||||
|
}
|
||||||
|
|
||||||
await _localStorageService.saveAirSamplingRecord(logData, logData['refID']);
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Resubmission successful!')),
|
SnackBar(
|
||||||
|
content: Text(result['message'] ?? 'Resubmission complete.'),
|
||||||
|
backgroundColor: (result['status'] as String).startsWith('S') ? Colors.green : Colors.red,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Resubmission failed: $e')),
|
SnackBar(content: Text('Resubmission failed: $e'), backgroundColor: Colors.red),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isResubmitting.remove(logKey));
|
setState(() => _isResubmitting.remove(logKey));
|
||||||
await _loadAllLogs();
|
await _loadAllLogs(); // Refresh the log list to show the updated status
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submitInstallation(Map<String, dynamic> data) async {
|
|
||||||
final dataToResubmit = AirInstallationData.fromJson(data);
|
|
||||||
final result = await _apiService.post('air/manual/installation', dataToResubmit.toJsonForApi());
|
|
||||||
|
|
||||||
final imageFiles = dataToResubmit.getImagesForUpload();
|
|
||||||
if (imageFiles.isNotEmpty && result['success'] == true) {
|
|
||||||
await _apiService.air.uploadInstallationImages(
|
|
||||||
airManId: result['data']['air_man_id'].toString(),
|
|
||||||
files: imageFiles,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _submitCollection(Map<String, dynamic> data) async {
|
|
||||||
final collectionData = data['collectionData'] ?? {};
|
|
||||||
final dataToResubmit = AirCollectionData.fromMap(collectionData);
|
|
||||||
final result = await _apiService.post('air/manual/collection', dataToResubmit.toJson());
|
|
||||||
|
|
||||||
final imageFiles = dataToResubmit.getImagesForUpload();
|
|
||||||
if (imageFiles.isNotEmpty && result['success'] == true) {
|
|
||||||
await _apiService.air.uploadCollectionImages(
|
|
||||||
airManId: dataToResubmit.airManId.toString(),
|
|
||||||
files: imageFiles,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final hasAnyLogs = _installationLogs.isNotEmpty || _collectionLogs.isNotEmpty;
|
// --- MODIFIED: Logic simplified to work with a single, comprehensive list ---
|
||||||
final hasFilteredLogs = _filteredInstallationLogs.isNotEmpty || _filteredCollectionLogs.isNotEmpty;
|
|
||||||
|
|
||||||
final logCategories = {
|
final logCategories = {
|
||||||
'Installation': _filteredInstallationLogs,
|
'Installation': _filteredLogs.where((log) => log.type == 'Installation').toList(),
|
||||||
'Collection': _filteredCollectionLogs,
|
'Collection': _filteredLogs.where((log) => log.type == 'Collection').toList(),
|
||||||
};
|
};
|
||||||
|
final hasAnyLogs = _allLogs.isNotEmpty;
|
||||||
|
final hasFilteredLogs = _filteredLogs.isNotEmpty;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Air Sampling Status Log')),
|
appBar: AppBar(title: const Text('Air Sampling Status Log')),
|
||||||
@ -270,8 +249,21 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
: ListView(
|
: ListView(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
children: [
|
children: [
|
||||||
|
// General search bar for all logs
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search by station, server, or ID...',
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 20),
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
...logCategories.entries
|
...logCategories.entries
|
||||||
.where((entry) => entry.value.isNotEmpty) // Only show categories with logs
|
.where((entry) => entry.value.isNotEmpty)
|
||||||
.map((entry) => _buildCategorySection(entry.key, entry.value)),
|
.map((entry) => _buildCategorySection(entry.key, entry.value)),
|
||||||
if (!hasFilteredLogs && hasAnyLogs)
|
if (!hasFilteredLogs && hasAnyLogs)
|
||||||
const Center(
|
const Center(
|
||||||
@ -286,45 +278,26 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// THIS SECTION IS UPDATED TO MATCH THE MARINE LOG UI
|
|
||||||
Widget _buildCategorySection(String category, List<SubmissionLogEntry> logs) {
|
Widget _buildCategorySection(String category, List<SubmissionLogEntry> logs) {
|
||||||
// Calculate height to show 5.5 items, indicating scrollability
|
|
||||||
final listHeight = (logs.length > 5 ? 5.5 : logs.length.toDouble()) * 75.0;
|
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
margin: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 8.0),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
child: TextField(
|
child: Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
controller: _searchControllers[category],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Search in $category...',
|
|
||||||
prefixIcon: const Icon(Icons.search, size: 20),
|
|
||||||
isDense: true,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
logs.isEmpty
|
ListView.builder(
|
||||||
? const Padding(
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
padding: EdgeInsets.all(16.0),
|
shrinkWrap: true,
|
||||||
child: Center(child: Text('No logs match your search in this category.')))
|
itemCount: logs.length,
|
||||||
: ConstrainedBox(
|
itemBuilder: (context, index) {
|
||||||
constraints: BoxConstraints(maxHeight: listHeight),
|
return _buildLogListItem(logs[index]);
|
||||||
child: ListView.builder(
|
},
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: logs.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
return _buildLogListItem(logs[index]);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -332,13 +305,13 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// THIS ITEM BUILDER IS UPDATED TO MATCH THE MARINE LOG UI
|
|
||||||
Widget _buildLogListItem(SubmissionLogEntry log) {
|
Widget _buildLogListItem(SubmissionLogEntry log) {
|
||||||
final isSuccess = log.status == 'S2' || log.status == 'S3';
|
final isSuccess = log.status.startsWith('S');
|
||||||
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
|
final logKey = log.rawData['refID']?.toString() ?? log.submissionDateTime.toIso8601String();
|
||||||
final isResubmitting = _isResubmitting[logKey] ?? false;
|
final isResubmitting = _isResubmitting[logKey] ?? false;
|
||||||
final title = '${log.title} (${log.stationCode})'; // Consistent title format
|
final title = '${log.title} (${log.stationCode})';
|
||||||
final subtitle = DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime);
|
// --- MODIFIED: Include the server name in the subtitle for clarity ---
|
||||||
|
final subtitle = '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}';
|
||||||
|
|
||||||
return ExpansionTile(
|
return ExpansionTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
@ -362,6 +335,8 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// --- MODIFIED: Add server name to the details view ---
|
||||||
|
_buildDetailRow('Server:', log.serverName),
|
||||||
_buildDetailRow('Report ID:', log.reportId ?? 'N/A'),
|
_buildDetailRow('Report ID:', log.reportId ?? 'N/A'),
|
||||||
_buildDetailRow('Status:', log.message),
|
_buildDetailRow('Status:', log.message),
|
||||||
_buildDetailRow('Submission Type:', log.type),
|
_buildDetailRow('Submission Type:', log.type),
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart';
|
|||||||
import 'package:environment_monitoring_app/services/local_storage_service.dart';
|
import 'package:environment_monitoring_app/services/local_storage_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/marine_api_service.dart';
|
import 'package:environment_monitoring_app/services/marine_api_service.dart';
|
||||||
|
|
||||||
|
// --- MODIFIED: Added serverName to the log entry model ---
|
||||||
class SubmissionLogEntry {
|
class SubmissionLogEntry {
|
||||||
final String type; // e.g., 'Manual Sampling', 'Tarball Sampling'
|
final String type; // e.g., 'Manual Sampling', 'Tarball Sampling'
|
||||||
final String title;
|
final String title;
|
||||||
@ -17,6 +18,7 @@ class SubmissionLogEntry {
|
|||||||
final String status;
|
final String status;
|
||||||
final String message;
|
final String message;
|
||||||
final Map<String, dynamic> rawData;
|
final Map<String, dynamic> rawData;
|
||||||
|
final String serverName; // ADDED
|
||||||
bool isResubmitting;
|
bool isResubmitting;
|
||||||
|
|
||||||
SubmissionLogEntry({
|
SubmissionLogEntry({
|
||||||
@ -28,6 +30,7 @@ class SubmissionLogEntry {
|
|||||||
required this.status,
|
required this.status,
|
||||||
required this.message,
|
required this.message,
|
||||||
required this.rawData,
|
required this.rawData,
|
||||||
|
required this.serverName, // ADDED
|
||||||
this.isResubmitting = false,
|
this.isResubmitting = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -95,6 +98,8 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
|||||||
status: log['submissionStatus'] ?? 'L1',
|
status: log['submissionStatus'] ?? 'L1',
|
||||||
message: log['submissionMessage'] ?? 'No status message.',
|
message: log['submissionMessage'] ?? 'No status message.',
|
||||||
rawData: log,
|
rawData: log,
|
||||||
|
// --- MODIFIED: Extract the server name from the log data ---
|
||||||
|
serverName: log['serverConfigName'] ?? 'Unknown Server',
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,6 +114,8 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
|||||||
status: log['submissionStatus'] ?? 'L1',
|
status: log['submissionStatus'] ?? 'L1',
|
||||||
message: log['submissionMessage'] ?? 'No status message.',
|
message: log['submissionMessage'] ?? 'No status message.',
|
||||||
rawData: log,
|
rawData: log,
|
||||||
|
// --- MODIFIED: Extract the server name from the log data ---
|
||||||
|
serverName: log['serverConfigName'] ?? 'Unknown Server',
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,8 +144,10 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
|||||||
|
|
||||||
bool _logMatchesQuery(SubmissionLogEntry log, String query) {
|
bool _logMatchesQuery(SubmissionLogEntry log, String query) {
|
||||||
if (query.isEmpty) return true;
|
if (query.isEmpty) return true;
|
||||||
|
// --- MODIFIED: Add serverName to search criteria ---
|
||||||
return log.title.toLowerCase().contains(query) ||
|
return log.title.toLowerCase().contains(query) ||
|
||||||
log.stationCode.toLowerCase().contains(query) ||
|
log.stationCode.toLowerCase().contains(query) ||
|
||||||
|
log.serverName.toLowerCase().contains(query) ||
|
||||||
(log.reportId?.toLowerCase() ?? '').contains(query);
|
(log.reportId?.toLowerCase() ?? '').contains(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -372,7 +381,8 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
|||||||
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
|
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
|
||||||
final isResubmitting = _isResubmitting[logKey] ?? false;
|
final isResubmitting = _isResubmitting[logKey] ?? false;
|
||||||
final title = '${log.title} (${log.stationCode})';
|
final title = '${log.title} (${log.stationCode})';
|
||||||
final subtitle = DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime);
|
// --- MODIFIED: Include the server name in the subtitle for clarity ---
|
||||||
|
final subtitle = '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}';
|
||||||
|
|
||||||
return ExpansionTile(
|
return ExpansionTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
@ -392,6 +402,8 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// --- MODIFIED: Add server name to the details view ---
|
||||||
|
_buildDetailRow('Server:', log.serverName),
|
||||||
_buildDetailRow('Report ID:', log.reportId ?? 'N/A'),
|
_buildDetailRow('Report ID:', log.reportId ?? 'N/A'),
|
||||||
_buildDetailRow('Status:', log.message),
|
_buildDetailRow('Status:', log.message),
|
||||||
_buildDetailRow('Submission Type:', log.type),
|
_buildDetailRow('Submission Type:', log.type),
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import 'package:environment_monitoring_app/auth_provider.dart'; // ADDED: Import
|
|||||||
import '../../../models/in_situ_sampling_data.dart';
|
import '../../../models/in_situ_sampling_data.dart';
|
||||||
import '../../../services/in_situ_sampling_service.dart';
|
import '../../../services/in_situ_sampling_service.dart';
|
||||||
import '../../../services/local_storage_service.dart';
|
import '../../../services/local_storage_service.dart';
|
||||||
|
// --- ADDED: Import to get the active server configuration name ---
|
||||||
|
import '../../../services/server_config_service.dart';
|
||||||
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';
|
||||||
@ -33,6 +35,9 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
|
|||||||
// Service for saving submission logs locally.
|
// Service for saving submission logs locally.
|
||||||
final LocalStorageService _localStorageService = LocalStorageService();
|
final LocalStorageService _localStorageService = LocalStorageService();
|
||||||
|
|
||||||
|
// --- ADDED: Service to get the active server configuration ---
|
||||||
|
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||||
|
|
||||||
int _currentPage = 0;
|
int _currentPage = 0;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
@ -94,8 +99,12 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
|
|||||||
_data.submissionMessage = result['message'];
|
_data.submissionMessage = result['message'];
|
||||||
_data.reportId = result['reportId']?.toString();
|
_data.reportId = result['reportId']?.toString();
|
||||||
|
|
||||||
// Save a log of the submission locally.
|
// --- MODIFIED: Get the active server name before saving the local log ---
|
||||||
await _localStorageService.saveInSituSamplingData(_data);
|
final activeConfig = await _serverConfigService.getActiveApiConfig();
|
||||||
|
final serverName = activeConfig?['config_name'] as String? ?? 'Default';
|
||||||
|
|
||||||
|
// Save a log of the submission locally, now with the server name.
|
||||||
|
await _localStorageService.saveInSituSamplingData(_data, serverName: serverName);
|
||||||
|
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,9 @@ import 'package:environment_monitoring_app/auth_provider.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/marine_api_service.dart';
|
import 'package:environment_monitoring_app/services/marine_api_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/local_storage_service.dart';
|
import 'package:environment_monitoring_app/services/local_storage_service.dart';
|
||||||
|
// --- ADDED: Import to get the active server configuration name ---
|
||||||
|
import 'package:environment_monitoring_app/services/server_config_service.dart';
|
||||||
|
|
||||||
|
|
||||||
class TarballSamplingStep3Summary extends StatefulWidget {
|
class TarballSamplingStep3Summary extends StatefulWidget {
|
||||||
final TarballSamplingData data;
|
final TarballSamplingData data;
|
||||||
@ -18,6 +21,8 @@ class TarballSamplingStep3Summary extends StatefulWidget {
|
|||||||
class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summary> {
|
class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summary> {
|
||||||
final MarineApiService _marineApiService = MarineApiService();
|
final MarineApiService _marineApiService = MarineApiService();
|
||||||
final LocalStorageService _localStorageService = LocalStorageService();
|
final LocalStorageService _localStorageService = LocalStorageService();
|
||||||
|
// --- ADDED: Service to get the active server configuration ---
|
||||||
|
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
// MODIFIED: This method now fetches appSettings and passes it to the API service.
|
// MODIFIED: This method now fetches appSettings and passes it to the API service.
|
||||||
@ -43,8 +48,12 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
|
|||||||
widget.data.submissionMessage = result['message'];
|
widget.data.submissionMessage = result['message'];
|
||||||
widget.data.reportId = result['reportId']?.toString();
|
widget.data.reportId = result['reportId']?.toString();
|
||||||
|
|
||||||
|
// --- MODIFIED: Get the active server name before saving the local log ---
|
||||||
|
final activeConfig = await _serverConfigService.getActiveApiConfig();
|
||||||
|
final serverName = activeConfig?['config_name'] as String? ?? 'Default';
|
||||||
|
|
||||||
// Step 3: Local Save with the complete data, including submission status.
|
// Step 3: Local Save with the complete data, including submission status.
|
||||||
final String? localPath = await _localStorageService.saveTarballSamplingData(widget.data);
|
final String? localPath = await _localStorageService.saveTarballSamplingData(widget.data, serverName: serverName);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
if (localPath != null) {
|
if (localPath != null) {
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import 'package:environment_monitoring_app/auth_provider.dart'; // ADDED: Import
|
|||||||
import '../../../models/river_in_situ_sampling_data.dart';
|
import '../../../models/river_in_situ_sampling_data.dart';
|
||||||
import '../../../services/river_in_situ_sampling_service.dart';
|
import '../../../services/river_in_situ_sampling_service.dart';
|
||||||
import '../../../services/local_storage_service.dart';
|
import '../../../services/local_storage_service.dart';
|
||||||
|
// --- ADDED: Import to get the active server configuration name ---
|
||||||
|
import '../../../services/server_config_service.dart';
|
||||||
import 'widgets/river_in_situ_step_1_sampling_info.dart';
|
import 'widgets/river_in_situ_step_1_sampling_info.dart';
|
||||||
import 'widgets/river_in_situ_step_2_site_info.dart';
|
import 'widgets/river_in_situ_step_2_site_info.dart';
|
||||||
import 'widgets/river_in_situ_step_3_data_capture.dart';
|
import 'widgets/river_in_situ_step_3_data_capture.dart';
|
||||||
@ -29,6 +31,8 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
|
|||||||
|
|
||||||
final RiverInSituSamplingService _samplingService = RiverInSituSamplingService();
|
final RiverInSituSamplingService _samplingService = RiverInSituSamplingService();
|
||||||
final LocalStorageService _localStorageService = LocalStorageService();
|
final LocalStorageService _localStorageService = LocalStorageService();
|
||||||
|
// --- ADDED: Service to get the active server configuration ---
|
||||||
|
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||||
|
|
||||||
int _currentPage = 0;
|
int _currentPage = 0;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
@ -84,7 +88,12 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
|
|||||||
_data.submissionMessage = result['message'];
|
_data.submissionMessage = result['message'];
|
||||||
_data.reportId = result['reportId']?.toString();
|
_data.reportId = result['reportId']?.toString();
|
||||||
|
|
||||||
await _localStorageService.saveRiverInSituSamplingData(_data);
|
// --- MODIFIED: Get the active server name before saving the local log ---
|
||||||
|
final activeConfig = await _serverConfigService.getActiveApiConfig();
|
||||||
|
final serverName = activeConfig?['config_name'] as String? ?? 'Default';
|
||||||
|
|
||||||
|
// --- MODIFIED: Pass the serverName to the save method ---
|
||||||
|
await _localStorageService.saveRiverInSituSamplingData(_data, serverName: serverName);
|
||||||
|
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
|
|
||||||
|
|||||||
@ -14,12 +14,17 @@ import '../models/air_collection_data.dart';
|
|||||||
import 'api_service.dart';
|
import 'api_service.dart';
|
||||||
import 'local_storage_service.dart';
|
import 'local_storage_service.dart';
|
||||||
import 'telegram_service.dart';
|
import 'telegram_service.dart';
|
||||||
|
// --- ADDED: Import for the service that manages active server configurations ---
|
||||||
|
import 'server_config_service.dart';
|
||||||
|
|
||||||
|
|
||||||
/// A dedicated service to handle all business logic for the Air Manual Sampling feature.
|
/// A dedicated service to handle all business logic for the Air Manual Sampling feature.
|
||||||
class AirSamplingService {
|
class AirSamplingService {
|
||||||
final ApiService _apiService = ApiService();
|
final ApiService _apiService = ApiService();
|
||||||
final LocalStorageService _localStorageService = LocalStorageService();
|
final LocalStorageService _localStorageService = LocalStorageService();
|
||||||
final TelegramService _telegramService = TelegramService();
|
final TelegramService _telegramService = TelegramService();
|
||||||
|
// --- ADDED: An instance of the service to get the active server name ---
|
||||||
|
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||||
|
|
||||||
/// Picks an image from the specified source, adds a timestamp watermark,
|
/// Picks an image from the specified source, adds a timestamp watermark,
|
||||||
/// and saves it to a temporary directory with a standardized name.
|
/// and saves it to a temporary directory with a standardized name.
|
||||||
@ -101,18 +106,23 @@ class AirSamplingService {
|
|||||||
/// Orchestrates a two-step submission process for air installation samples.
|
/// Orchestrates a two-step submission process for air installation samples.
|
||||||
// MODIFIED: Method now requires the appSettings list to pass down the call stack.
|
// MODIFIED: Method now requires the appSettings list to pass down the call stack.
|
||||||
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 {
|
||||||
|
// --- MODIFIED: Get the active server name to use for local storage ---
|
||||||
|
final activeConfig = await _serverConfigService.getActiveApiConfig();
|
||||||
|
final serverName = activeConfig?['config_name'] as String? ?? 'Default';
|
||||||
|
|
||||||
// --- OFFLINE-FIRST HELPER ---
|
// --- OFFLINE-FIRST HELPER ---
|
||||||
Future<Map<String, dynamic>> saveLocally(String status, String message) async {
|
Future<Map<String, dynamic>> saveLocally(String status, String message) async {
|
||||||
debugPrint("Saving installation locally with status: $status");
|
debugPrint("Saving installation locally with status: $status");
|
||||||
data.status = status; // Use the provided status
|
data.status = status; // Use the provided status
|
||||||
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
|
// --- MODIFIED: Pass the serverName to the save method ---
|
||||||
|
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!, serverName: serverName);
|
||||||
return {'status': status, 'message': message};
|
return {'status': status, 'message': message};
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the record's text data is already on the server, skip directly to image upload.
|
// If the record's text data is already on the server, skip directly to image upload.
|
||||||
if (data.status == 'L2_PENDING_IMAGES' && data.airManId != null) {
|
if (data.status == 'L2_PENDING_IMAGES' && data.airManId != null) {
|
||||||
debugPrint("Retrying image upload for existing record ID: ${data.airManId}");
|
debugPrint("Retrying image upload for existing record ID: ${data.airManId}");
|
||||||
return await _uploadInstallationImagesAndUpdate(data, appSettings);
|
return await _uploadInstallationImagesAndUpdate(data, appSettings, serverName: serverName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- STEP 1: SUBMIT TEXT DATA ---
|
// --- STEP 1: SUBMIT TEXT DATA ---
|
||||||
@ -142,17 +152,17 @@ class AirSamplingService {
|
|||||||
data.airManId = parsedRecordId;
|
data.airManId = parsedRecordId;
|
||||||
|
|
||||||
// --- STEP 2: UPLOAD IMAGE FILES ---
|
// --- STEP 2: UPLOAD IMAGE FILES ---
|
||||||
return await _uploadInstallationImagesAndUpdate(data, appSettings);
|
return await _uploadInstallationImagesAndUpdate(data, appSettings, serverName: serverName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A reusable function for handling the image upload and local data update logic.
|
/// A reusable function for handling the image upload and local data update logic.
|
||||||
// MODIFIED: Method now requires the appSettings list to pass to the alert handler.
|
// MODIFIED: Method now requires the serverName to pass to the save method.
|
||||||
Future<Map<String, dynamic>> _uploadInstallationImagesAndUpdate(AirInstallationData data, List<Map<String, dynamic>>? appSettings) async {
|
Future<Map<String, dynamic>> _uploadInstallationImagesAndUpdate(AirInstallationData data, List<Map<String, dynamic>>? appSettings, {required String serverName}) async {
|
||||||
final filesToUpload = data.getImagesForUpload();
|
final filesToUpload = data.getImagesForUpload();
|
||||||
if (filesToUpload.isEmpty) {
|
if (filesToUpload.isEmpty) {
|
||||||
debugPrint("No images to upload. Submission complete.");
|
debugPrint("No images to upload. Submission complete.");
|
||||||
data.status = 'S1'; // Server Pending (no images needed)
|
data.status = 'S1'; // Server Pending (no images needed)
|
||||||
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
|
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!, serverName: serverName);
|
||||||
_handleInstallationSuccessAlert(data, appSettings, isDataOnly: true);
|
_handleInstallationSuccessAlert(data, appSettings, isDataOnly: true);
|
||||||
return {'status': 'S1', 'message': 'Installation data submitted successfully.'};
|
return {'status': 'S1', 'message': 'Installation data submitted successfully.'};
|
||||||
}
|
}
|
||||||
@ -166,7 +176,7 @@ class AirSamplingService {
|
|||||||
if (imageUploadResult['success'] != true) {
|
if (imageUploadResult['success'] != true) {
|
||||||
debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}");
|
debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}");
|
||||||
data.status = 'L2_PENDING_IMAGES';
|
data.status = 'L2_PENDING_IMAGES';
|
||||||
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
|
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!, serverName: serverName);
|
||||||
return {
|
return {
|
||||||
'status': 'L2_PENDING_IMAGES',
|
'status': 'L2_PENDING_IMAGES',
|
||||||
'message': 'Data submitted, but image upload failed. Saved locally for retry.',
|
'message': 'Data submitted, but image upload failed. Saved locally for retry.',
|
||||||
@ -175,7 +185,7 @@ class AirSamplingService {
|
|||||||
|
|
||||||
debugPrint("Images uploaded successfully.");
|
debugPrint("Images uploaded successfully.");
|
||||||
data.status = 'S2'; // Server Pending (images uploaded)
|
data.status = 'S2'; // Server Pending (images uploaded)
|
||||||
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
|
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!, serverName: serverName);
|
||||||
_handleInstallationSuccessAlert(data, appSettings, isDataOnly: false);
|
_handleInstallationSuccessAlert(data, appSettings, isDataOnly: false);
|
||||||
return {
|
return {
|
||||||
'status': 'S2',
|
'status': 'S2',
|
||||||
@ -186,6 +196,10 @@ class AirSamplingService {
|
|||||||
/// Submits only the collection data, linked to a previous installation.
|
/// Submits only the collection data, linked to a previous installation.
|
||||||
// MODIFIED: Method now requires the appSettings list to pass down the call stack.
|
// MODIFIED: Method now requires the appSettings list to pass down the call stack.
|
||||||
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 {
|
||||||
|
// --- MODIFIED: Get the active server name to use for local storage ---
|
||||||
|
final activeConfig = await _serverConfigService.getActiveApiConfig();
|
||||||
|
final serverName = activeConfig?['config_name'] as String? ?? 'Default';
|
||||||
|
|
||||||
// --- OFFLINE-FIRST HELPER (CORRECTED) ---
|
// --- OFFLINE-FIRST HELPER (CORRECTED) ---
|
||||||
Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async {
|
Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async {
|
||||||
debugPrint("Saving collection data locally with status: $newStatus");
|
debugPrint("Saving collection data locally with status: $newStatus");
|
||||||
@ -197,7 +211,7 @@ class AirSamplingService {
|
|||||||
// FIX: Nest collection data to prevent overwriting installation fields.
|
// FIX: Nest collection data to prevent overwriting installation fields.
|
||||||
installationLog['collectionData'] = data.toMap();
|
installationLog['collectionData'] = data.toMap();
|
||||||
installationLog['status'] = newStatus; // Update the overall status
|
installationLog['status'] = newStatus; // Update the overall status
|
||||||
await _localStorageService.saveAirSamplingRecord(installationLog, data.installationRefID!);
|
await _localStorageService.saveAirSamplingRecord(installationLog, data.installationRefID!, serverName: serverName);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
'status': newStatus,
|
'status': newStatus,
|
||||||
@ -208,7 +222,7 @@ class AirSamplingService {
|
|||||||
// If the record's text data is already on the server, skip directly to image upload.
|
// If the record's text data is already on the server, skip directly to image upload.
|
||||||
if (data.status == 'L4_PENDING_IMAGES' && data.airManId != null) {
|
if (data.status == 'L4_PENDING_IMAGES' && data.airManId != null) {
|
||||||
debugPrint("Retrying collection image upload for existing record ID: ${data.airManId}");
|
debugPrint("Retrying collection image upload for existing record ID: ${data.airManId}");
|
||||||
return await _uploadCollectionImagesAndUpdate(data, installationData, appSettings);
|
return await _uploadCollectionImagesAndUpdate(data, installationData, appSettings, serverName: serverName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- STEP 1: SUBMIT TEXT DATA ---
|
// --- STEP 1: SUBMIT TEXT DATA ---
|
||||||
@ -222,13 +236,13 @@ class AirSamplingService {
|
|||||||
debugPrint("Collection text data submitted successfully.");
|
debugPrint("Collection text data submitted successfully.");
|
||||||
|
|
||||||
// --- STEP 2: UPLOAD IMAGE FILES ---
|
// --- STEP 2: UPLOAD IMAGE FILES ---
|
||||||
return await _uploadCollectionImagesAndUpdate(data, installationData, appSettings);
|
return await _uploadCollectionImagesAndUpdate(data, installationData, appSettings, serverName: serverName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A reusable function for handling the collection image upload and local data update logic.
|
/// A reusable function for handling the collection image upload and local data update logic.
|
||||||
// MODIFIED: Method now requires the appSettings list to pass to the alert handler.
|
// MODIFIED: Method now requires the serverName to pass to the save method.
|
||||||
Future<Map<String, dynamic>> _uploadCollectionImagesAndUpdate(AirCollectionData data, AirInstallationData installationData, List<Map<String, dynamic>>? appSettings) async {
|
Future<Map<String, dynamic>> _uploadCollectionImagesAndUpdate(AirCollectionData data, AirInstallationData installationData, List<Map<String, dynamic>>? appSettings, {required String serverName}) async {
|
||||||
// --- OFFLINE-FIRST HELPER (CORRECTED) ---
|
// --- OFFLINE-FIRST HELPER (CORRECTED & MODIFIED) ---
|
||||||
Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async {
|
Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async {
|
||||||
debugPrint("Saving collection data locally with status: $newStatus");
|
debugPrint("Saving collection data locally with status: $newStatus");
|
||||||
final allLogs = await _localStorageService.getAllAirSamplingLogs();
|
final allLogs = await _localStorageService.getAllAirSamplingLogs();
|
||||||
@ -238,7 +252,7 @@ class AirSamplingService {
|
|||||||
final installationLog = allLogs[logIndex];
|
final installationLog = allLogs[logIndex];
|
||||||
installationLog['collectionData'] = data.toMap();
|
installationLog['collectionData'] = data.toMap();
|
||||||
installationLog['status'] = newStatus;
|
installationLog['status'] = newStatus;
|
||||||
await _localStorageService.saveAirSamplingRecord(installationLog, data.installationRefID!);
|
await _localStorageService.saveAirSamplingRecord(installationLog, data.installationRefID!, serverName: serverName);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
'status': newStatus,
|
'status': newStatus,
|
||||||
|
|||||||
@ -154,6 +154,9 @@ class ApiService {
|
|||||||
'states': {'endpoint': 'states', 'handler': (d, id) async { await _dbHelper.upsertStates(d); await _dbHelper.deleteStates(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); }},
|
'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); }},
|
'parameterLimits': {'endpoint': 'parameter-limits', 'handler': (d, id) async { await _dbHelper.upsertParameterLimits(d); await _dbHelper.deleteParameterLimits(id); }},
|
||||||
|
// --- ADDED: New sync tasks for independent API and FTP configurations ---
|
||||||
|
'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
|
||||||
@ -274,7 +277,7 @@ class DatabaseHelper {
|
|||||||
static Database? _database;
|
static Database? _database;
|
||||||
static const String _dbName = 'app_data.db';
|
static const String _dbName = 'app_data.db';
|
||||||
// Incremented DB version to trigger the onUpgrade method
|
// Incremented DB version to trigger the onUpgrade method
|
||||||
static const int _dbVersion = 13;
|
static const int _dbVersion = 17;
|
||||||
|
|
||||||
static const String _profileTable = 'user_profile';
|
static const String _profileTable = 'user_profile';
|
||||||
static const String _usersTable = 'all_users';
|
static const String _usersTable = 'all_users';
|
||||||
@ -293,6 +296,12 @@ class DatabaseHelper {
|
|||||||
// Added new table constants
|
// Added new table constants
|
||||||
static const String _appSettingsTable = 'app_settings';
|
static const String _appSettingsTable = 'app_settings';
|
||||||
static const String _parameterLimitsTable = 'manual_parameter_limits';
|
static const String _parameterLimitsTable = 'manual_parameter_limits';
|
||||||
|
// --- ADDED: New tables for independent API and FTP configurations ---
|
||||||
|
static const String _apiConfigsTable = 'api_configurations';
|
||||||
|
static const String _ftpConfigsTable = 'ftp_configurations';
|
||||||
|
// --- ADDED: New table for the manual retry queue ---
|
||||||
|
static const String _retryQueueTable = 'retry_queue';
|
||||||
|
|
||||||
|
|
||||||
Future<Database> get database async {
|
Future<Database> get database async {
|
||||||
if (_database != null) return _database!;
|
if (_database != null) return _database!;
|
||||||
@ -323,6 +332,20 @@ class DatabaseHelper {
|
|||||||
// Added create statements for new tables
|
// Added create statements for new tables
|
||||||
await db.execute('CREATE TABLE $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)');
|
await db.execute('CREATE TABLE $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)');
|
||||||
await db.execute('CREATE TABLE $_parameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)');
|
await db.execute('CREATE TABLE $_parameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)');
|
||||||
|
// --- ADDED: Create statements for new configuration tables ---
|
||||||
|
await db.execute('CREATE TABLE $_apiConfigsTable(api_config_id INTEGER PRIMARY KEY, config_json TEXT)');
|
||||||
|
await db.execute('CREATE TABLE $_ftpConfigsTable(ftp_config_id INTEGER PRIMARY KEY, config_json TEXT)');
|
||||||
|
// --- ADDED: Create statement for the new retry queue table ---
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE $_retryQueueTable(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
endpoint_or_path TEXT NOT NULL,
|
||||||
|
payload TEXT,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||||
@ -337,6 +360,24 @@ class DatabaseHelper {
|
|||||||
await db.execute('CREATE TABLE IF NOT EXISTS $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)');
|
await db.execute('CREATE TABLE IF NOT EXISTS $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)');
|
||||||
await db.execute('CREATE TABLE IF NOT EXISTS $_parameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)');
|
await db.execute('CREATE TABLE IF NOT EXISTS $_parameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)');
|
||||||
}
|
}
|
||||||
|
// --- ADDED: Upgrade logic for new configuration tables ---
|
||||||
|
if (oldVersion < 16) {
|
||||||
|
await db.execute('CREATE TABLE IF NOT EXISTS $_apiConfigsTable(api_config_id INTEGER PRIMARY KEY, config_json TEXT)');
|
||||||
|
await db.execute('CREATE TABLE IF NOT EXISTS $_ftpConfigsTable(ftp_config_id INTEGER PRIMARY KEY, config_json TEXT)');
|
||||||
|
}
|
||||||
|
// --- ADDED: Upgrade logic for the new retry queue table ---
|
||||||
|
if (oldVersion < 17) {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS $_retryQueueTable(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
endpoint_or_path TEXT NOT NULL,
|
||||||
|
payload TEXT,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs an "upsert": inserts new records or replaces existing ones.
|
/// Performs an "upsert": inserts new records or replaces existing ones.
|
||||||
@ -445,4 +486,35 @@ class DatabaseHelper {
|
|||||||
Future<void> upsertParameterLimits(List<Map<String, dynamic>> data) => _upsertData(_parameterLimitsTable, 'param_autoid', data, 'limit');
|
Future<void> upsertParameterLimits(List<Map<String, dynamic>> data) => _upsertData(_parameterLimitsTable, 'param_autoid', data, 'limit');
|
||||||
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> deleteApiConfigs(List<dynamic> ids) => _deleteData(_apiConfigsTable, 'api_config_id', ids);
|
||||||
|
Future<List<Map<String, dynamic>>?> loadApiConfigs() => _loadData(_apiConfigsTable, 'config');
|
||||||
|
|
||||||
|
Future<void> upsertFtpConfigs(List<Map<String, dynamic>> data) => _upsertData(_ftpConfigsTable, 'ftp_config_id', data, 'config');
|
||||||
|
Future<void> deleteFtpConfigs(List<dynamic> ids) => _deleteData(_ftpConfigsTable, 'ftp_config_id', ids);
|
||||||
|
Future<List<Map<String, dynamic>>?> loadFtpConfigs() => _loadData(_ftpConfigsTable, 'config');
|
||||||
|
|
||||||
|
// --- ADDED: Methods for the new retry queue ---
|
||||||
|
Future<int> queueFailedRequest(Map<String, dynamic> data) async {
|
||||||
|
final db = await database;
|
||||||
|
return await db.insert(_retryQueueTable, data, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getPendingRequests() async {
|
||||||
|
final db = await database;
|
||||||
|
return await db.query(_retryQueueTable, where: 'status = ?', whereArgs: ['pending']);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> getRequestById(int id) async {
|
||||||
|
final db = await database;
|
||||||
|
final results = await db.query(_retryQueueTable, where: 'id = ?', whereArgs: [id]);
|
||||||
|
return results.isNotEmpty ? results.first : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteRequestFromQueue(int id) async {
|
||||||
|
final db = await database;
|
||||||
|
await db.delete(_retryQueueTable, where: 'id = ?', whereArgs: [id]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,13 +1,24 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:async/async.dart'; // Used for TimeoutException
|
||||||
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';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
import 'package:environment_monitoring_app/auth_provider.dart';
|
import 'package:environment_monitoring_app/auth_provider.dart';
|
||||||
|
// --- ADDED: Import for the service that manages active server configurations ---
|
||||||
|
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';
|
||||||
|
|
||||||
|
|
||||||
class BaseApiService {
|
class BaseApiService {
|
||||||
final String _baseUrl = 'https://dev14.pstw.com.my/v1';
|
// --- ADDED: An instance of the service to get the active URL dynamically ---
|
||||||
|
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||||
|
|
||||||
|
// --- REMOVED: This creates an infinite loop with RetryService ---
|
||||||
|
// final RetryService _retryService = RetryService();
|
||||||
|
|
||||||
|
|
||||||
// Private helper to construct headers with the auth token
|
// Private helper to construct headers with the auth token
|
||||||
Future<Map<String, String>> _getHeaders() async {
|
Future<Map<String, String>> _getHeaders() async {
|
||||||
@ -29,29 +40,41 @@ class BaseApiService {
|
|||||||
|
|
||||||
// Generic GET request handler
|
// Generic GET request handler
|
||||||
Future<Map<String, dynamic>> get(String endpoint) async {
|
Future<Map<String, dynamic>> get(String endpoint) async {
|
||||||
final url = Uri.parse('$_baseUrl/$endpoint');
|
|
||||||
try {
|
try {
|
||||||
final response = await http.get(url, headers: await _getJsonHeaders());
|
// --- MODIFIED: Fetches the active base URL before making the request ---
|
||||||
|
final baseUrl = await _serverConfigService.getActiveApiUrl();
|
||||||
|
final url = Uri.parse('$baseUrl/$endpoint');
|
||||||
|
final response = await http.get(url, headers: await _getJsonHeaders())
|
||||||
|
.timeout(const Duration(seconds: 60)); // --- MODIFIED: Added 60 second timeout ---
|
||||||
return _handleResponse(response);
|
return _handleResponse(response);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('GET request failed: $e');
|
debugPrint('GET request failed: $e');
|
||||||
return {'success': false, 'message': 'Network error: $e'};
|
return {'success': false, 'message': 'Network error or timeout: $e'};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic POST request handler for JSON data
|
// Generic POST request handler for JSON data
|
||||||
Future<Map<String, dynamic>> post(String endpoint, Map<String, dynamic> body) async {
|
Future<Map<String, dynamic>> post(String endpoint, Map<String, dynamic> body) async {
|
||||||
final url = Uri.parse('$_baseUrl/$endpoint');
|
|
||||||
try {
|
try {
|
||||||
|
// --- MODIFIED: Fetches the active base URL before making the request ---
|
||||||
|
final baseUrl = await _serverConfigService.getActiveApiUrl();
|
||||||
|
final url = Uri.parse('$baseUrl/$endpoint');
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: await _getJsonHeaders(),
|
headers: await _getJsonHeaders(),
|
||||||
body: jsonEncode(body),
|
body: jsonEncode(body),
|
||||||
);
|
).timeout(const Duration(seconds: 60)); // --- MODIFIED: Added 60 second timeout ---
|
||||||
return _handleResponse(response);
|
return _handleResponse(response);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('POST request failed: $e');
|
// --- MODIFIED: Create a local instance of RetryService to break the circular dependency ---
|
||||||
return {'success': false, 'message': 'Network error: $e'};
|
final retryService = RetryService();
|
||||||
|
debugPrint('POST request to $endpoint failed, queueing for retry. Error: $e');
|
||||||
|
await retryService.addApiToQueue(
|
||||||
|
endpoint: endpoint,
|
||||||
|
method: 'POST',
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
return {'success': false, 'message': 'Request failed and has been queued for manual retry.'};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,10 +84,12 @@ class BaseApiService {
|
|||||||
required Map<String, String> fields,
|
required Map<String, String> fields,
|
||||||
required Map<String, File> files,
|
required Map<String, File> files,
|
||||||
}) async {
|
}) async {
|
||||||
final url = Uri.parse('$_baseUrl/$endpoint');
|
|
||||||
debugPrint('Starting multipart upload to: $url');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// --- MODIFIED: Fetches the active base URL before making the request ---
|
||||||
|
final baseUrl = await _serverConfigService.getActiveApiUrl();
|
||||||
|
final url = Uri.parse('$baseUrl/$endpoint');
|
||||||
|
debugPrint('Starting multipart upload to: $url');
|
||||||
|
|
||||||
var request = http.MultipartRequest('POST', url);
|
var request = http.MultipartRequest('POST', url);
|
||||||
|
|
||||||
// Get and add headers (Authorization token)
|
// Get and add headers (Authorization token)
|
||||||
@ -92,7 +117,8 @@ class BaseApiService {
|
|||||||
debugPrint('${files.length} files added to the request.');
|
debugPrint('${files.length} files added to the request.');
|
||||||
|
|
||||||
debugPrint('Sending multipart request...');
|
debugPrint('Sending multipart request...');
|
||||||
var streamedResponse = await request.send();
|
// --- MODIFIED: Added 60 second timeout ---
|
||||||
|
var streamedResponse = await request.send().timeout(const Duration(seconds: 60));
|
||||||
debugPrint('Received response with status code: ${streamedResponse.statusCode}');
|
debugPrint('Received response with status code: ${streamedResponse.statusCode}');
|
||||||
|
|
||||||
final responseBody = await streamedResponse.stream.bytesToString();
|
final responseBody = await streamedResponse.stream.bytesToString();
|
||||||
@ -100,12 +126,17 @@ class BaseApiService {
|
|||||||
return _handleResponse(http.Response(responseBody, streamedResponse.statusCode));
|
return _handleResponse(http.Response(responseBody, streamedResponse.statusCode));
|
||||||
|
|
||||||
} catch (e, s) { // Catching both Exception and Error (e.g., OutOfMemoryError)
|
} catch (e, s) { // Catching both Exception and Error (e.g., OutOfMemoryError)
|
||||||
debugPrint('FATAL: An error occurred during multipart upload: $e');
|
// --- MODIFIED: Create a local instance of RetryService to break the circular dependency ---
|
||||||
|
final retryService = RetryService();
|
||||||
|
debugPrint('Multipart request to $endpoint failed, queueing for retry. Error: $e');
|
||||||
debugPrint('Stack trace: $s');
|
debugPrint('Stack trace: $s');
|
||||||
return {
|
await retryService.addApiToQueue(
|
||||||
'success': false,
|
endpoint: endpoint,
|
||||||
'message': 'Upload failed due to a critical error. This might be caused by low device memory or a network issue.'
|
method: 'POST_MULTIPART',
|
||||||
};
|
fields: fields,
|
||||||
|
files: files,
|
||||||
|
);
|
||||||
|
return {'success': false, 'message': 'Upload failed and has been queued for manual retry.'};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
79
lib/services/ftp_service.dart
Normal file
79
lib/services/ftp_service.dart
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
// lib/services/ftp_service.dart
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:ftpconnect/ftpconnect.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';
|
||||||
|
|
||||||
|
class FtpService {
|
||||||
|
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||||
|
// --- REMOVED: This creates an infinite loop with RetryService ---
|
||||||
|
// final RetryService _retryService = RetryService();
|
||||||
|
|
||||||
|
/// Uploads a single file to the active server's FTP.
|
||||||
|
///
|
||||||
|
/// [fileToUpload] The local file to be uploaded.
|
||||||
|
/// [remotePath] The destination path on the FTP server (e.g., '/uploads/images/').
|
||||||
|
/// Returns a map with 'success' and 'message' keys.
|
||||||
|
Future<Map<String, dynamic>> uploadFile(File fileToUpload, String remotePath) async {
|
||||||
|
final config = await _serverConfigService.getActiveFtpConfig();
|
||||||
|
|
||||||
|
if (config == null || config['ftp_host'] == null || config['ftp_user'] == null || config['ftp_pass'] == null) {
|
||||||
|
return {'success': false, 'message': 'FTP credentials are not configured or selected.'};
|
||||||
|
}
|
||||||
|
|
||||||
|
final ftpHost = config['ftp_host'] as String;
|
||||||
|
final ftpUser = config['ftp_user'] as String;
|
||||||
|
final ftpPass = config['ftp_pass'] as String;
|
||||||
|
final ftpPort = config['ftp_port'] as int? ?? 21; // Default to port 21
|
||||||
|
|
||||||
|
final ftpConnect = FTPConnect(
|
||||||
|
ftpHost,
|
||||||
|
user: ftpUser,
|
||||||
|
pass: ftpPass,
|
||||||
|
port: ftpPort,
|
||||||
|
showLog: kDebugMode, // Show logs only in debug mode
|
||||||
|
timeout: 60, // --- MODIFIED: Set the timeout to 60 seconds ---
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
debugPrint('FTP: Connecting to $ftpHost...');
|
||||||
|
await ftpConnect.connect();
|
||||||
|
|
||||||
|
debugPrint('FTP: Uploading file ${fileToUpload.path} to $remotePath...');
|
||||||
|
bool res = await ftpConnect.uploadFileWithRetry(
|
||||||
|
fileToUpload,
|
||||||
|
pRemoteName: remotePath,
|
||||||
|
pRetryCount: 3, // --- MODIFIED: Retry three times on failure ---
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
return {'success': true, 'message': 'File uploaded successfully via FTP.'};
|
||||||
|
} else {
|
||||||
|
// --- MODIFIED: Create a local instance of RetryService to break the circular dependency ---
|
||||||
|
final retryService = RetryService();
|
||||||
|
debugPrint('FTP upload for ${fileToUpload.path} failed after retries, queueing.');
|
||||||
|
await retryService.addFtpToQueue(
|
||||||
|
localFilePath: fileToUpload.path,
|
||||||
|
remotePath: remotePath
|
||||||
|
);
|
||||||
|
return {'success': false, 'message': 'FTP upload failed and has been queued for manual retry.'};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// --- MODIFIED: Create a local instance of RetryService to break the circular dependency ---
|
||||||
|
final retryService = RetryService();
|
||||||
|
debugPrint('FTP upload for ${fileToUpload.path} failed with an exception, queueing. Error: $e');
|
||||||
|
await retryService.addFtpToQueue(
|
||||||
|
localFilePath: fileToUpload.path,
|
||||||
|
remotePath: remotePath
|
||||||
|
);
|
||||||
|
return {'success': false, 'message': 'FTP upload failed and has been queued for manual retry.'};
|
||||||
|
} finally {
|
||||||
|
// Always ensure disconnection, even if an error occurs.
|
||||||
|
debugPrint('FTP: Disconnecting...');
|
||||||
|
await ftpConnect.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,12 +25,15 @@ class LocalStorageService {
|
|||||||
return status.isGranted;
|
return status.isGranted;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Directory?> _getPublicMMSV4Directory() async {
|
// --- MODIFIED: This method now accepts a serverName to create a server-specific root directory. ---
|
||||||
|
Future<Directory?> _getPublicMMSV4Directory({required String serverName}) async {
|
||||||
if (await _requestPermissions()) {
|
if (await _requestPermissions()) {
|
||||||
final Directory? externalDir = await getExternalStorageDirectory();
|
final Directory? externalDir = await getExternalStorageDirectory();
|
||||||
if (externalDir != null) {
|
if (externalDir != null) {
|
||||||
final publicRootPath = externalDir.path.split('/Android/')[0];
|
final publicRootPath = externalDir.path.split('/Android/')[0];
|
||||||
final mmsv4Dir = Directory(p.join(publicRootPath, 'MMSV4'));
|
// Create a subdirectory for the specific server configuration.
|
||||||
|
// If serverName is empty, it returns the root MMSV4 folder.
|
||||||
|
final mmsv4Dir = Directory(p.join(publicRootPath, 'MMSV4', serverName));
|
||||||
if (!await mmsv4Dir.exists()) {
|
if (!await mmsv4Dir.exists()) {
|
||||||
await mmsv4Dir.create(recursive: true);
|
await mmsv4Dir.create(recursive: true);
|
||||||
}
|
}
|
||||||
@ -45,8 +48,9 @@ class LocalStorageService {
|
|||||||
// Part 2: Air Manual Sampling Methods
|
// Part 2: Air Manual Sampling Methods
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
Future<Directory?> _getAirManualBaseDir() async {
|
// --- MODIFIED: Method now requires serverName to get the correct base directory. ---
|
||||||
final mmsv4Dir = await _getPublicMMSV4Directory();
|
Future<Directory?> _getAirManualBaseDir({required String serverName}) async {
|
||||||
|
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
|
||||||
if (mmsv4Dir == null) return null;
|
if (mmsv4Dir == null) return null;
|
||||||
|
|
||||||
final airDir = Directory(p.join(mmsv4Dir.path, 'air', 'air_manual_sampling'));
|
final airDir = Directory(p.join(mmsv4Dir.path, 'air', 'air_manual_sampling'));
|
||||||
@ -57,10 +61,9 @@ class LocalStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Saves or updates an air sampling record, including copying all associated images to permanent local storage.
|
/// Saves or updates an air sampling record, including copying all associated images to permanent local storage.
|
||||||
/// CORRECTED: This now robustly handles maps with File objects from both installation and collection,
|
// --- MODIFIED: Method now requires serverName. ---
|
||||||
/// preventing type errors that caused the installation screen to freeze.
|
Future<String?> saveAirSamplingRecord(Map<String, dynamic> data, String refID, {required String serverName}) async {
|
||||||
Future<String?> saveAirSamplingRecord(Map<String, dynamic> data, String refID) async {
|
final baseDir = await _getAirManualBaseDir(serverName: serverName);
|
||||||
final baseDir = await _getAirManualBaseDir();
|
|
||||||
if (baseDir == null) {
|
if (baseDir == null) {
|
||||||
debugPrint("Could not get public storage directory for Air Manual. Check permissions.");
|
debugPrint("Could not get public storage directory for Air Manual. Check permissions.");
|
||||||
return null;
|
return null;
|
||||||
@ -91,6 +94,9 @@ class LocalStorageService {
|
|||||||
|
|
||||||
// Create a mutable copy of the data map to avoid modifying the original
|
// Create a mutable copy of the data map to avoid modifying the original
|
||||||
final Map<String, dynamic> serializableData = Map.from(data);
|
final Map<String, dynamic> serializableData = Map.from(data);
|
||||||
|
// --- MODIFIED: Inject the server name into the data being saved. ---
|
||||||
|
serializableData['serverConfigName'] = serverName;
|
||||||
|
|
||||||
|
|
||||||
// Define the keys for installation images to look for in the map
|
// Define the keys for installation images to look for in the map
|
||||||
final installationImageKeys = ['imageFront', 'imageBack', 'imageLeft', 'imageRight', 'optionalImage1', 'optionalImage2', 'optionalImage3', 'optionalImage4'];
|
final installationImageKeys = ['imageFront', 'imageBack', 'imageLeft', 'imageRight', 'optionalImage1', 'optionalImage2', 'optionalImage3', 'optionalImage4'];
|
||||||
@ -101,7 +107,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.
|
||||||
// ** THE FIX **: Only remove the key if it was a File object that we have processed.
|
|
||||||
serializableData.remove(key);
|
serializableData.remove(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -112,19 +117,15 @@ class LocalStorageService {
|
|||||||
final collectionImageKeys = ['imageFront', 'imageBack', 'imageLeft', 'imageRight', 'imageChart', 'imageFilterPaper', 'optionalImage1', 'optionalImage2', 'optionalImage3', 'optionalImage4'];
|
final collectionImageKeys = ['imageFront', 'imageBack', 'imageLeft', 'imageRight', 'imageChart', 'imageFilterPaper', 'optionalImage1', 'optionalImage2', 'optionalImage3', 'optionalImage4'];
|
||||||
|
|
||||||
for (final key in collectionImageKeys) {
|
for (final key in collectionImageKeys) {
|
||||||
// Check if the key exists and the value is a File object
|
|
||||||
if (collectionMap.containsKey(key) && collectionMap[key] is File) {
|
if (collectionMap.containsKey(key) && collectionMap[key] is File) {
|
||||||
final newPath = await copyImageToLocal(collectionMap[key]);
|
final newPath = await copyImageToLocal(collectionMap[key]);
|
||||||
collectionMap['${key}Path'] = newPath;
|
collectionMap['${key}Path'] = newPath;
|
||||||
// ** THE FIX **: Only remove the key if it was a File object that we have processed.
|
|
||||||
collectionMap.remove(key);
|
collectionMap.remove(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Put the cleaned, serializable collection map back into the main data object
|
|
||||||
serializableData['collectionData'] = collectionMap;
|
serializableData['collectionData'] = collectionMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now that all File objects have been replaced with String paths, save the clean data.
|
|
||||||
final jsonFile = File(p.join(eventDir.path, 'data.json'));
|
final jsonFile = File(p.join(eventDir.path, 'data.json'));
|
||||||
await jsonFile.writeAsString(jsonEncode(serializableData));
|
await jsonFile.writeAsString(jsonEncode(serializableData));
|
||||||
debugPrint("Air sampling log and images saved to: ${eventDir.path}");
|
debugPrint("Air sampling log and images saved to: ${eventDir.path}");
|
||||||
@ -138,39 +139,44 @@ class LocalStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MODIFIED: This method now scans all server subdirectories to find all logs. ---
|
||||||
Future<List<Map<String, dynamic>>> getAllAirSamplingLogs() async {
|
Future<List<Map<String, dynamic>>> getAllAirSamplingLogs() async {
|
||||||
final baseDir = await _getAirManualBaseDir();
|
final mmsv4Root = await _getPublicMMSV4Directory(serverName: ''); // Get root MMSV4 without a server subfolder
|
||||||
if (baseDir == null || !await baseDir.exists()) return [];
|
if (mmsv4Root == null || !await mmsv4Root.exists()) return [];
|
||||||
|
|
||||||
try {
|
final List<Map<String, dynamic>> allLogs = [];
|
||||||
final List<Map<String, dynamic>> logs = [];
|
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
|
||||||
final entities = baseDir.listSync();
|
|
||||||
|
|
||||||
for (var entity in entities) {
|
for (var serverDir in serverDirs) {
|
||||||
if (entity is Directory) {
|
final baseDir = Directory(p.join(serverDir.path, 'air', 'air_manual_sampling'));
|
||||||
final jsonFile = File(p.join(entity.path, 'data.json'));
|
if (!await baseDir.exists()) continue;
|
||||||
if (await jsonFile.exists()) {
|
|
||||||
final content = await jsonFile.readAsString();
|
try {
|
||||||
final data = jsonDecode(content) as Map<String, dynamic>;
|
final entities = baseDir.listSync();
|
||||||
data['logDirectory'] = entity.path;
|
for (var entity in entities) {
|
||||||
logs.add(data);
|
if (entity is Directory) {
|
||||||
|
final jsonFile = File(p.join(entity.path, 'data.json'));
|
||||||
|
if (await jsonFile.exists()) {
|
||||||
|
final content = await jsonFile.readAsString();
|
||||||
|
final data = jsonDecode(content) as Map<String, dynamic>;
|
||||||
|
data['logDirectory'] = entity.path;
|
||||||
|
allLogs.add(data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error reading air logs from ${baseDir.path}: $e");
|
||||||
}
|
}
|
||||||
return logs;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("Error getting all air sampling logs: $e");
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
return allLogs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
// Part 3: Tarball Specific Methods
|
// Part 3: Tarball Specific Methods
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
Future<Directory?> _getTarballBaseDir() async {
|
Future<Directory?> _getTarballBaseDir({required String serverName}) async {
|
||||||
final mmsv4Dir = await _getPublicMMSV4Directory();
|
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
|
||||||
if (mmsv4Dir == null) return null;
|
if (mmsv4Dir == null) return null;
|
||||||
|
|
||||||
final tarballDir = Directory(p.join(mmsv4Dir.path, 'marine', 'marine_tarball_sampling'));
|
final tarballDir = Directory(p.join(mmsv4Dir.path, 'marine', 'marine_tarball_sampling'));
|
||||||
@ -180,8 +186,8 @@ class LocalStorageService {
|
|||||||
return tarballDir;
|
return tarballDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> saveTarballSamplingData(TarballSamplingData data) async {
|
Future<String?> saveTarballSamplingData(TarballSamplingData data, {required String serverName}) async {
|
||||||
final baseDir = await _getTarballBaseDir();
|
final baseDir = await _getTarballBaseDir(serverName: serverName);
|
||||||
if (baseDir == null) {
|
if (baseDir == null) {
|
||||||
debugPrint("Could not get public storage directory. Check permissions.");
|
debugPrint("Could not get public storage directory. Check permissions.");
|
||||||
return null;
|
return null;
|
||||||
@ -198,11 +204,10 @@ class LocalStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final Map<String, dynamic> jsonData = { ...data.toFormData(), 'submissionStatus': data.submissionStatus, 'submissionMessage': data.submissionMessage, 'reportId': data.reportId };
|
final Map<String, dynamic> jsonData = { ...data.toFormData(), 'submissionStatus': data.submissionStatus, 'submissionMessage': data.submissionMessage, 'reportId': data.reportId };
|
||||||
|
jsonData['serverConfigName'] = serverName;
|
||||||
jsonData['selectedStation'] = data.selectedStation;
|
jsonData['selectedStation'] = data.selectedStation;
|
||||||
|
|
||||||
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) {
|
||||||
@ -225,29 +230,33 @@ class LocalStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Map<String, dynamic>>> getAllTarballLogs() async {
|
Future<List<Map<String, dynamic>>> getAllTarballLogs() async {
|
||||||
final baseDir = await _getTarballBaseDir();
|
final mmsv4Root = await _getPublicMMSV4Directory(serverName: '');
|
||||||
if (baseDir == null || !await baseDir.exists()) return [];
|
if (mmsv4Root == null || !await mmsv4Root.exists()) return [];
|
||||||
|
|
||||||
try {
|
final List<Map<String, dynamic>> allLogs = [];
|
||||||
final List<Map<String, dynamic>> logs = [];
|
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
|
||||||
final entities = baseDir.listSync();
|
|
||||||
|
|
||||||
for (var entity in entities) {
|
for (var serverDir in serverDirs) {
|
||||||
if (entity is Directory) {
|
final baseDir = Directory(p.join(serverDir.path, 'marine', 'marine_tarball_sampling'));
|
||||||
final jsonFile = File(p.join(entity.path, 'data.json'));
|
if (!await baseDir.exists()) continue;
|
||||||
if (await jsonFile.exists()) {
|
try {
|
||||||
final content = await jsonFile.readAsString();
|
final entities = baseDir.listSync();
|
||||||
final data = jsonDecode(content) as Map<String, dynamic>;
|
for (var entity in entities) {
|
||||||
data['logDirectory'] = entity.path;
|
if (entity is Directory) {
|
||||||
logs.add(data);
|
final jsonFile = File(p.join(entity.path, 'data.json'));
|
||||||
|
if (await jsonFile.exists()) {
|
||||||
|
final content = await jsonFile.readAsString();
|
||||||
|
final data = jsonDecode(content) as Map<String, dynamic>;
|
||||||
|
data['logDirectory'] = entity.path;
|
||||||
|
allLogs.add(data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error reading tarball logs from ${baseDir.path}: $e");
|
||||||
}
|
}
|
||||||
return logs;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("Error getting all tarball logs: $e");
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
return allLogs;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateTarballLog(Map<String, dynamic> updatedLogData) async {
|
Future<void> updateTarballLog(Map<String, dynamic> updatedLogData) async {
|
||||||
@ -274,8 +283,8 @@ class LocalStorageService {
|
|||||||
// Part 4: Marine In-Situ Specific Methods
|
// Part 4: Marine In-Situ Specific Methods
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
Future<Directory?> _getInSituBaseDir() async {
|
Future<Directory?> _getInSituBaseDir({required String serverName}) async {
|
||||||
final mmsv4Dir = await _getPublicMMSV4Directory();
|
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
|
||||||
if (mmsv4Dir == null) return null;
|
if (mmsv4Dir == null) return null;
|
||||||
|
|
||||||
final inSituDir = Directory(p.join(mmsv4Dir.path, 'marine', 'marine_in_situ_sampling'));
|
final inSituDir = Directory(p.join(mmsv4Dir.path, 'marine', 'marine_in_situ_sampling'));
|
||||||
@ -285,8 +294,8 @@ class LocalStorageService {
|
|||||||
return inSituDir;
|
return inSituDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> saveInSituSamplingData(InSituSamplingData data) async {
|
Future<String?> saveInSituSamplingData(InSituSamplingData data, {required String serverName}) async {
|
||||||
final baseDir = await _getInSituBaseDir();
|
final baseDir = await _getInSituBaseDir(serverName: serverName);
|
||||||
if (baseDir == null) {
|
if (baseDir == null) {
|
||||||
debugPrint("Could not get public storage directory for In-Situ. Check permissions.");
|
debugPrint("Could not get public storage directory for In-Situ. Check permissions.");
|
||||||
return null;
|
return null;
|
||||||
@ -303,7 +312,7 @@ class LocalStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final Map<String, dynamic> jsonData = { ...data.toApiFormData(), 'submissionStatus': data.submissionStatus, 'submissionMessage': data.submissionMessage, 'reportId': data.reportId };
|
final Map<String, dynamic> jsonData = { ...data.toApiFormData(), 'submissionStatus': data.submissionStatus, 'submissionMessage': data.submissionMessage, 'reportId': data.reportId };
|
||||||
|
jsonData['serverConfigName'] = serverName;
|
||||||
jsonData['selectedStation'] = data.selectedStation;
|
jsonData['selectedStation'] = data.selectedStation;
|
||||||
|
|
||||||
final imageFiles = data.toApiImageFiles();
|
final imageFiles = data.toApiImageFiles();
|
||||||
@ -329,29 +338,33 @@ class LocalStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Map<String, dynamic>>> getAllInSituLogs() async {
|
Future<List<Map<String, dynamic>>> getAllInSituLogs() async {
|
||||||
final baseDir = await _getInSituBaseDir();
|
final mmsv4Root = await _getPublicMMSV4Directory(serverName: '');
|
||||||
if (baseDir == null || !await baseDir.exists()) return [];
|
if (mmsv4Root == null || !await mmsv4Root.exists()) return [];
|
||||||
|
|
||||||
try {
|
final List<Map<String, dynamic>> allLogs = [];
|
||||||
final List<Map<String, dynamic>> logs = [];
|
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
|
||||||
final entities = baseDir.listSync();
|
|
||||||
|
|
||||||
for (var entity in entities) {
|
for (var serverDir in serverDirs) {
|
||||||
if (entity is Directory) {
|
final baseDir = Directory(p.join(serverDir.path, 'marine', 'marine_in_situ_sampling'));
|
||||||
final jsonFile = File(p.join(entity.path, 'data.json'));
|
if (!await baseDir.exists()) continue;
|
||||||
if (await jsonFile.exists()) {
|
try {
|
||||||
final content = await jsonFile.readAsString();
|
final entities = baseDir.listSync();
|
||||||
final data = jsonDecode(content) as Map<String, dynamic>;
|
for (var entity in entities) {
|
||||||
data['logDirectory'] = entity.path;
|
if (entity is Directory) {
|
||||||
logs.add(data);
|
final jsonFile = File(p.join(entity.path, 'data.json'));
|
||||||
|
if (await jsonFile.exists()) {
|
||||||
|
final content = await jsonFile.readAsString();
|
||||||
|
final data = jsonDecode(content) as Map<String, dynamic>;
|
||||||
|
data['logDirectory'] = entity.path;
|
||||||
|
allLogs.add(data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error reading in-situ logs from ${baseDir.path}: $e");
|
||||||
}
|
}
|
||||||
return logs;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("Error getting all in-situ logs: $e");
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
return allLogs;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateInSituLog(Map<String, dynamic> updatedLogData) async {
|
Future<void> updateInSituLog(Map<String, dynamic> updatedLogData) async {
|
||||||
@ -377,8 +390,8 @@ class LocalStorageService {
|
|||||||
// Part 5: River In-Situ Specific Methods
|
// Part 5: River In-Situ Specific Methods
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
Future<Directory?> _getRiverInSituBaseDir(String? samplingType) async {
|
Future<Directory?> _getRiverInSituBaseDir(String? samplingType, {required String serverName}) async {
|
||||||
final mmsv4Dir = await _getPublicMMSV4Directory();
|
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
|
||||||
if (mmsv4Dir == null) return null;
|
if (mmsv4Dir == null) return null;
|
||||||
|
|
||||||
String subfolderName;
|
String subfolderName;
|
||||||
@ -395,8 +408,8 @@ class LocalStorageService {
|
|||||||
return inSituDir;
|
return inSituDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> saveRiverInSituSamplingData(RiverInSituSamplingData data) async {
|
Future<String?> saveRiverInSituSamplingData(RiverInSituSamplingData data, {required String serverName}) async {
|
||||||
final baseDir = await _getRiverInSituBaseDir(data.samplingType);
|
final baseDir = await _getRiverInSituBaseDir(data.samplingType, serverName: serverName);
|
||||||
if (baseDir == null) {
|
if (baseDir == null) {
|
||||||
debugPrint("Could not get public storage directory for River In-Situ. Check permissions.");
|
debugPrint("Could not get public storage directory for River In-Situ. Check permissions.");
|
||||||
return null;
|
return null;
|
||||||
@ -413,7 +426,7 @@ class LocalStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final Map<String, dynamic> jsonData = { ...data.toApiFormData(), 'submissionStatus': data.submissionStatus, 'submissionMessage': data.submissionMessage, 'reportId': data.reportId };
|
final Map<String, dynamic> jsonData = { ...data.toApiFormData(), 'submissionStatus': data.submissionStatus, 'submissionMessage': data.submissionMessage, 'reportId': data.reportId };
|
||||||
|
jsonData['serverConfigName'] = serverName;
|
||||||
jsonData['selectedStation'] = data.selectedStation;
|
jsonData['selectedStation'] = data.selectedStation;
|
||||||
|
|
||||||
final imageFiles = data.toApiImageFiles();
|
final imageFiles = data.toApiImageFiles();
|
||||||
@ -439,37 +452,38 @@ class LocalStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Map<String, dynamic>>> getAllRiverInSituLogs() async {
|
Future<List<Map<String, dynamic>>> getAllRiverInSituLogs() async {
|
||||||
final mmsv4Dir = await _getPublicMMSV4Directory();
|
final mmsv4Root = await _getPublicMMSV4Directory(serverName: '');
|
||||||
if (mmsv4Dir == null) return [];
|
if (mmsv4Root == null || !await mmsv4Root.exists()) return [];
|
||||||
|
|
||||||
final topLevelDir = Directory(p.join(mmsv4Dir.path, 'river', 'river_in_situ_sampling'));
|
final List<Map<String, dynamic>> allLogs = [];
|
||||||
if (!await topLevelDir.exists()) return [];
|
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
|
||||||
|
|
||||||
try {
|
for (var serverDir in serverDirs) {
|
||||||
final List<Map<String, dynamic>> logs = [];
|
final topLevelDir = Directory(p.join(serverDir.path, 'river', 'river_in_situ_sampling'));
|
||||||
final typeSubfolders = topLevelDir.listSync();
|
if (!await topLevelDir.exists()) continue;
|
||||||
|
try {
|
||||||
for (var typeSubfolder in typeSubfolders) {
|
final typeSubfolders = topLevelDir.listSync();
|
||||||
if (typeSubfolder is Directory) {
|
for (var typeSubfolder in typeSubfolders) {
|
||||||
final eventFolders = typeSubfolder.listSync();
|
if (typeSubfolder is Directory) {
|
||||||
for (var eventFolder in eventFolders) {
|
final eventFolders = typeSubfolder.listSync();
|
||||||
if (eventFolder is Directory) {
|
for (var eventFolder in eventFolders) {
|
||||||
final jsonFile = File(p.join(eventFolder.path, 'data.json'));
|
if (eventFolder is Directory) {
|
||||||
if (await jsonFile.exists()) {
|
final jsonFile = File(p.join(eventFolder.path, 'data.json'));
|
||||||
final content = await jsonFile.readAsString();
|
if (await jsonFile.exists()) {
|
||||||
final data = jsonDecode(content) as Map<String, dynamic>;
|
final content = await jsonFile.readAsString();
|
||||||
data['logDirectory'] = eventFolder.path;
|
final data = jsonDecode(content) as Map<String, dynamic>;
|
||||||
logs.add(data);
|
data['logDirectory'] = eventFolder.path;
|
||||||
|
allLogs.add(data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error getting all river in-situ logs from ${topLevelDir.path}: $e");
|
||||||
}
|
}
|
||||||
return logs;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("Error getting all river in-situ logs: $e");
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
return allLogs;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateRiverInSituLog(Map<String, dynamic> updatedLogData) async {
|
Future<void> updateRiverInSituLog(Map<String, dynamic> updatedLogData) async {
|
||||||
|
|||||||
131
lib/services/retry_service.dart
Normal file
131
lib/services/retry_service.dart
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
// lib/services/retry_service.dart
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:environment_monitoring_app/services/api_service.dart';
|
||||||
|
import 'package:environment_monitoring_app/services/base_api_service.dart';
|
||||||
|
import 'package:environment_monitoring_app/services/ftp_service.dart';
|
||||||
|
|
||||||
|
/// A dedicated service to manage the queue of failed API and FTP requests
|
||||||
|
/// for manual resubmission.
|
||||||
|
class RetryService {
|
||||||
|
// Use singleton instances to avoid re-creating services unnecessarily.
|
||||||
|
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||||
|
final BaseApiService _baseApiService = BaseApiService();
|
||||||
|
final FtpService _ftpService = FtpService();
|
||||||
|
|
||||||
|
/// Adds a failed API request to the local database queue.
|
||||||
|
Future<void> addApiToQueue({
|
||||||
|
required String endpoint,
|
||||||
|
required String method, // e.g., 'POST' or 'POST_MULTIPART'
|
||||||
|
Map<String, dynamic>? body,
|
||||||
|
Map<String, String>? fields,
|
||||||
|
Map<String, File>? files,
|
||||||
|
}) 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 payload = {
|
||||||
|
'method': method,
|
||||||
|
'body': body,
|
||||||
|
'fields': fields,
|
||||||
|
'files': serializableFiles,
|
||||||
|
};
|
||||||
|
|
||||||
|
await _dbHelper.queueFailedRequest({
|
||||||
|
'type': 'api',
|
||||||
|
'endpoint_or_path': endpoint,
|
||||||
|
'payload': jsonEncode(payload),
|
||||||
|
'timestamp': DateTime.now().toIso8601String(),
|
||||||
|
'status': 'pending',
|
||||||
|
});
|
||||||
|
debugPrint("API request for endpoint '$endpoint' has been queued for retry.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a failed FTP upload to the local database queue.
|
||||||
|
Future<void> addFtpToQueue({
|
||||||
|
required String localFilePath,
|
||||||
|
required String remotePath,
|
||||||
|
}) async {
|
||||||
|
final payload = {'localFilePath': localFilePath};
|
||||||
|
await _dbHelper.queueFailedRequest({
|
||||||
|
'type': 'ftp',
|
||||||
|
'endpoint_or_path': remotePath,
|
||||||
|
'payload': jsonEncode(payload),
|
||||||
|
'timestamp': DateTime.now().toIso8601String(),
|
||||||
|
'status': 'pending',
|
||||||
|
});
|
||||||
|
debugPrint("FTP upload for file '$localFilePath' has been queued for retry.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves all tasks currently in the 'pending' state from the queue.
|
||||||
|
Future<List<Map<String, dynamic>>> getPendingTasks() {
|
||||||
|
return _dbHelper.getPendingRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to re-execute a single failed task from the queue.
|
||||||
|
/// Returns `true` on success, `false` on failure.
|
||||||
|
Future<bool> retryTask(int taskId) async {
|
||||||
|
final task = await _dbHelper.getRequestById(taskId);
|
||||||
|
if (task == null) {
|
||||||
|
debugPrint("Retry failed: Task with ID $taskId not found in the queue.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool success = false;
|
||||||
|
final payload = jsonDecode(task['payload'] as String);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (task['type'] == 'api') {
|
||||||
|
final endpoint = task['endpoint_or_path'] as String;
|
||||||
|
final method = payload['method'] as String;
|
||||||
|
|
||||||
|
debugPrint("Retrying API task $taskId: $method to $endpoint");
|
||||||
|
Map<String, dynamic> result;
|
||||||
|
|
||||||
|
if (method == 'POST_MULTIPART') {
|
||||||
|
// Reconstruct fields and files from the stored payload
|
||||||
|
final Map<String, String> fields = Map<String, String>.from(payload['fields'] ?? {});
|
||||||
|
final Map<String, File> files = (payload['files'] as Map<String, dynamic>?)
|
||||||
|
?.map((key, value) => MapEntry(key, File(value as String))) ?? {};
|
||||||
|
|
||||||
|
result = await _baseApiService.postMultipart(endpoint: endpoint, fields: fields, files: files);
|
||||||
|
} else { // Assume 'POST'
|
||||||
|
final Map<String, dynamic> body = Map<String, dynamic>.from(payload['body'] ?? {});
|
||||||
|
result = await _baseApiService.post(endpoint, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
success = result['success'];
|
||||||
|
|
||||||
|
} else if (task['type'] == 'ftp') {
|
||||||
|
final remotePath = task['endpoint_or_path'] as String;
|
||||||
|
final localFile = File(payload['localFilePath'] as String);
|
||||||
|
|
||||||
|
debugPrint("Retrying FTP task $taskId: Uploading ${localFile.path} to $remotePath");
|
||||||
|
|
||||||
|
// Ensure the file still exists before attempting to re-upload
|
||||||
|
if (await localFile.exists()) {
|
||||||
|
final result = await _ftpService.uploadFile(localFile, remotePath);
|
||||||
|
// The FTP service already queues on failure, so we only care about success here.
|
||||||
|
success = result['success'];
|
||||||
|
} else {
|
||||||
|
debugPrint("Retry failed for FTP task $taskId: Source file no longer exists at ${localFile.path}");
|
||||||
|
success = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("A critical error occurred while retrying task $taskId: $e");
|
||||||
|
success = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
debugPrint("Task $taskId completed successfully. Removing from queue.");
|
||||||
|
await _dbHelper.deleteRequestFromQueue(taskId);
|
||||||
|
} else {
|
||||||
|
debugPrint("Retry attempt for task $taskId failed. It will remain in the queue.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
lib/services/server_config_service.dart
Normal file
55
lib/services/server_config_service.dart
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
// lib/services/server_config_service.dart
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class ServerConfigService {
|
||||||
|
static const String _activeApiConfigKey = 'active_api_config';
|
||||||
|
static const String _activeFtpConfigKey = 'active_ftp_config';
|
||||||
|
// A default URL to prevent crashes if no config is set.
|
||||||
|
static const String _defaultApiUrl = 'https://dev14.pstw.com.my/v1';
|
||||||
|
|
||||||
|
// --- API Config Methods ---
|
||||||
|
|
||||||
|
/// Saves the selected API configuration as the currently active one.
|
||||||
|
Future<void> setActiveApiConfig(Map<String, dynamic> config) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_activeApiConfigKey, jsonEncode(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves the currently active API configuration from local storage.
|
||||||
|
Future<Map<String, dynamic>?> getActiveApiConfig() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final String? configString = prefs.getString(_activeApiConfigKey);
|
||||||
|
if (configString != null) {
|
||||||
|
return jsonDecode(configString) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the API Base URL from the active configuration.
|
||||||
|
/// Falls back to a default URL if none is set.
|
||||||
|
Future<String> getActiveApiUrl() async {
|
||||||
|
final config = await getActiveApiConfig();
|
||||||
|
// The key from the database is 'api_url'.
|
||||||
|
return config?['api_url'] as String? ?? _defaultApiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FTP Config Methods ---
|
||||||
|
|
||||||
|
/// Saves the selected FTP configuration as the currently active one.
|
||||||
|
Future<void> setActiveFtpConfig(Map<String, dynamic> config) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_activeFtpConfigKey, jsonEncode(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves the currently active FTP configuration from local storage.
|
||||||
|
Future<Map<String, dynamic>?> getActiveFtpConfig() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final String? configString = prefs.getString(_activeFtpConfigKey);
|
||||||
|
if (configString != null) {
|
||||||
|
return jsonDecode(configString) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -257,6 +257,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
ftpconnect:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: ftpconnect
|
||||||
|
sha256: "6445074d957fe6f5ca8c68c95538132509d4b3256806fcfa35d8e59033b398c0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.5"
|
||||||
geolocator:
|
geolocator:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -36,6 +36,7 @@ dependencies:
|
|||||||
geolocator: ^11.0.0 # For GPS functionality
|
geolocator: ^11.0.0 # For GPS functionality
|
||||||
image: ^4.1.3 # For image processing (watermarks)
|
image: ^4.1.3 # For image processing (watermarks)
|
||||||
permission_handler: ^11.3.1
|
permission_handler: ^11.3.1
|
||||||
|
ftpconnect: ^2.0.5
|
||||||
# --- 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_blue_classic: ^0.0.3 # For Bluetooth sonde connection
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user