create multiple submission process either using api or ftp
This commit is contained in:
parent
6d37abf141
commit
5930dd500e
@ -28,7 +28,7 @@
|
||||
<!-- END: STORAGE PERMISSIONS -->
|
||||
|
||||
<application
|
||||
android:label="MMS V4 Debug"
|
||||
android:label="MMS V4 debug"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
|
||||
@ -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<Map<String, dynamic>>? _states;
|
||||
List<Map<String, dynamic>>? _appSettings;
|
||||
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 ---
|
||||
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 appSettings => _appSettings;
|
||||
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 ---
|
||||
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<void> 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
|
||||
|
||||
@ -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<String, dynamic> 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<AirManualDataStatusLog> {
|
||||
final LocalStorageService _localStorageService = LocalStorageService();
|
||||
final ApiService _apiService = ApiService();
|
||||
// --- MODIFIED: Use AirSamplingService for resubmission logic ---
|
||||
final AirSamplingService _airSamplingService = AirSamplingService();
|
||||
|
||||
// Raw data lists
|
||||
List<SubmissionLogEntry> _installationLogs = [];
|
||||
List<SubmissionLogEntry> _collectionLogs = [];
|
||||
// --- MODIFIED: Simplified state management to a single source of truth ---
|
||||
List<SubmissionLogEntry> _allLogs = [];
|
||||
List<SubmissionLogEntry> _filteredLogs = [];
|
||||
|
||||
// Filtered lists for the UI
|
||||
List<SubmissionLogEntry> _filteredInstallationLogs = [];
|
||||
List<SubmissionLogEntry> _filteredCollectionLogs = [];
|
||||
|
||||
// Per-category search controllers
|
||||
final Map<String, TextEditingController> _searchControllers = {};
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
bool _isLoading = true;
|
||||
final Map<String, bool> _isResubmitting = {};
|
||||
@ -59,15 +61,13 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
||||
@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<AirManualDataStatusLog> {
|
||||
setState(() => _isLoading = true);
|
||||
final airLogs = await _localStorageService.getAllAirSamplingLogs();
|
||||
|
||||
final List<SubmissionLogEntry> tempInstallation = [];
|
||||
final List<SubmissionLogEntry> tempCollection = [];
|
||||
final List<SubmissionLogEntry> 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<AirManualDataStatusLog> {
|
||||
DateTime _parseCollectionDateTime(Map<String, dynamic>? 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<AirManualDataStatusLog> {
|
||||
|
||||
String _getStatusMessage(Map<String, dynamic> 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<AirManualDataStatusLog> {
|
||||
}
|
||||
|
||||
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<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);
|
||||
|
||||
try {
|
||||
final logData = log.rawData;
|
||||
log.type == 'Installation'
|
||||
? await _submitInstallation(logData)
|
||||
: await _submitCollection(logData);
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final appSettings = authProvider.appSettings;
|
||||
Map<String, dynamic> 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<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
|
||||
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<AirManualDataStatusLog> {
|
||||
: 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<AirManualDataStatusLog> {
|
||||
);
|
||||
}
|
||||
|
||||
// THIS SECTION IS UPDATED TO MATCH THE MARINE LOG UI
|
||||
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(
|
||||
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<AirManualDataStatusLog> {
|
||||
);
|
||||
}
|
||||
|
||||
// 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<AirManualDataStatusLog> {
|
||||
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),
|
||||
|
||||
@ -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<String, dynamic> 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<MarineManualDataStatusLog> {
|
||||
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<MarineManualDataStatusLog> {
|
||||
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<MarineManualDataStatusLog> {
|
||||
|
||||
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<MarineManualDataStatusLog> {
|
||||
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<MarineManualDataStatusLog> {
|
||||
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),
|
||||
|
||||
@ -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<MarineInSituSampling> {
|
||||
// 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<MarineInSituSampling> {
|
||||
_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);
|
||||
|
||||
|
||||
@ -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<TarballSamplingStep3Summary> {
|
||||
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<TarballSamplingStep3Summar
|
||||
widget.data.submissionMessage = result['message'];
|
||||
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.
|
||||
final String? localPath = await _localStorageService.saveTarballSamplingData(widget.data);
|
||||
final String? localPath = await _localStorageService.saveTarballSamplingData(widget.data, serverName: serverName);
|
||||
|
||||
if (mounted) {
|
||||
if (localPath != null) {
|
||||
|
||||
@ -8,6 +8,8 @@ import 'package:environment_monitoring_app/auth_provider.dart'; // ADDED: Import
|
||||
import '../../../models/river_in_situ_sampling_data.dart';
|
||||
import '../../../services/river_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/river_in_situ_step_1_sampling_info.dart';
|
||||
import 'widgets/river_in_situ_step_2_site_info.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 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<RiverInSituSamplingScreen> {
|
||||
_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);
|
||||
|
||||
|
||||
@ -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<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 ---
|
||||
Future<Map<String, dynamic>> 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<Map<String, dynamic>> _uploadInstallationImagesAndUpdate(AirInstallationData data, List<Map<String, dynamic>>? appSettings) async {
|
||||
// MODIFIED: Method now requires the serverName to pass to the save method.
|
||||
Future<Map<String, dynamic>> _uploadInstallationImagesAndUpdate(AirInstallationData data, List<Map<String, dynamic>>? 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<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) ---
|
||||
Future<Map<String, dynamic>> 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<Map<String, dynamic>> _uploadCollectionImagesAndUpdate(AirCollectionData data, AirInstallationData installationData, List<Map<String, dynamic>>? appSettings) async {
|
||||
// --- OFFLINE-FIRST HELPER (CORRECTED) ---
|
||||
// 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, {required String serverName}) async {
|
||||
// --- OFFLINE-FIRST HELPER (CORRECTED & MODIFIED) ---
|
||||
Future<Map<String, dynamic>> 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,
|
||||
|
||||
@ -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<Database> 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<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 $_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<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<List<Map<String, dynamic>>?> loadParameterLimits() => _loadData(_parameterLimitsTable, 'limit');
|
||||
|
||||
// --- ADDED: Methods for independent API and FTP configurations ---
|
||||
Future<void> upsertApiConfigs(List<Map<String, dynamic>> data) => _upsertData(_apiConfigsTable, 'api_config_id', data, 'config');
|
||||
Future<void> deleteApiConfigs(List<dynamic> ids) => _deleteData(_apiConfigsTable, 'api_config_id', ids);
|
||||
Future<List<Map<String, dynamic>>?> loadApiConfigs() => _loadData(_apiConfigsTable, 'config');
|
||||
|
||||
Future<void> upsertFtpConfigs(List<Map<String, dynamic>> data) => _upsertData(_ftpConfigsTable, 'ftp_config_id', data, 'config');
|
||||
Future<void> deleteFtpConfigs(List<dynamic> ids) => _deleteData(_ftpConfigsTable, 'ftp_config_id', ids);
|
||||
Future<List<Map<String, dynamic>>?> loadFtpConfigs() => _loadData(_ftpConfigsTable, 'config');
|
||||
|
||||
// --- ADDED: Methods for the new retry queue ---
|
||||
Future<int> queueFailedRequest(Map<String, dynamic> data) async {
|
||||
final db = await database;
|
||||
return await db.insert(_retryQueueTable, data, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getPendingRequests() async {
|
||||
final db = await database;
|
||||
return await db.query(_retryQueueTable, where: 'status = ?', whereArgs: ['pending']);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getRequestById(int id) async {
|
||||
final db = await database;
|
||||
final results = await db.query(_retryQueueTable, where: 'id = ?', whereArgs: [id]);
|
||||
return results.isNotEmpty ? results.first : null;
|
||||
}
|
||||
|
||||
Future<void> deleteRequestFromQueue(int id) async {
|
||||
final db = await database;
|
||||
await db.delete(_retryQueueTable, where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,24 @@
|
||||
import 'dart:convert';
|
||||
import 'dart: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<Map<String, String>> _getHeaders() async {
|
||||
@ -29,29 +40,41 @@ class BaseApiService {
|
||||
|
||||
// Generic GET request handler
|
||||
Future<Map<String, dynamic>> 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<Map<String, dynamic>> post(String endpoint, Map<String, dynamic> 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<String, String> fields,
|
||||
required Map<String, File> 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.'};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
79
lib/services/ftp_service.dart
Normal file
79
lib/services/ftp_service.dart
Normal file
@ -0,0 +1,79 @@
|
||||
// lib/services/ftp_service.dart
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:ftpconnect/ftpconnect.dart';
|
||||
import 'package:environment_monitoring_app/services/server_config_service.dart';
|
||||
// --- ADDED: Import for the new service that manages the retry queue ---
|
||||
import 'package:environment_monitoring_app/services/retry_service.dart';
|
||||
|
||||
class FtpService {
|
||||
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||
// --- REMOVED: This creates an infinite loop with RetryService ---
|
||||
// final RetryService _retryService = RetryService();
|
||||
|
||||
/// Uploads a single file to the active server's FTP.
|
||||
///
|
||||
/// [fileToUpload] The local file to be uploaded.
|
||||
/// [remotePath] The destination path on the FTP server (e.g., '/uploads/images/').
|
||||
/// Returns a map with 'success' and 'message' keys.
|
||||
Future<Map<String, dynamic>> uploadFile(File fileToUpload, String remotePath) async {
|
||||
final config = await _serverConfigService.getActiveFtpConfig();
|
||||
|
||||
if (config == null || config['ftp_host'] == null || config['ftp_user'] == null || config['ftp_pass'] == null) {
|
||||
return {'success': false, 'message': 'FTP credentials are not configured or selected.'};
|
||||
}
|
||||
|
||||
final ftpHost = config['ftp_host'] as String;
|
||||
final ftpUser = config['ftp_user'] as String;
|
||||
final ftpPass = config['ftp_pass'] as String;
|
||||
final ftpPort = config['ftp_port'] as int? ?? 21; // Default to port 21
|
||||
|
||||
final ftpConnect = FTPConnect(
|
||||
ftpHost,
|
||||
user: ftpUser,
|
||||
pass: ftpPass,
|
||||
port: ftpPort,
|
||||
showLog: kDebugMode, // Show logs only in debug mode
|
||||
timeout: 60, // --- MODIFIED: Set the timeout to 60 seconds ---
|
||||
);
|
||||
|
||||
try {
|
||||
debugPrint('FTP: Connecting to $ftpHost...');
|
||||
await ftpConnect.connect();
|
||||
|
||||
debugPrint('FTP: Uploading file ${fileToUpload.path} to $remotePath...');
|
||||
bool res = await ftpConnect.uploadFileWithRetry(
|
||||
fileToUpload,
|
||||
pRemoteName: remotePath,
|
||||
pRetryCount: 3, // --- MODIFIED: Retry three times on failure ---
|
||||
);
|
||||
|
||||
if (res) {
|
||||
return {'success': true, 'message': 'File uploaded successfully via FTP.'};
|
||||
} else {
|
||||
// --- MODIFIED: Create a local instance of RetryService to break the circular dependency ---
|
||||
final retryService = RetryService();
|
||||
debugPrint('FTP upload for ${fileToUpload.path} failed after retries, queueing.');
|
||||
await retryService.addFtpToQueue(
|
||||
localFilePath: fileToUpload.path,
|
||||
remotePath: remotePath
|
||||
);
|
||||
return {'success': false, 'message': 'FTP upload failed and has been queued for manual retry.'};
|
||||
}
|
||||
} catch (e) {
|
||||
// --- MODIFIED: Create a local instance of RetryService to break the circular dependency ---
|
||||
final retryService = RetryService();
|
||||
debugPrint('FTP upload for ${fileToUpload.path} failed with an exception, queueing. Error: $e');
|
||||
await retryService.addFtpToQueue(
|
||||
localFilePath: fileToUpload.path,
|
||||
remotePath: remotePath
|
||||
);
|
||||
return {'success': false, 'message': 'FTP upload failed and has been queued for manual retry.'};
|
||||
} finally {
|
||||
// Always ensure disconnection, even if an error occurs.
|
||||
debugPrint('FTP: Disconnecting...');
|
||||
await ftpConnect.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -25,12 +25,15 @@ class LocalStorageService {
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
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()) {
|
||||
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<Directory?> _getAirManualBaseDir() async {
|
||||
final mmsv4Dir = await _getPublicMMSV4Directory();
|
||||
// --- MODIFIED: Method now requires serverName to get the correct base directory. ---
|
||||
Future<Directory?> _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<String?> saveAirSamplingRecord(Map<String, dynamic> data, String refID) async {
|
||||
final baseDir = await _getAirManualBaseDir();
|
||||
// --- MODIFIED: Method now requires serverName. ---
|
||||
Future<String?> saveAirSamplingRecord(Map<String, dynamic> 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<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
|
||||
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<List<Map<String, dynamic>>> 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<Map<String, dynamic>> logs = [];
|
||||
final entities = baseDir.listSync();
|
||||
final List<Map<String, dynamic>> allLogs = [];
|
||||
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
|
||||
|
||||
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<String, dynamic>;
|
||||
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<String, dynamic>;
|
||||
data['logDirectory'] = entity.path;
|
||||
allLogs.add(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error reading air logs from ${baseDir.path}: $e");
|
||||
}
|
||||
return logs;
|
||||
} catch (e) {
|
||||
debugPrint("Error getting all air sampling logs: $e");
|
||||
return [];
|
||||
}
|
||||
return allLogs;
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Part 3: Tarball Specific Methods
|
||||
// =======================================================================
|
||||
|
||||
Future<Directory?> _getTarballBaseDir() async {
|
||||
final mmsv4Dir = await _getPublicMMSV4Directory();
|
||||
Future<Directory?> _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<String?> saveTarballSamplingData(TarballSamplingData data) async {
|
||||
final baseDir = await _getTarballBaseDir();
|
||||
Future<String?> 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<String, dynamic> 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<List<Map<String, dynamic>>> 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<Map<String, dynamic>> logs = [];
|
||||
final entities = baseDir.listSync();
|
||||
final List<Map<String, dynamic>> allLogs = [];
|
||||
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
|
||||
|
||||
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<String, dynamic>;
|
||||
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<String, dynamic>;
|
||||
data['logDirectory'] = entity.path;
|
||||
allLogs.add(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error reading tarball logs from ${baseDir.path}: $e");
|
||||
}
|
||||
return logs;
|
||||
} catch (e) {
|
||||
debugPrint("Error getting all tarball logs: $e");
|
||||
return [];
|
||||
}
|
||||
return allLogs;
|
||||
}
|
||||
|
||||
Future<void> updateTarballLog(Map<String, dynamic> updatedLogData) async {
|
||||
@ -274,8 +283,8 @@ class LocalStorageService {
|
||||
// Part 4: Marine In-Situ Specific Methods
|
||||
// =======================================================================
|
||||
|
||||
Future<Directory?> _getInSituBaseDir() async {
|
||||
final mmsv4Dir = await _getPublicMMSV4Directory();
|
||||
Future<Directory?> _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<String?> saveInSituSamplingData(InSituSamplingData data) async {
|
||||
final baseDir = await _getInSituBaseDir();
|
||||
Future<String?> 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<String, dynamic> 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<List<Map<String, dynamic>>> 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<Map<String, dynamic>> logs = [];
|
||||
final entities = baseDir.listSync();
|
||||
final List<Map<String, dynamic>> allLogs = [];
|
||||
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
|
||||
|
||||
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<String, dynamic>;
|
||||
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<String, dynamic>;
|
||||
data['logDirectory'] = entity.path;
|
||||
allLogs.add(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error reading in-situ logs from ${baseDir.path}: $e");
|
||||
}
|
||||
return logs;
|
||||
} catch (e) {
|
||||
debugPrint("Error getting all in-situ logs: $e");
|
||||
return [];
|
||||
}
|
||||
return allLogs;
|
||||
}
|
||||
|
||||
Future<void> updateInSituLog(Map<String, dynamic> updatedLogData) async {
|
||||
@ -377,8 +390,8 @@ class LocalStorageService {
|
||||
// Part 5: River In-Situ Specific Methods
|
||||
// =======================================================================
|
||||
|
||||
Future<Directory?> _getRiverInSituBaseDir(String? samplingType) async {
|
||||
final mmsv4Dir = await _getPublicMMSV4Directory();
|
||||
Future<Directory?> _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<String?> saveRiverInSituSamplingData(RiverInSituSamplingData data) async {
|
||||
final baseDir = await _getRiverInSituBaseDir(data.samplingType);
|
||||
Future<String?> 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<String, dynamic> 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<List<Map<String, dynamic>>> 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<Map<String, dynamic>> allLogs = [];
|
||||
final serverDirs = mmsv4Root.listSync().whereType<Directory>();
|
||||
|
||||
try {
|
||||
final List<Map<String, dynamic>> 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<String, dynamic>;
|
||||
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<String, dynamic>;
|
||||
data['logDirectory'] = eventFolder.path;
|
||||
allLogs.add(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error getting all river in-situ logs from ${topLevelDir.path}: $e");
|
||||
}
|
||||
return logs;
|
||||
} catch (e) {
|
||||
debugPrint("Error getting all river in-situ logs: $e");
|
||||
return [];
|
||||
}
|
||||
return allLogs;
|
||||
}
|
||||
|
||||
Future<void> updateRiverInSituLog(Map<String, dynamic> updatedLogData) async {
|
||||
|
||||
131
lib/services/retry_service.dart
Normal file
131
lib/services/retry_service.dart
Normal file
@ -0,0 +1,131 @@
|
||||
// lib/services/retry_service.dart
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:environment_monitoring_app/services/api_service.dart';
|
||||
import 'package:environment_monitoring_app/services/base_api_service.dart';
|
||||
import 'package:environment_monitoring_app/services/ftp_service.dart';
|
||||
|
||||
/// A dedicated service to manage the queue of failed API and FTP requests
|
||||
/// for manual resubmission.
|
||||
class RetryService {
|
||||
// Use singleton instances to avoid re-creating services unnecessarily.
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final BaseApiService _baseApiService = BaseApiService();
|
||||
final FtpService _ftpService = FtpService();
|
||||
|
||||
/// Adds a failed API request to the local database queue.
|
||||
Future<void> addApiToQueue({
|
||||
required String endpoint,
|
||||
required String method, // e.g., 'POST' or 'POST_MULTIPART'
|
||||
Map<String, dynamic>? body,
|
||||
Map<String, String>? fields,
|
||||
Map<String, File>? files,
|
||||
}) async {
|
||||
// We must convert File objects to their string paths before saving to JSON.
|
||||
final serializableFiles = files?.map((key, value) => MapEntry(key, value.path));
|
||||
|
||||
final payload = {
|
||||
'method': method,
|
||||
'body': body,
|
||||
'fields': fields,
|
||||
'files': serializableFiles,
|
||||
};
|
||||
|
||||
await _dbHelper.queueFailedRequest({
|
||||
'type': 'api',
|
||||
'endpoint_or_path': endpoint,
|
||||
'payload': jsonEncode(payload),
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
'status': 'pending',
|
||||
});
|
||||
debugPrint("API request for endpoint '$endpoint' has been queued for retry.");
|
||||
}
|
||||
|
||||
/// Adds a failed FTP upload to the local database queue.
|
||||
Future<void> addFtpToQueue({
|
||||
required String localFilePath,
|
||||
required String remotePath,
|
||||
}) async {
|
||||
final payload = {'localFilePath': localFilePath};
|
||||
await _dbHelper.queueFailedRequest({
|
||||
'type': 'ftp',
|
||||
'endpoint_or_path': remotePath,
|
||||
'payload': jsonEncode(payload),
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
'status': 'pending',
|
||||
});
|
||||
debugPrint("FTP upload for file '$localFilePath' has been queued for retry.");
|
||||
}
|
||||
|
||||
/// Retrieves all tasks currently in the 'pending' state from the queue.
|
||||
Future<List<Map<String, dynamic>>> getPendingTasks() {
|
||||
return _dbHelper.getPendingRequests();
|
||||
}
|
||||
|
||||
/// Attempts to re-execute a single failed task from the queue.
|
||||
/// Returns `true` on success, `false` on failure.
|
||||
Future<bool> retryTask(int taskId) async {
|
||||
final task = await _dbHelper.getRequestById(taskId);
|
||||
if (task == null) {
|
||||
debugPrint("Retry failed: Task with ID $taskId not found in the queue.");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool success = false;
|
||||
final payload = jsonDecode(task['payload'] as String);
|
||||
|
||||
try {
|
||||
if (task['type'] == 'api') {
|
||||
final endpoint = task['endpoint_or_path'] as String;
|
||||
final method = payload['method'] as String;
|
||||
|
||||
debugPrint("Retrying API task $taskId: $method to $endpoint");
|
||||
Map<String, dynamic> result;
|
||||
|
||||
if (method == 'POST_MULTIPART') {
|
||||
// Reconstruct fields and files from the stored payload
|
||||
final Map<String, String> fields = Map<String, String>.from(payload['fields'] ?? {});
|
||||
final Map<String, File> files = (payload['files'] as Map<String, dynamic>?)
|
||||
?.map((key, value) => MapEntry(key, File(value as String))) ?? {};
|
||||
|
||||
result = await _baseApiService.postMultipart(endpoint: endpoint, fields: fields, files: files);
|
||||
} else { // Assume 'POST'
|
||||
final Map<String, dynamic> body = Map<String, dynamic>.from(payload['body'] ?? {});
|
||||
result = await _baseApiService.post(endpoint, body);
|
||||
}
|
||||
|
||||
success = result['success'];
|
||||
|
||||
} else if (task['type'] == 'ftp') {
|
||||
final remotePath = task['endpoint_or_path'] as String;
|
||||
final localFile = File(payload['localFilePath'] as String);
|
||||
|
||||
debugPrint("Retrying FTP task $taskId: Uploading ${localFile.path} to $remotePath");
|
||||
|
||||
// Ensure the file still exists before attempting to re-upload
|
||||
if (await localFile.exists()) {
|
||||
final result = await _ftpService.uploadFile(localFile, remotePath);
|
||||
// The FTP service already queues on failure, so we only care about success here.
|
||||
success = result['success'];
|
||||
} else {
|
||||
debugPrint("Retry failed for FTP task $taskId: Source file no longer exists at ${localFile.path}");
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("A critical error occurred while retrying task $taskId: $e");
|
||||
success = false;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
debugPrint("Task $taskId completed successfully. Removing from queue.");
|
||||
await _dbHelper.deleteRequestFromQueue(taskId);
|
||||
} else {
|
||||
debugPrint("Retry attempt for task $taskId failed. It will remain in the queue.");
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
55
lib/services/server_config_service.dart
Normal file
55
lib/services/server_config_service.dart
Normal file
@ -0,0 +1,55 @@
|
||||
// lib/services/server_config_service.dart
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class ServerConfigService {
|
||||
static const String _activeApiConfigKey = 'active_api_config';
|
||||
static const String _activeFtpConfigKey = 'active_ftp_config';
|
||||
// A default URL to prevent crashes if no config is set.
|
||||
static const String _defaultApiUrl = 'https://dev14.pstw.com.my/v1';
|
||||
|
||||
// --- API Config Methods ---
|
||||
|
||||
/// Saves the selected API configuration as the currently active one.
|
||||
Future<void> setActiveApiConfig(Map<String, dynamic> config) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_activeApiConfigKey, jsonEncode(config));
|
||||
}
|
||||
|
||||
/// Retrieves the currently active API configuration from local storage.
|
||||
Future<Map<String, dynamic>?> getActiveApiConfig() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final String? configString = prefs.getString(_activeApiConfigKey);
|
||||
if (configString != null) {
|
||||
return jsonDecode(configString) as Map<String, dynamic>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Gets the API Base URL from the active configuration.
|
||||
/// Falls back to a default URL if none is set.
|
||||
Future<String> getActiveApiUrl() async {
|
||||
final config = await getActiveApiConfig();
|
||||
// The key from the database is 'api_url'.
|
||||
return config?['api_url'] as String? ?? _defaultApiUrl;
|
||||
}
|
||||
|
||||
// --- FTP Config Methods ---
|
||||
|
||||
/// Saves the selected FTP configuration as the currently active one.
|
||||
Future<void> setActiveFtpConfig(Map<String, dynamic> config) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_activeFtpConfigKey, jsonEncode(config));
|
||||
}
|
||||
|
||||
/// Retrieves the currently active FTP configuration from local storage.
|
||||
Future<Map<String, dynamic>?> getActiveFtpConfig() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final String? configString = prefs.getString(_activeFtpConfigKey);
|
||||
if (configString != null) {
|
||||
return jsonDecode(configString) as Map<String, dynamic>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -257,6 +257,14 @@ packages:
|
||||
description: flutter
|
||||
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:
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user