configure database for river module and fix api and ftp transmission for river
This commit is contained in:
parent
3d74862576
commit
0d4d70cca6
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(() {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 ---
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
// }
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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.'};
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
323
lib/services/marine_in_situ_sampling_service.dart
Normal file
323
lib/services/marine_in_situ_sampling_service.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
194
lib/services/marine_tarball_sampling_service.dart
Normal file
194
lib/services/marine_tarball_sampling_service.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
109
lib/services/submission_api_service.dart
Normal file
109
lib/services/submission_api_service.dart
Normal 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.'};
|
||||
}
|
||||
}
|
||||
80
lib/services/submission_ftp_service.dart
Normal file
80
lib/services/submission_ftp_service.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
152
lib/services/user_preferences_service.dart
Normal file
152
lib/services/user_preferences_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user