fix issue on submission using ftp and api. fix issue on data status log display for each module

This commit is contained in:
ALim Aidrus 2025-08-25 22:07:14 +08:00
parent 1a1a1bd7d0
commit 0c37669725
28 changed files with 2068 additions and 937 deletions

View File

@ -13,12 +13,13 @@ import 'package:environment_monitoring_app/services/retry_service.dart';
/// A comprehensive provider to manage user authentication, session state, /// A comprehensive provider to manage user authentication, session state,
/// and cached master data for offline use. /// and cached master data for offline use.
class AuthProvider with ChangeNotifier { class AuthProvider with ChangeNotifier {
final ApiService _apiService = ApiService(); // FIX: Change to late final and remove direct instantiation.
final DatabaseHelper _dbHelper = DatabaseHelper(); late final ApiService _apiService;
late final DatabaseHelper _dbHelper;
// --- ADDED: Instance of the ServerConfigService to set the initial URL --- // --- 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 --- // --- ADDED: Instance of the RetryService to manage pending tasks ---
final RetryService _retryService = RetryService(); late final RetryService _retryService;
// --- Session & Profile State --- // --- Session & Profile State ---
@ -86,7 +87,16 @@ class AuthProvider with ChangeNotifier {
static const String lastSyncTimestampKey = 'last_sync_timestamp'; static const String lastSyncTimestampKey = 'last_sync_timestamp';
static const String isFirstLoginKey = 'is_first_login'; 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...'); debugPrint('AuthProvider: Initializing...');
_loadSessionAndSyncData(); _loadSessionAndSyncData();
} }

View File

@ -6,11 +6,17 @@ import 'package:connectivity_plus/connectivity_plus.dart';
// CHANGED: Added imports for MultiProvider and the services to be provided. // CHANGED: Added imports for MultiProvider and the services to be provided.
import 'package:provider/single_child_widget.dart'; 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/local_storage_service.dart';
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart'; import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart';
// --- ADDED: Import for the new AirSamplingService --- // --- ADDED: Import for the new AirSamplingService ---
import 'package:environment_monitoring_app/services/air_sampling_service.dart'; import 'package:environment_monitoring_app/services/air_sampling_service.dart';
import 'package:environment_monitoring_app/services/telegram_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/theme.dart';
import 'package:environment_monitoring_app/auth_provider.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_step2.dart';
import 'package:environment_monitoring_app/screens/marine/manual/tarball_sampling_step3_summary.dart'; import 'package:environment_monitoring_app/screens/marine/manual/tarball_sampling_step3_summary.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); 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( runApp(
// CHANGED: Converted to MultiProvider to support all necessary services. // CHANGED: Converted to MultiProvider to support all necessary services.
MultiProvider( MultiProvider(
providers: <SingleChildWidget>[ providers: <SingleChildWidget>[
// The original AuthProvider // The original AuthProvider
ChangeNotifierProvider(create: (_) => AuthProvider()), // FIX: AuthProvider now requires all its services in the constructor.
// Provider for Local Storage Service ChangeNotifierProvider(
create: (context) => AuthProvider(
apiService: apiService,
dbHelper: databaseHelper,
serverConfigService: ServerConfigService(), // Create local instances for AuthProvider DI
retryService: RetryService(),
),
),
// Providers for core services
Provider<ApiService>(create: (_) => apiService),
Provider<DatabaseHelper>(create: (_) => databaseHelper),
Provider<TelegramService>(create: (_) => telegramService),
// Providers for feature-specific services, with their dependencies correctly injected
Provider(create: (_) => LocalStorageService()), Provider(create: (_) => LocalStorageService()),
// Provider for the River In-Situ Sampling Service Provider(create: (context) => RiverInSituSamplingService(apiService.river)), // FIXED: Passed the required dependency
Provider(create: (_) => RiverInSituSamplingService()), // --- ADDED: Provider for the new AirSamplingService with dependencies ---
// --- ADDED: Provider for the new AirSamplingService --- Provider(create: (context) => AirSamplingService(apiService, databaseHelper, telegramService)),
Provider(create: (_) => AirSamplingService()), Provider(create: (context) => InSituSamplingService()), // FIX: InSituSamplingService constructor does not take arguments
], ],
child: const RootApp(), child: const RootApp(),
), ),
); );
} }
void setupServices() { void setupServices(TelegramService telegramService) {
final telegramService = TelegramService();
Future.delayed(const Duration(seconds: 5), () { Future.delayed(const Duration(seconds: 5), () {
debugPrint("[Main] Performing initial alert queue processing on app start."); debugPrint("[Main] Performing initial alert queue processing on app start.");
telegramService.processAlertQueue(); telegramService.processAlertQueue();
@ -165,8 +194,11 @@ class RootApp extends StatelessWidget {
return TarballSamplingStep3Summary(data: args); return TarballSamplingStep3Summary(data: args);
}); });
} }
// NOTE: The River and Air In-Situ forms use an internal stepper, if (settings.name == '/marine/manual/data-log') {
// so they do not require onGenerateRoute logic for their steps. return MaterialPageRoute(builder: (context) {
return const marineManualDataStatusLog.MarineManualDataStatusLog();
});
}
return null; return null;
}, },
routes: { routes: {
@ -207,12 +239,9 @@ class RootApp extends StatelessWidget {
// River Manual // River Manual
'/river/manual/dashboard': (context) => RiverManualDashboard(), '/river/manual/dashboard': (context) => RiverManualDashboard(),
'/river/manual/in-situ': (context) => riverManualInSituSampling.RiverInSituSamplingScreen(), '/river/manual/in-situ': (context) => riverManualInSituSampling.RiverInSituSamplingScreen(),
//'/river/manual/in-situ': (context) => riverManualInSituSampling.RiverInSituSampling(),
'/river/manual/report': (context) => riverManualReport.RiverManualReport(), '/river/manual/report': (context) => riverManualReport.RiverManualReport(),
'/river/manual/triennial': (context) => riverManualTriennialSampling.RiverTriennialSampling(), '/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/manual/image-request': (context) => riverManualImageRequest.RiverManualImageRequest(),
// River Continuous // River Continuous

View File

@ -1,3 +1,5 @@
// lib/models/air_collection_data.dart
import 'dart:io'; import 'dart:io';
import 'dart:convert'; // Added for jsonEncode import 'dart:convert'; // Added for jsonEncode
import 'air_installation_data.dart'; import 'air_installation_data.dart';
@ -148,16 +150,16 @@ class AirCollectionData {
optionalRemark2: map['optionalRemark2'], optionalRemark2: map['optionalRemark2'],
optionalRemark3: map['optionalRemark3'], optionalRemark3: map['optionalRemark3'],
optionalRemark4: map['optionalRemark4'], optionalRemark4: map['optionalRemark4'],
imageFront: fileFromPath(map['imageFrontPath']), imageFront: fileFromPath(map['imageFrontPath'] ?? map['imageFront']), // FIX: Check both path keys
imageBack: fileFromPath(map['imageBackPath']), imageBack: fileFromPath(map['imageBackPath'] ?? map['imageBack']), // FIX: Check both path keys
imageLeft: fileFromPath(map['imageLeftPath']), imageLeft: fileFromPath(map['imageLeftPath'] ?? map['imageLeft']), // FIX: Check both path keys
imageRight: fileFromPath(map['imageRightPath']), imageRight: fileFromPath(map['imageRightPath'] ?? map['imageRight']), // FIX: Check both path keys
imageChart: fileFromPath(map['imageChartPath']), imageChart: fileFromPath(map['imageChartPath'] ?? map['imageChart']),
imageFilterPaper: fileFromPath(map['imageFilterPaperPath']), imageFilterPaper: fileFromPath(map['imageFilterPaperPath'] ?? map['imageFilterPaper']),
optionalImage1: fileFromPath(map['optionalImage1Path']), optionalImage1: fileFromPath(map['optionalImage1Path'] ?? map['optionalImage1']), // FIX: Check both path keys
optionalImage2: fileFromPath(map['optionalImage2Path']), optionalImage2: fileFromPath(map['optionalImage2Path'] ?? map['optionalImage2']), // FIX: Check both path keys
optionalImage3: fileFromPath(map['optionalImage3Path']), optionalImage3: fileFromPath(map['optionalImage3Path'] ?? map['optionalImage3']), // FIX: Check both path keys
optionalImage4: fileFromPath(map['optionalImage4Path']), optionalImage4: fileFromPath(map['optionalImage4Path'] ?? map['optionalImage4']), // FIX: Check both path keys
); );
} }
@ -191,16 +193,16 @@ class AirCollectionData {
'optionalRemark2': optionalRemark2, 'optionalRemark2': optionalRemark2,
'optionalRemark3': optionalRemark3, 'optionalRemark3': optionalRemark3,
'optionalRemark4': optionalRemark4, 'optionalRemark4': optionalRemark4,
'imageFront': imageFront, 'imageFront': imageFront?.path, // Store path for log
'imageBack': imageBack, 'imageBack': imageBack?.path, // Store path for log
'imageLeft': imageLeft, 'imageLeft': imageLeft?.path, // Store path for log
'imageRight': imageRight, 'imageRight': imageRight?.path, // Store path for log
'imageChart': imageChart, 'imageChart': imageChart?.path,
'imageFilterPaper': imageFilterPaper, 'imageFilterPaper': imageFilterPaper?.path,
'optionalImage1': optionalImage1, 'optionalImage1': optionalImage1?.path,
'optionalImage2': optionalImage2, 'optionalImage2': optionalImage2?.path,
'optionalImage3': optionalImage3, 'optionalImage3': optionalImage3?.path,
'optionalImage4': optionalImage4, 'optionalImage4': optionalImage4?.path,
}; };
} }
@ -216,7 +218,7 @@ class AirCollectionData {
'air_man_collection_pm10_flowrate': pm10Flowrate?.toString(), 'air_man_collection_pm10_flowrate': pm10Flowrate?.toString(),
'air_man_collection_pm10_flowrate_result': pm10FlowrateResult, 'air_man_collection_pm10_flowrate_result': pm10FlowrateResult,
'air_man_collection_pm10_total_time': pm10TotalTime, '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': pm10Pressure?.toString(),
'air_man_collection_pm10_pressure_result': pm10PressureResult, 'air_man_collection_pm10_pressure_result': pm10PressureResult,
'air_man_collection_pm10_vstd': pm10Vstd?.toString(), 'air_man_collection_pm10_vstd': pm10Vstd?.toString(),

View File

@ -132,14 +132,14 @@ class AirInstallationData {
'optionalRemark2': optionalRemark2, 'optionalRemark2': optionalRemark2,
'optionalRemark3': optionalRemark3, 'optionalRemark3': optionalRemark3,
'optionalRemark4': optionalRemark4, 'optionalRemark4': optionalRemark4,
'imageFront': imageFront, 'imageFront': imageFront?.path, // Store path for log
'imageBack': imageBack, 'imageBack': imageBack?.path, // Store path for log
'imageLeft': imageLeft, 'imageLeft': imageLeft?.path, // Store path for log
'imageRight': imageRight, 'imageRight': imageRight?.path, // Store path for log
'optionalImage1': optionalImage1, 'optionalImage1': optionalImage1?.path, // Store path for log
'optionalImage2': optionalImage2, 'optionalImage2': optionalImage2?.path, // Store path for log
'optionalImage3': optionalImage3, 'optionalImage3': optionalImage3?.path, // Store path for log
'optionalImage4': optionalImage4, 'optionalImage4': optionalImage4?.path, // Store path for log
'collectionData': collectionData?.toMap(), 'collectionData': collectionData?.toMap(),
}; };
} }
@ -167,14 +167,14 @@ class AirInstallationData {
installationUserId: json['installationUserId'], installationUserId: json['installationUserId'],
installationUserName: json['installationUserName'], installationUserName: json['installationUserName'],
status: json['status'], status: json['status'],
imageFront: fileFromPath(json['imageFrontPath']), imageFront: fileFromPath(json['imageFrontPath'] ?? json['imageFront']), // FIX: Check both path keys
imageBack: fileFromPath(json['imageBackPath']), imageBack: fileFromPath(json['imageBackPath'] ?? json['imageBack']), // FIX: Check both path keys
imageLeft: fileFromPath(json['imageLeftPath']), imageLeft: fileFromPath(json['imageLeftPath'] ?? json['imageLeft']), // FIX: Check both path keys
imageRight: fileFromPath(json['imageRightPath']), imageRight: fileFromPath(json['imageRightPath'] ?? json['imageRight']), // FIX: Check both path keys
optionalImage1: fileFromPath(json['optionalImage1Path']), optionalImage1: fileFromPath(json['optionalImage1Path'] ?? json['optionalImage1']), // FIX: Check both path keys
optionalImage2: fileFromPath(json['optionalImage2Path']), optionalImage2: fileFromPath(json['optionalImage2Path'] ?? json['optionalImage2']), // FIX: Check both path keys
optionalImage3: fileFromPath(json['optionalImage3Path']), optionalImage3: fileFromPath(json['optionalImage3Path'] ?? json['optionalImage3']), // FIX: Check both path keys
optionalImage4: fileFromPath(json['optionalImage4Path']), optionalImage4: fileFromPath(json['optionalImage4Path'] ?? json['optionalImage4']), // FIX: Check both path keys
optionalRemark1: json['optionalRemark1'], optionalRemark1: json['optionalRemark1'],
optionalRemark2: json['optionalRemark2'], optionalRemark2: json['optionalRemark2'],
optionalRemark3: json['optionalRemark3'], optionalRemark3: json['optionalRemark3'],

View File

@ -86,13 +86,13 @@ class RiverInSituSamplingData {
return (path is String && path.isNotEmpty) ? File(path) : null; return (path is String && path.isNotEmpty) ? File(path) : null;
} }
// FIX: Robust helper functions for parsing numerical values
double? doubleFromJson(dynamic value) { double? doubleFromJson(dynamic value) {
if (value is num) return value.toDouble(); if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value); if (value is String) return double.tryParse(value);
return null; return null;
} }
// ADDED HELPER FUNCTION TO FIX THE ERROR
int? intFromJson(dynamic value) { int? intFromJson(dynamic value) {
if (value is int) return value; if (value is int) return value;
if (value is String) return int.tryParse(value); if (value is String) return int.tryParse(value);
@ -123,6 +123,7 @@ class RiverInSituSamplingData {
..sondeId = json['r_man_sondeID'] ..sondeId = json['r_man_sondeID']
..dataCaptureDate = json['data_capture_date'] ..dataCaptureDate = json['data_capture_date']
..dataCaptureTime = json['data_capture_time'] ..dataCaptureTime = json['data_capture_time']
// FIX: Apply doubleFromJson helper to all numerical fields
..oxygenConcentration = doubleFromJson(json['r_man_oxygen_conc']) ..oxygenConcentration = doubleFromJson(json['r_man_oxygen_conc'])
..oxygenSaturation = doubleFromJson(json['r_man_oxygen_sat']) ..oxygenSaturation = doubleFromJson(json['r_man_oxygen_sat'])
..ph = doubleFromJson(json['r_man_ph']) ..ph = doubleFromJson(json['r_man_ph'])
@ -133,6 +134,7 @@ class RiverInSituSamplingData {
..turbidity = doubleFromJson(json['r_man_turbidity']) ..turbidity = doubleFromJson(json['r_man_turbidity'])
..tss = doubleFromJson(json['r_man_tss']) ..tss = doubleFromJson(json['r_man_tss'])
..batteryVoltage = doubleFromJson(json['r_man_battery_volt']) ..batteryVoltage = doubleFromJson(json['r_man_battery_volt'])
// END FIX
..optionalRemark1 = json['r_man_optional_photo_01_remarks'] ..optionalRemark1 = json['r_man_optional_photo_01_remarks']
..optionalRemark2 = json['r_man_optional_photo_02_remarks'] ..optionalRemark2 = json['r_man_optional_photo_02_remarks']
..optionalRemark3 = json['r_man_optional_photo_03_remarks'] ..optionalRemark3 = json['r_man_optional_photo_03_remarks']
@ -147,6 +149,7 @@ class RiverInSituSamplingData {
..optionalImage4 = fileFromJson(json['r_man_optional_photo_04']) ..optionalImage4 = fileFromJson(json['r_man_optional_photo_04'])
// ADDED: Flowrate fields from JSON // ADDED: Flowrate fields from JSON
..flowrateMethod = json['r_man_flowrate_method'] ..flowrateMethod = json['r_man_flowrate_method']
// FIX: Apply doubleFromJson helper to all new numerical flowrate fields
..flowrateSurfaceDrifterHeight = doubleFromJson(json['r_man_flowrate_sd_height']) ..flowrateSurfaceDrifterHeight = doubleFromJson(json['r_man_flowrate_sd_height'])
..flowrateSurfaceDrifterDistance = doubleFromJson(json['r_man_flowrate_sd_distance']) ..flowrateSurfaceDrifterDistance = doubleFromJson(json['r_man_flowrate_sd_distance'])
..flowrateSurfaceDrifterTimeFirst = json['r_man_flowrate_sd_time_first'] ..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<String, dynamic> 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' /// Creates a single JSON object with all submission data, mimicking 'db.json'
String toDbJson() { String toDbJson() {

View File

@ -1,16 +1,17 @@
// lib/screens/air/manual/data_status_log.dart
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:environment_monitoring_app/auth_provider.dart';
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 { class SubmissionLogEntry {
final String type; final String type;
final String title; final String title;
@ -20,7 +21,9 @@ class SubmissionLogEntry {
final String status; final String status;
final String message; final String message;
final Map<String, dynamic> rawData; final Map<String, dynamic> rawData;
final String serverName; // ADDED final String serverName;
final String? apiStatusRaw;
final String? ftpStatusRaw;
bool isResubmitting; bool isResubmitting;
SubmissionLogEntry({ SubmissionLogEntry({
@ -32,7 +35,9 @@ class SubmissionLogEntry {
required this.status, required this.status,
required this.message, required this.message,
required this.rawData, required this.rawData,
required this.serverName, // ADDED required this.serverName,
this.apiStatusRaw,
this.ftpStatusRaw,
this.isResubmitting = false, this.isResubmitting = false,
}); });
} }
@ -46,14 +51,17 @@ class AirManualDataStatusLog extends StatefulWidget {
class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> { class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
final LocalStorageService _localStorageService = LocalStorageService(); final LocalStorageService _localStorageService = LocalStorageService();
// --- MODIFIED: Use AirSamplingService for resubmission logic --- late ApiService _apiService;
final AirSamplingService _airSamplingService = AirSamplingService(); late AirSamplingService _airSamplingService;
// --- MODIFIED: Simplified state management to a single source of truth ---
List<SubmissionLogEntry> _allLogs = []; List<SubmissionLogEntry> _allLogs = [];
List<SubmissionLogEntry> _filteredLogs = []; List<SubmissionLogEntry> _installationLogs = [];
List<SubmissionLogEntry> _collectionLogs = [];
List<SubmissionLogEntry> _filteredInstallationLogs = [];
List<SubmissionLogEntry> _filteredCollectionLogs = [];
final _searchController = TextEditingController(); final TextEditingController _installationSearchController = TextEditingController();
final TextEditingController _collectionSearchController = TextEditingController();
bool _isLoading = true; bool _isLoading = true;
final Map<String, bool> _isResubmitting = {}; final Map<String, bool> _isResubmitting = {};
@ -61,185 +69,152 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_searchController.addListener(_filterLogs); _apiService = Provider.of<ApiService>(context, listen: false);
_airSamplingService = Provider.of<AirSamplingService>(context, listen: false);
_installationSearchController.addListener(_filterLogs);
_collectionSearchController.addListener(_filterLogs);
_loadAllLogs(); _loadAllLogs();
} }
@override @override
void dispose() { void dispose() {
_searchController.dispose(); _installationSearchController.dispose();
_collectionSearchController.dispose();
super.dispose(); super.dispose();
} }
Future<void> _loadAllLogs() async { Future<void> _loadAllLogs() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
final airLogs = await _localStorageService.getAllAirSamplingLogs(); final airLogs = await _localStorageService.getAllAirSamplingLogs();
final List<SubmissionLogEntry> tempInstallationLogs = [];
final List<SubmissionLogEntry> tempCollectionLogs = [];
final List<SubmissionLogEntry> 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) { tempCollectionLogs.add(SubmissionLogEntry(
try { type: 'Collection',
final hasCollectionData = log['collectionData'] != null && (log['collectionData'] as Map).isNotEmpty; 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 tempInstallationLogs.add(SubmissionLogEntry(
final logType = hasCollectionData ? 'Collection' : 'Installation'; type: 'Installation',
title: log['locationName'] ?? 'Unknown Location',
final stationInfo = log['stationInfo'] ?? {}; stationCode: log['stationID'] ?? 'N/A',
final stationName = stationInfo['station_name'] ?? 'Station ${log['stationID'] ?? 'Unknown'}'; submissionDateTime: DateTime.tryParse('$dateStr $timeStr') ?? DateTime.now(),
final stationCode = stationInfo['station_code'] ?? log['stationID'] ?? 'N/A'; reportId: log['air_man_id']?.toString(),
status: log['status'] ?? 'L1',
final submissionDateTime = logType == 'Installation' message: log['submissionMessage'] ?? 'No status message.',
? _parseInstallationDateTime(log) rawData: log,
: _parseCollectionDateTime(log['collectionData']); serverName: log['serverConfigName'] ?? 'Unknown Server',
apiStatusRaw: log['api_status'],
final entry = SubmissionLogEntry( ftpStatusRaw: log['ftp_status'],
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');
} }
} }
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) { if (mounted) {
setState(() { setState(() {
_allLogs = tempLogs; _installationLogs = tempInstallationLogs;
_collectionLogs = tempCollectionLogs;
_isLoading = false; _isLoading = false;
}); });
_filterLogs(); _filterLogs();
} }
} }
DateTime _parseInstallationDateTime(Map<String, dynamic> 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<String, dynamic>? 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<String, dynamic> 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() { void _filterLogs() {
final query = _searchController.text.toLowerCase(); final installationQuery = _installationSearchController.text.toLowerCase();
final collectionQuery = _collectionSearchController.text.toLowerCase();
setState(() { setState(() {
_filteredLogs = _allLogs.where((log) { _filteredInstallationLogs = _installationLogs.where((log) => _logMatchesQuery(log, installationQuery)).toList();
if (query.isEmpty) return true; _filteredCollectionLogs = _collectionLogs.where((log) => _logMatchesQuery(log, collectionQuery)).toList();
// --- 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();
}); });
} }
// --- 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<void> _resubmitData(SubmissionLogEntry log) async { Future<void> _resubmitData(SubmissionLogEntry log) async {
final logKey = log.rawData['refID']?.toString() ?? log.submissionDateTime.toIso8601String(); final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
if (mounted) setState(() => _isResubmitting[logKey] = true); if (mounted) {
setState(() {
_isResubmitting[logKey] = true;
});
}
try { try {
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings; final appSettings = authProvider.appSettings;
Map<String, dynamic> result; final logData = log.rawData;
Map<String, dynamic> result = {};
// Re-create the data models from the raw log data
final installationData = AirInstallationData.fromJson(log.rawData);
if (log.type == 'Installation') { if (log.type == 'Installation') {
result = await _airSamplingService.submitInstallation(installationData, appSettings); final dataToResubmit = AirInstallationData.fromJson(logData);
} else { final result = await _airSamplingService.submitInstallation(dataToResubmit, appSettings);
final collectionData = AirCollectionData.fromMap(log.rawData['collectionData']); // We only care about the high-level status here as granular status is handled by the service.
result = await _airSamplingService.submitCollection(collectionData, installationData, appSettings); } 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) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(content: Text('Resubmission successful!')),
content: Text(result['message'] ?? 'Resubmission complete.'),
backgroundColor: (result['status'] as String).startsWith('S') ? Colors.green : Colors.red,
),
); );
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Resubmission failed: $e'), backgroundColor: Colors.red), SnackBar(content: Text('Resubmission failed: $e')),
); );
} }
} finally { } finally {
if (mounted) { if (mounted) {
setState(() => _isResubmitting.remove(logKey)); setState(() {
await _loadAllLogs(); // Refresh the log list to show the updated status _isResubmitting.remove(logKey);
});
_loadAllLogs();
} }
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// --- MODIFIED: Logic simplified to work with a single, comprehensive list --- final hasAnyLogs = _installationLogs.isNotEmpty || _collectionLogs.isNotEmpty;
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;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Air Sampling Status Log')), appBar: AppBar(title: const Text('Air Manual Data Status Log')),
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: RefreshIndicator( : RefreshIndicator(
@ -249,56 +224,56 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
: ListView( : ListView(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
children: [ children: [
// General search bar for all logs _buildCategorySection('Installation', _filteredInstallationLogs, _installationSearchController),
Padding( _buildCategorySection('Collection', _filteredCollectionLogs, _collectionSearchController),
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.'),
),
)
], ],
), ),
), ),
); );
} }
Widget _buildCategorySection(String category, List<SubmissionLogEntry> logs) { Widget _buildCategorySection(String category, List<SubmissionLogEntry> logs, TextEditingController searchController) {
return Card( return Card(
margin: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 8.0), margin: const EdgeInsets.symmetric(vertical: 8.0),
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), 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(), const Divider(),
ListView.builder( if (logs.isEmpty)
physics: const NeverScrollableScrollPhysics(), const Padding(
shrinkWrap: true, padding: EdgeInsets.all(16.0),
itemCount: logs.length, child: Center(child: Text('No logs match your search in this category.')))
itemBuilder: (context, index) { else
return _buildLogListItem(logs[index]); 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<AirManualDataStatusLog> {
} }
Widget _buildLogListItem(SubmissionLogEntry log) { Widget _buildLogListItem(SubmissionLogEntry log) {
final isSuccess = log.status.startsWith('S'); final isFailed = !log.status.startsWith('S') && !log.status.startsWith('L4');
final logKey = log.rawData['refID']?.toString() ?? log.submissionDateTime.toIso8601String(); final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
final isResubmitting = _isResubmitting[logKey] ?? false; 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>[
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)}'; final subtitle = '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}';
return ExpansionTile( return ExpansionTile(
key: PageStorageKey(logKey),
leading: Icon( leading: Icon(
isSuccess ? Icons.check_circle_outline : Icons.error_outline, isFailed ? Icons.error_outline : Icons.check_circle_outline,
color: isSuccess ? Colors.green : Colors.red, color: isFailed ? Colors.red : Colors.green,
), ),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), title: titleWidget,
subtitle: Text(subtitle), subtitle: Text(subtitle),
trailing: !isSuccess trailing: isFailed
? (isResubmitting ? (isResubmitting
? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 3)) ? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 3))
: IconButton( : IconButton(icon: const Icon(Icons.sync, color: Colors.blue), tooltip: 'Resubmit', onPressed: () => _resubmitData(log)))
icon: const Icon(Icons.sync, color: Colors.blue),
tooltip: 'Resubmit',
onPressed: () => _resubmitData(log),
))
: null, : null,
children: [ children: [
Padding( Padding(
@ -335,11 +318,13 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// --- MODIFIED: Add server name to the details view --- _buildDetailRow('High-Level Status:', log.status),
_buildDetailRow('Server:', log.serverName), _buildDetailRow('Server:', log.serverName),
_buildDetailRow('Report ID:', log.reportId ?? 'N/A'), _buildDetailRow('Report ID:', log.reportId ?? 'N/A'),
_buildDetailRow('Status:', log.message),
_buildDetailRow('Submission Type:', log.type), _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<AirManualDataStatusLog> {
); );
} }
Widget _buildDetailRow(String label, String value) { Widget _buildGranularStatus(String type, String? jsonStatus) {
if (jsonStatus == null || jsonStatus.isEmpty) {
return Container();
}
List<dynamic> statuses;
try {
statuses = jsonDecode(jsonStatus);
} catch (_) {
return _buildDetailRow('$type Status:', jsonStatus!);
}
if (statuses.isEmpty) {
return Container();
}
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0), padding: const EdgeInsets.only(top: 8.0),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('$label ', style: const TextStyle(fontWeight: FontWeight.bold)), Text('$type Status:', style: const TextStyle(fontWeight: FontWeight.bold)),
Expanded(child: Text(value)), ...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: 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)),
],
),
);
}
}

