From 5930dd500e6e02380bfefb30c2131aaaf7a51d13 Mon Sep 17 00:00:00 2001 From: ALim Aidrus Date: Sun, 24 Aug 2025 12:17:13 +0800 Subject: [PATCH] create multiple submission process either using api or ftp --- android/app/src/main/AndroidManifest.xml | 2 +- lib/auth_provider.dart | 59 +++++ lib/screens/air/manual/data_status_log.dart | 223 ++++++++---------- .../marine/manual/data_status_log.dart | 14 +- .../marine/manual/in_situ_sampling.dart | 13 +- .../tarball_sampling_step3_summary.dart | 11 +- .../river/manual/in_situ_sampling.dart | 11 +- lib/services/air_sampling_service.dart | 44 ++-- lib/services/api_service.dart | 74 +++++- lib/services/base_api_service.dart | 67 ++++-- lib/services/ftp_service.dart | 79 +++++++ lib/services/local_storage_service.dart | 222 +++++++++-------- lib/services/retry_service.dart | 131 ++++++++++ lib/services/server_config_service.dart | 55 +++++ pubspec.lock | 8 + pubspec.yaml | 1 + 16 files changed, 746 insertions(+), 268 deletions(-) create mode 100644 lib/services/ftp_service.dart create mode 100644 lib/services/retry_service.dart create mode 100644 lib/services/server_config_service.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 184f8c9..0b0a3d9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,7 +28,7 @@ diff --git a/lib/auth_provider.dart b/lib/auth_provider.dart index 14c8ee9..0e44ec4 100644 --- a/lib/auth_provider.dart +++ b/lib/auth_provider.dart @@ -4,12 +4,22 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'dart:convert'; 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, /// and cached master data for offline use. class AuthProvider with ChangeNotifier { final ApiService _apiService = ApiService(); 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 --- String? _jwtToken; @@ -42,6 +52,11 @@ class AuthProvider with ChangeNotifier { List>? _states; List>? _appSettings; List>? _parameterLimits; + List>? _apiConfigs; + List>? _ftpConfigs; + // --- ADDED: State variable for the list of tasks pending manual retry --- + List>? _pendingRetries; + // --- Getters for UI access --- List>? get allUsers => _allUsers; @@ -58,6 +73,11 @@ class AuthProvider with ChangeNotifier { List>? get states => _states; List>? get appSettings => _appSettings; List>? get parameterLimits => _parameterLimits; + List>? get apiConfigs => _apiConfigs; + List>? get ftpConfigs => _ftpConfigs; + // --- ADDED: Getter for the list of tasks pending manual retry --- + List>? get pendingRetries => _pendingRetries; + // --- SharedPreferences Keys --- static const String tokenKey = 'jwt_token'; @@ -81,6 +101,23 @@ class AuthProvider with ChangeNotifier { _userEmail = prefs.getString(userEmailKey); _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 final lastSyncString = prefs.getString(lastSyncTimestampKey); if (lastSyncString != null) { @@ -128,6 +165,13 @@ class AuthProvider with ChangeNotifier { await prefs.setString(lastSyncTimestampKey, 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. await _loadDataFromCache(); notifyListeners(); @@ -167,9 +211,20 @@ class AuthProvider with ChangeNotifier { // ADDED: Load new data types from the local database _appSettings = await _dbHelper.loadAppSettings(); _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."); } + // --- 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 refreshPendingTasks() async { + _pendingRetries = await _retryService.getPendingTasks(); + notifyListeners(); + } + // --- Methods for UI interaction --- /// 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 _appSettings = null; _parameterLimits = null; + _apiConfigs = null; + _ftpConfigs = null; + // --- ADDED: Clear pending retry tasks on logout --- + _pendingRetries = null; final prefs = await SharedPreferences.getInstance(); // MODIFIED: Removed keys individually for safer logout diff --git a/lib/screens/air/manual/data_status_log.dart b/lib/screens/air/manual/data_status_log.dart index 0f38098..33403d9 100644 --- a/lib/screens/air/manual/data_status_log.dart +++ b/lib/screens/air/manual/data_status_log.dart @@ -1,12 +1,16 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import '../../../../auth_provider.dart'; import '../../../../models/air_installation_data.dart'; import '../../../../models/air_collection_data.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 { final String type; final String title; @@ -16,6 +20,7 @@ class SubmissionLogEntry { final String status; final String message; final Map rawData; + final String serverName; // ADDED bool isResubmitting; SubmissionLogEntry({ @@ -27,6 +32,7 @@ class SubmissionLogEntry { required this.status, required this.message, required this.rawData, + required this.serverName, // ADDED this.isResubmitting = false, }); } @@ -40,18 +46,14 @@ class AirManualDataStatusLog extends StatefulWidget { class _AirManualDataStatusLogState extends State { final LocalStorageService _localStorageService = LocalStorageService(); - final ApiService _apiService = ApiService(); + // --- MODIFIED: Use AirSamplingService for resubmission logic --- + final AirSamplingService _airSamplingService = AirSamplingService(); - // Raw data lists - List _installationLogs = []; - List _collectionLogs = []; + // --- MODIFIED: Simplified state management to a single source of truth --- + List _allLogs = []; + List _filteredLogs = []; - // Filtered lists for the UI - List _filteredInstallationLogs = []; - List _filteredCollectionLogs = []; - - // Per-category search controllers - final Map _searchControllers = {}; + final _searchController = TextEditingController(); bool _isLoading = true; final Map _isResubmitting = {}; @@ -59,15 +61,13 @@ class _AirManualDataStatusLogState extends State { @override void initState() { super.initState(); - _searchControllers['Installation'] = TextEditingController()..addListener(_filterLogs); - _searchControllers['Collection'] = TextEditingController()..addListener(_filterLogs); + _searchController.addListener(_filterLogs); _loadAllLogs(); } @override void dispose() { - _searchControllers['Installation']?.dispose(); - _searchControllers['Collection']?.dispose(); + _searchController.dispose(); super.dispose(); } @@ -75,53 +75,48 @@ class _AirManualDataStatusLogState extends State { setState(() => _isLoading = true); final airLogs = await _localStorageService.getAllAirSamplingLogs(); - final List tempInstallation = []; - final List tempCollection = []; + final List tempLogs = []; for (var log in airLogs) { try { final hasCollectionData = log['collectionData'] != null && (log['collectionData'] as Map).isNotEmpty; - final isInstallation = !hasCollectionData && log['air_man_id'] != null; - final stationInfo = isInstallation - ? log['stationInfo'] ?? {} - : log['collectionData']?['stationInfo'] ?? {}; + // Determine if it's an Installation or Collection log + final logType = hasCollectionData ? 'Collection' : 'Installation'; + final stationInfo = log['stationInfo'] ?? {}; final stationName = stationInfo['station_name'] ?? 'Station ${log['stationID'] ?? 'Unknown'}'; final stationCode = stationInfo['station_code'] ?? log['stationID'] ?? 'N/A'; - final submissionDateTime = isInstallation + final submissionDateTime = logType == 'Installation' ? _parseInstallationDateTime(log) : _parseCollectionDateTime(log['collectionData']); final entry = SubmissionLogEntry( - type: isInstallation ? 'Installation' : 'Collection', + type: logType, title: stationName, stationCode: stationCode, submissionDateTime: submissionDateTime, - reportId: isInstallation ? log['air_man_id']?.toString() : log['collectionData']?['air_man_id']?.toString(), + reportId: log['airManId']?.toString(), status: log['status'] ?? 'L1', message: _getStatusMessage(log), rawData: log, + // --- MODIFIED: Extract the server name from the log data --- + serverName: log['serverConfigName'] ?? 'Unknown Server', ); - if (isInstallation) { - tempInstallation.add(entry); - } else { - tempCollection.add(entry); - } + tempLogs.add(entry); + } catch (e) { debugPrint('Error processing log entry: $e'); } } - tempInstallation.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); - tempCollection.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + tempLogs.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); if (mounted) { setState(() { - _installationLogs = tempInstallation; - _collectionLogs = tempCollection; + _allLogs = tempLogs; _isLoading = false; }); _filterLogs(); @@ -145,8 +140,8 @@ class _AirManualDataStatusLogState extends State { DateTime _parseCollectionDateTime(Map? collectionData) { try { if (collectionData == null) return DateTime.now(); - final dateKey = 'air_man_collection_date'; - final timeKey = 'air_man_collection_time'; + final dateKey = 'collectionDate'; // Corrected key based on AirCollectionData model + final timeKey = 'collectionTime'; // Corrected key if (collectionData[dateKey] != null) { final date = collectionData[dateKey]; @@ -162,13 +157,15 @@ class _AirManualDataStatusLogState extends State { String _getStatusMessage(Map log) { switch (log['status']) { + case 'S1': case 'S2': case 'S3': return 'Successfully submitted to server'; case 'L1': case 'L3': return 'Saved locally (pending submission)'; - case 'L4': + case 'L2_PENDING_IMAGES': + case 'L4_PENDING_IMAGES': return 'Partial submission (images failed)'; default: return 'Submission status unknown'; @@ -176,88 +173,70 @@ class _AirManualDataStatusLogState extends State { } void _filterLogs() { - final installationQuery = _searchControllers['Installation']?.text.toLowerCase() ?? ''; - final collectionQuery = _searchControllers['Collection']?.text.toLowerCase() ?? ''; - + final query = _searchController.text.toLowerCase(); setState(() { - _filteredInstallationLogs = _installationLogs.where((log) => _logMatchesQuery(log, installationQuery)).toList(); - _filteredCollectionLogs = _collectionLogs.where((log) => _logMatchesQuery(log, collectionQuery)).toList(); + _filteredLogs = _allLogs.where((log) { + 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) { - if (query.isEmpty) return true; - return log.title.toLowerCase().contains(query) || - log.stationCode.toLowerCase().contains(query) || - (log.reportId?.toLowerCase() ?? '').contains(query); - } - + // --- MODIFIED: Complete overhaul of the resubmission logic --- Future _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); try { - final logData = log.rawData; - log.type == 'Installation' - ? await _submitInstallation(logData) - : await _submitCollection(logData); + final authProvider = Provider.of(context, listen: false); + final appSettings = authProvider.appSettings; + Map result; + + // 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) { 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) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Resubmission failed: $e')), + SnackBar(content: Text('Resubmission failed: $e'), backgroundColor: Colors.red), ); } } finally { if (mounted) { setState(() => _isResubmitting.remove(logKey)); - await _loadAllLogs(); + await _loadAllLogs(); // Refresh the log list to show the updated status } } } - Future _submitInstallation(Map 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 _submitCollection(Map 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 Widget build(BuildContext context) { - final hasAnyLogs = _installationLogs.isNotEmpty || _collectionLogs.isNotEmpty; - final hasFilteredLogs = _filteredInstallationLogs.isNotEmpty || _filteredCollectionLogs.isNotEmpty; - + // --- MODIFIED: Logic simplified to work with a single, comprehensive list --- final logCategories = { - 'Installation': _filteredInstallationLogs, - 'Collection': _filteredCollectionLogs, + 'Installation': _filteredLogs.where((log) => log.type == 'Installation').toList(), + 'Collection': _filteredLogs.where((log) => log.type == 'Collection').toList(), }; + final hasAnyLogs = _allLogs.isNotEmpty; + final hasFilteredLogs = _filteredLogs.isNotEmpty; return Scaffold( appBar: AppBar(title: const Text('Air Sampling Status Log')), @@ -270,8 +249,21 @@ class _AirManualDataStatusLogState extends State { : ListView( padding: const EdgeInsets.all(8.0), 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 - .where((entry) => entry.value.isNotEmpty) // Only show categories with logs + .where((entry) => entry.value.isNotEmpty) .map((entry) => _buildCategorySection(entry.key, entry.value)), if (!hasFilteredLogs && hasAnyLogs) const Center( @@ -286,45 +278,26 @@ class _AirManualDataStatusLogState extends State { ); } - // THIS SECTION IS UPDATED TO MATCH THE MARINE LOG UI Widget _buildCategorySection(String category, List logs) { - // Calculate height to show 5.5 items, indicating scrollability - final listHeight = (logs.length > 5 ? 5.5 : logs.length.toDouble()) * 75.0; - return Card( - margin: const EdgeInsets.symmetric(vertical: 8.0), + margin: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 8.0), child: Padding( padding: const EdgeInsets.all(8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: TextField( - controller: _searchControllers[category], - decoration: InputDecoration( - hintText: 'Search in $category...', - prefixIcon: const Icon(Icons.search, size: 20), - isDense: true, - border: const OutlineInputBorder(), - ), - ), + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), ), const Divider(), - logs.isEmpty - ? const Padding( - 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, - itemCount: logs.length, - itemBuilder: (context, index) { - return _buildLogListItem(logs[index]); - }, - ), + ListView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: logs.length, + itemBuilder: (context, index) { + return _buildLogListItem(logs[index]); + }, ), ], ), @@ -332,13 +305,13 @@ class _AirManualDataStatusLogState extends State { ); } - // THIS ITEM BUILDER IS UPDATED TO MATCH THE MARINE LOG UI Widget _buildLogListItem(SubmissionLogEntry log) { - final isSuccess = log.status == 'S2' || log.status == 'S3'; - final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); + final isSuccess = log.status.startsWith('S'); + final logKey = log.rawData['refID']?.toString() ?? log.submissionDateTime.toIso8601String(); final isResubmitting = _isResubmitting[logKey] ?? false; - final title = '${log.title} (${log.stationCode})'; // Consistent title format - final subtitle = DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime); + final title = '${log.title} (${log.stationCode})'; + // --- 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( leading: Icon( @@ -362,6 +335,8 @@ class _AirManualDataStatusLogState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // --- MODIFIED: Add server name to the details view --- + _buildDetailRow('Server:', log.serverName), _buildDetailRow('Report ID:', log.reportId ?? 'N/A'), _buildDetailRow('Status:', log.message), _buildDetailRow('Submission Type:', log.type), diff --git a/lib/screens/marine/manual/data_status_log.dart b/lib/screens/marine/manual/data_status_log.dart index 9bde34a..dd2e8a3 100644 --- a/lib/screens/marine/manual/data_status_log.dart +++ b/lib/screens/marine/manual/data_status_log.dart @@ -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/marine_api_service.dart'; +// --- MODIFIED: Added serverName to the log entry model --- class SubmissionLogEntry { final String type; // e.g., 'Manual Sampling', 'Tarball Sampling' final String title; @@ -17,6 +18,7 @@ class SubmissionLogEntry { final String status; final String message; final Map rawData; + final String serverName; // ADDED bool isResubmitting; SubmissionLogEntry({ @@ -28,6 +30,7 @@ class SubmissionLogEntry { required this.status, required this.message, required this.rawData, + required this.serverName, // ADDED this.isResubmitting = false, }); } @@ -95,6 +98,8 @@ class _MarineManualDataStatusLogState extends State { status: log['submissionStatus'] ?? 'L1', message: log['submissionMessage'] ?? 'No status message.', rawData: log, + // --- MODIFIED: Extract the server name from the log data --- + serverName: log['serverConfigName'] ?? 'Unknown Server', )); } @@ -109,6 +114,8 @@ class _MarineManualDataStatusLogState extends State { status: log['submissionStatus'] ?? 'L1', message: log['submissionMessage'] ?? 'No status message.', rawData: log, + // --- MODIFIED: Extract the server name from the log data --- + serverName: log['serverConfigName'] ?? 'Unknown Server', )); } @@ -137,8 +144,10 @@ class _MarineManualDataStatusLogState extends State { bool _logMatchesQuery(SubmissionLogEntry log, String query) { 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); } @@ -372,7 +381,8 @@ class _MarineManualDataStatusLogState extends State { final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); final isResubmitting = _isResubmitting[logKey] ?? false; 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( leading: Icon( @@ -392,6 +402,8 @@ class _MarineManualDataStatusLogState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // --- MODIFIED: Add server name to the details view --- + _buildDetailRow('Server:', log.serverName), _buildDetailRow('Report ID:', log.reportId ?? 'N/A'), _buildDetailRow('Status:', log.message), _buildDetailRow('Submission Type:', log.type), diff --git a/lib/screens/marine/manual/in_situ_sampling.dart b/lib/screens/marine/manual/in_situ_sampling.dart index bdce32f..1b27b8d 100644 --- a/lib/screens/marine/manual/in_situ_sampling.dart +++ b/lib/screens/marine/manual/in_situ_sampling.dart @@ -6,6 +6,8 @@ import 'package:environment_monitoring_app/auth_provider.dart'; // ADDED: Import import '../../../models/in_situ_sampling_data.dart'; import '../../../services/in_situ_sampling_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_2_site_info.dart'; import 'widgets/in_situ_step_3_data_capture.dart'; @@ -33,6 +35,9 @@ class _MarineInSituSamplingState extends State { // Service for saving submission logs locally. final LocalStorageService _localStorageService = LocalStorageService(); + // --- ADDED: Service to get the active server configuration --- + final ServerConfigService _serverConfigService = ServerConfigService(); + int _currentPage = 0; bool _isLoading = false; @@ -94,8 +99,12 @@ class _MarineInSituSamplingState extends State { _data.submissionMessage = result['message']; _data.reportId = result['reportId']?.toString(); - // Save a log of the submission locally. - await _localStorageService.saveInSituSamplingData(_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'; + + // Save a log of the submission locally, now with the server name. + await _localStorageService.saveInSituSamplingData(_data, serverName: serverName); setState(() => _isLoading = false); diff --git a/lib/screens/marine/manual/tarball_sampling_step3_summary.dart b/lib/screens/marine/manual/tarball_sampling_step3_summary.dart index b73678c..beaf67c 100644 --- a/lib/screens/marine/manual/tarball_sampling_step3_summary.dart +++ b/lib/screens/marine/manual/tarball_sampling_step3_summary.dart @@ -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/services/marine_api_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 { final TarballSamplingData data; @@ -18,6 +21,8 @@ class TarballSamplingStep3Summary extends StatefulWidget { class _TarballSamplingStep3SummaryState extends State { final MarineApiService _marineApiService = MarineApiService(); final LocalStorageService _localStorageService = LocalStorageService(); + // --- ADDED: Service to get the active server configuration --- + final ServerConfigService _serverConfigService = ServerConfigService(); bool _isLoading = false; // MODIFIED: This method now fetches appSettings and passes it to the API service. @@ -43,8 +48,12 @@ class _TarballSamplingStep3SummaryState extends State { final RiverInSituSamplingService _samplingService = RiverInSituSamplingService(); final LocalStorageService _localStorageService = LocalStorageService(); + // --- ADDED: Service to get the active server configuration --- + final ServerConfigService _serverConfigService = ServerConfigService(); int _currentPage = 0; bool _isLoading = false; @@ -84,7 +88,12 @@ class _RiverInSituSamplingScreenState extends State { _data.submissionMessage = result['message']; _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); diff --git a/lib/services/air_sampling_service.dart b/lib/services/air_sampling_service.dart index 1683c03..84856f8 100644 --- a/lib/services/air_sampling_service.dart +++ b/lib/services/air_sampling_service.dart @@ -14,12 +14,17 @@ import '../models/air_collection_data.dart'; import 'api_service.dart'; import 'local_storage_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. class AirSamplingService { final ApiService _apiService = ApiService(); final LocalStorageService _localStorageService = LocalStorageService(); 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, /// 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. // MODIFIED: Method now requires the appSettings list to pass down the call stack. Future> submitInstallation(AirInstallationData data, List>? 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 --- Future> saveLocally(String status, String message) async { debugPrint("Saving installation locally with status: $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}; } // 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) { 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 --- @@ -142,17 +152,17 @@ class AirSamplingService { data.airManId = parsedRecordId; // --- 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. - // MODIFIED: Method now requires the appSettings list to pass to the alert handler. - Future> _uploadInstallationImagesAndUpdate(AirInstallationData data, List>? appSettings) async { + // MODIFIED: Method now requires the serverName to pass to the save method. + Future> _uploadInstallationImagesAndUpdate(AirInstallationData data, List>? appSettings, {required String serverName}) async { final filesToUpload = data.getImagesForUpload(); if (filesToUpload.isEmpty) { debugPrint("No images to upload. Submission complete."); 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); return {'status': 'S1', 'message': 'Installation data submitted successfully.'}; } @@ -166,7 +176,7 @@ class AirSamplingService { if (imageUploadResult['success'] != true) { debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}"); data.status = 'L2_PENDING_IMAGES'; - await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!); + await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!, serverName: serverName); return { 'status': 'L2_PENDING_IMAGES', 'message': 'Data submitted, but image upload failed. Saved locally for retry.', @@ -175,7 +185,7 @@ class AirSamplingService { debugPrint("Images uploaded successfully."); 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); return { 'status': 'S2', @@ -186,6 +196,10 @@ class AirSamplingService { /// Submits only the collection data, linked to a previous installation. // MODIFIED: Method now requires the appSettings list to pass down the call stack. Future> submitCollection(AirCollectionData data, AirInstallationData installationData, List>? 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) --- Future> updateAndSaveLocally(String newStatus, {String? message}) async { debugPrint("Saving collection data locally with status: $newStatus"); @@ -197,7 +211,7 @@ class AirSamplingService { // FIX: Nest collection data to prevent overwriting installation fields. installationLog['collectionData'] = data.toMap(); installationLog['status'] = newStatus; // Update the overall status - await _localStorageService.saveAirSamplingRecord(installationLog, data.installationRefID!); + await _localStorageService.saveAirSamplingRecord(installationLog, data.installationRefID!, serverName: serverName); } return { '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 (data.status == 'L4_PENDING_IMAGES' && data.airManId != null) { 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 --- @@ -222,13 +236,13 @@ class AirSamplingService { debugPrint("Collection text data submitted successfully."); // --- 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. - // MODIFIED: Method now requires the appSettings list to pass to the alert handler. - Future> _uploadCollectionImagesAndUpdate(AirCollectionData data, AirInstallationData installationData, List>? appSettings) async { - // --- OFFLINE-FIRST HELPER (CORRECTED) --- + // MODIFIED: Method now requires the serverName to pass to the save method. + Future> _uploadCollectionImagesAndUpdate(AirCollectionData data, AirInstallationData installationData, List>? appSettings, {required String serverName}) async { + // --- OFFLINE-FIRST HELPER (CORRECTED & MODIFIED) --- Future> updateAndSaveLocally(String newStatus, {String? message}) async { debugPrint("Saving collection data locally with status: $newStatus"); final allLogs = await _localStorageService.getAllAirSamplingLogs(); @@ -238,7 +252,7 @@ class AirSamplingService { final installationLog = allLogs[logIndex]; installationLog['collectionData'] = data.toMap(); installationLog['status'] = newStatus; - await _localStorageService.saveAirSamplingRecord(installationLog, data.installationRefID!); + await _localStorageService.saveAirSamplingRecord(installationLog, data.installationRefID!, serverName: serverName); } return { 'status': newStatus, diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 0ddd9c8..6e4d95a 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -154,6 +154,9 @@ class ApiService { 'states': {'endpoint': 'states', 'handler': (d, id) async { await _dbHelper.upsertStates(d); await _dbHelper.deleteStates(id); }}, 'appSettings': {'endpoint': 'settings', 'handler': (d, id) async { await _dbHelper.upsertAppSettings(d); await _dbHelper.deleteAppSettings(id); }}, 'parameterLimits': {'endpoint': 'parameter-limits', 'handler': (d, id) async { await _dbHelper.upsertParameterLimits(d); await _dbHelper.deleteParameterLimits(id); }}, + // --- 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 @@ -274,7 +277,7 @@ class DatabaseHelper { static Database? _database; static const String _dbName = 'app_data.db'; // 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 _usersTable = 'all_users'; @@ -293,6 +296,12 @@ class DatabaseHelper { // Added new table constants static const String _appSettingsTable = 'app_settings'; 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 get database async { if (_database != null) return _database!; @@ -323,6 +332,20 @@ class DatabaseHelper { // 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 $_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 _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 $_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. @@ -445,4 +486,35 @@ class DatabaseHelper { Future upsertParameterLimits(List> data) => _upsertData(_parameterLimitsTable, 'param_autoid', data, 'limit'); Future deleteParameterLimits(List ids) => _deleteData(_parameterLimitsTable, 'param_autoid', ids); Future>?> loadParameterLimits() => _loadData(_parameterLimitsTable, 'limit'); + + // --- ADDED: Methods for independent API and FTP configurations --- + Future upsertApiConfigs(List> data) => _upsertData(_apiConfigsTable, 'api_config_id', data, 'config'); + Future deleteApiConfigs(List ids) => _deleteData(_apiConfigsTable, 'api_config_id', ids); + Future>?> loadApiConfigs() => _loadData(_apiConfigsTable, 'config'); + + Future upsertFtpConfigs(List> data) => _upsertData(_ftpConfigsTable, 'ftp_config_id', data, 'config'); + Future deleteFtpConfigs(List ids) => _deleteData(_ftpConfigsTable, 'ftp_config_id', ids); + Future>?> loadFtpConfigs() => _loadData(_ftpConfigsTable, 'config'); + + // --- ADDED: Methods for the new retry queue --- + Future queueFailedRequest(Map data) async { + final db = await database; + return await db.insert(_retryQueueTable, data, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future>> getPendingRequests() async { + final db = await database; + return await db.query(_retryQueueTable, where: 'status = ?', whereArgs: ['pending']); + } + + Future?> 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 deleteRequestFromQueue(int id) async { + final db = await database; + await db.delete(_retryQueueTable, where: 'id = ?', whereArgs: [id]); + } } \ No newline at end of file diff --git a/lib/services/base_api_service.dart b/lib/services/base_api_service.dart index 0181c24..c4beec0 100644 --- a/lib/services/base_api_service.dart +++ b/lib/services/base_api_service.dart @@ -1,13 +1,24 @@ import 'dart:convert'; import 'dart:io'; +import 'package:async/async.dart'; // Used for TimeoutException import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'package:path/path.dart' as path; 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 { - 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 Future> _getHeaders() async { @@ -29,29 +40,41 @@ class BaseApiService { // Generic GET request handler Future> get(String endpoint) async { - final url = Uri.parse('$_baseUrl/$endpoint'); 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); } catch (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 Future> post(String endpoint, Map body) 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'); final response = await http.post( url, headers: await _getJsonHeaders(), body: jsonEncode(body), - ); + ).timeout(const Duration(seconds: 60)); // --- MODIFIED: Added 60 second timeout --- return _handleResponse(response); } catch (e) { - debugPrint('POST request failed: $e'); - return {'success': false, 'message': 'Network error: $e'}; + // --- MODIFIED: Create a local instance of RetryService to break the circular dependency --- + 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 fields, required Map files, }) async { - final url = Uri.parse('$_baseUrl/$endpoint'); - debugPrint('Starting multipart upload to: $url'); - try { + // --- MODIFIED: Fetches the active base URL before making the request --- + final baseUrl = await _serverConfigService.getActiveApiUrl(); + final url = Uri.parse('$baseUrl/$endpoint'); + debugPrint('Starting multipart upload to: $url'); + var request = http.MultipartRequest('POST', url); // Get and add headers (Authorization token) @@ -92,7 +117,8 @@ class BaseApiService { debugPrint('${files.length} files added to the 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}'); final responseBody = await streamedResponse.stream.bytesToString(); @@ -100,12 +126,17 @@ class BaseApiService { return _handleResponse(http.Response(responseBody, streamedResponse.statusCode)); } 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'); - return { - 'success': false, - 'message': 'Upload failed due to a critical error. This might be caused by low device memory or a network issue.' - }; + await retryService.addApiToQueue( + endpoint: endpoint, + method: 'POST_MULTIPART', + fields: fields, + files: files, + ); + return {'success': false, 'message': 'Upload failed and has been queued for manual retry.'}; } } @@ -131,4 +162,4 @@ class BaseApiService { return {'success': false, 'message': 'Failed to parse server response. Body: ${response.body}'}; } } -} +} \ No newline at end of file diff --git a/lib/services/ftp_service.dart b/lib/services/ftp_service.dart new file mode 100644 index 0000000..288251f --- /dev/null +++ b/lib/services/ftp_service.dart @@ -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> 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(); + } + } +} \ No newline at end of file diff --git a/lib/services/local_storage_service.dart b/lib/services/local_storage_service.dart index a8c5cf3..16e705f 100644 --- a/lib/services/local_storage_service.dart +++ b/lib/services/local_storage_service.dart @@ -25,12 +25,15 @@ class LocalStorageService { return status.isGranted; } - Future _getPublicMMSV4Directory() async { + // --- MODIFIED: This method now accepts a serverName to create a server-specific root directory. --- + Future _getPublicMMSV4Directory({required String serverName}) async { if (await _requestPermissions()) { final Directory? externalDir = await getExternalStorageDirectory(); if (externalDir != null) { 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()) { await mmsv4Dir.create(recursive: true); } @@ -45,8 +48,9 @@ class LocalStorageService { // Part 2: Air Manual Sampling Methods // ======================================================================= - Future _getAirManualBaseDir() async { - final mmsv4Dir = await _getPublicMMSV4Directory(); + // --- MODIFIED: Method now requires serverName to get the correct base directory. --- + Future _getAirManualBaseDir({required String serverName}) async { + final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName); if (mmsv4Dir == null) return null; 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. - /// CORRECTED: This now robustly handles maps with File objects from both installation and collection, - /// preventing type errors that caused the installation screen to freeze. - Future saveAirSamplingRecord(Map data, String refID) async { - final baseDir = await _getAirManualBaseDir(); + // --- MODIFIED: Method now requires serverName. --- + Future saveAirSamplingRecord(Map data, String refID, {required String serverName}) async { + final baseDir = await _getAirManualBaseDir(serverName: serverName); if (baseDir == null) { debugPrint("Could not get public storage directory for Air Manual. Check permissions."); return null; @@ -91,6 +94,9 @@ class LocalStorageService { // Create a mutable copy of the data map to avoid modifying the original final Map 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 final installationImageKeys = ['imageFront', 'imageBack', 'imageLeft', 'imageRight', 'optionalImage1', 'optionalImage2', 'optionalImage3', 'optionalImage4']; @@ -101,7 +107,6 @@ class LocalStorageService { if (serializableData.containsKey(key) && serializableData[key] is File) { final newPath = await copyImageToLocal(serializableData[key]); 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); } } @@ -112,19 +117,15 @@ class LocalStorageService { final collectionImageKeys = ['imageFront', 'imageBack', 'imageLeft', 'imageRight', 'imageChart', 'imageFilterPaper', 'optionalImage1', 'optionalImage2', 'optionalImage3', 'optionalImage4']; 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) { final newPath = await copyImageToLocal(collectionMap[key]); collectionMap['${key}Path'] = newPath; - // ** THE FIX **: Only remove the key if it was a File object that we have processed. collectionMap.remove(key); } } - // Put the cleaned, serializable collection map back into the main data object 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')); await jsonFile.writeAsString(jsonEncode(serializableData)); debugPrint("Air sampling log and images saved to: ${eventDir.path}"); @@ -138,39 +139,44 @@ class LocalStorageService { } } - + // --- MODIFIED: This method now scans all server subdirectories to find all logs. --- Future>> getAllAirSamplingLogs() async { - final baseDir = await _getAirManualBaseDir(); - if (baseDir == null || !await baseDir.exists()) return []; + final mmsv4Root = await _getPublicMMSV4Directory(serverName: ''); // Get root MMSV4 without a server subfolder + if (mmsv4Root == null || !await mmsv4Root.exists()) return []; - try { - final List> logs = []; - final entities = baseDir.listSync(); + final List> allLogs = []; + final serverDirs = mmsv4Root.listSync().whereType(); - for (var entity in entities) { - if (entity is Directory) { - final jsonFile = File(p.join(entity.path, 'data.json')); - if (await jsonFile.exists()) { - final content = await jsonFile.readAsString(); - final data = jsonDecode(content) as Map; - data['logDirectory'] = entity.path; - logs.add(data); + for (var serverDir in serverDirs) { + final baseDir = Directory(p.join(serverDir.path, 'air', 'air_manual_sampling')); + if (!await baseDir.exists()) continue; + + try { + final entities = baseDir.listSync(); + for (var entity in entities) { + if (entity is Directory) { + final jsonFile = File(p.join(entity.path, 'data.json')); + if (await jsonFile.exists()) { + final content = await jsonFile.readAsString(); + final data = jsonDecode(content) as Map; + data['logDirectory'] = entity.path; + allLogs.add(data); + } } } + } catch (e) { + debugPrint("Error reading air logs from ${baseDir.path}: $e"); } - return logs; - } catch (e) { - debugPrint("Error getting all air sampling logs: $e"); - return []; } + return allLogs; } // ======================================================================= // Part 3: Tarball Specific Methods // ======================================================================= - Future _getTarballBaseDir() async { - final mmsv4Dir = await _getPublicMMSV4Directory(); + Future _getTarballBaseDir({required String serverName}) async { + final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName); if (mmsv4Dir == null) return null; final tarballDir = Directory(p.join(mmsv4Dir.path, 'marine', 'marine_tarball_sampling')); @@ -180,8 +186,8 @@ class LocalStorageService { return tarballDir; } - Future saveTarballSamplingData(TarballSamplingData data) async { - final baseDir = await _getTarballBaseDir(); + Future saveTarballSamplingData(TarballSamplingData data, {required String serverName}) async { + final baseDir = await _getTarballBaseDir(serverName: serverName); if (baseDir == null) { debugPrint("Could not get public storage directory. Check permissions."); return null; @@ -198,11 +204,10 @@ class LocalStorageService { } final Map jsonData = { ...data.toFormData(), 'submissionStatus': data.submissionStatus, 'submissionMessage': data.submissionMessage, 'reportId': data.reportId }; - + jsonData['serverConfigName'] = serverName; jsonData['selectedStation'] = data.selectedStation; final imageFiles = data.toImageFiles(); - for (var entry in imageFiles.entries) { final File? imageFile = entry.value; if (imageFile != null) { @@ -225,29 +230,33 @@ class LocalStorageService { } Future>> getAllTarballLogs() async { - final baseDir = await _getTarballBaseDir(); - if (baseDir == null || !await baseDir.exists()) return []; + final mmsv4Root = await _getPublicMMSV4Directory(serverName: ''); + if (mmsv4Root == null || !await mmsv4Root.exists()) return []; - try { - final List> logs = []; - final entities = baseDir.listSync(); + final List> allLogs = []; + final serverDirs = mmsv4Root.listSync().whereType(); - for (var entity in entities) { - if (entity is Directory) { - final jsonFile = File(p.join(entity.path, 'data.json')); - if (await jsonFile.exists()) { - final content = await jsonFile.readAsString(); - final data = jsonDecode(content) as Map; - data['logDirectory'] = entity.path; - logs.add(data); + for (var serverDir in serverDirs) { + final baseDir = Directory(p.join(serverDir.path, 'marine', 'marine_tarball_sampling')); + if (!await baseDir.exists()) continue; + try { + final entities = baseDir.listSync(); + for (var entity in entities) { + if (entity is Directory) { + final jsonFile = File(p.join(entity.path, 'data.json')); + if (await jsonFile.exists()) { + final content = await jsonFile.readAsString(); + final data = jsonDecode(content) as Map; + data['logDirectory'] = entity.path; + allLogs.add(data); + } } } + } catch (e) { + debugPrint("Error reading tarball logs from ${baseDir.path}: $e"); } - return logs; - } catch (e) { - debugPrint("Error getting all tarball logs: $e"); - return []; } + return allLogs; } Future updateTarballLog(Map updatedLogData) async { @@ -274,8 +283,8 @@ class LocalStorageService { // Part 4: Marine In-Situ Specific Methods // ======================================================================= - Future _getInSituBaseDir() async { - final mmsv4Dir = await _getPublicMMSV4Directory(); + Future _getInSituBaseDir({required String serverName}) async { + final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName); if (mmsv4Dir == null) return null; final inSituDir = Directory(p.join(mmsv4Dir.path, 'marine', 'marine_in_situ_sampling')); @@ -285,8 +294,8 @@ class LocalStorageService { return inSituDir; } - Future saveInSituSamplingData(InSituSamplingData data) async { - final baseDir = await _getInSituBaseDir(); + Future saveInSituSamplingData(InSituSamplingData data, {required String serverName}) async { + final baseDir = await _getInSituBaseDir(serverName: serverName); if (baseDir == null) { debugPrint("Could not get public storage directory for In-Situ. Check permissions."); return null; @@ -303,7 +312,7 @@ class LocalStorageService { } final Map jsonData = { ...data.toApiFormData(), 'submissionStatus': data.submissionStatus, 'submissionMessage': data.submissionMessage, 'reportId': data.reportId }; - + jsonData['serverConfigName'] = serverName; jsonData['selectedStation'] = data.selectedStation; final imageFiles = data.toApiImageFiles(); @@ -329,29 +338,33 @@ class LocalStorageService { } Future>> getAllInSituLogs() async { - final baseDir = await _getInSituBaseDir(); - if (baseDir == null || !await baseDir.exists()) return []; + final mmsv4Root = await _getPublicMMSV4Directory(serverName: ''); + if (mmsv4Root == null || !await mmsv4Root.exists()) return []; - try { - final List> logs = []; - final entities = baseDir.listSync(); + final List> allLogs = []; + final serverDirs = mmsv4Root.listSync().whereType(); - for (var entity in entities) { - if (entity is Directory) { - final jsonFile = File(p.join(entity.path, 'data.json')); - if (await jsonFile.exists()) { - final content = await jsonFile.readAsString(); - final data = jsonDecode(content) as Map; - data['logDirectory'] = entity.path; - logs.add(data); + for (var serverDir in serverDirs) { + final baseDir = Directory(p.join(serverDir.path, 'marine', 'marine_in_situ_sampling')); + if (!await baseDir.exists()) continue; + try { + final entities = baseDir.listSync(); + for (var entity in entities) { + if (entity is Directory) { + final jsonFile = File(p.join(entity.path, 'data.json')); + if (await jsonFile.exists()) { + final content = await jsonFile.readAsString(); + final data = jsonDecode(content) as Map; + data['logDirectory'] = entity.path; + allLogs.add(data); + } } } + } catch (e) { + debugPrint("Error reading in-situ logs from ${baseDir.path}: $e"); } - return logs; - } catch (e) { - debugPrint("Error getting all in-situ logs: $e"); - return []; } + return allLogs; } Future updateInSituLog(Map updatedLogData) async { @@ -377,8 +390,8 @@ class LocalStorageService { // Part 5: River In-Situ Specific Methods // ======================================================================= - Future _getRiverInSituBaseDir(String? samplingType) async { - final mmsv4Dir = await _getPublicMMSV4Directory(); + Future _getRiverInSituBaseDir(String? samplingType, {required String serverName}) async { + final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName); if (mmsv4Dir == null) return null; String subfolderName; @@ -395,8 +408,8 @@ class LocalStorageService { return inSituDir; } - Future saveRiverInSituSamplingData(RiverInSituSamplingData data) async { - final baseDir = await _getRiverInSituBaseDir(data.samplingType); + Future saveRiverInSituSamplingData(RiverInSituSamplingData data, {required String serverName}) async { + final baseDir = await _getRiverInSituBaseDir(data.samplingType, serverName: serverName); if (baseDir == null) { debugPrint("Could not get public storage directory for River In-Situ. Check permissions."); return null; @@ -413,7 +426,7 @@ class LocalStorageService { } final Map jsonData = { ...data.toApiFormData(), 'submissionStatus': data.submissionStatus, 'submissionMessage': data.submissionMessage, 'reportId': data.reportId }; - + jsonData['serverConfigName'] = serverName; jsonData['selectedStation'] = data.selectedStation; final imageFiles = data.toApiImageFiles(); @@ -439,37 +452,38 @@ class LocalStorageService { } Future>> getAllRiverInSituLogs() async { - final mmsv4Dir = await _getPublicMMSV4Directory(); - if (mmsv4Dir == null) return []; + final mmsv4Root = await _getPublicMMSV4Directory(serverName: ''); + if (mmsv4Root == null || !await mmsv4Root.exists()) return []; - final topLevelDir = Directory(p.join(mmsv4Dir.path, 'river', 'river_in_situ_sampling')); - if (!await topLevelDir.exists()) return []; + final List> allLogs = []; + final serverDirs = mmsv4Root.listSync().whereType(); - try { - final List> logs = []; - final typeSubfolders = topLevelDir.listSync(); - - for (var typeSubfolder in typeSubfolders) { - if (typeSubfolder is Directory) { - final eventFolders = typeSubfolder.listSync(); - for (var eventFolder in eventFolders) { - if (eventFolder is Directory) { - final jsonFile = File(p.join(eventFolder.path, 'data.json')); - if (await jsonFile.exists()) { - final content = await jsonFile.readAsString(); - final data = jsonDecode(content) as Map; - data['logDirectory'] = eventFolder.path; - logs.add(data); + for (var serverDir in serverDirs) { + final topLevelDir = Directory(p.join(serverDir.path, 'river', 'river_in_situ_sampling')); + if (!await topLevelDir.exists()) continue; + try { + final typeSubfolders = topLevelDir.listSync(); + for (var typeSubfolder in typeSubfolders) { + if (typeSubfolder is Directory) { + final eventFolders = typeSubfolder.listSync(); + for (var eventFolder in eventFolders) { + if (eventFolder is Directory) { + final jsonFile = File(p.join(eventFolder.path, 'data.json')); + if (await jsonFile.exists()) { + final content = await jsonFile.readAsString(); + final data = jsonDecode(content) as Map; + data['logDirectory'] = eventFolder.path; + allLogs.add(data); + } } } } } + } catch (e) { + debugPrint("Error getting all river in-situ logs from ${topLevelDir.path}: $e"); } - return logs; - } catch (e) { - debugPrint("Error getting all river in-situ logs: $e"); - return []; } + return allLogs; } Future updateRiverInSituLog(Map updatedLogData) async { diff --git a/lib/services/retry_service.dart b/lib/services/retry_service.dart new file mode 100644 index 0000000..f8896f7 --- /dev/null +++ b/lib/services/retry_service.dart @@ -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 addApiToQueue({ + required String endpoint, + required String method, // e.g., 'POST' or 'POST_MULTIPART' + Map? body, + Map? fields, + Map? 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 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>> getPendingTasks() { + return _dbHelper.getPendingRequests(); + } + + /// Attempts to re-execute a single failed task from the queue. + /// Returns `true` on success, `false` on failure. + Future 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 result; + + if (method == 'POST_MULTIPART') { + // Reconstruct fields and files from the stored payload + final Map fields = Map.from(payload['fields'] ?? {}); + final Map files = (payload['files'] as Map?) + ?.map((key, value) => MapEntry(key, File(value as String))) ?? {}; + + result = await _baseApiService.postMultipart(endpoint: endpoint, fields: fields, files: files); + } else { // Assume 'POST' + final Map body = Map.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; + } +} \ No newline at end of file diff --git a/lib/services/server_config_service.dart b/lib/services/server_config_service.dart new file mode 100644 index 0000000..2867770 --- /dev/null +++ b/lib/services/server_config_service.dart @@ -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 setActiveApiConfig(Map config) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_activeApiConfigKey, jsonEncode(config)); + } + + /// Retrieves the currently active API configuration from local storage. + Future?> getActiveApiConfig() async { + final prefs = await SharedPreferences.getInstance(); + final String? configString = prefs.getString(_activeApiConfigKey); + if (configString != null) { + return jsonDecode(configString) as Map; + } + return null; + } + + /// Gets the API Base URL from the active configuration. + /// Falls back to a default URL if none is set. + Future 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 setActiveFtpConfig(Map config) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_activeFtpConfigKey, jsonEncode(config)); + } + + /// Retrieves the currently active FTP configuration from local storage. + Future?> getActiveFtpConfig() async { + final prefs = await SharedPreferences.getInstance(); + final String? configString = prefs.getString(_activeFtpConfigKey); + if (configString != null) { + return jsonDecode(configString) as Map; + } + return null; + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index e4fc48a..beac146 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -257,6 +257,14 @@ packages: description: flutter source: sdk version: "0.0.0" + ftpconnect: + dependency: "direct main" + description: + name: ftpconnect + sha256: "6445074d957fe6f5ca8c68c95538132509d4b3256806fcfa35d8e59033b398c0" + url: "https://pub.dev" + source: hosted + version: "2.0.5" geolocator: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 0660998..5fb22f5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,6 +36,7 @@ dependencies: geolocator: ^11.0.0 # For GPS functionality image: ^4.1.3 # For image processing (watermarks) permission_handler: ^11.3.1 + ftpconnect: ^2.0.5 # --- Added for In-Situ Sampling Module --- simple_barcode_scanner: ^0.3.0 # For scanning sample IDs #flutter_blue_classic: ^0.0.3 # For Bluetooth sonde connection