upgrade multiple api and ftp submission

This commit is contained in:
ALim Aidrus 2025-08-24 21:40:52 +08:00
parent 5930dd500e
commit 1a1a1bd7d0
19 changed files with 1112 additions and 267 deletions

View File

@ -27,6 +27,7 @@
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<!-- END: STORAGE PERMISSIONS --> <!-- END: STORAGE PERMISSIONS -->
<!-- MMS V4 1.2.02 -->
<application <application
android:label="MMS V4 debug" android:label="MMS V4 debug"
android:name="${applicationName}" android:name="${applicationName}"

View File

@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'dart:convert'; // Added for jsonEncode
import 'air_installation_data.dart'; import 'air_installation_data.dart';
class AirCollectionData { class AirCollectionData {

View File

@ -1,6 +1,7 @@
// lib/models/air_installation_data.dart // lib/models/air_installation_data.dart
import 'dart:io'; import 'dart:io';
import 'dart:convert'; // Added for jsonEncode
import 'air_collection_data.dart'; // Import the collection data model import 'air_collection_data.dart'; // Import the collection data model
class AirInstallationData { class AirInstallationData {
@ -216,4 +217,67 @@ class AirInstallationData {
if (optionalImage4 != null) files['optional_04'] = optionalImage4!; if (optionalImage4 != null) files['optional_04'] = optionalImage4!;
return files; return files;
} }
// --- ADDED: Methods to format data for FTP submission as separate JSON files ---
/// Creates a single JSON object with all submission data, mimicking 'db.json'
Map<String, dynamic> toDbJson() {
final data = {
'refID': refID,
'air_man_id': airManId,
'samplingDate': samplingDate,
'clientId': clientId,
'installationDate': installationDate,
'installationTime': installationTime,
'stateID': stateID,
'locationName': locationName,
'stationID': stationID,
'region': region,
'weather': weather,
'temp': temp,
'powerFailure': powerFailure,
'pm10FilterId': pm10FilterId,
'pm25FilterId': pm25FilterId,
'remark': remark,
'installationUserId': installationUserId,
'installationUserName': installationUserName,
'status': status,
'optionalRemark1': optionalRemark1,
'optionalRemark2': optionalRemark2,
'optionalRemark3': optionalRemark3,
'optionalRemark4': optionalRemark4,
'collectionData': collectionData?.toMap(),
};
return data;
}
/// Creates a JSON object for basic installation form info, mimicking 'installation_form.json'.
Map<String, dynamic> toInstallationFormJson() {
return {
'air_man_station_code': stationID,
'air_man_sampling_date': samplingDate,
'air_man_client_id': clientId,
'air_man_installation_date': installationDate,
'air_man_installation_time': installationTime,
'air_man_installation_weather': weather,
'air_man_installation_temperature': temp,
'air_man_installation_power_failure': powerFailure ? 'Yes' : 'No',
'air_man_installation_pm10_filter_id': pm10FilterId,
'air_man_installation_pm25_filter_id': pm25FilterId,
'air_man_installation_remarks': remark,
'air_man_installation_image_optional_01_remarks': optionalRemark1,
'air_man_installation_image_optional_02_remarks': optionalRemark2,
'air_man_installation_image_optional_03_remarks': optionalRemark3,
'air_man_installation_image_optional_04_remarks': optionalRemark4,
};
}
/// Creates a JSON object for manual info, mimicking a generic 'manual_info.json' file.
Map<String, dynamic> toManualInfoJson() {
return {
'weather': weather,
'remarks_event': remark,
'remarks_lab': null,
};
}
} }

View File

@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'dart:convert'; // Added for jsonEncode
/// A data model class to hold all information for the multi-step /// A data model class to hold all information for the multi-step
/// In-Situ Sampling form. /// In-Situ Sampling form.
@ -71,6 +72,61 @@ class InSituSamplingData {
this.samplingTime, this.samplingTime,
}); });
// --- ADDED: Factory constructor to create a new instance from a map ---
factory InSituSamplingData.fromJson(Map<String, dynamic> json) {
double? doubleFromJson(dynamic value) {
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
int? intFromJson(dynamic value) {
if (value is int) return value;
if (value is String) return int.tryParse(value);
return null;
}
return InSituSamplingData()
..firstSamplerName = json['first_sampler_name']
..firstSamplerUserId = intFromJson(json['first_sampler_user_id'])
..secondSampler = json['secondSampler']
..samplingDate = json['man_date']
..samplingTime = json['man_time']
..samplingType = json['man_type']
..sampleIdCode = json['man_sample_id_code']
..selectedStateName = json['selectedStateName']
..selectedCategoryName = json['selectedCategoryName']
..selectedStation = json['selectedStation']
..stationLatitude = json['stationLatitude']
..stationLongitude = json['stationLongitude']
..currentLatitude = json['man_current_latitude']?.toString()
..currentLongitude = json['man_current_longitude']?.toString()
..distanceDifferenceInKm = doubleFromJson(json['man_distance_difference'])
..distanceDifferenceRemarks = json['man_distance_difference_remarks']
..weather = json['man_weather']
..tideLevel = json['man_tide_level']
..seaCondition = json['man_sea_condition']
..eventRemarks = json['man_event_remark']
..labRemarks = json['man_lab_remark']
..sondeId = json['man_sondeID']
..dataCaptureDate = json['data_capture_date']
..dataCaptureTime = json['data_capture_time']
..oxygenConcentration = doubleFromJson(json['man_oxygen_conc'])
..oxygenSaturation = doubleFromJson(json['man_oxygen_sat'])
..ph = doubleFromJson(json['man_ph'])
..salinity = doubleFromJson(json['man_salinity'])
..electricalConductivity = doubleFromJson(json['man_conductivity'])
..temperature = doubleFromJson(json['man_temperature'])
..tds = doubleFromJson(json['man_tds'])
..turbidity = doubleFromJson(json['man_turbidity'])
..tss = doubleFromJson(json['man_tss'])
..batteryVoltage = doubleFromJson(json['man_battery_volt'])
..optionalRemark1 = json['man_optional_photo_01_remarks']
..optionalRemark2 = json['man_optional_photo_02_remarks']
..optionalRemark3 = json['man_optional_photo_03_remarks']
..optionalRemark4 = json['man_optional_photo_04_remarks'];
}
/// Generates a formatted Telegram alert message for successful submissions. /// Generates a formatted Telegram alert message for successful submissions.
String generateTelegramAlertMessage({required bool isDataOnly}) { String generateTelegramAlertMessage({required bool isDataOnly}) {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
@ -171,4 +227,95 @@ class InSituSamplingData {
'man_optional_photo_04': optionalImage4, 'man_optional_photo_04': optionalImage4,
}; };
} }
// --- ADDED: Methods to format data for FTP submission as separate JSON files ---
/// Creates a single JSON object with all submission data, mimicking 'db.json'
Map<String, dynamic> toDbJson() {
// This is a direct conversion of the model's properties to a map,
// with keys matching the expected JSON file format.
return {
'first_sampler_name': firstSamplerName,
'first_sampler_user_id': firstSamplerUserId,
'second_sampler': secondSampler,
'sampling_date': samplingDate,
'sampling_time': samplingTime,
'sampling_type': samplingType,
'sample_id_code': sampleIdCode,
'selected_state_name': selectedStateName,
'selected_category_name': selectedCategoryName,
'selected_station': selectedStation,
'station_latitude': stationLatitude,
'station_longitude': stationLongitude,
'current_latitude': currentLatitude,
'current_longitude': currentLongitude,
'distance_difference_in_km': distanceDifferenceInKm,
'distance_difference_remarks': distanceDifferenceRemarks,
'weather': weather,
'tide_level': tideLevel,
'sea_condition': seaCondition,
'event_remarks': eventRemarks,
'lab_remarks': labRemarks,
'sonde_id': sondeId,
'data_capture_date': dataCaptureDate,
'data_capture_time': dataCaptureTime,
'oxygen_concentration': oxygenConcentration,
'oxygen_saturation': oxygenSaturation,
'ph': ph,
'salinity': salinity,
'electrical_conductivity': electricalConductivity,
'temperature': temperature,
'tds': tds,
'turbidity': turbidity,
'tss': tss,
'battery_voltage': batteryVoltage,
'submission_status': submissionStatus,
'submission_message': submissionMessage,
'report_id': reportId,
};
}
/// Creates a JSON object for basic form info, mimicking 'basic_form.json'.
Map<String, dynamic> toBasicFormJson() {
return {
'tech_name': firstSamplerName,
'sampler_2ndname': secondSampler?['user_name'],
'sample_date': samplingDate,
'sample_time': samplingTime,
'sampling_type': samplingType,
'sample_state': selectedStateName,
'station_id': selectedStation?['man_station_code'],
'station_latitude': stationLatitude,
'station_longitude': stationLongitude,
'latitude': currentLatitude,
'longitude': currentLongitude,
'sample_id': sampleIdCode,
};
}
/// Creates a JSON object for sensor readings, mimicking 'reading.json'.
Map<String, dynamic> toReadingJson() {
return {
'do_mgl': oxygenConcentration,
'do_sat': oxygenSaturation,
'ph': ph,
'salinity': salinity,
'temperature': temperature,
'turbidity': turbidity,
'tds': tds,
'electric_conductivity': electricalConductivity,
'flowrate': null, // This is not collected in marine in-situ
'date_sampling_reading': dataCaptureDate,
'time_sampling_reading': dataCaptureTime,
};
}
/// Creates a JSON object for manual info, mimicking 'manual_info.json'.
Map<String, dynamic> toManualInfoJson() {
return {
'weather': weather,
'remarks_event': eventRemarks,
'remarks_lab': labRemarks,
};
}
} }

View File

