configure database for river module and fix api and ftp transmission for river

This commit is contained in:
ALim Aidrus 2025-09-02 21:13:20 +08:00
parent 3d74862576
commit 0d4d70cca6
34 changed files with 2290 additions and 2089 deletions

View File

@ -4,19 +4,18 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
// CHANGED: Added imports for MultiProvider and the services to be provided.
import 'package:provider/single_child_widget.dart';
import 'package:environment_monitoring_app/services/api_service.dart';
import 'package:environment_monitoring_app/services/base_api_service.dart';
import 'package:environment_monitoring_app/services/local_storage_service.dart';
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart';
// --- ADDED: Import for the new AirSamplingService ---
import 'package:environment_monitoring_app/services/air_sampling_service.dart';
import 'package:environment_monitoring_app/services/telegram_service.dart';
// FIX: ADDED MISSING IMPORTS
import 'package:environment_monitoring_app/services/server_config_service.dart';
import 'package:environment_monitoring_app/services/retry_service.dart';
import 'package:environment_monitoring_app/services/in_situ_sampling_service.dart'; // FIX: ADDED MISSING IMPORT
// START CHANGE: Import the new dedicated Marine services and remove the obsolete one
import 'package:environment_monitoring_app/services/marine_in_situ_sampling_service.dart';
import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart';
// END CHANGE
import 'package:environment_monitoring_app/theme.dart';
import 'package:environment_monitoring_app/auth_provider.dart';
@ -37,7 +36,6 @@ import 'package:environment_monitoring_app/screens/marine/marine_home_page.dart'
// Air Screens
import 'package:environment_monitoring_app/screens/air/manual/air_manual_dashboard.dart';
// --- UPDATED: Imports now point to the new separate screens ---
import 'package:environment_monitoring_app/screens/air/manual/air_manual_installation_screen.dart';
import 'package:environment_monitoring_app/screens/air/manual/air_manual_collection_screen.dart';
import 'package:environment_monitoring_app/screens/air/manual/report.dart' as airManualReport;
@ -54,14 +52,10 @@ import 'package:environment_monitoring_app/screens/air/investigative/report.dart
// River Screens
import 'package:environment_monitoring_app/screens/river/manual/river_manual_dashboard.dart';
// NOTE: This import points to the main stepper screen for the River In-Situ workflow.
import 'package:environment_monitoring_app/screens/river/manual/in_situ_sampling.dart' as riverManualInSituSampling;
import 'package:environment_monitoring_app/screens/river/manual/data_status_log.dart' as riverManualDataStatusLog;
//import 'package:environment_monitoring_app/screens/river/manual/in_situ_sampling.dart' as riverManualInSituSampling;
import 'package:environment_monitoring_app/screens/river/manual/report.dart' as riverManualReport;
import 'package:environment_monitoring_app/screens/river/manual/triennial_sampling.dart' as riverManualTriennialSampling;
//import 'package:environment_monitoring_app/screens/river/manual/data_status_log.dart' as riverManualDataStatusLog;
import 'package:environment_monitoring_app/screens/river/manual/image_request.dart' as riverManualImageRequest;
import 'package:environment_monitoring_app/screens/river/continuous/river_continuous_dashboard.dart';
import 'package:environment_monitoring_app/screens/river/continuous/overview.dart' as riverContinuousOverview;
@ -98,32 +92,21 @@ void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Create singleton instances of core services before running the app
// 1. Create dependent services
final DatabaseHelper databaseHelper = DatabaseHelper();
// FIX: TelegramService is created first, without ApiService.
final TelegramService telegramService = TelegramService();
// 2. Create the primary service, injecting its dependency
// FIX: ApiService now requires the TelegramService instance.
final ApiService apiService = ApiService(telegramService: telegramService);
// 3. Complete the circular reference injection (TelegramService needs ApiService)
// FIX: Inject the ApiService back into the TelegramService instance.
telegramService.setApiService(apiService);
setupServices(telegramService);
runApp(
// CHANGED: Converted to MultiProvider to support all necessary services.
MultiProvider(
providers: <SingleChildWidget>[
// The original AuthProvider
// FIX: AuthProvider now requires all its services in the constructor.
ChangeNotifierProvider(
create: (context) => AuthProvider(
apiService: apiService,
dbHelper: databaseHelper,
serverConfigService: ServerConfigService(), // Create local instances for AuthProvider DI
serverConfigService: ServerConfigService(),
retryService: RetryService(),
),
),
@ -131,12 +114,14 @@ void main() async {
Provider<ApiService>(create: (_) => apiService),
Provider<DatabaseHelper>(create: (_) => databaseHelper),
Provider<TelegramService>(create: (_) => telegramService),
// Providers for feature-specific services, with their dependencies correctly injected
Provider(create: (_) => LocalStorageService()),
Provider(create: (context) => RiverInSituSamplingService(apiService.river)), // FIXED: Passed the required dependency
// --- ADDED: Provider for the new AirSamplingService with dependencies ---
Provider(create: (context) => AirSamplingService(apiService, databaseHelper, telegramService)),
Provider(create: (context) => InSituSamplingService()), // FIX: InSituSamplingService constructor does not take arguments
// MODIFIED: Provide all dedicated services with their required dependencies
Provider(create: (context) => RiverInSituSamplingService(telegramService)),
Provider(create: (context) => AirSamplingService(databaseHelper, telegramService)),
// FIX: Pass the global telegramService to the marine service constructors
Provider(create: (context) => MarineInSituSamplingService(telegramService)),
Provider(create: (context) => MarineTarballSamplingService(telegramService)),
],
child: const RootApp(),
),
@ -217,7 +202,6 @@ class RootApp extends StatelessWidget {
// Air Manual
'/air/manual/dashboard': (context) => AirManualDashboard(),
// --- UPDATED: Routes now point to the new separate screens ---
'/air/manual/installation': (context) => const AirManualInstallationScreen(),
'/air/manual/collection': (context) => const AirManualCollectionScreen(),
'/air/manual/report': (context) => airManualReport.AirManualReport(),

View File

@ -1,3 +1,5 @@
// lib/models/in_situ_sampling_data.dart
import 'dart:io';
import 'dart:convert'; // Added for jsonEncode
@ -86,6 +88,11 @@ class InSituSamplingData {
return null;
}
// Helper to create a File object from a path string
File? fileFromPath(dynamic path) {
return (path is String && path.isNotEmpty) ? File(path) : null;
}
return InSituSamplingData()
..firstSamplerName = json['first_sampler_name']
..firstSamplerUserId = intFromJson(json['first_sampler_user_id'])
@ -124,7 +131,16 @@ class InSituSamplingData {
..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'];
..optionalRemark4 = json['man_optional_photo_04_remarks']
..leftLandViewImage = fileFromPath(json['man_left_side_land_view'])
..rightLandViewImage = fileFromPath(json['man_right_side_land_view'])
..waterFillingImage = fileFromPath(json['man_filling_water_into_sample_bottle'])
..seawaterColorImage = fileFromPath(json['man_seawater_in_clear_glass_bottle'])
..phPaperImage = fileFromPath(json['man_examine_preservative_ph_paper'])
..optionalImage1 = fileFromPath(json['man_optional_photo_01'])
..optionalImage2 = fileFromPath(json['man_optional_photo_02'])
..optionalImage3 = fileFromPath(json['man_optional_photo_03'])
..optionalImage4 = fileFromPath(json['man_optional_photo_04']);
}
/// Generates a formatted Telegram alert message for successful submissions.
@ -142,7 +158,6 @@ class InSituSamplingData {
..writeln('*Sonde ID:* ${sondeId ?? "N/A"}')
..writeln('*Status of Submission:* Successful');
// Add distance alert if relevant
if (distanceDifferenceInKm != null && distanceDifferenceInKm! > 0) {
buffer
..writeln()
@ -161,26 +176,29 @@ class InSituSamplingData {
Map<String, String> toApiFormData() {
final Map<String, String> map = {};
// Helper to add non-null values to the map
void add(String key, dynamic value) {
if (value != null) {
if (value != null && value.toString().isNotEmpty) {
map[key] = value.toString();
}
}
// Step 1 Data
add('first_sampler_user_id', firstSamplerUserId);
add('man_second_sampler_id', secondSampler?['user_id']);
// --- Required fields that were missing or incorrect ---
add('station_id', selectedStation?['station_id']);
add('man_date', samplingDate);
add('man_time', samplingTime);
add('first_sampler_user_id', firstSamplerUserId);
// --- Other Step 1 Data ---
add('man_second_sampler_id', secondSampler?['user_id']);
add('man_type', samplingType);
add('man_sample_id_code', sampleIdCode);
add('station_id', selectedStation?['station_id']);
add('man_current_latitude', currentLatitude);
add('man_current_longitude', currentLongitude);
add('man_distance_difference', distanceDifferenceInKm);
add('man_distance_difference_remarks', distanceDifferenceRemarks);
// Step 2 Data
// --- Step 2 Data ---
add('man_weather', weather);
add('man_tide_level', tideLevel);
add('man_sea_condition', seaCondition);
@ -191,7 +209,7 @@ class InSituSamplingData {
add('man_optional_photo_03_remarks', optionalRemark3);
add('man_optional_photo_04_remarks', optionalRemark4);
// Step 3 Data
// --- Step 3 Data ---
add('man_sondeID', sondeId);
add('data_capture_date', dataCaptureDate);
add('data_capture_time', dataCaptureTime);
@ -206,6 +224,7 @@ class InSituSamplingData {
add('man_tss', tss);
add('man_battery_volt', batteryVoltage);
// --- Human-readable fields for server-side alerts ---
add('first_sampler_name', firstSamplerName);
add('man_station_code', selectedStation?['man_station_code']);
add('man_station_name', selectedStation?['man_station_name']);
@ -228,12 +247,8 @@ class InSituSamplingData {
};
}
// --- ADDED: Methods to format data for FTP submission as separate JSON files ---
/// Creates a single JSON object with all submission data, mimicking 'db.json'
/// 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,

View File

@ -59,7 +59,7 @@ class RiverInSituSamplingData {
double? temperature;
double? tds;
double? turbidity;
double? tss;
double? ammonia; // MODIFIED: Replaced tss with ammonia
double? batteryVoltage;
// ADDED: New properties for Flowrate
@ -132,7 +132,7 @@ class RiverInSituSamplingData {
..temperature = doubleFromJson(json['r_man_temperature'])
..tds = doubleFromJson(json['r_man_tds'])
..turbidity = doubleFromJson(json['r_man_turbidity'])
..tss = doubleFromJson(json['r_man_tss'])
..ammonia = doubleFromJson(json['r_man_ammonia']) // MODIFIED: Replaced tss with ammonia
..batteryVoltage = doubleFromJson(json['r_man_battery_volt'])
// END FIX
..optionalRemark1 = json['r_man_optional_photo_01_remarks']
@ -204,7 +204,7 @@ class RiverInSituSamplingData {
add('r_man_temperature', temperature);
add('r_man_tds', tds);
add('r_man_turbidity', turbidity);
add('r_man_tss', tss);
add('r_man_ammonia', ammonia); // MODIFIED: Replaced tss with ammonia
add('r_man_battery_volt', batteryVoltage);
// ADDED: Flowrate fields to API form data
@ -284,7 +284,7 @@ class RiverInSituSamplingData {
'temperature': temperature,
'tds': tds,
'turbidity': turbidity,
'tss': tss,
'ammonia': ammonia, // MODIFIED: Replaced tss with ammonia
'batteryVoltage': batteryVoltage,
'flowrateMethod': flowrateMethod,
'flowrateSurfaceDrifterHeight': flowrateSurfaceDrifterHeight,
@ -324,6 +324,7 @@ class RiverInSituSamplingData {
'turbidity': turbidity,
'tds': tds,
'electric_conductivity': electricalConductivity,
'ammonia': ammonia, // MODIFIED: Added ammonia
'flowrate': flowrateValue,
'odour': '', // Assuming these are not collected in this form
'floatable': '', // Assuming these are not collected in this form
@ -365,6 +366,7 @@ class RiverInSituSamplingData {
'turbidity': turbidity,
'tds': tds,
'electric_conductivity': electricalConductivity,
'ammonia': ammonia, // MODIFIED: Added ammonia
'flowrate': flowrateValue,
'date_sampling_reading': samplingDate,
'time_sampling_reading': samplingTime,

View File

@ -1,4 +1,5 @@
//import 'dart' as dart;
// lib/models/tarball_data.dart
import 'dart:io';
import 'dart:convert';
@ -77,40 +78,36 @@ class TarballSamplingData {
/// Converts the form's text and selection data into a Map suitable for JSON encoding.
/// This map will be sent as the body of the first API request.
// START CHANGE: Corrected the toFormData method to include all required fields for the API.
Map<String, String> toFormData() {
final Map<String, String> data = {
// Required fields
// Required fields that were missing or not being sent correctly
'station_id': selectedStation?['station_id']?.toString() ?? '',
'sampling_date': samplingDate ?? '',
'sampling_time': samplingTime ?? '',
// User ID fields
'first_sampler_user_id': firstSamplerUserId?.toString() ?? '',
'second_sampler_user_id': secondSampler?['user_id']?.toString() ?? '',
// Foreign Key ID for classification
'classification_id': classificationId?.toString() ?? '',
'first_sampler_user_id': firstSamplerUserId?.toString() ?? '',
// Other nullable fields
// Optional fields
'second_sampler_user_id': secondSampler?['user_id']?.toString() ?? '',
'current_latitude': currentLatitude ?? '',
'current_longitude': currentLongitude ?? '',
'distance_difference': distanceDifference?.toString() ?? '',
'distance_remarks': distanceDifferenceRemarks ?? '',
'optional_photo_remark_01': optionalRemark1 ?? '',
'optional_photo_remark_02': optionalRemark2 ?? '',
'optional_photo_remark_03': optionalRemark3 ?? '',
'optional_photo_remark_04': optionalRemark4 ?? '',
'distance_remarks': distanceDifferenceRemarks ?? '',
// Human-readable names for the Telegram alert
// Human-readable names for the Telegram alert on the server-side
'tbl_station_name': selectedStation?['tbl_station_name']?.toString() ?? '',
'tbl_station_code': selectedStation?['tbl_station_code']?.toString() ?? '',
'first_sampler_name': firstSampler ?? '',
// NECESSARY CHANGE: Add the classification name for the alert.
'classification_name': selectedClassification?['classification_name']?.toString() ?? '',
};
return data;
}
// END CHANGE
/// Gathers all non-null image files into a Map.
/// This map is used to build the multipart request for the second API call (image upload).

View File

@ -9,17 +9,20 @@ import 'package:environment_monitoring_app/models/tarball_data.dart';
import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart';
import 'package:environment_monitoring_app/services/local_storage_service.dart';
import 'package:environment_monitoring_app/services/api_service.dart';
import 'package:environment_monitoring_app/services/in_situ_sampling_service.dart';
// START CHANGE: Import the new dedicated services
import 'package:environment_monitoring_app/services/marine_in_situ_sampling_service.dart';
import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart';
// END CHANGE
import 'dart:convert';
/// A unified model for a submission log entry, specific to the Marine module.
class SubmissionLogEntry {
final String type; // e.g., 'Manual Sampling', 'Tarball Sampling'
final String type;
final String title;
final String stationCode;
final DateTime submissionDateTime;
final String? reportId;
final String status; // High-level status (S3, L1, etc.)
final String status;
final String message;
final Map<String, dynamic> rawData;
final String serverName;
@ -52,10 +55,10 @@ class MarineManualDataStatusLog extends StatefulWidget {
class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
final LocalStorageService _localStorageService = LocalStorageService();
late ApiService _apiService;
late InSituSamplingService _marineInSituService;
// MODIFIED: Declare the service variables but do not instantiate them here.
late MarineInSituSamplingService _marineInSituService;
late MarineTarballSamplingService _marineTarballService;
List<SubmissionLogEntry> _allLogs = [];
List<SubmissionLogEntry> _manualLogs = [];
List<SubmissionLogEntry> _tarballLogs = [];
List<SubmissionLogEntry> _filteredManualLogs = [];
@ -70,13 +73,23 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
@override
void initState() {
super.initState();
_apiService = Provider.of<ApiService>(context, listen: false);
_marineInSituService = Provider.of<InSituSamplingService>(context, listen: false);
// MODIFIED: Service instantiations are removed from initState.
// They will be initialized in didChangeDependencies.
_manualSearchController.addListener(_filterLogs);
_tarballSearchController.addListener(_filterLogs);
_loadAllLogs();
}
// ADDED: didChangeDependencies to safely get services from the Provider.
// This is the correct lifecycle method for this purpose.
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Fetch the single, global instances of the services from the Provider tree.
_marineInSituService = Provider.of<MarineInSituSamplingService>(context);
_marineTarballService = Provider.of<MarineTarballSamplingService>(context);
}
@override
void dispose() {
_manualSearchController.dispose();
@ -94,8 +107,8 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
final List<SubmissionLogEntry> tempTarball = [];
for (var log in inSituLogs) {
final String dateStr = log['data_capture_date'] ?? log['sampling_date'] ?? '';
final String timeStr = log['data_capture_time'] ?? log['sampling_time'] ?? '';
final String dateStr = log['samplingDate'] ?? '';
final String timeStr = log['samplingTime'] ?? '';
tempManual.add(SubmissionLogEntry(
type: 'Manual Sampling',
@ -171,84 +184,74 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings;
final logData = log.rawData;
Map<String, dynamic> result = {};
if (log.type == 'Manual Sampling') {
final dataToResubmit = InSituSamplingData.fromJson(logData);
final Map<String, File?> imageFiles = {};
dataToResubmit.toApiImageFiles().keys.forEach((key) {
final imagePath = logData[key];
if (imagePath is String && imagePath.isNotEmpty) {
imageFiles[key] = File(imagePath);
}
});
result = await _apiService.marine.submitInSituSample(
formData: dataToResubmit.toApiFormData(),
imageFiles: imageFiles,
inSituData: dataToResubmit,
// START CHANGE: Reconstruct data object and call the new service
final dataToResubmit = InSituSamplingData.fromJson(log.rawData);
// Re-attach File objects from paths
dataToResubmit.leftLandViewImage = File(log.rawData['man_left_side_land_view'] ?? '');
dataToResubmit.rightLandViewImage = File(log.rawData['man_right_side_land_view'] ?? '');
dataToResubmit.waterFillingImage = File(log.rawData['man_filling_water_into_sample_bottle'] ?? '');
dataToResubmit.seawaterColorImage = File(log.rawData['man_seawater_in_clear_glass_bottle'] ?? '');
dataToResubmit.phPaperImage = File(log.rawData['man_examine_preservative_ph_paper'] ?? '');
dataToResubmit.optionalImage1 = File(log.rawData['man_optional_photo_01'] ?? '');
dataToResubmit.optionalImage2 = File(log.rawData['man_optional_photo_02'] ?? '');
dataToResubmit.optionalImage3 = File(log.rawData['man_optional_photo_03'] ?? '');
dataToResubmit.optionalImage4 = File(log.rawData['man_optional_photo_04'] ?? '');
result = await _marineInSituService.submitInSituSample(
data: dataToResubmit,
appSettings: appSettings,
);
// END CHANGE
} else if (log.type == 'Tarball Sampling') {
// FIX: Manually map the raw data to a new TarballSamplingData instance
final int? firstSamplerId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? '');
final int? classificationId = int.tryParse(logData['classification_id']?.toString() ?? '');
// START CHANGE: Reconstruct data object and call the new service
final dataToResubmit = TarballSamplingData(); // Create a fresh instance
final logData = log.rawData;
final dataToResubmit = TarballSamplingData()
..firstSamplerUserId = firstSamplerId
..secondSampler = logData['secondSampler']
..samplingDate = logData['samplingDate']
..samplingTime = logData['samplingTime']
..selectedStateName = logData['selectedStateName']
..selectedCategoryName = logData['selectedCategoryName']
..selectedStation = logData['selectedStation']
..stationLatitude = logData['stationLatitude']
..stationLongitude = logData['stationLongitude']
..currentLatitude = logData['currentLatitude']
..currentLongitude = logData['currentLongitude']
..distanceDifference = double.tryParse(logData['distanceDifference']?.toString() ?? '0.0')
..distanceDifferenceRemarks = logData['distanceDifferenceRemarks']
..classificationId = classificationId
..selectedClassification = logData['selectedClassification']
..optionalRemark1 = logData['optionalRemark1']
..optionalRemark2 = logData['optionalRemark2']
..optionalRemark3 = logData['optionalRemark3']
..optionalRemark4 = logData['optionalRemark4']
..reportId = logData['reportId']?.toString();
// Manually map fields from the raw log data to the new object
dataToResubmit.firstSampler = logData['firstSampler'];
dataToResubmit.firstSamplerUserId = logData['firstSamplerUserId'];
dataToResubmit.secondSampler = logData['secondSampler'];
dataToResubmit.samplingDate = logData['samplingDate'];
dataToResubmit.samplingTime = logData['samplingTime'];
dataToResubmit.selectedStateName = logData['selectedStateName'];
dataToResubmit.selectedCategoryName = logData['selectedCategoryName'];
dataToResubmit.selectedStation = logData['selectedStation'];
dataToResubmit.stationLatitude = logData['stationLatitude'];
dataToResubmit.stationLongitude = logData['stationLongitude'];
dataToResubmit.currentLatitude = logData['currentLatitude'];
dataToResubmit.currentLongitude = logData['currentLongitude'];
dataToResubmit.distanceDifference = logData['distanceDifference'];
dataToResubmit.distanceDifferenceRemarks = logData['distanceDifferenceRemarks'];
dataToResubmit.classificationId = logData['classificationId'];
dataToResubmit.selectedClassification = logData['selectedClassification'];
dataToResubmit.optionalRemark1 = logData['optionalRemark1'];
dataToResubmit.optionalRemark2 = logData['optionalRemark2'];
dataToResubmit.optionalRemark3 = logData['optionalRemark3'];
dataToResubmit.optionalRemark4 = logData['optionalRemark4'];
final Map<String, File?> imageFiles = {};
dataToResubmit.toImageFiles().keys.forEach((key) {
final imagePath = logData[key];
if (imagePath is String && imagePath.isNotEmpty) {
imageFiles[key] = File(imagePath);
}
});
result = await _apiService.marine.submitTarballSample(
formData: dataToResubmit.toFormData(),
imageFiles: imageFiles,
// Re-attach File objects from paths
dataToResubmit.leftCoastalViewImage = File(logData['left_side_coastal_view'] ?? '');
dataToResubmit.rightCoastalViewImage = File(logData['right_side_coastal_view'] ?? '');
dataToResubmit.verticalLinesImage = File(logData['drawing_vertical_lines'] ?? '');
dataToResubmit.horizontalLineImage = File(logData['drawing_horizontal_line'] ?? '');
dataToResubmit.optionalImage1 = File(logData['optional_photo_01'] ?? '');
dataToResubmit.optionalImage2 = File(logData['optional_photo_02'] ?? '');
dataToResubmit.optionalImage3 = File(logData['optional_photo_03'] ?? '');
dataToResubmit.optionalImage4 = File(logData['optional_photo_04'] ?? '');
result = await _marineTarballService.submitTarballSample(
data: dataToResubmit,
appSettings: appSettings,
);
// END CHANGE
}
final updatedLogData = log.rawData;
updatedLogData['submissionStatus'] = result['status'];
updatedLogData['submissionMessage'] = result['message'];
updatedLogData['reportId'] = result['reportId']?.toString() ?? updatedLogData['reportId'];
updatedLogData['api_status'] = jsonEncode(result['api_status']);
updatedLogData['ftp_status'] = jsonEncode(result['ftp_status']);
// This line is likely incorrect, assuming you want the name of the successful server
// updatedLogData['serverConfigName'] = (await _apiService.dbHelper.loadApiConfigs() ?? []).firstWhere((c) => c['api_config_id'] == 1)['config_name'];
if (log.type == 'Manual Sampling') {
await _localStorageService.updateInSituLog(updatedLogData);
} else if (log.type == 'Tarball Sampling') {
await _localStorageService.updateTarballLog(updatedLogData);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Resubmission successful!')),
SnackBar(content: Text(result['message'] ?? 'Resubmission process completed.')),
);
}
} catch (e) {
@ -308,13 +311,12 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
prefixIcon: const Icon(Icons.search, size: 20),
isDense: true,
border: const OutlineInputBorder(),
suffixIcon: IconButton(
suffixIcon: searchController.text.isNotEmpty ? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
searchController.clear();
_filterLogs();
},
),
) : null,
),
),
),
@ -399,7 +401,7 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
try {
statuses = jsonDecode(jsonStatus);
} catch (_) {
return _buildDetailRow('$type Status:', jsonStatus!);
return _buildDetailRow('$type Status:', jsonStatus);
}
if (statuses.isEmpty) {
@ -413,7 +415,7 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
children: [
Text('$type Status:', style: const TextStyle(fontWeight: FontWeight.bold)),
...statuses.map((s) {
final serverName = s['server_name'] ?? 'Server N/A';
final serverName = s['server_name'] ?? s['config_name'] ?? 'Server N/A';
final status = s['status'] ?? 'N/A';
final bool isSuccess = status.toLowerCase().contains('success') || status.toLowerCase().contains('queued') || status.toLowerCase().contains('not_configured') || status.toLowerCase().contains('not_applicable') || status.toLowerCase().contains('not_required');
final IconData icon = isSuccess ? Icons.check_circle_outline : (status.toLowerCase().contains('failed') ? Icons.error_outline : Icons.sync);

View File

@ -4,19 +4,11 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
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';
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';
// START CHANGE: Import the new, consolidated MarineInSituSamplingService
import '../../../services/marine_in_situ_sampling_service.dart';
// END CHANGE
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';
@ -37,17 +29,9 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
late InSituSamplingData _data;
// A single instance of the service to be used by all child widgets.
final InSituSamplingService _samplingService = InSituSamplingService();
// Service for saving submission logs locally.
final LocalStorageService _localStorageService = LocalStorageService();
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 ---
// MODIFIED: Declare the service variable but do not instantiate it here.
// It will be initialized from the Provider.
late MarineInSituSamplingService _samplingService;
int _currentPage = 0;
bool _isLoading = false;
@ -61,10 +45,24 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
);
}
// ADDED: didChangeDependencies to safely get the service from the Provider.
// This is the correct lifecycle method to access inherited widgets like Provider.
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Fetch the single, global instance of the service from the Provider tree.
_samplingService = Provider.of<MarineInSituSamplingService>(context);
}
@override
void dispose() {
_pageController.dispose();
_samplingService.dispose();
// START FIX
// REMOVED: _samplingService.dispose();
// The service is managed by a higher-level Provider and should not be disposed of
// here, as other widgets might still be listening to it. This prevents the
// "ValueNotifier was used after being disposed" error.
// END FIX
super.dispose();
}
@ -88,128 +86,47 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
}
}
// --- REPLACED: _submitForm() method with the new workflow ---
// START CHANGE: The _submitForm method is now greatly simplified.
Future<void> _submitForm() async {
setState(() => _isLoading = true);
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';
// 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();
// Delegate the entire submission process to the new dedicated service.
// The service handles API calls, zipping, FTP queuing, logging, and alerts.
final result = await _samplingService.submitInSituSample(
data: _data,
appSettings: appSettings,
);
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 (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.';
}
await _localStorageService.saveInSituSamplingData(_data, serverName: serverName);
setState(() => _isLoading = false);
final message = _data.submissionMessage ?? 'An unknown error occurred.';
final color = (apiSuccess || ftpSuccess) ? Colors.green : Colors.red;
// Display the final result to the user
final message = result['message'] ?? 'An unknown error occurred.';
final color = (result['success'] == true) ? Colors.green : Colors.red;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
);
Navigator.of(context).popUntil((route) => route.isFirst);
}
// END CHANGE
@override
Widget build(BuildContext context) {
// Use Provider.value to provide the existing service instance to all child widgets.
return Provider.value(
// START CHANGE: Provide the new MarineInSituSamplingService to all child widgets.
// The child widgets (Step 1, 2, 3) can now access all its methods (for location,
// image picking, and device connection) via Provider.
return Provider<MarineInSituSamplingService>.value(
value: _samplingService,
// END CHANGE
child: Scaffold(
appBar: AppBar(
title: Text('In-Situ Sampling (${_currentPage + 1}/4)'),
// Show a back button on all pages except the first one.
leading: _currentPage > 0
? IconButton(
icon: const Icon(Icons.arrow_back),
@ -219,7 +136,6 @@ class _MarineInSituSamplingState extends State<MarineInSituSampling> {
),
body: PageView(
controller: _pageController,
// Disable manual swiping between pages.
physics: const NeverScrollableScrollPhysics(),
onPageChanged: (page) {
setState(() {

View File

@ -12,7 +12,9 @@ import 'package:path/path.dart' as path;
import 'package:image/image.dart' as img;
import 'package:environment_monitoring_app/auth_provider.dart';
import 'package:environment_monitoring_app/services/marine_api_service.dart';
// START CHANGE: Import the new dedicated service
import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart';
// END CHANGE
import 'package:environment_monitoring_app/models/tarball_data.dart';
class MarineTarballSampling extends StatefulWidget {
@ -27,7 +29,8 @@ class _MarineTarballSamplingState extends State<MarineTarballSampling> {
final _formKey2 = GlobalKey<FormState>();
int _currentStep = 1;
final MarineApiService _marineApiService = MarineApiService();
// MODIFIED: The service instance is no longer created here.
// It will be fetched from the Provider right before it is used.
bool _isLoading = false;
final TarballSamplingData _data = TarballSamplingData();
@ -86,7 +89,7 @@ class _MarineTarballSamplingState extends State<MarineTarballSampling> {
Future<void> _getCurrentLocation() async { /* ... Location logic ... */ }
void _calculateDistance() { /* ... Distance logic ... */ }
// MODIFIED: This method now fetches appSettings and passes it to the API service.
// MODIFIED: This method now uses the new dedicated service for submission.
Future<void> _submitForm() async {
setState(() => _isLoading = true);
@ -94,12 +97,15 @@ class _MarineTarballSamplingState extends State<MarineTarballSampling> {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings;
// Pass the appSettings list to the submit method.
final result = await _marineApiService.submitTarballSample(
formData: _data.toFormData(),
imageFiles: _data.toImageFiles(),
// ADDED: Fetch the global service instance from Provider.
final tarballService = Provider.of<MarineTarballSamplingService>(context, listen: false);
// START CHANGE: Call the method on the new dedicated service
final result = await tarballService.submitTarballSample(
data: _data,
appSettings: appSettings,
);
// END CHANGE
if (!mounted) return;
setState(() => _isLoading = false);

View File

@ -1,20 +1,14 @@
// lib/screens/marine/manual/tarball_sampling_step3_summary.dart
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';
// 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';
// START CHANGE: Import the new dedicated service
import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.dart';
// END CHANGE
class TarballSamplingStep3Summary extends StatefulWidget {
@ -26,226 +20,42 @@ class TarballSamplingStep3Summary extends StatefulWidget {
}
class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summary> {
// FIX: Removed direct instantiation of 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 ---
// MODIFIED: The service instance is no longer created here.
// It will be fetched from the Provider where it's needed.
bool _isLoading = false;
// --- REPLACED: _submitForm() method with the new workflow ---
// START CHANGE: The _submitForm method is now greatly simplified
Future<void> _submitForm() async {
setState(() => _isLoading = true);
final authProvider = Provider.of<AuthProvider>(context, listen: false);
// FIX: Retrieve ApiService from the Provider tree
final apiService = Provider.of<ApiService>(context, listen: false);
final appSettings = authProvider.appSettings;
final activeApiConfig = await _serverConfigService.getActiveApiConfig();
final serverName = activeApiConfig?['config_name'] as String? ?? 'Default';
// 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();
// ADDED: Fetch the global service instance from Provider before using it.
// We use listen: false as this is a one-time action within a method.
final tarballService = Provider.of<MarineTarballSamplingService>(context, listen: false);
// Create a temporary, separate copy of the data for the FTP process
final dataForFtp = widget.data;
bool apiSuccess = false;
bool ftpQueueSuccess = false;
List<Map<String, dynamic>> apiStatuses = [];
List<Map<String, dynamic>> ftpStatuses = [];
String finalStatus = 'L1';
String finalMessage = 'All submission attempts failed. Data saved locally for retry.';
// --- Step 1: Attempt API Submission ---
debugPrint("Step 1: Attempting API submission...");
final apiResult = await apiService.marine.submitTarballSample( // FIX: Use retrieved ApiService
formData: widget.data.toFormData(),
imageFiles: widget.data.toImageFiles(),
// Delegate the entire submission process to the new dedicated service
final result = await tarballService.submitTarballSample(
data: widget.data,
appSettings: appSettings,
);
apiSuccess = apiResult['success'] == true;
widget.data.reportId = apiResult['reportId']?.toString();
final serverReportId = widget.data.reportId;
// Determine granular API statuses (Simulation based on BaseApiService trying 2 servers)
for (int i = 0; i < apiConfigs.length; i++) {
final config = apiConfigs[i];
String status;
String message;
if (apiSuccess && i == 0) {
status = "SUCCESS";
message = "Data posted successfully to primary API.";
} else if (apiSuccess && i > 0) {
status = "SUCCESS (Fallback)";
message = "Data posted successfully to fallback API.";
} else {
status = "FAILED";
message = apiResult['message'] ?? "Connection or server error.";
}
apiStatuses.add({
"server_name": config['config_name'],
"status": status,
"message": message,
});
}
// --- Step 2: Attempt FTP Submission Queueing ---
if (ftpConfigs.isNotEmpty) {
debugPrint("Step 2: FTP server configured. Proceeding with zipping and queuing.");
final stationCode = dataForFtp.selectedStation?['tbl_station_code'] ?? 'NA';
final reportId = serverReportId ?? DateTime.now().millisecondsSinceEpoch.toString();
final baseFileName = '${stationCode}_$reportId';
// Flags to check if zipping/queuing was successful for AT LEAST ONE FTP server
bool dataZipQueued = false;
bool imageZipQueued = false;
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,
);
// Queue for each configured FTP server
for (var config in ftpConfigs) {
// Note: We use the RetryService method here, which queues if the upload fails,
// but since we are not *uploading* here, we just queue everything for subsequent FTP process.
String status;
String message;
if (dataZip != null) {
await _retryService.addFtpToQueue(
localFilePath: dataZip.path,
remotePath: '/uploads/data/${p.basename(dataZip.path)}',
);
dataZipQueued = true;
status = "QUEUED";
message = "Data zip queued successfully.";
} else {
status = "FAILED (ZIP)";
message = "Data zip file could not be created.";
}
ftpStatuses.add({
"server_name": config['config_name'],
"status": status,
"message": message,
"type": "data",
});
// Queue images only if they exist
if (imageZip != null) {
await _retryService.addFtpToQueue(
localFilePath: imageZip.path,
remotePath: '/uploads/images/${p.basename(imageZip.path)}',
);
imageZipQueued = true;
status = "QUEUED";
message = "Image zip queued successfully.";
} else {
status = "NOT_REQUIRED/N/A";
message = "No images to queue or zip failed.";
}
ftpStatuses.add({
"server_name": config['config_name'],
"status": status,
"message": message,
"type": "images",
});
}
ftpQueueSuccess = dataZipQueued || imageZipQueued;
} catch (e) {
debugPrint("FTP zipping or queuing failed with an error: $e");
ftpQueueSuccess = false;
}
} else {
ftpStatuses.add({
"server_name": "N/A",
"status": "NOT_CONFIGURED",
"message": "No FTP servers configured.",
});
}
// --- Step 3: Determine Final Status and Log to DB ---
if (apiSuccess && ftpQueueSuccess) {
finalStatus = 'S4'; // Submitted API, Queued FTP
finalMessage = 'Data submitted to API and files queued for FTP upload.';
} else if (apiSuccess) {
finalStatus = 'S3'; // Submitted API Only
finalMessage = 'Data submitted successfully to API. FTP queueing failed or not configured.';
} else if (ftpQueueSuccess) {
finalStatus = 'L4'; // Failed API, Queued FTP
finalMessage = 'API submission failed but files were successfully queued for FTP.';
} else {
finalStatus = 'L1'; // All submissions failed
finalMessage = 'All submission attempts failed. Data saved locally for retry.';
}
// Set final high-level status in the model
widget.data.submissionStatus = finalStatus;
widget.data.submissionMessage = finalMessage;
// 4. Save the final high-level status (to file system for resubmission tracking)
await _localStorageService.saveTarballSamplingData(widget.data, serverName: serverName);
// 5. Save granular status to the central database log
final logData = {
'submission_id': widget.data.reportId ?? widget.data.samplingDate!,
'module': 'marine',
'type': 'Tarball Sampling',
'status': finalStatus,
'message': finalMessage,
'report_id': widget.data.reportId,
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(widget.data.toDbJson()),
'image_data': jsonEncode(widget.data.toImageFiles().keys.map((k) => widget.data.toImageFiles()[k]?.path).where((p) => p != null).toList()),
'server_name': serverName,
'api_status': jsonEncode(apiStatuses), // GRANULAR API STATUSES
'ftp_status': jsonEncode(ftpStatuses), // GRANULAR FTP STATUSES
};
await _dbHelper.saveSubmissionLog(logData);
if (!mounted) return;
setState(() => _isLoading = false);
final message = widget.data.submissionMessage ?? 'An unknown error occurred.';
final color = (apiSuccess || ftpQueueSuccess) ? Colors.green : Colors.red;
final message = result['message'] ?? 'An unknown error occurred.';
final color = (result['success'] == true) ? Colors.green : Colors.red;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
);
Navigator.of(context).popUntil((route) => route.isFirst);
}
// END CHANGE
@override
Widget build(BuildContext context) {
@ -293,7 +103,6 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
? "${(widget.data.distanceDifference! * 1000).toStringAsFixed(0)} meters"
: "N/A"
),
// NECESSARY CHANGE: Add this line to display the remarks.
_buildDetailRow("Distance Remarks:", widget.data.distanceDifferenceRemarks),
],
),
@ -301,7 +110,6 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
_buildSectionCard(
"On-Site Information & Photos",
[
// CORRECTED: Look up the classification name from the provider using the ID.
Consumer<AuthProvider>(
builder: (context, auth, child) {
String classificationName = 'N/A';
@ -377,10 +185,9 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
}
Widget _buildDetailRow(String label, String? value) {
// This function now correctly handles null or empty remarks by displaying 'N/A'
final bool isValueAvailable = value != null && value.isNotEmpty;
return Visibility(
visible: isValueAvailable, // Only show the row if there is a value to display
visible: isValueAvailable,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
@ -397,7 +204,7 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
Expanded(
flex: 3,
child: Text(
value ?? 'N/A', // Display the value or 'N/A' if null
value ?? 'N/A',
style: Theme.of(context).textTheme.bodyLarge,
),
),

View File

@ -8,7 +8,9 @@ import 'package:simple_barcode_scanner/simple_barcode_scanner.dart';
import '../../../../auth_provider.dart';
import '../../../../models/in_situ_sampling_data.dart';
import '../../../../services/in_situ_sampling_service.dart';
// START CHANGE: Import the new, correct service file
import '../../../../services/marine_in_situ_sampling_service.dart';
// END CHANGE
class InSituStep1SamplingInfo extends StatefulWidget {
final InSituSamplingData data;
@ -119,7 +121,9 @@ class _InSituStep1SamplingInfoState extends State<InSituStep1SamplingInfo> {
Future<void> _getCurrentLocation() async {
setState(() => _isLoadingLocation = true);
final service = Provider.of<InSituSamplingService>(context, listen: false);
// START CHANGE: Use the correct, new service type from Provider
final service = Provider.of<MarineInSituSamplingService>(context, listen: false);
// END CHANGE
try {
final position = await service.getCurrentLocation();
@ -150,7 +154,9 @@ class _InSituStep1SamplingInfoState extends State<InSituStep1SamplingInfo> {
final lon2Str = widget.data.currentLongitude;
if (lat1Str != null && lon1Str != null && lat2Str != null && lon2Str != null) {
final service = Provider.of<InSituSamplingService>(context, listen: false);
// START CHANGE: Use the correct, new service type from Provider
final service = Provider.of<MarineInSituSamplingService>(context, listen: false);
// END CHANGE
final lat1 = double.tryParse(lat1Str);
final lon1 = double.tryParse(lon1Str);
final lat2 = double.tryParse(lat2Str);
@ -271,7 +277,7 @@ class _InSituStep1SamplingInfoState extends State<InSituStep1SamplingInfo> {
child: ListView(
padding: const EdgeInsets.all(24.0),
children: [
// Sampling Information section... (unchanged)
// Sampling Information section...
Text("Sampling Information", style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 24),
TextFormField(controller: _firstSamplerController, readOnly: true, decoration: const InputDecoration(labelText: '1st Sampler')),
@ -302,7 +308,9 @@ class _InSituStep1SamplingInfoState extends State<InSituStep1SamplingInfo> {
),
const SizedBox(height: 24),
// Station Selection section... (unchanged)
// Station Selection section...
Text("Station Selection", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
DropdownSearch<String>(
items: _statesList,
selectedItem: widget.data.selectedStateName,
@ -382,7 +390,6 @@ class _InSituStep1SamplingInfoState extends State<InSituStep1SamplingInfo> {
TextFormField(controller: _currentLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Latitude')),
const SizedBox(height: 16),
TextFormField(controller: _currentLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Longitude')),
// MODIFIED: Distance text is now more prominent and styled
if (widget.data.distanceDifferenceInKm != null)
Padding(
padding: const EdgeInsets.only(top: 16.0),

View File

@ -1,10 +1,14 @@
// lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '../../../../models/in_situ_sampling_data.dart';
import '../../../../services/in_situ_sampling_service.dart';
// START CHANGE: Import the new, correct service file
import '../../../../services/marine_in_situ_sampling_service.dart';
// END CHANGE
/// The second step of the In-Situ Sampling form.
/// Gathers on-site conditions (weather, tide) and handles all photo attachments.
@ -66,7 +70,9 @@ class _InSituStep2SiteInfoState extends State<InSituStep2SiteInfo> {
if (_isPickingImage) return;
setState(() => _isPickingImage = true);
final service = Provider.of<InSituSamplingService>(context, listen: false);
// START CHANGE: Use the correct service type from Provider
final service = Provider.of<MarineInSituSamplingService>(context, listen: false);
// END CHANGE
final file = await service.pickAndProcessImage(source, data: widget.data, imageInfo: imageInfo, isRequired: isRequired);
@ -163,10 +169,10 @@ class _InSituStep2SiteInfoState extends State<InSituStep2SiteInfo> {
// --- Section: Optional Photos ---
Text("Optional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
_buildImagePicker('Optional Photo 1', 'OPTIONAL_1', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, remarkController: _optionalRemark1Controller, isRequired: true),
_buildImagePicker('Optional Photo 2', 'OPTIONAL_2', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, remarkController: _optionalRemark2Controller, isRequired: true),
_buildImagePicker('Optional Photo 3', 'OPTIONAL_3', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, remarkController: _optionalRemark3Controller, isRequired: true),
_buildImagePicker('Optional Photo 4', 'OPTIONAL_4', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, remarkController: _optionalRemark4Controller, isRequired: true),
_buildImagePicker('Optional Photo 1', 'OPTIONAL_1', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, remarkController: _optionalRemark1Controller, isRequired: false),
_buildImagePicker('Optional Photo 2', 'OPTIONAL_2', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, remarkController: _optionalRemark2Controller, isRequired: false),
_buildImagePicker('Optional Photo 3', 'OPTIONAL_3', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, remarkController: _optionalRemark3Controller, isRequired: false),
_buildImagePicker('Optional Photo 4', 'OPTIONAL_4', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, remarkController: _optionalRemark4Controller, isRequired: false),
const SizedBox(height: 24),
// --- Section: Remarks ---

View File

@ -1,3 +1,5 @@
// lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -5,7 +7,7 @@ import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:usb_serial/usb_serial.dart';
import '../../../../models/in_situ_sampling_data.dart';
import '../../../../services/in_situ_sampling_service.dart';
import '../../../../services/marine_in_situ_sampling_service.dart';
import '../../../../bluetooth/bluetooth_manager.dart'; // For connection state enum
import '../../../../serial/serial_manager.dart'; // For connection state enum
import '../../../../bluetooth/widgets/bluetooth_device_list_dialog.dart';
@ -25,7 +27,9 @@ class InSituStep3DataCapture extends StatefulWidget {
State<InSituStep3DataCapture> createState() => _InSituStep3DataCaptureState();
}
class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> {
// START CHANGE: Add WidgetsBindingObserver to listen for app lifecycle events
class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with WidgetsBindingObserver {
// END CHANGE
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _isAutoReading = false;
@ -51,15 +55,34 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> {
void initState() {
super.initState();
_initializeControllers();
// START CHANGE: Register the lifecycle observer
WidgetsBinding.instance.addObserver(this);
// END CHANGE
}
@override
void dispose() {
_dataSubscription?.cancel();
_disposeControllers();
// START CHANGE: Remove the lifecycle observer
WidgetsBinding.instance.removeObserver(this);
// END CHANGE
super.dispose();
}
// START CHANGE: Add the observer method to handle app resume events
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// When the app is resumed (e.g., after the user grants the native USB permission),
// call setState to force the widget to rebuild and show the latest connection status.
if (mounted) {
setState(() {});
}
}
}
// END CHANGE
void _initializeControllers() {
// Use the date and time from Step 1
widget.data.dataCaptureDate = widget.data.samplingDate;
@ -127,7 +150,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> {
/// Handles the entire connection flow, including a permission check.
Future<void> _handleConnectionAttempt(String type) async {
final service = context.read<InSituSamplingService>();
final service = context.read<MarineInSituSamplingService>();
final bool hasPermissions = await service.requestDevicePermissions();
if (!hasPermissions && mounted) {
@ -154,7 +177,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> {
Future<bool> _connectToDevice(String type) async {
setState(() => _isLoading = true);
final service = context.read<InSituSamplingService>();
final service = context.read<MarineInSituSamplingService>();
bool success = false;
try {
@ -191,7 +214,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> {
}
void _toggleAutoReading(String activeType) {
final service = context.read<InSituSamplingService>();
final service = context.read<MarineInSituSamplingService>();
setState(() {
_isAutoReading = !_isAutoReading;
if (_isAutoReading) {
@ -205,7 +228,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> {
}
void _disconnect(String type) {
final service = context.read<InSituSamplingService>();
final service = context.read<MarineInSituSamplingService>();
if (type == 'bluetooth') {
service.disconnectFromBluetooth();
} else {
@ -219,7 +242,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> {
}
void _disconnectFromAll() {
final service = context.read<InSituSamplingService>();
final service = context.read<MarineInSituSamplingService>();
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
_disconnect('bluetooth');
}
@ -298,7 +321,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> {
}
Map<String, dynamic>? _getActiveConnectionDetails() {
final service = context.watch<InSituSamplingService>();
final service = context.watch<MarineInSituSamplingService>();
if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
return {
'type': 'bluetooth',
@ -318,7 +341,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> {
@override
Widget build(BuildContext context) {
final service = context.watch<InSituSamplingService>();
final service = context.watch<MarineInSituSamplingService>();
final activeConnection = _getActiveConnectionDetails();
final String? activeType = activeConnection?['type'] as String?;
@ -373,10 +396,14 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> {
valueListenable: service.sondeId,
builder: (context, sondeId, child) {
final newSondeId = sondeId ?? '';
if (_sondeIdController.text != newSondeId) {
_sondeIdController.text = newSondeId;
widget.data.sondeId = newSondeId;
}
// START CHANGE: Safely update the controller after the build frame is complete to prevent crash
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _sondeIdController.text != newSondeId) {
_sondeIdController.text = newSondeId;
widget.data.sondeId = newSondeId;
}
});
// END CHANGE
return TextFormField(
controller: _sondeIdController,
decoration: const InputDecoration(
@ -401,7 +428,6 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> {
),
const Divider(height: 32),
// REPAIRED: Replaced GridView with a Column of modern list items.
Column(
children: _parameters.map((param) {
return _buildParameterListItem(
@ -424,7 +450,6 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> {
);
}
// ADDED: New helper for modern list item view
Widget _buildParameterListItem({
required IconData icon,
required String label,

View File

@ -1,3 +1,5 @@
// lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart
import 'dart:io';
import 'package:flutter/material.dart';

View File

@ -35,20 +35,16 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
late RiverInSituSamplingData _data;
// FIX: _samplingService is now retrieved via Provider in _submitForm
// final RiverInSituSamplingService _samplingService = RiverInSituSamplingService();
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;
// FIX: Use late initialization to retrieve service instances in the build method.
late RiverInSituSamplingService _samplingService;
// FIX: Removed the late variable, it will be fetched from context directly.
// late RiverInSituSamplingService _samplingService;
@override
void initState() {
@ -57,16 +53,12 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()),
samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()),
);
// Initialize services that require context/Provider access later
WidgetsBinding.instance.addPostFrameCallback((_) {
_samplingService = Provider.of<RiverInSituSamplingService>(context, listen: false);
});
// FIX: Removed the problematic post-frame callback initialization.
}
@override
void dispose() {
_pageController.dispose();
// FIX: Removed _samplingService.dispose() call as it is now retrieved from Provider/context.
super.dispose();
}
@ -88,22 +80,20 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
}
}
// --- REPLACED: _submitForm() method with the simplified workflow ---
Future<void> _submitForm() async {
setState(() => _isLoading = true);
// FIX: Get the sampling service directly from the context here.
final samplingService = Provider.of<RiverInSituSamplingService>(context, listen: false);
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings;
// The service now handles all API/FTP attempts and internal logging.
final result = await _samplingService.submitData(_data, appSettings);
final result = await samplingService.submitData(_data, appSettings);
// FIX: Update local data model status with the granular API result
_data.submissionStatus = result['status'];
_data.submissionMessage = result['message'];
_data.reportId = result['reportId']?.toString();
// NOTE: The separate local file saving is now redundant here, but kept for historical context/backup.
final activeApiConfig = await _serverConfigService.getActiveApiConfig();
final serverName = activeApiConfig?['config_name'] as String? ?? 'Default';
await _localStorageService.saveRiverInSituSamplingData(_data, serverName: serverName);
@ -113,11 +103,9 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
setState(() => _isLoading = false);
final message = _data.submissionMessage ?? 'An unknown error occurred.';
// FIX: Use granular status (api_status or image_upload_status is not directly available here,
// rely on the high-level status 'status' returned by the service for UI feedback).
final highLevelStatus = result['status'] as String? ?? 'L1';
final bool isSuccess = highLevelStatus.startsWith('S') || highLevelStatus.startsWith('L4'); // L4 means FTP queue success, which is good.
final bool isSuccess = highLevelStatus.startsWith('S') || highLevelStatus.startsWith('L4');
final color = isSuccess ? Colors.green : Colors.red;
@ -130,47 +118,34 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
@override
Widget build(BuildContext context) {
// Note: Since _samplingService is initialized using addPostFrameCallback,
// we must ensure it's provided if a child widget relies on Provider.of.
// However, since the Provider.value below directly uses _samplingService,
// and the constructor was updated to take no args, we'll keep the
// Provider.value here.
// FIX: Revert _samplingService initialization to the final definition
// used in other modules where it is retrieved by the main builder function.
// Given the complexity of the DI in main.dart, the service should be retrieved
// inside the build method or initState if it needs to be used in methods.
// Since we fixed the constructor issue in the previous files, we rely on
// the provider instance created in main.dart.
return Provider.value(
value: _samplingService,
child: Scaffold(
appBar: AppBar(
title: Text('In-Situ Sampling (${_currentPage + 1}/5)'),
leading: _currentPage > 0
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _previousPage,
)
: null,
),
body: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
onPageChanged: (page) {
setState(() {
_currentPage = page;
});
},
children: [
RiverInSituStep1SamplingInfo(data: _data, onNext: _nextPage),
RiverInSituStep2SiteInfo(data: _data, onNext: _nextPage),
RiverInSituStep3DataCapture(data: _data, onNext: _nextPage),
RiverInSituStep4AdditionalInfo(data: _data, onNext: _nextPage),
RiverInSituStep5Summary(data: _data, onSubmit: _submitForm, isLoading: _isLoading),
],
),
// FIX: The Provider.value wrapper is removed. The service is already
// available in the widget tree from a higher-level provider, and child
// widgets can access it using Provider.of or context.read.
return Scaffold(
appBar: AppBar(
title: Text('In-Situ Sampling (${_currentPage + 1}/5)'),
leading: _currentPage > 0
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _previousPage,
)
: null,
),
body: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
onPageChanged: (page) {
setState(() {
_currentPage = page;
});
},
children: [
RiverInSituStep1SamplingInfo(data: _data, onNext: _nextPage),
RiverInSituStep2SiteInfo(data: _data, onNext: _nextPage),
RiverInSituStep3DataCapture(data: _data, onNext: _nextPage),
RiverInSituStep4AdditionalInfo(data: _data, onNext: _nextPage),
RiverInSituStep5Summary(data: _data, onSubmit: _submitForm, isLoading: _isLoading),
],
),
);
}

View File

@ -28,7 +28,8 @@ class RiverInSituStep3DataCapture extends StatefulWidget {
State<RiverInSituStep3DataCapture> createState() => _RiverInSituStep3DataCaptureState();
}
class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCapture> {
// MODIFIED: Added 'with WidgetsBindingObserver' to listen for app lifecycle events.
class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCapture> with WidgetsBindingObserver {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _isAutoReading = false;
@ -48,7 +49,7 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
final _tempController = TextEditingController();
final _tdsController = TextEditingController();
final _turbidityController = TextEditingController();
final _tssController = TextEditingController();
final _ammoniaController = TextEditingController(); // MODIFIED: Replaced tss with ammonia
final _batteryController = TextEditingController();
// ADDED: Flowrate controllers and state
@ -64,6 +65,8 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
super.initState();
_initializeControllers();
_initializeFlowrateControllers();
// ADDED: Register the observer to listen for app lifecycle changes.
WidgetsBinding.instance.addObserver(this);
}
@override
@ -71,9 +74,24 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
_dataSubscription?.cancel();
_disposeControllers();
_disposeFlowrateControllers();
// ADDED: Remove the observer to prevent memory leaks.
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
// ADDED: This method is called whenever the app lifecycle state changes.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// When the app resumes from the background (e.g., after the user grants a permission),
// call setState to force a UI rebuild. This ensures the connection buttons
// and status are updated correctly without needing to navigate away and back.
if (mounted) {
setState(() {});
}
}
}
void _initializeControllers() {
widget.data.dataCaptureDate = widget.data.samplingDate;
widget.data.dataCaptureTime = widget.data.samplingTime;
@ -90,7 +108,7 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
_tempController.text = widget.data.temperature?.toString() ?? '-999.0';
_tdsController.text = widget.data.tds?.toString() ?? '-999.0';
_turbidityController.text = widget.data.turbidity?.toString() ?? '-999.0';
_tssController.text = widget.data.tss?.toString() ?? '-999.0';
_ammoniaController.text = widget.data.ammonia?.toString() ?? '-999.0'; // MODIFIED: Replaced tss with ammonia
_batteryController.text = widget.data.batteryVoltage?.toString() ?? '-999.0';
if (_parameters.isEmpty) {
@ -103,7 +121,7 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
{'icon': Icons.thermostat, 'label': 'Temperature', 'unit': '°C', 'controller': _tempController},
{'icon': Icons.grain, 'label': 'TDS', 'unit': 'mg/L', 'controller': _tdsController},
{'icon': Icons.opacity, 'label': 'Turbidity', 'unit': 'NTU', 'controller': _turbidityController},
{'icon': Icons.filter_alt_outlined, 'label': 'TSS', 'unit': 'mg/L', 'controller': _tssController},
{'icon': Icons.science, 'label': 'Ammonia', 'unit': 'mg/L', 'controller': _ammoniaController}, // MODIFIED: Replaced TSS with Ammonia
{'icon': Icons.battery_charging_full, 'label': 'Battery', 'unit': 'V', 'controller': _batteryController},
]);
}
@ -121,7 +139,7 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
_tempController.dispose();
_tdsController.dispose();
_turbidityController.dispose();
_tssController.dispose();
_ammoniaController.dispose(); // MODIFIED: Replaced tss with ammonia
_batteryController.dispose();
}
@ -310,9 +328,10 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
_ecController.text = (readings['Conductivity: us/cm'] ?? defaultValue).toStringAsFixed(5);
_salinityController.text = (readings['Conductivity: Salinity'] ?? defaultValue).toStringAsFixed(5);
_tdsController.text = (readings['Conductivity:TDS mg/L'] ?? defaultValue).toStringAsFixed(5);
_tssController.text = (readings['Turbidity: TSS'] ?? defaultValue).toStringAsFixed(5);
_turbidityController.text = (readings['Turbidity: FNU'] ?? defaultValue).toStringAsFixed(5);
_batteryController.text = (readings['Sonde: Battery Voltage'] ?? defaultValue).toStringAsFixed(5);
// FIX: Add this line to read and display the Ammonia value from the sensor readings.
_ammoniaController.text = (readings['Ammonium (NH4+) mg/L'] ?? defaultValue).toStringAsFixed(5);
});
}
@ -343,7 +362,7 @@ class _RiverInSituStep3DataCaptureState extends State<RiverInSituStep3DataCaptur
widget.data.oxygenSaturation = double.tryParse(_oxySatController.text) ?? defaultValue;
widget.data.tds = double.tryParse(_tdsController.text) ?? defaultValue;
widget.data.turbidity = double.tryParse(_turbidityController.text) ?? defaultValue;
widget.data.tss = double.tryParse(_tssController.text) ?? defaultValue;
widget.data.ammonia = double.tryParse(_ammoniaController.text) ?? defaultValue; // MODIFIED: Replaced tss with ammonia
widget.data.batteryVoltage = double.tryParse(_batteryController.text) ?? defaultValue;
// Save flowrate data

View File

@ -100,10 +100,9 @@ class RiverInSituStep5Summary extends StatelessWidget {
_buildParameterListItem(context, icon: Icons.thermostat, label: "Temperature", unit: "°C", value: data.temperature?.toStringAsFixed(2)),
_buildParameterListItem(context, icon: Icons.grain, label: "TDS", unit: "mg/L", value: data.tds?.toStringAsFixed(2)),
_buildParameterListItem(context, icon: Icons.opacity, label: "Turbidity", unit: "NTU", value: data.turbidity?.toStringAsFixed(2)),
_buildParameterListItem(context, icon: Icons.filter_alt_outlined, label: "TSS", unit: "mg/L", value: data.tss?.toStringAsFixed(2)),
_buildParameterListItem(context, icon: Icons.science, label: "Ammonia", unit: "mg/L", value: data.ammonia?.toStringAsFixed(2)), // MODIFIED: Replaced TSS with Ammonia
_buildParameterListItem(context, icon: Icons.battery_charging_full, label: "Battery", unit: "V", value: data.batteryVoltage?.toStringAsFixed(2)),
// ADDED: Display for Flowrate
const Divider(height: 20),
_buildFlowrateSummary(context),
],
@ -230,23 +229,37 @@ class RiverInSituStep5Summary extends StatelessWidget {
);
}
// ADDED: Widget to build the flowrate summary section
// FIX: Reorganized the widget for a cleaner and more beautiful presentation.
Widget _buildFlowrateSummary(BuildContext context) {
final method = data.flowrateMethod ?? 'N/A';
final value = data.flowrateValue != null ? '${data.flowrateValue!.toStringAsFixed(4)} m/s' : 'NA';
List<Widget> children = [
_buildDetailRow("Flowrate Method:", method),
];
if (method == 'Surface Drifter') {
children.add(
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 4.0),
child: Column(
children: [
_buildDetailRow("Height:", data.flowrateSurfaceDrifterHeight != null ? "${data.flowrateSurfaceDrifterHeight} m" : "N/A"),
_buildDetailRow("Distance:", data.flowrateSurfaceDrifterDistance != null ? "${data.flowrateSurfaceDrifterDistance} m" : "N/A"),
_buildDetailRow("Time First:", data.flowrateSurfaceDrifterTimeFirst ?? "N/A"),
_buildDetailRow("Time Last:", data.flowrateSurfaceDrifterTimeLast ?? "N/A"),
],
),
)
);
}
children.add(
_buildDetailRow("Flowrate Value:", data.flowrateValue != null ? '${data.flowrateValue!.toStringAsFixed(4)} m/s' : 'NA')
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailRow("Flowrate Method:", method),
if (method == 'Surface Drifter') ...[
_buildDetailRow(" Height:", data.flowrateSurfaceDrifterHeight?.toString()),
_buildDetailRow(" Distance:", data.flowrateSurfaceDrifterDistance?.toString()),
_buildDetailRow(" Time First:", data.flowrateSurfaceDrifterTimeFirst),
_buildDetailRow(" Time Last:", data.flowrateSurfaceDrifterTimeLast),
],
_buildDetailRow("Flowrate Value:", value),
],
children: children,
);
}
}

View File

@ -1,8 +1,30 @@
// lib/screens/settings.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'package:environment_monitoring_app/auth_provider.dart';
import 'package:environment_monitoring_app/services/settings_service.dart';
// START CHANGE: Import the new UserPreferencesService to manage submission settings
import 'package:environment_monitoring_app/services/user_preferences_service.dart';
// END CHANGE
// START CHANGE: A helper class to manage the state of each module's settings in the UI
class _ModuleSettings {
bool isApiEnabled;
bool isFtpEnabled;
List<Map<String, dynamic>> apiConfigs;
List<Map<String, dynamic>> ftpConfigs;
_ModuleSettings({
this.isApiEnabled = true,
this.isFtpEnabled = true,
required this.apiConfigs,
required this.ftpConfigs,
});
}
// END CHANGE
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@ -15,6 +37,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
final SettingsService _settingsService = SettingsService();
bool _isSyncingData = false;
// START CHANGE: New state variables for managing submission preferences UI
final UserPreferencesService _preferencesService = UserPreferencesService();
bool _isLoadingSettings = true;
bool _isSaving = false;
// This map holds the live state of the settings UI for each module
final Map<String, _ModuleSettings> _moduleSettings = {};
// This list defines which modules will appear in the new settings section
final List<Map<String, String>> _configurableModules = [
{'key': 'marine_tarball', 'name': 'Marine Tarball'},
{'key': 'marine_in_situ', 'name': 'Marine In-Situ'},
{'key': 'river_in_situ', 'name': 'River In-Situ'},
{'key': 'air_installation', 'name': 'Air Installation'},
{'key': 'air_collection', 'name': 'Air Collection'},
];
// END CHANGE
final TextEditingController _tarballSearchController = TextEditingController();
String _tarballSearchQuery = '';
final TextEditingController _manualSearchController = TextEditingController();
@ -31,6 +71,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
void initState() {
super.initState();
_loadAllModuleSettings(); // Load the new submission preferences on init
_tarballSearchController.addListener(_onTarballSearchChanged);
_manualSearchController.addListener(_onManualSearchChanged);
_riverManualSearchController.addListener(_onRiverManualSearchChanged);
@ -86,6 +127,55 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
}
// START CHANGE: New methods for loading and saving the submission preferences
Future<void> _loadAllModuleSettings() async {
setState(() => _isLoadingSettings = true);
for (var module in _configurableModules) {
final moduleKey = module['key']!;
final prefs = await _preferencesService.getModulePreference(moduleKey);
final apiConfigsWithPrefs = await _preferencesService.getAllApiConfigsWithModulePreferences(moduleKey);
final ftpConfigsWithPrefs = await _preferencesService.getAllFtpConfigsWithModulePreferences(moduleKey);
_moduleSettings[moduleKey] = _ModuleSettings(
isApiEnabled: prefs['is_api_enabled'],
isFtpEnabled: prefs['is_ftp_enabled'],
apiConfigs: apiConfigsWithPrefs,
ftpConfigs: ftpConfigsWithPrefs,
);
}
if (mounted) {
setState(() => _isLoadingSettings = false);
}
}
Future<void> _saveAllModuleSettings() async {
setState(() => _isSaving = true);
try {
for (var module in _configurableModules) {
final moduleKey = module['key']!;
final settings = _moduleSettings[moduleKey]!;
await _preferencesService.saveModulePreference(
moduleName: moduleKey,
isApiEnabled: settings.isApiEnabled,
isFtpEnabled: settings.isFtpEnabled,
);
await _preferencesService.saveApiLinksForModule(moduleKey, settings.apiConfigs);
await _preferencesService.saveFtpLinksForModule(moduleKey, settings.ftpConfigs);
}
_showSnackBar('Submission preferences saved successfully.', isError: false);
} catch (e) {
_showSnackBar('Failed to save settings: $e', isError: true);
} finally {
if (mounted) {
setState(() => _isSaving = false);
}
}
}
// END CHANGE
Future<void> _manualDataSync() async {
if (_isSyncingData) return;
setState(() => _isSyncingData = true);
@ -94,6 +184,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
try {
await auth.syncAllData(forceRefresh: true);
// MODIFIED: After syncing, also reload module settings to reflect any new server configurations.
await _loadAllModuleSettings();
if (mounted) {
_showSnackBar('Data synced successfully.', isError: false);
@ -194,6 +286,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
return Scaffold(
appBar: AppBar(
title: const Text("Settings"),
// START CHANGE: Add a save button to the AppBar
actions: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: _isSaving
? const Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white)))
: IconButton(
icon: const Icon(Icons.save),
onPressed: _isLoadingSettings ? null : _saveAllModuleSettings,
tooltip: 'Save Submission Preferences',
),
)
],
// END CHANGE
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
@ -224,6 +330,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 32),
// START CHANGE: Insert the new Submission Preferences section
_buildSectionHeader(context, "Submission Preferences"),
_isLoadingSettings
? const Center(child: Padding(padding: EdgeInsets.all(16.0), child: CircularProgressIndicator()))
: Card(
margin: EdgeInsets.zero,
child: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _configurableModules.length,
itemBuilder: (context, index) {
final module = _configurableModules[index];
final settings = _moduleSettings[module['key']];
if (settings == null) return const SizedBox.shrink();
return _buildModulePreferenceTile(module['name']!, module['key']!, settings);
},
),
),
const SizedBox(height: 32),
// END CHANGE
Text("Telegram Alert Settings", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Card(
@ -480,6 +607,67 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
// START CHANGE: New helper widgets for the preferences UI
Widget _buildModulePreferenceTile(String title, String moduleKey, _ModuleSettings settings) {
return ExpansionTile(
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
children: [
SwitchListTile(
title: const Text('Enable API Submission'),
value: settings.isApiEnabled,
onChanged: (value) => setState(() => settings.isApiEnabled = value),
),
if (settings.isApiEnabled)
_buildDestinationList('API Destinations', settings.apiConfigs, 'api_config_id'),
const Divider(),
SwitchListTile(
title: const Text('Enable FTP Submission'),
value: settings.isFtpEnabled,
onChanged: (value) => setState(() => settings.isFtpEnabled = value),
),
if (settings.isFtpEnabled)
_buildDestinationList('FTP Destinations', settings.ftpConfigs, 'ftp_config_id'),
],
);
}
Widget _buildDestinationList(String title, List<Map<String, dynamic>> configs, String idKey) {
if (configs.isEmpty) {
return const ListTile(
dense: true,
title: Center(child: Text('No destinations configured. Sync to fetch.')),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, bottom: 8.0),
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
),
...configs.map((config) {
return CheckboxListTile(
title: Text(config['config_name'] ?? 'Unnamed'),
subtitle: Text(config['api_url'] ?? config['ftp_host'] ?? 'No URL/Host'),
value: config['is_enabled'] ?? false,
onChanged: (bool? value) {
setState(() {
config['is_enabled'] = value ?? false;
});
},
dense: true,
);
}).toList(),
],
),
);
}
// END CHANGE
Widget _buildSectionHeader(BuildContext context, String title) {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),

View File

@ -1,4 +1,5 @@
// lib/serial/serial_manager.dart
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart'; // For debugPrint
@ -146,11 +147,12 @@ class SerialManager {
if (connectionState.value != SerialConnectionState.disconnected) {
debugPrint("SerialManager: Disconnecting...");
stopAutoReading(); // Stop any active auto-reading timer and timeout
// FIX: Update ValueNotifiers before closing the stream and port.
connectionState.value = SerialConnectionState.disconnected;
connectedDeviceName.value = null;
sondeId.value = null; // Clear Sonde ID on disconnect
await _port?.close(); // Now correctly awaiting the close operation
_port = null; // Clear port reference
connectedDeviceName.value = null; // Clear device name
sondeId.value = null; // Clear Sonde ID on disconnect
connectionState.value = SerialConnectionState.disconnected; // Update connection state
_responseBuffer.clear(); // Clear any buffered data
_isReading = false; // Reset reading flag
_communicationLevel = 0; // Reset communication level
@ -534,8 +536,9 @@ class SerialManager {
_isDisposed = true; // Set the flag immediately
disconnect(); // Ensure full disconnection and cleanup
_dataStreamController.close(); // Close the data stream controller
connectionState.dispose(); // Dispose the ValueNotifier
connectedDeviceName.dispose(); // Dispose the ValueNotifier
sondeId.dispose(); // Dispose the Sonde ID ValueNotifier
// FIX: Dispose of all ValueNotifiers to prevent "used after dispose" errors
connectionState.dispose();
connectedDeviceName.dispose();
sondeId.dispose();
}
}

View File

@ -1,13 +0,0 @@
// lib/services/air_api_service.dart
import 'package:environment_monitoring_app/services/base_api_service.dart';
class AirApiService {
final BaseApiService _baseService = BaseApiService();
// You can add methods for air-related API calls here in the future
// For example:
// Future<Map<String, dynamic>> getAirQualityData() {
// return _baseService.get('air/dashboard');
// }
}

View File

@ -1,6 +1,5 @@
// lib/services/air_sampling_service.dart
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:image_picker/image_picker.dart';
@ -15,26 +14,33 @@ import '../models/air_collection_data.dart';
import 'api_service.dart';
import 'local_storage_service.dart';
import 'telegram_service.dart';
// --- ADDED: Import for the service that manages active server configurations ---
import 'server_config_service.dart';
import 'zipping_service.dart';
// START CHANGE: Import the new common submission services
import 'submission_api_service.dart';
import 'submission_ftp_service.dart';
// END CHANGE
/// A dedicated service for handling all business logic for the Air Manual Sampling feature.
class AirSamplingService {
final ApiService _apiService;
// START CHANGE: Instantiate new services and remove ApiService
final DatabaseHelper _dbHelper;
final TelegramService _telegramService;
final SubmissionApiService _submissionApiService = SubmissionApiService();
final SubmissionFtpService _submissionFtpService = SubmissionFtpService();
final ServerConfigService _serverConfigService = ServerConfigService();
final ZippingService _zippingService = ZippingService();
final LocalStorageService _localStorageService = LocalStorageService();
// END CHANGE
// MODIFIED: Constructor no longer needs ApiService
AirSamplingService(this._dbHelper, this._telegramService);
// REVISED: Constructor now takes dependencies as parameters
AirSamplingService(this._apiService, this._dbHelper, this._telegramService);
// Helper method to create a map suitable for LocalStorageService (retains File objects)
// This helper method remains unchanged as it's for local saving logic
Map<String, dynamic> _toMapForLocalSave(dynamic data) {
if (data is AirInstallationData) {
final map = data.toMap(); // Get map with paths for DB logging
// Overwrite paths with live File objects for local saving process
final map = data.toMap();
map['imageFront'] = data.imageFront;
map['imageBack'] = data.imageBack;
map['imageLeft'] = data.imageLeft;
@ -49,8 +55,7 @@ class AirSamplingService {
}
return map;
} else if (data is AirCollectionData) {
final map = data.toMap(); // Get map with paths for DB logging
// Overwrite paths with live File objects for local saving process
final map = data.toMap();
map['imageFront'] = data.imageFront;
map['imageBack'] = data.imageBack;
map['imageLeft'] = data.imageLeft;
@ -66,9 +71,7 @@ class AirSamplingService {
return {};
}
/// Picks an image from the specified source, adds a timestamp watermark,
/// and saves it to a temporary directory with a standardized name.
// This image processing utility remains unchanged
Future<File?> pickAndProcessImage(
ImageSource source, {
required String stationCode,
@ -85,10 +88,9 @@ class AirSamplingService {
img.Image? originalImage = img.decodeImage(bytes);
if (originalImage == null) return null;
// MODIFIED: Enforce landscape orientation for required photos
if (isRequired && originalImage.height > originalImage.width) {
debugPrint("Image orientation check failed: Image must be in landscape mode.");
return null; // Return null to indicate failure
return null;
}
final String watermarkTimestamp =
@ -116,11 +118,205 @@ class AirSamplingService {
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
}
// MODIFIED: Method now requires the appSettings list to pass to TelegramService.
// --- REFACTORED submitInstallation method ---
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data, List<Map<String, dynamic>>? appSettings) async {
const String moduleName = 'air_installation';
final activeConfig = await _serverConfigService.getActiveApiConfig();
final serverName = activeConfig?['config_name'] as String? ?? 'Default';
// --- 1. API SUBMISSION (DATA) ---
debugPrint("Step 1: Delegating Installation Data submission to SubmissionApiService...");
final dataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'air/manual/installation',
body: data.toJsonForApi(),
);
if (dataResult['success'] != true) {
await _logAndSave(data: data, status: 'L1', message: dataResult['message']!, apiResults: [dataResult], ftpStatuses: [], serverName: serverName, type: 'Installation');
return {'status': 'L1', 'message': dataResult['message']};
}
final recordId = dataResult['data']?['air_man_id']?.toString();
if (recordId == null) {
await _logAndSave(data: data, status: 'L1', message: 'API Error: Missing record ID.', apiResults: [dataResult], ftpStatuses: [], serverName: serverName, type: 'Installation');
return {'status': 'L1', 'message': 'API Error: Missing record ID.'};
}
data.airManId = int.tryParse(recordId);
// --- 2. API SUBMISSION (IMAGES) ---
debugPrint("Step 2: Delegating Installation Image submission to SubmissionApiService...");
final imageFiles = data.getImagesForUpload();
final imageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName,
endpoint: 'air/manual/installation-images',
fields: {'air_man_id': recordId},
files: imageFiles,
);
final bool apiImagesSuccess = imageResult['success'] == true;
// --- 3. FTP SUBMISSION ---
debugPrint("Step 3: Delegating Installation FTP submission to SubmissionFtpService...");
final stationCode = data.stationID ?? 'UNKNOWN';
final samplingDateTime = "${data.installationDate}_${data.installationTime}".replaceAll(':', '-').replaceAll(' ', '_');
final baseFileName = "${stationCode}_INSTALLATION_${samplingDateTime}";
// Zip and submit data
final dataZip = await _zippingService.createDataZip(jsonDataMap: {'db.json': jsonEncode(data.toDbJson())}, baseFileName: baseFileName);
Map<String, dynamic> ftpDataResult = {'statuses': []};
if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: dataZip, remotePath: '/air/data/${path.basename(dataZip.path)}');
}
// Zip and submit images
final imageZip = await _zippingService.createImageZip(imageFiles: imageFiles.values.toList(), baseFileName: baseFileName);
Map<String, dynamic> ftpImageResult = {'statuses': []};
if (imageZip != null) {
ftpImageResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: imageZip, remotePath: '/air/images/${path.basename(imageZip.path)}');
}
final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true);
// --- 4. DETERMINE FINAL STATUS, LOG, AND ALERT ---
String finalStatus;
String finalMessage;
if (apiImagesSuccess) {
finalStatus = ftpSuccess ? 'S4' : 'S2';
finalMessage = ftpSuccess ? 'Data and files submitted successfully.' : 'Data submitted to API. FTP upload failed or was queued.';
} else {
finalStatus = ftpSuccess ? 'L2_FTP_ONLY' : 'L2_PENDING_IMAGES';
finalMessage = ftpSuccess ? 'API image upload failed, but files were sent via FTP.' : 'Data submitted, but API image and FTP uploads failed.';
}
await _logAndSave(data: data, status: finalStatus, message: finalMessage, apiResults: [dataResult, imageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Installation');
_handleInstallationSuccessAlert(data, appSettings, isDataOnly: !apiImagesSuccess);
return {'status': finalStatus, 'message': finalMessage};
}
// --- REFACTORED submitCollection method ---
Future<Map<String, dynamic>> submitCollection(AirCollectionData data, AirInstallationData installationData, List<Map<String, dynamic>>? appSettings) async {
const String moduleName = 'air_collection';
final activeConfig = await _serverConfigService.getActiveApiConfig();
final serverName = activeConfig?['config_name'] as String? ?? 'Default';
// --- 1. API SUBMISSION (DATA) ---
debugPrint("Step 1: Delegating Collection Data submission to SubmissionApiService...");
data.airManId = installationData.airManId; // Ensure collection is linked to installation
final dataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'air/manual/collection',
body: data.toJson(),
);
if (dataResult['success'] != true) {
await _logAndSave(data: data, installationData: installationData, status: 'L3', message: dataResult['message']!, apiResults: [dataResult], ftpStatuses: [], serverName: serverName, type: 'Collection');
return {'status': 'L3', 'message': dataResult['message']};
}
// --- 2. API SUBMISSION (IMAGES) ---
debugPrint("Step 2: Delegating Collection Image submission to SubmissionApiService...");
final imageFiles = data.getImagesForUpload();
final imageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName,
endpoint: 'air/manual/collection-images',
fields: {'air_man_id': data.airManId.toString()},
files: imageFiles,
);
final bool apiImagesSuccess = imageResult['success'] == true;
// --- 3. FTP SUBMISSION ---
debugPrint("Step 3: Delegating Collection FTP submission to SubmissionFtpService...");
final stationCode = installationData.stationID ?? 'UNKNOWN';
final samplingDateTime = "${data.collectionDate}_${data.collectionTime}".replaceAll(':', '-').replaceAll(' ', '_');
final baseFileName = "${stationCode}_COLLECTION_${samplingDateTime}";
// Zip and submit data (includes both installation and collection data)
final combinedJson = jsonEncode({"installation": installationData.toDbJson(), "collection": data.toMap()});
final dataZip = await _zippingService.createDataZip(jsonDataMap: {'db.json': combinedJson}, baseFileName: baseFileName);
Map<String, dynamic> ftpDataResult = {'statuses': []};
if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: dataZip, remotePath: '/air/data/${path.basename(dataZip.path)}');
}
// Zip and submit images
final imageZip = await _zippingService.createImageZip(imageFiles: imageFiles.values.toList(), baseFileName: baseFileName);
Map<String, dynamic> ftpImageResult = {'statuses': []};
if (imageZip != null) {
ftpImageResult = await _submissionFtpService.submit(moduleName: moduleName, fileToUpload: imageZip, remotePath: '/air/images/${path.basename(imageZip.path)}');
}
final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true);
// --- 4. DETERMINE FINAL STATUS, LOG, AND ALERT ---
String finalStatus;
String finalMessage;
if (apiImagesSuccess) {
finalStatus = ftpSuccess ? 'S4_API_FTP' : 'S3';
finalMessage = ftpSuccess ? 'Data and files submitted successfully.' : 'Data submitted to API. FTP upload failed or was queued.';
} else {
finalStatus = ftpSuccess ? 'L4_FTP_ONLY' : 'L4_PENDING_IMAGES';
finalMessage = ftpSuccess ? 'API image upload failed, but files were sent via FTP.' : 'Data submitted, but API image and FTP uploads failed.';
}
await _logAndSave(data: data, installationData: installationData, status: finalStatus, message: finalMessage, apiResults: [dataResult, imageResult], ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']], serverName: serverName, type: 'Collection');
_handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: !apiImagesSuccess);
return {'status': finalStatus, 'message': finalMessage};
}
/// Centralized method for logging and saving data locally.
Future<void> _logAndSave({
required dynamic data,
AirInstallationData? installationData,
required String status,
required String message,
required List<Map<String, dynamic>> apiResults,
required List<Map<String, dynamic>> ftpStatuses,
required String serverName,
required String type,
}) async {
String refID;
Map<String, dynamic> formData;
List<String> imagePaths;
if (type == 'Installation') {
final installation = data as AirInstallationData;
installation.status = status;
refID = installation.refID!;
formData = installation.toMap();
imagePaths = _getInstallationImagePaths(installation);
await _localStorageService.saveAirSamplingRecord(_toMapForLocalSave(installation), refID, serverName: serverName);
} else {
final collection = data as AirCollectionData;
collection.status = status;
refID = collection.installationRefID!;
formData = collection.toMap();
imagePaths = _getCollectionImagePaths(collection);
await _localStorageService.saveAirSamplingRecord(_toMapForLocalSave(installationData!..collectionData = collection), refID, serverName: serverName);
}
final logData = {
'submission_id': refID,
'module': 'air',
'type': type,
'status': status,
'message': message,
'report_id': (data.airManId ?? installationData?.airManId)?.toString(),
'created_at': DateTime.now(),
'form_data': jsonEncode(formData),
'image_data': jsonEncode(imagePaths),
'server_name': serverName,
'api_status': jsonEncode(apiResults),
'ftp_status': jsonEncode(ftpStatuses),
};
await _dbHelper.saveSubmissionLog(logData);
}
// Helper and Alert methods remain unchanged
Future<void> _handleInstallationSuccessAlert(AirInstallationData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try {
final message = data.generateInstallationTelegramAlert(isDataOnly: isDataOnly);
// Pass the appSettings list to the telegram service methods
final bool wasSent = await _telegramService.sendAlertImmediately('air_manual', message, appSettings);
if (!wasSent) {
await _telegramService.queueMessage('air_manual', message, appSettings);
@ -130,11 +326,9 @@ class AirSamplingService {
}
}
// MODIFIED: Method now requires the appSettings list to pass to TelegramService.
Future<void> _handleCollectionSuccessAlert(AirCollectionData data, AirInstallationData installationData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try {
final message = data.generateCollectionTelegramAlert(installationData, isDataOnly: isDataOnly);
// Pass the appSettings list to the telegram service methods
final bool wasSent = await _telegramService.sendAlertImmediately('air_manual', message, appSettings);
if (!wasSent) {
await _telegramService.queueMessage('air_manual', message, appSettings);
@ -144,7 +338,6 @@ class AirSamplingService {
}
}
// --- ADDED HELPER METHODS TO GET IMAGE PATHS FROM MODELS ---
List<String> _getInstallationImagePaths(AirInstallationData data) {
final List<File?> files = [
data.imageFront, data.imageBack, data.imageLeft, data.imageRight,
@ -162,489 +355,17 @@ class AirSamplingService {
return files.where((f) => f != null).map((f) => f!.path).toList();
}
/// Orchestrates a two-step submission process for air installation samples.
// MODIFIED: Method now requires the appSettings list to pass down the call stack.
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data, List<Map<String, dynamic>>? appSettings) async {
// --- MODIFIED: Get the active server name to use for local storage ---
final activeConfig = await _serverConfigService.getActiveApiConfig();
final serverName = activeConfig?['config_name'] as String? ?? 'Default';
final localStorageService = LocalStorageService(); // Instance for file system save
// If the record's text data is already on the server, skip directly to image upload.
if (data.status == 'L2_PENDING_IMAGES' && data.airManId != null) {
debugPrint("Retrying image upload for existing record ID: ${data.airManId}");
final result = await _uploadInstallationImagesAndUpdate(data, appSettings, serverName: serverName);
// LOG DEBUG START
final logData = {
'submission_id': data.refID!,
'module': 'air',
'type': 'Installation',
'status': result['status'],
'message': result['message'],
'report_id': data.airManId.toString(),
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toMap()),
'image_data': jsonEncode(_getInstallationImagePaths(data)),
'server_name': serverName,
'api_status': jsonEncode([{"server_name": serverName, "status": "PENDING", "message": "Resubmitting images."}]),
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "FTP not used for images."}]),
};
debugPrint("DB LOGGING (Installation Retry): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
// LOG DEBUG END
// --- ADDED: Log the final result to the central database ---
await _dbHelper.saveSubmissionLog(logData);
return result;
}
// --- STEP 1: SUBMIT TEXT DATA ---
debugPrint("Step 1: Submitting installation text data...");
final textDataResult = await _apiService.air.submitInstallation(data);
// --- CRITICAL FIX: Save to local file system immediately regardless of API success ---
data.status = 'L1'; // Temporary set status to Local Only
// Use the special helper method to pass live File objects for copying
final localSaveMap = _toMapForLocalSave(data);
final localSaveResult = await localStorageService.saveAirSamplingRecord(localSaveMap, data.refID!, serverName: serverName);
if (localSaveResult == null) {
debugPrint("CRITICAL ERROR: Failed to save Air Installation record to local file system.");
}
// --- END CRITICAL FIX ---
if (textDataResult['success'] != true) {
debugPrint("Failed to submit text data. Reason: ${textDataResult['message']}");
final result = {'status': 'L1', 'message': 'No connection or server error. Installation data saved locally.'};
// LOG DEBUG START
final logData = {
'submission_id': data.refID!,
'module': 'air',
'type': 'Installation',
'status': result['status'],
'message': result['message'],
'report_id': data.airManId.toString(),
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toMap()),
'image_data': jsonEncode(_getInstallationImagePaths(data)),
'server_name': serverName,
'api_status': jsonEncode([{"server_name": serverName, "status": "FAILED", "message": "API submission failed."}]),
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]),
};
debugPrint("DB LOGGING (Installation L1): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
// LOG DEBUG END
// --- ADDED: Log the failed submission to the central database ---
await _dbHelper.saveSubmissionLog(logData);
return result;
}
// --- NECESSARY FIX: Safely parse the record ID from the server response ---
final dynamic recordIdFromServer = textDataResult['data']?['air_man_id'];
if (recordIdFromServer == null) {
debugPrint("Text data submitted, but did not receive a record ID.");
final result = {'status': 'L1', 'message': 'Data submitted, but server response was invalid.'};
// LOG DEBUG START
final logData = {
'submission_id': data.refID!,
'module': 'air',
'type': 'Installation',
'status': result['status'],
'message': result['message'],
'report_id': data.airManId.toString(),
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toMap()),
'image_data': jsonEncode(_getInstallationImagePaths(data)),
'server_name': serverName,
'api_status': jsonEncode([{"server_name": serverName, "status": "FAILED", "message": "Invalid response from server."}]),
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]),
};
debugPrint("DB LOGGING (Installation L1/Invalid ID): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
// LOG DEBUG END
// --- ADDED: Log the failed submission to the central database ---
await _dbHelper.saveSubmissionLog(logData);
return result;
}
final int? parsedRecordId = int.tryParse(recordIdFromServer.toString());
if (parsedRecordId == null) {
debugPrint("Could not parse the received record ID: $recordIdFromServer");
final result = {'status': 'L1', 'message': 'Data submitted, but server response was invalid.'};
// LOG DEBUG START
final logData = {
'submission_id': data.refID!,
'module': 'air',
'type': 'Installation',
'status': result['status'],
'message': result['message'],
'report_id': data.airManId.toString(),
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toMap()),
'image_data': jsonEncode(_getInstallationImagePaths(data)),
'server_name': serverName,
'api_status': jsonEncode([{"server_name": serverName, "status": "FAILED", "message": "Invalid response from server."}]),
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]),
};
debugPrint("DB LOGGING (Installation L1/Parse Fail): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
// LOG DEBUG END
// --- ADDED: Log the failed submission to the central database ---
await _dbHelper.saveSubmissionLog(logData);
return result;
}
data.airManId = parsedRecordId;
// --- STEP 2: UPLOAD IMAGE FILES ---
return await _uploadInstallationImagesAndUpdate(data, appSettings, serverName: serverName);
}
/// A reusable function for handling the image upload and local data update logic.
// MODIFIED: Method now requires the serverName to pass to the save method.
Future<Map<String, dynamic>> _uploadInstallationImagesAndUpdate(AirInstallationData data, List<Map<String, dynamic>>? appSettings, {required String serverName}) async {
final filesToUpload = data.getImagesForUpload();
final localStorageService = LocalStorageService();
// Since text data was successfully submitted, the status moves to S1 (Server Pending)
data.status = 'S1';
// We already saved the file in submitInstallation (L1 status). Now we update the status in the local file.
await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.refID!, serverName: serverName);
if (filesToUpload.isEmpty) {
debugPrint("No images to upload. Submission complete.");
// LOG DEBUG START
final logData = {
'submission_id': data.refID!,
'module': 'air',
'type': 'Installation',
'status': data.status,
'message': 'Installation data submitted successfully.',
'report_id': data.airManId.toString(),
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toMap()),
'image_data': jsonEncode(_getInstallationImagePaths(data)),
'server_name': serverName,
'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text data submitted."}]),
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_REQUIRED", "message": "No images were attached."}]),
};
debugPrint("DB LOGGING (Installation S1/Data Only): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
// LOG DEBUG END
// --- MODIFIED: Log the successful submission to the central database ---
await _dbHelper.saveSubmissionLog(logData);
_handleInstallationSuccessAlert(data, appSettings, isDataOnly: true);
return {'status': 'S1', 'message': 'Installation data submitted successfully.'};
}
debugPrint("Step 2: Uploading ${filesToUpload.length} images for record ID ${data.airManId}...");
final imageUploadResult = await _apiService.air.uploadInstallationImages(
airManId: data.airManId.toString(),
files: filesToUpload,
);
if (imageUploadResult['success'] != true) {
debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}");
data.status = 'L2_PENDING_IMAGES';
final result = {
'status': 'L2_PENDING_IMAGES',
'message': 'Data submitted, but image upload failed. Saved locally for retry.',
};
// Update the local file with the image failure status
await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.refID!, serverName: serverName);
// LOG DEBUG START
final logData = {
'submission_id': data.refID!,
'module': 'air',
'type': 'Installation',
'status': result['status'],
'message': result['message'],
'report_id': data.airManId.toString(),
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toMap()),
'image_data': jsonEncode(_getInstallationImagePaths(data)),
'server_name': serverName,
'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text data submitted."}]),
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "FAILED", "message": "Image upload failed."}]),
};
debugPrint("DB LOGGING (Installation L2/Image Fail): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
// LOG DEBUG END
// --- MODIFIED: Log the failed submission to the central database ---
await _dbHelper.saveSubmissionLog(logData);
return result;
}
debugPrint("Images uploaded successfully.");
data.status = 'S2'; // Server Pending (images uploaded)
final result = {
'status': 'S2',
'message': 'Installation data and images submitted successfully.',
};
// LOG DEBUG START
final logData = {
'submission_id': data.refID!,
'module': 'air',
'type': 'Installation',
'status': result['status'],
'message': result['message'],
'report_id': data.airManId.toString(),
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toMap()),
'image_data': jsonEncode(_getInstallationImagePaths(data)),
'server_name': serverName,
'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text and image data submitted."}]),
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]),
};
debugPrint("DB LOGGING (Installation S2/Success): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
// LOG DEBUG END
// --- MODIFIED: Log the successful submission to the central database ---
await _dbHelper.saveSubmissionLog(logData);
// Update the local file with the final success status
await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.refID!, serverName: serverName);
_handleInstallationSuccessAlert(data, appSettings, isDataOnly: false);
return result;
}
/// Submits only the collection data, linked to a previous installation.
// MODIFIED: Method now requires the appSettings list to pass down the call stack.
Future<Map<String, dynamic>> submitCollection(AirCollectionData data, AirInstallationData installationData, List<Map<String, dynamic>>? appSettings) async {
// --- MODIFIED: Get the active server name to use for local storage ---
final activeConfig = await _serverConfigService.getActiveApiConfig();
final serverName = activeConfig?['config_name'] as String? ?? 'Default';
final apiConfigs = (await _dbHelper.loadApiConfigs() ?? []).take(2).toList();
final localStorageService = LocalStorageService();
// If the record's text data is already on the server, skip directly to image upload.
if (data.status == 'L4_PENDING_IMAGES' && data.airManId != null) {
debugPrint("Retrying collection image upload for existing record ID: ${data.airManId}");
final result = await _uploadCollectionImagesAndUpdate(data, installationData, appSettings, serverName: serverName);
// LOG DEBUG START
final logData = {
'submission_id': data.installationRefID!,
'module': 'air',
'type': 'Collection',
'status': result['status'],
'message': result['message'],
'report_id': data.airManId.toString(),
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toMap()),
'image_data': jsonEncode(_getCollectionImagePaths(data)),
'server_name': serverName,
'api_status': jsonEncode([{"server_name": serverName, "status": "PENDING", "message": "Resubmitting images."}]),
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "FTP not used."}]),
};
debugPrint("DB LOGGING (Collection Retry): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
// LOG DEBUG END
// --- ADDED: Log the final result to the central database ---
await _dbHelper.saveSubmissionLog(logData);
return result;
}
// --- STEP 1: SUBMIT TEXT DATA ---
debugPrint("Step 1: Submitting collection text data...");
final textDataResult = await _apiService.air.submitCollection(data);
// --- CRITICAL FIX: Save to local file system immediately regardless of API success ---
data.status = 'L3'; // Temporary set status to Local Only
final localSaveMap = _toMapForLocalSave(data);
final localSaveResult = await localStorageService.saveAirSamplingRecord(localSaveMap, data.installationRefID!, serverName: serverName);
if (localSaveResult == null) {
debugPrint("CRITICAL ERROR: Failed to save Air Collection record to local file system.");
}
// --- END CRITICAL FIX ---
if (textDataResult['success'] != true) {
debugPrint("Failed to submit collection text data. Reason: ${textDataResult['message']}");
final result = {'status': 'L3', 'message': 'No connection or server error. Collection data saved locally.'};
// LOG DEBUG START
final logData = {
'submission_id': data.installationRefID!,
'module': 'air',
'type': 'Collection',
'status': result['status'],
'message': result['message'],
'report_id': data.airManId.toString(),
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toMap()),
'image_data': jsonEncode(_getCollectionImagePaths(data)),
'server_name': serverName,
'api_status': jsonEncode([{"server_name": serverName, "status": "FAILED", "message": "API submission failed."}]),
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]),
};
debugPrint("DB LOGGING (Collection L3): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
// LOG DEBUG END
// --- ADDED: Log the failed submission to the central database ---
await _dbHelper.saveSubmissionLog(logData);
return result;
}
debugPrint("Collection text data submitted successfully.");
data.airManId = textDataResult['data']['air_man_id'];
// --- STEP 2: UPLOAD IMAGE FILES ---
return await _uploadCollectionImagesAndUpdate(data, installationData, appSettings, serverName: serverName);
}
/// A reusable function for handling the collection image upload and local data update logic.
// MODIFIED: Method now requires the serverName to pass to the save method.
Future<Map<String, dynamic>> _uploadCollectionImagesAndUpdate(AirCollectionData data, AirInstallationData installationData, List<Map<String, dynamic>>? appSettings, {required String serverName}) async {
final filesToUpload = data.getImagesForUpload();
final localStorageService = LocalStorageService();
// Since text data was successfully submitted, the status moves to S3 (Server Pending)
data.status = 'S3';
// Update local file status (which was already saved with L3 status)
await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.installationRefID!, serverName: serverName);
if (filesToUpload.isEmpty) {
debugPrint("No collection images to upload. Submission complete.");
final result = {'status': 'S3', 'message': 'Collection data submitted successfully.'};
// LOG DEBUG START
final logData = {
'submission_id': data.installationRefID!,
'module': 'air',
'type': 'Collection',
'status': result['status'],
'message': result['message'],
'report_id': data.airManId.toString(),
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toMap()),
'image_data': jsonEncode(_getCollectionImagePaths(data)),
'server_name': serverName,
'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text data submitted."}]),
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_REQUIRED", "message": "No images were attached."}]),
};
debugPrint("DB LOGGING (Collection S3/Data Only): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
// LOG DEBUG END
// --- MODIFIED: Log the successful submission to the central database ---
await _dbHelper.saveSubmissionLog(logData);
_handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: true);
return result;
}
debugPrint("Step 2: Uploading ${filesToUpload.length} collection images...");
final imageUploadResult = await _apiService.air.uploadCollectionImages(
airManId: data.airManId.toString(),
files: filesToUpload,
);
if (imageUploadResult['success'] != true) {
debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}");
data.status = 'L4_PENDING_IMAGES';
final result = {
'status': 'L4_PENDING_IMAGES',
'message': 'Data submitted, but image upload failed. Saved locally for retry.',
};
// Update the local file with the image failure status
await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.installationRefID!, serverName: serverName);
// LOG DEBUG START
final logData = {
'submission_id': data.installationRefID!,
'module': 'air',
'type': 'Collection',
'status': result['status'],
'message': result['message'],
'report_id': data.airManId.toString(),
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toMap()),
'image_data': jsonEncode(_getCollectionImagePaths(data)),
'server_name': serverName,
'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text data submitted."}]),
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "FAILED", "message": "Image upload failed."}]),
};
debugPrint("DB LOGGING (Collection L4/Image Fail): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
// LOG DEBUG END
// --- MODIFIED: Log the failed submission to the central database ---
await _dbHelper.saveSubmissionLog(logData);
return result;
}
debugPrint("Images uploaded successfully.");
final result = {
'status': 'S3',
'message': 'Collection data and images submitted successfully.',
};
// LOG DEBUG START
final logData = {
'submission_id': data.installationRefID!,
'module': 'air',
'type': 'Collection',
'status': result['status'],
'message': result['message'],
'report_id': data.airManId.toString(),
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toMap()),
'image_data': jsonEncode(_getCollectionImagePaths(data)),
'server_name': serverName,
'api_status': jsonEncode([{"server_name": serverName, "status": "SUCCESS", "message": "Text and image data submitted."}]),
'ftp_status': jsonEncode([{"server_name": "N/A", "status": "NOT_APPLICABLE", "message": "Not applicable."}]),
};
debugPrint("DB LOGGING (Collection S3/Success): Status: ${logData['status']}, API Status: ${logData['api_status']}, FTP Status: ${logData['ftp_status']}");
// LOG DEBUG END
// --- MODIFIED: Log the successful submission to the central database ---
await _dbHelper.saveSubmissionLog(logData);
// Update the local file with the final success status
await localStorageService.saveAirSamplingRecord(_toMapForLocalSave(data), data.installationRefID!, serverName: serverName);
_handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: false);
return result;
}
/// Fetches installations that are pending collection from local storage.
// getPendingInstallations can be moved to a different service or screen logic later
Future<List<AirInstallationData>> getPendingInstallations() async {
debugPrint("Fetching pending installations from local storage...");
final logs = await _dbHelper.loadSubmissionLogs(module: 'air');
final pendingInstallations = logs
?.where((log) {
final status = log['status'];
// --- CORRECTED ---
// Only show installations that have been synced to the server (S1, S2).
// 'L1' (Local only) records cannot be collected until they are synced.
return status == 'S1' || status == 'S2';
})
.map((log) => AirInstallationData.fromJson(jsonDecode(log['form_data'])))
.toList() ?? [];
return pendingInstallations;
}
void dispose() {
// Clean up any resources if necessary
}
}

View File

@ -16,6 +16,9 @@ import 'package:environment_monitoring_app/models/tarball_data.dart';
import 'package:environment_monitoring_app/models/air_collection_data.dart';
import 'package:environment_monitoring_app/models/air_installation_data.dart';
import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.dart';
// START CHANGE: Added import for ServerConfigService to get the base URL
import 'package:environment_monitoring_app/services/server_config_service.dart';
// END CHANGE
// =======================================================================
// Part 1: Unified API Service
@ -27,6 +30,9 @@ import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.da
class ApiService {
final BaseApiService _baseService = BaseApiService();
final DatabaseHelper dbHelper = DatabaseHelper();
// START CHANGE: Added ServerConfigService to provide the base URL for API calls
final ServerConfigService _serverConfigService = ServerConfigService();
// END CHANGE
late final MarineApiService marine;
late final RiverApiService river;
@ -35,15 +41,19 @@ class ApiService {
static const String imageBaseUrl = 'https://dev14.pstw.com.my/';
ApiService({required TelegramService telegramService}) {
marine = MarineApiService(_baseService, telegramService);
river = RiverApiService(_baseService, telegramService);
air = AirApiService(_baseService, telegramService);
// START CHANGE: Pass the ServerConfigService to the sub-services
marine = MarineApiService(_baseService, telegramService, _serverConfigService);
river = RiverApiService(_baseService, telegramService, _serverConfigService);
air = AirApiService(_baseService, telegramService, _serverConfigService);
// END CHANGE
}
// --- Core API Methods (Unchanged) ---
Future<Map<String, dynamic>> login(String email, String password) {
return _baseService.post('auth/login', {'email': email, 'password': password});
// START CHANGE: Update all calls to _baseService to pass the required baseUrl
Future<Map<String, dynamic>> login(String email, String password) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.post(baseUrl, 'auth/login', {'email': email, 'password': password});
}
Future<Map<String, dynamic>> register({
@ -56,7 +66,8 @@ class ApiService {
int? departmentId,
int? companyId,
int? positionId,
}) {
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
final Map<String, dynamic> body = {
'username': username,
'email': email,
@ -69,27 +80,47 @@ class ApiService {
'position_id': positionId,
};
body.removeWhere((key, value) => value == null);
return _baseService.post('auth/register', body);
return _baseService.post(baseUrl, 'auth/register', body);
}
Future<Map<String, dynamic>> post(String endpoint, Map<String, dynamic> data) {
return _baseService.post(endpoint, data);
Future<Map<String, dynamic>> post(String endpoint, Map<String, dynamic> data) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.post(baseUrl, endpoint, data);
}
Future<Map<String, dynamic>> getProfile() => _baseService.get('profile');
Future<Map<String, dynamic>> getAllUsers() => _baseService.get('users');
Future<Map<String, dynamic>> getProfile() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'profile');
}
Future<Map<String, dynamic>> getAllUsers() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'users');
}
Future<Map<String, dynamic>> getAllDepartments() => _baseService.get('departments');
Future<Map<String, dynamic>> getAllCompanies() => _baseService.get('companies');
Future<Map<String, dynamic>> getAllPositions() => _baseService.get('positions');
Future<Map<String, dynamic>> getAllStates() => _baseService.get('states');
Future<Map<String, dynamic>> getAllDepartments() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'departments');
}
Future<Map<String, dynamic>> getAllCompanies() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'companies');
}
Future<Map<String, dynamic>> getAllPositions() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'positions');
}
Future<Map<String, dynamic>> getAllStates() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'states');
}
Future<Map<String, dynamic>> sendTelegramAlert({
required String chatId,
required String message,
}) {
return _baseService.post('marine/telegram-alert', {
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.post(baseUrl, 'marine/telegram-alert', {
'chat_id': chatId,
'message': message,
});
@ -110,13 +141,16 @@ class ApiService {
return null;
}
Future<Map<String, dynamic>> uploadProfilePicture(File imageFile) {
Future<Map<String, dynamic>> uploadProfilePicture(File imageFile) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.postMultipart(
baseUrl: baseUrl,
endpoint: 'profile/upload-picture',
fields: {},
files: {'profile_picture': imageFile}
);
}
// END CHANGE
Future<Map<String, dynamic>> refreshProfile() async {
debugPrint('ApiService: Refreshing profile data from server...');
@ -131,13 +165,16 @@ class ApiService {
// --- REWRITTEN FOR DELTA SYNC ---
/// Helper method to make a delta-sync API call.
Future<Map<String, dynamic>> _fetchDelta(String endpoint, String? lastSyncTimestamp) {
Future<Map<String, dynamic>> _fetchDelta(String endpoint, String? lastSyncTimestamp) async {
// START CHANGE: Get baseUrl and pass it to the get method
final baseUrl = await _serverConfigService.getActiveApiUrl();
String url = endpoint;
if (lastSyncTimestamp != null) {
// Append the 'since' parameter to the URL for delta requests
url += '?since=$lastSyncTimestamp';
}
return _baseService.get(url);
return _baseService.get(baseUrl, url);
// END CHANGE
}
/// Orchestrates a full DELTA sync from the server to the local database.
@ -161,7 +198,6 @@ class ApiService {
'states': {'endpoint': 'states', 'handler': (d, id) async { await dbHelper.upsertStates(d); await dbHelper.deleteStates(id); }},
'appSettings': {'endpoint': 'settings', 'handler': (d, id) async { await dbHelper.upsertAppSettings(d); await dbHelper.deleteAppSettings(id); }},
'parameterLimits': {'endpoint': 'parameter-limits', 'handler': (d, id) async { await dbHelper.upsertParameterLimits(d); await dbHelper.deleteParameterLimits(id); }},
// --- ADDED: New sync tasks for independent API and FTP configurations ---
'apiConfigs': {'endpoint': 'api-configs', 'handler': (d, id) async { await dbHelper.upsertApiConfigs(d); await dbHelper.deleteApiConfigs(id); }},
'ftpConfigs': {'endpoint': 'ftp-configs', 'handler': (d, id) async { await dbHelper.upsertFtpConfigs(d); await dbHelper.deleteFtpConfigs(id); }},
};
@ -207,24 +243,38 @@ class ApiService {
class AirApiService {
final BaseApiService _baseService;
final TelegramService? _telegramService;
AirApiService(this._baseService, [this._telegramService]);
// START CHANGE: Add ServerConfigService dependency
final ServerConfigService _serverConfigService;
AirApiService(this._baseService, this._telegramService, this._serverConfigService);
// END CHANGE
Future<Map<String, dynamic>> getManualStations() => _baseService.get('air/manual-stations');
Future<Map<String, dynamic>> getClients() => _baseService.get('air/clients');
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data) {
return _baseService.post('air/manual/installation', data.toJsonForApi());
// START CHANGE: Update all calls to _baseService to pass the required baseUrl
Future<Map<String, dynamic>> getManualStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'air/manual-stations');
}
Future<Map<String, dynamic>> getClients() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'air/clients');
}
Future<Map<String, dynamic>> submitCollection(AirCollectionData data) {
return _baseService.post('air/manual/collection', data.toJson());
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.post(baseUrl, 'air/manual/installation', data.toJsonForApi());
}
Future<Map<String, dynamic>> submitCollection(AirCollectionData data) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.post(baseUrl, 'air/manual/collection', data.toJson());
}
Future<Map<String, dynamic>> uploadInstallationImages({
required String airManId,
required Map<String, File> files,
}) {
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.postMultipart(
baseUrl: baseUrl,
endpoint: 'air/manual/installation-images',
fields: {'air_man_id': airManId},
files: files,
@ -234,26 +284,41 @@ class AirApiService {
Future<Map<String, dynamic>> uploadCollectionImages({
required String airManId,
required Map<String, File> files,
}) {
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.postMultipart(
baseUrl: baseUrl,
endpoint: 'air/manual/collection-images',
fields: {'air_man_id': airManId},
files: files,
);
}
// END CHANGE
}
class MarineApiService {
final BaseApiService _baseService;
final TelegramService _telegramService;
MarineApiService(this._baseService, this._telegramService);
// START CHANGE: Add ServerConfigService dependency
final ServerConfigService _serverConfigService;
MarineApiService(this._baseService, this._telegramService, this._serverConfigService);
// END CHANGE
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');
// START CHANGE: Update all calls to _baseService to pass the required baseUrl
Future<Map<String, dynamic>> getTarballStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/tarball/stations');
}
Future<Map<String, dynamic>> getManualStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/manual/stations');
}
Future<Map<String, dynamic>> getTarballClassifications() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/tarball/classifications');
}
// FIX: Added submitInSituSample implementation for Marine from marine_api_service.dart
Future<Map<String, dynamic>> submitInSituSample({
required Map<String, String> formData,
required Map<String, File?> imageFiles,
@ -261,7 +326,8 @@ class MarineApiService {
required List<Map<String, dynamic>>? appSettings,
}) async {
debugPrint("Step 1: Submitting in-situ form data to the server...");
final dataResult = await _baseService.post('marine/manual/sample', formData);
final baseUrl = await _serverConfigService.getActiveApiUrl();
final dataResult = await _baseService.post(baseUrl, 'marine/manual/sample', formData);
if (dataResult['success'] != true) {
debugPrint("API submission failed for In-Situ. Message: ${dataResult['message']}");
@ -303,6 +369,7 @@ class MarineApiService {
debugPrint("Step 2: Uploading ${filesToUpload.length} in-situ images for record ID: $recordId");
final imageResult = await _baseService.postMultipart(
baseUrl: baseUrl,
endpoint: 'marine/manual/images',
fields: {'man_id': recordId.toString()},
files: filesToUpload,
@ -339,14 +406,14 @@ class MarineApiService {
debugPrint("Failed to handle In-Situ Telegram alert: $e");
}
}
// END FIX: Added submitInSituSample implementation for Marine
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);
final baseUrl = await _serverConfigService.getActiveApiUrl();
final dataResult = await _baseService.post(baseUrl, 'marine/tarball/sample', formData);
if (dataResult['success'] != true) return {'status': 'L1', 'success': false, 'message': 'Failed to submit data: ${dataResult['message']}'};
final recordId = dataResult['data']?['autoid'];
@ -360,7 +427,7 @@ class MarineApiService {
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(baseUrl: baseUrl, endpoint: 'marine/tarball/images', fields: {'autoid': recordId.toString()}, files: filesToUpload);
if (imageResult['success'] != true) {
_handleTarballSuccessAlert(formData, appSettings, isDataOnly: true);
return {'status': 'L2', 'success': false, 'message': 'Data submitted, but image upload failed: ${imageResult['message']}', 'reportId': recordId};
@ -369,6 +436,7 @@ class MarineApiService {
_handleTarballSuccessAlert(formData, appSettings, isDataOnly: false);
return {'status': 'L3', 'success': true, 'message': 'Data and images submitted successfully.', 'reportId': recordId};
}
// END CHANGE
Future<void> _handleTarballSuccessAlert(Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
debugPrint("Triggering Telegram alert logic...");
@ -418,19 +486,28 @@ class MarineApiService {
class RiverApiService {
final BaseApiService _baseService;
final TelegramService _telegramService;
RiverApiService(this._baseService, this._telegramService);
// START CHANGE: Add ServerConfigService dependency
final ServerConfigService _serverConfigService;
RiverApiService(this._baseService, this._telegramService, this._serverConfigService);
// END CHANGE
Future<Map<String, dynamic>> getManualStations() => _baseService.get('river/manual-stations');
Future<Map<String, dynamic>> getTriennialStations() => _baseService.get('river/triennial-stations');
// START CHANGE: Update all calls to _baseService to pass the required baseUrl
Future<Map<String, dynamic>> getManualStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'river/manual-stations');
}
Future<Map<String, dynamic>> getTriennialStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'river/triennial-stations');
}
// FIX: Added submitInSituSample implementation for River from river_api_service.dart
Future<Map<String, dynamic>> submitInSituSample({
required Map<String, String> formData,
required Map<String, File?> imageFiles,
required List<Map<String, dynamic>>? appSettings,
}) async {
// --- Step 1: Submit Form Data as JSON ---
final dataResult = await _baseService.post('river/manual/sample', formData);
final baseUrl = await _serverConfigService.getActiveApiUrl();
final dataResult = await _baseService.post(baseUrl, 'river/manual/sample', formData);
if (dataResult['success'] != true) {
return {
@ -441,7 +518,6 @@ class RiverApiService {
};
}
// --- Step 2: Upload Image Files ---
final recordId = dataResult['data']?['r_man_id'];
if (recordId == null) {
return {
@ -468,6 +544,7 @@ class RiverApiService {
}
final imageResult = await _baseService.postMultipart(
baseUrl: baseUrl,
endpoint: 'river/manual/images', // Separate endpoint for images
fields: {'r_man_id': recordId.toString()}, // Link images to the submitted record ID
files: filesToUpload,
@ -490,6 +567,7 @@ class RiverApiService {
'reportId': recordId.toString()
};
}
// END CHANGE
Future<void> _handleInSituSuccessAlert(Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try {
@ -524,7 +602,6 @@ class RiverApiService {
final String message = buffer.toString();
// MODIFIED: Pass the appSettings list to the TelegramService methods.
final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings);
if (!wasSent) {
await _telegramService.queueMessage('river_in_situ', message, appSettings);
@ -533,7 +610,6 @@ class RiverApiService {
debugPrint("Failed to handle River Telegram alert: $e");
}
}
// END FIX: Added submitInSituSample implementation for River
}
// =======================================================================
@ -543,7 +619,7 @@ class RiverApiService {
class DatabaseHelper {
static Database? _database;
static const String _dbName = 'app_data.db';
static const int _dbVersion = 18;
static const int _dbVersion = 19;
static const String _profileTable = 'user_profile';
static const String _usersTable = 'all_users';
@ -564,9 +640,12 @@ class DatabaseHelper {
static const String _apiConfigsTable = 'api_configurations';
static const String _ftpConfigsTable = 'ftp_configurations';
static const String _retryQueueTable = 'retry_queue';
// FIX: Updated submission log table schema for granular status tracking
static const String _submissionLogTable = 'submission_log';
static const String _modulePreferencesTable = 'module_preferences';
static const String _moduleApiLinksTable = 'module_api_links';
static const String _moduleFtpLinksTable = 'module_ftp_links';
Future<Database> get database async {
if (_database != null) return _database!;
@ -625,6 +704,32 @@ class DatabaseHelper {
ftp_status TEXT
)
''');
// START CHANGE: Added CREATE TABLE statements for the new tables.
await db.execute('''
CREATE TABLE $_modulePreferencesTable (
module_name TEXT PRIMARY KEY,
is_api_enabled INTEGER NOT NULL DEFAULT 1,
is_ftp_enabled INTEGER NOT NULL DEFAULT 1
)
''');
await db.execute('''
CREATE TABLE $_moduleApiLinksTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
module_name TEXT NOT NULL,
api_config_id INTEGER NOT NULL,
is_enabled INTEGER NOT NULL DEFAULT 1
)
''');
await db.execute('''
CREATE TABLE $_moduleFtpLinksTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
module_name TEXT NOT NULL,
ftp_config_id INTEGER NOT NULL,
is_enabled INTEGER NOT NULL DEFAULT 1
)
''');
// END CHANGE
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
@ -682,6 +787,32 @@ class DatabaseHelper {
} catch (_) {
// Ignore if columns already exist during a complex migration path
}
// START CHANGE: Add upgrade path for the new preference tables.
await db.execute('''
CREATE TABLE IF NOT EXISTS $_modulePreferencesTable (
module_name TEXT PRIMARY KEY,
is_api_enabled INTEGER NOT NULL DEFAULT 1,
is_ftp_enabled INTEGER NOT NULL DEFAULT 1
)
''');
await db.execute('''
CREATE TABLE IF NOT EXISTS $_moduleApiLinksTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
module_name TEXT NOT NULL,
api_config_id INTEGER NOT NULL,
is_enabled INTEGER NOT NULL DEFAULT 1
)
''');
await db.execute('''
CREATE TABLE IF NOT EXISTS $_moduleFtpLinksTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
module_name TEXT NOT NULL,
ftp_config_id INTEGER NOT NULL,
is_enabled INTEGER NOT NULL DEFAULT 1
)
''');
// END CHANGE
}
}
@ -858,4 +989,97 @@ class DatabaseHelper {
if (maps.isNotEmpty) return maps;
return null;
}
// START CHANGE: Added helper methods for the new preference tables.
/// Saves or updates a module's master submission preferences.
Future<void> saveModulePreference({
required String moduleName,
required bool isApiEnabled,
required bool isFtpEnabled,
}) async {
final db = await database;
await db.insert(
_modulePreferencesTable,
{
'module_name': moduleName,
'is_api_enabled': isApiEnabled ? 1 : 0,
'is_ftp_enabled': isFtpEnabled ? 1 : 0,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
/// Retrieves a module's master submission preferences.
Future<Map<String, dynamic>?> getModulePreference(String moduleName) async {
final db = await database;
final result = await db.query(
_modulePreferencesTable,
where: 'module_name = ?',
whereArgs: [moduleName],
);
if (result.isNotEmpty) {
final row = result.first;
return {
'module_name': row['module_name'],
'is_api_enabled': (row['is_api_enabled'] as int) == 1,
'is_ftp_enabled': (row['is_ftp_enabled'] as int) == 1,
};
}
return null; // Return null if no specific preference is set
}
/// Saves the complete set of API links for a specific module, replacing any old ones.
Future<void> saveApiLinksForModule(String moduleName, List<Map<String, dynamic>> links) async {
final db = await database;
await db.transaction((txn) async {
// First, delete all existing links for this module
await txn.delete(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]);
// Then, insert all the new links
for (final link in links) {
await txn.insert(_moduleApiLinksTable, {
'module_name': moduleName,
'api_config_id': link['api_config_id'],
'is_enabled': (link['is_enabled'] as bool? ?? true) ? 1 : 0,
});
}
});
}
/// Saves the complete set of FTP links for a specific module, replacing any old ones.
Future<void> saveFtpLinksForModule(String moduleName, List<Map<String, dynamic>> links) async {
final db = await database;
await db.transaction((txn) async {
await txn.delete(_moduleFtpLinksTable, where: 'module_name = ?', whereArgs: [moduleName]);
for (final link in links) {
await txn.insert(_moduleFtpLinksTable, {
'module_name': moduleName,
'ftp_config_id': link['ftp_config_id'],
'is_enabled': (link['is_enabled'] as bool? ?? true) ? 1 : 0,
});
}
});
}
/// Retrieves all API links for a specific module, regardless of enabled status.
Future<List<Map<String, dynamic>>> getAllApiLinksForModule(String moduleName) async {
final db = await database;
final result = await db.query(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]);
return result.map((row) => {
'api_config_id': row['api_config_id'],
'is_enabled': (row['is_enabled'] as int) == 1,
}).toList();
}
/// Retrieves all FTP links for a specific module, regardless of enabled status.
Future<List<Map<String, dynamic>>> getAllFtpLinksForModule(String moduleName) async {
final db = await database;
final result = await db.query(_moduleFtpLinksTable, where: 'module_name = ?', whereArgs: [moduleName]);
return result.map((row) => {
'ftp_config_id': row['ftp_config_id'],
'is_enabled': (row['is_enabled'] as int) == 1,
}).toList();
}
// END CHANGE
}

View File

@ -2,20 +2,16 @@
import 'dart:convert';
import 'dart:io';
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';
import 'package:environment_monitoring_app/services/server_config_service.dart';
import 'package:environment_monitoring_app/services/retry_service.dart';
import 'package:environment_monitoring_app/services/api_service.dart';
/// A low-level service for making direct HTTP requests.
/// This service is now "dumb" and only sends a request to the specific
/// baseUrl provided. It no longer contains logic for server fallbacks.
class BaseApiService {
final ServerConfigService _serverConfigService = ServerConfigService();
final DatabaseHelper _dbHelper = DatabaseHelper();
Future<Map<String, String>> _getHeaders() async {
final prefs = await SharedPreferences.getInstance();
@ -31,185 +27,76 @@ class BaseApiService {
return headers;
}
// Generic GET request handler (remains unchanged)
Future<Map<String, dynamic>> get(String endpoint) async {
/// Generic GET request handler.
Future<Map<String, dynamic>> get(String baseUrl, String endpoint) async {
try {
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));
return _handleResponse(response);
} catch (e) {
debugPrint('GET request failed: $e');
debugPrint('GET request to $baseUrl failed: $e');
return {'success': false, 'message': 'Network error or timeout: $e'};
}
}
// --- MODIFIED: Generic POST request handler now attempts multiple servers ---
Future<Map<String, dynamic>> post(String endpoint, Map<String, dynamic> body) async {
final configs = await _dbHelper.loadApiConfigs() ?? [];
// --- 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();
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.'};
}
/// Generic POST request handler to a specific server.
Future<Map<String, dynamic>> post(String baseUrl, String endpoint, Map<String, dynamic> body) async {
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 $baseUrl failed. Error: $e');
return {'success': false, 'message': 'API connection failed: $e'};
}
// 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})');
// --- FIX: The check now correctly targets the 'api_url' key in the decoded map ---
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.'};
}
// --- MODIFIED: Generic multipart handler now attempts multiple servers ---
/// Generic multipart request handler to a specific server.
Future<Map<String, dynamic>> postMultipart({
required String baseUrl,
required String endpoint,
required Map<String, String> fields,
required Map<String, File> files,
}) async {
final configs = await _dbHelper.loadApiConfigs() ?? [];
try {
final url = Uri.parse('$baseUrl/$endpoint');
debugPrint('Attempting multipart upload to: $url');
// --- 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();
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.'};
var request = http.MultipartRequest('POST', url);
final headers = await _getHeaders();
request.headers.addAll(headers);
if (fields.isNotEmpty) {
request.fields.addAll(fields);
}
}
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})');
// --- FIX: The check now correctly targets the 'api_url' key in the decoded map ---
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()) {
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;
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),
));
} else {
debugPrint('Multipart upload to $baseUrl failed with an API error. Trying next server if available. Error: ${result['message']}');
debugPrint('File does not exist: ${entry.value.path}. Skipping this file.');
}
} 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.'};
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, s) {
debugPrint('Multipart upload to $baseUrl failed. Error: $e');
debugPrint('Stack trace: $s');
return {'success': false, 'message': 'API connection failed: $e'};
}
}
Map<String, dynamic> _handleResponse(http.Response response) {

View File

@ -3,92 +3,67 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:ftpconnect/ftpconnect.dart';
import 'package:environment_monitoring_app/services/server_config_service.dart';
// --- ADDED: Import for the new service that manages the retry queue ---
import 'package:environment_monitoring_app/services/retry_service.dart';
// --- ADDED: Import for the local database helper ---
import 'package:environment_monitoring_app/services/api_service.dart';
/// A low-level service for making a direct FTP connection and uploading a single file.
/// This service only performs the upload; it does not decide which server to use.
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.
/// Uploads a single file to a specific FTP server defined in the config.
///
/// [config] A map containing FTP credentials ('ftp_host', 'ftp_user', 'ftp_pass', 'ftp_port').
/// [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.
/// 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 configs = await _dbHelper.loadFtpConfigs() ?? []; // Get all FTP configs
final latestConfigs = configs.take(2).toList(); // Limit to the two latest configs
Future<Map<String, dynamic>> uploadFile({
required Map<String, dynamic> config,
required File fileToUpload,
required String remotePath,
}) async {
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 (latestConfigs.isEmpty) {
return {'success': false, 'message': 'FTP credentials are not configured or selected.'};
if (ftpHost == null || ftpUser == null || ftpPass == null) {
final message = 'FTP configuration is incomplete. Missing host, user, or password.';
debugPrint('FTP: $message');
return {'success': false, 'message': message};
}
// 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,
timeout: 60,
);
if (ftpHost == null || ftpUser == null || ftpPass == null) {
debugPrint('FTP: Configuration is incomplete. Skipping to next server if available.');
continue;
}
try {
debugPrint('FTP: Connecting to $ftpHost...');
await ftpConnect.connect();
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 ---
debugPrint('FTP: Uploading file ${fileToUpload.path} to $remotePath...');
bool res = await ftpConnect.uploadFileWithRetry(
fileToUpload,
pRemoteName: remotePath,
pRetryCount: 3,
);
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
await ftpConnect.disconnect();
if (res) {
debugPrint('FTP upload to $ftpHost succeeded.');
return {'success': true, 'message': 'File uploaded successfully to $ftpHost.'};
} else {
debugPrint('FTP upload to $ftpHost failed after retries.');
return {'success': false, 'message': 'FTP upload to $ftpHost failed after retries.'};
}
} catch (e) {
debugPrint('FTP upload to $ftpHost failed with an exception. Error: $e');
try {
await ftpConnect.disconnect();
} catch (_) {}
return {'success': false, 'message': 'FTP upload to $ftpHost failed: $e'};
}
// 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.'};
}
}

View File

@ -1,154 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:image/image.dart' as img;
import 'package:geolocator/geolocator.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:usb_serial/usb_serial.dart';
import 'location_service.dart';
import 'marine_api_service.dart';
import '../models/in_situ_sampling_data.dart';
import '../bluetooth/bluetooth_manager.dart';
import '../serial/serial_manager.dart';
/// A dedicated service to handle all business logic for the In-Situ Sampling feature.
/// This includes location, image processing, device communication, and data submission.
class InSituSamplingService {
final LocationService _locationService = LocationService();
final MarineApiService _marineApiService = MarineApiService();
final BluetoothManager _bluetoothManager = BluetoothManager();
final SerialManager _serialManager = SerialManager();
// This channel name MUST match the one defined in MainActivity.kt
static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
// --- Location Services ---
Future<Position> getCurrentLocation() => _locationService.getCurrentLocation();
double calculateDistance(double lat1, double lon1, double lat2, double lon2) => _locationService.calculateDistance(lat1, lon1, lat2, lon2);
// --- Image Processing ---
Future<File?> pickAndProcessImage(ImageSource source, {
required InSituSamplingData data,
required String imageInfo,
bool isRequired = false,
}) async {
final picker = ImagePicker();
final XFile? photo = await picker.pickImage(source: source, imageQuality: 85, maxWidth: 1024);
if (photo == null) return null;
final bytes = await photo.readAsBytes();
img.Image? originalImage = img.decodeImage(bytes);
if (originalImage == null) return null;
if (isRequired && originalImage.height > originalImage.width) {
debugPrint("Image rejected: Must be in landscape orientation.");
return null;
}
final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}";
final font = img.arial24;
final textWidth = watermarkTimestamp.length * 12;
img.fillRect(originalImage, x1: 5, y1: 5, x2: textWidth + 15, y2: 35, color: img.ColorRgb8(255, 255, 255));
img.drawString(originalImage, watermarkTimestamp, font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0));
final tempDir = await getTemporaryDirectory();
final stationCode = data.selectedStation?['man_station_code'] ?? 'NA';
final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-');
final newFileName = "${stationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg";
final filePath = path.join(tempDir.path, newFileName);
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
}
// --- Device Connection (Delegated to Managers) ---
ValueNotifier<BluetoothConnectionState> get bluetoothConnectionState => _bluetoothManager.connectionState;
ValueNotifier<SerialConnectionState> get serialConnectionState => _serialManager.connectionState;
// REPAIRED: This getter now dynamically returns the correct Sonde ID notifier
// based on the active connection, which is essential for the UI.
ValueNotifier<String?> get sondeId {
if (_bluetoothManager.connectionState.value != BluetoothConnectionState.disconnected) {
return _bluetoothManager.sondeId;
}
return _serialManager.sondeId;
}
Stream<Map<String, double>> get bluetoothDataStream => _bluetoothManager.dataStream;
Stream<Map<String, double>> get serialDataStream => _serialManager.dataStream;
// REPAIRED: Added .value to both getters for consistency and to prevent errors.
String? get connectedBluetoothDeviceName => _bluetoothManager.connectedDeviceName.value;
String? get connectedSerialDeviceName => _serialManager.connectedDeviceName.value;
// --- Permissions ---
Future<bool> requestDevicePermissions() async {
Map<Permission, PermissionStatus> statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
Permission.locationWhenInUse,
].request();
// Return true only if the essential permissions are granted.
if (statuses[Permission.bluetoothScan] == PermissionStatus.granted &&
statuses[Permission.bluetoothConnect] == PermissionStatus.granted) {
return true;
} else {
return false;
}
}
// --- Bluetooth Methods ---
Future<List<BluetoothDevice>> getPairedBluetoothDevices() => _bluetoothManager.getPairedDevices();
Future<void> connectToBluetoothDevice(BluetoothDevice device) => _bluetoothManager.connect(device);
void disconnectFromBluetooth() => _bluetoothManager.disconnect();
void startBluetoothAutoReading({Duration? interval}) => _bluetoothManager.startAutoReading(interval: interval ?? const Duration(seconds: 5));
void stopBluetoothAutoReading() => _bluetoothManager.stopAutoReading();
// --- USB Serial Methods ---
Future<List<UsbDevice>> getAvailableSerialDevices() => _serialManager.getAvailableDevices();
Future<bool> requestUsbPermission(UsbDevice device) async {
try {
return await platform.invokeMethod('requestUsbPermission', {'vid': device.vid, 'pid': device.pid}) ?? false;
} on PlatformException catch (e) {
debugPrint("Failed to request USB permission: '${e.message}'.");
return false;
}
}
Future<void> connectToSerialDevice(UsbDevice device) async {
final bool permissionGranted = await requestUsbPermission(device);
if (permissionGranted) {
await _serialManager.connect(device);
} else {
throw Exception("USB permission was not granted.");
}
}
void disconnectFromSerial() => _serialManager.disconnect();
void startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 5));
void stopSerialAutoReading() => _serialManager.stopAutoReading();
void dispose() {
_bluetoothManager.dispose();
_serialManager.dispose();
}
// --- Data Submission ---
// MODIFIED: Method now requires the appSettings list to pass to the MarineApiService.
Future<Map<String, dynamic>> submitData(InSituSamplingData data, List<Map<String, dynamic>>? appSettings) {
return _marineApiService.submitInSituSample(
formData: data.toApiFormData(),
imageFiles: data.toApiImageFiles(),
inSituData: data,
appSettings: appSettings, // Added this required parameter
);
}
}

View File

@ -1,233 +1,28 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
// lib/services/marine_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/services/settings_service.dart';
import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart';
import 'package:environment_monitoring_app/models/tarball_data.dart';
import 'package:environment_monitoring_app/services/server_config_service.dart';
class MarineApiService {
final BaseApiService _baseService = BaseApiService();
final TelegramService _telegramService = TelegramService();
// REMOVED: SettingsService is no longer called directly from this file for chat IDs.
// final SettingsService _settingsService = SettingsService();
final BaseApiService _baseService;
final TelegramService _telegramService;
final ServerConfigService _serverConfigService;
Future<Map<String, dynamic>> getTarballStations() {
return _baseService.get('marine/tarball/stations');
MarineApiService(this._baseService, this._telegramService, this._serverConfigService);
Future<Map<String, dynamic>> getTarballStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/tarball/stations');
}
Future<Map<String, dynamic>> getManualStations() {
return _baseService.get('marine/manual/stations');
Future<Map<String, dynamic>> getManualStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/manual/stations');
}
Future<Map<String, dynamic>> getTarballClassifications() {
return _baseService.get('marine/tarball/classifications');
}
// --- 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, // 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,
'message': 'Failed to submit data to server: ${dataResult['message']}',
'reportId': null,
};
}
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,
'message': 'Data submitted, but failed to get a record ID to link images.',
'reportId': null,
};
}
final filesToUpload = <String, File>{};
imageFiles.forEach((key, value) {
if (value != null) filesToUpload[key] = value;
});
if (filesToUpload.isEmpty) {
debugPrint("No images to upload. Finalizing submission.");
_handleTarballSuccessAlert(formData, appSettings, isDataOnly: true);
return {
'status': 'L3',
'success': true,
'message': 'Data submitted successfully. No images were attached.',
'reportId': recordId,
};
}
debugPrint("Step 2: Uploading ${filesToUpload.length} tarball images for record ID: $recordId");
final imageResult = await _baseService.postMultipart(
endpoint: 'marine/tarball/images',
fields: {'autoid': recordId.toString()},
files: filesToUpload,
);
if (imageResult['success'] != true) {
debugPrint("Image upload failed for Tarball. Message: ${imageResult['message']}");
return {
'status': 'L2',
'success': false,
'message': 'Data submitted to server, but image upload failed: ${imageResult['message']}',
'reportId': recordId,
};
}
debugPrint("Step 2 successful. All images uploaded.");
_handleTarballSuccessAlert(formData, appSettings, isDataOnly: false);
return {
'status': 'L3',
'success': true,
'message': 'Data and images submitted to server successfully.',
'reportId': recordId,
};
}
// MODIFIED: Method now requires the appSettings list.
Future<Map<String, dynamic>> submitInSituSample({
required Map<String, String> formData,
required Map<String, File?> imageFiles,
required InSituSamplingData inSituData,
required List<Map<String, dynamic>>? appSettings,
}) async {
debugPrint("Step 1: Submitting in-situ form data to the server...");
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,
'message': 'Failed to submit in-situ data: ${dataResult['message']}',
'reportId': null,
};
}
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,
'message': 'In-situ data submitted, but failed to get a record ID for images.',
'reportId': null,
};
}
final filesToUpload = <String, File>{};
imageFiles.forEach((key, value) {
if (value != null) filesToUpload[key] = value;
});
if (filesToUpload.isEmpty) {
debugPrint("No images to upload. Finalizing submission.");
_handleInSituSuccessAlert(inSituData, appSettings, isDataOnly: true);
return {
'status': 'L3',
'success': true,
'message': 'In-situ data submitted successfully. No images were attached.',
'reportId': recordId.toString(),
};
}
debugPrint("Step 2: Uploading ${filesToUpload.length} in-situ images for record ID: $recordId");
final imageResult = await _baseService.postMultipart(
endpoint: 'marine/manual/images',
fields: {'man_id': recordId.toString()},
files: filesToUpload,
);
if (imageResult['success'] != true) {
debugPrint("Image upload failed for In-Situ. Message: ${imageResult['message']}");
return {
'status': 'L2',
'success': false,
'message': 'In-situ data submitted, but image upload failed: ${imageResult['message']}',
'reportId': recordId.toString(),
};
}
debugPrint("Step 2 successful. All images uploaded.");
_handleInSituSuccessAlert(inSituData, appSettings, isDataOnly: false);
return {
'status': 'L3',
'success': true,
'message': 'Data and images submitted to server successfully.',
'reportId': recordId.toString(),
};
}
// MODIFIED: Method now requires appSettings and calls the updated TelegramService.
Future<void> _handleTarballSuccessAlert(Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
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");
}
}
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();
}
// MODIFIED: Method now requires appSettings and calls the updated TelegramService.
Future<void> _handleInSituSuccessAlert(InSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try {
final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly);
final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings);
if (!wasSent) {
await _telegramService.queueMessage('marine_in_situ', message, appSettings);
}
} catch (e) {
debugPrint("Failed to handle In-Situ Telegram alert: $e");
}
Future<Map<String, dynamic>> getTarballClassifications() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/tarball/classifications');
}
}

View File

@ -0,0 +1,323 @@
// lib/services/marine_in_situ_sampling_service.dart
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:image/image.dart' as img;
import 'package:geolocator/geolocator.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:usb_serial/usb_serial.dart';
import 'dart:convert';
import 'location_service.dart';
import '../models/in_situ_sampling_data.dart';
import '../bluetooth/bluetooth_manager.dart';
import '../serial/serial_manager.dart';
import 'local_storage_service.dart';
import 'server_config_service.dart';
import 'zipping_service.dart';
import 'api_service.dart';
import 'submission_api_service.dart';
import 'submission_ftp_service.dart';
import 'telegram_service.dart';
/// A dedicated service to handle all business logic for the Marine In-Situ Sampling feature.
/// This includes location, image processing, device communication, and data submission.
class MarineInSituSamplingService {
// Business Logic Services
final LocationService _locationService = LocationService();
final BluetoothManager _bluetoothManager = BluetoothManager();
final SerialManager _serialManager = SerialManager();
// Submission & Utility Services
final SubmissionApiService _submissionApiService = SubmissionApiService();
final SubmissionFtpService _submissionFtpService = SubmissionFtpService();
final ZippingService _zippingService = ZippingService();
final LocalStorageService _localStorageService = LocalStorageService();
final ServerConfigService _serverConfigService = ServerConfigService();
final DatabaseHelper _dbHelper = DatabaseHelper();
// MODIFIED: Declare the service, but do not initialize it here.
final TelegramService _telegramService;
// ADDED: A constructor to accept the global TelegramService instance.
MarineInSituSamplingService(this._telegramService);
static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
// --- Location Services ---
Future<Position> getCurrentLocation() => _locationService.getCurrentLocation();
double calculateDistance(double lat1, double lon1, double lat2, double lon2) => _locationService.calculateDistance(lat1, lon1, lat2, lon2);
// --- Image Processing ---
Future<File?> pickAndProcessImage(ImageSource source, {
required InSituSamplingData data,
required String imageInfo,
bool isRequired = false,
}) async {
final picker = ImagePicker();
final XFile? photo = await picker.pickImage(source: source, imageQuality: 85, maxWidth: 1024);
if (photo == null) return null;
final bytes = await photo.readAsBytes();
img.Image? originalImage = img.decodeImage(bytes);
if (originalImage == null) return null;
if (isRequired && originalImage.height > originalImage.width) {
debugPrint("Image rejected: Must be in landscape orientation.");
return null;
}
final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}";
final font = img.arial24;
final textWidth = watermarkTimestamp.length * 12;
img.fillRect(originalImage, x1: 5, y1: 5, x2: textWidth + 15, y2: 35, color: img.ColorRgb8(255, 255, 255));
img.drawString(originalImage, watermarkTimestamp, font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0));
final tempDir = await getTemporaryDirectory();
final stationCode = data.selectedStation?['man_station_code'] ?? 'NA';
final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-');
final newFileName = "${stationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg";
final filePath = path.join(tempDir.path, newFileName);
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
}
// --- Device Connection (Delegated to Managers) ---
ValueNotifier<BluetoothConnectionState> get bluetoothConnectionState => _bluetoothManager.connectionState;
ValueNotifier<SerialConnectionState> get serialConnectionState => _serialManager.connectionState;
ValueNotifier<String?> get sondeId {
if (_bluetoothManager.connectionState.value != BluetoothConnectionState.disconnected) {
return _bluetoothManager.sondeId;
}
return _serialManager.sondeId;
}
Stream<Map<String, double>> get bluetoothDataStream => _bluetoothManager.dataStream;
Stream<Map<String, double>> get serialDataStream => _serialManager.dataStream;
String? get connectedBluetoothDeviceName => _bluetoothManager.connectedDeviceName.value;
String? get connectedSerialDeviceName => _serialManager.connectedDeviceName.value;
// --- Permissions ---
Future<bool> requestDevicePermissions() async {
Map<Permission, PermissionStatus> statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
Permission.locationWhenInUse,
].request();
if (statuses[Permission.bluetoothScan] == PermissionStatus.granted &&
statuses[Permission.bluetoothConnect] == PermissionStatus.granted) {
return true;
} else {
return false;
}
}
// --- Bluetooth Methods ---
Future<List<BluetoothDevice>> getPairedBluetoothDevices() => _bluetoothManager.getPairedDevices();
Future<void> connectToBluetoothDevice(BluetoothDevice device) => _bluetoothManager.connect(device);
void disconnectFromBluetooth() => _bluetoothManager.disconnect();
void startBluetoothAutoReading({Duration? interval}) => _bluetoothManager.startAutoReading(interval: interval ?? const Duration(seconds: 5));
void stopBluetoothAutoReading() => _bluetoothManager.stopAutoReading();
// --- USB Serial Methods ---
Future<List<UsbDevice>> getAvailableSerialDevices() => _serialManager.getAvailableDevices();
Future<bool> requestUsbPermission(UsbDevice device) async {
try {
return await platform.invokeMethod('requestUsbPermission', {'vid': device.vid, 'pid': device.pid}) ?? false;
} on PlatformException catch (e) {
debugPrint("Failed to request USB permission: '${e.message}'.");
return false;
}
}
Future<void> connectToSerialDevice(UsbDevice device) async {
final bool permissionGranted = await requestUsbPermission(device);
if (permissionGranted) {
await _serialManager.connect(device);
} else {
throw Exception("USB permission was not granted.");
}
}
void disconnectFromSerial() => _serialManager.disconnect();
void startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 5));
void stopSerialAutoReading() => _serialManager.stopAutoReading();
void dispose() {
_bluetoothManager.dispose();
_serialManager.dispose();
}
// --- Data Submission ---
Future<Map<String, dynamic>> submitInSituSample({
required InSituSamplingData data,
required List<Map<String, dynamic>>? appSettings,
}) async {
const String moduleName = 'marine_in_situ';
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
final imageFilesWithNulls = data.toApiImageFiles();
imageFilesWithNulls.removeWhere((key, value) => value == null);
final Map<String, File> finalImageFiles = imageFilesWithNulls.cast<String, File>();
// START CHANGE: Implement the correct two-step submission process.
// Step 1A: Submit form data as JSON.
debugPrint("Step 1A: Submitting In-Situ form data...");
final apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/manual/sample',
body: data.toApiFormData(),
);
// If the initial data submission fails, log and exit early.
if (apiDataResult['success'] != true) {
data.submissionStatus = 'L1';
data.submissionMessage = apiDataResult['message'] ?? 'Failed to submit form data.';
await _logAndSave(data: data, apiResults: [apiDataResult], ftpStatuses: [], serverName: serverName, finalImageFiles: finalImageFiles);
return {'success': false, 'message': data.submissionMessage};
}
final reportId = apiDataResult['data']?['man_id']?.toString();
if (reportId == null) {
data.submissionStatus = 'L1';
data.submissionMessage = 'API Error: Missing man_id in response.';
await _logAndSave(data: data, apiResults: [apiDataResult], ftpStatuses: [], serverName: serverName, finalImageFiles: finalImageFiles);
return {'success': false, 'message': data.submissionMessage};
}
data.reportId = reportId;
// Step 1B: Submit images as multipart/form-data.
debugPrint("Step 1B: Submitting In-Situ images...");
Map<String, dynamic> apiImageResult = {'success': true, 'message': 'No images to upload.'};
if (finalImageFiles.isNotEmpty) {
apiImageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName,
endpoint: 'marine/manual/images', // Assumed endpoint for uploadManualImages
fields: {'man_id': reportId},
files: finalImageFiles,
);
}
final bool apiSuccess = apiImageResult['success'] == true;
// END CHANGE
// Step 2: FTP Submission
final stationCode = data.selectedStation?['man_station_code'] ?? 'NA';
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
final baseFileName = '${stationCode}_$fileTimestamp';
final Directory? logDirectory = await _localStorageService.getLogDirectory(
serverName: serverName,
module: 'marine',
subModule: 'marine_in_situ_sampling',
);
final Directory? localSubmissionDir = logDirectory != null ? Directory(path.join(logDirectory.path, data.reportId ?? baseFileName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
await localSubmissionDir.create(recursive: true);
}
final dataZip = await _zippingService.createDataZip(
jsonDataMap: {'db.json': jsonEncode(data.toDbJson())},
baseFileName: baseFileName,
destinationDir: localSubmissionDir);
Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []};
if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit(
moduleName: moduleName,
fileToUpload: dataZip,
remotePath: '/${path.basename(dataZip.path)}');
}
final imageZip = await _zippingService.createImageZip(
imageFiles: finalImageFiles.values.toList(),
baseFileName: baseFileName,
destinationDir: localSubmissionDir);
Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []};
if (imageZip != null) {
ftpImageResult = await _submissionFtpService.submit(
moduleName: moduleName,
fileToUpload: imageZip,
remotePath: '/${path.basename(imageZip.path)}');
}
final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true);
// Step 3: Finalize and Log
String finalStatus;
String finalMessage;
if (apiSuccess) {
finalStatus = ftpSuccess ? 'S4' : 'S3';
finalMessage = ftpSuccess ? 'Data submitted successfully.' : 'Data sent to API. FTP upload failed/queued.';
} else {
finalStatus = ftpSuccess ? 'L4' : 'L1';
finalMessage = ftpSuccess ? 'API failed, but files sent to FTP.' : 'All submission attempts failed.';
}
data.submissionStatus = finalStatus;
data.submissionMessage = finalMessage;
await _logAndSave(
data: data,
apiResults: [apiDataResult, apiImageResult], // Log both API steps
ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']],
serverName: serverName,
finalImageFiles: finalImageFiles,
);
if (apiSuccess || ftpSuccess) {
_handleInSituSuccessAlert(data, appSettings, isDataOnly: !apiSuccess);
}
return {'success': apiSuccess || ftpSuccess, 'message': finalMessage};
}
// Helper function to centralize logging and local saving.
Future<void> _logAndSave({
required InSituSamplingData data,
required List<Map<String, dynamic>> apiResults,
required List<Map<String, dynamic>> ftpStatuses,
required String serverName,
required Map<String, File> finalImageFiles,
}) async {
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
await _localStorageService.saveInSituSamplingData(data, serverName: serverName);
final logData = {
'submission_id': data.reportId ?? fileTimestamp,
'module': 'marine',
'type': 'In-Situ',
'status': data.submissionStatus,
'message': data.submissionMessage,
'report_id': data.reportId,
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toDbJson()),
'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()),
'server_name': serverName,
'api_status': jsonEncode(apiResults),
'ftp_status': jsonEncode(ftpStatuses),
};
await _dbHelper.saveSubmissionLog(logData);
}
Future<void> _handleInSituSuccessAlert(InSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try {
final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly);
final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings);
if (!wasSent) {
await _telegramService.queueMessage('marine_in_situ', message, appSettings);
}
} catch (e) {
debugPrint("Failed to handle In-Situ Telegram alert: $e");
}
}
}

View File

@ -0,0 +1,194 @@
// lib/services/marine_tarball_sampling_service.dart
import 'dart:io';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:environment_monitoring_app/models/tarball_data.dart';
import 'package:environment_monitoring_app/services/local_storage_service.dart';
import 'package:environment_monitoring_app/services/server_config_service.dart';
import 'package:environment_monitoring_app/services/zipping_service.dart';
import 'package:environment_monitoring_app/services/api_service.dart';
import 'package:environment_monitoring_app/services/submission_api_service.dart';
import 'package:environment_monitoring_app/services/submission_ftp_service.dart';
import 'package:environment_monitoring_app/services/telegram_service.dart';
/// A dedicated service to handle all business logic for the Marine Tarball Sampling feature.
class MarineTarballSamplingService {
final SubmissionApiService _submissionApiService = SubmissionApiService();
final SubmissionFtpService _submissionFtpService = SubmissionFtpService();
final ZippingService _zippingService = ZippingService();
final LocalStorageService _localStorageService = LocalStorageService();
final ServerConfigService _serverConfigService = ServerConfigService();
final DatabaseHelper _dbHelper = DatabaseHelper();
// MODIFIED: Declare the service, but do not initialize it here.
final TelegramService _telegramService;
// ADDED: A constructor to accept the global TelegramService instance.
MarineTarballSamplingService(this._telegramService);
Future<Map<String, dynamic>> submitTarballSample({
required TarballSamplingData data,
required List<Map<String, dynamic>>? appSettings,
}) async {
const String moduleName = 'marine_tarball';
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
final imageFilesWithNulls = data.toImageFiles();
imageFilesWithNulls.removeWhere((key, value) => value == null);
final Map<String, File> finalImageFiles = imageFilesWithNulls.cast<String, File>();
// START CHANGE: Revert to the correct two-step API submission process
// --- Step 1A: API Data Submission ---
debugPrint("Step 1A: Submitting Tarball form data...");
final apiDataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'marine/tarball/sample',
body: data.toFormData(),
);
if (apiDataResult['success'] != true) {
// If the initial data submission fails, log and exit early.
await _logAndSave(data: data, status: 'L1', message: apiDataResult['message']!, apiResults: [apiDataResult], ftpStatuses: [], serverName: serverName, finalImageFiles: finalImageFiles);
return {'success': false, 'message': apiDataResult['message']};
}
final recordId = apiDataResult['data']?['autoid']?.toString();
if (recordId == null) {
await _logAndSave(data: data, status: 'L1', message: 'API Error: Missing record ID.', apiResults: [apiDataResult], ftpStatuses: [], serverName: serverName, finalImageFiles: finalImageFiles);
return {'success': false, 'message': 'API Error: Missing record ID.'};
}
data.reportId = recordId;
// --- Step 1B: API Image Submission ---
debugPrint("Step 1B: Submitting Tarball images...");
final apiImageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName,
endpoint: 'marine/tarball/images',
fields: {'autoid': recordId},
files: finalImageFiles,
);
final bool apiSuccess = apiImageResult['success'] == true;
// END CHANGE
// --- Step 2: FTP Submission ---
final stationCode = data.selectedStation?['tbl_station_code'] ?? 'NA';
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
final baseFileName = '${stationCode}_$fileTimestamp';
final Directory? logDirectory = await _localStorageService.getLogDirectory(
serverName: serverName,
module: 'marine',
subModule: 'marine_tarball_sampling',
);
final Directory? localSubmissionDir = logDirectory != null ? Directory(p.join(logDirectory.path, data.reportId ?? baseFileName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
await localSubmissionDir.create(recursive: true);
}
final dataZip = await _zippingService.createDataZip(
jsonDataMap: {'data.json': jsonEncode(data.toDbJson())},
baseFileName: baseFileName,
destinationDir: localSubmissionDir,
);
Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []};
if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit(
moduleName: moduleName,
fileToUpload: dataZip,
remotePath: '/${p.basename(dataZip.path)}',
);
}
final imageZip = await _zippingService.createImageZip(
imageFiles: finalImageFiles.values.toList(),
baseFileName: baseFileName,
destinationDir: localSubmissionDir,
);
Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []};
if (imageZip != null) {
ftpImageResult = await _submissionFtpService.submit(
moduleName: moduleName,
fileToUpload: imageZip,
remotePath: '/${p.basename(imageZip.path)}',
);
}
final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true);
// --- Step 3: Finalize and Log ---
String finalStatus;
String finalMessage;
if (apiSuccess) {
finalStatus = ftpSuccess ? 'S4' : 'S3';
finalMessage = ftpSuccess ? 'Data submitted successfully.' : 'Data sent to API. FTP upload failed/queued.';
} else {
finalStatus = ftpSuccess ? 'L4' : 'L1';
finalMessage = ftpSuccess ? 'API failed, but files sent to FTP.' : 'All submission attempts failed.';
}
await _logAndSave(
data: data,
status: finalStatus,
message: finalMessage,
apiResults: [apiDataResult, apiImageResult], // Log results from both API steps
ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']],
serverName: serverName,
finalImageFiles: finalImageFiles
);
if (apiSuccess || ftpSuccess) {
_handleTarballSuccessAlert(data, appSettings, isDataOnly: !apiSuccess);
}
return {'success': apiSuccess || ftpSuccess, 'message': finalMessage, 'reportId': data.reportId};
}
// Added a helper to reduce code duplication in the main submit method
Future<void> _logAndSave({
required TarballSamplingData data,
required String status,
required String message,
required List<Map<String, dynamic>> apiResults,
required List<Map<String, dynamic>> ftpStatuses,
required String serverName,
required Map<String, File> finalImageFiles,
}) async {
data.submissionStatus = status;
data.submissionMessage = message;
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
await _localStorageService.saveTarballSamplingData(data, serverName: serverName);
final logData = {
'submission_id': data.reportId ?? fileTimestamp,
'module': 'marine',
'type': 'Tarball',
'status': status,
'message': message,
'report_id': data.reportId,
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toDbJson()),
'image_data': jsonEncode(finalImageFiles.values.map((f) => f.path).toList()),
'server_name': serverName,
'api_status': jsonEncode(apiResults),
'ftp_status': jsonEncode(ftpStatuses),
};
await _dbHelper.saveSubmissionLog(logData);
}
Future<void> _handleTarballSuccessAlert(TarballSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try {
final message = data.generateTelegramAlertMessage(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");
}
}
}

View File

@ -6,6 +6,9 @@ import 'package:flutter/foundation.dart';
import 'package:environment_monitoring_app/services/api_service.dart';
import 'package:environment_monitoring_app/services/base_api_service.dart';
import 'package:environment_monitoring_app/services/ftp_service.dart';
// START CHANGE: Added imports to get server configurations
import 'package:environment_monitoring_app/services/server_config_service.dart';
// END CHANGE
/// A dedicated service to manage the queue of failed API and FTP requests
/// for manual resubmission.
@ -14,6 +17,9 @@ class RetryService {
final DatabaseHelper _dbHelper = DatabaseHelper();
final BaseApiService _baseApiService = BaseApiService();
final FtpService _ftpService = FtpService();
// START CHANGE: Add instance of ServerConfigService
final ServerConfigService _serverConfigService = ServerConfigService();
// END CHANGE
/// Adds a failed API request to the local database queue.
Future<void> addApiToQueue({
@ -81,20 +87,22 @@ class RetryService {
final endpoint = task['endpoint_or_path'] as String;
final method = payload['method'] as String;
debugPrint("Retrying API task $taskId: $method to $endpoint");
// START CHANGE: Fetch the current active base URL to perform the retry
final baseUrl = await _serverConfigService.getActiveApiUrl();
debugPrint("Retrying API task $taskId: $method to $baseUrl/$endpoint");
Map<String, dynamic> result;
if (method == 'POST_MULTIPART') {
// Reconstruct fields and files from the stored payload
final Map<String, String> fields = Map<String, String>.from(payload['fields'] ?? {});
final Map<String, File> files = (payload['files'] as Map<String, dynamic>?)
?.map((key, value) => MapEntry(key, File(value as String))) ?? {};
result = await _baseApiService.postMultipart(endpoint: endpoint, fields: fields, files: files);
result = await _baseApiService.postMultipart(baseUrl: baseUrl, endpoint: endpoint, fields: fields, files: files);
} else { // Assume 'POST'
final Map<String, dynamic> body = Map<String, dynamic>.from(payload['body'] ?? {});
result = await _baseApiService.post(endpoint, body);
result = await _baseApiService.post(baseUrl, endpoint, body);
}
// END CHANGE
success = result['success'];
@ -104,11 +112,26 @@ class RetryService {
debugPrint("Retrying FTP task $taskId: Uploading ${localFile.path} to $remotePath");
// Ensure the file still exists before attempting to re-upload
if (await localFile.exists()) {
final result = await _ftpService.uploadFile(localFile, remotePath);
// The FTP service already queues on failure, so we only care about success here.
success = result['success'];
// START CHANGE: On retry, attempt to upload to ALL available FTP servers.
final ftpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
if (ftpConfigs.isEmpty) {
debugPrint("Retry failed for FTP task $taskId: No FTP configurations found.");
return false;
}
for (final config in ftpConfigs) {
final result = await _ftpService.uploadFile(
config: config,
fileToUpload: localFile,
remotePath: remotePath
);
if (result['success']) {
success = true;
break; // Stop on the first successful upload
}
}
// END CHANGE
} else {
debugPrint("Retry failed for FTP task $taskId: Source file no longer exists at ${localFile.path}");
success = false;

View File

@ -1,151 +1,25 @@
// lib/services/river_api_service.dart
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
import 'package:flutter/foundation.dart';
import 'package:environment_monitoring_app/services/base_api_service.dart';
import 'package:environment_monitoring_app/services/telegram_service.dart';
// NOTE: RiverApiService still needs RiverInSituSamplingData import for its handle alert method,
// but since the model file wasn't provided directly, we assume it's correctly handled by the caller/context.
import 'package:environment_monitoring_app/services/server_config_service.dart';
class RiverApiService {
final BaseApiService _baseService;
final TelegramService _telegramService;
RiverApiService(this._baseService, this._telegramService);
final ServerConfigService _serverConfigService;
Future<Map<String, dynamic>> getManualStations() {
return _baseService.get('river/manual-stations');
RiverApiService(this._baseService, this._telegramService, this._serverConfigService);
Future<Map<String, dynamic>> getManualStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'river/manual-stations');
}
Future<Map<String, dynamic>> getTriennialStations() {
return _baseService.get('river/triennial-stations');
}
// MODIFIED: Method now returns granular status tracking for API and Images.
Future<Map<String, dynamic>> submitInSituSample({
required Map<String, String> formData,
required Map<String, File?> imageFiles,
required List<Map<String, dynamic>>? appSettings,
}) async {
Map<String, dynamic> finalResult = {
'success': false,
'status': 'L1', // Default: Local Failure (Data failed)
'api_status': 'NOT_ATTEMPTED',
'image_upload_status': 'NOT_ATTEMPTED',
'message': 'Submission failed.',
'reportId': null,
};
// --- Step 1: Submit Form Data as JSON ---
debugPrint("Step 1: Submitting River In-Situ form data...");
final dataResult = await _baseService.post('river/manual/sample', formData);
finalResult['api_status'] = dataResult['success'] == true ? 'SUCCESS' : 'FAILED';
if (dataResult['success'] != true) {
finalResult['message'] = dataResult['message'] ?? 'Failed to submit river in-situ data (API failed).';
return finalResult;
}
// Update status and reportId upon successful data submission
final recordId = dataResult['data']?['r_man_id'];
finalResult['reportId'] = recordId?.toString();
if (recordId == null) {
finalResult['api_status'] = 'FAILED';
finalResult['message'] = 'Data submitted, but server did not return a record ID.';
return finalResult;
}
final filesToUpload = <String, File>{};
imageFiles.forEach((key, value) {
if (value != null) filesToUpload[key] = value;
});
// --- Step 2: Upload Image Files ---
if (filesToUpload.isEmpty) {
debugPrint("No images to upload. Finalizing submission.");
finalResult['image_upload_status'] = 'NOT_REQUIRED';
// Final Status: S3 (Success, Data Only)
finalResult['success'] = true;
finalResult['status'] = 'S3';
finalResult['message'] = 'Data submitted successfully. No images were attached.';
_handleInSituSuccessAlert(formData, appSettings, isDataOnly: true);
return finalResult;
}
debugPrint("Step 2: Uploading ${filesToUpload.length} images...");
final imageResult = await _baseService.postMultipart(
endpoint: 'river/manual/images', // Separate endpoint for images
fields: {'r_man_id': recordId.toString()}, // Link images to the submitted record ID
files: filesToUpload,
);
finalResult['image_upload_status'] = imageResult['success'] == true ? 'SUCCESS' : 'FAILED';
if (imageResult['success'] != true) {
// Data submitted successfully, but images failed (L2/L4 equivalent)
finalResult['success'] = true; // API data transfer was still successful
finalResult['status'] = 'L2_PENDING_IMAGES';
finalResult['message'] = 'Data submitted, but image upload failed: ${imageResult['message']}';
_handleInSituSuccessAlert(formData, appSettings, isDataOnly: true); // Alert for data only
return finalResult;
}
// --- Step 3: Full Success ---
finalResult['success'] = true;
finalResult['status'] = 'S2'; // S2 means Data+Images submitted
finalResult['message'] = 'Data and images submitted successfully.';
_handleInSituSuccessAlert(formData, appSettings, isDataOnly: false);
return finalResult;
}
// MODIFIED: Method now requires appSettings and calls the updated TelegramService.
Future<void> _handleInSituSuccessAlert(Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = formData['r_man_station_name'] ?? 'N/A';
final stationCode = formData['r_man_station_code'] ?? 'N/A';
final submissionDate = formData['r_man_date'] ?? DateFormat('yyyy-MM-dd').format(DateTime.now());
final submitter = formData['first_sampler_name'] ?? 'N/A';
final sondeID = formData['r_man_sondeID'] ?? 'N/A';
final distanceKm = double.tryParse(formData['r_man_distance_difference'] ?? '0') ?? 0;
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
final distanceRemarks = formData['r_man_distance_difference_remarks'] ?? 'N/A';
final buffer = StringBuffer()
..writeln('✅ *River In-Situ Sample ${submissionType} Submitted:*')
..writeln()
..writeln('*Station Name & Code:* $stationName ($stationCode)')
..writeln('*Date of Submitted:* $submissionDate')
..writeln('*Submitted by User:* $submitter')
..writeln('*Sonde ID:* $sondeID')
..writeln('*Status of Submission:* Successful');
if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) {
buffer
..writeln()
..writeln('🔔 *Alert:*')
..writeln('*Distance from station:* $distanceMeters meters');
if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
buffer.writeln('*Remarks for distance:* $distanceRemarks');
}
}
final String message = buffer.toString();
// MODIFIED: Pass the appSettings list to the TelegramService methods.
final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings);
if (!wasSent) {
await _telegramService.queueMessage('river_in_situ', message, appSettings);
}
} catch (e) {
debugPrint("Failed to handle River Telegram alert: $e");
}
Future<Map<String, dynamic>> getTriennialStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'river/triennial-stations');
}
}

View File

@ -13,87 +13,87 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:usb_serial/usb_serial.dart';
import 'dart:convert';
import 'package:intl/intl.dart';
// CHANGED: Import river-specific services and models
import 'location_service.dart';
// REMOVED: import 'river_api_service.dart'; // Conflict: RiverApiService is defined here and in api_service.dart
import '../models/river_in_situ_sampling_data.dart';
import '../bluetooth/bluetooth_manager.dart';
import '../serial/serial_manager.dart';
// ADDED: Services needed for logging and configuration
import 'api_service.dart';
import 'local_storage_service.dart';
import 'server_config_service.dart';
// ADDED: DatabaseHelper import for local instantiation (as in your original code)
import 'api_service.dart'; // DatabaseHelper lives here, redundant but ensures access
import 'zipping_service.dart';
import 'submission_api_service.dart';
import 'submission_ftp_service.dart';
import 'telegram_service.dart';
/// A dedicated service to handle all business logic for the River In-Situ Sampling feature.
// CHANGED: Renamed class for the River In-Situ Sampling Service
class RiverInSituSamplingService {
final LocationService _locationService = LocationService();
// NOTE: RiverApiService type is defined in api_service.dart and used for DI
final RiverApiService _riverApiService;
final BluetoothManager _bluetoothManager = BluetoothManager();
final SerialManager _serialManager = SerialManager();
// ADDED: Instances for logging/configuration
final SubmissionApiService _submissionApiService = SubmissionApiService();
final SubmissionFtpService _submissionFtpService = SubmissionFtpService();
final DatabaseHelper _dbHelper = DatabaseHelper();
final LocalStorageService _localStorageService = LocalStorageService();
final ServerConfigService _serverConfigService = ServerConfigService();
final ZippingService _zippingService = ZippingService();
final TelegramService _telegramService;
final ImagePicker _picker = ImagePicker();
// This channel name MUST match the one defined in MainActivity.kt
static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
// FIX: Constructor requires RiverApiService for dependency injection
RiverInSituSamplingService(this._riverApiService);
RiverInSituSamplingService(this._telegramService);
// --- Location Services ---
Future<Position> getCurrentLocation() => _locationService.getCurrentLocation();
double calculateDistance(double lat1, double lon1, double lat2, double lon2) => _locationService.calculateDistance(lat1, lon1, lat2, lon2);
// --- Image Processing ---
Future<File?> pickAndProcessImage(ImageSource source, {
// CHANGED: Use the river-specific data model
required RiverInSituSamplingData data,
required String imageInfo,
bool isRequired = false,
String? stationCode, // Accept station code for naming
}) async {
final picker = ImagePicker();
final XFile? photo = await picker.pickImage(source: source, imageQuality: 85, maxWidth: 1024);
if (photo == null) return null;
Future<File?> pickAndProcessImage(ImageSource source, { required RiverInSituSamplingData data, required String imageInfo, bool isRequired = false, String? stationCode}) async {
try {
final XFile? pickedFile = await _picker.pickImage(
source: source,
imageQuality: 85,
maxWidth: 1024,
);
final bytes = await photo.readAsBytes();
img.Image? originalImage = img.decodeImage(bytes);
if (originalImage == null) return null;
if (pickedFile == null) {
return null;
}
if (isRequired && originalImage.height > originalImage.width) {
debugPrint("Image rejected: Must be in landscape orientation.");
final bytes = await pickedFile.readAsBytes();
img.Image? originalImage = img.decodeImage(bytes);
if (originalImage == null) {
return null;
}
if (isRequired && originalImage.height > originalImage.width) {
debugPrint("Image rejected: Must be in landscape orientation.");
return null;
}
final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}";
final font = img.arial24;
final textWidth = watermarkTimestamp.length * 12;
img.fillRect(originalImage, x1: 5, y1: 5, x2: textWidth + 15, y2: 35, color: img.ColorRgb8(255, 255, 255),);
img.drawString(originalImage, watermarkTimestamp, font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0));
final tempDir = await getTemporaryDirectory();
final finalStationCode = stationCode ?? 'NA';
final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-');
final newFileName = "${finalStationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg";
final filePath = path.join(tempDir.path, newFileName);
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
} catch (e) {
debugPrint('Error in pickAndProcessImage: $e');
return null;
}
final String watermarkTimestamp = "${data.samplingDate} ${data.samplingTime}";
final font = img.arial24;
final textWidth = watermarkTimestamp.length * 12;
img.fillRect(originalImage, x1: 5, y1: 5, x2: textWidth + 15, y2: 35, color: img.ColorRgb8(255, 255, 255));
img.drawString(originalImage, watermarkTimestamp, font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0));
final tempDir = await getTemporaryDirectory();
final finalStationCode = stationCode ?? 'NA';
final fileTimestamp = "${data.samplingDate}-${data.samplingTime}".replaceAll(':', '-');
final newFileName = "${finalStationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg";
final filePath = path.join(tempDir.path, newFileName);
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
}
// --- Device Connection (Delegated to Managers) ---
ValueNotifier<BluetoothConnectionState> get bluetoothConnectionState => _bluetoothManager.connectionState;
ValueNotifier<SerialConnectionState> get serialConnectionState => _serialManager.connectionState;
// This getter now dynamically returns the correct Sonde ID notifier
// based on the active connection, which is essential for the UI.
ValueNotifier<String?> get sondeId {
if (_bluetoothManager.connectionState.value != BluetoothConnectionState.disconnected) {
return _bluetoothManager.sondeId;
@ -103,11 +103,9 @@ class RiverInSituSamplingService {
Stream<Map<String, double>> get bluetoothDataStream => _bluetoothManager.dataStream;
Stream<Map<String, double>> get serialDataStream => _serialManager.dataStream;
String? get connectedBluetoothDeviceName => _bluetoothManager.connectedDeviceName.value;
String? get connectedSerialDeviceName => _serialManager.connectedDeviceName.value;
// --- Permissions ---
Future<bool> requestDevicePermissions() async {
Map<Permission, PermissionStatus> statuses = await [
Permission.bluetoothScan,
@ -115,7 +113,6 @@ class RiverInSituSamplingService {
Permission.locationWhenInUse,
].request();
// Return true only if the essential permissions are granted.
if (statuses[Permission.bluetoothScan] == PermissionStatus.granted &&
statuses[Permission.bluetoothConnect] == PermissionStatus.granted) {
return true;
@ -124,15 +121,11 @@ class RiverInSituSamplingService {
}
}
// --- Bluetooth Methods ---
Future<List<BluetoothDevice>> getPairedBluetoothDevices() => _bluetoothManager.getPairedDevices();
Future<void> connectToBluetoothDevice(BluetoothDevice device) => _bluetoothManager.connect(device);
void disconnectFromBluetooth() => _bluetoothManager.disconnect();
void startBluetoothAutoReading({Duration? interval}) => _bluetoothManager.startAutoReading(interval: interval ?? const Duration(seconds: 5));
void stopBluetoothAutoReading() => _bluetoothManager.stopAutoReading();
// --- USB Serial Methods ---
Future<List<UsbDevice>> getAvailableSerialDevices() => _serialManager.getAvailableDevices();
Future<bool> requestUsbPermission(UsbDevice device) async {
@ -156,130 +149,173 @@ class RiverInSituSamplingService {
void disconnectFromSerial() => _serialManager.disconnect();
void startSerialAutoReading({Duration? interval}) => _serialManager.startAutoReading(interval: interval ?? const Duration(seconds: 5));
void stopSerialAutoReading() => _serialManager.stopAutoReading();
void dispose() {
_bluetoothManager.dispose();
_serialManager.dispose();
}
// --- Data Submission ---
// MODIFIED: This method orchestrates submission, local saving, and logging.
Future<Map<String, dynamic>> submitData(RiverInSituSamplingData data, List<Map<String, dynamic>>? appSettings) async {
final formData = data.toApiFormData();
final imageFiles = data.toApiImageFiles();
const String moduleName = 'river_in_situ';
final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
// Get server name for logging
final activeConfig = await _serverConfigService.getActiveApiConfig();
final serverName = activeConfig?['config_name'] as String? ?? 'Default';
final imageFilesWithNulls = data.toApiImageFiles();
imageFilesWithNulls.removeWhere((key, value) => value == null);
final Map<String, File> finalImageFiles = imageFilesWithNulls.cast<String, File>();
// Get API/FTP configs for granular logging (assuming max 2 servers for each)
final apiConfigs = (await _dbHelper.loadApiConfigs() ?? []).take(2).toList();
final ftpConfigs = (await _dbHelper.loadFtpConfigs() ?? []).take(2).toList();
// 1. Attempt API Submission (Data + Images)
final apiResult = await _riverApiService.submitInSituSample(
formData: formData,
imageFiles: imageFiles,
appSettings: appSettings,
final dataResult = await _submissionApiService.submitPost(
moduleName: moduleName,
endpoint: 'river/manual/sample',
body: data.toApiFormData(),
);
final apiSuccess = apiResult['success'] == true;
final serverReportId = apiResult['reportId'];
// Determine granular API statuses (Simulation based on BaseApiService trying 2 servers)
List<Map<String, dynamic>> apiStatuses = [];
for (int i = 0; i < apiConfigs.length; i++) {
final config = apiConfigs[i];
String status;
String message;
if (apiSuccess && i == 0) {
status = "SUCCESS";
message = "Data posted successfully to primary API.";
} else if (apiSuccess && i > 0) {
status = "SUCCESS (Fallback)";
message = "Data posted successfully to fallback API.";
} else {
status = "FAILED";
message = apiResult['message'] ?? "Connection or server error.";
}
apiStatuses.add({
"server_name": config['config_name'],
"status": status,
"message": message,
});
if (dataResult['success'] != true) {
await _logAndSave(data: data, status: 'L1', message: dataResult['message']!, apiResults: [dataResult], ftpStatuses: [], serverName: serverName);
return {'success': false, 'message': dataResult['message']};
}
// 2. Determine FTP Status (Simulated based on configuration existence)
List<Map<String, dynamic>> ftpStatuses = [];
bool ftpQueueSuccess = false;
final recordId = dataResult['data']?['r_man_id']?.toString();
if (recordId == null) {
await _logAndSave(data: data, status: 'L1', message: 'API Error: Missing record ID.', apiResults: [dataResult], ftpStatuses: [], serverName: serverName);
return {'success': false, 'message': 'API Error: Missing record ID.'};
}
data.reportId = recordId;
if (ftpConfigs.isNotEmpty) {
// Assume zipping and queuing is successful here as separate service handles transfer
for (var config in ftpConfigs) {
ftpStatuses.add({
"server_name": config['config_name'],
"status": "QUEUED",
"message": "Files queued for transfer.",
});
}
ftpQueueSuccess = true;
} else {
ftpStatuses.add({
"server_name": "N/A",
"status": "NOT_CONFIGURED",
"message": "No FTP servers configured.",
});
Map<String, dynamic> imageResult = {'success': true, 'message': 'No images to upload.'};
if (finalImageFiles.isNotEmpty) {
imageResult = await _submissionApiService.submitMultipart(
moduleName: moduleName,
endpoint: 'river/manual/images',
fields: {'r_man_id': recordId},
files: finalImageFiles,
);
}
final bool apiSuccess = imageResult['success'] == true;
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'UNKNOWN';
final fileTimestamp = "${data.samplingDate}_${data.samplingTime}".replaceAll(':', '-').replaceAll(' ', '_');
final baseFileName = "${stationCode}_$fileTimestamp";
final Directory? logDirectory = await _localStorageService.getLogDirectory(
serverName: serverName,
module: 'river',
subModule: 'river_in_situ_sampling',
);
final Directory? localSubmissionDir = logDirectory != null ? Directory(path.join(logDirectory.path, data.reportId ?? baseFileName)) : null;
if (localSubmissionDir != null && !await localSubmissionDir.exists()) {
await localSubmissionDir.create(recursive: true);
}
// --- Step 3: Determine Final Status and Log to DB ---
final dataZip = await _zippingService.createDataZip(
jsonDataMap: {'db.json': data.toDbJson()},
baseFileName: baseFileName,
destinationDir: localSubmissionDir,
);
Map<String, dynamic> ftpDataResult = {'success': true, 'statuses': []};
if (dataZip != null) {
ftpDataResult = await _submissionFtpService.submit(
moduleName: moduleName, fileToUpload: dataZip, remotePath: '/${path.basename(dataZip.path)}');
}
final imageZip = await _zippingService.createImageZip(
imageFiles: finalImageFiles.values.toList(),
baseFileName: baseFileName,
destinationDir: localSubmissionDir,
);
Map<String, dynamic> ftpImageResult = {'success': true, 'statuses': []};
if (imageZip != null) {
ftpImageResult = await _submissionFtpService.submit(
moduleName: moduleName, fileToUpload: imageZip, remotePath: '/${path.basename(imageZip.path)}');
}
final bool ftpSuccess = (ftpDataResult['success'] == true && ftpImageResult['success'] == true);
String finalStatus;
String finalMessage;
if (apiSuccess && ftpQueueSuccess) {
finalStatus = 'S4'; // Submitted API, Queued FTP
finalMessage = 'Data submitted to API and files queued for FTP upload.';
} else if (apiSuccess) {
finalStatus = 'S3'; // Submitted API Only
finalMessage = 'Data submitted successfully to API. FTP queueing failed or not configured.';
} else if (ftpQueueSuccess) {
finalStatus = 'L4'; // Failed API, Queued FTP
finalMessage = 'API submission failed but files were successfully queued for FTP.';
if (apiSuccess) {
finalStatus = ftpSuccess ? 'S4' : 'S3';
finalMessage = ftpSuccess ? 'Data submitted successfully.' : 'Data sent to API. FTP upload failed/queued.';
} else {
finalStatus = 'L1'; // All submissions failed
finalMessage = 'All submission attempts failed. Data saved locally for retry.';
finalStatus = ftpSuccess ? 'L4' : 'L1';
finalMessage = ftpSuccess ? 'API failed, but files sent to FTP.' : 'All submission attempts failed.';
}
// FIX: Ensure submissionId is initialized, or get it from data
final String submissionId = data.reportId ?? DateTime.now().millisecondsSinceEpoch.toString();
await _logAndSave(
data: data,
status: finalStatus,
message: finalMessage,
apiResults: [dataResult, imageResult],
ftpStatuses: [...ftpDataResult['statuses'], ...ftpImageResult['statuses']],
serverName: serverName
);
// 4. Update data model and save to local storage
data.submissionStatus = finalStatus;
data.submissionMessage = finalMessage;
data.reportId = serverReportId;
if (apiSuccess || ftpSuccess) {
_handleSuccessAlert(data, appSettings, isDataOnly: !apiSuccess);
}
return {'success': apiSuccess || ftpSuccess, 'message': finalMessage};
}
Future<void> _logAndSave({
required RiverInSituSamplingData data,
required String status,
required String message,
required List<Map<String, dynamic>> apiResults,
required List<Map<String, dynamic>> ftpStatuses,
required String serverName,
}) async {
data.submissionStatus = status;
data.submissionMessage = message;
await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName);
// 5. Save submission status to Central DB Log
final imagePaths = data.toApiImageFiles().values.whereType<File>().map((f) => f.path).toList();
final logData = {
'submission_id': submissionId,
'module': 'river',
'type': data.samplingType ?? 'Others',
'status': finalStatus, // High-level status
'message': finalMessage,
'report_id': serverReportId,
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toMap()),
'image_data': jsonEncode(imageFiles.keys.map((key) => imageFiles[key]?.path).where((p) => p != null).toList()),
'server_name': serverName,
'api_status': jsonEncode(apiStatuses), // GRANULAR API STATUSES
'ftp_status': jsonEncode(ftpStatuses), // GRANULAR FTP STATUSES
'submission_id': data.reportId ?? DateTime.now().millisecondsSinceEpoch.toString(),
'module': 'river', 'type': data.samplingType ?? 'In-Situ', 'status': status,
'message': message, 'report_id': data.reportId, 'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(data.toMap()), 'image_data': jsonEncode(imagePaths),
'server_name': serverName, 'api_status': jsonEncode(apiResults), 'ftp_status': jsonEncode(ftpStatuses),
};
await _dbHelper.saveSubmissionLog(logData);
}
// 6. Return the final API result (which contains the granular statuses)
return apiResult;
Future<void> _handleSuccessAlert(RiverInSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
debugPrint("[DEBUG] appSettings passed to _handleSuccessAlert: ${jsonEncode(appSettings)}");
try {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = data.selectedStation?['sampling_river'] ?? 'N/A';
final stationCode = data.selectedStation?['sampling_station_code'] ?? 'N/A';
final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now());
final submitter = data.firstSamplerName ?? 'N/A';
final sondeID = data.sondeId ?? 'N/A';
final distanceKm = data.distanceDifferenceInKm ?? 0;
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
final distanceRemarks = data.distanceDifferenceRemarks ?? 'N/A';
final buffer = StringBuffer()
..writeln('✅ *River In-Situ Sample ${submissionType} Submitted:*')
..writeln()
..writeln('*Station Name & Code:* $stationName ($stationCode)')
..writeln('*Date of Submitted:* $submissionDate')
..writeln('*Submitted by User:* $submitter')
..writeln('*Sonde ID:* $sondeID')
..writeln('*Status of Submission:* Successful');
if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) {
buffer
..writeln()
..writeln('🔔 *Alert:*')
..writeln('*Distance from station:* $distanceMeters meters');
if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
buffer.writeln('*Remarks for distance:* $distanceRemarks');
}
}
final String message = buffer.toString();
final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings);
if (!wasSent) {
await _telegramService.queueMessage('river_in_situ', message, appSettings);
}
} catch (e) {
debugPrint("Failed to handle River Telegram alert: $e");
}
}
}

View File

@ -0,0 +1,109 @@
// lib/services/submission_api_service.dart
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:environment_monitoring_app/services/user_preferences_service.dart';
import 'package:environment_monitoring_app/services/base_api_service.dart';
import 'package:environment_monitoring_app/services/retry_service.dart';
/// A generic, reusable service for handling the entire API submission process.
/// It respects user preferences for enabled destinations for any given module.
class SubmissionApiService {
final UserPreferencesService _userPreferencesService = UserPreferencesService();
final BaseApiService _baseApiService = BaseApiService();
final RetryService _retryService = RetryService();
/// Submits a standard JSON POST request to all enabled destinations for a module.
///
/// Returns a success result if AT LEAST ONE destination succeeds.
/// If all destinations fail, it queues the request for manual retry and returns a failure result.
Future<Map<String, dynamic>> submitPost({
required String moduleName,
required String endpoint,
required Map<String, dynamic> body,
}) async {
final destinations = await _userPreferencesService.getEnabledApiConfigsForModule(moduleName);
if (destinations.isEmpty) {
debugPrint("SubmissionApiService: No enabled API destinations for module '$moduleName'. Skipping.");
return {'success': true, 'message': 'No API destinations enabled for this module.'};
}
for (final dest in destinations) {
final baseUrl = dest['api_url'] as String?;
if (baseUrl == null) {
debugPrint("SubmissionApiService: Skipping destination '${dest['config_name']}' due to missing URL.");
continue;
}
debugPrint("SubmissionApiService: Attempting to POST to '${dest['config_name']}' ($baseUrl)");
final result = await _baseApiService.post(baseUrl, endpoint, body);
if (result['success'] == true) {
debugPrint("SubmissionApiService: Successfully submitted to '${dest['config_name']}'.");
return result; // Return immediately on the first success
} else {
debugPrint("SubmissionApiService: Failed to submit to '${dest['config_name']}'. Trying next destination.");
}
}
// If the loop completes, it means all attempts failed.
debugPrint("SubmissionApiService: All API submission attempts for module '$moduleName' failed. Queuing for retry.");
await _retryService.addApiToQueue(
endpoint: endpoint,
method: 'POST',
body: body,
);
return {'success': false, 'message': 'All API attempts failed. Request has been queued for manual retry.'};
}
/// Submits a multipart (form data with files) request to all enabled destinations.
///
/// Follows the same logic as `submitPost`: succeeds on the first successful upload,
/// and queues for retry if all attempts fail.
Future<Map<String, dynamic>> submitMultipart({
required String moduleName,
required String endpoint,
required Map<String, String> fields,
required Map<String, File> files,
}) async {
final destinations = await _userPreferencesService.getEnabledApiConfigsForModule(moduleName);
if (destinations.isEmpty) {
debugPrint("SubmissionApiService: No enabled API destinations for module '$moduleName'. Skipping multipart upload.");
return {'success': true, 'message': 'No API destinations enabled for this module.'};
}
for (final dest in destinations) {
final baseUrl = dest['api_url'] as String?;
if (baseUrl == null) continue;
debugPrint("SubmissionApiService: Attempting multipart upload to '${dest['config_name']}' ($baseUrl)");
final result = await _baseApiService.postMultipart(
baseUrl: baseUrl,
endpoint: endpoint,
fields: fields,
files: files,
);
if (result['success'] == true) {
debugPrint("SubmissionApiService: Successfully uploaded to '${dest['config_name']}'.");
return result;
} else {
debugPrint("SubmissionApiService: Failed to upload to '${dest['config_name']}'. Trying next destination.");
}
}
debugPrint("SubmissionApiService: All multipart upload attempts for module '$moduleName' failed. Queuing for retry.");
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.'};
}
}

View File

@ -0,0 +1,80 @@
// lib/services/submission_ftp_service.dart
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:environment_monitoring_app/services/user_preferences_service.dart';
import 'package:environment_monitoring_app/services/ftp_service.dart';
import 'package:environment_monitoring_app/services/retry_service.dart';
/// A generic, reusable service for handling the FTP submission process.
/// It respects user preferences for enabled destinations for any given module.
class SubmissionFtpService {
final UserPreferencesService _userPreferencesService = UserPreferencesService();
final FtpService _ftpService = FtpService();
final RetryService _retryService = RetryService();
/// Submits a file to all enabled FTP destinations for a given module.
///
/// This method works differently from the API service. It attempts to upload
/// to ALL enabled destinations. It returns a summary of success/failure for each.
/// If any upload fails, it is queued for individual retry.
/// The overall result is considered successful if there are no hard errors
/// during the process, even if some uploads are queued.
Future<Map<String, dynamic>> submit({
required String moduleName,
required File fileToUpload,
required String remotePath,
}) async {
final destinations = await _userPreferencesService.getEnabledFtpConfigsForModule(moduleName);
if (destinations.isEmpty) {
debugPrint("SubmissionFtpService: No enabled FTP destinations for module '$moduleName'. Skipping.");
return {'success': true, 'message': 'No FTP destinations enabled for this module.'};
}
final List<Map<String, dynamic>> statuses = [];
bool allSucceeded = true;
for (final dest in destinations) {
final configName = dest['config_name'] as String? ?? 'Unknown FTP';
debugPrint("SubmissionFtpService: Attempting to upload to '$configName'");
final result = await _ftpService.uploadFile(
config: dest,
fileToUpload: fileToUpload,
remotePath: remotePath,
);
statuses.add({
'config_name': configName,
'success': result['success'],
'message': result['message'],
});
if (result['success'] != true) {
allSucceeded = false;
// If an individual upload fails, queue it for manual retry.
debugPrint("SubmissionFtpService: Upload to '$configName' failed. Queuing for retry.");
await _retryService.addFtpToQueue(
localFilePath: fileToUpload.path,
remotePath: remotePath,
);
}
}
if (allSucceeded) {
return {
'success': true,
'message': 'File successfully uploaded to all enabled FTP destinations.',
'statuses': statuses,
};
} else {
return {
'success': true, // The process itself succeeded, even if some uploads were queued.
'message': 'One or more FTP uploads failed and have been queued for retry.',
'statuses': statuses,
};
}
}
}

View File

@ -6,38 +6,40 @@ import 'package:environment_monitoring_app/services/api_service.dart';
import 'package:environment_monitoring_app/services/settings_service.dart';
class TelegramService {
// FIX: Change to a nullable, externally injected dependency.
ApiService? _apiService;
final DatabaseHelper _dbHelper = DatabaseHelper();
final SettingsService _settingsService = SettingsService();
// REMOVED: The SettingsService is no longer needed here as we will perform a direct lookup.
// final SettingsService _settingsService = SettingsService();
bool _isProcessing = false;
// FIX: Accept ApiService in the constructor to break the circular dependency at runtime.
TelegramService({ApiService? apiService}) : _apiService = apiService;
// FIX: Re-introduce the setter for circular injection (used in main.dart)
void setApiService(ApiService apiService) {
_apiService = apiService;
}
// MODIFIED: This method is now synchronous and requires the appSettings list.
// FIX: Replaced the brittle switch statement with a robust, generic lookup function.
// This function can now find the Chat ID for ANY module, including 'river_in_situ'.
String _getChatIdForModule(String module, List<Map<String, dynamic>>? appSettings) {
switch (module) {
case 'marine_in_situ':
return _settingsService.getInSituChatId(appSettings);
case 'marine_tarball':
return _settingsService.getTarballChatId(appSettings);
case 'air_manual': // ADDED THIS CASE
return _settingsService.getAirManualChatId(appSettings);
default:
return '';
if (appSettings == null) {
return '';
}
try {
final setting = appSettings.firstWhere(
(settingMap) =>
settingMap['module_name'] == module &&
settingMap['setting_key'] == 'telegram_chat_id'
);
return setting['setting_value'] as String? ?? '';
} catch (e) {
// This catch block handles cases where no matching setting is found in the list.
return '';
}
}
/// Tries to send an alert immediately over the network.
/// Returns `true` on success, `false` on failure.
// MODIFIED: This method now requires the appSettings list to be passed in.
Future<bool> sendAlertImmediately(String module, String message, List<Map<String, dynamic>>? appSettings) async {
debugPrint("[TelegramService] Attempting to send alert immediately for module: $module");
String chatId = _getChatIdForModule(module, appSettings);
@ -47,7 +49,6 @@ class TelegramService {
return false;
}
// FIX: Check for the injected ApiService
if (_apiService == null) {
debugPrint("[TelegramService] ❌ ApiService is not available.");
return false;
@ -68,7 +69,6 @@ class TelegramService {
}
/// Saves an alert to the local database queue. (This is now the fallback)
// MODIFIED: This method now requires the appSettings list to be passed in.
Future<void> queueMessage(String module, String message, List<Map<String, dynamic>>? appSettings) async {
String chatId = _getChatIdForModule(module, appSettings);
@ -91,7 +91,6 @@ class TelegramService {
}
/// Processes all pending alerts in the queue.
/// This method does NOT need changes because the chatId is already stored in the queue.
Future<void> processAlertQueue() async {
if (_isProcessing) {
debugPrint("[TelegramService] ⏳ Queue is already being processed. Skipping.");
@ -110,7 +109,6 @@ class TelegramService {
return;
}
// FIX: Check for ApiService before starting the loop
if (_apiService == null) {
debugPrint("[TelegramService] ❌ ApiService is not available for processing queue.");
_isProcessing = false;

View File

@ -0,0 +1,152 @@
// lib/services/user_preferences_service.dart
import 'package:flutter/foundation.dart';
import 'package:environment_monitoring_app/services/api_service.dart'; // Contains DatabaseHelper
/// A dedicated service to manage the user's local preferences for
/// module-specific submission destinations.
class UserPreferencesService {
final DatabaseHelper _dbHelper = DatabaseHelper();
/// Retrieves a module's master submission preferences.
/// If no preference has been saved for this module, it returns a default
/// where both API and FTP are enabled.
Future<Map<String, dynamic>> getModulePreference(String moduleName) async {
final preference = await _dbHelper.getModulePreference(moduleName);
if (preference != null) {
return preference;
}
// Return a default value if no preference is found in the database.
return {
'module_name': moduleName,
'is_api_enabled': true,
'is_ftp_enabled': true,
};
}
/// Saves or updates a module's master on/off switches for API and FTP submissions.
Future<void> saveModulePreference({
required String moduleName,
required bool isApiEnabled,
required bool isFtpEnabled,
}) async {
await _dbHelper.saveModulePreference(
moduleName: moduleName,
isApiEnabled: isApiEnabled,
isFtpEnabled: isFtpEnabled,
);
}
/// Retrieves all available API configurations and merges them with the user's
/// saved preferences for a specific module.
///
/// This is primarily for the Settings UI to display all possible destinations
/// with their current enabled/disabled state (e.g., checkboxes).
Future<List<Map<String, dynamic>>> getAllApiConfigsWithModulePreferences(String moduleName) async {
// 1. Get all possible API destinations that have been synced to the device.
final allApiConfigs = await _dbHelper.loadApiConfigs() ?? [];
if (allApiConfigs.isEmpty) return [];
// 2. Get the specific links the user has previously saved for this module.
final savedLinks = await _dbHelper.getAllApiLinksForModule(moduleName);
// 3. Merge the two lists.
return allApiConfigs.map((config) {
final configId = config['api_config_id'];
bool isEnabled = false; // Default to disabled
try {
// Find if a link exists for this config ID in the user's saved preferences.
final matchingLink = savedLinks.firstWhere(
(link) => link['api_config_id'] == configId,
// If no link is found, 'orElse' is not triggered, it throws.
);
isEnabled = matchingLink['is_enabled'] as bool? ?? false;
} catch (e) {
// A 'firstWhere' with no match throws an error. We catch it here.
// This means no link was saved for this config, so it remains disabled.
isEnabled = false;
}
// Return a new map containing the original config details plus the 'is_enabled' flag.
return {
...config,
'is_enabled': isEnabled,
};
}).toList();
}
/// Retrieves all available FTP configurations and merges them with the user's
/// saved preferences for a specific module. (For the Settings UI).
Future<List<Map<String, dynamic>>> getAllFtpConfigsWithModulePreferences(String moduleName) async {
final allFtpConfigs = await _dbHelper.loadFtpConfigs() ?? [];
if (allFtpConfigs.isEmpty) return [];
final savedLinks = await _dbHelper.getAllFtpLinksForModule(moduleName);
return allFtpConfigs.map((config) {
final configId = config['ftp_config_id'];
bool isEnabled = false;
try {
final matchingLink = savedLinks.firstWhere(
(link) => link['ftp_config_id'] == configId,
);
isEnabled = matchingLink['is_enabled'] as bool? ?? false;
} catch (e) {
isEnabled = false;
}
return {
...config,
'is_enabled': isEnabled,
};
}).toList();
}
/// Saves the complete set of enabled/disabled API links for a specific module.
/// This will replace all previous links for that module.
Future<void> saveApiLinksForModule(String moduleName, List<Map<String, dynamic>> links) async {
await _dbHelper.saveApiLinksForModule(moduleName, links);
}
/// Saves the complete set of enabled/disabled FTP links for a specific module.
Future<void> saveFtpLinksForModule(String moduleName, List<Map<String, dynamic>> links) async {
await _dbHelper.saveFtpLinksForModule(moduleName, links);
}
/// Retrieves only the API configurations that are actively enabled for a given module.
///
/// This is primarily for the submission services to know exactly which
/// destinations to send data to.
Future<List<Map<String, dynamic>>> getEnabledApiConfigsForModule(String moduleName) async {
// 1. Check the master switch for the module.
final pref = await getModulePreference(moduleName);
if (!(pref['is_api_enabled'] as bool)) {
debugPrint("API submissions are disabled for module '$moduleName' via master switch.");
return []; // Return empty list if API is globally disabled for this module.
}
// 2. Get all configs with their preference flags.
final allConfigsWithPrefs = await getAllApiConfigsWithModulePreferences(moduleName);
// 3. Filter for only those that are enabled.
final enabledConfigs = allConfigsWithPrefs.where((config) => config['is_enabled'] == true).toList();
debugPrint("Found ${enabledConfigs.length} enabled API destinations for module '$moduleName'.");
return enabledConfigs;
}
/// Retrieves only the FTP configurations that are actively enabled for a given module.
Future<List<Map<String, dynamic>>> getEnabledFtpConfigsForModule(String moduleName) async {
final pref = await getModulePreference(moduleName);
if (!(pref['is_ftp_enabled'] as bool)) {
debugPrint("FTP submissions are disabled for module '$moduleName' via master switch.");
return [];
}
final allConfigsWithPrefs = await getAllFtpConfigsWithModulePreferences(moduleName);
final enabledConfigs = allConfigsWithPrefs.where((config) => config['is_enabled'] == true).toList();
debugPrint("Found ${enabledConfigs.length} enabled FTP destinations for module '$moduleName'.");
return enabledConfigs;
}
}

View File

@ -12,10 +12,15 @@ class ZippingService {
Future<File?> createDataZip({
required Map<String, String> jsonDataMap,
required String baseFileName,
Directory? destinationDir,
}) async {
try {
final tempDir = await getTemporaryDirectory();
final zipFilePath = p.join(tempDir.path, '$baseFileName.zip');
final targetDir = destinationDir ?? await getTemporaryDirectory();
// Ensure the target directory exists before creating the file
if (!await targetDir.exists()) {
await targetDir.create(recursive: true);
}
final zipFilePath = p.join(targetDir.path, '$baseFileName.zip');
final encoder = ZipFileEncoder();
encoder.create(zipFilePath);
@ -45,6 +50,7 @@ class ZippingService {
Future<File?> createImageZip({
required List<File> imageFiles,
required String baseFileName,
Directory? destinationDir, // ADDED: New optional parameter
}) async {
if (imageFiles.isEmpty) {
debugPrint("No images provided to create an image ZIP.");
@ -52,8 +58,12 @@ class ZippingService {
}
try {
final tempDir = await getTemporaryDirectory();
final zipFilePath = p.join(tempDir.path, '${baseFileName}_img.zip');
final targetDir = destinationDir ?? await getTemporaryDirectory();
// Ensure the target directory exists before creating the file
if (!await targetDir.exists()) {
await targetDir.create(recursive: true);
}
final zipFilePath = p.join(targetDir.path, '${baseFileName}_img.zip');
final encoder = ZipFileEncoder();
encoder.create(zipFilePath);