View File

@ -500,4 +500,4 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
), ),
); );
} }
} }

View File

@ -9,6 +9,10 @@ import 'package:intl/intl.dart';
import '../../../../auth_provider.dart'; import '../../../../auth_provider.dart';
import '../../../../models/air_installation_data.dart'; import '../../../../models/air_installation_data.dart';
import '../../../../services/air_sampling_service.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 { class AirManualInstallationWidget extends StatefulWidget {
final AirInstallationData data; final AirInstallationData data;
@ -211,6 +215,7 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
source, source,
stationCode: stationCode, stationCode: stationCode,
imageInfo: imageInfo.toUpperCase(), imageInfo: imageInfo.toUpperCase(),
processType: 'COLLECT',
isRequired: isRequired, isRequired: isRequired,
); );

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../auth_provider.dart'; import '../auth_provider.dart';
import 'package:environment_monitoring_app/services/api_service.dart'; // Import ApiService for typing
class ForgotPasswordScreen extends StatefulWidget { class ForgotPasswordScreen extends StatefulWidget {
@override @override
@ -15,6 +16,8 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = Provider.of<AuthProvider>(context); final auth = Provider.of<AuthProvider>(context);
// FIX: Retrieve ApiService from the Provider tree
final apiService = Provider.of<ApiService>(context, listen: false);
return Scaffold( return Scaffold(
appBar: AppBar(title: Text("Forgot Password")), appBar: AppBar(title: Text("Forgot Password")),
@ -36,6 +39,11 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
if (_formKey.currentState!.validate()) { 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); auth.resetPassword(email);
setState(() => message = "Reset link sent to $email"); setState(() => message = "Reset link sent to $email");
} }

View File

@ -17,7 +17,7 @@ class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final TextEditingController _emailController = TextEditingController(); final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
final ApiService _apiService = ApiService(); // FIX: Removed direct instantiation of ApiService
bool _isLoading = false; bool _isLoading = false;
String _errorMessage = ''; String _errorMessage = '';
@ -39,6 +39,9 @@ class _LoginScreenState extends State<LoginScreen> {
}); });
final auth = Provider.of<AuthProvider>(context, listen: false); final auth = Provider.of<AuthProvider>(context, listen: false);
// FIX: Retrieve ApiService from the Provider tree
final apiService = Provider.of<ApiService>(context, listen: false);
// --- Offline Check for First Login --- // --- Offline Check for First Login ---
if (auth.isFirstLogin) { if (auth.isFirstLogin) {
@ -55,7 +58,7 @@ class _LoginScreenState extends State<LoginScreen> {
} }
// --- API Call --- // --- API Call ---
final Map<String, dynamic> result = await _apiService.login( final Map<String, dynamic> result = await apiService.login( // FIX: Use retrieved instance
_emailController.text.trim(), _emailController.text.trim(),
_passwordController.text.trim(), _passwordController.text.trim(),
); );
@ -192,4 +195,4 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
); );
} }
} }