@ -1,6 +1,7 @@
// lib/models/river_in_situ_sampling_data.dart // lib/models/river_in_situ_sampling_data.dart
import 'dart:io'; import 'dart:io';
import 'dart:convert'; // Added for jsonEncode
/// A data model class to hold all information for the multi-step /// A data model class to hold all information for the multi-step
/// River In-Situ Sampling form. /// River In-Situ Sampling form.
@ -234,4 +235,89 @@ class RiverInSituSamplingData {
'r_man_optional_photo_04': optionalImage4, 'r_man_optional_photo_04': optionalImage4,
}; };
} }
// --- ADDED: Methods to format data for FTP submission as separate JSON files ---
/// Creates a single JSON object with all submission data, mimicking 'db.json'
String toDbJson() {
// This is a direct conversion of the model's properties to a map,
// with keys matching the expected JSON file format.
final data = {
'battery_cap': batteryVoltage,
'device_name': sondeId,
'sampling_type': samplingType,
'report_id': reportId,
'sampler_2ndname': secondSampler?['user_name'],
'sample_state': selectedStateName,
'station_id': selectedStation?['sampling_station_code'],
'tech_id': firstSamplerUserId,
'tech_name': firstSamplerName,
'latitude': stationLatitude,
'longitude': stationLongitude,
'record_dt': '$samplingDate $samplingTime',
'do_mgl': oxygenConcentration,
'do_sat': oxygenSaturation,
'ph': ph,
'salinity': salinity,
'temperature': temperature,
'turbidity': turbidity,
'tds': tds,
'electric_conductivity': electricalConductivity,
'flowrate': flowrateValue,
'odour': '', // Assuming these are not collected in this form
'floatable': '', // Assuming these are not collected in this form
'sample_id': sampleIdCode,
'weather': weather,
'remarks_event': eventRemarks,
'remarks_lab': labRemarks,
};
return jsonEncode(data);
}
/// Creates a JSON object for basic form info, mimicking 'river_insitu_basic_form.json'.
String toBasicFormJson() {
final data = {
'tech_name': firstSamplerName,
'sampler_2ndname': secondSampler?['user_name'],
'sample_date': samplingDate,
'sample_time': samplingTime,
'sampling_type': samplingType,
'sample_state': selectedStateName,
'station_id': selectedStation?['sampling_station_code'],
'station_latitude': stationLatitude,
'station_longitude': stationLongitude,
'latitude': currentLatitude,
'longitude': currentLongitude,
'sample_id': sampleIdCode,
};
return jsonEncode(data);
}
/// Creates a JSON object for sensor readings, mimicking 'river_sampling_reading.json'.
String toReadingJson() {
final data = {
'do_mgl': oxygenConcentration,
'do_sat': oxygenSaturation,
'ph': ph,
'salinity': salinity,
'temperature': temperature,
'turbidity': turbidity,
'tds': tds,
'electric_conductivity': electricalConductivity,
'flowrate': flowrateValue,
'date_sampling_reading': samplingDate,
'time_sampling_reading': samplingTime,
};
return jsonEncode(data);
}
/// Creates a JSON object for manual info, mimicking 'river_manual_info.json'.
String toManualInfoJson() {
final data = {
'weather': weather,
'remarks_event': eventRemarks,
'remarks_lab': labRemarks,
};
return jsonEncode(data);
}
} }

View File

@ -1,5 +1,6 @@
//import 'dart' as dart; //import 'dart' as dart;
import 'dart:io'; import 'dart:io';
import 'dart:convert';
/// This class holds all the data collected across the multi-step tarball sampling form. /// This class holds all the data collected across the multi-step tarball sampling form.
/// It acts as a temporary data container that is passed between screens. /// It acts as a temporary data container that is passed between screens.
@ -125,4 +126,76 @@ class TarballSamplingData {
'optional_photo_04': optionalImage4, 'optional_photo_04': optionalImage4,
}; };
} }
// --- ADDED: Methods to format data for FTP submission as separate JSON files ---
/// Creates a single JSON object with all submission data, mimicking 'db.json'
Map<String, dynamic> toDbJson() {
return {
'firstSampler': firstSampler,
'firstSamplerUserId': firstSamplerUserId,
'secondSampler': secondSampler,
'samplingDate': samplingDate,
'samplingTime': samplingTime,
'selectedStateName': selectedStateName,
'selectedCategoryName': selectedCategoryName,
'selectedStation': selectedStation,
'stationLatitude': stationLatitude,
'stationLongitude': stationLongitude,
'currentLatitude': currentLatitude,
'currentLongitude': currentLongitude,
'distanceDifference': distanceDifference,
'distanceDifferenceRemarks': distanceDifferenceRemarks,
'classificationId': classificationId,
'selectedClassification': selectedClassification,
'optionalRemark1': optionalRemark1,
'optionalRemark2': optionalRemark2,
'optionalRemark3': optionalRemark3,
'optionalRemark4': optionalRemark4,
'reportId': reportId,
'submissionStatus': submissionStatus,
'submissionMessage': submissionMessage,
};
}
/// Creates a JSON object for basic form info, mimicking 'basic_form.json'.
Map<String, dynamic> toBasicFormJson() {
return {
'tech_name': firstSampler,
'sampler_2ndname': secondSampler?['user_name'],
'sample_date': samplingDate,
'sample_time': samplingTime,
'sample_state': selectedStateName,
'station_id': selectedStation?['tbl_station_code'],
'station_latitude': stationLatitude,
'station_longitude': stationLongitude,
'latitude': currentLatitude,
'longitude': currentLongitude,
'sample_id': reportId, // Using reportId as a unique identifier for the sample.
};
}
/// Creates a JSON object for sensor readings, mimicking 'reading.json'.
Map<String, dynamic> toReadingJson() {
return {
'classification': selectedClassification?['classification_name'],
'classification_id': classificationId,
'optional_remark_1': optionalRemark1,
'optional_remark_2': optionalRemark2,
'optional_remark_3': optionalRemark3,
'optional_remark_4': optionalRemark4,
'distance_difference': distanceDifference,
'distance_difference_remarks': distanceDifferenceRemarks,
};
}
/// Creates a JSON object for manual info, mimicking 'manual_info.json'.
Map<String, dynamic> toManualInfoJson() {
return {
// Tarball forms don't have a specific 'weather' or general remarks field,
// so we use the distance remarks as a stand-in if available.
'remarks_event': distanceDifferenceRemarks,
'remarks_lab': null,
};
}
} }

View File

