create multiple submission process either using api or ftp

This commit is contained in:
ALim Aidrus 2025-08-24 12:17:13 +08:00
parent 6d37abf141
commit 5930dd500e
16 changed files with 746 additions and 268 deletions

View File

@ -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">

View File

@ -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

View File

@ -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,59 +278,40 @@ 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),
child: Center(child: Text('No logs match your search in this category.')))
: ConstrainedBox(
constraints: BoxConstraints(maxHeight: listHeight),
child: ListView.builder(
shrinkWrap: true, shrinkWrap: true,
itemCount: logs.length, itemCount: logs.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _buildLogListItem(logs[index]); return _buildLogListItem(logs[index]);
}, },
), ),
),
], ],
), ),
), ),
); );
} }
// 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),

View File

@ -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),

View File

@ -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);

View File

@ -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) {

View File

@ -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);

View File

@ -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,

View File

@ -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]);
}
} }

View File

@ -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'); 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'); debugPrint('Starting multipart upload to: $url');
try {
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.'};
} }
} }

View 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();
}
}
}

View File

@ -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,15 +139,20 @@ 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 [];
final List<Map<String, dynamic>> allLogs = [];
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
for (var serverDir in serverDirs) {
final baseDir = Directory(p.join(serverDir.path, 'air', 'air_manual_sampling'));
if (!await baseDir.exists()) continue;
try { try {
final List<Map<String, dynamic>> logs = [];
final entities = baseDir.listSync(); final entities = baseDir.listSync();
for (var entity in entities) { for (var entity in entities) {
if (entity is Directory) { if (entity is Directory) {
final jsonFile = File(p.join(entity.path, 'data.json')); final jsonFile = File(p.join(entity.path, 'data.json'));
@ -154,23 +160,23 @@ class LocalStorageService {
final content = await jsonFile.readAsString(); final content = await jsonFile.readAsString();
final data = jsonDecode(content) as Map<String, dynamic>; final data = jsonDecode(content) as Map<String, dynamic>;
data['logDirectory'] = entity.path; data['logDirectory'] = entity.path;
logs.add(data); allLogs.add(data);
} }
} }
} }
return logs;
} catch (e) { } catch (e) {
debugPrint("Error getting all air sampling logs: $e"); debugPrint("Error reading air logs from ${baseDir.path}: $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,13 +230,17 @@ 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 [];
final List<Map<String, dynamic>> allLogs = [];
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
for (var serverDir in serverDirs) {
final baseDir = Directory(p.join(serverDir.path, 'marine', 'marine_tarball_sampling'));
if (!await baseDir.exists()) continue;
try { try {
final List<Map<String, dynamic>> logs = [];
final entities = baseDir.listSync(); final entities = baseDir.listSync();
for (var entity in entities) { for (var entity in entities) {
if (entity is Directory) { if (entity is Directory) {
final jsonFile = File(p.join(entity.path, 'data.json')); final jsonFile = File(p.join(entity.path, 'data.json'));
@ -239,16 +248,16 @@ class LocalStorageService {
final content = await jsonFile.readAsString(); final content = await jsonFile.readAsString();
final data = jsonDecode(content) as Map<String, dynamic>; final data = jsonDecode(content) as Map<String, dynamic>;
data['logDirectory'] = entity.path; data['logDirectory'] = entity.path;
logs.add(data); allLogs.add(data);
} }
} }
} }
return logs;
} catch (e) { } catch (e) {
debugPrint("Error getting all tarball logs: $e"); debugPrint("Error reading tarball logs from ${baseDir.path}: $e");
return [];
} }
} }
return allLogs;
}
Future<void> updateTarballLog(Map<String, dynamic> updatedLogData) async { Future<void> updateTarballLog(Map<String, dynamic> updatedLogData) async {
final logDir = updatedLogData['logDirectory']; final logDir = updatedLogData['logDirectory'];
@ -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,13 +338,17 @@ 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 [];
final List<Map<String, dynamic>> allLogs = [];
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
for (var serverDir in serverDirs) {
final baseDir = Directory(p.join(serverDir.path, 'marine', 'marine_in_situ_sampling'));
if (!await baseDir.exists()) continue;
try { try {
final List<Map<String, dynamic>> logs = [];
final entities = baseDir.listSync(); final entities = baseDir.listSync();
for (var entity in entities) { for (var entity in entities) {
if (entity is Directory) { if (entity is Directory) {
final jsonFile = File(p.join(entity.path, 'data.json')); final jsonFile = File(p.join(entity.path, 'data.json'));
@ -343,16 +356,16 @@ class LocalStorageService {
final content = await jsonFile.readAsString(); final content = await jsonFile.readAsString();
final data = jsonDecode(content) as Map<String, dynamic>; final data = jsonDecode(content) as Map<String, dynamic>;
data['logDirectory'] = entity.path; data['logDirectory'] = entity.path;
logs.add(data); allLogs.add(data);
} }
} }
} }
return logs;
} catch (e) { } catch (e) {
debugPrint("Error getting all in-situ logs: $e"); debugPrint("Error reading in-situ logs from ${baseDir.path}: $e");
return [];
} }
} }
return allLogs;
}
Future<void> updateInSituLog(Map<String, dynamic> updatedLogData) async { Future<void> updateInSituLog(Map<String, dynamic> updatedLogData) async {
final logDir = updatedLogData['logDirectory']; final logDir = updatedLogData['logDirectory'];
@ -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,16 +452,17 @@ 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>();
for (var serverDir in serverDirs) {
final topLevelDir = Directory(p.join(serverDir.path, 'river', 'river_in_situ_sampling'));
if (!await topLevelDir.exists()) continue;
try { try {
final List<Map<String, dynamic>> logs = [];
final typeSubfolders = topLevelDir.listSync(); final typeSubfolders = topLevelDir.listSync();
for (var typeSubfolder in typeSubfolders) { for (var typeSubfolder in typeSubfolders) {
if (typeSubfolder is Directory) { if (typeSubfolder is Directory) {
final eventFolders = typeSubfolder.listSync(); final eventFolders = typeSubfolder.listSync();
@ -459,18 +473,18 @@ class LocalStorageService {
final content = await jsonFile.readAsString(); final content = await jsonFile.readAsString();
final data = jsonDecode(content) as Map<String, dynamic>; final data = jsonDecode(content) as Map<String, dynamic>;
data['logDirectory'] = eventFolder.path; data['logDirectory'] = eventFolder.path;
logs.add(data); allLogs.add(data);
} }
} }
} }
} }
} }
return logs;
} catch (e) { } catch (e) {
debugPrint("Error getting all river in-situ logs: $e"); debugPrint("Error getting all river in-situ logs from ${topLevelDir.path}: $e");
return [];
} }
} }
return allLogs;
}
Future<void> updateRiverInSituLog(Map<String, dynamic> updatedLogData) async { Future<void> updateRiverInSituLog(Map<String, dynamic> updatedLogData) async {
final logDir = updatedLogData['logDirectory']; final logDir = updatedLogData['logDirectory'];

View 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;
}
}

View 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;
}
}

View File

@ -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:

View File

@ -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