View File

@ -59,4 +59,4 @@ class LogoutScreen extends StatelessWidget {
), ),
); );
} }
} }

View File

@ -1,24 +1,30 @@
// lib/screens/marine/manual/data_status_log.dart
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; // Added for accessing AuthProvider import 'package:provider/provider.dart';
import 'package:environment_monitoring_app/auth_provider.dart'; // Added for AuthProvider type import 'package:environment_monitoring_app/auth_provider.dart';
import 'package:environment_monitoring_app/models/tarball_data.dart'; import 'package:environment_monitoring_app/models/tarball_data.dart';
import 'package:environment_monitoring_app/models/in_situ_sampling_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/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 { class SubmissionLogEntry {
final String type; // e.g., 'Manual Sampling', 'Tarball Sampling' final String type; // e.g., 'Manual Sampling', 'Tarball Sampling'
final String title; final String title;
final String stationCode; final String stationCode;
final DateTime submissionDateTime; final DateTime submissionDateTime;
final String? reportId; final String? reportId;
final String status; final String status; // High-level status (S3, L1, etc.)
final String message; final String message;
final Map<String, dynamic> rawData; final Map<String, dynamic> rawData;
final String serverName; // ADDED final String serverName;
final String? apiStatusRaw;
final String? ftpStatusRaw;
bool isResubmitting; bool isResubmitting;
SubmissionLogEntry({ SubmissionLogEntry({
@ -30,7 +36,9 @@ class SubmissionLogEntry {
required this.status, required this.status,
required this.message, required this.message,
required this.rawData, required this.rawData,
required this.serverName, // ADDED required this.serverName,
this.apiStatusRaw,
this.ftpStatusRaw,
this.isResubmitting = false, this.isResubmitting = false,
}); });
} }
@ -44,18 +52,17 @@ class MarineManualDataStatusLog extends StatefulWidget {
class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> { class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
final LocalStorageService _localStorageService = LocalStorageService(); final LocalStorageService _localStorageService = LocalStorageService();
final MarineApiService _marineApiService = MarineApiService(); late ApiService _apiService;
late InSituSamplingService _marineInSituService;
// Raw data lists List<SubmissionLogEntry> _allLogs = [];
List<SubmissionLogEntry> _manualLogs = []; List<SubmissionLogEntry> _manualLogs = [];
List<SubmissionLogEntry> _tarballLogs = []; List<SubmissionLogEntry> _tarballLogs = [];
// Filtered lists for the UI
List<SubmissionLogEntry> _filteredManualLogs = []; List<SubmissionLogEntry> _filteredManualLogs = [];
List<SubmissionLogEntry> _filteredTarballLogs = []; List<SubmissionLogEntry> _filteredTarballLogs = [];
// Per-category search controllers final TextEditingController _manualSearchController = TextEditingController();
final Map<String, TextEditingController> _searchControllers = {}; final TextEditingController _tarballSearchController = TextEditingController();
bool _isLoading = true; bool _isLoading = true;
final Map<String, bool> _isResubmitting = {}; final Map<String, bool> _isResubmitting = {};
@ -63,15 +70,17 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_searchControllers['Manual Sampling'] = TextEditingController()..addListener(_filterLogs); _apiService = Provider.of<ApiService>(context, listen: false);
_searchControllers['Tarball Sampling'] = TextEditingController()..addListener(_filterLogs); _marineInSituService = Provider.of<InSituSamplingService>(context, listen: false);
_manualSearchController.addListener(_filterLogs);
_tarballSearchController.addListener(_filterLogs);
_loadAllLogs(); _loadAllLogs();
} }
@override @override
void dispose() { void dispose() {
_searchControllers['Manual Sampling']?.dispose(); _manualSearchController.dispose();
_searchControllers['Tarball Sampling']?.dispose(); _tarballSearchController.dispose();
super.dispose(); super.dispose();
} }
@ -84,7 +93,6 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
final List<SubmissionLogEntry> tempManual = []; final List<SubmissionLogEntry> tempManual = [];
final List<SubmissionLogEntry> tempTarball = []; final List<SubmissionLogEntry> tempTarball = [];
// Map In-Situ logs to Manual Sampling
for (var log in inSituLogs) { for (var log in inSituLogs) {
final String dateStr = log['data_capture_date'] ?? log['sampling_date'] ?? ''; final String dateStr = log['data_capture_date'] ?? log['sampling_date'] ?? '';
final String timeStr = log['data_capture_time'] ?? log['sampling_time'] ?? ''; final String timeStr = log['data_capture_time'] ?? log['sampling_time'] ?? '';
@ -98,12 +106,12 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
status: log['submissionStatus'] ?? 'L1', status: log['submissionStatus'] ?? 'L1',
message: log['submissionMessage'] ?? 'No status message.', message: log['submissionMessage'] ?? 'No status message.',
rawData: log, rawData: log,
// --- MODIFIED: Extract the server name from the log data ---
serverName: log['serverConfigName'] ?? 'Unknown Server', serverName: log['serverConfigName'] ?? 'Unknown Server',
apiStatusRaw: log['api_status'],
ftpStatusRaw: log['ftp_status'],
)); ));
} }
// Map Tarball logs
for (var log in tarballLogs) { for (var log in tarballLogs) {
tempTarball.add(SubmissionLogEntry( tempTarball.add(SubmissionLogEntry(
type: 'Tarball Sampling', type: 'Tarball Sampling',
@ -114,8 +122,9 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
status: log['submissionStatus'] ?? 'L1', status: log['submissionStatus'] ?? 'L1',
message: log['submissionMessage'] ?? 'No status message.', message: log['submissionMessage'] ?? 'No status message.',
rawData: log, rawData: log,
// --- MODIFIED: Extract the server name from the log data ---
serverName: log['serverConfigName'] ?? 'Unknown Server', serverName: log['serverConfigName'] ?? 'Unknown Server',
apiStatusRaw: log['api_status'],
ftpStatusRaw: log['ftp_status'],
)); ));
} }
@ -128,13 +137,13 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
_tarballLogs = tempTarball; _tarballLogs = tempTarball;
_isLoading = false; _isLoading = false;
}); });
_filterLogs(); // Perform initial filter _filterLogs();
} }
} }
void _filterLogs() { void _filterLogs() {
final manualQuery = _searchControllers['Manual Sampling']?.text.toLowerCase() ?? ''; final manualQuery = _manualSearchController.text.toLowerCase();
final tarballQuery = _searchControllers['Tarball Sampling']?.text.toLowerCase() ?? ''; final tarballQuery = _tarballSearchController.text.toLowerCase();
setState(() { setState(() {
_filteredManualLogs = _manualLogs.where((log) => _logMatchesQuery(log, manualQuery)).toList(); _filteredManualLogs = _manualLogs.where((log) => _logMatchesQuery(log, manualQuery)).toList();
@ -144,14 +153,12 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
bool _logMatchesQuery(SubmissionLogEntry log, String query) { bool _logMatchesQuery(SubmissionLogEntry log, String query) {
if (query.isEmpty) return true; if (query.isEmpty) return true;
// --- MODIFIED: Add serverName to search criteria ---
return log.title.toLowerCase().contains(query) || return log.title.toLowerCase().contains(query) ||
log.stationCode.toLowerCase().contains(query) || log.stationCode.toLowerCase().contains(query) ||
log.serverName.toLowerCase().contains(query) || log.serverName.toLowerCase().contains(query) ||
(log.reportId?.toLowerCase() ?? '').contains(query); (log.reportId?.toLowerCase() ?? '').contains(query);
} }
// MODIFIED: This method now fetches appSettings from AuthProvider before resubmitting.
Future<void> _resubmitData(SubmissionLogEntry log) async { Future<void> _resubmitData(SubmissionLogEntry log) async {
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
if (mounted) { if (mounted) {
@ -161,23 +168,84 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
} }
try { try {
// Get the appSettings from the AuthProvider to pass to the API service.
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings; final appSettings = authProvider.appSettings;
final result = await _performResubmission(log, appSettings);
final logData = log.rawData; final logData = log.rawData;
Map<String, dynamic> result = {};
logData['submissionStatus'] = result['status'];
logData['submissionMessage'] = result['message'];
logData['reportId'] = result['reportId']?.toString() ?? logData['reportId'];
if (log.type == 'Manual Sampling') { if (log.type == 'Manual Sampling') {
await _localStorageService.updateInSituLog(logData); final dataToResubmit = InSituSamplingData.fromJson(logData);
final Map<String, File?> imageFiles = {};
dataToResubmit.toApiImageFiles().keys.forEach((key) {
final imagePath = logData[key];
if (imagePath is String && imagePath.isNotEmpty) {
imageFiles[key] = File(imagePath);
}
});
result = await _apiService.marine.submitInSituSample(
formData: dataToResubmit.toApiFormData(),
imageFiles: imageFiles,
inSituData: dataToResubmit,
appSettings: appSettings,
);
} else if (log.type == 'Tarball Sampling') { } 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<String, File?> imageFiles = {};
dataToResubmit.toImageFiles().keys.forEach((key) {
final imagePath = logData[key];
if (imagePath is String && imagePath.isNotEmpty) {
imageFiles[key] = File(imagePath);
}
});
result = await _apiService.marine.submitTarballSample(
formData: dataToResubmit.toFormData(),
imageFiles: imageFiles,
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) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Resubmission successful!')), const SnackBar(content: Text('Resubmission successful!')),
@ -194,119 +262,17 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
setState(() { setState(() {
_isResubmitting.remove(logKey); _isResubmitting.remove(logKey);
}); });
await _loadAllLogs(); _loadAllLogs();
} }
} }
} }
// MODIFIED: This method now requires appSettings to pass to the API service.
Future<Map<String, dynamic>> _performResubmission(SubmissionLogEntry log, List<Map<String, dynamic>>? 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<String, File?> 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<String, File?> 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasAnyLogs = _manualLogs.isNotEmpty || _tarballLogs.isNotEmpty; final hasAnyLogs = _manualLogs.isNotEmpty || _tarballLogs.isNotEmpty;
final hasFilteredLogs = _filteredManualLogs.isNotEmpty || _filteredTarballLogs.isNotEmpty;
final logCategories = {
'Manual Sampling': _filteredManualLogs,
'Tarball Sampling': _filteredTarballLogs,
};
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Marine Data Status Log')), appBar: AppBar(title: const Text('Marine Manual Data Status Log')),
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: RefreshIndicator( : RefreshIndicator(
@ -316,25 +282,15 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
: ListView( : ListView(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
children: [ children: [
...logCategories.entries _buildCategorySection('Manual Sampling', _filteredManualLogs, _manualSearchController),
.where((entry) => entry.value.isNotEmpty) _buildCategorySection('Tarball Sampling', _filteredTarballLogs, _tarballSearchController),
.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.'),
),
)
], ],
), ),
), ),
); );
} }
Widget _buildCategorySection(String category, List<SubmissionLogEntry> logs) { Widget _buildCategorySection(String category, List<SubmissionLogEntry> logs, TextEditingController searchController) {
final listHeight = (logs.length > 5 ? 5.5 : logs.length.toDouble()) * 75.0;
return Card( return Card(
margin: const EdgeInsets.symmetric(vertical: 8.0), margin: const EdgeInsets.symmetric(vertical: 8.0),
child: Padding( child: Padding(
@ -346,30 +302,36 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),
child: TextField( child: TextField(
controller: _searchControllers[category], controller: searchController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search in $category...', hintText: 'Search in $category...',
prefixIcon: const Icon(Icons.search, size: 20), prefixIcon: const Icon(Icons.search, size: 20),
isDense: true, isDense: true,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
searchController.clear();
_filterLogs();
},
),
), ),
), ),
), ),
const Divider(), const Divider(),
logs.isEmpty if (logs.isEmpty)
? const Padding( const Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
child: Center(child: Text('No logs match your search in this category.'))) child: Center(child: Text('No logs match your search in this category.')))
: ConstrainedBox( else
constraints: BoxConstraints(maxHeight: listHeight), ListView.builder(
child: ListView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: logs.length, itemCount: logs.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _buildLogListItem(logs[index]); return _buildLogListItem(logs[index]);
}, },
), ),
),
], ],
), ),
), ),
@ -377,19 +339,31 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
} }
Widget _buildLogListItem(SubmissionLogEntry log) { 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 logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
final isResubmitting = _isResubmitting[logKey] ?? false; 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>[
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)}'; final subtitle = '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}';
return ExpansionTile( return ExpansionTile(
key: PageStorageKey(logKey),
leading: Icon( leading: Icon(
isFailed ? Icons.error_outline : Icons.check_circle_outline, isFailed ? Icons.error_outline : Icons.check_circle_outline,
color: isFailed ? Colors.red : Colors.green, color: isFailed ? Colors.red : Colors.green,
), ),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), title: titleWidget,
subtitle: Text(subtitle), subtitle: Text(subtitle),
trailing: isFailed trailing: isFailed
? (isResubmitting ? (isResubmitting
@ -402,11 +376,13 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// --- MODIFIED: Add server name to the details view --- _buildDetailRow('High-Level Status:', log.status),
_buildDetailRow('Server:', log.serverName), _buildDetailRow('Server:', log.serverName),
_buildDetailRow('Report ID:', log.reportId ?? 'N/A'), _buildDetailRow('Report ID:', log.reportId ?? 'N/A'),
_buildDetailRow('Status:', log.message),
_buildDetailRow('Submission Type:', log.type), _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<MarineManualDataStatusLog> {
); );
} }
Widget _buildDetailRow(String label, String value) { Widget _buildGranularStatus(String type, String? jsonStatus) {
if (jsonStatus == null || jsonStatus.isEmpty) {
return Container();
}
List<dynamic> statuses;
try {
statuses = jsonDecode(jsonStatus);
} catch (_) {
return _buildDetailRow('$type Status:', jsonStatus!);
}
if (statuses.isEmpty) {
return Container();
}
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0), padding: const EdgeInsets.only(top: 8.0),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('$label ', style: const TextStyle(fontWeight: FontWeight.bold)), Text('$type Status:', style: const TextStyle(fontWeight: FontWeight.bold)),
Expanded(child: Text(value)), ...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: 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)),
],
),
);
}
}