@ -1,13 +1,22 @@
// lib/screens/marine/manual/in_situ_sampling.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:environment_monitoring_app/auth_provider.dart'; // ADDED: Import for AuthProvider import 'package:environment_monitoring_app/auth_provider.dart';
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import '../../../models/in_situ_sampling_data.dart'; import '../../../models/in_situ_sampling_data.dart';
import '../../../services/in_situ_sampling_service.dart'; import '../../../services/in_situ_sampling_service.dart';
import '../../../services/local_storage_service.dart'; import '../../../services/local_storage_service.dart';
// --- ADDED: Import to get the active server configuration name ---
import '../../../services/server_config_service.dart'; import '../../../services/server_config_service.dart';
// --- ADDED: Imports for zipping and retry queue logic ---
import '../../../services/zipping_service.dart';
import '../../../services/retry_service.dart';
// --- ADDED: Import for the DatabaseHelper ---
import '../../../services/api_service.dart';
import 'widgets/in_situ_step_1_sampling_info.dart'; import 'widgets/in_situ_step_1_sampling_info.dart';
import 'widgets/in_situ_step_2_site_info.dart'; import 'widgets/in_situ_step_2_site_info.dart';
import 'widgets/in_situ_step_3_data_capture.dart'; import 'widgets/in_situ_step_3_data_capture.dart';
@ -26,7 +35,6 @@ class MarineInSituSampling extends StatefulWidget {
class _MarineInSituSamplingState extends State<MarineInSituSampling> { class _MarineInSituSamplingState extends State<MarineInSituSampling> {
final PageController _pageController = PageController(); final PageController _pageController = PageController();
// REPAIRED: Changed from `final` to `late` to allow re-initialization.
late InSituSamplingData _data; late InSituSamplingData _data;
// A single instance of the service to be used by all child widgets. // A single instance of the service to be used by all child widgets.
@ -34,19 +42,19 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
// Service for saving submission logs locally. // Service for saving submission logs locally.
final LocalStorageService _localStorageService = LocalStorageService(); final LocalStorageService _localStorageService = LocalStorageService();
// --- ADDED: Service to get the active server configuration ---
final ServerConfigService _serverConfigService = ServerConfigService(); final ServerConfigService _serverConfigService = ServerConfigService();
// --- ADDED: Services for zipping and queueing ---
final ZippingService _zippingService = ZippingService();
final RetryService _retryService = RetryService();
final DatabaseHelper _dbHelper = DatabaseHelper(); // --- ADDED: Instance of DatabaseHelper for configs ---
int _currentPage = 0; int _currentPage = 0;
bool _isLoading = false; bool _isLoading = false;
// ADDED: initState to create a fresh data object each time the widget is created.
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// This is the core of the fix. It creates a NEW data object with the
// CURRENT date and time every time the user starts a new sampling.
_data = InSituSamplingData( _data = InSituSamplingData(
samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()), samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()),
samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()), samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()),
@ -56,7 +64,6 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
@override @override
void dispose() { void dispose() {
_pageController.dispose(); _pageController.dispose();
// Dispose the service to clean up its resources (e.g., stream controllers).
_samplingService.dispose(); _samplingService.dispose();
super.dispose(); super.dispose();
} }
@ -81,48 +88,118 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
} }
} }
/// Handles the final submission process. // --- REPLACED: _submitForm() method with the new workflow ---
Future<void> _submitForm() async { Future<void> _submitForm() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
// MODIFIED: Get the appSettings list from AuthProvider.
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings; final appSettings = authProvider.appSettings;
final activeApiConfig = await _serverConfigService.getActiveApiConfig();
final serverName = activeApiConfig?['config_name'] as String? ?? 'Default';
// MODIFIED: Pass the appSettings to the submitData method. // Get all API and FTP configs from the database and limit them to the latest 2.
final result = await _samplingService.submitData(_data, appSettings); final apiConfigs = (await _dbHelper.loadApiConfigs() ?? []).take(2).toList();
final ftpConfigs = (await _dbHelper.loadFtpConfigs() ?? []).take(2).toList();
bool apiSuccess = false;
bool ftpSuccess = false;
// --- Path A: Attempt API Submission ---
debugPrint("Step 1: Attempting API submission...");
try {
final apiResult = await _samplingService.submitData(_data, appSettings);
apiSuccess = apiResult['success'] == true;
_data.submissionStatus = apiResult['status'];
_data.submissionMessage = apiResult['message'];
_data.reportId = apiResult['reportId']?.toString();
} catch (e) {
debugPrint("API submission failed with a critical error: $e");
apiSuccess = false;
}
// --- Path B: Attempt FTP Submission if configurations exist ---
if (ftpConfigs.isNotEmpty) {
debugPrint("Step 2: FTP server configured. Proceeding with zipping and queuing.");
final stationCode = _data.selectedStation?['man_station_code'] ?? 'NA';
final reportId = _data.reportId ?? DateTime.now().millisecondsSinceEpoch;
final baseFileName = '${stationCode}_$reportId';
try {
// Create a dedicated folder and copy the data for FTP
final ftpDir = await _localStorageService.getInSituBaseDir(serverName: '${serverName}_ftp');
// REPAIRED: This line was the cause of the error.
final dataForFtp = InSituSamplingData.fromJson(Map<String, dynamic>.from(_data.toApiFormData()));
final Map<String, String> jsonDataMap = {
// Now that fromJson exists, we can call these methods.
'db.json': jsonEncode(dataForFtp.toDbJson()),
'basic_form.json': jsonEncode(dataForFtp.toBasicFormJson()),
'reading.json': jsonEncode(dataForFtp.toReadingJson()),
'manual_info.json': jsonEncode(dataForFtp.toManualInfoJson()),
};
final File? dataZip = await _zippingService.createDataZip(
jsonDataMap: jsonDataMap,
baseFileName: baseFileName,
);
final File? imageZip = await _zippingService.createImageZip(
imageFiles: dataForFtp.toApiImageFiles().values.whereType<File>().toList(),
baseFileName: baseFileName,
);
if (dataZip != null) {
await _retryService.addFtpToQueue(
localFilePath: dataZip.path,
remotePath: '/uploads/data/${p.basename(dataZip.path)}',
);
ftpSuccess = true;
}
if (imageZip != null) {
await _retryService.addFtpToQueue(
localFilePath: imageZip.path,
remotePath: '/uploads/images/${p.basename(imageZip.path)}',
);
ftpSuccess = true;
}
} catch (e) {
debugPrint("FTP zipping or queuing failed with an error: $e");
ftpSuccess = false;
}
}
// --- Final Status Update and Navigation ---
if (!mounted) return; if (!mounted) return;
// Update the data model with the submission result. if (apiSuccess && ftpSuccess) {
_data.submissionStatus = result['status']; _data.submissionStatus = 'S4'; // Submitted API, Queued FTP
_data.submissionMessage = result['message']; _data.submissionMessage = 'Data submitted and files are queued for FTP upload.';
_data.reportId = result['reportId']?.toString(); } else if (apiSuccess) {
_data.submissionStatus = 'S3'; // Submitted API Only
_data.submissionMessage = 'Data submitted successfully to API. No FTP configured or FTP failed.';
} else if (ftpSuccess) {
_data.submissionStatus = 'L4'; // Failed API, Queued FTP
_data.submissionMessage = 'API submission failed but files were successfully queued for FTP.';
} else {
_data.submissionStatus = 'L1'; // All submissions failed
_data.submissionMessage = 'All submission attempts failed. Data saved locally for retry.';
}
// --- 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); await _localStorageService.saveInSituSamplingData(_data, serverName: serverName);
setState(() => _isLoading = false); setState(() => _isLoading = false);
// Show feedback to the user based on the result. final message = _data.submissionMessage ?? 'An unknown error occurred.';
final message = result['message'] ?? 'An unknown error occurred.'; final color = (apiSuccess || ftpSuccess) ? Colors.green : Colors.red;
final color = (result['status'] == 'L3')
? Colors.green
: (result['status'] == 'L2' ? Colors.orange : Colors.red);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)), SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
); );
// If submission was fully successful, navigate back to the home screen.
if (result['status'] == 'L3') {
Navigator.of(context).popUntil((route) => route.isFirst); Navigator.of(context).popUntil((route) => route.isFirst);
} }
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -1,13 +1,20 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'dart:convert';
import 'package:path/path.dart' as p;
import 'package:environment_monitoring_app/auth_provider.dart'; import 'package:environment_monitoring_app/auth_provider.dart';
import 'package:environment_monitoring_app/models/tarball_data.dart'; import 'package:environment_monitoring_app/models/tarball_data.dart';
import 'package:environment_monitoring_app/services/marine_api_service.dart'; // REMOVED: Direct import of marine_api_service.dart to fix the naming conflict.
import 'package:environment_monitoring_app/services/local_storage_service.dart'; import 'package:environment_monitoring_app/services/local_storage_service.dart';
// --- ADDED: Import to get the active server configuration name --- // --- ADDED: Import to get the active server configuration name ---
import 'package:environment_monitoring_app/services/server_config_service.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart';
// --- ADDED: Imports for zipping and retry queue logic ---
import 'package:environment_monitoring_app/services/zipping_service.dart';
import 'package:environment_monitoring_app/services/retry_service.dart';
// --- ADDED: Import for the DatabaseHelper and ApiService ---
import 'package:environment_monitoring_app/services/api_service.dart';
class TarballSamplingStep3Summary extends StatefulWidget { class TarballSamplingStep3Summary extends StatefulWidget {
@ -19,72 +26,128 @@ class TarballSamplingStep3Summary extends StatefulWidget {
} }
class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summary> { class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summary> {
final MarineApiService _marineApiService = MarineApiService(); // CORRECTED: Use the main ApiService to access its marine property.
final ApiService _apiService = ApiService();
final LocalStorageService _localStorageService = LocalStorageService(); final LocalStorageService _localStorageService = LocalStorageService();
// --- ADDED: Service to get the active server configuration --- // --- ADDED: Service to get the active server configuration ---
final ServerConfigService _serverConfigService = ServerConfigService(); final ServerConfigService _serverConfigService = ServerConfigService();
// --- ADDED: Services for zipping and queueing ---
final ZippingService _zippingService = ZippingService();
final RetryService _retryService = RetryService();
final DatabaseHelper _dbHelper = DatabaseHelper(); // --- ADDED: Instance of DatabaseHelper for configs ---
bool _isLoading = false; bool _isLoading = false;
// MODIFIED: This method now fetches appSettings and passes it to the API service. // --- REPLACED: _submitForm() method with the new workflow ---
Future<void> _submitForm() async { Future<void> _submitForm() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
// Get the appSettings list from AuthProvider.
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings; final appSettings = authProvider.appSettings;
final activeApiConfig = await _serverConfigService.getActiveApiConfig();
final serverName = activeApiConfig?['config_name'] as String? ?? 'Default';
// Step 1: Orchestrated Server Submission // Get all FTP configs from the database and limit them to the latest 2.
// Pass the appSettings list to the submit method. final ftpConfigs = (await _dbHelper.loadFtpConfigs() ?? []).take(2).toList();
final result = await _marineApiService.submitTarballSample(
// Create a temporary, separate copy of the data for the FTP process
final dataForFtp = widget.data;
bool apiSuccess = false;
bool ftpSuccess = false;
// --- Step 1: Attempt API Submission ---
debugPrint("Step 1: Attempting API submission...");
try {
final apiResult = await _apiService.marine.submitTarballSample(
formData: widget.data.toFormData(), formData: widget.data.toFormData(),
imageFiles: widget.data.toImageFiles(), imageFiles: widget.data.toImageFiles(),
appSettings: appSettings, appSettings: appSettings,
); );
apiSuccess = apiResult['success'] == true;
widget.data.submissionStatus = apiResult['status'];
widget.data.submissionMessage = apiResult['message'];
widget.data.reportId = apiResult['reportId']?.toString();
debugPrint("API submission successful.");
} catch (e) {
debugPrint("API submission failed with a critical error: $e");
apiSuccess = false;
}
// --- Step 2: Attempt FTP Submission if configurations exist ---
if (ftpConfigs.isNotEmpty) {
debugPrint("Step 2: FTP server configured. Proceeding with zipping and queuing.");
final stationCode = dataForFtp.selectedStation?['tbl_station_code'] ?? 'NA';
final reportId = dataForFtp.reportId ?? DateTime.now().millisecondsSinceEpoch;
final baseFileName = '${stationCode}_$reportId';
try {
final Map<String, String> jsonDataMap = {
'db.json': jsonEncode(dataForFtp.toDbJson()),
'basic_form.json': jsonEncode(dataForFtp.toBasicFormJson()),
'reading.json': jsonEncode(dataForFtp.toReadingJson()),
'manual_info.json': jsonEncode(dataForFtp.toManualInfoJson()),
};
final File? dataZip = await _zippingService.createDataZip(
jsonDataMap: jsonDataMap,
baseFileName: baseFileName,
);
final File? imageZip = await _zippingService.createImageZip(
imageFiles: dataForFtp.toImageFiles().values.whereType<File>().toList(),
baseFileName: baseFileName,
);
if (dataZip != null) {
await _retryService.addFtpToQueue(
localFilePath: dataZip.path,
remotePath: '/uploads/data/${p.basename(dataZip.path)}',
);
ftpSuccess = true;
}
if (imageZip != null) {
await _retryService.addFtpToQueue(
localFilePath: imageZip.path,
remotePath: '/uploads/images/${p.basename(imageZip.path)}',
);
ftpSuccess = true;
}
} catch (e) {
debugPrint("FTP zipping or queuing failed with an error: $e");
ftpSuccess = false;
}
}
// --- Step 3: Save the final status to the local log and handle UI feedback ---
if (!mounted) return; if (!mounted) return;
// Step 2: Update the data model with submission results if (apiSuccess && ftpSuccess) {
widget.data.submissionStatus = result['status']; widget.data.submissionStatus = 'S4'; // Submitted API, Queued FTP
widget.data.submissionMessage = result['message']; widget.data.submissionMessage = 'Data submitted and files are queued for FTP upload.';
widget.data.reportId = result['reportId']?.toString(); } else if (apiSuccess) {
widget.data.submissionStatus = 'S3'; // Submitted API Only
// --- MODIFIED: Get the active server name before saving the local log --- widget.data.submissionMessage = 'Data submitted successfully to API. No FTP configured or FTP failed.';
final activeConfig = await _serverConfigService.getActiveApiConfig(); } else if (ftpSuccess) {
final serverName = activeConfig?['config_name'] as String? ?? 'Default'; widget.data.submissionStatus = 'L4'; // Failed API, Queued FTP
widget.data.submissionMessage = 'API submission failed but files were successfully queued for FTP.';
// Step 3: Local Save with the complete data, including submission status.
final String? localPath = await _localStorageService.saveTarballSamplingData(widget.data, serverName: serverName);
if (mounted) {
if (localPath != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Submission log saved locally to: $localPath")),
);
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( widget.data.submissionStatus = 'L1'; // All submissions failed
const SnackBar(content: Text("Warning: Could not save submission log locally."), backgroundColor: Colors.orange), widget.data.submissionMessage = 'All submission attempts failed. Data saved locally for retry.';
);
}
} }
// Always save the final status to the local log regardless of submission outcome
await _localStorageService.saveTarballSamplingData(widget.data, serverName: serverName);
setState(() => _isLoading = false); setState(() => _isLoading = false);
// Step 4: Handle UI feedback based on the final status final message = widget.data.submissionMessage ?? 'An unknown error occurred.';
final status = result['status']; final color = (apiSuccess || ftpSuccess) ? Colors.green : Colors.red;
final message = result['message'] ?? 'An unknown error occurred.';
debugPrint("Submission final status: $status. Report ID: ${widget.data.reportId}. Message: $message");
if (status == 'L3') {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.green), SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
); );
Navigator.of(context).popUntil((route) => route.isFirst); Navigator.of(context).popUntil((route) => route.isFirst);
} else { // L1 or L2 failures
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: (status == 'L2' ? Colors.orange : Colors.red), duration: const Duration(seconds: 5)),
);
}
} }
@override @override

