upgrade multiple api and ftp submission
This commit is contained in:
parent
5930dd500e
commit
1a1a1bd7d0
@ -27,6 +27,7 @@
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<!-- END: STORAGE PERMISSIONS -->
|
||||
|
||||
<!-- MMS V4 1.2.02 -->
|
||||
<application
|
||||
android:label="MMS V4 debug"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert'; // Added for jsonEncode
|
||||
import 'air_installation_data.dart';
|
||||
|
||||
class AirCollectionData {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// lib/models/air_installation_data.dart
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:convert'; // Added for jsonEncode
|
||||
import 'air_collection_data.dart'; // Import the collection data model
|
||||
|
||||
class AirInstallationData {
|
||||
@ -216,4 +217,67 @@ class AirInstallationData {
|
||||
if (optionalImage4 != null) files['optional_04'] = optionalImage4!;
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert'; // Added for jsonEncode
|
||||
|
||||
/// A data model class to hold all information for the multi-step
|
||||
/// In-Situ Sampling form.
|
||||
@ -71,6 +72,61 @@ class InSituSamplingData {
|
||||
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.
|
||||
String generateTelegramAlertMessage({required bool isDataOnly}) {
|
||||
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||
@ -171,4 +227,95 @@ class InSituSamplingData {
|
||||
'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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
// lib/models/river_in_situ_sampling_data.dart
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:convert'; // Added for jsonEncode
|
||||
|
||||
/// A data model class to hold all information for the multi-step
|
||||
/// River In-Situ Sampling form.
|
||||
@ -234,4 +235,89 @@ class RiverInSituSamplingData {
|
||||
'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);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
//import 'dart' as dart;
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
/// 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.
|
||||
@ -125,4 +126,76 @@ class TarballSamplingData {
|
||||
'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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,22 @@
|
||||
// lib/screens/marine/manual/in_situ_sampling.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.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 '../../../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';
|
||||
// --- 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_2_site_info.dart';
|
||||
import 'widgets/in_situ_step_3_data_capture.dart';
|
||||
@ -26,7 +35,6 @@ class MarineInSituSampling extends StatefulWidget {
|
||||
class _MarineInSituSamplingState extends State<MarineInSituSampling> {
|
||||
final PageController _pageController = PageController();
|
||||
|
||||
// REPAIRED: Changed from `final` to `late` to allow re-initialization.
|
||||
late InSituSamplingData _data;
|
||||
|
||||
// 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.
|
||||
final LocalStorageService _localStorageService = LocalStorageService();
|
||||
|
||||
// --- ADDED: Service to get the active server configuration ---
|
||||
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;
|
||||
bool _isLoading = false;
|
||||
|
||||
// ADDED: initState to create a fresh data object each time the widget is created.
|
||||
@override
|
||||
void 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(
|
||||
samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()),
|
||||
samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()),
|
||||
@ -56,7 +64,6 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
// Dispose the service to clean up its resources (e.g., stream controllers).
|
||||
_samplingService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@ -81,49 +88,119 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the final submission process.
|
||||
// --- REPLACED: _submitForm() method with the new workflow ---
|
||||
Future<void> _submitForm() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
// MODIFIED: Get the appSettings list from AuthProvider.
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
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.
|
||||
final result = await _samplingService.submitData(_data, appSettings);
|
||||
// Get all API and FTP configs from the database and limit them to the latest 2.
|
||||
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;
|
||||
|
||||
// Update the data model with the submission result.
|
||||
_data.submissionStatus = result['status'];
|
||||
_data.submissionMessage = result['message'];
|
||||
_data.reportId = result['reportId']?.toString();
|
||||
if (apiSuccess && ftpSuccess) {
|
||||
_data.submissionStatus = 'S4'; // Submitted API, Queued FTP
|
||||
_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';
|
||||
|
||||
// Save a log of the submission locally, now with the server name.
|
||||
await _localStorageService.saveInSituSamplingData(_data, serverName: serverName);
|
||||
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
// Show feedback to the user based on the result.
|
||||
final message = result['message'] ?? 'An unknown error occurred.';
|
||||
final color = (result['status'] == 'L3')
|
||||
? Colors.green
|
||||
: (result['status'] == 'L2' ? Colors.orange : Colors.red);
|
||||
|
||||
final message = _data.submissionMessage ?? 'An unknown error occurred.';
|
||||
final color = (apiSuccess || ftpSuccess) ? Colors.green : Colors.red;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
// Use Provider.value to provide the existing service instance to all child widgets.
|
||||
|
||||
@ -1,13 +1,20 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.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/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';
|
||||
// --- ADDED: Import to get the active server configuration name ---
|
||||
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 {
|
||||
@ -19,72 +26,128 @@ class TarballSamplingStep3Summary extends StatefulWidget {
|
||||
}
|
||||
|
||||
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();
|
||||
// --- ADDED: Service to get the active server configuration ---
|
||||
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;
|
||||
|
||||
// MODIFIED: This method now fetches appSettings and passes it to the API service.
|
||||
// --- REPLACED: _submitForm() method with the new workflow ---
|
||||
Future<void> _submitForm() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
// Get the appSettings list from AuthProvider.
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final appSettings = authProvider.appSettings;
|
||||
final activeApiConfig = await _serverConfigService.getActiveApiConfig();
|
||||
final serverName = activeApiConfig?['config_name'] as String? ?? 'Default';
|
||||
|
||||
// Step 1: Orchestrated Server Submission
|
||||
// Pass the appSettings list to the submit method.
|
||||
final result = await _marineApiService.submitTarballSample(
|
||||
formData: widget.data.toFormData(),
|
||||
imageFiles: widget.data.toImageFiles(),
|
||||
appSettings: appSettings,
|
||||
);
|
||||
// Get all FTP configs from the database and limit them to the latest 2.
|
||||
final ftpConfigs = (await _dbHelper.loadFtpConfigs() ?? []).take(2).toList();
|
||||
|
||||
if (!mounted) return;
|
||||
// Create a temporary, separate copy of the data for the FTP process
|
||||
final dataForFtp = widget.data;
|
||||
|
||||
// Step 2: Update the data model with submission results
|
||||
widget.data.submissionStatus = result['status'];
|
||||
widget.data.submissionMessage = result['message'];
|
||||
widget.data.reportId = result['reportId']?.toString();
|
||||
bool apiSuccess = false;
|
||||
bool ftpSuccess = false;
|
||||
|
||||
// --- 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 1: Attempt API Submission ---
|
||||
debugPrint("Step 1: Attempting API submission...");
|
||||
try {
|
||||
final apiResult = await _apiService.marine.submitTarballSample(
|
||||
formData: widget.data.toFormData(),
|
||||
imageFiles: widget.data.toImageFiles(),
|
||||
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 3: Local Save with the complete data, including submission status.
|
||||
final String? localPath = await _localStorageService.saveTarballSamplingData(widget.data, serverName: serverName);
|
||||
// --- Step 2: Attempt FTP Submission if configurations exist ---
|
||||
if (ftpConfigs.isNotEmpty) {
|
||||
debugPrint("Step 2: FTP server configured. Proceeding with zipping and queuing.");
|
||||
|
||||
if (mounted) {
|
||||
if (localPath != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("Submission log saved locally to: $localPath")),
|
||||
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,
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Warning: Could not save submission log locally."), backgroundColor: Colors.orange),
|
||||
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 (apiSuccess && ftpSuccess) {
|
||||
widget.data.submissionStatus = 'S4'; // Submitted API, Queued FTP
|
||||
widget.data.submissionMessage = 'Data submitted and files are queued for FTP upload.';
|
||||
} else if (apiSuccess) {
|
||||
widget.data.submissionStatus = 'S3'; // Submitted API Only
|
||||
widget.data.submissionMessage = 'Data submitted successfully to API. No FTP configured or FTP failed.';
|
||||
} else if (ftpSuccess) {
|
||||
widget.data.submissionStatus = 'L4'; // Failed API, Queued FTP
|
||||
widget.data.submissionMessage = 'API submission failed but files were successfully queued for FTP.';
|
||||
} else {
|
||||
widget.data.submissionStatus = 'L1'; // All submissions failed
|
||||
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);
|
||||
|
||||
// Step 4: Handle UI feedback based on the final status
|
||||
final status = result['status'];
|
||||
final message = result['message'] ?? 'An unknown error occurred.';
|
||||
final message = widget.data.submissionMessage ?? 'An unknown error occurred.';
|
||||
final color = (apiSuccess || ftpSuccess) ? Colors.green : Colors.red;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
|
||||
);
|
||||
|
||||
debugPrint("Submission final status: $status. Report ID: ${widget.data.reportId}. Message: $message");
|
||||
|
||||
if (status == 'L3') {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.green),
|
||||
);
|
||||
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)),
|
||||
);
|
||||
}
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@ -3,13 +3,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.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 '../../../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';
|
||||
// --- 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_2_site_info.dart';
|
||||
import 'widgets/river_in_situ_step_3_data_capture.dart';
|
||||
@ -33,6 +39,9 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
|
||||
final LocalStorageService _localStorageService = LocalStorageService();
|
||||
// --- ADDED: Service to get the active server configuration ---
|
||||
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||
// --- ADDED: Services for zipping and queueing ---
|
||||
final ZippingService _zippingService = ZippingService();
|
||||
final RetryService _retryService = RetryService();
|
||||
|
||||
int _currentPage = 0;
|
||||
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 {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
// Get the appSettings list from AuthProvider.
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
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.
|
||||
final result = await _samplingService.submitData(_data, appSettings);
|
||||
// A copy of the data is made to avoid modifying the original during the FTP process.
|
||||
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;
|
||||
|
||||
_data.submissionStatus = result['status'];
|
||||
_data.submissionMessage = result['message'];
|
||||
_data.reportId = result['reportId']?.toString();
|
||||
if (apiSuccess && ftpSuccess) {
|
||||
_data.submissionStatus = 'S4'; // Submitted API, Queued FTP
|
||||
_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);
|
||||
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
final message = result['message'] ?? 'An unknown error occurred.';
|
||||
final color = (result['status'] == 'L3')
|
||||
? Colors.green
|
||||
: (result['status'] == 'L2' ? Colors.orange : Colors.red);
|
||||
|
||||
final message = _data.submissionMessage ?? 'An unknown error occurred.';
|
||||
final color = (apiSuccess || ftpSuccess) ? Colors.green : Colors.red;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -24,6 +24,9 @@ class SerialManager {
|
||||
// This will be updated when the Sonde ID is parsed from Level 0 data.
|
||||
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.
|
||||
final StreamController<Map<String, double>> _dataStreamController =
|
||||
StreamController<Map<String, double>>.broadcast();
|
||||
@ -62,6 +65,7 @@ class SerialManager {
|
||||
}
|
||||
|
||||
try {
|
||||
_isDisposed = false; // Reset disposed flag on new connection
|
||||
connectionState.value = SerialConnectionState.connecting;
|
||||
connectedDeviceName.value = device.productName ?? 'Unknown Device'; // Update device name early
|
||||
|
||||
@ -109,7 +113,11 @@ class SerialManager {
|
||||
},
|
||||
);
|
||||
|
||||
connectionState.value = SerialConnectionState.connected;
|
||||
// --- REVISED: Added null check before updating state ---
|
||||
if (!_isDisposed) {
|
||||
connectionState.value = SerialConnectionState.connected;
|
||||
connectedDeviceName.value = device.productName;
|
||||
}
|
||||
debugPrint("SerialManager: Connected to ${connectedDeviceName.value}");
|
||||
|
||||
// Start the live reading cycle immediately after connecting
|
||||
@ -118,9 +126,11 @@ class SerialManager {
|
||||
} catch (e) {
|
||||
debugPrint("SerialManager: Connection Error: $e");
|
||||
// Ensure all states are reset on error during connection
|
||||
connectionState.value = SerialConnectionState.disconnected;
|
||||
connectedDeviceName.value = null;
|
||||
sondeId.value = null; // Clear Sonde ID
|
||||
if (!_isDisposed) {
|
||||
connectionState.value = SerialConnectionState.disconnected;
|
||||
connectedDeviceName.value = null;
|
||||
sondeId.value = null; // Clear Sonde ID
|
||||
}
|
||||
_port = null;
|
||||
_responseBuffer.clear();
|
||||
_isReading = false;
|
||||
@ -278,6 +288,12 @@ class SerialManager {
|
||||
/// Dispatches the complete and validated response string to the appropriate handler
|
||||
/// based on the current communication level.
|
||||
void _handleResponse(String response) {
|
||||
// --- REVISED: Added null check before dispatching ---
|
||||
if (_isDisposed) {
|
||||
debugPrint("SerialManager: Ignoring response on disposed manager.");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (_communicationLevel) {
|
||||
case 0:
|
||||
_handleResponseLevel0(response);
|
||||
@ -326,11 +342,14 @@ class SerialManager {
|
||||
debugPrint("SerialManager: Parsed L0 -> Address: $_parentAddress, Serial: $_serialNumber");
|
||||
|
||||
// --- Update sondeId ValueNotifier here ---
|
||||
if (_serialNumber != null && _serialNumber!.isNotEmpty) {
|
||||
sondeId.value = _serialNumber;
|
||||
debugPrint("SerialManager: Updated Sonde ID: ${sondeId.value}");
|
||||
} else {
|
||||
sondeId.value = "N/A"; // Or handle empty/null serial number appropriately
|
||||
// --- REVISED: Added null check before updating ValueNotifier ---
|
||||
if (!_isDisposed) {
|
||||
if (_serialNumber != null && _serialNumber!.isNotEmpty) {
|
||||
sondeId.value = _serialNumber;
|
||||
debugPrint("SerialManager: Updated Sonde ID: ${sondeId.value}");
|
||||
} else {
|
||||
sondeId.value = "N/A"; // Or handle empty/null serial number appropriately
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -420,7 +439,10 @@ class SerialManager {
|
||||
// Ensure the number of parsed parameters matches the number of parsed values.
|
||||
if (_parameterList.length == values.length) {
|
||||
Map<String, double> finalReadings = Map.fromIterables(_parameterList, values);
|
||||
_dataStreamController.add(finalReadings); // Broadcast the final parsed readings
|
||||
// --- REVISED: Added null check before adding to the stream controller ---
|
||||
if (!_isDisposed) {
|
||||
_dataStreamController.add(finalReadings); // Broadcast the final parsed readings
|
||||
}
|
||||
debugPrint("SerialManager: Final Parsed Readings: $finalReadings");
|
||||
} else {
|
||||
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.
|
||||
void dispose() {
|
||||
debugPrint("SerialManager: Disposing.");
|
||||
// No need to await disconnect here, as dispose shouldn't necessarily block
|
||||
// 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.
|
||||
_isDisposed = true; // Set the flag immediately
|
||||
disconnect(); // Ensure full disconnection and cleanup
|
||||
_dataStreamController.close(); // Close the data stream controller
|
||||
connectionState.dispose(); // Dispose the ValueNotifier
|
||||
|
||||
@ -7,8 +7,12 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sqflite/sqflite.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/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
|
||||
@ -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 {
|
||||
@ -230,17 +234,20 @@ class AirApiService {
|
||||
|
||||
|
||||
class MarineApiService {
|
||||
// ... (No changes needed here)
|
||||
// --- ADDED: TelegramService instance ---
|
||||
final BaseApiService _baseService;
|
||||
final TelegramService _telegramService = TelegramService();
|
||||
MarineApiService(this._baseService);
|
||||
|
||||
Future<Map<String, dynamic>> getTarballStations() => _baseService.get('marine/tarball/stations');
|
||||
Future<Map<String, dynamic>> getManualStations() => _baseService.get('marine/manual/stations');
|
||||
Future<Map<String, dynamic>> getTarballClassifications() => _baseService.get('marine/tarball/classifications');
|
||||
|
||||
// --- REVISED: Now includes appSettings parameter and triggers Telegram alert ---
|
||||
Future<Map<String, dynamic>> submitTarballSample({
|
||||
required Map<String, String> formData,
|
||||
required Map<String, File?> imageFiles,
|
||||
required List<Map<String, dynamic>>? appSettings,
|
||||
}) async {
|
||||
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']}'};
|
||||
@ -251,13 +258,68 @@ class MarineApiService {
|
||||
final filesToUpload = <String, File>{};
|
||||
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);
|
||||
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};
|
||||
}
|
||||
|
||||
// --- 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 {
|
||||
|
||||
@ -1,51 +1,43 @@
|
||||
// lib/services/base_api_service.dart
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:async/async.dart'; // Used for TimeoutException
|
||||
import 'package:async/async.dart';
|
||||
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';
|
||||
import 'package:environment_monitoring_app/services/api_service.dart';
|
||||
|
||||
|
||||
class BaseApiService {
|
||||
// --- ADDED: An instance of the service to get the active URL dynamically ---
|
||||
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 {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final String? token = prefs.getString(AuthProvider.tokenKey);
|
||||
// For multipart requests, 'Content-Type' is set by the http client.
|
||||
// We only need Authorization here.
|
||||
return {
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
};
|
||||
}
|
||||
|
||||
// Private helper for JSON headers
|
||||
Future<Map<String, String>> _getJsonHeaders() async {
|
||||
final headers = await _getHeaders();
|
||||
headers['Content-Type'] = 'application/json';
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Generic GET request handler
|
||||
// Generic GET request handler (remains unchanged)
|
||||
Future<Map<String, dynamic>> get(String endpoint) async {
|
||||
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.get(url, headers: await _getJsonHeaders())
|
||||
.timeout(const Duration(seconds: 60)); // --- MODIFIED: Added 60 second timeout ---
|
||||
.timeout(const Duration(seconds: 60));
|
||||
return _handleResponse(response);
|
||||
} catch (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 {
|
||||
try {
|
||||
// --- MODIFIED: Fetches the active base URL before making the request ---
|
||||
final configs = await _dbHelper.loadApiConfigs() ?? []; // Get all API configs
|
||||
|
||||
// --- 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 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) {
|
||||
// --- 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.'};
|
||||
try {
|
||||
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));
|
||||
return _handleResponse(response);
|
||||
} catch (e) {
|
||||
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();
|
||||
await retryService.addApiToQueue(
|
||||
endpoint: endpoint,
|
||||
method: 'POST',
|
||||
body: body,
|
||||
);
|
||||
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({
|
||||
required String endpoint,
|
||||
required Map<String, String> fields,
|
||||
required Map<String, File> files,
|
||||
}) async {
|
||||
try {
|
||||
// --- MODIFIED: Fetches the active base URL before making the request ---
|
||||
final configs = await _dbHelper.loadApiConfigs() ?? []; // Get all API configs
|
||||
|
||||
// --- 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 url = Uri.parse('$baseUrl/$endpoint');
|
||||
debugPrint('Starting multipart upload to: $url');
|
||||
|
||||
var request = http.MultipartRequest('POST', url);
|
||||
|
||||
// Get and add headers (Authorization token)
|
||||
final headers = await _getHeaders();
|
||||
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) {
|
||||
request.fields.addAll(fields);
|
||||
debugPrint('Fields added directly to multipart request: $fields');
|
||||
try {
|
||||
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()) {
|
||||
request.files.add(await http.MultipartFile.fromPath(
|
||||
entry.key,
|
||||
entry.value.path,
|
||||
filename: path.basename(entry.value.path),
|
||||
));
|
||||
}
|
||||
}
|
||||
var streamedResponse = await request.send().timeout(const Duration(seconds: 60));
|
||||
final responseBody = await streamedResponse.stream.bytesToString();
|
||||
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.'};
|
||||
}
|
||||
|
||||
// Add files
|
||||
for (var entry in files.entries) {
|
||||
debugPrint('Adding file: ${entry.key}, path: ${entry.value.path}');
|
||||
request.files.add(await http.MultipartFile.fromPath(
|
||||
entry.key,
|
||||
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));
|
||||
debugPrint('Received response with status code: ${streamedResponse.statusCode}');
|
||||
|
||||
final responseBody = await streamedResponse.stream.bytesToString();
|
||||
// We create a standard http.Response to use our standard handler
|
||||
return _handleResponse(http.Response(responseBody, streamedResponse.statusCode));
|
||||
|
||||
} catch (e, s) { // Catching both Exception and Error (e.g., OutOfMemoryError)
|
||||
// --- 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');
|
||||
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.'};
|
||||
}
|
||||
|
||||
final latestConfigs = configs.take(2).toList(); // Limit to the two latest configs
|
||||
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 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');
|
||||
}
|
||||
}
|
||||
|
||||
// If all attempts fail, queue for manual retry
|
||||
final retryService = RetryService();
|
||||
await retryService.addApiToQueue(
|
||||
endpoint: endpoint,
|
||||
method: 'POST_MULTIPART',
|
||||
fields: fields,
|
||||
files: files,
|
||||
);
|
||||
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) {
|
||||
debugPrint('Handling response. Status: ${response.statusCode}, Body: ${response.body}');
|
||||
try {
|
||||
final Map<String, dynamic> responseData = jsonDecode(response.body);
|
||||
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) {
|
||||
return {'success': true, 'data': responseData['data'], 'message': responseData['message']};
|
||||
} else {
|
||||
return {'success': false, 'message': responseData['message'] ?? 'An unknown API error occurred.'};
|
||||
}
|
||||
} else {
|
||||
// Handle server errors (4xx, 5xx)
|
||||
return {'success': false, 'message': responseData['message'] ?? 'Server error: ${response.statusCode}'};
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle cases where the response body is not valid JSON
|
||||
debugPrint('Failed to parse server response: $e');
|
||||
return {'success': false, 'message': 'Failed to parse server response. Body: ${response.body}'};
|
||||
}
|
||||
|
||||
@ -6,74 +6,89 @@ 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';
|
||||
// --- ADDED: Import for the local database helper ---
|
||||
import 'package:environment_monitoring_app/services/api_service.dart';
|
||||
|
||||
class FtpService {
|
||||
final ServerConfigService _serverConfigService = ServerConfigService();
|
||||
// --- REMOVED: This creates an infinite loop with 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.
|
||||
///
|
||||
/// [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.
|
||||
// --- MODIFIED: Method now attempts to upload to multiple servers ---
|
||||
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.'};
|
||||
}
|
||||
|
||||
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
|
||||
// Loop through each of the two latest configurations and attempt to upload
|
||||
for (final configMap in latestConfigs) {
|
||||
final config = configMap['config_json']; // The data is nested under this key
|
||||
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;
|
||||
|
||||
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.'};
|
||||
if (ftpHost == null || ftpUser == null || ftpPass == null) {
|
||||
debugPrint('FTP: Configuration is incomplete. Skipping to next server if available.');
|
||||
continue;
|
||||
}
|
||||
} 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
|
||||
|
||||
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 ---
|
||||
);
|
||||
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();
|
||||
|
||||
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 ---
|
||||
);
|
||||
|
||||
await ftpConnect.disconnect(); // Disconnect immediately upon success
|
||||
if (res) {
|
||||
debugPrint('FTP upload to $ftpHost succeeded.');
|
||||
return {'success': true, 'message': 'File uploaded successfully via FTP.'};
|
||||
} else {
|
||||
debugPrint('FTP upload to $ftpHost failed after retries. Trying next server.');
|
||||
continue; // Move to the next configuration in the loop
|
||||
}
|
||||
} catch (e) {
|
||||
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();
|
||||
debugPrint('All FTP upload attempts failed. Queueing for manual retry.');
|
||||
await retryService.addFtpToQueue(
|
||||
localFilePath: fileToUpload.path,
|
||||
remotePath: remotePath
|
||||
);
|
||||
return {'success': false, 'message': 'All FTP upload attempts failed and have been queued for manual retry.'};
|
||||
}
|
||||
}
|
||||
@ -283,7 +283,8 @@ class LocalStorageService {
|
||||
// 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);
|
||||
if (mmsv4Dir == null) return null;
|
||||
|
||||
@ -295,7 +296,7 @@ class LocalStorageService {
|
||||
}
|
||||
|
||||
Future<String?> saveInSituSamplingData(InSituSamplingData data, {required String serverName}) async {
|
||||
final baseDir = await _getInSituBaseDir(serverName: serverName);
|
||||
final baseDir = await getInSituBaseDir(serverName: serverName);
|
||||
if (baseDir == null) {
|
||||
debugPrint("Could not get public storage directory for In-Situ. Check permissions.");
|
||||
return null;
|
||||
@ -390,7 +391,7 @@ class LocalStorageService {
|
||||
// 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);
|
||||
if (mmsv4Dir == null) return null;
|
||||
|
||||
@ -409,7 +410,7 @@ class LocalStorageService {
|
||||
}
|
||||
|
||||
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) {
|
||||
debugPrint("Could not get public storage directory for River In-Situ. Check permissions.");
|
||||
return null;
|
||||
|
||||
@ -25,16 +25,17 @@ class MarineApiService {
|
||||
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({
|
||||
required Map<String, String> formData,
|
||||
required Map<String, File?> imageFiles,
|
||||
required List<Map<String, dynamic>>? appSettings,
|
||||
required List<Map<String, dynamic>>? appSettings, // ADDED: New required parameter
|
||||
}) async {
|
||||
debugPrint("Step 1: Submitting tarball form data to the server...");
|
||||
final dataResult = await _baseService.post('marine/tarball/sample', formData);
|
||||
|
||||
if (dataResult['success'] != true) {
|
||||
debugPrint("API submission failed for Tarball. Message: ${dataResult['message']}");
|
||||
return {
|
||||
'status': 'L1',
|
||||
'success': false,
|
||||
@ -42,10 +43,11 @@ class MarineApiService {
|
||||
'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'];
|
||||
if (recordId == null) {
|
||||
debugPrint("API submitted, but no record ID returned.");
|
||||
return {
|
||||
'status': 'L2',
|
||||
'success': false,
|
||||
@ -60,6 +62,7 @@ class MarineApiService {
|
||||
});
|
||||
|
||||
if (filesToUpload.isEmpty) {
|
||||
debugPrint("No images to upload. Finalizing submission.");
|
||||
_handleTarballSuccessAlert(formData, appSettings, isDataOnly: true);
|
||||
return {
|
||||
'status': 'L3',
|
||||
@ -77,6 +80,7 @@ class MarineApiService {
|
||||
);
|
||||
|
||||
if (imageResult['success'] != true) {
|
||||
debugPrint("Image upload failed for Tarball. Message: ${imageResult['message']}");
|
||||
return {
|
||||
'status': 'L2',
|
||||
'success': false,
|
||||
@ -85,6 +89,7 @@ class MarineApiService {
|
||||
};
|
||||
}
|
||||
|
||||
debugPrint("Step 2 successful. All images uploaded.");
|
||||
_handleTarballSuccessAlert(formData, appSettings, isDataOnly: false);
|
||||
return {
|
||||
'status': 'L3',
|
||||
@ -105,6 +110,7 @@ class MarineApiService {
|
||||
final dataResult = await _baseService.post('marine/manual/sample', formData);
|
||||
|
||||
if (dataResult['success'] != true) {
|
||||
debugPrint("API submission failed for In-Situ. Message: ${dataResult['message']}");
|
||||
return {
|
||||
'status': 'L1',
|
||||
'success': false,
|
||||
@ -112,10 +118,11 @@ class MarineApiService {
|
||||
'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'];
|
||||
if (recordId == null) {
|
||||
debugPrint("API submitted, but no record ID returned.");
|
||||
return {
|
||||
'status': 'L2',
|
||||
'success': false,
|
||||
@ -130,6 +137,7 @@ class MarineApiService {
|
||||
});
|
||||
|
||||
if (filesToUpload.isEmpty) {
|
||||
debugPrint("No images to upload. Finalizing submission.");
|
||||
_handleInSituSuccessAlert(inSituData, appSettings, isDataOnly: true);
|
||||
return {
|
||||
'status': 'L3',
|
||||
@ -147,6 +155,7 @@ class MarineApiService {
|
||||
);
|
||||
|
||||
if (imageResult['success'] != true) {
|
||||
debugPrint("Image upload failed for In-Situ. Message: ${imageResult['message']}");
|
||||
return {
|
||||
'status': 'L2',
|
||||
'success': false,
|
||||
@ -155,11 +164,12 @@ class MarineApiService {
|
||||
};
|
||||
}
|
||||
|
||||
debugPrint("Step 2 successful. All images uploaded.");
|
||||
_handleInSituSuccessAlert(inSituData, appSettings, isDataOnly: false);
|
||||
return {
|
||||
'status': 'L3',
|
||||
'success': true,
|
||||
'message': 'In-situ data and images submitted successfully.',
|
||||
'message': 'Data and images submitted to server successfully.',
|
||||
'reportId': recordId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
79
lib/services/zipping_service.dart
Normal file
79
lib/services/zipping_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
archive:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: archive
|
||||
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||
|
||||
@ -37,6 +37,7 @@ dependencies:
|
||||
image: ^4.1.3 # For image processing (watermarks)
|
||||
permission_handler: ^11.3.1
|
||||
ftpconnect: ^2.0.5
|
||||
archive: ^4.0.3 # For creating ZIP files
|
||||
# --- 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