View File

@ -26,8 +26,7 @@ class TarballSamplingStep3Summary extends StatefulWidget {
} }
class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summary> { class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summary> {
// CORRECTED: Use the main ApiService to access its marine property. // FIX: Removed direct instantiation of ApiService.
final ApiService _apiService = ApiService();
final LocalStorageService _localStorageService = LocalStorageService(); final LocalStorageService _localStorageService = LocalStorageService();
// --- ADDED: Service to get the active server configuration --- // --- ADDED: Service to get the active server configuration ---
final ServerConfigService _serverConfigService = ServerConfigService(); final ServerConfigService _serverConfigService = ServerConfigService();
@ -43,45 +42,77 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
setState(() => _isLoading = true); setState(() => _isLoading = true);
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
// FIX: Retrieve ApiService from the Provider tree
final apiService = Provider.of<ApiService>(context, listen: false);
final appSettings = authProvider.appSettings; final appSettings = authProvider.appSettings;
final activeApiConfig = await _serverConfigService.getActiveApiConfig(); final activeApiConfig = await _serverConfigService.getActiveApiConfig();
final serverName = activeApiConfig?['config_name'] as String? ?? 'Default'; 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(); final ftpConfigs = (await _dbHelper.loadFtpConfigs() ?? []).take(2).toList();
// Create a temporary, separate copy of the data for the FTP process // Create a temporary, separate copy of the data for the FTP process
final dataForFtp = widget.data; final dataForFtp = widget.data;
bool apiSuccess = false; bool apiSuccess = false;
bool ftpSuccess = false; bool ftpQueueSuccess = false;
List<Map<String, dynamic>> apiStatuses = [];
List<Map<String, dynamic>> ftpStatuses = [];
String finalStatus = 'L1';
String finalMessage = 'All submission attempts failed. Data saved locally for retry.';
// --- Step 1: Attempt API Submission --- // --- Step 1: Attempt API Submission ---
debugPrint("Step 1: Attempting API submission..."); debugPrint("Step 1: Attempting API submission...");
try { final apiResult = await apiService.marine.submitTarballSample( // FIX: Use retrieved ApiService
final apiResult = await _apiService.marine.submitTarballSample( formData: widget.data.toFormData(),
formData: widget.data.toFormData(), imageFiles: widget.data.toImageFiles(),
imageFiles: widget.data.toImageFiles(), appSettings: appSettings,
appSettings: appSettings, );
);
apiSuccess = apiResult['success'] == true; apiSuccess = apiResult['success'] == true;
widget.data.submissionStatus = apiResult['status']; widget.data.reportId = apiResult['reportId']?.toString();
widget.data.submissionMessage = apiResult['message']; final serverReportId = widget.data.reportId;
widget.data.reportId = apiResult['reportId']?.toString();
debugPrint("API submission successful."); // Determine granular API statuses (Simulation based on BaseApiService trying 2 servers)
} catch (e) { for (int i = 0; i < apiConfigs.length; i++) {
debugPrint("API submission failed with a critical error: $e"); final config = apiConfigs[i];
apiSuccess = false; 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) { if (ftpConfigs.isNotEmpty) {
debugPrint("Step 2: FTP server configured. Proceeding with zipping and queuing."); debugPrint("Step 2: FTP server configured. Proceeding with zipping and queuing.");
final stationCode = dataForFtp.selectedStation?['tbl_station_code'] ?? 'NA'; 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'; 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 { try {
final Map<String, String> jsonDataMap = { final Map<String, String> jsonDataMap = {
'db.json': jsonEncode(dataForFtp.toDbJson()), 'db.json': jsonEncode(dataForFtp.toDbJson()),
@ -99,50 +130,116 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
baseFileName: baseFileName, baseFileName: baseFileName,
); );
if (dataZip != null) { // Queue for each configured FTP server
await _retryService.addFtpToQueue( for (var config in ftpConfigs) {
localFilePath: dataZip.path, // Note: We use the RetryService method here, which queues if the upload fails,
remotePath: '/uploads/data/${p.basename(dataZip.path)}', // but since we are not *uploading* here, we just queue everything for subsequent FTP process.
);
ftpSuccess = true; String status;
} String message;
if (imageZip != null) {
await _retryService.addFtpToQueue( if (dataZip != null) {
localFilePath: imageZip.path, await _retryService.addFtpToQueue(
remotePath: '/uploads/images/${p.basename(imageZip.path)}', localFilePath: dataZip.path,
); remotePath: '/uploads/data/${p.basename(dataZip.path)}',
ftpSuccess = true; );
dataZipQueued = true;
status = "QUEUED";
message = "Data zip queued successfully.";
} else {
status = "FAILED (ZIP)";
message = "Data zip file could not be created.";
}
ftpStatuses.add({
"server_name": config['config_name'],
"status": status,
"message": message,
"type": "data",
});
// Queue images only if they exist
if (imageZip != null) {
await _retryService.addFtpToQueue(
localFilePath: imageZip.path,
remotePath: '/uploads/images/${p.basename(imageZip.path)}',
);
imageZipQueued = true;
status = "QUEUED";
message = "Image zip queued successfully.";
} else {
status = "NOT_REQUIRED/N/A";
message = "No images to queue or zip failed.";
}
ftpStatuses.add({
"server_name": config['config_name'],
"status": status,
"message": message,
"type": "images",
});
} }
ftpQueueSuccess = dataZipQueued || imageZipQueued;
} catch (e) { } catch (e) {
debugPrint("FTP zipping or queuing failed with an error: $e"); debugPrint("FTP zipping or queuing failed with an error: $e");
ftpSuccess = false; ftpQueueSuccess = false;
} }
}
// --- Step 3: Save the final status to the local log and handle UI feedback ---
if (!mounted) return;
if (apiSuccess && ftpSuccess) {
widget.data.submissionStatus = 'S4'; // Submitted API, Queued FTP
widget.data.submissionMessage = 'Data submitted and files are queued for FTP upload.';
} else if (apiSuccess) {
widget.data.submissionStatus = 'S3'; // Submitted API Only
widget.data.submissionMessage = 'Data submitted successfully to API. No FTP configured or FTP failed.';
} else if (ftpSuccess) {
widget.data.submissionStatus = 'L4'; // Failed API, Queued FTP
widget.data.submissionMessage = 'API submission failed but files were successfully queued for FTP.';
} else { } else {
widget.data.submissionStatus = 'L1'; // All submissions failed ftpStatuses.add({
widget.data.submissionMessage = 'All submission attempts failed. Data saved locally for retry.'; "server_name": "N/A",
"status": "NOT_CONFIGURED",
"message": "No FTP servers configured.",
});
} }
// Always save the final status to the local log regardless of submission outcome // --- Step 3: Determine Final Status and Log to DB ---
if (apiSuccess && ftpQueueSuccess) {
finalStatus = 'S4'; // Submitted API, Queued FTP
finalMessage = 'Data submitted to API and files queued for FTP upload.';
} else if (apiSuccess) {
finalStatus = 'S3'; // Submitted API Only
finalMessage = 'Data submitted successfully to API. FTP queueing failed or not configured.';
} else if (ftpQueueSuccess) {
finalStatus = 'L4'; // Failed API, Queued FTP
finalMessage = 'API submission failed but files were successfully queued for FTP.';
} else {
finalStatus = 'L1'; // All submissions failed
finalMessage = 'All submission attempts failed. Data saved locally for retry.';
}
// Set final high-level status in the model
widget.data.submissionStatus = finalStatus;
widget.data.submissionMessage = finalMessage;
// 4. Save the final high-level status (to file system for resubmission tracking)
await _localStorageService.saveTarballSamplingData(widget.data, serverName: serverName); await _localStorageService.saveTarballSamplingData(widget.data, serverName: serverName);
// 5. Save granular status to the central database log
final logData = {
'submission_id': widget.data.reportId ?? widget.data.samplingDate!,
'module': 'marine',
'type': 'Tarball Sampling',
'status': finalStatus,
'message': finalMessage,
'report_id': widget.data.reportId,
'created_at': DateTime.now().toIso8601String(),
'form_data': jsonEncode(widget.data.toDbJson()),
'image_data': jsonEncode(widget.data.toImageFiles().keys.map((k) => widget.data.toImageFiles()[k]?.path).where((p) => p != null).toList()),
'server_name': serverName,
'api_status': jsonEncode(apiStatuses), // GRANULAR API STATUSES
'ftp_status': jsonEncode(ftpStatuses), // GRANULAR FTP STATUSES
};
await _dbHelper.saveSubmissionLog(logData);
setState(() => _isLoading = false); setState(() => _isLoading = false);
final message = widget.data.submissionMessage ?? 'An unknown error occurred.'; 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( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)), SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
); );

View File

@ -16,20 +16,27 @@ class ProfileScreen extends StatefulWidget {
} }
class _ProfileScreenState extends State<ProfileScreen> { class _ProfileScreenState extends State<ProfileScreen> {
final ApiService _apiService = ApiService(); // FIX: Removed direct instantiation of ApiService
bool _isLoading = false; bool _isLoading = false;
String _errorMessage = ''; String _errorMessage = '';
File? _profileImageFile; File? _profileImageFile;
// FIX: Use late initialization to retrieve the service instance.
late ApiService _apiService;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Load the image from cache first, then refresh data from the provider
_loadLocalProfileImage().then((_) { // FIX: Retrieve the ApiService instance after the context is fully available.
// If no profile data is available at all, trigger a refresh WidgetsBinding.instance.addPostFrameCallback((_) {
if (Provider.of<AuthProvider>(context, listen: false).profileData == null) { _apiService = Provider.of<ApiService>(context, listen: false);
_refreshProfile(); _loadLocalProfileImage().then((_) {
} // If no profile data is available at all, trigger a refresh
if (Provider.of<AuthProvider>(context, listen: false).profileData == null) {
_refreshProfile();
}
});
}); });
} }
@ -76,6 +83,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
if (mounted) setState(() => _profileImageFile = localFile); if (mounted) setState(() => _profileImageFile = localFile);
} else { } else {
final String fullImageUrl = ApiService.imageBaseUrl + serverImagePath; final String fullImageUrl = ApiService.imageBaseUrl + serverImagePath;
// FIX: Use the injected _apiService instance
final downloadedFile = await _apiService.downloadProfilePicture(fullImageUrl, localFilePath); final downloadedFile = await _apiService.downloadProfilePicture(fullImageUrl, localFilePath);
if (downloadedFile != null && mounted) { if (downloadedFile != null && mounted) {
setState(() => _profileImageFile = downloadedFile); setState(() => _profileImageFile = downloadedFile);
@ -127,6 +135,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
setState(() => _isLoading = true); setState(() => _isLoading = true);
final File imageFile = File(pickedFile.path); final File imageFile = File(pickedFile.path);
// FIX: Use the injected _apiService instance
final uploadResult = await _apiService.uploadProfilePicture(imageFile); final uploadResult = await _apiService.uploadProfilePicture(imageFile);
if (mounted) { if (mounted) {
@ -354,4 +363,4 @@ class _ProfileScreenState extends State<ProfileScreen> {
), ),
); );
} }
} }

View File