View File

@ -3,13 +3,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:environment_monitoring_app/auth_provider.dart'; // ADDED: Import for AuthProvider import 'package:environment_monitoring_app/auth_provider.dart';
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import '../../../models/river_in_situ_sampling_data.dart'; import '../../../models/river_in_situ_sampling_data.dart';
import '../../../services/river_in_situ_sampling_service.dart'; import '../../../services/river_in_situ_sampling_service.dart';
import '../../../services/local_storage_service.dart'; import '../../../services/local_storage_service.dart';
// --- ADDED: Import to get the active server configuration name --- // --- ADDED: Import to get the active server configuration name ---
import '../../../services/server_config_service.dart'; import '../../../services/server_config_service.dart';
// --- ADDED: Imports for zipping and retry queue logic ---
import '../../../services/zipping_service.dart';
import '../../../services/retry_service.dart';
import 'widgets/river_in_situ_step_1_sampling_info.dart'; import 'widgets/river_in_situ_step_1_sampling_info.dart';
import 'widgets/river_in_situ_step_2_site_info.dart'; import 'widgets/river_in_situ_step_2_site_info.dart';
import 'widgets/river_in_situ_step_3_data_capture.dart'; import 'widgets/river_in_situ_step_3_data_capture.dart';
@ -33,6 +39,9 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
final LocalStorageService _localStorageService = LocalStorageService(); final LocalStorageService _localStorageService = LocalStorageService();
// --- ADDED: Service to get the active server configuration --- // --- ADDED: Service to get the active server configuration ---
final ServerConfigService _serverConfigService = ServerConfigService(); final ServerConfigService _serverConfigService = ServerConfigService();
// --- ADDED: Services for zipping and queueing ---
final ZippingService _zippingService = ZippingService();
final RetryService _retryService = RetryService();
int _currentPage = 0; int _currentPage = 0;
bool _isLoading = false; bool _isLoading = false;
@ -71,43 +80,112 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
} }
} }
// MODIFIED: This method now fetches appSettings and passes it to the service. // --- REPLACED: _submitForm() method with the new workflow ---
Future<void> _submitForm() async { Future<void> _submitForm() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
// Get the appSettings list from AuthProvider.
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings; final appSettings = authProvider.appSettings;
final activeApiConfig = await _serverConfigService.getActiveApiConfig();
final serverName = activeApiConfig?['config_name'] as String? ?? 'Default';
// Pass the appSettings list to the submitData method. // A copy of the data is made to avoid modifying the original during the FTP process.
final result = await _samplingService.submitData(_data, appSettings); final dataForFtp = RiverInSituSamplingData.fromJson(_data.toApiFormData());
bool apiSuccess = false;
bool ftpSuccess = false;
// --- Path A: Attempt API Submission ---
debugPrint("Step 1: Attempting API submission...");
final apiResult = await _samplingService.submitData(_data, appSettings);
if (apiResult['success'] == true) {
apiSuccess = true;
_data.submissionStatus = apiResult['status'];
_data.submissionMessage = apiResult['message'];
_data.reportId = apiResult['reportId']?.toString();
debugPrint("API submission successful.");
} else {
_data.submissionStatus = apiResult['status'];
_data.submissionMessage = apiResult['message'];
_data.reportId = null;
debugPrint("API submission failed. Reason: ${apiResult['message']}");
}
// --- Path B: Attempt FTP Submission if configurations exist ---
final activeFtpConfig = await _serverConfigService.getActiveFtpConfig();
if (activeFtpConfig != null) {
debugPrint("Step 2: FTP server configured. Proceeding with zipping and queuing.");
final stationCode = _data.selectedStation?['sampling_station_code'] ?? 'NA';
final reportId = _data.reportId ?? DateTime.now().millisecondsSinceEpoch;
final baseFileName = '${stationCode}_$reportId';
try {
// --- Create a separate folder for FTP data to avoid conflicts ---
final ftpDir = await _localStorageService.getRiverInSituBaseDir(dataForFtp.samplingType, serverName: '${serverName}_ftp');
if (ftpDir != null) {
final dataForFtpJson = dataForFtp.toApiFormData(); // This is the data used for the ZIP files
final File? dataZip = await _zippingService.createDataZip(
jsonDataMap: {
'river_insitu_basic_form.json': jsonEncode(dataForFtpJson),
// Add other JSON files if necessary
},
baseFileName: baseFileName,
);
final File? imageZip = await _zippingService.createImageZip(
imageFiles: dataForFtp.toApiImageFiles().values.whereType<File>().toList(),
baseFileName: baseFileName,
);
if (dataZip != null) {
await _retryService.addFtpToQueue(
localFilePath: dataZip.path,
remotePath: '/uploads/data/${p.basename(dataZip.path)}',
);
ftpSuccess = true; // Mark as successful if at least one file is queued
}
if (imageZip != null) {
await _retryService.addFtpToQueue(
localFilePath: imageZip.path,
remotePath: '/uploads/images/${p.basename(imageZip.path)}',
);
ftpSuccess = true;
}
}
} catch (e) {
debugPrint("FTP zipping or queuing failed with an error: $e");
ftpSuccess = false;
}
}
// --- Final Status Update and Navigation ---
if (!mounted) return; if (!mounted) return;
_data.submissionStatus = result['status']; if (apiSuccess && ftpSuccess) {
_data.submissionMessage = result['message']; _data.submissionStatus = 'S4'; // Submitted API, Queued FTP
_data.reportId = result['reportId']?.toString(); _data.submissionMessage = 'Data submitted and files are queued for FTP upload.';
} else if (apiSuccess) {
_data.submissionStatus = 'S3'; // Submitted API Only
_data.submissionMessage = 'Data submitted successfully to API. No FTP configured or FTP failed.';
} else if (ftpSuccess) {
_data.submissionStatus = 'L4'; // Failed API, Queued FTP
_data.submissionMessage = 'API submission failed but files were successfully queued for FTP.';
} else {
_data.submissionStatus = 'L1'; // All submissions failed
_data.submissionMessage = 'All submission attempts failed. Data saved locally for retry.';
}
// --- 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); await _localStorageService.saveRiverInSituSamplingData(_data, serverName: serverName);
setState(() => _isLoading = false); setState(() => _isLoading = false);
final message = result['message'] ?? 'An unknown error occurred.'; final message = _data.submissionMessage ?? 'An unknown error occurred.';
final color = (result['status'] == 'L3') final color = (apiSuccess || ftpSuccess) ? Colors.green : Colors.red;
? Colors.green
: (result['status'] == 'L2' ? Colors.orange : Colors.red);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)), SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
); );
// CORRECTED: The navigation now happens regardless of the submission status.
// This ensures the form closes even after a failed (offline) submission.
Navigator.of(context).popUntil((route) => route.isFirst); Navigator.of(context).popUntil((route) => route.isFirst);
} }

View File

