From 0c37669725bc428b46cb62d9986b087d96536d75 Mon Sep 17 00:00:00 2001 From: ALim Aidrus Date: Mon, 25 Aug 2025 22:07:14 +0800 Subject: [PATCH] fix issue on submission using ftp and api. fix issue on data status log display for each module --- lib/auth_provider.dart | 20 +- lib/main.dart | 63 ++- lib/models/air_collection_data.dart | 44 +- lib/models/air_installation_data.dart | 32 +- lib/models/river_in_situ_sampling_data.dart | 65 ++- lib/screens/air/manual/data_status_log.dart | 414 ++++++++------- .../manual/widgets/air_manual_collection.dart | 2 +- .../widgets/air_manual_installation.dart | 5 + lib/screens/forgot_password.dart | 8 + lib/screens/login.dart | 9 +- lib/screens/logout.dart | 2 +- .../marine/manual/data_status_log.dart | 367 +++++++------ .../tarball_sampling_step3_summary.dart | 201 ++++++-- lib/screens/profile.dart | 25 +- lib/screens/register.dart | 9 +- lib/screens/river/manual/data_status_log.dart | 406 +++++++++------ .../river/manual/in_situ_sampling.dart | 136 ++--- lib/screens/settings.dart | 2 +- lib/services/air_sampling_service.dart | 488 +++++++++++++++--- lib/services/api_service.dart | 375 ++++++++++++-- lib/services/base_api_service.dart | 14 +- lib/services/local_storage_service.dart | 48 +- lib/services/river_api_service.dart | 91 ++-- .../river_in_situ_sampling_service.dart | 143 ++++- lib/services/server_config_service.dart | 2 +- lib/services/telegram_service.dart | 30 +- pubspec.lock | 2 +- pubspec.yaml | 2 + 28 files changed, 2068 insertions(+), 937 deletions(-) diff --git a/lib/auth_provider.dart b/lib/auth_provider.dart index 0e44ec4..0c90fb7 100644 --- a/lib/auth_provider.dart +++ b/lib/auth_provider.dart @@ -13,12 +13,13 @@ import 'package:environment_monitoring_app/services/retry_service.dart'; /// A comprehensive provider to manage user authentication, session state, /// and cached master data for offline use. class AuthProvider with ChangeNotifier { - final ApiService _apiService = ApiService(); - final DatabaseHelper _dbHelper = DatabaseHelper(); + // FIX: Change to late final and remove direct instantiation. + late final ApiService _apiService; + late final DatabaseHelper _dbHelper; // --- ADDED: Instance of the ServerConfigService to set the initial URL --- - final ServerConfigService _serverConfigService = ServerConfigService(); + late final ServerConfigService _serverConfigService; // --- ADDED: Instance of the RetryService to manage pending tasks --- - final RetryService _retryService = RetryService(); + late final RetryService _retryService; // --- Session & Profile State --- @@ -86,7 +87,16 @@ class AuthProvider with ChangeNotifier { static const String lastSyncTimestampKey = 'last_sync_timestamp'; static const String isFirstLoginKey = 'is_first_login'; - AuthProvider() { + // FIX: Constructor now accepts dependencies. + AuthProvider({ + required ApiService apiService, + required DatabaseHelper dbHelper, + required ServerConfigService serverConfigService, + required RetryService retryService, + }) : _apiService = apiService, + _dbHelper = dbHelper, + _serverConfigService = serverConfigService, + _retryService = retryService { debugPrint('AuthProvider: Initializing...'); _loadSessionAndSyncData(); } diff --git a/lib/main.dart b/lib/main.dart index 6bb9016..c978051 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,11 +6,17 @@ 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 import 'package:environment_monitoring_app/theme.dart'; import 'package:environment_monitoring_app/auth_provider.dart'; @@ -88,33 +94,56 @@ import 'package:environment_monitoring_app/screens/marine/manual/tarball_samplin import 'package:environment_monitoring_app/screens/marine/manual/tarball_sampling_step2.dart'; import 'package:environment_monitoring_app/screens/marine/manual/tarball_sampling_step3_summary.dart'; - void main() async { WidgetsFlutterBinding.ensureInitialized(); - setupServices(); + // 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: [ // The original AuthProvider - ChangeNotifierProvider(create: (_) => AuthProvider()), - // Provider for Local Storage Service + // 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 + retryService: RetryService(), + ), + ), + // Providers for core services + Provider(create: (_) => apiService), + Provider(create: (_) => databaseHelper), + Provider(create: (_) => telegramService), + // Providers for feature-specific services, with their dependencies correctly injected Provider(create: (_) => LocalStorageService()), - // Provider for the River In-Situ Sampling Service - Provider(create: (_) => RiverInSituSamplingService()), - // --- ADDED: Provider for the new AirSamplingService --- - Provider(create: (_) => AirSamplingService()), + 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 ], child: const RootApp(), ), ); } -void setupServices() { - final telegramService = TelegramService(); - +void setupServices(TelegramService telegramService) { Future.delayed(const Duration(seconds: 5), () { debugPrint("[Main] Performing initial alert queue processing on app start."); telegramService.processAlertQueue(); @@ -165,8 +194,11 @@ class RootApp extends StatelessWidget { return TarballSamplingStep3Summary(data: args); }); } - // NOTE: The River and Air In-Situ forms use an internal stepper, - // so they do not require onGenerateRoute logic for their steps. + if (settings.name == '/marine/manual/data-log') { + return MaterialPageRoute(builder: (context) { + return const marineManualDataStatusLog.MarineManualDataStatusLog(); + }); + } return null; }, routes: { @@ -207,12 +239,9 @@ class RootApp extends StatelessWidget { // River Manual '/river/manual/dashboard': (context) => RiverManualDashboard(), '/river/manual/in-situ': (context) => riverManualInSituSampling.RiverInSituSamplingScreen(), - - //'/river/manual/in-situ': (context) => riverManualInSituSampling.RiverInSituSampling(), '/river/manual/report': (context) => riverManualReport.RiverManualReport(), '/river/manual/triennial': (context) => riverManualTriennialSampling.RiverTriennialSampling(), - '/river/manual/data-log': (context) => riverManualDataStatusLog.RiverDataStatusLog(), - //'/river/manual/data-log': (context) => riverManualDataStatusLog.RiverManualDataStatusLog(), + '/river/manual/data-log': (context) => riverManualDataStatusLog.RiverManualDataStatusLog(), '/river/manual/image-request': (context) => riverManualImageRequest.RiverManualImageRequest(), // River Continuous diff --git a/lib/models/air_collection_data.dart b/lib/models/air_collection_data.dart index 7a66272..2ec8b28 100644 --- a/lib/models/air_collection_data.dart +++ b/lib/models/air_collection_data.dart @@ -1,3 +1,5 @@ +// lib/models/air_collection_data.dart + import 'dart:io'; import 'dart:convert'; // Added for jsonEncode import 'air_installation_data.dart'; @@ -148,16 +150,16 @@ class AirCollectionData { optionalRemark2: map['optionalRemark2'], optionalRemark3: map['optionalRemark3'], optionalRemark4: map['optionalRemark4'], - imageFront: fileFromPath(map['imageFrontPath']), - imageBack: fileFromPath(map['imageBackPath']), - imageLeft: fileFromPath(map['imageLeftPath']), - imageRight: fileFromPath(map['imageRightPath']), - imageChart: fileFromPath(map['imageChartPath']), - imageFilterPaper: fileFromPath(map['imageFilterPaperPath']), - optionalImage1: fileFromPath(map['optionalImage1Path']), - optionalImage2: fileFromPath(map['optionalImage2Path']), - optionalImage3: fileFromPath(map['optionalImage3Path']), - optionalImage4: fileFromPath(map['optionalImage4Path']), + imageFront: fileFromPath(map['imageFrontPath'] ?? map['imageFront']), // FIX: Check both path keys + imageBack: fileFromPath(map['imageBackPath'] ?? map['imageBack']), // FIX: Check both path keys + imageLeft: fileFromPath(map['imageLeftPath'] ?? map['imageLeft']), // FIX: Check both path keys + imageRight: fileFromPath(map['imageRightPath'] ?? map['imageRight']), // FIX: Check both path keys + imageChart: fileFromPath(map['imageChartPath'] ?? map['imageChart']), + imageFilterPaper: fileFromPath(map['imageFilterPaperPath'] ?? map['imageFilterPaper']), + optionalImage1: fileFromPath(map['optionalImage1Path'] ?? map['optionalImage1']), // FIX: Check both path keys + optionalImage2: fileFromPath(map['optionalImage2Path'] ?? map['optionalImage2']), // FIX: Check both path keys + optionalImage3: fileFromPath(map['optionalImage3Path'] ?? map['optionalImage3']), // FIX: Check both path keys + optionalImage4: fileFromPath(map['optionalImage4Path'] ?? map['optionalImage4']), // FIX: Check both path keys ); } @@ -191,16 +193,16 @@ class AirCollectionData { 'optionalRemark2': optionalRemark2, 'optionalRemark3': optionalRemark3, 'optionalRemark4': optionalRemark4, - 'imageFront': imageFront, - 'imageBack': imageBack, - 'imageLeft': imageLeft, - 'imageRight': imageRight, - 'imageChart': imageChart, - 'imageFilterPaper': imageFilterPaper, - 'optionalImage1': optionalImage1, - 'optionalImage2': optionalImage2, - 'optionalImage3': optionalImage3, - 'optionalImage4': optionalImage4, + 'imageFront': imageFront?.path, // Store path for log + 'imageBack': imageBack?.path, // Store path for log + 'imageLeft': imageLeft?.path, // Store path for log + 'imageRight': imageRight?.path, // Store path for log + 'imageChart': imageChart?.path, + 'imageFilterPaper': imageFilterPaper?.path, + 'optionalImage1': optionalImage1?.path, + 'optionalImage2': optionalImage2?.path, + 'optionalImage3': optionalImage3?.path, + 'optionalImage4': optionalImage4?.path, }; } @@ -216,7 +218,7 @@ class AirCollectionData { 'air_man_collection_pm10_flowrate': pm10Flowrate?.toString(), 'air_man_collection_pm10_flowrate_result': pm10FlowrateResult, 'air_man_collection_pm10_total_time': pm10TotalTime, - 'air_man_collection_total_time_result': pm10TotalTimeResult, + 'air_man_collection_pm10_total_time_result': pm10TotalTimeResult, 'air_man_collection_pm10_pressure': pm10Pressure?.toString(), 'air_man_collection_pm10_pressure_result': pm10PressureResult, 'air_man_collection_pm10_vstd': pm10Vstd?.toString(), diff --git a/lib/models/air_installation_data.dart b/lib/models/air_installation_data.dart index 7170906..7eac33c 100644 --- a/lib/models/air_installation_data.dart +++ b/lib/models/air_installation_data.dart @@ -132,14 +132,14 @@ class AirInstallationData { 'optionalRemark2': optionalRemark2, 'optionalRemark3': optionalRemark3, 'optionalRemark4': optionalRemark4, - 'imageFront': imageFront, - 'imageBack': imageBack, - 'imageLeft': imageLeft, - 'imageRight': imageRight, - 'optionalImage1': optionalImage1, - 'optionalImage2': optionalImage2, - 'optionalImage3': optionalImage3, - 'optionalImage4': optionalImage4, + 'imageFront': imageFront?.path, // Store path for log + 'imageBack': imageBack?.path, // Store path for log + 'imageLeft': imageLeft?.path, // Store path for log + 'imageRight': imageRight?.path, // Store path for log + 'optionalImage1': optionalImage1?.path, // Store path for log + 'optionalImage2': optionalImage2?.path, // Store path for log + 'optionalImage3': optionalImage3?.path, // Store path for log + 'optionalImage4': optionalImage4?.path, // Store path for log 'collectionData': collectionData?.toMap(), }; } @@ -167,14 +167,14 @@ class AirInstallationData { installationUserId: json['installationUserId'], installationUserName: json['installationUserName'], status: json['status'], - imageFront: fileFromPath(json['imageFrontPath']), - imageBack: fileFromPath(json['imageBackPath']), - imageLeft: fileFromPath(json['imageLeftPath']), - imageRight: fileFromPath(json['imageRightPath']), - optionalImage1: fileFromPath(json['optionalImage1Path']), - optionalImage2: fileFromPath(json['optionalImage2Path']), - optionalImage3: fileFromPath(json['optionalImage3Path']), - optionalImage4: fileFromPath(json['optionalImage4Path']), + imageFront: fileFromPath(json['imageFrontPath'] ?? json['imageFront']), // FIX: Check both path keys + imageBack: fileFromPath(json['imageBackPath'] ?? json['imageBack']), // FIX: Check both path keys + imageLeft: fileFromPath(json['imageLeftPath'] ?? json['imageLeft']), // FIX: Check both path keys + imageRight: fileFromPath(json['imageRightPath'] ?? json['imageRight']), // FIX: Check both path keys + optionalImage1: fileFromPath(json['optionalImage1Path'] ?? json['optionalImage1']), // FIX: Check both path keys + optionalImage2: fileFromPath(json['optionalImage2Path'] ?? json['optionalImage2']), // FIX: Check both path keys + optionalImage3: fileFromPath(json['optionalImage3Path'] ?? json['optionalImage3']), // FIX: Check both path keys + optionalImage4: fileFromPath(json['optionalImage4Path'] ?? json['optionalImage4']), // FIX: Check both path keys optionalRemark1: json['optionalRemark1'], optionalRemark2: json['optionalRemark2'], optionalRemark3: json['optionalRemark3'], diff --git a/lib/models/river_in_situ_sampling_data.dart b/lib/models/river_in_situ_sampling_data.dart index 6b5bd32..08de3a1 100644 --- a/lib/models/river_in_situ_sampling_data.dart +++ b/lib/models/river_in_situ_sampling_data.dart @@ -86,13 +86,13 @@ class RiverInSituSamplingData { return (path is String && path.isNotEmpty) ? File(path) : null; } + // FIX: Robust helper functions for parsing numerical values double? doubleFromJson(dynamic value) { if (value is num) return value.toDouble(); if (value is String) return double.tryParse(value); return null; } - // ADDED HELPER FUNCTION TO FIX THE ERROR int? intFromJson(dynamic value) { if (value is int) return value; if (value is String) return int.tryParse(value); @@ -123,6 +123,7 @@ class RiverInSituSamplingData { ..sondeId = json['r_man_sondeID'] ..dataCaptureDate = json['data_capture_date'] ..dataCaptureTime = json['data_capture_time'] + // FIX: Apply doubleFromJson helper to all numerical fields ..oxygenConcentration = doubleFromJson(json['r_man_oxygen_conc']) ..oxygenSaturation = doubleFromJson(json['r_man_oxygen_sat']) ..ph = doubleFromJson(json['r_man_ph']) @@ -133,6 +134,7 @@ class RiverInSituSamplingData { ..turbidity = doubleFromJson(json['r_man_turbidity']) ..tss = doubleFromJson(json['r_man_tss']) ..batteryVoltage = doubleFromJson(json['r_man_battery_volt']) + // END FIX ..optionalRemark1 = json['r_man_optional_photo_01_remarks'] ..optionalRemark2 = json['r_man_optional_photo_02_remarks'] ..optionalRemark3 = json['r_man_optional_photo_03_remarks'] @@ -147,6 +149,7 @@ class RiverInSituSamplingData { ..optionalImage4 = fileFromJson(json['r_man_optional_photo_04']) // ADDED: Flowrate fields from JSON ..flowrateMethod = json['r_man_flowrate_method'] + // FIX: Apply doubleFromJson helper to all new numerical flowrate fields ..flowrateSurfaceDrifterHeight = doubleFromJson(json['r_man_flowrate_sd_height']) ..flowrateSurfaceDrifterDistance = doubleFromJson(json['r_man_flowrate_sd_distance']) ..flowrateSurfaceDrifterTimeFirst = json['r_man_flowrate_sd_time_first'] @@ -236,7 +239,65 @@ class RiverInSituSamplingData { }; } - // --- ADDED: Methods to format data for FTP submission as separate JSON files --- + // ADDED: A new method to support the centralized submission logging + Map toMap() { + return { + 'firstSamplerName': firstSamplerName, + 'firstSamplerUserId': firstSamplerUserId, + 'secondSampler': secondSampler, + 'samplingDate': samplingDate, + 'samplingTime': samplingTime, + 'samplingType': samplingType, + 'sampleIdCode': sampleIdCode, + 'selectedStateName': selectedStateName, + 'selectedCategoryName': selectedCategoryName, + 'selectedStation': selectedStation, + 'stationLatitude': stationLatitude, + 'stationLongitude': stationLongitude, + 'currentLatitude': currentLatitude, + 'currentLongitude': currentLongitude, + 'distanceDifferenceInKm': distanceDifferenceInKm, + 'distanceDifferenceRemarks': distanceDifferenceRemarks, + 'weather': weather, + 'eventRemarks': eventRemarks, + 'labRemarks': labRemarks, + 'backgroundStationImage': backgroundStationImage?.path, + 'upstreamRiverImage': upstreamRiverImage?.path, + 'downstreamRiverImage': downstreamRiverImage?.path, + 'sampleTurbidityImage': sampleTurbidityImage?.path, + 'optionalImage1': optionalImage1?.path, + 'optionalRemark1': optionalRemark1, + 'optionalImage2': optionalImage2?.path, + 'optionalRemark2': optionalRemark2, + 'optionalImage3': optionalImage3?.path, + 'optionalRemark3': optionalRemark3, + 'optionalImage4': optionalImage4?.path, + 'optionalRemark4': optionalRemark4, + 'sondeId': sondeId, + 'dataCaptureDate': dataCaptureDate, + 'dataCaptureTime': dataCaptureTime, + 'oxygenConcentration': oxygenConcentration, + 'oxygenSaturation': oxygenSaturation, + 'ph': ph, + 'salinity': salinity, + 'electricalConductivity': electricalConductivity, + 'temperature': temperature, + 'tds': tds, + 'turbidity': turbidity, + 'tss': tss, + 'batteryVoltage': batteryVoltage, + 'flowrateMethod': flowrateMethod, + 'flowrateSurfaceDrifterHeight': flowrateSurfaceDrifterHeight, + 'flowrateSurfaceDrifterDistance': flowrateSurfaceDrifterDistance, + 'flowrateSurfaceDrifterTimeFirst': flowrateSurfaceDrifterTimeFirst, + 'flowrateSurfaceDrifterTimeLast': flowrateSurfaceDrifterTimeLast, + 'flowrateValue': flowrateValue, + 'submissionStatus': submissionStatus, + 'submissionMessage': submissionMessage, + 'reportId': reportId, + }; + } + /// Creates a single JSON object with all submission data, mimicking 'db.json' String toDbJson() { diff --git a/lib/screens/air/manual/data_status_log.dart b/lib/screens/air/manual/data_status_log.dart index 33403d9..db26741 100644 --- a/lib/screens/air/manual/data_status_log.dart +++ b/lib/screens/air/manual/data_status_log.dart @@ -1,16 +1,17 @@ +// lib/screens/air/manual/data_status_log.dart + import 'dart:io'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; +import 'package:environment_monitoring_app/auth_provider.dart'; +import 'package:environment_monitoring_app/models/air_installation_data.dart'; +import 'package:environment_monitoring_app/models/air_collection_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/air_sampling_service.dart'; +import 'dart:convert'; -import '../../../../auth_provider.dart'; -import '../../../../models/air_installation_data.dart'; -import '../../../../models/air_collection_data.dart'; -import '../../../../services/local_storage_service.dart'; -// --- MODIFIED: Import AirSamplingService to handle resubmissions correctly --- -import '../../../../services/air_sampling_service.dart'; - -// --- MODIFIED: Added serverName to the log entry model --- class SubmissionLogEntry { final String type; final String title; @@ -20,7 +21,9 @@ class SubmissionLogEntry { final String status; final String message; final Map rawData; - final String serverName; // ADDED + final String serverName; + final String? apiStatusRaw; + final String? ftpStatusRaw; bool isResubmitting; SubmissionLogEntry({ @@ -32,7 +35,9 @@ class SubmissionLogEntry { required this.status, required this.message, required this.rawData, - required this.serverName, // ADDED + required this.serverName, + this.apiStatusRaw, + this.ftpStatusRaw, this.isResubmitting = false, }); } @@ -46,14 +51,17 @@ class AirManualDataStatusLog extends StatefulWidget { class _AirManualDataStatusLogState extends State { final LocalStorageService _localStorageService = LocalStorageService(); - // --- MODIFIED: Use AirSamplingService for resubmission logic --- - final AirSamplingService _airSamplingService = AirSamplingService(); + late ApiService _apiService; + late AirSamplingService _airSamplingService; - // --- MODIFIED: Simplified state management to a single source of truth --- List _allLogs = []; - List _filteredLogs = []; + List _installationLogs = []; + List _collectionLogs = []; + List _filteredInstallationLogs = []; + List _filteredCollectionLogs = []; - final _searchController = TextEditingController(); + final TextEditingController _installationSearchController = TextEditingController(); + final TextEditingController _collectionSearchController = TextEditingController(); bool _isLoading = true; final Map _isResubmitting = {}; @@ -61,185 +69,152 @@ class _AirManualDataStatusLogState extends State { @override void initState() { super.initState(); - _searchController.addListener(_filterLogs); + _apiService = Provider.of(context, listen: false); + _airSamplingService = Provider.of(context, listen: false); + _installationSearchController.addListener(_filterLogs); + _collectionSearchController.addListener(_filterLogs); _loadAllLogs(); } @override void dispose() { - _searchController.dispose(); + _installationSearchController.dispose(); + _collectionSearchController.dispose(); super.dispose(); } Future _loadAllLogs() async { setState(() => _isLoading = true); + final airLogs = await _localStorageService.getAllAirSamplingLogs(); + final List tempInstallationLogs = []; + final List tempCollectionLogs = []; - final List tempLogs = []; + if (airLogs != null) { + for (var log in airLogs) { + if (log.containsKey('collectionData') && log['collectionData'] != null) { + // This is a collection log + final collectionData = log['collectionData']; + final String dateStr = collectionData['air_man_collection_date'] ?? ''; + final String timeStr = collectionData['air_man_collection_time'] ?? ''; - for (var log in airLogs) { - try { - final hasCollectionData = log['collectionData'] != null && (log['collectionData'] as Map).isNotEmpty; + tempCollectionLogs.add(SubmissionLogEntry( + type: 'Collection', + title: log['locationName'] ?? 'Unknown Location', + stationCode: log['stationID'] ?? 'N/A', + submissionDateTime: DateTime.tryParse('$dateStr $timeStr') ?? DateTime.now(), + reportId: collectionData['air_man_id']?.toString(), + status: collectionData['status'] ?? 'L3', + message: collectionData['submissionMessage'] ?? 'No status message.', + rawData: log, + serverName: log['serverConfigName'] ?? 'Unknown Server', + apiStatusRaw: collectionData['api_status'], + ftpStatusRaw: collectionData['ftp_status'], + )); + } else { + // This is an installation log + final String dateStr = log['installationDate'] ?? ''; + final String timeStr = log['installationTime'] ?? ''; - // Determine if it's an Installation or Collection log - final logType = hasCollectionData ? 'Collection' : 'Installation'; - - final stationInfo = log['stationInfo'] ?? {}; - final stationName = stationInfo['station_name'] ?? 'Station ${log['stationID'] ?? 'Unknown'}'; - final stationCode = stationInfo['station_code'] ?? log['stationID'] ?? 'N/A'; - - final submissionDateTime = logType == 'Installation' - ? _parseInstallationDateTime(log) - : _parseCollectionDateTime(log['collectionData']); - - final entry = SubmissionLogEntry( - type: logType, - title: stationName, - stationCode: stationCode, - submissionDateTime: submissionDateTime, - reportId: log['airManId']?.toString(), - status: log['status'] ?? 'L1', - message: _getStatusMessage(log), - rawData: log, - // --- MODIFIED: Extract the server name from the log data --- - serverName: log['serverConfigName'] ?? 'Unknown Server', - ); - - tempLogs.add(entry); - - } catch (e) { - debugPrint('Error processing log entry: $e'); + tempInstallationLogs.add(SubmissionLogEntry( + type: 'Installation', + title: log['locationName'] ?? 'Unknown Location', + stationCode: log['stationID'] ?? 'N/A', + submissionDateTime: DateTime.tryParse('$dateStr $timeStr') ?? DateTime.now(), + reportId: log['air_man_id']?.toString(), + status: log['status'] ?? 'L1', + message: log['submissionMessage'] ?? 'No status message.', + rawData: log, + serverName: log['serverConfigName'] ?? 'Unknown Server', + apiStatusRaw: log['api_status'], + ftpStatusRaw: log['ftp_status'], + )); + } } } - tempLogs.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + tempInstallationLogs.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + tempCollectionLogs.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); if (mounted) { setState(() { - _allLogs = tempLogs; + _installationLogs = tempInstallationLogs; + _collectionLogs = tempCollectionLogs; _isLoading = false; }); _filterLogs(); } } - DateTime _parseInstallationDateTime(Map log) { - try { - if (log['installationDate'] != null) { - final date = log['installationDate']; - final time = log['installationTime'] ?? '00:00'; - return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time'); - } - return DateTime.now(); - } catch (e) { - debugPrint('Error parsing installation date: $e'); - return DateTime.now(); - } - } - - DateTime _parseCollectionDateTime(Map? collectionData) { - try { - if (collectionData == null) return DateTime.now(); - final dateKey = 'collectionDate'; // Corrected key based on AirCollectionData model - final timeKey = 'collectionTime'; // Corrected key - - if (collectionData[dateKey] != null) { - final date = collectionData[dateKey]; - final time = collectionData[timeKey] ?? '00:00'; - return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time'); - } - return DateTime.now(); - } catch (e) { - debugPrint('Error parsing collection date: $e'); - return DateTime.now(); - } - } - - String _getStatusMessage(Map log) { - switch (log['status']) { - case 'S1': - case 'S2': - case 'S3': - return 'Successfully submitted to server'; - case 'L1': - case 'L3': - return 'Saved locally (pending submission)'; - case 'L2_PENDING_IMAGES': - case 'L4_PENDING_IMAGES': - return 'Partial submission (images failed)'; - default: - return 'Submission status unknown'; - } - } - void _filterLogs() { - final query = _searchController.text.toLowerCase(); + final installationQuery = _installationSearchController.text.toLowerCase(); + final collectionQuery = _collectionSearchController.text.toLowerCase(); + setState(() { - _filteredLogs = _allLogs.where((log) { - if (query.isEmpty) return true; - // --- MODIFIED: Add serverName to search criteria --- - return log.title.toLowerCase().contains(query) || - log.stationCode.toLowerCase().contains(query) || - log.serverName.toLowerCase().contains(query) || - (log.reportId?.toLowerCase() ?? '').contains(query); - }).toList(); + _filteredInstallationLogs = _installationLogs.where((log) => _logMatchesQuery(log, installationQuery)).toList(); + _filteredCollectionLogs = _collectionLogs.where((log) => _logMatchesQuery(log, collectionQuery)).toList(); }); } - // --- MODIFIED: Complete overhaul of the resubmission logic --- + bool _logMatchesQuery(SubmissionLogEntry log, String query) { + if (query.isEmpty) return true; + return log.title.toLowerCase().contains(query) || + log.stationCode.toLowerCase().contains(query) || + log.serverName.toLowerCase().contains(query) || + (log.reportId?.toLowerCase() ?? '').contains(query); + } + Future _resubmitData(SubmissionLogEntry log) async { - final logKey = log.rawData['refID']?.toString() ?? log.submissionDateTime.toIso8601String(); - if (mounted) setState(() => _isResubmitting[logKey] = true); + final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); + if (mounted) { + setState(() { + _isResubmitting[logKey] = true; + }); + } try { final authProvider = Provider.of(context, listen: false); final appSettings = authProvider.appSettings; - Map result; - - // Re-create the data models from the raw log data - final installationData = AirInstallationData.fromJson(log.rawData); + final logData = log.rawData; + Map result = {}; if (log.type == 'Installation') { - result = await _airSamplingService.submitInstallation(installationData, appSettings); - } else { - final collectionData = AirCollectionData.fromMap(log.rawData['collectionData']); - result = await _airSamplingService.submitCollection(collectionData, installationData, appSettings); + final dataToResubmit = AirInstallationData.fromJson(logData); + final result = await _airSamplingService.submitInstallation(dataToResubmit, appSettings); + // We only care about the high-level status here as granular status is handled by the service. + } else if (log.type == 'Collection') { + final installationData = AirInstallationData.fromJson(logData); + final collectionData = AirCollectionData.fromMap(logData['collectionData']); + final result = await _airSamplingService.submitCollection(collectionData, installationData, appSettings); } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(result['message'] ?? 'Resubmission complete.'), - backgroundColor: (result['status'] as String).startsWith('S') ? Colors.green : Colors.red, - ), + const SnackBar(content: Text('Resubmission successful!')), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Resubmission failed: $e'), backgroundColor: Colors.red), + SnackBar(content: Text('Resubmission failed: $e')), ); } } finally { if (mounted) { - setState(() => _isResubmitting.remove(logKey)); - await _loadAllLogs(); // Refresh the log list to show the updated status + setState(() { + _isResubmitting.remove(logKey); + }); + _loadAllLogs(); } } } @override Widget build(BuildContext context) { - // --- MODIFIED: Logic simplified to work with a single, comprehensive list --- - final logCategories = { - 'Installation': _filteredLogs.where((log) => log.type == 'Installation').toList(), - 'Collection': _filteredLogs.where((log) => log.type == 'Collection').toList(), - }; - final hasAnyLogs = _allLogs.isNotEmpty; - final hasFilteredLogs = _filteredLogs.isNotEmpty; + final hasAnyLogs = _installationLogs.isNotEmpty || _collectionLogs.isNotEmpty; return Scaffold( - appBar: AppBar(title: const Text('Air Sampling Status Log')), + appBar: AppBar(title: const Text('Air Manual Data Status Log')), body: _isLoading ? const Center(child: CircularProgressIndicator()) : RefreshIndicator( @@ -249,56 +224,56 @@ class _AirManualDataStatusLogState extends State { : ListView( padding: const EdgeInsets.all(8.0), children: [ - // General search bar for all logs - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Search by station, server, or ID...', - prefixIcon: const Icon(Icons.search, size: 20), - isDense: true, - border: const OutlineInputBorder(), - ), - ), - ), - ...logCategories.entries - .where((entry) => entry.value.isNotEmpty) - .map((entry) => _buildCategorySection(entry.key, entry.value)), - if (!hasFilteredLogs && hasAnyLogs) - const Center( - child: Padding( - padding: EdgeInsets.all(24.0), - child: Text('No logs match your search.'), - ), - ) + _buildCategorySection('Installation', _filteredInstallationLogs, _installationSearchController), + _buildCategorySection('Collection', _filteredCollectionLogs, _collectionSearchController), ], ), ), ); } - Widget _buildCategorySection(String category, List logs) { + Widget _buildCategorySection(String category, List logs, TextEditingController searchController) { return Card( - margin: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 8.0), + margin: const EdgeInsets.symmetric(vertical: 8.0), child: Padding( padding: const EdgeInsets.all(8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: TextField( + controller: searchController, + decoration: InputDecoration( + hintText: 'Search in $category...', + prefixIcon: const Icon(Icons.search, size: 20), + isDense: true, + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + searchController.clear(); + _filterLogs(); + }, + ), + ), + ), ), const Divider(), - ListView.builder( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: logs.length, - itemBuilder: (context, index) { - return _buildLogListItem(logs[index]); - }, - ), + if (logs.isEmpty) + const Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: Text('No logs match your search in this category.'))) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: logs.length, + itemBuilder: (context, index) { + return _buildLogListItem(logs[index]); + }, + ), ], ), ), @@ -306,28 +281,36 @@ class _AirManualDataStatusLogState extends State { } Widget _buildLogListItem(SubmissionLogEntry log) { - final isSuccess = log.status.startsWith('S'); - final logKey = log.rawData['refID']?.toString() ?? log.submissionDateTime.toIso8601String(); + final isFailed = !log.status.startsWith('S') && !log.status.startsWith('L4'); + final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); final isResubmitting = _isResubmitting[logKey] ?? false; - final title = '${log.title} (${log.stationCode})'; - // --- MODIFIED: Include the server name in the subtitle for clarity --- + + final titleWidget = RichText( + text: TextSpan( + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + children: [ + TextSpan(text: '${log.title} '), + TextSpan( + text: '(${log.stationCode})', + style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.normal), + ), + ], + ), + ); final subtitle = '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}'; return ExpansionTile( + key: PageStorageKey(logKey), leading: Icon( - isSuccess ? Icons.check_circle_outline : Icons.error_outline, - color: isSuccess ? Colors.green : Colors.red, + isFailed ? Icons.error_outline : Icons.check_circle_outline, + color: isFailed ? Colors.red : Colors.green, ), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + title: titleWidget, subtitle: Text(subtitle), - trailing: !isSuccess + trailing: isFailed ? (isResubmitting ? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 3)) - : IconButton( - icon: const Icon(Icons.sync, color: Colors.blue), - tooltip: 'Resubmit', - onPressed: () => _resubmitData(log), - )) + : IconButton(icon: const Icon(Icons.sync, color: Colors.blue), tooltip: 'Resubmit', onPressed: () => _resubmitData(log))) : null, children: [ Padding( @@ -335,11 +318,13 @@ class _AirManualDataStatusLogState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // --- MODIFIED: Add server name to the details view --- + _buildDetailRow('High-Level Status:', log.status), _buildDetailRow('Server:', log.serverName), _buildDetailRow('Report ID:', log.reportId ?? 'N/A'), - _buildDetailRow('Status:', log.message), _buildDetailRow('Submission Type:', log.type), + const Divider(height: 10), + _buildGranularStatus('API', log.apiStatusRaw), + _buildGranularStatus('FTP', log.ftpStatusRaw), ], ), ) @@ -347,16 +332,63 @@ class _AirManualDataStatusLogState extends State { ); } - Widget _buildDetailRow(String label, String value) { + Widget _buildGranularStatus(String type, String? jsonStatus) { + if (jsonStatus == null || jsonStatus.isEmpty) { + return Container(); + } + + List statuses; + try { + statuses = jsonDecode(jsonStatus); + } catch (_) { + return _buildDetailRow('$type Status:', jsonStatus!); + } + + if (statuses.isEmpty) { + return Container(); + } + return Padding( - padding: const EdgeInsets.symmetric(vertical: 2.0), - child: Row( + padding: const EdgeInsets.only(top: 8.0), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('$label ', style: const TextStyle(fontWeight: FontWeight.bold)), - Expanded(child: Text(value)), + Text('$type Status:', style: const TextStyle(fontWeight: FontWeight.bold)), + ...statuses.map((s) { + final serverName = s['server_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); + final Color color = isSuccess ? Colors.green : (status.toLowerCase().contains('failed') ? Colors.red : Colors.grey); + String detailLabel = (s['type'] != null) ? '(${s['type']})' : ''; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 8.0), + child: Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 5), + Expanded(child: Text('$serverName $detailLabel: $status')), + ], + ), + ); + }).toList(), ], ), ); } -} \ No newline at end of file + + Widget _buildDetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 2, child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold))), + const SizedBox(width: 8), + Expanded(flex: 3, child: Text(value)), + ], + ), + ); + } +} diff --git a/lib/screens/air/manual/widgets/air_manual_collection.dart b/lib/screens/air/manual/widgets/air_manual_collection.dart index 21ea33b..b86775a 100644 --- a/lib/screens/air/manual/widgets/air_manual_collection.dart +++ b/lib/screens/air/manual/widgets/air_manual_collection.dart @@ -500,4 +500,4 @@ class _AirManualCollectionWidgetState extends State { ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/air/manual/widgets/air_manual_installation.dart b/lib/screens/air/manual/widgets/air_manual_installation.dart index e7e65ff..4dac68c 100644 --- a/lib/screens/air/manual/widgets/air_manual_installation.dart +++ b/lib/screens/air/manual/widgets/air_manual_installation.dart @@ -9,6 +9,10 @@ import 'package:intl/intl.dart'; import '../../../../auth_provider.dart'; import '../../../../models/air_installation_data.dart'; import '../../../../services/air_sampling_service.dart'; +// --- ADDED: Import ApiService for dependency injection --- +import '../../../../services/api_service.dart'; +// --- REMOVED: Import of local_storage_service.dart as it is no longer used for logging --- +import '../../../../services/telegram_service.dart'; // Import TelegramService class AirManualInstallationWidget extends StatefulWidget { final AirInstallationData data; @@ -211,6 +215,7 @@ class _AirManualInstallationWidgetState extends State { @override Widget build(BuildContext context) { final auth = Provider.of(context); + // FIX: Retrieve ApiService from the Provider tree + final apiService = Provider.of(context, listen: false); return Scaffold( appBar: AppBar(title: Text("Forgot Password")), @@ -36,6 +39,11 @@ class _ForgotPasswordScreenState extends State { ElevatedButton( onPressed: () { if (_formKey.currentState!.validate()) { + // FIX: Use the retrieved ApiService instance for the post call via AuthProvider + // Note: AuthProvider's resetPassword method still uses its internal _apiService field. + // This is an architectural inconsistency (using Provider for one service but not others inside AuthProvider) + // but the immediate fix for this screen is to ensure the resetPassword method works. + // Since AuthProvider's resetPassword method uses its internal, injected _apiService, we can call it directly. auth.resetPassword(email); setState(() => message = "Reset link sent to $email"); } diff --git a/lib/screens/login.dart b/lib/screens/login.dart index 0663b19..fad1776 100644 --- a/lib/screens/login.dart +++ b/lib/screens/login.dart @@ -17,7 +17,7 @@ class _LoginScreenState extends State { final _formKey = GlobalKey(); final TextEditingController _emailController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); - final ApiService _apiService = ApiService(); + // FIX: Removed direct instantiation of ApiService bool _isLoading = false; String _errorMessage = ''; @@ -39,6 +39,9 @@ class _LoginScreenState extends State { }); final auth = Provider.of(context, listen: false); + // FIX: Retrieve ApiService from the Provider tree + final apiService = Provider.of(context, listen: false); + // --- Offline Check for First Login --- if (auth.isFirstLogin) { @@ -55,7 +58,7 @@ class _LoginScreenState extends State { } // --- API Call --- - final Map result = await _apiService.login( + final Map result = await apiService.login( // FIX: Use retrieved instance _emailController.text.trim(), _passwordController.text.trim(), ); @@ -192,4 +195,4 @@ class _LoginScreenState extends State { ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/logout.dart b/lib/screens/logout.dart index e538d26..3728284 100644 --- a/lib/screens/logout.dart +++ b/lib/screens/logout.dart @@ -59,4 +59,4 @@ class LogoutScreen extends StatelessWidget { ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/marine/manual/data_status_log.dart b/lib/screens/marine/manual/data_status_log.dart index dd2e8a3..55a45c0 100644 --- a/lib/screens/marine/manual/data_status_log.dart +++ b/lib/screens/marine/manual/data_status_log.dart @@ -1,24 +1,30 @@ +// lib/screens/marine/manual/data_status_log.dart + import 'dart:io'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; // Added for accessing AuthProvider -import 'package:environment_monitoring_app/auth_provider.dart'; // Added for AuthProvider type +import 'package:provider/provider.dart'; +import 'package:environment_monitoring_app/auth_provider.dart'; import 'package:environment_monitoring_app/models/tarball_data.dart'; import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart'; import 'package:environment_monitoring_app/services/local_storage_service.dart'; -import 'package:environment_monitoring_app/services/marine_api_service.dart'; +import 'package:environment_monitoring_app/services/api_service.dart'; +import 'package:environment_monitoring_app/services/in_situ_sampling_service.dart'; +import 'dart:convert'; -// --- MODIFIED: Added serverName to the log entry model --- +/// 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 title; final String stationCode; final DateTime submissionDateTime; final String? reportId; - final String status; + final String status; // High-level status (S3, L1, etc.) final String message; final Map rawData; - final String serverName; // ADDED + final String serverName; + final String? apiStatusRaw; + final String? ftpStatusRaw; bool isResubmitting; SubmissionLogEntry({ @@ -30,7 +36,9 @@ class SubmissionLogEntry { required this.status, required this.message, required this.rawData, - required this.serverName, // ADDED + required this.serverName, + this.apiStatusRaw, + this.ftpStatusRaw, this.isResubmitting = false, }); } @@ -44,18 +52,17 @@ class MarineManualDataStatusLog extends StatefulWidget { class _MarineManualDataStatusLogState extends State { final LocalStorageService _localStorageService = LocalStorageService(); - final MarineApiService _marineApiService = MarineApiService(); + late ApiService _apiService; + late InSituSamplingService _marineInSituService; - // Raw data lists + List _allLogs = []; List _manualLogs = []; List _tarballLogs = []; - - // Filtered lists for the UI List _filteredManualLogs = []; List _filteredTarballLogs = []; - // Per-category search controllers - final Map _searchControllers = {}; + final TextEditingController _manualSearchController = TextEditingController(); + final TextEditingController _tarballSearchController = TextEditingController(); bool _isLoading = true; final Map _isResubmitting = {}; @@ -63,15 +70,17 @@ class _MarineManualDataStatusLogState extends State { @override void initState() { super.initState(); - _searchControllers['Manual Sampling'] = TextEditingController()..addListener(_filterLogs); - _searchControllers['Tarball Sampling'] = TextEditingController()..addListener(_filterLogs); + _apiService = Provider.of(context, listen: false); + _marineInSituService = Provider.of(context, listen: false); + _manualSearchController.addListener(_filterLogs); + _tarballSearchController.addListener(_filterLogs); _loadAllLogs(); } @override void dispose() { - _searchControllers['Manual Sampling']?.dispose(); - _searchControllers['Tarball Sampling']?.dispose(); + _manualSearchController.dispose(); + _tarballSearchController.dispose(); super.dispose(); } @@ -84,7 +93,6 @@ class _MarineManualDataStatusLogState extends State { final List tempManual = []; final List tempTarball = []; - // Map In-Situ logs to Manual Sampling 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'] ?? ''; @@ -98,12 +106,12 @@ class _MarineManualDataStatusLogState extends State { status: log['submissionStatus'] ?? 'L1', message: log['submissionMessage'] ?? 'No status message.', rawData: log, - // --- MODIFIED: Extract the server name from the log data --- serverName: log['serverConfigName'] ?? 'Unknown Server', + apiStatusRaw: log['api_status'], + ftpStatusRaw: log['ftp_status'], )); } - // Map Tarball logs for (var log in tarballLogs) { tempTarball.add(SubmissionLogEntry( type: 'Tarball Sampling', @@ -114,8 +122,9 @@ class _MarineManualDataStatusLogState extends State { status: log['submissionStatus'] ?? 'L1', message: log['submissionMessage'] ?? 'No status message.', rawData: log, - // --- MODIFIED: Extract the server name from the log data --- serverName: log['serverConfigName'] ?? 'Unknown Server', + apiStatusRaw: log['api_status'], + ftpStatusRaw: log['ftp_status'], )); } @@ -128,13 +137,13 @@ class _MarineManualDataStatusLogState extends State { _tarballLogs = tempTarball; _isLoading = false; }); - _filterLogs(); // Perform initial filter + _filterLogs(); } } void _filterLogs() { - final manualQuery = _searchControllers['Manual Sampling']?.text.toLowerCase() ?? ''; - final tarballQuery = _searchControllers['Tarball Sampling']?.text.toLowerCase() ?? ''; + final manualQuery = _manualSearchController.text.toLowerCase(); + final tarballQuery = _tarballSearchController.text.toLowerCase(); setState(() { _filteredManualLogs = _manualLogs.where((log) => _logMatchesQuery(log, manualQuery)).toList(); @@ -144,14 +153,12 @@ class _MarineManualDataStatusLogState extends State { bool _logMatchesQuery(SubmissionLogEntry log, String query) { if (query.isEmpty) return true; - // --- MODIFIED: Add serverName to search criteria --- return log.title.toLowerCase().contains(query) || log.stationCode.toLowerCase().contains(query) || log.serverName.toLowerCase().contains(query) || (log.reportId?.toLowerCase() ?? '').contains(query); } - // MODIFIED: This method now fetches appSettings from AuthProvider before resubmitting. Future _resubmitData(SubmissionLogEntry log) async { final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); if (mounted) { @@ -161,23 +168,84 @@ class _MarineManualDataStatusLogState extends State { } try { - // Get the appSettings from the AuthProvider to pass to the API service. final authProvider = Provider.of(context, listen: false); final appSettings = authProvider.appSettings; - final result = await _performResubmission(log, appSettings); final logData = log.rawData; - - logData['submissionStatus'] = result['status']; - logData['submissionMessage'] = result['message']; - logData['reportId'] = result['reportId']?.toString() ?? logData['reportId']; + Map result = {}; if (log.type == 'Manual Sampling') { - await _localStorageService.updateInSituLog(logData); + final dataToResubmit = InSituSamplingData.fromJson(logData); + final Map 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, + appSettings: appSettings, + ); } else if (log.type == 'Tarball Sampling') { - await _localStorageService.updateTarballLog(logData); + // 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() ?? ''); + + 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(); + + final Map 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, + appSettings: appSettings, + ); } + 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!')), @@ -194,119 +262,17 @@ class _MarineManualDataStatusLogState extends State { setState(() { _isResubmitting.remove(logKey); }); - await _loadAllLogs(); + _loadAllLogs(); } } } - // MODIFIED: This method now requires appSettings to pass to the API service. - Future> _performResubmission(SubmissionLogEntry log, List>? appSettings) async { - final logData = log.rawData; - - if (log.type == 'Manual Sampling') { - final InSituSamplingData dataToResubmit = InSituSamplingData() - ..firstSamplerUserId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? '') - ..secondSampler = logData['secondSampler'] - ..samplingDate = logData['sampling_date'] - ..samplingTime = logData['sampling_time'] - ..samplingType = logData['sampling_type'] - ..sampleIdCode = logData['sample_id_code'] - ..selectedStation = logData['selectedStation'] - ..currentLatitude = logData['current_latitude']?.toString() - ..currentLongitude = logData['current_longitude']?.toString() - ..distanceDifferenceInKm = double.tryParse(logData['distance_difference']?.toString() ?? '0.0') - ..weather = logData['weather'] - ..tideLevel = logData['tide_level'] - ..seaCondition = logData['sea_condition'] - ..eventRemarks = logData['event_remarks'] - ..labRemarks = logData['lab_remarks'] - ..optionalRemark1 = logData['optional_photo_remark_1'] - ..optionalRemark2 = logData['optional_photo_remark_2'] - ..optionalRemark3 = logData['optional_photo_remark_3'] - ..optionalRemark4 = logData['optional_photo_remark_4'] - ..sondeId = logData['sonde_id'] - ..dataCaptureDate = logData['data_capture_date'] - ..dataCaptureTime = logData['data_capture_time'] - ..oxygenConcentration = double.tryParse(logData['oxygen_concentration_mg_l']?.toString() ?? '0.0') - ..oxygenSaturation = double.tryParse(logData['oxygen_saturation_percent']?.toString() ?? '0.0') - ..ph = double.tryParse(logData['ph']?.toString() ?? '0.0') - ..salinity = double.tryParse(logData['salinity_ppt']?.toString() ?? '0.0') - ..electricalConductivity = double.tryParse(logData['ec_us_cm']?.toString() ?? '0.0') - ..temperature = double.tryParse(logData['temperature_c']?.toString() ?? '0.0') - ..tds = double.tryParse(logData['tds_mg_l']?.toString() ?? '0.0') - ..turbidity = double.tryParse(logData['turbidity_ntu']?.toString() ?? '0.0') - ..tss = double.tryParse(logData['tss_mg_l']?.toString() ?? '0.0') - ..batteryVoltage = double.tryParse(logData['battery_v']?.toString() ?? '0.0'); - - final Map imageFiles = {}; - final imageKeys = dataToResubmit.toApiImageFiles().keys; - for (var key in imageKeys) { - final imagePath = logData[key]; - if (imagePath is String && imagePath.isNotEmpty) { - final file = File(imagePath); - if (await file.exists()) { - imageFiles[key] = file; - } - } - } - - return _marineApiService.submitInSituSample( - formData: dataToResubmit.toApiFormData(), - imageFiles: imageFiles, - inSituData: dataToResubmit, - appSettings: appSettings, // Added this required parameter - ); - } else if (log.type == 'Tarball Sampling') { - final int? firstSamplerId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? ''); - final int? classificationId = int.tryParse(logData['classification_id']?.toString() ?? ''); - - final TarballSamplingData dataToResubmit = TarballSamplingData() - ..selectedStation = logData['selectedStation'] - ..samplingDate = logData['sampling_date'] - ..samplingTime = logData['sampling_time'] - ..firstSamplerUserId = firstSamplerId - ..secondSampler = logData['secondSampler'] - ..classificationId = classificationId - ..currentLatitude = logData['current_latitude']?.toString() - ..currentLongitude = logData['current_longitude']?.toString() - ..distanceDifference = logData['distance_difference'] - ..optionalRemark1 = logData['optional_photo_remark_01'] - ..optionalRemark2 = logData['optional_photo_remark_02'] - ..optionalRemark3 = logData['optional_photo_remark_03'] - ..optionalRemark4 = logData['optional_photo_remark_04']; - - final Map imageFiles = {}; - final imageKeys = ['left_side_coastal_view', 'right_side_coastal_view', 'drawing_vertical_lines', 'drawing_horizontal_line', 'optional_photo_01', 'optional_photo_02', 'optional_photo_03', 'optional_photo_04']; - for (var key in imageKeys) { - final imagePath = logData[key]; - if (imagePath != null && imagePath.isNotEmpty) { - final file = File(imagePath); - if (await file.exists()) imageFiles[key] = file; - } - } - - return _marineApiService.submitTarballSample( - formData: dataToResubmit.toFormData(), - imageFiles: imageFiles, - appSettings: appSettings, // Added this required parameter - ); - } - - throw Exception('Unknown submission type: ${log.type}'); - } - @override Widget build(BuildContext context) { final hasAnyLogs = _manualLogs.isNotEmpty || _tarballLogs.isNotEmpty; - final hasFilteredLogs = _filteredManualLogs.isNotEmpty || _filteredTarballLogs.isNotEmpty; - - final logCategories = { - 'Manual Sampling': _filteredManualLogs, - 'Tarball Sampling': _filteredTarballLogs, - }; return Scaffold( - appBar: AppBar(title: const Text('Marine Data Status Log')), + appBar: AppBar(title: const Text('Marine Manual Data Status Log')), body: _isLoading ? const Center(child: CircularProgressIndicator()) : RefreshIndicator( @@ -316,25 +282,15 @@ class _MarineManualDataStatusLogState extends State { : ListView( padding: const EdgeInsets.all(8.0), children: [ - ...logCategories.entries - .where((entry) => entry.value.isNotEmpty) - .map((entry) => _buildCategorySection(entry.key, entry.value)), - if (!hasFilteredLogs && hasAnyLogs) - const Center( - child: Padding( - padding: EdgeInsets.all(24.0), - child: Text('No logs match your search.'), - ), - ) + _buildCategorySection('Manual Sampling', _filteredManualLogs, _manualSearchController), + _buildCategorySection('Tarball Sampling', _filteredTarballLogs, _tarballSearchController), ], ), ), ); } - Widget _buildCategorySection(String category, List logs) { - final listHeight = (logs.length > 5 ? 5.5 : logs.length.toDouble()) * 75.0; - + Widget _buildCategorySection(String category, List logs, TextEditingController searchController) { return Card( margin: const EdgeInsets.symmetric(vertical: 8.0), child: Padding( @@ -346,30 +302,36 @@ class _MarineManualDataStatusLogState extends State { Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: TextField( - controller: _searchControllers[category], + controller: searchController, decoration: InputDecoration( hintText: 'Search in $category...', prefixIcon: const Icon(Icons.search, size: 20), isDense: true, border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + searchController.clear(); + _filterLogs(); + }, + ), ), ), ), const Divider(), - logs.isEmpty - ? const Padding( - padding: EdgeInsets.all(16.0), - child: Center(child: Text('No logs match your search in this category.'))) - : ConstrainedBox( - constraints: BoxConstraints(maxHeight: listHeight), - child: ListView.builder( + if (logs.isEmpty) + const Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: Text('No logs match your search in this category.'))) + else + ListView.builder( shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), itemCount: logs.length, itemBuilder: (context, index) { return _buildLogListItem(logs[index]); }, ), - ), ], ), ), @@ -377,19 +339,31 @@ class _MarineManualDataStatusLogState extends State { } Widget _buildLogListItem(SubmissionLogEntry log) { - final isFailed = log.status != 'L3'; + final isFailed = !log.status.startsWith('S') && !log.status.startsWith('L4'); final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); final isResubmitting = _isResubmitting[logKey] ?? false; - final title = '${log.title} (${log.stationCode})'; - // --- MODIFIED: Include the server name in the subtitle for clarity --- + + final titleWidget = RichText( + text: TextSpan( + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + children: [ + TextSpan(text: '${log.title} '), + TextSpan( + text: '(${log.stationCode})', + style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.normal), + ), + ], + ), + ); final subtitle = '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}'; return ExpansionTile( + key: PageStorageKey(logKey), leading: Icon( isFailed ? Icons.error_outline : Icons.check_circle_outline, color: isFailed ? Colors.red : Colors.green, ), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + title: titleWidget, subtitle: Text(subtitle), trailing: isFailed ? (isResubmitting @@ -402,11 +376,13 @@ class _MarineManualDataStatusLogState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // --- MODIFIED: Add server name to the details view --- + _buildDetailRow('High-Level Status:', log.status), _buildDetailRow('Server:', log.serverName), _buildDetailRow('Report ID:', log.reportId ?? 'N/A'), - _buildDetailRow('Status:', log.message), _buildDetailRow('Submission Type:', log.type), + const Divider(height: 10), + _buildGranularStatus('API', log.apiStatusRaw), + _buildGranularStatus('FTP', log.ftpStatusRaw), ], ), ) @@ -414,16 +390,63 @@ class _MarineManualDataStatusLogState extends State { ); } - Widget _buildDetailRow(String label, String value) { + Widget _buildGranularStatus(String type, String? jsonStatus) { + if (jsonStatus == null || jsonStatus.isEmpty) { + return Container(); + } + + List statuses; + try { + statuses = jsonDecode(jsonStatus); + } catch (_) { + return _buildDetailRow('$type Status:', jsonStatus!); + } + + if (statuses.isEmpty) { + return Container(); + } + return Padding( - padding: const EdgeInsets.symmetric(vertical: 2.0), - child: Row( + padding: const EdgeInsets.only(top: 8.0), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('$label ', style: const TextStyle(fontWeight: FontWeight.bold)), - Expanded(child: Text(value)), + Text('$type Status:', style: const TextStyle(fontWeight: FontWeight.bold)), + ...statuses.map((s) { + final serverName = s['server_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); + final Color color = isSuccess ? Colors.green : (status.toLowerCase().contains('failed') ? Colors.red : Colors.grey); + String detailLabel = (s['type'] != null) ? '(${s['type']})' : ''; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 8.0), + child: Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 5), + Expanded(child: Text('$serverName $detailLabel: $status')), + ], + ), + ); + }).toList(), ], ), ); } -} \ No newline at end of file + + Widget _buildDetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 2, child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold))), + const SizedBox(width: 8), + Expanded(flex: 3, child: Text(value)), + ], + ), + ); + } +} diff --git a/lib/screens/marine/manual/tarball_sampling_step3_summary.dart b/lib/screens/marine/manual/tarball_sampling_step3_summary.dart index 06fb1fd..2c4f841 100644 --- a/lib/screens/marine/manual/tarball_sampling_step3_summary.dart +++ b/lib/screens/marine/manual/tarball_sampling_step3_summary.dart @@ -26,8 +26,7 @@ class TarballSamplingStep3Summary extends StatefulWidget { } class _TarballSamplingStep3SummaryState extends State { - // CORRECTED: Use the main ApiService to access its marine property. - final ApiService _apiService = ApiService(); + // FIX: Removed direct instantiation of ApiService. final LocalStorageService _localStorageService = LocalStorageService(); // --- ADDED: Service to get the active server configuration --- final ServerConfigService _serverConfigService = ServerConfigService(); @@ -43,45 +42,77 @@ class _TarballSamplingStep3SummaryState extends State _isLoading = true); final authProvider = Provider.of(context, listen: false); + // FIX: Retrieve ApiService from the Provider tree + final apiService = Provider.of(context, listen: false); + final appSettings = authProvider.appSettings; final activeApiConfig = await _serverConfigService.getActiveApiConfig(); final serverName = activeApiConfig?['config_name'] as String? ?? 'Default'; - // Get all FTP configs from the database and limit them to the latest 2. + // 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(); // Create a temporary, separate copy of the data for the FTP process final dataForFtp = widget.data; bool apiSuccess = false; - bool ftpSuccess = false; + bool ftpQueueSuccess = false; + List> apiStatuses = []; + List> 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..."); - try { - final apiResult = await _apiService.marine.submitTarballSample( - formData: widget.data.toFormData(), - imageFiles: widget.data.toImageFiles(), - appSettings: appSettings, - ); - apiSuccess = apiResult['success'] == true; - widget.data.submissionStatus = apiResult['status']; - widget.data.submissionMessage = apiResult['message']; - widget.data.reportId = apiResult['reportId']?.toString(); - debugPrint("API submission successful."); - } catch (e) { - debugPrint("API submission failed with a critical error: $e"); - apiSuccess = false; + final apiResult = await apiService.marine.submitTarballSample( // FIX: Use retrieved ApiService + formData: widget.data.toFormData(), + imageFiles: widget.data.toImageFiles(), + 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 if configurations exist --- + // --- 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 = dataForFtp.reportId ?? DateTime.now().millisecondsSinceEpoch; + 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 jsonDataMap = { 'db.json': jsonEncode(dataForFtp.toDbJson()), @@ -99,50 +130,116 @@ class _TarballSamplingStep3SummaryState extends State 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); + + setState(() => _isLoading = false); final message = widget.data.submissionMessage ?? 'An unknown error occurred.'; - final color = (apiSuccess || ftpSuccess) ? Colors.green : Colors.red; + final color = (apiSuccess || ftpQueueSuccess) ? Colors.green : Colors.red; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)), ); diff --git a/lib/screens/profile.dart b/lib/screens/profile.dart index e14b6d6..a294deb 100644 --- a/lib/screens/profile.dart +++ b/lib/screens/profile.dart @@ -16,20 +16,27 @@ class ProfileScreen extends StatefulWidget { } class _ProfileScreenState extends State { - final ApiService _apiService = ApiService(); + // FIX: Removed direct instantiation of ApiService bool _isLoading = false; String _errorMessage = ''; File? _profileImageFile; + // FIX: Use late initialization to retrieve the service instance. + late ApiService _apiService; + @override void initState() { super.initState(); - // Load the image from cache first, then refresh data from the provider - _loadLocalProfileImage().then((_) { - // If no profile data is available at all, trigger a refresh - if (Provider.of(context, listen: false).profileData == null) { - _refreshProfile(); - } + + // FIX: Retrieve the ApiService instance after the context is fully available. + WidgetsBinding.instance.addPostFrameCallback((_) { + _apiService = Provider.of(context, listen: false); + _loadLocalProfileImage().then((_) { + // If no profile data is available at all, trigger a refresh + if (Provider.of(context, listen: false).profileData == null) { + _refreshProfile(); + } + }); }); } @@ -76,6 +83,7 @@ class _ProfileScreenState extends State { if (mounted) setState(() => _profileImageFile = localFile); } else { final String fullImageUrl = ApiService.imageBaseUrl + serverImagePath; + // FIX: Use the injected _apiService instance final downloadedFile = await _apiService.downloadProfilePicture(fullImageUrl, localFilePath); if (downloadedFile != null && mounted) { setState(() => _profileImageFile = downloadedFile); @@ -127,6 +135,7 @@ class _ProfileScreenState extends State { setState(() => _isLoading = true); final File imageFile = File(pickedFile.path); + // FIX: Use the injected _apiService instance final uploadResult = await _apiService.uploadProfilePicture(imageFile); if (mounted) { @@ -354,4 +363,4 @@ class _ProfileScreenState extends State { ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/register.dart b/lib/screens/register.dart index 22151c5..06ffac4 100644 --- a/lib/screens/register.dart +++ b/lib/screens/register.dart @@ -15,7 +15,7 @@ class RegisterScreen extends StatefulWidget { class _RegisterScreenState extends State { final _formKey = GlobalKey(); - final ApiService _apiService = ApiService(); + // FIX: Removed direct instantiation of ApiService bool _isLoading = false; String _errorMessage = ''; @@ -55,6 +55,9 @@ class _RegisterScreenState extends State { _errorMessage = ''; }); + // FIX: Retrieve ApiService from the Provider tree + final apiService = Provider.of(context, listen: false); + final connectivityResult = await Connectivity().checkConnectivity(); if (connectivityResult == ConnectivityResult.none) { if (!mounted) return; @@ -66,7 +69,7 @@ class _RegisterScreenState extends State { return; } - final result = await _apiService.register( + final result = await apiService.register( // FIX: Use retrieved instance username: _usernameController.text.trim(), firstName: _firstNameController.text.trim(), lastName: _lastNameController.text.trim(), @@ -221,4 +224,4 @@ class _RegisterScreenState extends State { ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/river/manual/data_status_log.dart b/lib/screens/river/manual/data_status_log.dart index cb046b2..3f4c1b3 100644 --- a/lib/screens/river/manual/data_status_log.dart +++ b/lib/screens/river/manual/data_status_log.dart @@ -1,12 +1,15 @@ +// lib/screens/river/manual/data_status_log.dart + import 'dart:io'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; // ADDED: Import for Provider -import 'package:environment_monitoring_app/auth_provider.dart'; // ADDED: Import for AuthProvider - -import '../../../../models/river_in_situ_sampling_data.dart'; -import '../../../../services/local_storage_service.dart'; -import '../../../../services/river_api_service.dart'; +import 'package:provider/provider.dart'; +import 'package:environment_monitoring_app/auth_provider.dart'; +import 'package:environment_monitoring_app/models/river_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/river_in_situ_sampling_service.dart'; +import 'dart:convert'; class SubmissionLogEntry { final String type; @@ -17,6 +20,9 @@ class SubmissionLogEntry { final String status; final String message; final Map rawData; + final String serverName; + final String? apiStatusRaw; + final String? ftpStatusRaw; bool isResubmitting; SubmissionLogEntry({ @@ -28,51 +34,43 @@ class SubmissionLogEntry { required this.status, required this.message, required this.rawData, + required this.serverName, + this.apiStatusRaw, + this.ftpStatusRaw, this.isResubmitting = false, }); } -class RiverDataStatusLog extends StatefulWidget { - const RiverDataStatusLog({super.key}); +class RiverManualDataStatusLog extends StatefulWidget { + const RiverManualDataStatusLog({super.key}); @override - State createState() => _RiverDataStatusLogState(); + State createState() => _RiverManualDataStatusLogState(); } -class _RiverDataStatusLogState extends State { +class _RiverManualDataStatusLogState extends State { final LocalStorageService _localStorageService = LocalStorageService(); - final RiverApiService _riverApiService = RiverApiService(); - - // Raw data lists - List _scheduleLogs = []; - List _triennialLogs = []; - List _otherLogs = []; - - // Filtered lists for the UI - List _filteredScheduleLogs = []; - List _filteredTriennialLogs = []; - List _filteredOtherLogs = []; - - // Per-category search controllers - final Map _searchControllers = {}; + late ApiService _apiService; + late RiverInSituSamplingService _riverInSituService; + List _allLogs = []; + List _filteredLogs = []; + final TextEditingController _searchController = TextEditingController(); bool _isLoading = true; final Map _isResubmitting = {}; @override void initState() { super.initState(); - _searchControllers['Schedule'] = TextEditingController()..addListener(_filterLogs); - _searchControllers['Triennial'] = TextEditingController()..addListener(_filterLogs); - _searchControllers['Others'] = TextEditingController()..addListener(_filterLogs); + _apiService = Provider.of(context, listen: false); + _riverInSituService = Provider.of(context, listen: false); + _searchController.addListener(_filterLogs); _loadAllLogs(); } @override void dispose() { - _searchControllers['Schedule']?.dispose(); - _searchControllers['Triennial']?.dispose(); - _searchControllers['Others']?.dispose(); + _searchController.dispose(); super.dispose(); } @@ -80,60 +78,83 @@ class _RiverDataStatusLogState extends State { setState(() => _isLoading = true); final riverLogs = await _localStorageService.getAllRiverInSituLogs(); + final List tempLogs = []; - final List tempSchedule = []; - final List tempTriennial = []; - final List tempOthers = []; - - for (var log in riverLogs) { - final entry = SubmissionLogEntry( - type: log['r_man_type'] as String? ?? 'Others', - title: log['selectedStation']?['sampling_river'] ?? 'Unknown Station', - stationCode: log['selectedStation']?['sampling_station_code'] ?? 'N/A', - submissionDateTime: DateTime.tryParse('${log['r_man_date']} ${log['r_man_time']}') ?? DateTime.now(), - reportId: log['reportId']?.toString(), - status: log['submissionStatus'] ?? 'L1', - message: log['submissionMessage'] ?? 'No status message.', - rawData: log, - ); - - switch (entry.type) { - case 'Schedule': - tempSchedule.add(entry); - break; - case 'Triennial': - tempTriennial.add(entry); - break; - default: - tempOthers.add(entry); - break; + if (riverLogs != null) { + for (var log in riverLogs) { + final entry = _createLogEntry(log); + if (entry != null) { + tempLogs.add(entry); + } } } - tempSchedule.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); - tempTriennial.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); - tempOthers.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); - + tempLogs.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); if (mounted) { setState(() { - _scheduleLogs = tempSchedule; - _triennialLogs = tempTriennial; - _otherLogs = tempOthers; + _allLogs = tempLogs; _isLoading = false; }); - _filterLogs(); // Perform initial filter + _filterLogs(); } } - void _filterLogs() { - final scheduleQuery = _searchControllers['Schedule']?.text.toLowerCase() ?? ''; - final triennialQuery = _searchControllers['Triennial']?.text.toLowerCase() ?? ''; - final otherQuery = _searchControllers['Others']?.text.toLowerCase() ?? ''; + SubmissionLogEntry? _createLogEntry(Map log) { + String? type; + String? title; + String? stationCode; + DateTime submissionDateTime = DateTime.now(); + String? dateStr; + String? timeStr; + if (log.containsKey('samplingType')) { + type = log['samplingType'] ?? 'In-Situ Sampling'; + title = log['selectedStation']?['sampling_river'] ?? 'Unknown River'; + stationCode = log['selectedStation']?['sampling_station_code'] ?? 'N/A'; + dateStr = log['r_man_date'] ?? log['samplingDate'] ?? ''; + timeStr = log['r_man_time'] ?? log['samplingTime'] ?? ''; + } else { + return null; + } + + // FIX: Safely parse date and time by providing default values + try { + final String fullDateString = '$dateStr ${timeStr!.length == 5 ? timeStr + ':00' : timeStr}'; + submissionDateTime = DateTime.tryParse(fullDateString) ?? DateTime.now(); + } catch (_) { + submissionDateTime = DateTime.now(); + } + + // FIX: Safely handle apiStatusRaw and ftpStatusRaw to prevent null access + String? apiStatusRaw; + if (log['api_status'] != null) { + apiStatusRaw = log['api_status'] is String ? log['api_status'] : jsonEncode(log['api_status']); + } + + String? ftpStatusRaw; + if (log['ftp_status'] != null) { + ftpStatusRaw = log['ftp_status'] is String ? log['ftp_status'] : jsonEncode(log['ftp_status']); + } + + return SubmissionLogEntry( + type: type!, + title: title!, + stationCode: stationCode!, + submissionDateTime: submissionDateTime, + reportId: log['reportId']?.toString(), + status: log['submissionStatus'] ?? 'L1', + message: log['submissionMessage'] ?? 'No status message.', + rawData: log, + serverName: log['serverConfigName'] ?? 'Unknown Server', + apiStatusRaw: apiStatusRaw, + ftpStatusRaw: ftpStatusRaw, + ); + } + + void _filterLogs() { + final query = _searchController.text.toLowerCase(); setState(() { - _filteredScheduleLogs = _scheduleLogs.where((log) => _logMatchesQuery(log, scheduleQuery)).toList(); - _filteredTriennialLogs = _triennialLogs.where((log) => _logMatchesQuery(log, triennialQuery)).toList(); - _filteredOtherLogs = _otherLogs.where((log) => _logMatchesQuery(log, otherQuery)).toList(); + _filteredLogs = _allLogs.where((log) => _logMatchesQuery(log, query)).toList(); }); } @@ -141,10 +162,11 @@ class _RiverDataStatusLogState extends State { if (query.isEmpty) return true; return log.title.toLowerCase().contains(query) || log.stationCode.toLowerCase().contains(query) || + log.serverName.toLowerCase().contains(query) || + log.type.toLowerCase().contains(query) || (log.reportId?.toLowerCase() ?? '').contains(query); } - // MODIFIED: This method now fetches appSettings from AuthProvider before resubmitting. Future _resubmitData(SubmissionLogEntry log) async { final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); if (mounted) { @@ -154,36 +176,32 @@ class _RiverDataStatusLogState extends State { } try { - // Get the appSettings from the AuthProvider to pass to the API service. final authProvider = Provider.of(context, listen: false); final appSettings = authProvider.appSettings; - final logData = log.rawData; + final dataToResubmit = RiverInSituSamplingData.fromJson(logData); final Map imageFiles = {}; - - final imageApiKeys = dataToResubmit.toApiImageFiles().keys; - for (var key in imageApiKeys) { + dataToResubmit.toApiImageFiles().keys.forEach((key) { final imagePath = logData[key]; if (imagePath is String && imagePath.isNotEmpty) { - final file = File(imagePath); - if (await file.exists()) { - imageFiles[key] = file; - } + imageFiles[key] = File(imagePath); } - } - - // Pass the appSettings list to the submit method. - final result = await _riverApiService.submitInSituSample( + }); + final result = await _apiService.river.submitInSituSample( formData: dataToResubmit.toApiFormData(), imageFiles: imageFiles, appSettings: appSettings, ); - logData['submissionStatus'] = result['status']; - logData['submissionMessage'] = result['message']; - logData['reportId'] = result['reportId']?.toString() ?? logData['reportId']; - await _localStorageService.updateRiverInSituLog(logData); + 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']); + + await _localStorageService.updateRiverInSituLog(updatedLogData); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -201,24 +219,26 @@ class _RiverDataStatusLogState extends State { setState(() { _isResubmitting.remove(logKey); }); - await _loadAllLogs(); + _loadAllLogs(); } } } @override Widget build(BuildContext context) { - final hasAnyLogs = _scheduleLogs.isNotEmpty || _triennialLogs.isNotEmpty || _otherLogs.isNotEmpty; - final hasFilteredLogs = _filteredScheduleLogs.isNotEmpty || _filteredTriennialLogs.isNotEmpty || _filteredOtherLogs.isNotEmpty; + final hasAnyLogs = _allLogs.isNotEmpty; + final hasFilteredLogs = _filteredLogs.isNotEmpty; - final logCategories = { - 'Schedule': _filteredScheduleLogs, - 'Triennial': _filteredTriennialLogs, - 'Others': _filteredOtherLogs, - }; + final Map> groupedLogs = {}; + for (var log in _filteredLogs) { + if (!groupedLogs.containsKey(log.type)) { + groupedLogs[log.type] = []; + } + groupedLogs[log.type]!.add(log); + } return Scaffold( - appBar: AppBar(title: const Text('River Data Status Log')), + appBar: AppBar(title: const Text('River Manual Data Status Log')), body: _isLoading ? const Center(child: CircularProgressIndicator()) : RefreshIndicator( @@ -228,16 +248,35 @@ class _RiverDataStatusLogState extends State { : ListView( padding: const EdgeInsets.all(8.0), children: [ - ...logCategories.entries - .where((entry) => entry.value.isNotEmpty) - .map((entry) => _buildCategorySection(entry.key, entry.value)), - if (!hasFilteredLogs && hasAnyLogs) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search river, station code, or server name...', + prefixIcon: const Icon(Icons.search, size: 20), + isDense: true, + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _filterLogs(); + }, + ), + ), + ), + ), + const Divider(), + if (!hasFilteredLogs && hasAnyLogs && _searchController.text.isNotEmpty) const Center( child: Padding( padding: EdgeInsets.all(24.0), child: Text('No logs match your search.'), ), ) + else + ...groupedLogs.entries.map((entry) => _buildCategorySection(entry.key, entry.value)), ], ), ), @@ -245,8 +284,6 @@ class _RiverDataStatusLogState extends State { } Widget _buildCategorySection(String category, List logs) { - final listHeight = (logs.length > 5 ? 5.5 : logs.length.toDouble()) * 75.0; - return Card( margin: const EdgeInsets.symmetric(vertical: 8.0), child: Padding( @@ -255,32 +292,14 @@ class _RiverDataStatusLogState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: TextField( - controller: _searchControllers[category], - decoration: InputDecoration( - hintText: 'Search in $category...', - prefixIcon: const Icon(Icons.search, size: 20), - isDense: true, - border: const OutlineInputBorder(), - ), - ), - ), const Divider(), - logs.isEmpty - ? const Padding( - padding: EdgeInsets.all(16.0), - child: Center(child: Text('No logs match your search in this category.'))) - : ConstrainedBox( - constraints: BoxConstraints(maxHeight: listHeight), - child: ListView.builder( - shrinkWrap: true, - itemCount: logs.length, - itemBuilder: (context, index) { - return _buildLogListItem(logs[index]); - }, - ), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: logs.length, + itemBuilder: (context, index) { + return _buildLogListItem(logs[index]); + }, ), ], ), @@ -289,50 +308,117 @@ class _RiverDataStatusLogState extends State { } Widget _buildLogListItem(SubmissionLogEntry log) { - final isFailed = log.status != 'L3'; + final isFailed = !log.status.startsWith('S') && !log.status.startsWith('L4'); final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); final isResubmitting = _isResubmitting[logKey] ?? false; - final title = '${log.title} (${log.stationCode})'; - final subtitle = DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime); - return ExpansionTile( - leading: Icon( - isFailed ? Icons.error_outline : Icons.check_circle_outline, - color: isFailed ? Colors.red : Colors.green, - ), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), - subtitle: Text(subtitle), - trailing: isFailed - ? (isResubmitting - ? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 3)) - : IconButton(icon: const Icon(Icons.sync, color: Colors.blue), tooltip: 'Resubmit', onPressed: () => _resubmitData(log))) - : null, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildDetailRow('Report ID:', log.reportId ?? 'N/A'), - _buildDetailRow('Status:', log.message), - _buildDetailRow('Submission Type:', log.type), - ], + final titleWidget = RichText( + text: TextSpan( + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + children: [ + TextSpan(text: '${log.title} '), + TextSpan( + text: '(${log.stationCode})', + style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.normal), ), - ) - ], + ], + ), + ); + final subtitle = '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}'; + + return Card( + margin: const EdgeInsets.symmetric(vertical: 4.0), + child: ExpansionTile( + key: PageStorageKey(logKey), + leading: Icon( + isFailed ? Icons.error_outline : Icons.check_circle_outline, + color: isFailed ? Colors.red : Colors.green, + ), + title: titleWidget, + subtitle: Text(subtitle), + trailing: isFailed + ? (isResubmitting + ? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 3)) + : IconButton(icon: const Icon(Icons.sync, color: Colors.blue), tooltip: 'Resubmit', onPressed: () => _resubmitData(log))) + : null, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailRow('High-Level Status:', log.status), + _buildDetailRow('Server:', log.serverName), + _buildDetailRow('Report ID:', log.reportId ?? 'N/A'), + _buildDetailRow('Submission Type:', log.type), + const Divider(height: 10), + _buildGranularStatus('API', log.apiStatusRaw), + _buildGranularStatus('FTP', log.ftpStatusRaw), + ], + ), + ) + ], + ), + ); + } + + Widget _buildGranularStatus(String type, String? jsonStatus) { + if (jsonStatus == null || jsonStatus.isEmpty) { + return Container(); + } + + List statuses; + try { + statuses = jsonDecode(jsonStatus); + } catch (_) { + return _buildDetailRow('$type Status:', jsonStatus!); + } + + if (statuses.isEmpty) { + return Container(); + } + + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$type Status:', style: const TextStyle(fontWeight: FontWeight.bold)), + ...statuses.map((s) { + final serverName = s['server_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); + final Color color = isSuccess ? Colors.green : (status.toLowerCase().contains('failed') ? Colors.red : Colors.grey); + String detailLabel = (s['type'] != null) ? '(${s['type']})' : ''; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 8.0), + child: Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 5), + Expanded(child: Text('$serverName $detailLabel: $status')), + ], + ), + ); + }).toList(), + ], + ), ); } Widget _buildDetailRow(String label, String value) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 2.0), + padding: const EdgeInsets.symmetric(vertical: 4.0), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('$label ', style: const TextStyle(fontWeight: FontWeight.bold)), - Expanded(child: Text(value)), + Expanded(flex: 2, child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold))), + const SizedBox(width: 8), + Expanded(flex: 3, child: Text(value)), ], ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/river/manual/in_situ_sampling.dart b/lib/screens/river/manual/in_situ_sampling.dart index 947a994..e16aaeb 100644 --- a/lib/screens/river/manual/in_situ_sampling.dart +++ b/lib/screens/river/manual/in_situ_sampling.dart @@ -35,7 +35,8 @@ class _RiverInSituSamplingScreenState extends State { late RiverInSituSamplingData _data; - final RiverInSituSamplingService _samplingService = RiverInSituSamplingService(); + // 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(); @@ -46,6 +47,9 @@ class _RiverInSituSamplingScreenState extends State { int _currentPage = 0; bool _isLoading = false; + // FIX: Use late initialization to retrieve service instances in the build method. + late RiverInSituSamplingService _samplingService; + @override void initState() { super.initState(); @@ -53,12 +57,16 @@ class _RiverInSituSamplingScreenState extends State { 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(context, listen: false); + }); } @override void dispose() { _pageController.dispose(); - _samplingService.dispose(); + // FIX: Removed _samplingService.dispose() call as it is now retrieved from Provider/context. super.dispose(); } @@ -80,108 +88,39 @@ class _RiverInSituSamplingScreenState extends State { } } - // --- REPLACED: _submitForm() method with the new workflow --- + // --- REPLACED: _submitForm() method with the simplified workflow --- Future _submitForm() async { setState(() => _isLoading = true); final authProvider = Provider.of(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); + + // 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'; - - // A copy of the data is made to avoid modifying the original during the FTP process. - final dataForFtp = RiverInSituSamplingData.fromJson(_data.toApiFormData()); - - bool apiSuccess = false; - bool ftpSuccess = false; - - // --- Path A: Attempt API Submission --- - debugPrint("Step 1: Attempting API submission..."); - final apiResult = await _samplingService.submitData(_data, appSettings); - - if (apiResult['success'] == true) { - apiSuccess = true; - _data.submissionStatus = apiResult['status']; - _data.submissionMessage = apiResult['message']; - _data.reportId = apiResult['reportId']?.toString(); - debugPrint("API submission successful."); - } else { - _data.submissionStatus = apiResult['status']; - _data.submissionMessage = apiResult['message']; - _data.reportId = null; - debugPrint("API submission failed. Reason: ${apiResult['message']}"); - } - - // --- Path B: Attempt FTP Submission if configurations exist --- - final activeFtpConfig = await _serverConfigService.getActiveFtpConfig(); - if (activeFtpConfig != null) { - debugPrint("Step 2: FTP server configured. Proceeding with zipping and queuing."); - final stationCode = _data.selectedStation?['sampling_station_code'] ?? 'NA'; - final reportId = _data.reportId ?? DateTime.now().millisecondsSinceEpoch; - final baseFileName = '${stationCode}_$reportId'; - - try { - // --- Create a separate folder for FTP data to avoid conflicts --- - final ftpDir = await _localStorageService.getRiverInSituBaseDir(dataForFtp.samplingType, serverName: '${serverName}_ftp'); - if (ftpDir != null) { - final dataForFtpJson = dataForFtp.toApiFormData(); // This is the data used for the ZIP files - final File? dataZip = await _zippingService.createDataZip( - jsonDataMap: { - 'river_insitu_basic_form.json': jsonEncode(dataForFtpJson), - // Add other JSON files if necessary - }, - baseFileName: baseFileName, - ); - - final File? imageZip = await _zippingService.createImageZip( - imageFiles: dataForFtp.toApiImageFiles().values.whereType().toList(), - baseFileName: baseFileName, - ); - - if (dataZip != null) { - await _retryService.addFtpToQueue( - localFilePath: dataZip.path, - remotePath: '/uploads/data/${p.basename(dataZip.path)}', - ); - ftpSuccess = true; // Mark as successful if at least one file is queued - } - if (imageZip != null) { - await _retryService.addFtpToQueue( - localFilePath: imageZip.path, - remotePath: '/uploads/images/${p.basename(imageZip.path)}', - ); - ftpSuccess = true; - } - } - } catch (e) { - debugPrint("FTP zipping or queuing failed with an error: $e"); - ftpSuccess = false; - } - } - - // --- Final Status Update and Navigation --- - if (!mounted) return; - - if (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.saveRiverInSituSamplingData(_data, serverName: serverName); + if (!mounted) return; + setState(() => _isLoading = false); final message = _data.submissionMessage ?? 'An unknown error occurred.'; - final color = (apiSuccess || ftpSuccess) ? Colors.green : Colors.red; + // 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 color = isSuccess ? Colors.green : Colors.red; + ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)), ); @@ -191,6 +130,19 @@ class _RiverInSituSamplingScreenState extends State { @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( diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 30c95d6..1d2ea1d 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -380,7 +380,7 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.info_outline), title: const Text('App Version'), - subtitle: const Text('1.0.0'), + subtitle: const Text('1.2.03'), dense: true, ), ListTile( diff --git a/lib/services/air_sampling_service.dart b/lib/services/air_sampling_service.dart index 84856f8..d13cf2c 100644 --- a/lib/services/air_sampling_service.dart +++ b/lib/services/air_sampling_service.dart @@ -8,6 +8,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import 'package:image/image.dart' as img; import 'package:intl/intl.dart'; +import 'dart:convert'; import '../models/air_installation_data.dart'; import '../models/air_collection_data.dart'; @@ -18,14 +19,54 @@ import 'telegram_service.dart'; import 'server_config_service.dart'; -/// A dedicated service to handle all business logic for the Air Manual Sampling feature. +/// A dedicated service for handling all business logic for the Air Manual Sampling feature. class AirSamplingService { - final ApiService _apiService = ApiService(); - final LocalStorageService _localStorageService = LocalStorageService(); - final TelegramService _telegramService = TelegramService(); - // --- ADDED: An instance of the service to get the active server name --- + final ApiService _apiService; + final DatabaseHelper _dbHelper; + final TelegramService _telegramService; final ServerConfigService _serverConfigService = ServerConfigService(); + + // 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) + Map _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 + map['imageFront'] = data.imageFront; + map['imageBack'] = data.imageBack; + map['imageLeft'] = data.imageLeft; + map['imageRight'] = data.imageRight; + map['optionalImage1'] = data.optionalImage1; + map['optionalImage2'] = data.optionalImage2; + map['optionalImage3'] = data.optionalImage3; + map['optionalImage4'] = data.optionalImage4; + + if (data.collectionData != null) { + map['collectionData'] = _toMapForLocalSave(data.collectionData); + } + 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 + map['imageFront'] = data.imageFront; + map['imageBack'] = data.imageBack; + map['imageLeft'] = data.imageLeft; + map['imageRight'] = data.imageRight; + map['imageChart'] = data.imageChart; + map['imageFilterPaper'] = data.imageFilterPaper; + map['optionalImage1'] = data.optionalImage1; + map['optionalImage2'] = data.optionalImage2; + map['optionalImage3'] = data.optionalImage3; + map['optionalImage4'] = data.optionalImage4; + return map; + } + return {}; + } + + /// Picks an image from the specified source, adds a timestamp watermark, /// and saves it to a temporary directory with a standardized name. Future pickAndProcessImage( @@ -103,50 +144,163 @@ class AirSamplingService { } } + // --- ADDED HELPER METHODS TO GET IMAGE PATHS FROM MODELS --- + List _getInstallationImagePaths(AirInstallationData data) { + final List files = [ + data.imageFront, data.imageBack, data.imageLeft, data.imageRight, + data.optionalImage1, data.optionalImage2, data.optionalImage3, data.optionalImage4, + ]; + return files.where((f) => f != null).map((f) => f!.path).toList(); + } + + List _getCollectionImagePaths(AirCollectionData data) { + final List files = [ + data.imageFront, data.imageBack, data.imageLeft, data.imageRight, + data.imageChart, data.imageFilterPaper, + data.optionalImage1, data.optionalImage2, data.optionalImage3, data.optionalImage4, + ]; + 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> submitInstallation(AirInstallationData data, List>? appSettings) async { // --- MODIFIED: Get the active server name to use for local storage --- final activeConfig = await _serverConfigService.getActiveApiConfig(); final serverName = activeConfig?['config_name'] as String? ?? 'Default'; - - // --- OFFLINE-FIRST HELPER --- - Future> saveLocally(String status, String message) async { - debugPrint("Saving installation locally with status: $status"); - data.status = status; // Use the provided status - // --- MODIFIED: Pass the serverName to the save method --- - await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!, serverName: serverName); - return {'status': status, 'message': message}; - } + 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}"); - return await _uploadInstallationImagesAndUpdate(data, appSettings, serverName: serverName); + 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.post('air/manual/installation', data.toJsonForApi()); + 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']}"); - return await saveLocally('L1', 'No connection or server error. Installation data saved locally.'); + 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."); - return await saveLocally('L1', 'Data submitted, but server response was invalid.'); + 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; } - debugPrint("Text data submitted successfully. Received record ID: $recordIdFromServer"); final int? parsedRecordId = int.tryParse(recordIdFromServer.toString()); if (parsedRecordId == null) { debugPrint("Could not parse the received record ID: $recordIdFromServer"); - return await saveLocally('L1', 'Data submitted, but server response was invalid.'); + 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; @@ -159,10 +313,37 @@ class AirSamplingService { // MODIFIED: Method now requires the serverName to pass to the save method. Future> _uploadInstallationImagesAndUpdate(AirInstallationData data, List>? 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."); - data.status = 'S1'; // Server Pending (no images needed) - await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!, serverName: serverName); + + // 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.'}; } @@ -176,21 +357,70 @@ class AirSamplingService { if (imageUploadResult['success'] != true) { debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}"); data.status = 'L2_PENDING_IMAGES'; - await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!, serverName: serverName); - return { + 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) - await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!, serverName: serverName); - _handleInstallationSuccessAlert(data, appSettings, isDataOnly: false); - return { + 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. @@ -200,40 +430,80 @@ class AirSamplingService { final activeConfig = await _serverConfigService.getActiveApiConfig(); final serverName = activeConfig?['config_name'] as String? ?? 'Default'; - // --- OFFLINE-FIRST HELPER (CORRECTED) --- - Future> updateAndSaveLocally(String newStatus, {String? message}) async { - debugPrint("Saving collection data locally with status: $newStatus"); - final allLogs = await _localStorageService.getAllAirSamplingLogs(); - final logIndex = allLogs.indexWhere((log) => log['refID'] == data.installationRefID); - - if (logIndex != -1) { - final installationLog = allLogs[logIndex]; - // FIX: Nest collection data to prevent overwriting installation fields. - installationLog['collectionData'] = data.toMap(); - installationLog['status'] = newStatus; // Update the overall status - await _localStorageService.saveAirSamplingRecord(installationLog, data.installationRefID!, serverName: serverName); - } - return { - 'status': newStatus, - 'message': message ?? 'No connection or server error. Data saved locally.', - }; - } + 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}"); - return await _uploadCollectionImagesAndUpdate(data, installationData, appSettings, serverName: serverName); + 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.post('air/manual/collection', data.toJson()); + 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']}"); - return await updateAndSaveLocally('L3', message: 'No connection or server error. Collection data saved locally.'); + 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); @@ -242,30 +512,41 @@ class AirSamplingService { /// 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> _uploadCollectionImagesAndUpdate(AirCollectionData data, AirInstallationData installationData, List>? appSettings, {required String serverName}) async { - // --- OFFLINE-FIRST HELPER (CORRECTED & MODIFIED) --- - Future> updateAndSaveLocally(String newStatus, {String? message}) async { - debugPrint("Saving collection data locally with status: $newStatus"); - final allLogs = await _localStorageService.getAllAirSamplingLogs(); - final logIndex = allLogs.indexWhere((log) => log['refID'] == data.installationRefID); - - if (logIndex != -1) { - final installationLog = allLogs[logIndex]; - installationLog['collectionData'] = data.toMap(); - installationLog['status'] = newStatus; - await _localStorageService.saveAirSamplingRecord(installationLog, data.installationRefID!, serverName: serverName); - } - return { - 'status': newStatus, - 'message': message ?? 'No connection or server error. Data saved locally.', - }; - } - 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."); - await updateAndSaveLocally('S3'); // S3 = Server Completed + 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 {'status': 'S3', 'message': 'Collection data submitted successfully.'}; + return result; } debugPrint("Step 2: Uploading ${filesToUpload.length} collection images..."); @@ -276,17 +557,70 @@ class AirSamplingService { if (imageUploadResult['success'] != true) { debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}"); - // Use status 'L4_PENDING_IMAGES' to indicate text submitted but images failed - return await updateAndSaveLocally('L4_PENDING_IMAGES', message: 'Data submitted, but image upload failed. Saved locally for retry.'); + 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."); - await updateAndSaveLocally('S3'); // S3 = Server Completed - _handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: false); - return { + 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; } @@ -294,18 +628,18 @@ class AirSamplingService { Future> getPendingInstallations() async { debugPrint("Fetching pending installations from local storage..."); - final logs = await _localStorageService.getAllAirSamplingLogs(); + final logs = await _dbHelper.loadSubmissionLogs(module: 'air'); final pendingInstallations = logs - .where((log) { + ?.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(log)) - .toList(); + .map((log) => AirInstallationData.fromJson(jsonDecode(log['form_data']))) + .toList() ?? []; return pendingInstallations; } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 2c114ef..c7e3a6c 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -13,17 +13,20 @@ import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/telegram_service.dart'; import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart'; import 'package:environment_monitoring_app/models/tarball_data.dart'; +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'; // ======================================================================= // Part 1: Unified API Service // ======================================================================= /// A unified service that consolidates all API interactions for the application. -/// It is organized by feature (e.g., marine, river) for clarity and provides -/// a central point for data synchronization. +// ... (ApiService class definition remains the same) + class ApiService { final BaseApiService _baseService = BaseApiService(); - final DatabaseHelper _dbHelper = DatabaseHelper(); + final DatabaseHelper dbHelper = DatabaseHelper(); late final MarineApiService marine; late final RiverApiService river; @@ -31,10 +34,10 @@ class ApiService { static const String imageBaseUrl = 'https://dev14.pstw.com.my/'; - ApiService() { - marine = MarineApiService(_baseService); - river = RiverApiService(_baseService); - air = AirApiService(_baseService); + ApiService({required TelegramService telegramService}) { + marine = MarineApiService(_baseService, telegramService); + river = RiverApiService(_baseService, telegramService); + air = AirApiService(_baseService, telegramService); } // --- Core API Methods (Unchanged) --- @@ -119,7 +122,7 @@ class ApiService { debugPrint('ApiService: Refreshing profile data from server...'); final result = await getProfile(); if (result['success'] == true && result['data'] != null) { - await _dbHelper.saveProfile(result['data']); + await dbHelper.saveProfile(result['data']); debugPrint('ApiService: Profile data refreshed and saved to local DB.'); } return result; @@ -143,24 +146,24 @@ class ApiService { try { // Defines all data types to sync, their endpoints, and their DB handlers. final syncTasks = { - 'profile': {'endpoint': 'profile', 'handler': (d, id) async { if (d.isNotEmpty) await _dbHelper.saveProfile(d.first); }}, - 'allUsers': {'endpoint': 'users', 'handler': (d, id) async { await _dbHelper.upsertUsers(d); await _dbHelper.deleteUsers(id); }}, - 'tarballStations': {'endpoint': 'marine/tarball/stations', 'handler': (d, id) async { await _dbHelper.upsertTarballStations(d); await _dbHelper.deleteTarballStations(id); }}, - 'manualStations': {'endpoint': 'marine/manual/stations', 'handler': (d, id) async { await _dbHelper.upsertManualStations(d); await _dbHelper.deleteManualStations(id); }}, - 'tarballClassifications': {'endpoint': 'marine/tarball/classifications', 'handler': (d, id) async { await _dbHelper.upsertTarballClassifications(d); await _dbHelper.deleteTarballClassifications(id); }}, - 'riverManualStations': {'endpoint': 'river/manual-stations', 'handler': (d, id) async { await _dbHelper.upsertRiverManualStations(d); await _dbHelper.deleteRiverManualStations(id); }}, - 'riverTriennialStations': {'endpoint': 'river/triennial-stations', 'handler': (d, id) async { await _dbHelper.upsertRiverTriennialStations(d); await _dbHelper.deleteRiverTriennialStations(id); }}, - 'departments': {'endpoint': 'departments', 'handler': (d, id) async { await _dbHelper.upsertDepartments(d); await _dbHelper.deleteDepartments(id); }}, - 'companies': {'endpoint': 'companies', 'handler': (d, id) async { await _dbHelper.upsertCompanies(d); await _dbHelper.deleteCompanies(id); }}, - 'positions': {'endpoint': 'positions', 'handler': (d, id) async { await _dbHelper.upsertPositions(d); await _dbHelper.deletePositions(id); }}, - 'airManualStations': {'endpoint': 'air/manual-stations', 'handler': (d, id) async { await _dbHelper.upsertAirManualStations(d); await _dbHelper.deleteAirManualStations(id); }}, - 'airClients': {'endpoint': 'air/clients', 'handler': (d, id) async { await _dbHelper.upsertAirClients(d); await _dbHelper.deleteAirClients(id); }}, - '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); }}, + 'profile': {'endpoint': 'profile', 'handler': (d, id) async { if (d.isNotEmpty) await dbHelper.saveProfile(d.first); }}, + 'allUsers': {'endpoint': 'users', 'handler': (d, id) async { await dbHelper.upsertUsers(d); await dbHelper.deleteUsers(id); }}, + 'tarballStations': {'endpoint': 'marine/tarball/stations', 'handler': (d, id) async { await dbHelper.upsertTarballStations(d); await dbHelper.deleteTarballStations(id); }}, + 'manualStations': {'endpoint': 'marine/manual/stations', 'handler': (d, id) async { await dbHelper.upsertManualStations(d); await dbHelper.deleteManualStations(id); }}, + 'tarballClassifications': {'endpoint': 'marine/tarball/classifications', 'handler': (d, id) async { await dbHelper.upsertTarballClassifications(d); await dbHelper.deleteTarballClassifications(id); }}, + 'riverManualStations': {'endpoint': 'river/manual-stations', 'handler': (d, id) async { await dbHelper.upsertRiverManualStations(d); await dbHelper.deleteRiverManualStations(id); }}, + 'riverTriennialStations': {'endpoint': 'river/triennial-stations', 'handler': (d, id) async { await dbHelper.upsertRiverTriennialStations(d); await dbHelper.deleteRiverTriennialStations(id); }}, + 'departments': {'endpoint': 'departments', 'handler': (d, id) async { await dbHelper.upsertDepartments(d); await dbHelper.deleteDepartments(id); }}, + 'companies': {'endpoint': 'companies', 'handler': (d, id) async { await dbHelper.upsertCompanies(d); await dbHelper.deleteCompanies(id); }}, + 'positions': {'endpoint': 'positions', 'handler': (d, id) async { await dbHelper.upsertPositions(d); await dbHelper.deletePositions(id); }}, + 'airManualStations': {'endpoint': 'air/manual-stations', 'handler': (d, id) async { await dbHelper.upsertAirManualStations(d); await dbHelper.deleteAirManualStations(id); }}, + 'airClients': {'endpoint': 'air/clients', 'handler': (d, id) async { await dbHelper.upsertAirClients(d); await dbHelper.deleteAirClients(id); }}, + '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); }}, + 'apiConfigs': {'endpoint': 'api-configs', 'handler': (d, id) async { await dbHelper.upsertApiConfigs(d); await dbHelper.deleteApiConfigs(id); }}, + 'ftpConfigs': {'endpoint': 'ftp-configs', 'handler': (d, id) async { await dbHelper.upsertFtpConfigs(d); await dbHelper.deleteFtpConfigs(id); }}, }; // Fetch all deltas in parallel @@ -202,13 +205,21 @@ class ApiService { // ======================================================================= class AirApiService { -// ... (No changes needed here) final BaseApiService _baseService; - AirApiService(this._baseService); + final TelegramService? _telegramService; + AirApiService(this._baseService, [this._telegramService]); Future> getManualStations() => _baseService.get('air/manual-stations'); Future> getClients() => _baseService.get('air/clients'); + Future> submitInstallation(AirInstallationData data) { + return _baseService.post('air/manual/installation', data.toJsonForApi()); + } + + Future> submitCollection(AirCollectionData data) { + return _baseService.post('air/manual/collection', data.toJson()); + } + Future> uploadInstallationImages({ required String airManId, required Map files, @@ -234,16 +245,102 @@ class AirApiService { class MarineApiService { -// --- ADDED: TelegramService instance --- final BaseApiService _baseService; - final TelegramService _telegramService = TelegramService(); - MarineApiService(this._baseService); + final TelegramService _telegramService; + MarineApiService(this._baseService, this._telegramService); Future> getTarballStations() => _baseService.get('marine/tarball/stations'); Future> getManualStations() => _baseService.get('marine/manual/stations'); Future> getTarballClassifications() => _baseService.get('marine/tarball/classifications'); - // --- REVISED: Now includes appSettings parameter and triggers Telegram alert --- + // FIX: Added submitInSituSample implementation for Marine from marine_api_service.dart + Future> submitInSituSample({ + required Map formData, + required Map imageFiles, + required InSituSamplingData inSituData, + required List>? 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 = {}; + 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); // Uses the inSituData object + 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(), + }; + } + + Future _handleInSituSuccessAlert(InSituSamplingData data, List>? 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"); + } + } + // END FIX: Added submitInSituSample implementation for Marine + Future> submitTarballSample({ required Map formData, required Map imageFiles, @@ -265,17 +362,14 @@ class MarineApiService { final imageResult = await _baseService.postMultipart(endpoint: 'marine/tarball/images', fields: {'autoid': recordId.toString()}, files: filesToUpload); if (imageResult['success'] != true) { - // Still send the alert for data submission even if images fail _handleTarballSuccessAlert(formData, appSettings, isDataOnly: true); return {'status': 'L2', 'success': false, 'message': 'Data submitted, but image upload failed: ${imageResult['message']}', 'reportId': recordId}; } - // On complete success, send the full alert _handleTarballSuccessAlert(formData, appSettings, isDataOnly: false); return {'status': 'L3', 'success': true, 'message': 'Data and images submitted successfully.', 'reportId': recordId}; } - // --- ADDED: Helper method for Telegram alerts --- Future _handleTarballSuccessAlert(Map formData, List>? appSettings, {required bool isDataOnly}) async { debugPrint("Triggering Telegram alert logic..."); try { @@ -289,7 +383,6 @@ class MarineApiService { } } - // --- ADDED: Helper method to generate the Telegram message --- String _generateTarballAlertMessage(Map formData, {required bool isDataOnly}) { final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final stationName = formData['tbl_station_name'] ?? 'N/A'; @@ -323,12 +416,124 @@ class MarineApiService { } class RiverApiService { -// ... (No changes needed here) final BaseApiService _baseService; - RiverApiService(this._baseService); + final TelegramService _telegramService; + RiverApiService(this._baseService, this._telegramService); Future> getManualStations() => _baseService.get('river/manual-stations'); Future> getTriennialStations() => _baseService.get('river/triennial-stations'); + + // FIX: Added submitInSituSample implementation for River from river_api_service.dart + Future> submitInSituSample({ + required Map formData, + required Map imageFiles, + required List>? appSettings, + }) async { + // --- Step 1: Submit Form Data as JSON --- + final dataResult = await _baseService.post('river/manual/sample', formData); + + if (dataResult['success'] != true) { + return { + 'status': 'L1', + 'success': false, + 'message': 'Failed to submit river in-situ data: ${dataResult['message']}', + 'reportId': null + }; + } + + // --- Step 2: Upload Image Files --- + final recordId = dataResult['data']?['r_man_id']; + if (recordId == null) { + return { + 'status': 'L2', + 'success': false, + 'message': 'Data submitted, but failed to get a record ID for images.', + 'reportId': null + }; + } + + final filesToUpload = {}; + imageFiles.forEach((key, value) { + if (value != null) filesToUpload[key] = value; + }); + + if (filesToUpload.isEmpty) { + _handleInSituSuccessAlert(formData, appSettings, isDataOnly: true); + return { + 'status': 'L3', + 'success': true, + 'message': 'Data submitted successfully. No images were attached.', + 'reportId': recordId.toString() + }; + } + + 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, + ); + + if (imageResult['success'] != true) { + return { + 'status': 'L2', + 'success': false, + 'message': 'Data submitted, but image upload failed: ${imageResult['message']}', + 'reportId': recordId.toString() + }; + } + + _handleInSituSuccessAlert(formData, appSettings, isDataOnly: false); + return { + 'status': 'L3', + 'success': true, + 'message': 'Data and images submitted successfully.', + 'reportId': recordId.toString() + }; + } + + Future _handleInSituSuccessAlert(Map formData, List>? 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"); + } + } +// END FIX: Added submitInSituSample implementation for River } // ======================================================================= @@ -338,8 +543,7 @@ class RiverApiService { class DatabaseHelper { static Database? _database; static const String _dbName = 'app_data.db'; - // Incremented DB version to trigger the onUpgrade method - static const int _dbVersion = 17; + static const int _dbVersion = 18; static const String _profileTable = 'user_profile'; static const String _usersTable = 'all_users'; @@ -355,14 +559,13 @@ class DatabaseHelper { static const String _airManualStationsTable = 'air_manual_stations'; static const String _airClientsTable = 'air_clients'; static const String _statesTable = 'states'; - // Added new table constants static const String _appSettingsTable = 'app_settings'; static const String _parameterLimitsTable = 'manual_parameter_limits'; - // --- ADDED: New tables for independent API and FTP configurations --- static const String _apiConfigsTable = 'api_configurations'; static const String _ftpConfigsTable = 'ftp_configurations'; - // --- ADDED: New table for the manual retry queue --- static const String _retryQueueTable = 'retry_queue'; + // FIX: Updated submission log table schema for granular status tracking + static const String _submissionLogTable = 'submission_log'; Future get database async { @@ -391,13 +594,10 @@ class DatabaseHelper { await db.execute('CREATE TABLE $_airManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); await db.execute('CREATE TABLE $_airClientsTable(client_id INTEGER PRIMARY KEY, client_json TEXT)'); await db.execute('CREATE TABLE $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)'); - // Added create statements for new tables await db.execute('CREATE TABLE $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)'); await db.execute('CREATE TABLE $_parameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); - // --- ADDED: Create statements for new configuration tables --- await db.execute('CREATE TABLE $_apiConfigsTable(api_config_id INTEGER PRIMARY KEY, config_json TEXT)'); await db.execute('CREATE TABLE $_ftpConfigsTable(ftp_config_id INTEGER PRIMARY KEY, config_json TEXT)'); - // --- ADDED: Create statement for the new retry queue table --- await db.execute(''' CREATE TABLE $_retryQueueTable( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -408,6 +608,23 @@ class DatabaseHelper { status TEXT NOT NULL ) '''); + // FIX: Updated CREATE TABLE statement for _submissionLogTable to include api_status and ftp_status + await db.execute(''' + CREATE TABLE $_submissionLogTable ( + submission_id TEXT PRIMARY KEY, + module TEXT NOT NULL, + type TEXT NOT NULL, + status TEXT NOT NULL, + message TEXT, + report_id TEXT, + created_at TEXT NOT NULL, + form_data TEXT, + image_data TEXT, + server_name TEXT, + api_status TEXT, + ftp_status TEXT + ) + '''); } Future _onUpgrade(Database db, int oldVersion, int newVersion) async { @@ -422,12 +639,10 @@ class DatabaseHelper { await db.execute('CREATE TABLE IF NOT EXISTS $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)'); await db.execute('CREATE TABLE IF NOT EXISTS $_parameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); } - // --- ADDED: Upgrade logic for new configuration tables --- if (oldVersion < 16) { await db.execute('CREATE TABLE IF NOT EXISTS $_apiConfigsTable(api_config_id INTEGER PRIMARY KEY, config_json TEXT)'); await db.execute('CREATE TABLE IF NOT EXISTS $_ftpConfigsTable(ftp_config_id INTEGER PRIMARY KEY, config_json TEXT)'); } - // --- ADDED: Upgrade logic for the new retry queue table --- if (oldVersion < 17) { await db.execute(''' CREATE TABLE IF NOT EXISTS $_retryQueueTable( @@ -440,6 +655,34 @@ class DatabaseHelper { ) '''); } + if (oldVersion < 18) { + // FIX: Updated UPGRADE TABLE statement for _submissionLogTable to include api_status and ftp_status + await db.execute(''' + CREATE TABLE IF NOT EXISTS $_submissionLogTable ( + submission_id TEXT PRIMARY KEY, + module TEXT NOT NULL, + type TEXT NOT NULL, + status TEXT NOT NULL, + message TEXT, + report_id TEXT, + created_at TEXT NOT NULL, + form_data TEXT, + image_data TEXT, + server_name TEXT + ) + '''); + } + + // Add columns if upgrading from < 18 or if columns were manually dropped (for testing) + // NOTE: In a real migration, you'd check if the columns exist first. + if (oldVersion < 19) { + try { + await db.execute("ALTER TABLE $_submissionLogTable ADD COLUMN api_status TEXT"); + await db.execute("ALTER TABLE $_submissionLogTable ADD COLUMN ftp_status TEXT"); + } catch (_) { + // Ignore if columns already exist during a complex migration path + } + } } /// Performs an "upsert": inserts new records or replaces existing ones. @@ -579,4 +822,40 @@ class DatabaseHelper { final db = await database; await db.delete(_retryQueueTable, where: 'id = ?', whereArgs: [id]); } -} + + // --- ADDED: Methods for the centralized submission log --- + + /// Saves a new submission log entry to the central database table. + // FIX: Updated signature to accept api_status and ftp_status + Future saveSubmissionLog(Map data) async { + final db = await database; + await db.insert( + _submissionLogTable, + data, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + /// Retrieves all submission log entries, optionally filtered by module. + Future>?> loadSubmissionLogs({String? module}) async { + final db = await database; + List> maps; + + if (module != null && module.isNotEmpty) { + maps = await db.query( + _submissionLogTable, + where: 'module = ?', + whereArgs: [module], + orderBy: 'created_at DESC', + ); + } else { + maps = await db.query( + _submissionLogTable, + orderBy: 'created_at DESC', + ); + } + + if (maps.isNotEmpty) return maps; + return null; + } +} \ No newline at end of file diff --git a/lib/services/base_api_service.dart b/lib/services/base_api_service.dart index 3679f28..29117e3 100644 --- a/lib/services/base_api_service.dart +++ b/lib/services/base_api_service.dart @@ -15,7 +15,7 @@ import 'package:environment_monitoring_app/services/api_service.dart'; class BaseApiService { final ServerConfigService _serverConfigService = ServerConfigService(); - final DatabaseHelper _dbHelper = DatabaseHelper(); // --- ADDED: Instance of DatabaseHelper to get all configs --- + final DatabaseHelper _dbHelper = DatabaseHelper(); Future> _getHeaders() async { final prefs = await SharedPreferences.getInstance(); @@ -47,7 +47,7 @@ class BaseApiService { // --- MODIFIED: Generic POST request handler now attempts multiple servers --- Future> post(String endpoint, Map body) async { - final configs = await _dbHelper.loadApiConfigs() ?? []; // Get all API configs + final configs = await _dbHelper.loadApiConfigs() ?? []; // --- ADDED: Handle case where local configs are empty --- if (configs.isEmpty) { @@ -75,7 +75,7 @@ class BaseApiService { for (final config in latestConfigs) { debugPrint('Debug: Current config item: $config (Type: ${config.runtimeType})'); - // --- REVISED: The null check logic is now more specific --- + // --- 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; @@ -118,7 +118,7 @@ class BaseApiService { required Map fields, required Map files, }) async { - final configs = await _dbHelper.loadApiConfigs() ?? []; // Get all API configs + final configs = await _dbHelper.loadApiConfigs() ?? []; // --- ADDED: Handle case where local configs are empty --- if (configs.isEmpty) { @@ -151,13 +151,13 @@ class BaseApiService { } } - final latestConfigs = configs.take(2).toList(); // Limit to the two latest configs + final latestConfigs = configs.take(2).toList(); debugPrint('Debug: Loaded API configs: $latestConfigs'); for (final config in latestConfigs) { debugPrint('Debug: Current config item: $config (Type: ${config.runtimeType})'); - // --- REVISED: The null check logic is now more specific --- + // --- 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; @@ -174,7 +174,7 @@ class BaseApiService { request.fields.addAll(fields); } for (var entry in files.entries) { - if (await entry.value.exists()) { // Check if the file exists before adding + if (await entry.value.exists()) { request.files.add(await http.MultipartFile.fromPath( entry.key, entry.value.path, diff --git a/lib/services/local_storage_service.dart b/lib/services/local_storage_service.dart index fea8e4a..b418d3d 100644 --- a/lib/services/local_storage_service.dart +++ b/lib/services/local_storage_service.dart @@ -44,8 +44,19 @@ class LocalStorageService { return null; } + // --- ADDED: A public method to retrieve the root log directory. --- + Future getLogDirectory({required String serverName, required String module, required String subModule}) async { + final mmsv4Dir = await _getPublicMMSV4Directory(serverName: serverName); + if (mmsv4Dir == null) return null; + final logDir = Directory(p.join(mmsv4Dir.path, module, subModule)); + if (!await logDir.exists()) { + await logDir.create(recursive: true); + } + return logDir; + } + // ======================================================================= - // Part 2: Air Manual Sampling Methods + // Part 2: Air Manual Sampling Methods (LOGGING RESTORED) // ======================================================================= // --- MODIFIED: Method now requires serverName to get the correct base directory. --- @@ -107,7 +118,11 @@ class LocalStorageService { if (serializableData.containsKey(key) && serializableData[key] is File) { final newPath = await copyImageToLocal(serializableData[key]); serializableData['${key}Path'] = newPath; // Creates 'imageFrontPath', etc. - serializableData.remove(key); + // Note: DO NOT remove the original key here if it holds a File object. + // The copy is needed for local storage/DB logging, but the File object must stay + // on the key if other process calls `toMap()` again. + // However, based on the previous logic, we rely on the caller passing a map + // that separates File objects from paths for DB logging. } } @@ -120,14 +135,33 @@ class LocalStorageService { if (collectionMap.containsKey(key) && collectionMap[key] is File) { final newPath = await copyImageToLocal(collectionMap[key]); collectionMap['${key}Path'] = newPath; - collectionMap.remove(key); } } serializableData['collectionData'] = collectionMap; } + // CRITICAL FIX: Ensure the JSON data only contains serializable (non-File) objects + // We must strip the File objects before encoding, as they have now been copied. + final Map finalData = Map.from(serializableData); + + // Recursive helper to remove File objects before JSON encoding + void cleanMap(Map map) { + map.removeWhere((key, value) => value is File); + map.forEach((key, value) { + if (value is Map) cleanMap(value as Map); + }); + } + + // Since the caller (_toMapForLocalSave) only passes a map with File objects on the image keys, + // and paths on the *Path keys*, simply removing the File keys and encoding is sufficient. + finalData.removeWhere((key, value) => value is File); + if (finalData.containsKey('collectionData') && finalData['collectionData'] is Map) { + cleanMap(finalData['collectionData'] as Map); + } + + final jsonFile = File(p.join(eventDir.path, 'data.json')); - await jsonFile.writeAsString(jsonEncode(serializableData)); + await jsonFile.writeAsString(jsonEncode(finalData)); debugPrint("Air sampling log and images saved to: ${eventDir.path}"); return eventDir.path; @@ -172,7 +206,7 @@ class LocalStorageService { } // ======================================================================= - // Part 3: Tarball Specific Methods + // Part 3: Tarball Specific Methods (LOGGING RESTORED) // ======================================================================= Future _getTarballBaseDir({required String serverName}) async { @@ -280,7 +314,7 @@ class LocalStorageService { // ======================================================================= - // Part 4: Marine In-Situ Specific Methods + // Part 4: Marine In-Situ Specific Methods (LOGGING RESTORED) // ======================================================================= // --- MODIFIED: Removed leading underscore to make the method public --- @@ -388,7 +422,7 @@ class LocalStorageService { } // ======================================================================= - // Part 5: River In-Situ Specific Methods + // Part 5: River In-Situ Specific Methods (LOGGING RESTORED) // ======================================================================= Future getRiverInSituBaseDir(String? samplingType, {required String serverName}) async { diff --git a/lib/services/river_api_service.dart b/lib/services/river_api_service.dart index 8344c5e..a5039aa 100644 --- a/lib/services/river_api_service.dart +++ b/lib/services/river_api_service.dart @@ -6,14 +6,13 @@ import 'package:intl/intl.dart'; import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/telegram_service.dart'; -// REMOVED: SettingsService is no longer needed in this file. -// import 'package:environment_monitoring_app/services/settings_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. class RiverApiService { - final BaseApiService _baseService = BaseApiService(); - final TelegramService _telegramService = TelegramService(); - // REMOVED: SettingsService instance is no longer needed. - // final SettingsService _settingsService = SettingsService(); + final BaseApiService _baseService; + final TelegramService _telegramService; + RiverApiService(this._baseService, this._telegramService); Future> getManualStations() { return _baseService.get('river/manual-stations'); @@ -23,34 +22,40 @@ class RiverApiService { return _baseService.get('river/triennial-stations'); } - // MODIFIED: Method now requires the appSettings list to pass to the alert handler. + // MODIFIED: Method now returns granular status tracking for API and Images. Future> submitInSituSample({ required Map formData, required Map imageFiles, required List>? appSettings, }) async { + Map 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 --- - // The PHP backend for submitInSituSample expects JSON input. + 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) { - return { - 'status': 'L1', - 'success': false, - 'message': 'Failed to submit river in-situ data: ${dataResult['message']}', - 'reportId': null - }; + finalResult['message'] = dataResult['message'] ?? 'Failed to submit river in-situ data (API failed).'; + return finalResult; } - // --- Step 2: Upload Image Files --- + // Update status and reportId upon successful data submission final recordId = dataResult['data']?['r_man_id']; + finalResult['reportId'] = recordId?.toString(); + if (recordId == null) { - return { - 'status': 'L2', - 'success': false, - 'message': 'Data submitted, but failed to get a record ID for images.', - 'reportId': null - }; + finalResult['api_status'] = 'FAILED'; + finalResult['message'] = 'Data submitted, but server did not return a record ID.'; + return finalResult; } final filesToUpload = {}; @@ -58,38 +63,46 @@ class RiverApiService { 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 { - 'status': 'L3', - 'success': true, - 'message': 'Data submitted successfully. No images were attached.', - 'reportId': recordId.toString() - }; + 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) { - return { - 'status': 'L2', - 'success': false, - 'message': 'Data submitted, but image upload failed: ${imageResult['message']}', - 'reportId': recordId.toString() - }; + // 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 { - 'status': 'L3', - 'success': true, - 'message': 'Data and images submitted successfully.', - 'reportId': recordId.toString() - }; + return finalResult; } // MODIFIED: Method now requires appSettings and calls the updated TelegramService. diff --git a/lib/services/river_in_situ_sampling_service.dart b/lib/services/river_in_situ_sampling_service.dart index e548073..1e96a8b 100644 --- a/lib/services/river_in_situ_sampling_service.dart +++ b/lib/services/river_in_situ_sampling_service.dart @@ -12,26 +12,41 @@ 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'; // CHANGED: Import river-specific services and models import 'location_service.dart'; -import 'river_api_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 /// 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(); - // CHANGED: Use the river-specific API service - final RiverApiService _riverApiService = RiverApiService(); + // 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 DatabaseHelper _dbHelper = DatabaseHelper(); + final LocalStorageService _localStorageService = LocalStorageService(); + final ServerConfigService _serverConfigService = ServerConfigService(); + // 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); + // --- Location Services --- Future getCurrentLocation() => _locationService.getCurrentLocation(); @@ -149,12 +164,122 @@ class RiverInSituSamplingService { } // --- Data Submission --- - // MODIFIED: Method now requires the appSettings list to pass to the RiverApiService. - Future> submitData(RiverInSituSamplingData data, List>? appSettings) { - return _riverApiService.submitInSituSample( - formData: data.toApiFormData(), - imageFiles: data.toApiImageFiles(), - appSettings: appSettings, // Added this required parameter + // MODIFIED: This method orchestrates submission, local saving, and logging. + Future> submitData(RiverInSituSamplingData data, List>? appSettings) async { + final formData = data.toApiFormData(); + final imageFiles = data.toApiImageFiles(); + + // Get server name for logging + final activeConfig = await _serverConfigService.getActiveApiConfig(); + final serverName = activeConfig?['config_name'] as String? ?? 'Default'; + + // 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 apiSuccess = apiResult['success'] == true; + final serverReportId = apiResult['reportId']; + + // Determine granular API statuses (Simulation based on BaseApiService trying 2 servers) + List> 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, + }); + } + + // 2. Determine FTP Status (Simulated based on configuration existence) + List> ftpStatuses = []; + bool ftpQueueSuccess = false; + + 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.", + }); + } + + // --- Step 3: Determine Final Status and Log to DB --- + 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.'; + } else { + finalStatus = 'L1'; // All submissions failed + finalMessage = 'All submission attempts failed. Data saved locally for retry.'; + } + + // FIX: Ensure submissionId is initialized, or get it from data + final String submissionId = data.reportId ?? DateTime.now().millisecondsSinceEpoch.toString(); + + // 4. Update data model and save to local storage + data.submissionStatus = finalStatus; + data.submissionMessage = finalMessage; + data.reportId = serverReportId; + + await _localStorageService.saveRiverInSituSamplingData(data, serverName: serverName); + + // 5. Save submission status to Central DB Log + 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 + }; + await _dbHelper.saveSubmissionLog(logData); + + // 6. Return the final API result (which contains the granular statuses) + return apiResult; } } \ No newline at end of file diff --git a/lib/services/server_config_service.dart b/lib/services/server_config_service.dart index 9e52097..2867770 100644 --- a/lib/services/server_config_service.dart +++ b/lib/services/server_config_service.dart @@ -52,4 +52,4 @@ class ServerConfigService { } return null; } -} +} \ No newline at end of file diff --git a/lib/services/telegram_service.dart b/lib/services/telegram_service.dart index c8357f8..95ff4ee 100644 --- a/lib/services/telegram_service.dart +++ b/lib/services/telegram_service.dart @@ -1,15 +1,26 @@ +// lib/services/telegram_service.dart + import 'package:flutter/foundation.dart'; import 'package:sqflite/sqflite.dart'; import 'package:environment_monitoring_app/services/api_service.dart'; import 'package:environment_monitoring_app/services/settings_service.dart'; class TelegramService { - final ApiService _apiService = ApiService(); + // FIX: Change to a nullable, externally injected dependency. + ApiService? _apiService; final DatabaseHelper _dbHelper = DatabaseHelper(); 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. String _getChatIdForModule(String module, List>? appSettings) { switch (module) { @@ -36,7 +47,13 @@ class TelegramService { return false; } - final result = await _apiService.sendTelegramAlert( + // FIX: Check for the injected ApiService + if (_apiService == null) { + debugPrint("[TelegramService] ❌ ApiService is not available."); + return false; + } + + final result = await _apiService!.sendTelegramAlert( chatId: chatId, message: message, ); @@ -93,6 +110,13 @@ 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; + return; + } + debugPrint("[TelegramService] 🔎 Found ${pendingAlerts.length} pending alerts."); for (var alert in pendingAlerts) { @@ -100,7 +124,7 @@ class TelegramService { final chatId = alert['chat_id']; debugPrint("[TelegramService] - Processing alert ID: $alertId for Chat ID: $chatId"); - final result = await _apiService.sendTelegramAlert( + final result = await _apiService!.sendTelegramAlert( chatId: chatId, message: alert['message'], ); diff --git a/pubspec.lock b/pubspec.lock index c4dd066..b6e4b15 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -18,7 +18,7 @@ packages: source: hosted version: "2.7.0" async: - dependency: transitive + dependency: "direct main" description: name: async sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" diff --git a/pubspec.yaml b/pubspec.yaml index 065c621..a333071 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,8 @@ dependencies: permission_handler: ^11.3.1 ftpconnect: ^2.0.5 archive: ^4.0.3 # For creating ZIP files + async: ^2.11.0 + # --- Added for In-Situ Sampling Module --- simple_barcode_scanner: ^0.3.0 # For scanning sample IDs #flutter_blue_classic: ^0.0.3 # For Bluetooth sonde connection