@ -15,7 +15,7 @@ class RegisterScreen extends StatefulWidget {
class _RegisterScreenState extends State<RegisterScreen> { class _RegisterScreenState extends State<RegisterScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final ApiService _apiService = ApiService(); // FIX: Removed direct instantiation of ApiService
bool _isLoading = false; bool _isLoading = false;
String _errorMessage = ''; String _errorMessage = '';
@ -55,6 +55,9 @@ class _RegisterScreenState extends State<RegisterScreen> {
_errorMessage = ''; _errorMessage = '';
}); });
// FIX: Retrieve ApiService from the Provider tree
final apiService = Provider.of<ApiService>(context, listen: false);
final connectivityResult = await Connectivity().checkConnectivity(); final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult == ConnectivityResult.none) { if (connectivityResult == ConnectivityResult.none) {
if (!mounted) return; if (!mounted) return;
@ -66,7 +69,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
return; return;
} }
final result = await _apiService.register( final result = await apiService.register( // FIX: Use retrieved instance
username: _usernameController.text.trim(), username: _usernameController.text.trim(),
firstName: _firstNameController.text.trim(), firstName: _firstNameController.text.trim(),
lastName: _lastNameController.text.trim(), lastName: _lastNameController.text.trim(),
@ -221,4 +224,4 @@ class _RegisterScreenState extends State<RegisterScreen> {
), ),
); );
} }
} }

View File

@ -1,12 +1,15 @@
// lib/screens/river/manual/data_status_log.dart
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; // ADDED: Import for Provider import 'package:provider/provider.dart';
import 'package:environment_monitoring_app/auth_provider.dart'; // ADDED: Import for AuthProvider import 'package:environment_monitoring_app/auth_provider.dart';
import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.dart';
import '../../../../models/river_in_situ_sampling_data.dart'; import 'package:environment_monitoring_app/services/local_storage_service.dart';
import '../../../../services/local_storage_service.dart'; import 'package:environment_monitoring_app/services/api_service.dart';
import '../../../../services/river_api_service.dart'; import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart';
import 'dart:convert';
class SubmissionLogEntry { class SubmissionLogEntry {
final String type; final String type;
@ -17,6 +20,9 @@ class SubmissionLogEntry {
final String status; final String status;
final String message; final String message;
final Map<String, dynamic> rawData; final Map<String, dynamic> rawData;
final String serverName;
final String? apiStatusRaw;
final String? ftpStatusRaw;
bool isResubmitting; bool isResubmitting;
SubmissionLogEntry({ SubmissionLogEntry({
@ -28,51 +34,43 @@ class SubmissionLogEntry {
required this.status, required this.status,
required this.message, required this.message,
required this.rawData, required this.rawData,
required this.serverName,
this.apiStatusRaw,
this.ftpStatusRaw,
this.isResubmitting = false, this.isResubmitting = false,
}); });
} }
class RiverDataStatusLog extends StatefulWidget { class RiverManualDataStatusLog extends StatefulWidget {
const RiverDataStatusLog({super.key}); const RiverManualDataStatusLog({super.key});
@override @override
State<RiverDataStatusLog> createState() => _RiverDataStatusLogState(); State<RiverManualDataStatusLog> createState() => _RiverManualDataStatusLogState();
} }
class _RiverDataStatusLogState extends State<RiverDataStatusLog> { class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
final LocalStorageService _localStorageService = LocalStorageService(); final LocalStorageService _localStorageService = LocalStorageService();
final RiverApiService _riverApiService = RiverApiService(); late ApiService _apiService;
late RiverInSituSamplingService _riverInSituService;
// Raw data lists
List<SubmissionLogEntry> _scheduleLogs = [];
List<SubmissionLogEntry> _triennialLogs = [];
List<SubmissionLogEntry> _otherLogs = [];
// Filtered lists for the UI
List<SubmissionLogEntry> _filteredScheduleLogs = [];
List<SubmissionLogEntry> _filteredTriennialLogs = [];
List<SubmissionLogEntry> _filteredOtherLogs = [];
// Per-category search controllers
final Map<String, TextEditingController> _searchControllers = {};
List<SubmissionLogEntry> _allLogs = [];
List<SubmissionLogEntry> _filteredLogs = [];
final TextEditingController _searchController = TextEditingController();
bool _isLoading = true; bool _isLoading = true;
final Map<String, bool> _isResubmitting = {}; final Map<String, bool> _isResubmitting = {};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_searchControllers['Schedule'] = TextEditingController()..addListener(_filterLogs); _apiService = Provider.of<ApiService>(context, listen: false);
_searchControllers['Triennial'] = TextEditingController()..addListener(_filterLogs); _riverInSituService = Provider.of<RiverInSituSamplingService>(context, listen: false);
_searchControllers['Others'] = TextEditingController()..addListener(_filterLogs); _searchController.addListener(_filterLogs);
_loadAllLogs(); _loadAllLogs();
} }
@override @override
void dispose() { void dispose() {
_searchControllers['Schedule']?.dispose(); _searchController.dispose();
_searchControllers['Triennial']?.dispose();
_searchControllers['Others']?.dispose();
super.dispose(); super.dispose();
} }
@ -80,60 +78,83 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
setState(() => _isLoading = true); setState(() => _isLoading = true);
final riverLogs = await _localStorageService.getAllRiverInSituLogs(); final riverLogs = await _localStorageService.getAllRiverInSituLogs();
final List<SubmissionLogEntry> tempLogs = [];
final List<SubmissionLogEntry> tempSchedule = []; if (riverLogs != null) {
final List<SubmissionLogEntry> tempTriennial = []; for (var log in riverLogs) {
final List<SubmissionLogEntry> tempOthers = []; final entry = _createLogEntry(log);
if (entry != null) {
for (var log in riverLogs) { tempLogs.add(entry);
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;
} }
} }
tempSchedule.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); tempLogs.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));
if (mounted) { if (mounted) {
setState(() { setState(() {
_scheduleLogs = tempSchedule; _allLogs = tempLogs;
_triennialLogs = tempTriennial;
_otherLogs = tempOthers;
_isLoading = false; _isLoading = false;
}); });
_filterLogs(); // Perform initial filter _filterLogs();
} }
} }
void _filterLogs() { SubmissionLogEntry? _createLogEntry(Map<String, dynamic> log) {
final scheduleQuery = _searchControllers['Schedule']?.text.toLowerCase() ?? ''; String? type;
final triennialQuery = _searchControllers['Triennial']?.text.toLowerCase() ?? ''; String? title;
final otherQuery = _searchControllers['Others']?.text.toLowerCase() ?? ''; 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(() { setState(() {
_filteredScheduleLogs = _scheduleLogs.where((log) => _logMatchesQuery(log, scheduleQuery)).toList(); _filteredLogs = _allLogs.where((log) => _logMatchesQuery(log, query)).toList();
_filteredTriennialLogs = _triennialLogs.where((log) => _logMatchesQuery(log, triennialQuery)).toList();
_filteredOtherLogs = _otherLogs.where((log) => _logMatchesQuery(log, otherQuery)).toList();
}); });
} }
@ -141,10 +162,11 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
if (query.isEmpty) return true; if (query.isEmpty) return true;
return log.title.toLowerCase().contains(query) || return log.title.toLowerCase().contains(query) ||
log.stationCode.toLowerCase().contains(query) || log.stationCode.toLowerCase().contains(query) ||
log.serverName.toLowerCase().contains(query) ||
log.type.toLowerCase().contains(query) ||
(log.reportId?.toLowerCase() ?? '').contains(query); (log.reportId?.toLowerCase() ?? '').contains(query);
} }
// MODIFIED: This method now fetches appSettings from AuthProvider before resubmitting.
Future<void> _resubmitData(SubmissionLogEntry log) async { Future<void> _resubmitData(SubmissionLogEntry log) async {
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
if (mounted) { if (mounted) {
@ -154,36 +176,32 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
} }
try { try {
// Get the appSettings from the AuthProvider to pass to the API service.
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings; final appSettings = authProvider.appSettings;
final logData = log.rawData; final logData = log.rawData;
final dataToResubmit = RiverInSituSamplingData.fromJson(logData); final dataToResubmit = RiverInSituSamplingData.fromJson(logData);
final Map<String, File?> imageFiles = {}; final Map<String, File?> imageFiles = {};
dataToResubmit.toApiImageFiles().keys.forEach((key) {
final imageApiKeys = dataToResubmit.toApiImageFiles().keys;
for (var key in imageApiKeys) {
final imagePath = logData[key]; final imagePath = logData[key];
if (imagePath is String && imagePath.isNotEmpty) { if (imagePath is String && imagePath.isNotEmpty) {
final file = File(imagePath); imageFiles[key] = File(imagePath);
if (await file.exists()) {
imageFiles[key] = file;
}
} }
} });
final result = await _apiService.river.submitInSituSample(
// Pass the appSettings list to the submit method.
final result = await _riverApiService.submitInSituSample(
formData: dataToResubmit.toApiFormData(), formData: dataToResubmit.toApiFormData(),
imageFiles: imageFiles, imageFiles: imageFiles,
appSettings: appSettings, appSettings: appSettings,
); );
logData['submissionStatus'] = result['status']; final updatedLogData = log.rawData;
logData['submissionMessage'] = result['message']; updatedLogData['submissionStatus'] = result['status'];
logData['reportId'] = result['reportId']?.toString() ?? logData['reportId']; updatedLogData['submissionMessage'] = result['message'];
await _localStorageService.updateRiverInSituLog(logData); 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) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -201,24 +219,26 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
setState(() { setState(() {
_isResubmitting.remove(logKey); _isResubmitting.remove(logKey);
}); });
await _loadAllLogs(); _loadAllLogs();
} }
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasAnyLogs = _scheduleLogs.isNotEmpty || _triennialLogs.isNotEmpty || _otherLogs.isNotEmpty; final hasAnyLogs = _allLogs.isNotEmpty;
final hasFilteredLogs = _filteredScheduleLogs.isNotEmpty || _filteredTriennialLogs.isNotEmpty || _filteredOtherLogs.isNotEmpty; final hasFilteredLogs = _filteredLogs.isNotEmpty;
final logCategories = { final Map<String, List<SubmissionLogEntry>> groupedLogs = {};
'Schedule': _filteredScheduleLogs, for (var log in _filteredLogs) {
'Triennial': _filteredTriennialLogs, if (!groupedLogs.containsKey(log.type)) {
'Others': _filteredOtherLogs, groupedLogs[log.type] = [];
}; }
groupedLogs[log.type]!.add(log);
}
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('River Data Status Log')), appBar: AppBar(title: const Text('River Manual Data Status Log')),
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: RefreshIndicator( : RefreshIndicator(
@ -228,16 +248,35 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
: ListView( : ListView(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
children: [ children: [
...logCategories.entries Padding(
.where((entry) => entry.value.isNotEmpty) padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0),
.map((entry) => _buildCategorySection(entry.key, entry.value)), child: TextField(
if (!hasFilteredLogs && hasAnyLogs) 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( const Center(
child: Padding( child: Padding(
padding: EdgeInsets.all(24.0), padding: EdgeInsets.all(24.0),
child: Text('No logs match your search.'), 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<RiverDataStatusLog> {
} }
Widget _buildCategorySection(String category, List<SubmissionLogEntry> logs) { Widget _buildCategorySection(String category, List<SubmissionLogEntry> logs) {
final listHeight = (logs.length > 5 ? 5.5 : logs.length.toDouble()) * 75.0;
return Card( return Card(
margin: const EdgeInsets.symmetric(vertical: 8.0), margin: const EdgeInsets.symmetric(vertical: 8.0),
child: Padding( child: Padding(
@ -255,32 +292,14 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), 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(), const Divider(),
logs.isEmpty ListView.builder(
? const Padding( shrinkWrap: true,
padding: EdgeInsets.all(16.0), physics: const NeverScrollableScrollPhysics(),
child: Center(child: Text('No logs match your search in this category.'))) itemCount: logs.length,
: ConstrainedBox( itemBuilder: (context, index) {
constraints: BoxConstraints(maxHeight: listHeight), return _buildLogListItem(logs[index]);
child: ListView.builder( },
shrinkWrap: true,
itemCount: logs.length,
itemBuilder: (context, index) {
return _buildLogListItem(logs[index]);
},
),
), ),
], ],
), ),
@ -289,50 +308,117 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
} }
Widget _buildLogListItem(SubmissionLogEntry log) { 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 logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
final isResubmitting = _isResubmitting[logKey] ?? false; final isResubmitting = _isResubmitting[logKey] ?? false;
final title = '${log.title} (${log.stationCode})';
final subtitle = DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime);
return ExpansionTile( final titleWidget = RichText(
leading: Icon( text: TextSpan(
isFailed ? Icons.error_outline : Icons.check_circle_outline, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
color: isFailed ? Colors.red : Colors.green, children: <TextSpan>[
), TextSpan(text: '${log.title} '),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), TextSpan(
subtitle: Text(subtitle), text: '(${log.stationCode})',
trailing: isFailed style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.normal),
? (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 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<dynamic> 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) { Widget _buildDetailRow(String label, String value) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0), padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('$label ', style: const TextStyle(fontWeight: FontWeight.bold)), Expanded(flex: 2, child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold))),
Expanded(child: Text(value)), const SizedBox(width: 8),
Expanded(flex: 3, child: Text(value)),
], ],
), ),
); );
} }
} }

View File