@ -24,6 +24,9 @@ class SerialManager {
// This will be updated when the Sonde ID is parsed from Level 0 data. // This will be updated when the Sonde ID is parsed from Level 0 data.
final ValueNotifier<String?> sondeId = ValueNotifier<String?>(null); final ValueNotifier<String?> sondeId = ValueNotifier<String?>(null);
// --- ADDED: Flag to prevent updates after disposal ---
bool _isDisposed = false;
// StreamController to broadcast parsed data readings to multiple listeners. // StreamController to broadcast parsed data readings to multiple listeners.
final StreamController<Map<String, double>> _dataStreamController = final StreamController<Map<String, double>> _dataStreamController =
StreamController<Map<String, double>>.broadcast(); StreamController<Map<String, double>>.broadcast();
@ -62,6 +65,7 @@ class SerialManager {
} }
try { try {
_isDisposed = false; // Reset disposed flag on new connection
connectionState.value = SerialConnectionState.connecting; connectionState.value = SerialConnectionState.connecting;
connectedDeviceName.value = device.productName ?? 'Unknown Device'; // Update device name early connectedDeviceName.value = device.productName ?? 'Unknown Device'; // Update device name early
@ -109,7 +113,11 @@ class SerialManager {
}, },
); );
// --- REVISED: Added null check before updating state ---
if (!_isDisposed) {
connectionState.value = SerialConnectionState.connected; connectionState.value = SerialConnectionState.connected;
connectedDeviceName.value = device.productName;
}
debugPrint("SerialManager: Connected to ${connectedDeviceName.value}"); debugPrint("SerialManager: Connected to ${connectedDeviceName.value}");
// Start the live reading cycle immediately after connecting // Start the live reading cycle immediately after connecting
@ -118,9 +126,11 @@ class SerialManager {
} catch (e) { } catch (e) {
debugPrint("SerialManager: Connection Error: $e"); debugPrint("SerialManager: Connection Error: $e");
// Ensure all states are reset on error during connection // Ensure all states are reset on error during connection
if (!_isDisposed) {
connectionState.value = SerialConnectionState.disconnected; connectionState.value = SerialConnectionState.disconnected;
connectedDeviceName.value = null; connectedDeviceName.value = null;
sondeId.value = null; // Clear Sonde ID sondeId.value = null; // Clear Sonde ID
}
_port = null; _port = null;
_responseBuffer.clear(); _responseBuffer.clear();
_isReading = false; _isReading = false;
@ -278,6 +288,12 @@ class SerialManager {
/// Dispatches the complete and validated response string to the appropriate handler /// Dispatches the complete and validated response string to the appropriate handler
/// based on the current communication level. /// based on the current communication level.
void _handleResponse(String response) { void _handleResponse(String response) {
// --- REVISED: Added null check before dispatching ---
if (_isDisposed) {
debugPrint("SerialManager: Ignoring response on disposed manager.");
return;
}
switch (_communicationLevel) { switch (_communicationLevel) {
case 0: case 0:
_handleResponseLevel0(response); _handleResponseLevel0(response);
@ -326,12 +342,15 @@ class SerialManager {
debugPrint("SerialManager: Parsed L0 -> Address: $_parentAddress, Serial: $_serialNumber"); debugPrint("SerialManager: Parsed L0 -> Address: $_parentAddress, Serial: $_serialNumber");
// --- Update sondeId ValueNotifier here --- // --- Update sondeId ValueNotifier here ---
// --- REVISED: Added null check before updating ValueNotifier ---
if (!_isDisposed) {
if (_serialNumber != null && _serialNumber!.isNotEmpty) { if (_serialNumber != null && _serialNumber!.isNotEmpty) {
sondeId.value = _serialNumber; sondeId.value = _serialNumber;
debugPrint("SerialManager: Updated Sonde ID: ${sondeId.value}"); debugPrint("SerialManager: Updated Sonde ID: ${sondeId.value}");
} else { } else {
sondeId.value = "N/A"; // Or handle empty/null serial number appropriately sondeId.value = "N/A"; // Or handle empty/null serial number appropriately
} }
}
if (_parentAddress != "00000000") { // Check for valid parent address if (_parentAddress != "00000000") { // Check for valid parent address
@ -420,7 +439,10 @@ class SerialManager {
// Ensure the number of parsed parameters matches the number of parsed values. // Ensure the number of parsed parameters matches the number of parsed values.
if (_parameterList.length == values.length) { if (_parameterList.length == values.length) {
Map<String, double> finalReadings = Map.fromIterables(_parameterList, values); Map<String, double> finalReadings = Map.fromIterables(_parameterList, values);
// --- REVISED: Added null check before adding to the stream controller ---
if (!_isDisposed) {
_dataStreamController.add(finalReadings); // Broadcast the final parsed readings _dataStreamController.add(finalReadings); // Broadcast the final parsed readings
}
debugPrint("SerialManager: Final Parsed Readings: $finalReadings"); debugPrint("SerialManager: Final Parsed Readings: $finalReadings");
} else { } else {
debugPrint("SerialManager: L2 Data Mismatch: ${values.length} values for ${_parameterList.length} parameters. Parameter list: $_parameterList, Values: $values"); debugPrint("SerialManager: L2 Data Mismatch: ${values.length} values for ${_parameterList.length} parameters. Parameter list: $_parameterList, Values: $values");
@ -509,10 +531,7 @@ class SerialManager {
/// Cleans up all resources held by the SerialManager when it's no longer needed. /// Cleans up all resources held by the SerialManager when it's no longer needed.
void dispose() { void dispose() {
debugPrint("SerialManager: Disposing."); debugPrint("SerialManager: Disposing.");
// No need to await disconnect here, as dispose shouldn't necessarily block _isDisposed = true; // Set the flag immediately
// on a full disconnection completion before proceeding with other disposals.
// However, if you *must* ensure full disconnection before other disposals,
// you could add `await disconnect();`. For now, a fire-and-forget call is fine.
disconnect(); // Ensure full disconnection and cleanup disconnect(); // Ensure full disconnection and cleanup
_dataStreamController.close(); // Close the data stream controller _dataStreamController.close(); // Close the data stream controller
connectionState.dispose(); // Dispose the ValueNotifier connectionState.dispose(); // Dispose the ValueNotifier

View File

@ -7,8 +7,12 @@ import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:intl/intl.dart';
import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/base_api_service.dart';
import 'package:environment_monitoring_app/services/telegram_service.dart';
import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart';
import 'package:environment_monitoring_app/models/tarball_data.dart';
// ======================================================================= // =======================================================================
// Part 1: Unified API Service // Part 1: Unified API Service
@ -194,7 +198,7 @@ class ApiService {
} }
// ======================================================================= // =======================================================================
// Part 2: Feature-Specific API Services (Unchanged) // Part 2: Feature-Specific API Services (Refactored to include Telegram)
// ======================================================================= // =======================================================================
class AirApiService { class AirApiService {
@ -230,17 +234,20 @@ class AirApiService {
class MarineApiService { class MarineApiService {
// ... (No changes needed here) // --- ADDED: TelegramService instance ---
final BaseApiService _baseService; final BaseApiService _baseService;
final TelegramService _telegramService = TelegramService();
MarineApiService(this._baseService); MarineApiService(this._baseService);
Future<Map<String, dynamic>> getTarballStations() => _baseService.get('marine/tarball/stations'); Future<Map<String, dynamic>> getTarballStations() => _baseService.get('marine/tarball/stations');
Future<Map<String, dynamic>> getManualStations() => _baseService.get('marine/manual/stations'); Future<Map<String, dynamic>> getManualStations() => _baseService.get('marine/manual/stations');
Future<Map<String, dynamic>> getTarballClassifications() => _baseService.get('marine/tarball/classifications'); Future<Map<String, dynamic>> getTarballClassifications() => _baseService.get('marine/tarball/classifications');
// --- REVISED: Now includes appSettings parameter and triggers Telegram alert ---
Future<Map<String, dynamic>> submitTarballSample({ Future<Map<String, dynamic>> submitTarballSample({
required Map<String, String> formData, required Map<String, String> formData,
required Map<String, File?> imageFiles, required Map<String, File?> imageFiles,
required List<Map<String, dynamic>>? appSettings,
}) async { }) async {
final dataResult = await _baseService.post('marine/tarball/sample', formData); final dataResult = await _baseService.post('marine/tarball/sample', formData);
if (dataResult['success'] != true) return {'status': 'L1', 'success': false, 'message': 'Failed to submit data: ${dataResult['message']}'}; if (dataResult['success'] != true) return {'status': 'L1', 'success': false, 'message': 'Failed to submit data: ${dataResult['message']}'};
@ -251,13 +258,68 @@ class MarineApiService {
final filesToUpload = <String, File>{}; final filesToUpload = <String, File>{};
imageFiles.forEach((key, value) { if (value != null) filesToUpload[key] = value; }); imageFiles.forEach((key, value) { if (value != null) filesToUpload[key] = value; });
if (filesToUpload.isEmpty) return {'status': 'L3', 'success': true, 'message': 'Data submitted successfully.', 'reportId': recordId}; if (filesToUpload.isEmpty) {
_handleTarballSuccessAlert(formData, appSettings, isDataOnly: true);
return {'status': 'L3', 'success': true, 'message': 'Data submitted successfully.', 'reportId': recordId};
}
final imageResult = await _baseService.postMultipart(endpoint: 'marine/tarball/images', fields: {'autoid': recordId.toString()}, files: filesToUpload); final imageResult = await _baseService.postMultipart(endpoint: 'marine/tarball/images', fields: {'autoid': recordId.toString()}, files: filesToUpload);
if (imageResult['success'] != true) return {'status': 'L2', 'success': false, 'message': 'Data submitted, but image upload failed: ${imageResult['message']}', 'reportId': recordId}; if (imageResult['success'] != true) {
// Still send the alert for data submission even if images fail
_handleTarballSuccessAlert(formData, appSettings, isDataOnly: true);
return {'status': 'L2', 'success': false, 'message': 'Data submitted, but image upload failed: ${imageResult['message']}', 'reportId': recordId};
}
// On complete success, send the full alert
_handleTarballSuccessAlert(formData, appSettings, isDataOnly: false);
return {'status': 'L3', 'success': true, 'message': 'Data and images submitted successfully.', 'reportId': recordId}; return {'status': 'L3', 'success': true, 'message': 'Data and images submitted successfully.', 'reportId': recordId};
} }
// --- ADDED: Helper method for Telegram alerts ---
Future<void> _handleTarballSuccessAlert(Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
debugPrint("Triggering Telegram alert logic...");
try {
final message = _generateTarballAlertMessage(formData, isDataOnly: isDataOnly);
final bool wasSent = await _telegramService.sendAlertImmediately('marine_tarball', message, appSettings);
if (!wasSent) {
await _telegramService.queueMessage('marine_tarball', message, appSettings);
}
} catch (e) {
debugPrint("Failed to handle Tarball Telegram alert: $e");
}
}
// --- ADDED: Helper method to generate the Telegram message ---
String _generateTarballAlertMessage(Map<String, String> formData, {required bool isDataOnly}) {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = formData['tbl_station_name'] ?? 'N/A';
final stationCode = formData['tbl_station_code'] ?? 'N/A';
final classification = formData['classification_name'] ?? formData['classification_id'] ?? 'N/A';
final buffer = StringBuffer()
..writeln('✅ *Tarball Sample $submissionType Submitted:*')
..writeln()
..writeln('*Station Name & Code:* $stationName ($stationCode)')
..writeln('*Date of Submission:* ${formData['sampling_date']}')
..writeln('*Submitted by User:* ${formData['first_sampler_name'] ?? 'N/A'}')
..writeln('*Classification:* $classification')
..writeln('*Status of Submission:* Successful');
if (formData['distance_difference'] != null &&
double.tryParse(formData['distance_difference']!) != null &&
double.parse(formData['distance_difference']!) > 0) {
buffer
..writeln()
..writeln('🔔 *Alert:*')
..writeln('*Distance from station:* ${(double.parse(formData['distance_difference']!) * 1000).toStringAsFixed(0)} meters');
if (formData['distance_difference_remarks'] != null && formData['distance_difference_remarks']!.isNotEmpty) {
buffer.writeln('*Remarks for distance:* ${formData['distance_difference_remarks']}');
}
}
return buffer.toString();
}
} }
class RiverApiService { class RiverApiService {

View File

@ -1,51 +1,43 @@
// lib/services/base_api_service.dart
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:async/async.dart'; // Used for TimeoutException import 'package:async/async.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:environment_monitoring_app/auth_provider.dart'; import 'package:environment_monitoring_app/auth_provider.dart';
// --- ADDED: Import for the service that manages active server configurations ---
import 'package:environment_monitoring_app/services/server_config_service.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart';
// --- ADDED: Import for the new service that manages the retry queue ---
import 'package:environment_monitoring_app/services/retry_service.dart'; import 'package:environment_monitoring_app/services/retry_service.dart';
import 'package:environment_monitoring_app/services/api_service.dart';
class BaseApiService { class BaseApiService {
// --- ADDED: An instance of the service to get the active URL dynamically ---
final ServerConfigService _serverConfigService = ServerConfigService(); final ServerConfigService _serverConfigService = ServerConfigService();
final DatabaseHelper _dbHelper = DatabaseHelper(); // --- ADDED: Instance of DatabaseHelper to get all configs ---
// --- 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 { Future<Map<String, String>> _getHeaders() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final String? token = prefs.getString(AuthProvider.tokenKey); final String? token = prefs.getString(AuthProvider.tokenKey);
// For multipart requests, 'Content-Type' is set by the http client.
// We only need Authorization here.
return { return {
if (token != null) 'Authorization': 'Bearer $token', if (token != null) 'Authorization': 'Bearer $token',
}; };
} }
// Private helper for JSON headers
Future<Map<String, String>> _getJsonHeaders() async { Future<Map<String, String>> _getJsonHeaders() async {
final headers = await _getHeaders(); final headers = await _getHeaders();
headers['Content-Type'] = 'application/json'; headers['Content-Type'] = 'application/json';
return headers; return headers;
} }
// Generic GET request handler // Generic GET request handler (remains unchanged)
Future<Map<String, dynamic>> get(String endpoint) async { Future<Map<String, dynamic>> get(String endpoint) async {
try { try {
// --- MODIFIED: Fetches the active base URL before making the request ---
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
final url = Uri.parse('$baseUrl/$endpoint'); final url = Uri.parse('$baseUrl/$endpoint');
final response = await http.get(url, headers: await _getJsonHeaders()) final response = await http.get(url, headers: await _getJsonHeaders())
.timeout(const Duration(seconds: 60)); // --- MODIFIED: Added 60 second timeout --- .timeout(const Duration(seconds: 60));
return _handleResponse(response); return _handleResponse(response);
} catch (e) { } catch (e) {
debugPrint('GET request failed: $e'); debugPrint('GET request failed: $e');
@ -53,111 +45,187 @@ class BaseApiService {
} }
} }
// Generic POST request handler for JSON data // --- MODIFIED: Generic POST request handler now attempts multiple servers ---
Future<Map<String, dynamic>> post(String endpoint, Map<String, dynamic> body) async { Future<Map<String, dynamic>> post(String endpoint, Map<String, dynamic> body) async {
try { final configs = await _dbHelper.loadApiConfigs() ?? []; // Get all API configs
// --- MODIFIED: Fetches the active base URL before making the request ---
// --- ADDED: Handle case where local configs are empty ---
if (configs.isEmpty) {
debugPrint('No local API configs found. Attempting to use default bootstrap URL.');
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
try {
final url = Uri.parse('$baseUrl/$endpoint'); final url = Uri.parse('$baseUrl/$endpoint');
debugPrint('Attempting POST to: $url');
final response = await http.post( final response = await http.post(
url, url,
headers: await _getJsonHeaders(), headers: await _getJsonHeaders(),
body: jsonEncode(body), body: jsonEncode(body),
).timeout(const Duration(seconds: 60)); // --- MODIFIED: Added 60 second timeout --- ).timeout(const Duration(seconds: 60));
return _handleResponse(response); return _handleResponse(response);
} catch (e) { } catch (e) {
// --- MODIFIED: Create a local instance of RetryService to break the circular dependency --- debugPrint('POST to default URL failed. Error: $e');
return {'success': false, 'message': 'API connection failed. Request has been queued for manual retry.'};
}
}
// If configs exist, try them (up to the two latest)
final latestConfigs = configs.take(2).toList();
debugPrint('Debug: Loaded API configs: $latestConfigs');
for (final config in latestConfigs) {
debugPrint('Debug: Current config item: $config (Type: ${config.runtimeType})');
// --- REVISED: The null check logic is now more specific ---
if (config == null || config['api_url'] == null) {
debugPrint('Skipping null or invalid API configuration.');
continue;
}
try {
final baseUrl = config['api_url'];
final url = Uri.parse('$baseUrl/$endpoint');
debugPrint('Attempting POST to: $url');
final response = await http.post(
url,
headers: await _getJsonHeaders(),
body: jsonEncode(body),
).timeout(const Duration(seconds: 60));
final result = _handleResponse(response);
if (result['success'] == true) {
debugPrint('POST to $baseUrl succeeded.');
return result;
} else {
debugPrint('POST to $baseUrl failed with an API error. Trying next server if available. Error: ${result['message']}');
}
} catch (e) {
debugPrint('POST to this server failed. Trying next server if available. Error: $e');
}
}
// If all attempts fail, queue for manual retry
final retryService = RetryService(); final retryService = RetryService();
debugPrint('POST request to $endpoint failed, queueing for retry. Error: $e');
await retryService.addApiToQueue( await retryService.addApiToQueue(
endpoint: endpoint, endpoint: endpoint,
method: 'POST', method: 'POST',
body: body, body: body,
); );
return {'success': false, 'message': 'Request failed and has been queued for manual retry.'}; return {'success': false, 'message': 'All API attempts failed. Request has been queued for manual retry.'};
}
} }
// Generic handler for multipart (file upload) requests // --- MODIFIED: Generic multipart handler now attempts multiple servers ---
Future<Map<String, dynamic>> postMultipart({ Future<Map<String, dynamic>> postMultipart({
required String endpoint, required String endpoint,
required Map<String, String> fields, required Map<String, String> fields,
required Map<String, File> files, required Map<String, File> files,
}) async { }) async {
try { final configs = await _dbHelper.loadApiConfigs() ?? []; // Get all API configs
// --- MODIFIED: Fetches the active base URL before making the request ---
// --- ADDED: Handle case where local configs are empty ---
if (configs.isEmpty) {
debugPrint('No local API configs found. Attempting to use default bootstrap URL.');
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
try {
final url = Uri.parse('$baseUrl/$endpoint'); final url = Uri.parse('$baseUrl/$endpoint');
debugPrint('Starting multipart upload to: $url'); debugPrint('Attempting multipart upload to: $url');
var request = http.MultipartRequest('POST', url); var request = http.MultipartRequest('POST', url);
// Get and add headers (Authorization token)
final headers = await _getHeaders(); final headers = await _getHeaders();
request.headers.addAll(headers); request.headers.addAll(headers);
debugPrint('Headers added to multipart request.');
// --- CORRECTED ---
// This adds each field directly to the request body, which is the standard
// for multipart/form-data and matches what the PHP backend expects.
if (fields.isNotEmpty) { if (fields.isNotEmpty) {
request.fields.addAll(fields); request.fields.addAll(fields);
debugPrint('Fields added directly to multipart request: $fields');
} }
// Add files
for (var entry in files.entries) { for (var entry in files.entries) {
debugPrint('Adding file: ${entry.key}, path: ${entry.value.path}'); if (await entry.value.exists()) {
request.files.add(await http.MultipartFile.fromPath( request.files.add(await http.MultipartFile.fromPath(
entry.key, entry.key,
entry.value.path, entry.value.path,
filename: path.basename(entry.value.path), filename: path.basename(entry.value.path),
)); ));
} }
debugPrint('${files.length} files added to the request.'); }
debugPrint('Sending multipart request...');
// --- MODIFIED: Added 60 second timeout ---
var streamedResponse = await request.send().timeout(const Duration(seconds: 60)); var streamedResponse = await request.send().timeout(const Duration(seconds: 60));
debugPrint('Received response with status code: ${streamedResponse.statusCode}');
final responseBody = await streamedResponse.stream.bytesToString(); final responseBody = await streamedResponse.stream.bytesToString();
// We create a standard http.Response to use our standard handler
return _handleResponse(http.Response(responseBody, streamedResponse.statusCode)); return _handleResponse(http.Response(responseBody, streamedResponse.statusCode));
} catch (e) {
debugPrint('Multipart upload to default URL failed. Error: $e');
return {'success': false, 'message': 'API connection failed. Upload has been queued for manual retry.'};
}
}
} catch (e, s) { // Catching both Exception and Error (e.g., OutOfMemoryError) final latestConfigs = configs.take(2).toList(); // Limit to the two latest configs
// --- MODIFIED: Create a local instance of RetryService to break the circular dependency --- debugPrint('Debug: Loaded API configs: $latestConfigs');
final retryService = RetryService();
debugPrint('Multipart request to $endpoint failed, queueing for retry. Error: $e'); for (final config in latestConfigs) {
debugPrint('Debug: Current config item: $config (Type: ${config.runtimeType})');
// --- REVISED: The null check logic is now more specific ---
if (config == null || config['api_url'] == null) {
debugPrint('Skipping null or invalid API configuration.');
continue;
}
try {
final baseUrl = config['api_url'];
final url = Uri.parse('$baseUrl/$endpoint');
debugPrint('Attempting multipart upload to: $url');
var request = http.MultipartRequest('POST', url);
final headers = await _getHeaders();
request.headers.addAll(headers);
if (fields.isNotEmpty) {
request.fields.addAll(fields);
}
for (var entry in files.entries) {
if (await entry.value.exists()) { // Check if the file exists before adding
request.files.add(await http.MultipartFile.fromPath(
entry.key,
entry.value.path,
filename: path.basename(entry.value.path),
));
} else {
debugPrint('File does not exist: ${entry.value.path}. Skipping this file.');
}
}
var streamedResponse = await request.send().timeout(const Duration(seconds: 60));
final responseBody = await streamedResponse.stream.bytesToString();
final result = _handleResponse(http.Response(responseBody, streamedResponse.statusCode));
if (result['success'] == true) {
debugPrint('Multipart upload to $baseUrl succeeded.');
return result;
} else {
debugPrint('Multipart upload to $baseUrl failed with an API error. Trying next server if available. Error: ${result['message']}');
}
} catch (e, s) {
debugPrint('Multipart upload to this server failed. Trying next server if available. Error: $e');
debugPrint('Stack trace: $s'); debugPrint('Stack trace: $s');
}
}
// If all attempts fail, queue for manual retry
final retryService = RetryService();
await retryService.addApiToQueue( await retryService.addApiToQueue(
endpoint: endpoint, endpoint: endpoint,
method: 'POST_MULTIPART', method: 'POST_MULTIPART',
fields: fields, fields: fields,
files: files, files: files,
); );
return {'success': false, 'message': 'Upload failed and has been queued for manual retry.'}; return {'success': false, 'message': 'All API attempts failed. Upload has been queued for manual retry.'};
}
} }
// Centralized response handling
Map<String, dynamic> _handleResponse(http.Response response) { Map<String, dynamic> _handleResponse(http.Response response) {
debugPrint('Handling response. Status: ${response.statusCode}, Body: ${response.body}'); debugPrint('Handling response. Status: ${response.statusCode}, Body: ${response.body}');
try { try {
final Map<String, dynamic> responseData = jsonDecode(response.body); final Map<String, dynamic> responseData = jsonDecode(response.body);
if (response.statusCode >= 200 && response.statusCode < 300) { if (response.statusCode >= 200 && response.statusCode < 300) {
// Assuming the API returns a 'status' field in the JSON body
if (responseData['status'] == 'success' || responseData['success'] == true) { if (responseData['status'] == 'success' || responseData['success'] == true) {
return {'success': true, 'data': responseData['data'], 'message': responseData['message']}; return {'success': true, 'data': responseData['data'], 'message': responseData['message']};
} else { } else {
return {'success': false, 'message': responseData['message'] ?? 'An unknown API error occurred.'}; return {'success': false, 'message': responseData['message'] ?? 'An unknown API error occurred.'};
} }
} else { } else {
// Handle server errors (4xx, 5xx)
return {'success': false, 'message': responseData['message'] ?? 'Server error: ${response.statusCode}'}; return {'success': false, 'message': responseData['message'] ?? 'Server error: ${response.statusCode}'};
} }
} catch (e) { } catch (e) {
// Handle cases where the response body is not valid JSON
debugPrint('Failed to parse server response: $e'); debugPrint('Failed to parse server response: $e');
return {'success': false, 'message': 'Failed to parse server response. Body: ${response.body}'}; return {'success': false, 'message': 'Failed to parse server response. Body: ${response.body}'};
} }

View File

@ -6,28 +6,41 @@ import 'package:ftpconnect/ftpconnect.dart';
import 'package:environment_monitoring_app/services/server_config_service.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart';
// --- ADDED: Import for the new service that manages the retry queue --- // --- ADDED: Import for the new service that manages the retry queue ---
import 'package:environment_monitoring_app/services/retry_service.dart'; import 'package:environment_monitoring_app/services/retry_service.dart';
// --- ADDED: Import for the local database helper ---
import 'package:environment_monitoring_app/services/api_service.dart';
class FtpService { class FtpService {
final ServerConfigService _serverConfigService = ServerConfigService(); final ServerConfigService _serverConfigService = ServerConfigService();
// --- REMOVED: This creates an infinite loop with RetryService --- // --- REMOVED: This creates an infinite loop with RetryService ---
// final RetryService _retryService = RetryService(); // final RetryService _retryService = RetryService();
final DatabaseHelper _dbHelper = DatabaseHelper(); // --- ADDED: Instance of DatabaseHelper to get all configs ---
/// Uploads a single file to the active server's FTP. /// Uploads a single file to the active server's FTP.
/// ///
/// [fileToUpload] The local file to be uploaded. /// [fileToUpload] The local file to be uploaded.
/// [remotePath] The destination path on the FTP server (e.g., '/uploads/images/'). /// [remotePath] The destination path on the FTP server (e.g., '/uploads/images/').
/// Returns a map with 'success' and 'message' keys. /// Returns a map with 'success' and 'message' keys.
// --- MODIFIED: Method now attempts to upload to multiple servers ---
Future<Map<String, dynamic>> uploadFile(File fileToUpload, String remotePath) async { Future<Map<String, dynamic>> uploadFile(File fileToUpload, String remotePath) async {
final config = await _serverConfigService.getActiveFtpConfig(); final configs = await _dbHelper.loadFtpConfigs() ?? []; // Get all FTP configs
final latestConfigs = configs.take(2).toList(); // Limit to the two latest configs
if (config == null || config['ftp_host'] == null || config['ftp_user'] == null || config['ftp_pass'] == null) { if (latestConfigs.isEmpty) {
return {'success': false, 'message': 'FTP credentials are not configured or selected.'}; return {'success': false, 'message': 'FTP credentials are not configured or selected.'};
} }
final ftpHost = config['ftp_host'] as String; // Loop through each of the two latest configurations and attempt to upload
final ftpUser = config['ftp_user'] as String; for (final configMap in latestConfigs) {
final ftpPass = config['ftp_pass'] as String; final config = configMap['config_json']; // The data is nested under this key
final ftpPort = config['ftp_port'] as int? ?? 21; // Default to port 21 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;
if (ftpHost == null || ftpUser == null || ftpPass == null) {
debugPrint('FTP: Configuration is incomplete. Skipping to next server if available.');
continue;
}
final ftpConnect = FTPConnect( final ftpConnect = FTPConnect(
ftpHost, ftpHost,
@ -49,31 +62,33 @@ class FtpService {
pRetryCount: 3, // --- MODIFIED: Retry three times on failure --- pRetryCount: 3, // --- MODIFIED: Retry three times on failure ---
); );
await ftpConnect.disconnect(); // Disconnect immediately upon success
if (res) { if (res) {
debugPrint('FTP upload to $ftpHost succeeded.');
return {'success': true, 'message': 'File uploaded successfully via FTP.'}; return {'success': true, 'message': 'File uploaded successfully via FTP.'};
} else { } else {
// --- MODIFIED: Create a local instance of RetryService to break the circular dependency --- debugPrint('FTP upload to $ftpHost failed after retries. Trying next server.');
final retryService = RetryService(); continue; // Move to the next configuration in the loop
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) { } catch (e) {
// --- MODIFIED: Create a local instance of RetryService to break the circular dependency --- debugPrint('FTP upload to $ftpHost failed with an exception. Trying next server. Error: $e');
try {
// Attempt to disconnect even if an error occurred during connect/upload
await ftpConnect.disconnect();
} catch (_) {
// Ignore errors during disconnect
}
continue; // Move to the next configuration in the loop
}
}
// If the loop completes and no server was successful, queue for manual retry.
final retryService = RetryService(); final retryService = RetryService();
debugPrint('FTP upload for ${fileToUpload.path} failed with an exception, queueing. Error: $e'); debugPrint('All FTP upload attempts failed. Queueing for manual retry.');
await retryService.addFtpToQueue( await retryService.addFtpToQueue(
localFilePath: fileToUpload.path, localFilePath: fileToUpload.path,
remotePath: remotePath remotePath: remotePath
); );
return {'success': false, 'message': 'FTP upload failed and has been queued for manual retry.'}; return {'success': false, 'message': 'All FTP upload attempts failed and have been queued for manual retry.'};
} finally {
// Always ensure disconnection, even if an error occurs.
debugPrint('FTP: Disconnecting...');
await ftpConnect.disconnect();
}
} }
} }

View File

@ -283,7 +283,8 @@ class LocalStorageService {
// Part 4: Marine In-Situ Specific Methods // Part 4: Marine In-Situ Specific Methods
// ======================================================================= // =======================================================================
Future<Directory?> _getInSituBaseDir({required String serverName}) async { // --- MODIFIED: Removed leading underscore to make the method public ---
Future<Directory?> getInSituBaseDir({required String serverName}) async {
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName); final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
if (mmsv4Dir == null) return null; if (mmsv4Dir == null) return null;
@ -295,7 +296,7 @@ class LocalStorageService {
} }
Future<String?> saveInSituSamplingData(InSituSamplingData data, {required String serverName}) async { Future<String?> saveInSituSamplingData(InSituSamplingData data, {required String serverName}) async {
final baseDir = await _getInSituBaseDir(serverName: serverName); final baseDir = await getInSituBaseDir(serverName: serverName);
if (baseDir == null) { if (baseDir == null) {
debugPrint("Could not get public storage directory for In-Situ. Check permissions."); debugPrint("Could not get public storage directory for In-Situ. Check permissions.");
return null; return null;
@ -390,7 +391,7 @@ class LocalStorageService {
// Part 5: River In-Situ Specific Methods // Part 5: River In-Situ Specific Methods
// ======================================================================= // =======================================================================
Future<Directory?> _getRiverInSituBaseDir(String? samplingType, {required String serverName}) async { Future<Directory?> getRiverInSituBaseDir(String? samplingType, {required String serverName}) async {
final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName); final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName);
if (mmsv4Dir == null) return null; if (mmsv4Dir == null) return null;
@ -409,7 +410,7 @@ class LocalStorageService {
} }
Future<String?> saveRiverInSituSamplingData(RiverInSituSamplingData data, {required String serverName}) async { Future<String?> saveRiverInSituSamplingData(RiverInSituSamplingData data, {required String serverName}) async {
final baseDir = await _getRiverInSituBaseDir(data.samplingType, serverName: serverName); final baseDir = await getRiverInSituBaseDir(data.samplingType, serverName: serverName);
if (baseDir == null) { if (baseDir == null) {
debugPrint("Could not get public storage directory for River In-Situ. Check permissions."); debugPrint("Could not get public storage directory for River In-Situ. Check permissions.");
return null; return null;

View File

@ -25,16 +25,17 @@ class MarineApiService {
return _baseService.get('marine/tarball/classifications'); return _baseService.get('marine/tarball/classifications');
} }
// MODIFIED: Method now requires the appSettings list. // --- MODIFIED: Added appSettings to the method signature ---
Future<Map<String, dynamic>> submitTarballSample({ Future<Map<String, dynamic>> submitTarballSample({
required Map<String, String> formData, required Map<String, String> formData,
required Map<String, File?> imageFiles, required Map<String, File?> imageFiles,
required List<Map<String, dynamic>>? appSettings, required List<Map<String, dynamic>>? appSettings, // ADDED: New required parameter
}) async { }) async {
debugPrint("Step 1: Submitting tarball form data to the server..."); debugPrint("Step 1: Submitting tarball form data to the server...");
final dataResult = await _baseService.post('marine/tarball/sample', formData); final dataResult = await _baseService.post('marine/tarball/sample', formData);
if (dataResult['success'] != true) { if (dataResult['success'] != true) {
debugPrint("API submission failed for Tarball. Message: ${dataResult['message']}");
return { return {
'status': 'L1', 'status': 'L1',
'success': false, 'success': false,
@ -42,10 +43,11 @@ class MarineApiService {
'reportId': null, 'reportId': null,
}; };
} }
debugPrint("Step 1 successful. Tarball data submitted."); debugPrint("Step 1 successful. Tarball data submitted. Report ID: ${dataResult['data']?['autoid']}");
final recordId = dataResult['data']?['autoid']; final recordId = dataResult['data']?['autoid'];
if (recordId == null) { if (recordId == null) {
debugPrint("API submitted, but no record ID returned.");
return { return {
'status': 'L2', 'status': 'L2',
'success': false, 'success': false,
@ -60,6 +62,7 @@ class MarineApiService {
}); });
if (filesToUpload.isEmpty) { if (filesToUpload.isEmpty) {
debugPrint("No images to upload. Finalizing submission.");
_handleTarballSuccessAlert(formData, appSettings, isDataOnly: true); _handleTarballSuccessAlert(formData, appSettings, isDataOnly: true);
return { return {
'status': 'L3', 'status': 'L3',
@ -77,6 +80,7 @@ class MarineApiService {
); );
if (imageResult['success'] != true) { if (imageResult['success'] != true) {
debugPrint("Image upload failed for Tarball. Message: ${imageResult['message']}");
return { return {
'status': 'L2', 'status': 'L2',
'success': false, 'success': false,
@ -85,6 +89,7 @@ class MarineApiService {
}; };
} }
debugPrint("Step 2 successful. All images uploaded.");
_handleTarballSuccessAlert(formData, appSettings, isDataOnly: false); _handleTarballSuccessAlert(formData, appSettings, isDataOnly: false);
return { return {
'status': 'L3', 'status': 'L3',
@ -105,6 +110,7 @@ class MarineApiService {
final dataResult = await _baseService.post('marine/manual/sample', formData); final dataResult = await _baseService.post('marine/manual/sample', formData);
if (dataResult['success'] != true) { if (dataResult['success'] != true) {
debugPrint("API submission failed for In-Situ. Message: ${dataResult['message']}");
return { return {
'status': 'L1', 'status': 'L1',
'success': false, 'success': false,
@ -112,10 +118,11 @@ class MarineApiService {
'reportId': null, 'reportId': null,
}; };
} }
debugPrint("Step 1 successful. In-situ data submitted."); debugPrint("Step 1 successful. In-situ data submitted. Report ID: ${dataResult['data']?['man_id']}");
final recordId = dataResult['data']?['man_id']; final recordId = dataResult['data']?['man_id'];
if (recordId == null) { if (recordId == null) {
debugPrint("API submitted, but no record ID returned.");
return { return {
'status': 'L2', 'status': 'L2',
'success': false, 'success': false,
@ -130,6 +137,7 @@ class MarineApiService {
}); });
if (filesToUpload.isEmpty) { if (filesToUpload.isEmpty) {
debugPrint("No images to upload. Finalizing submission.");
_handleInSituSuccessAlert(inSituData, appSettings, isDataOnly: true); _handleInSituSuccessAlert(inSituData, appSettings, isDataOnly: true);
return { return {
'status': 'L3', 'status': 'L3',
@ -147,6 +155,7 @@ class MarineApiService {
); );
if (imageResult['success'] != true) { if (imageResult['success'] != true) {
debugPrint("Image upload failed for In-Situ. Message: ${imageResult['message']}");
return { return {
'status': 'L2', 'status': 'L2',
'success': false, 'success': false,
@ -155,11 +164,12 @@ class MarineApiService {
}; };
} }
debugPrint("Step 2 successful. All images uploaded.");
_handleInSituSuccessAlert(inSituData, appSettings, isDataOnly: false); _handleInSituSuccessAlert(inSituData, appSettings, isDataOnly: false);
return { return {
'status': 'L3', 'status': 'L3',
'success': true, 'success': true,
'message': 'In-situ data and images submitted successfully.', 'message': 'Data and images submitted to server successfully.',
'reportId': recordId.toString(), 'reportId': recordId.toString(),
}; };
} }

View File

@ -0,0 +1,79 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:archive/archive_io.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
/// A dedicated service to handle the creation of ZIP archives for FTP submission.
class ZippingService {
/// Creates multiple JSON files from a map of data and zips them into a single archive.
/// The map keys will be the filenames (e.g., 'db.json', 'form_data.json').
/// The map values should be the JSON string content for each file.
Future<File?> createDataZip({
required Map<String, String> jsonDataMap,
required String baseFileName,
}) async {
try {
final tempDir = await getTemporaryDirectory();
final zipFilePath = p.join(tempDir.path, '$baseFileName.zip');
final encoder = ZipFileEncoder();
encoder.create(zipFilePath);
debugPrint("Creating data ZIP at: $zipFilePath");
for (var entry in jsonDataMap.entries) {
final fileName = entry.key;
final jsonContent = entry.value;
// --- MODIFIED: The codeUnits property is already a List<int>. No need to wrap it in a Stream. ---
final archiveFile = ArchiveFile(fileName, jsonContent.length, jsonContent.codeUnits);
encoder.addArchiveFile(archiveFile);
debugPrint("Added $fileName to data ZIP.");
}
encoder.close();
debugPrint("Data ZIP creation complete.");
return File(zipFilePath);
} catch (e) {
debugPrint("Error creating data ZIP file: $e");
return null;
}
}
/// Creates a ZIP file from a list of image files.
Future<File?> createImageZip({
required List<File> imageFiles,
required String baseFileName,
}) async {
if (imageFiles.isEmpty) {
debugPrint("No images provided to create an image ZIP.");
return null;
}
try {
final tempDir = await getTemporaryDirectory();
final zipFilePath = p.join(tempDir.path, '${baseFileName}_img.zip');
final encoder = ZipFileEncoder();
encoder.create(zipFilePath);
debugPrint("Creating image ZIP at: $zipFilePath");
for (var imageFile in imageFiles) {
if (await imageFile.exists()) {
await encoder.addFile(imageFile);
debugPrint("Added ${p.basename(imageFile.path)} to image ZIP.");
} else {
debugPrint("Skipping non-existent file: ${imageFile.path}");
}
}
encoder.close();
debugPrint("Image ZIP creation complete.");
return File(zipFilePath);
} catch (e) {
debugPrint("Error creating image ZIP file: $e");
return null;
}
}
}

View File

@ -2,7 +2,7 @@
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
archive: archive:
dependency: transitive dependency: "direct main"
description: description:
name: archive name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"

View File

@ -37,6 +37,7 @@ dependencies:
image: ^4.1.3 # For image processing (watermarks) image: ^4.1.3 # For image processing (watermarks)
permission_handler: ^11.3.1 permission_handler: ^11.3.1
ftpconnect: ^2.0.5 ftpconnect: ^2.0.5
archive: ^4.0.3 # For creating ZIP files
# --- Added for In-Situ Sampling Module --- # --- Added for In-Situ Sampling Module ---
simple_barcode_scanner: ^0.3.0 # For scanning sample IDs simple_barcode_scanner: ^0.3.0 # For scanning sample IDs
#flutter_blue_classic: ^0.0.3 # For Bluetooth sonde connection #flutter_blue_classic: ^0.0.3 # For Bluetooth sonde connection