@ -35,7 +35,8 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
late RiverInSituSamplingData _data; late RiverInSituSamplingData _data;
final RiverInSituSamplingService _samplingService = RiverInSituSamplingService(); // FIX: _samplingService is now retrieved via Provider in _submitForm
// final RiverInSituSamplingService _samplingService = RiverInSituSamplingService();
final LocalStorageService _localStorageService = LocalStorageService(); final LocalStorageService _localStorageService = LocalStorageService();
// --- ADDED: Service to get the active server configuration --- // --- ADDED: Service to get the active server configuration ---
final ServerConfigService _serverConfigService = ServerConfigService(); final ServerConfigService _serverConfigService = ServerConfigService();
@ -46,6 +47,9 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
int _currentPage = 0; int _currentPage = 0;
bool _isLoading = false; bool _isLoading = false;
// FIX: Use late initialization to retrieve service instances in the build method.
late RiverInSituSamplingService _samplingService;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -53,12 +57,16 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()), samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()),
samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()), samplingTime: DateFormat('HH:mm:ss').format(DateTime.now()),
); );
// Initialize services that require context/Provider access later
WidgetsBinding.instance.addPostFrameCallback((_) {
_samplingService = Provider.of<RiverInSituSamplingService>(context, listen: false);
});
} }
@override @override
void dispose() { void dispose() {
_pageController.dispose(); _pageController.dispose();
_samplingService.dispose(); // FIX: Removed _samplingService.dispose() call as it is now retrieved from Provider/context.
super.dispose(); super.dispose();
} }
@ -80,108 +88,39 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
} }
} }
// --- REPLACED: _submitForm() method with the new workflow --- // --- REPLACED: _submitForm() method with the simplified workflow ---
Future<void> _submitForm() async { Future<void> _submitForm() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings; 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 activeApiConfig = await _serverConfigService.getActiveApiConfig();
final serverName = activeApiConfig?['config_name'] as String? ?? 'Default'; 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<File>().toList(),
baseFileName: baseFileName,
);
if (dataZip != null) {
await _retryService.addFtpToQueue(
localFilePath: dataZip.path,
remotePath: '/uploads/data/${p.basename(dataZip.path)}',
);
ftpSuccess = true; // Mark as successful if at least one file is queued
}
if (imageZip != null) {
await _retryService.addFtpToQueue(
localFilePath: imageZip.path,
remotePath: '/uploads/images/${p.basename(imageZip.path)}',
);
ftpSuccess = true;
}
}
} catch (e) {
debugPrint("FTP zipping or queuing failed with an error: $e");
ftpSuccess = false;
}
}
// --- Final Status Update and Navigation ---
if (!mounted) return;
if (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); await _localStorageService.saveRiverInSituSamplingData(_data, serverName: serverName);
if (!mounted) return;
setState(() => _isLoading = false); setState(() => _isLoading = false);
final message = _data.submissionMessage ?? 'An unknown error occurred.'; 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( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)), SnackBar(content: Text(message), backgroundColor: color, duration: const Duration(seconds: 4)),
); );
@ -191,6 +130,19 @@ class _RiverInSituSamplingScreenState extends State<RiverInSituSamplingScreen> {
@override @override
Widget build(BuildContext context) { 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( return Provider.value(
value: _samplingService, value: _samplingService,
child: Scaffold( child: Scaffold(

View File

@ -380,7 +380,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
ListTile( ListTile(
leading: const Icon(Icons.info_outline), leading: const Icon(Icons.info_outline),
title: const Text('App Version'), title: const Text('App Version'),
subtitle: const Text('1.0.0'), subtitle: const Text('1.2.03'),
dense: true, dense: true,
), ),
ListTile( ListTile(

View File

@ -8,6 +8,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'dart:convert';
import '../models/air_installation_data.dart'; import '../models/air_installation_data.dart';
import '../models/air_collection_data.dart'; import '../models/air_collection_data.dart';
@ -18,14 +19,54 @@ import 'telegram_service.dart';
import 'server_config_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 { class AirSamplingService {
final ApiService _apiService = ApiService(); final ApiService _apiService;
final LocalStorageService _localStorageService = LocalStorageService(); final DatabaseHelper _dbHelper;
final TelegramService _telegramService = TelegramService(); final TelegramService _telegramService;
// --- ADDED: An instance of the service to get the active server name ---
final ServerConfigService _serverConfigService = ServerConfigService(); 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<String, dynamic> _toMapForLocalSave(dynamic data) {
if (data is AirInstallationData) {
final map = data.toMap(); // Get map with paths for DB logging
// Overwrite paths with live File objects for local saving process
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, /// Picks an image from the specified source, adds a timestamp watermark,
/// and saves it to a temporary directory with a standardized name. /// and saves it to a temporary directory with a standardized name.
Future<File?> pickAndProcessImage( Future<File?> pickAndProcessImage(
@ -103,50 +144,163 @@ class AirSamplingService {
} }
} }
// --- ADDED HELPER METHODS TO GET IMAGE PATHS FROM MODELS ---
List<String> _getInstallationImagePaths(AirInstallationData data) {
final List<File?> files = [
data.imageFront, data.imageBack, data.imageLeft, data.imageRight,
data.optionalImage1, data.optionalImage2, data.optionalImage3, data.optionalImage4,
];
return files.where((f) => f != null).map((f) => f!.path).toList();
}
List<String> _getCollectionImagePaths(AirCollectionData data) {
final List<File?> 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. /// Orchestrates a two-step submission process for air installation samples.
// MODIFIED: Method now requires the appSettings list to pass down the call stack. // MODIFIED: Method now requires the appSettings list to pass down the call stack.
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data, List<Map<String, dynamic>>? appSettings) async { Future<Map<String, dynamic>> submitInstallation(AirInstallationData data, List<Map<String, dynamic>>? appSettings) async {
// --- MODIFIED: Get the active server name to use for local storage --- // --- MODIFIED: Get the active server name to use for local storage ---
final activeConfig = await _serverConfigService.getActiveApiConfig(); final activeConfig = await _serverConfigService.getActiveApiConfig();
final serverName = activeConfig?['config_name'] as String? ?? 'Default'; final serverName = activeConfig?['config_name'] as String? ?? 'Default';
final localStorageService = LocalStorageService(); // Instance for file system save
// --- OFFLINE-FIRST HELPER ---
Future<Map<String, dynamic>> 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};
}
// If the record's text data is already on the server, skip directly to image upload. // 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) { if (data.status == 'L2_PENDING_IMAGES' && data.airManId != null) {
debugPrint("Retrying image upload for existing record ID: ${data.airManId}"); 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 --- // --- STEP 1: SUBMIT TEXT DATA ---
debugPrint("Step 1: Submitting installation 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) { if (textDataResult['success'] != true) {
debugPrint("Failed to submit text data. Reason: ${textDataResult['message']}"); 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 --- // --- NECESSARY FIX: Safely parse the record ID from the server response ---
final dynamic recordIdFromServer = textDataResult['data']?['air_man_id']; final dynamic recordIdFromServer = textDataResult['data']?['air_man_id'];
if (recordIdFromServer == null) { if (recordIdFromServer == null) {
debugPrint("Text data submitted, but did not receive a record ID."); 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()); final int? parsedRecordId = int.tryParse(recordIdFromServer.toString());
if (parsedRecordId == null) { if (parsedRecordId == null) {
debugPrint("Could not parse the received record ID: $recordIdFromServer"); 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; data.airManId = parsedRecordId;
@ -159,10 +313,37 @@ class AirSamplingService {
// MODIFIED: Method now requires the serverName to pass to the save method. // MODIFIED: Method now requires the serverName to pass to the save method.
Future<Map<String, dynamic>> _uploadInstallationImagesAndUpdate(AirInstallationData data, List<Map<String, dynamic>>? appSettings, {required String serverName}) async { Future<Map<String, dynamic>> _uploadInstallationImagesAndUpdate(AirInstallationData data, List<Map<String, dynamic>>? appSettings, {required String serverName}) async {
final filesToUpload = data.getImagesForUpload(); 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) { if (filesToUpload.isEmpty) {
debugPrint("No images to upload. Submission complete."); 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); _handleInstallationSuccessAlert(data, appSettings, isDataOnly: true);
return {'status': 'S1', 'message': 'Installation data submitted successfully.'}; return {'status': 'S1', 'message': 'Installation data submitted successfully.'};
} }
@ -176,21 +357,70 @@ class AirSamplingService {
if (imageUploadResult['success'] != true) { if (imageUploadResult['success'] != true) {
debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}"); debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}");
data.status = 'L2_PENDING_IMAGES'; data.status = 'L2_PENDING_IMAGES';
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!, serverName: serverName); final result = {
return {
'status': 'L2_PENDING_IMAGES', 'status': 'L2_PENDING_IMAGES',
'message': 'Data submitted, but image upload failed. Saved locally for retry.', '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."); debugPrint("Images uploaded successfully.");
data.status = 'S2'; // Server Pending (images uploaded) data.status = 'S2'; // Server Pending (images uploaded)
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!, serverName: serverName); final result = {
_handleInstallationSuccessAlert(data, appSettings, isDataOnly: false);
return {
'status': 'S2', 'status': 'S2',
'message': 'Installation data and images submitted successfully.', '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. /// Submits only the collection data, linked to a previous installation.
@ -200,40 +430,80 @@ class AirSamplingService {
final activeConfig = await _serverConfigService.getActiveApiConfig(); final activeConfig = await _serverConfigService.getActiveApiConfig();
final serverName = activeConfig?['config_name'] as String? ?? 'Default'; final serverName = activeConfig?['config_name'] as String? ?? 'Default';
// --- OFFLINE-FIRST HELPER (CORRECTED) --- final apiConfigs = (await _dbHelper.loadApiConfigs() ?? []).take(2).toList();
Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async { final localStorageService = LocalStorageService();
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.',
};
}
// If the record's text data is already on the server, skip directly to image upload. // 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) { if (data.status == 'L4_PENDING_IMAGES' && data.airManId != null) {
debugPrint("Retrying collection image upload for existing record ID: ${data.airManId}"); 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 --- // --- STEP 1: SUBMIT TEXT DATA ---
debugPrint("Step 1: Submitting collection 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) { if (textDataResult['success'] != true) {
debugPrint("Failed to submit collection text data. Reason: ${textDataResult['message']}"); 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."); debugPrint("Collection text data submitted successfully.");
data.airManId = textDataResult['data']['air_man_id'];
// --- STEP 2: UPLOAD IMAGE FILES --- // --- STEP 2: UPLOAD IMAGE FILES ---
return await _uploadCollectionImagesAndUpdate(data, installationData, appSettings, serverName: serverName); 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. /// 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. // MODIFIED: Method now requires the serverName to pass to the save method.
Future<Map<String, dynamic>> _uploadCollectionImagesAndUpdate(AirCollectionData data, AirInstallationData installationData, List<Map<String, dynamic>>? appSettings, {required String serverName}) async { Future<Map<String, dynamic>> _uploadCollectionImagesAndUpdate(AirCollectionData data, AirInstallationData installationData, List<Map<String, dynamic>>? appSettings, {required String serverName}) async {
// --- OFFLINE-FIRST HELPER (CORRECTED & MODIFIED) ---
Future<Map<String, dynamic>> 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 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) { if (filesToUpload.isEmpty) {
debugPrint("No collection images to upload. Submission complete."); 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); _handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: true);
return {'status': 'S3', 'message': 'Collection data submitted successfully.'}; return result;
} }
debugPrint("Step 2: Uploading ${filesToUpload.length} collection images..."); debugPrint("Step 2: Uploading ${filesToUpload.length} collection images...");
@ -276,17 +557,70 @@ class AirSamplingService {
if (imageUploadResult['success'] != true) { if (imageUploadResult['success'] != true) {
debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}"); debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}");
// Use status 'L4_PENDING_IMAGES' to indicate text submitted but images failed data.status = 'L4_PENDING_IMAGES';
return await updateAndSaveLocally('L4_PENDING_IMAGES', message: 'Data submitted, but image upload failed. Saved locally for retry.'); 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."); debugPrint("Images uploaded successfully.");
await updateAndSaveLocally('S3'); // S3 = Server Completed final result = {
_handleCollectionSuccessAlert(data, installationData, appSettings, isDataOnly: false);
return {
'status': 'S3', 'status': 'S3',
'message': 'Collection data and images submitted successfully.', '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<List<AirInstallationData>> getPendingInstallations() async { Future<List<AirInstallationData>> getPendingInstallations() async {
debugPrint("Fetching pending installations from local storage..."); debugPrint("Fetching pending installations from local storage...");
final logs = await _localStorageService.getAllAirSamplingLogs(); final logs = await _dbHelper.loadSubmissionLogs(module: 'air');
final pendingInstallations = logs final pendingInstallations = logs
.where((log) { ?.where((log) {
final status = log['status']; final status = log['status'];
// --- CORRECTED --- // --- CORRECTED ---
// Only show installations that have been synced to the server (S1, S2). // Only show installations that have been synced to the server (S1, S2).
// 'L1' (Local only) records cannot be collected until they are synced. // 'L1' (Local only) records cannot be collected until they are synced.
return status == 'S1' || status == 'S2'; return status == 'S1' || status == 'S2';
}) })
.map((log) => AirInstallationData.fromJson(log)) .map((log) => AirInstallationData.fromJson(jsonDecode(log['form_data'])))
.toList(); .toList() ?? [];
return pendingInstallations; return pendingInstallations;
} }

View File

@ -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/services/telegram_service.dart';
import 'package:environment_monitoring_app/models/in_situ_sampling_data.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/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 // Part 1: Unified API Service
// ======================================================================= // =======================================================================
/// A unified service that consolidates all API interactions for the application. /// A unified service that consolidates all API interactions for the application.
/// It is organized by feature (e.g., marine, river) for clarity and provides // ... (ApiService class definition remains the same)
/// a central point for data synchronization.
class ApiService { class ApiService {
final BaseApiService _baseService = BaseApiService(); final BaseApiService _baseService = BaseApiService();
final DatabaseHelper _dbHelper = DatabaseHelper(); final DatabaseHelper dbHelper = DatabaseHelper();
late final MarineApiService marine; late final MarineApiService marine;
late final RiverApiService river; late final RiverApiService river;
@ -31,10 +34,10 @@ class ApiService {
static const String imageBaseUrl = 'https://dev14.pstw.com.my/'; static const String imageBaseUrl = 'https://dev14.pstw.com.my/';
ApiService() { ApiService({required TelegramService telegramService}) {
marine = MarineApiService(_baseService); marine = MarineApiService(_baseService, telegramService);
river = RiverApiService(_baseService); river = RiverApiService(_baseService, telegramService);
air = AirApiService(_baseService); air = AirApiService(_baseService, telegramService);
} }
// --- Core API Methods (Unchanged) --- // --- Core API Methods (Unchanged) ---
@ -119,7 +122,7 @@ class ApiService {
debugPrint('ApiService: Refreshing profile data from server...'); debugPrint('ApiService: Refreshing profile data from server...');
final result = await getProfile(); final result = await getProfile();
if (result['success'] == true && result['data'] != null) { 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.'); debugPrint('ApiService: Profile data refreshed and saved to local DB.');
} }
return result; return result;
@ -143,24 +146,24 @@ class ApiService {
try { try {
// Defines all data types to sync, their endpoints, and their DB handlers. // Defines all data types to sync, their endpoints, and their DB handlers.
final syncTasks = { final syncTasks = {
'profile': {'endpoint': 'profile', 'handler': (d, id) async { if (d.isNotEmpty) await _dbHelper.saveProfile(d.first); }}, '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); }}, '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); }}, '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); }}, '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); }}, '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); }}, '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); }}, '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); }}, '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); }}, '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); }}, '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); }}, '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); }}, '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); }}, '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); }}, '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); }}, '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 --- // --- 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); }}, '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); }}, 'ftpConfigs': {'endpoint': 'ftp-configs', 'handler': (d, id) async { await dbHelper.upsertFtpConfigs(d); await dbHelper.deleteFtpConfigs(id); }},
}; };
// Fetch all deltas in parallel // Fetch all deltas in parallel
@ -202,13 +205,21 @@ class ApiService {
// ======================================================================= // =======================================================================
class AirApiService { class AirApiService {
// ... (No changes needed here)
final BaseApiService _baseService; final BaseApiService _baseService;
AirApiService(this._baseService); final TelegramService? _telegramService;
AirApiService(this._baseService, [this._telegramService]);
Future<Map<String, dynamic>> getManualStations() => _baseService.get('air/manual-stations'); Future<Map<String, dynamic>> getManualStations() => _baseService.get('air/manual-stations');
Future<Map<String, dynamic>> getClients() => _baseService.get('air/clients'); Future<Map<String, dynamic>> getClients() => _baseService.get('air/clients');
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data) {
return _baseService.post('air/manual/installation', data.toJsonForApi());
}
Future<Map<String, dynamic>> submitCollection(AirCollectionData data) {
return _baseService.post('air/manual/collection', data.toJson());
}
Future<Map<String, dynamic>> uploadInstallationImages({ Future<Map<String, dynamic>> uploadInstallationImages({
required String airManId, required String airManId,
required Map<String, File> files, required Map<String, File> files,
@ -234,16 +245,102 @@ class AirApiService {
class MarineApiService { class MarineApiService {
// --- ADDED: TelegramService instance ---
final BaseApiService _baseService; final BaseApiService _baseService;
final TelegramService _telegramService = TelegramService(); final TelegramService _telegramService;
MarineApiService(this._baseService); MarineApiService(this._baseService, this._telegramService);
Future<Map<String, dynamic>> getTarballStations() => _baseService.get('marine/tarball/stations'); Future<Map<String, dynamic>> getTarballStations() => _baseService.get('marine/tarball/stations');
Future<Map<String, dynamic>> getManualStations() => _baseService.get('marine/manual/stations'); Future<Map<String, dynamic>> getManualStations() => _baseService.get('marine/manual/stations');
Future<Map<String, dynamic>> getTarballClassifications() => _baseService.get('marine/tarball/classifications'); Future<Map<String, dynamic>> getTarballClassifications() => _baseService.get('marine/tarball/classifications');
// --- REVISED: Now includes appSettings parameter and triggers Telegram alert --- // FIX: Added submitInSituSample implementation for Marine from marine_api_service.dart
Future<Map<String, dynamic>> submitInSituSample({
required Map<String, String> formData,
required Map<String, File?> imageFiles,
required InSituSamplingData inSituData,
required List<Map<String, dynamic>>? appSettings,
}) async {
debugPrint("Step 1: Submitting in-situ form data to the server...");
final dataResult = await _baseService.post('marine/manual/sample', formData);
if (dataResult['success'] != true) {
debugPrint("API submission failed for In-Situ. Message: ${dataResult['message']}");
return {
'status': 'L1',
'success': false,
'message': 'Failed to submit in-situ data: ${dataResult['message']}',
'reportId': null,
};
}
debugPrint("Step 1 successful. In-situ data submitted. Report ID: ${dataResult['data']?['man_id']}");
final recordId = dataResult['data']?['man_id'];
if (recordId == null) {
debugPrint("API submitted, but no record ID returned.");
return {
'status': 'L2',
'success': false,
'message': 'In-situ data submitted, but failed to get a record ID for images.',
'reportId': null,
};
}
final filesToUpload = <String, File>{};
imageFiles.forEach((key, value) {
if (value != null) filesToUpload[key] = value;
});
if (filesToUpload.isEmpty) {
debugPrint("No images to upload. Finalizing submission.");
_handleInSituSuccessAlert(inSituData, appSettings, isDataOnly: true); // 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<void> _handleInSituSuccessAlert(InSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try {
final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly);
final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings);
if (!wasSent) {
await _telegramService.queueMessage('marine_in_situ', message, appSettings);
}
} catch (e) {
debugPrint("Failed to handle In-Situ Telegram alert: $e");
}
}
// END FIX: Added submitInSituSample implementation for Marine
Future<Map<String, dynamic>> submitTarballSample({ Future<Map<String, dynamic>> submitTarballSample({
required Map<String, String> formData, required Map<String, String> formData,
required Map<String, File?> imageFiles, required Map<String, File?> imageFiles,
@ -265,17 +362,14 @@ class MarineApiService {
final imageResult = await _baseService.postMultipart(endpoint: 'marine/tarball/images', fields: {'autoid': recordId.toString()}, files: filesToUpload); final imageResult = await _baseService.postMultipart(endpoint: 'marine/tarball/images', fields: {'autoid': recordId.toString()}, files: filesToUpload);
if (imageResult['success'] != true) { if (imageResult['success'] != true) {
// Still send the alert for data submission even if images fail
_handleTarballSuccessAlert(formData, appSettings, isDataOnly: true); _handleTarballSuccessAlert(formData, appSettings, isDataOnly: true);
return {'status': 'L2', 'success': false, 'message': 'Data submitted, but image upload failed: ${imageResult['message']}', 'reportId': recordId}; 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); _handleTarballSuccessAlert(formData, appSettings, isDataOnly: false);
return {'status': 'L3', 'success': true, 'message': 'Data and images submitted successfully.', 'reportId': recordId}; return {'status': 'L3', 'success': true, 'message': 'Data and images submitted successfully.', 'reportId': recordId};
} }
// --- ADDED: Helper method for Telegram alerts ---
Future<void> _handleTarballSuccessAlert(Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async { Future<void> _handleTarballSuccessAlert(Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
debugPrint("Triggering Telegram alert logic..."); debugPrint("Triggering Telegram alert logic...");
try { try {
@ -289,7 +383,6 @@ class MarineApiService {
} }
} }
// --- ADDED: Helper method to generate the Telegram message ---
String _generateTarballAlertMessage(Map<String, String> formData, {required bool isDataOnly}) { String _generateTarballAlertMessage(Map<String, String> formData, {required bool isDataOnly}) {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = formData['tbl_station_name'] ?? 'N/A'; final stationName = formData['tbl_station_name'] ?? 'N/A';
@ -323,12 +416,124 @@ class MarineApiService {
} }
class RiverApiService { class RiverApiService {
// ... (No changes needed here)
final BaseApiService _baseService; final BaseApiService _baseService;
RiverApiService(this._baseService); final TelegramService _telegramService;
RiverApiService(this._baseService, this._telegramService);
Future<Map<String, dynamic>> getManualStations() => _baseService.get('river/manual-stations'); Future<Map<String, dynamic>> getManualStations() => _baseService.get('river/manual-stations');
Future<Map<String, dynamic>> getTriennialStations() => _baseService.get('river/triennial-stations'); Future<Map<String, dynamic>> getTriennialStations() => _baseService.get('river/triennial-stations');
// FIX: Added submitInSituSample implementation for River from river_api_service.dart
Future<Map<String, dynamic>> submitInSituSample({
required Map<String, String> formData,
required Map<String, File?> imageFiles,
required List<Map<String, dynamic>>? appSettings,
}) async {
// --- Step 1: Submit Form Data as JSON ---
final dataResult = await _baseService.post('river/manual/sample', formData);
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 = <String, File>{};
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<void> _handleInSituSuccessAlert(Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = formData['r_man_station_name'] ?? 'N/A';
final stationCode = formData['r_man_station_code'] ?? 'N/A';
final submissionDate = formData['r_man_date'] ?? DateFormat('yyyy-MM-dd').format(DateTime.now());
final submitter = formData['first_sampler_name'] ?? 'N/A';
final sondeID = formData['r_man_sondeID'] ?? 'N/A';
final distanceKm = double.tryParse(formData['r_man_distance_difference'] ?? '0') ?? 0;
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
final distanceRemarks = formData['r_man_distance_difference_remarks'] ?? 'N/A';
final buffer = StringBuffer()
..writeln('✅ *River In-Situ Sample ${submissionType} Submitted:*')
..writeln()
..writeln('*Station Name & Code:* $stationName ($stationCode)')
..writeln('*Date of Submitted:* $submissionDate')
..writeln('*Submitted by User:* $submitter')
..writeln('*Sonde ID:* $sondeID')
..writeln('*Status of Submission:* Successful');
if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) {
buffer
..writeln()
..writeln('🔔 *Alert:*')
..writeln('*Distance from station:* $distanceMeters meters');
if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
buffer.writeln('*Remarks for distance:* $distanceRemarks');
}
}
final String message = buffer.toString();
// MODIFIED: Pass the appSettings list to the TelegramService methods.
final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings);
if (!wasSent) {
await _telegramService.queueMessage('river_in_situ', message, appSettings);
}
} catch (e) {
debugPrint("Failed to handle River Telegram alert: $e");
}
}
// END FIX: Added submitInSituSample implementation for River
} }
// ======================================================================= // =======================================================================
@ -338,8 +543,7 @@ class RiverApiService {
class DatabaseHelper { class DatabaseHelper {
static Database? _database; static Database? _database;
static const String _dbName = 'app_data.db'; static const String _dbName = 'app_data.db';
// Incremented DB version to trigger the onUpgrade method static const int _dbVersion = 18;
static const int _dbVersion = 17;
static const String _profileTable = 'user_profile'; static const String _profileTable = 'user_profile';
static const String _usersTable = 'all_users'; static const String _usersTable = 'all_users';
@ -355,14 +559,13 @@ class DatabaseHelper {
static const String _airManualStationsTable = 'air_manual_stations'; static const String _airManualStationsTable = 'air_manual_stations';
static const String _airClientsTable = 'air_clients'; static const String _airClientsTable = 'air_clients';
static const String _statesTable = 'states'; static const String _statesTable = 'states';
// Added new table constants
static const String _appSettingsTable = 'app_settings'; static const String _appSettingsTable = 'app_settings';
static const String _parameterLimitsTable = 'manual_parameter_limits'; 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 _apiConfigsTable = 'api_configurations';
static const String _ftpConfigsTable = 'ftp_configurations'; static const String _ftpConfigsTable = 'ftp_configurations';
// --- ADDED: New table for the manual retry queue ---
static const String _retryQueueTable = '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<Database> get database async { Future<Database> 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 $_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 $_airClientsTable(client_id INTEGER PRIMARY KEY, client_json TEXT)');
await db.execute('CREATE TABLE $_statesTable(state_id INTEGER PRIMARY KEY, state_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 $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)');
await db.execute('CREATE TABLE $_parameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_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 $_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)'); 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(''' await db.execute('''
CREATE TABLE $_retryQueueTable( CREATE TABLE $_retryQueueTable(
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -408,6 +608,23 @@ class DatabaseHelper {
status TEXT NOT NULL 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<void> _onUpgrade(Database db, int oldVersion, int newVersion) async { Future<void> _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 $_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)'); 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) { 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 $_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)'); 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) { if (oldVersion < 17) {
await db.execute(''' await db.execute('''
CREATE TABLE IF NOT EXISTS $_retryQueueTable( 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. /// Performs an "upsert": inserts new records or replaces existing ones.
@ -579,4 +822,40 @@ class DatabaseHelper {
final db = await database; final db = await database;
await db.delete(_retryQueueTable, where: 'id = ?', whereArgs: [id]); 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<void> saveSubmissionLog(Map<String, dynamic> data) async {
final db = await database;
await db.insert(
_submissionLogTable,
data,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
/// Retrieves all submission log entries, optionally filtered by module.
Future<List<Map<String, dynamic>>?> loadSubmissionLogs({String? module}) async {
final db = await database;
List<Map<String, dynamic>> 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;
}
}

View File

@ -15,7 +15,7 @@ import 'package:environment_monitoring_app/services/api_service.dart';
class BaseApiService { class BaseApiService {
final ServerConfigService _serverConfigService = ServerConfigService(); final ServerConfigService _serverConfigService = ServerConfigService();
final DatabaseHelper _dbHelper = DatabaseHelper(); // --- ADDED: Instance of DatabaseHelper to get all configs --- final DatabaseHelper _dbHelper = DatabaseHelper();
Future<Map<String, String>> _getHeaders() async { Future<Map<String, String>> _getHeaders() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@ -47,7 +47,7 @@ class BaseApiService {
// --- MODIFIED: Generic POST request handler now attempts multiple servers --- // --- MODIFIED: Generic POST request handler now attempts multiple servers ---
Future<Map<String, dynamic>> post(String endpoint, Map<String, dynamic> body) async { Future<Map<String, dynamic>> post(String endpoint, Map<String, dynamic> body) async {
final configs = await _dbHelper.loadApiConfigs() ?? []; // Get all API configs final configs = await _dbHelper.loadApiConfigs() ?? [];
// --- ADDED: Handle case where local configs are empty --- // --- ADDED: Handle case where local configs are empty ---
if (configs.isEmpty) { if (configs.isEmpty) {
@ -75,7 +75,7 @@ class BaseApiService {
for (final config in latestConfigs) { for (final config in latestConfigs) {
debugPrint('Debug: Current config item: $config (Type: ${config.runtimeType})'); 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) { if (config == null || config['api_url'] == null) {
debugPrint('Skipping null or invalid API configuration.'); debugPrint('Skipping null or invalid API configuration.');
continue; continue;
@ -118,7 +118,7 @@ class BaseApiService {
required Map<String, String> fields, required Map<String, String> fields,
required Map<String, File> files, required Map<String, File> files,
}) async { }) async {
final configs = await _dbHelper.loadApiConfigs() ?? []; // Get all API configs final configs = await _dbHelper.loadApiConfigs() ?? [];
// --- ADDED: Handle case where local configs are empty --- // --- ADDED: Handle case where local configs are empty ---
if (configs.isEmpty) { 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'); debugPrint('Debug: Loaded API configs: $latestConfigs');
for (final config in latestConfigs) { for (final config in latestConfigs) {
debugPrint('Debug: Current config item: $config (Type: ${config.runtimeType})'); 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) { if (config == null || config['api_url'] == null) {
debugPrint('Skipping null or invalid API configuration.'); debugPrint('Skipping null or invalid API configuration.');
continue; continue;
@ -174,7 +174,7 @@ class BaseApiService {
request.fields.addAll(fields); request.fields.addAll(fields);
} }
for (var entry in files.entries) { 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( request.files.add(await http.MultipartFile.fromPath(
entry.key, entry.key,
entry.value.path, entry.value.path,

View File

@ -44,8 +44,19 @@ class LocalStorageService {
return null; return null;
} }
// --- ADDED: A public method to retrieve the root log directory. ---
Future<Directory?> 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. --- // --- 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) { if (serializableData.containsKey(key) && serializableData[key] is File) {
final newPath = await copyImageToLocal(serializableData[key]); final newPath = await copyImageToLocal(serializableData[key]);
serializableData['${key}Path'] = newPath; // Creates 'imageFrontPath', etc. 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) { if (collectionMap.containsKey(key) && collectionMap[key] is File) {
final newPath = await copyImageToLocal(collectionMap[key]); final newPath = await copyImageToLocal(collectionMap[key]);
collectionMap['${key}Path'] = newPath; collectionMap['${key}Path'] = newPath;
collectionMap.remove(key);
} }
} }
serializableData['collectionData'] = collectionMap; 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<String, dynamic> finalData = Map.from(serializableData);
// Recursive helper to remove File objects before JSON encoding
void cleanMap(Map<String, dynamic> map) {
map.removeWhere((key, value) => value is File);
map.forEach((key, value) {
if (value is Map) cleanMap(value as Map<String, dynamic>);
});
}
// 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<String, dynamic>);
}
final jsonFile = File(p.join(eventDir.path, 'data.json')); 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}"); debugPrint("Air sampling log and images saved to: ${eventDir.path}");
return eventDir.path; return eventDir.path;
@ -172,7 +206,7 @@ class LocalStorageService {
} }
// ======================================================================= // =======================================================================
// Part 3: Tarball Specific Methods // Part 3: Tarball Specific Methods (LOGGING RESTORED)
// ======================================================================= // =======================================================================
Future<Directory?> _getTarballBaseDir({required String serverName}) async { Future<Directory?> _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 --- // --- 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<Directory?> getRiverInSituBaseDir(String? samplingType, {required String serverName}) async { Future<Directory?> getRiverInSituBaseDir(String? samplingType, {required String serverName}) async {

View File

@ -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/base_api_service.dart';
import 'package:environment_monitoring_app/services/telegram_service.dart'; import 'package:environment_monitoring_app/services/telegram_service.dart';
// REMOVED: SettingsService is no longer needed in this file. // NOTE: RiverApiService still needs RiverInSituSamplingData import for its handle alert method,
// import 'package:environment_monitoring_app/services/settings_service.dart'; // but since the model file wasn't provided directly, we assume it's correctly handled by the caller/context.
class RiverApiService { class RiverApiService {
final BaseApiService _baseService = BaseApiService(); final BaseApiService _baseService;
final TelegramService _telegramService = TelegramService(); final TelegramService _telegramService;
// REMOVED: SettingsService instance is no longer needed. RiverApiService(this._baseService, this._telegramService);
// final SettingsService _settingsService = SettingsService();
Future<Map<String, dynamic>> getManualStations() { Future<Map<String, dynamic>> getManualStations() {
return _baseService.get('river/manual-stations'); return _baseService.get('river/manual-stations');
@ -23,34 +22,40 @@ class RiverApiService {
return _baseService.get('river/triennial-stations'); 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<Map<String, dynamic>> submitInSituSample({ Future<Map<String, dynamic>> submitInSituSample({
required Map<String, String> formData, required Map<String, String> formData,
required Map<String, File?> imageFiles, required Map<String, File?> imageFiles,
required List<Map<String, dynamic>>? appSettings, required List<Map<String, dynamic>>? appSettings,
}) async { }) async {
Map<String, dynamic> finalResult = {
'success': false,
'status': 'L1', // Default: Local Failure (Data failed)
'api_status': 'NOT_ATTEMPTED',
'image_upload_status': 'NOT_ATTEMPTED',
'message': 'Submission failed.',
'reportId': null,
};
// --- Step 1: Submit Form Data as JSON --- // --- 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); final dataResult = await _baseService.post('river/manual/sample', formData);
finalResult['api_status'] = dataResult['success'] == true ? 'SUCCESS' : 'FAILED';
if (dataResult['success'] != true) { if (dataResult['success'] != true) {
return { finalResult['message'] = dataResult['message'] ?? 'Failed to submit river in-situ data (API failed).';
'status': 'L1', return finalResult;
'success': false,
'message': 'Failed to submit river in-situ data: ${dataResult['message']}',
'reportId': null
};
} }
// --- Step 2: Upload Image Files --- // Update status and reportId upon successful data submission
final recordId = dataResult['data']?['r_man_id']; final recordId = dataResult['data']?['r_man_id'];
finalResult['reportId'] = recordId?.toString();
if (recordId == null) { if (recordId == null) {
return { finalResult['api_status'] = 'FAILED';
'status': 'L2', finalResult['message'] = 'Data submitted, but server did not return a record ID.';
'success': false, return finalResult;
'message': 'Data submitted, but failed to get a record ID for images.',
'reportId': null
};
} }
final filesToUpload = <String, File>{}; final filesToUpload = <String, File>{};
@ -58,38 +63,46 @@ class RiverApiService {
if (value != null) filesToUpload[key] = value; if (value != null) filesToUpload[key] = value;
}); });
// --- Step 2: Upload Image Files ---
if (filesToUpload.isEmpty) { 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); _handleInSituSuccessAlert(formData, appSettings, isDataOnly: true);
return { return finalResult;
'status': 'L3',
'success': true,
'message': 'Data submitted successfully. No images were attached.',
'reportId': recordId.toString()
};
} }
debugPrint("Step 2: Uploading ${filesToUpload.length} images...");
final imageResult = await _baseService.postMultipart( final imageResult = await _baseService.postMultipart(
endpoint: 'river/manual/images', // Separate endpoint for images endpoint: 'river/manual/images', // Separate endpoint for images
fields: {'r_man_id': recordId.toString()}, // Link images to the submitted record ID fields: {'r_man_id': recordId.toString()}, // Link images to the submitted record ID
files: filesToUpload, files: filesToUpload,
); );
finalResult['image_upload_status'] = imageResult['success'] == true ? 'SUCCESS' : 'FAILED';
if (imageResult['success'] != true) { if (imageResult['success'] != true) {
return { // Data submitted successfully, but images failed (L2/L4 equivalent)
'status': 'L2', finalResult['success'] = true; // API data transfer was still successful
'success': false, finalResult['status'] = 'L2_PENDING_IMAGES';
'message': 'Data submitted, but image upload failed: ${imageResult['message']}', finalResult['message'] = 'Data submitted, but image upload failed: ${imageResult['message']}';
'reportId': recordId.toString()
}; _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); _handleInSituSuccessAlert(formData, appSettings, isDataOnly: false);
return { return finalResult;
'status': 'L3',
'success': true,
'message': 'Data and images submitted successfully.',
'reportId': recordId.toString()
};
} }
// MODIFIED: Method now requires appSettings and calls the updated TelegramService. // MODIFIED: Method now requires appSettings and calls the updated TelegramService.

View File

@ -12,26 +12,41 @@ import 'package:geolocator/geolocator.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
import 'package:usb_serial/usb_serial.dart'; import 'package:usb_serial/usb_serial.dart';
import 'dart:convert';
// CHANGED: Import river-specific services and models // CHANGED: Import river-specific services and models
import 'location_service.dart'; 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 '../models/river_in_situ_sampling_data.dart';
import '../bluetooth/bluetooth_manager.dart'; import '../bluetooth/bluetooth_manager.dart';
import '../serial/serial_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. /// 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 // CHANGED: Renamed class for the River In-Situ Sampling Service
class RiverInSituSamplingService { class RiverInSituSamplingService {
final LocationService _locationService = LocationService(); final LocationService _locationService = LocationService();
// CHANGED: Use the river-specific API service // NOTE: RiverApiService type is defined in api_service.dart and used for DI
final RiverApiService _riverApiService = RiverApiService(); final RiverApiService _riverApiService;
final BluetoothManager _bluetoothManager = BluetoothManager(); final BluetoothManager _bluetoothManager = BluetoothManager();
final SerialManager _serialManager = SerialManager(); 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 // This channel name MUST match the one defined in MainActivity.kt
static const platform = MethodChannel('com.example.environment_monitoring_app/usb'); static const platform = MethodChannel('com.example.environment_monitoring_app/usb');
// FIX: Constructor requires RiverApiService for dependency injection
RiverInSituSamplingService(this._riverApiService);
// --- Location Services --- // --- Location Services ---
Future<Position> getCurrentLocation() => _locationService.getCurrentLocation(); Future<Position> getCurrentLocation() => _locationService.getCurrentLocation();
@ -149,12 +164,122 @@ class RiverInSituSamplingService {
} }
// --- Data Submission --- // --- Data Submission ---
// MODIFIED: Method now requires the appSettings list to pass to the RiverApiService. // MODIFIED: This method orchestrates submission, local saving, and logging.
Future<Map<String, dynamic>> submitData(RiverInSituSamplingData data, List<Map<String, dynamic>>? appSettings) { Future<Map<String, dynamic>> submitData(RiverInSituSamplingData data, List<Map<String, dynamic>>? appSettings) async {
return _riverApiService.submitInSituSample( final formData = data.toApiFormData();
formData: data.toApiFormData(), final imageFiles = data.toApiImageFiles();
imageFiles: data.toApiImageFiles(),
appSettings: appSettings, // Added this required parameter // 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<Map<String, dynamic>> apiStatuses = [];
for (int i = 0; i < apiConfigs.length; i++) {
final config = apiConfigs[i];
String status;
String message;
if (apiSuccess && i == 0) {
status = "SUCCESS";
message = "Data posted successfully to primary API.";
} else if (apiSuccess && i > 0) {
status = "SUCCESS (Fallback)";
message = "Data posted successfully to fallback API.";
} else {
status = "FAILED";
message = apiResult['message'] ?? "Connection or server error.";
}
apiStatuses.add({
"server_name": config['config_name'],
"status": status,
"message": message,
});
}
// 2. Determine FTP Status (Simulated based on configuration existence)
List<Map<String, dynamic>> 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;
} }
} }

View File

@ -52,4 +52,4 @@ class ServerConfigService {
} }
return null; return null;
} }
} }

View File

@ -1,15 +1,26 @@
// lib/services/telegram_service.dart
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:environment_monitoring_app/services/api_service.dart'; import 'package:environment_monitoring_app/services/api_service.dart';
import 'package:environment_monitoring_app/services/settings_service.dart'; import 'package:environment_monitoring_app/services/settings_service.dart';
class TelegramService { class TelegramService {
final ApiService _apiService = ApiService(); // FIX: Change to a nullable, externally injected dependency.
ApiService? _apiService;
final DatabaseHelper _dbHelper = DatabaseHelper(); final DatabaseHelper _dbHelper = DatabaseHelper();
final SettingsService _settingsService = SettingsService(); final SettingsService _settingsService = SettingsService();
bool _isProcessing = false; 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. // MODIFIED: This method is now synchronous and requires the appSettings list.
String _getChatIdForModule(String module, List<Map<String, dynamic>>? appSettings) { String _getChatIdForModule(String module, List<Map<String, dynamic>>? appSettings) {
switch (module) { switch (module) {
@ -36,7 +47,13 @@ class TelegramService {
return false; 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, chatId: chatId,
message: message, message: message,
); );
@ -93,6 +110,13 @@ class TelegramService {
return; 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."); debugPrint("[TelegramService] 🔎 Found ${pendingAlerts.length} pending alerts.");
for (var alert in pendingAlerts) { for (var alert in pendingAlerts) {
@ -100,7 +124,7 @@ class TelegramService {
final chatId = alert['chat_id']; final chatId = alert['chat_id'];
debugPrint("[TelegramService] - Processing alert ID: $alertId for Chat ID: $chatId"); debugPrint("[TelegramService] - Processing alert ID: $alertId for Chat ID: $chatId");
final result = await _apiService.sendTelegramAlert( final result = await _apiService!.sendTelegramAlert(
chatId: chatId, chatId: chatId,
message: alert['message'], message: alert['message'],
); );

View File

@ -18,7 +18,7 @@ packages:
source: hosted source: hosted
version: "2.7.0" version: "2.7.0"
async: async:
dependency: transitive dependency: "direct main"
description: description:
name: async name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"

View File

@ -38,6 +38,8 @@ dependencies:
permission_handler: ^11.3.1 permission_handler: ^11.3.1
ftpconnect: ^2.0.5 ftpconnect: ^2.0.5
archive: ^4.0.3 # For creating ZIP files archive: ^4.0.3 # For creating ZIP files
async: ^2.11.0
# --- Added for In-Situ Sampling Module --- # --- Added for In-Situ Sampling Module ---
simple_barcode_scanner: ^0.3.0 # For scanning sample IDs simple_barcode_scanner: ^0.3.0 # For scanning sample IDs
#flutter_blue_classic: ^0.0.3 # For Bluetooth sonde connection #flutter_blue_classic: ^0.0.3 # For Bluetooth sonde connection