From da892821a215620996ab41400bcf5735d5af2565 Mon Sep 17 00:00:00 2001 From: ALim Aidrus Date: Tue, 11 Nov 2025 21:15:29 +0800 Subject: [PATCH] repair comment for marine department --- lib/auth_provider.dart | 17 +- lib/main.dart | 26 + lib/models/marine_manual_npe_report_data.dart | 48 + .../marine_investigative_data_status_log.dart | 776 +++++++++++++++ .../marine_investigative_image_request.dart | 659 +++++++++++++ .../manual/marine_manual_data_status_log.dart | 736 ++++++++++++-- .../marine/manual/marine_manual_report.dart | 2 +- ...e_manual_equipment_maintenance_screen.dart | 151 ++- .../reports/marine_manual_npe_report_hub.dart | 6 +- ...arine_manual_sonde_calibration_screen.dart | 325 ++++-- .../reports/npe_report_from_in_situ.dart | 505 ++++++++-- .../reports/npe_report_from_tarball.dart | 144 ++- .../reports/npe_report_new_location.dart | 143 ++- .../marine/manual/tarball_sampling_step1.dart | 4 +- .../marine/manual/tarball_sampling_step2.dart | 2 +- .../widgets/in_situ_step_1_sampling_info.dart | 6 +- .../widgets/in_situ_step_2_site_info.dart | 76 +- .../widgets/in_situ_step_3_data_capture.dart | 11 +- .../widgets/in_situ_step_4_summary.dart | 43 +- lib/screens/marine/marine_home_page.dart | 5 +- ...ver_inves_in_situ_step_3_data_capture.dart | 3 +- ..._inves_in_situ_step_4_additional_info.dart | 8 +- .../river_investigative_data_status_log.dart | 837 ++++++++++++++++ .../river_investigative_image_request.dart | 609 ++++++++++++ .../manual/river_manual_data_status_log.dart | 774 ++++++++++++--- .../manual/river_manual_image_request.dart | 145 ++- ..._manual_triennial_step_3_data_capture.dart | 3 +- ...nual_triennial_step_4_additional_info.dart | 12 +- .../river_in_situ_step_3_data_capture.dart | 3 +- .../river_in_situ_step_4_additional_info.dart | 14 +- lib/screens/river/river_home_page.dart | 6 + lib/services/air_api_service.dart | 64 ++ lib/services/air_sampling_service.dart | 1 + lib/services/api_service.dart | 928 +----------------- lib/services/database_helper.dart | 640 ++++++++++++ lib/services/marine_api_service.dart | 115 ++- .../marine_in_situ_sampling_service.dart | 214 +++- ...marine_investigative_sampling_service.dart | 2 + ..._manual_equipment_maintenance_service.dart | 2 + .../marine_manual_pre_departure_service.dart | 2 + ...rine_manual_sonde_calibration_service.dart | 2 + lib/services/marine_npe_report_service.dart | 2 + .../marine_tarball_sampling_service.dart | 36 +- lib/services/retry_service.dart | 4 +- lib/services/river_api_service.dart | 57 ++ .../river_in_situ_sampling_service.dart | 14 +- .../river_investigative_sampling_service.dart | 7 +- ...ver_manual_triennial_sampling_service.dart | 5 +- lib/services/submission_ftp_service.dart | 3 +- lib/services/telegram_service.dart | 2 + lib/services/user_preferences_service.dart | 1 + 51 files changed, 6719 insertions(+), 1481 deletions(-) create mode 100644 lib/screens/marine/investigative/marine_investigative_data_status_log.dart create mode 100644 lib/screens/marine/investigative/marine_investigative_image_request.dart create mode 100644 lib/screens/river/investigative/river_investigative_data_status_log.dart create mode 100644 lib/screens/river/investigative/river_investigative_image_request.dart create mode 100644 lib/services/air_api_service.dart create mode 100644 lib/services/database_helper.dart diff --git a/lib/auth_provider.dart b/lib/auth_provider.dart index 3219c02..6ad6b53 100644 --- a/lib/auth_provider.dart +++ b/lib/auth_provider.dart @@ -8,8 +8,10 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'dart:convert'; import 'package:bcrypt/bcrypt.dart'; // Import bcrypt import 'package:flutter_secure_storage/flutter_secure_storage.dart'; // Import secure storage - import 'package:environment_monitoring_app/services/api_service.dart'; +//import 'package:environment_monitoring_app/services/api_service.dart'; +import 'package:environment_monitoring_app/services/database_helper.dart'; + import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart'; import 'package:environment_monitoring_app/services/retry_service.dart'; @@ -57,9 +59,7 @@ class AuthProvider with ChangeNotifier { List>? _tarballClassifications; List>? _riverManualStations; List>? _riverTriennialStations; - // --- ADDED: River Investigative Stations --- - List>? _riverInvestigativeStations; - // --- END ADDED --- + List>? _departments; List>? _companies; List>? _positions; @@ -83,7 +83,7 @@ class AuthProvider with ChangeNotifier { List>? get riverManualStations => _riverManualStations; List>? get riverTriennialStations => _riverTriennialStations; // --- ADDED: Getter for River Investigative Stations --- - List>? get riverInvestigativeStations => _riverInvestigativeStations; + List>? get riverInvestigativeStations => _riverManualStations; // --- END ADDED --- List>? get departments => _departments; List>? get companies => _companies; @@ -527,9 +527,7 @@ class AuthProvider with ChangeNotifier { _tarballClassifications = await _dbHelper.loadTarballClassifications(); _riverManualStations = await _dbHelper.loadRiverManualStations(); _riverTriennialStations = await _dbHelper.loadRiverTriennialStations(); - // --- MODIFIED: Load River Investigative Stations --- - _riverInvestigativeStations = await _dbHelper.loadRiverInvestigativeStations(); - // --- END MODIFIED --- + _departments = await _dbHelper.loadDepartments(); _companies = await _dbHelper.loadCompanies(); _positions = await _dbHelper.loadPositions(); @@ -658,9 +656,6 @@ class AuthProvider with ChangeNotifier { _tarballClassifications = null; _riverManualStations = null; _riverTriennialStations = null; - // --- MODIFIED: Clear River Investigative Stations --- - _riverInvestigativeStations = null; - // --- END MODIFIED --- _departments = null; _companies = null; _positions = null; diff --git a/lib/main.dart b/lib/main.dart index 4cd0ab8..6823d21 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,8 @@ import 'dart:async'; // Import Timer import 'package:provider/single_child_widget.dart'; import 'package:environment_monitoring_app/services/api_service.dart'; +import 'package:environment_monitoring_app/services/database_helper.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_manual_triennial_sampling_service.dart'; @@ -80,6 +82,12 @@ import 'package:environment_monitoring_app/screens/river/continuous/report.dart' import 'package:environment_monitoring_app/screens/river/investigative/river_investigative_info_centre_document.dart'; // *** ADDED: Import River Investigative Manual Sampling Screen *** import 'package:environment_monitoring_app/screens/river/investigative/river_investigative_manual_sampling.dart' as riverInvestigativeManualSampling; +// *** START: ADDED NEW RIVER INVESTIGATIVE IMPORTS *** +import 'package:environment_monitoring_app/screens/river/investigative/river_investigative_data_status_log.dart' +as riverInvestigativeDataStatusLog; +import 'package:environment_monitoring_app/screens/river/investigative/river_investigative_image_request.dart' +as riverInvestigativeImageRequest; +// *** END: ADDED NEW RIVER INVESTIGATIVE IMPORTS *** import 'package:environment_monitoring_app/screens/river/investigative/overview.dart' as riverInvestigativeOverview; import 'package:environment_monitoring_app/screens/river/investigative/entry.dart' as riverInvestigativeEntry; import 'package:environment_monitoring_app/screens/river/investigative/report.dart' as riverInvestigativeReport; @@ -106,6 +114,12 @@ import 'package:environment_monitoring_app/screens/marine/continuous/report.dart import 'package:environment_monitoring_app/screens/marine/investigative/marine_investigative_info_centre_document.dart'; import 'package:environment_monitoring_app/screens/marine/investigative/marine_investigative_manual_sampling.dart' as marineInvestigativeManualSampling; +// *** START: ADDED NEW MARINE INVESTIGATIVE IMPORTS *** +import 'package:environment_monitoring_app/screens/marine/investigative/marine_investigative_data_status_log.dart' +as marineInvestigativeDataStatusLog; +import 'package:environment_monitoring_app/screens/marine/investigative/marine_investigative_image_request.dart' +as marineInvestigativeImageRequest; +// *** END: ADDED NEW MARINE INVESTIGATIVE IMPORTS *** import 'package:environment_monitoring_app/screens/marine/investigative/overview.dart' as marineInvestigativeOverview; import 'package:environment_monitoring_app/screens/marine/investigative/entry.dart' as marineInvestigativeEntry; import 'package:environment_monitoring_app/screens/marine/investigative/report.dart' as marineInvestigativeReport; @@ -420,6 +434,12 @@ class _RootAppState extends State { // *** ADDED: Route for River Investigative Manual Sampling *** '/river/investigative/manual-sampling': (context) => riverInvestigativeManualSampling.RiverInvestigativeManualSamplingScreen(), + // *** START: ADDED NEW RIVER INVESTIGATIVE ROUTES *** + '/river/investigative/data-log': (context) => + const riverInvestigativeDataStatusLog.RiverInvestigativeDataStatusLog(), + '/river/investigative/image-request': (context) => + const riverInvestigativeImageRequest.RiverInvestigativeImageRequest(), + // *** END: ADDED NEW RIVER INVESTIGATIVE ROUTES *** '/river/investigative/overview': (context) => riverInvestigativeOverview.OverviewScreen(), // Keep placeholder/future routes '/river/investigative/entry': (context) => @@ -452,6 +472,12 @@ class _RootAppState extends State { '/marine/investigative/info': (context) => const MarineInvestigativeInfoCentreDocument(), '/marine/investigative/manual-sampling': (context) => marineInvestigativeManualSampling.MarineInvestigativeManualSampling(), + // *** START: ADDED NEW MARINE INVESTIGATIVE ROUTES *** + '/marine/investigative/data-log': (context) => + const marineInvestigativeDataStatusLog.MarineInvestigativeDataStatusLog(), + '/marine/investigative/image-request': (context) => + const marineInvestigativeImageRequest.MarineInvestigativeImageRequestScreen(), + // *** END: ADDED NEW MARINE INVESTIGATIVE ROUTES *** '/marine/investigative/overview': (context) => marineInvestigativeOverview.OverviewScreen(), '/marine/investigative/entry': (context) => marineInvestigativeEntry.EntryScreen(), '/marine/investigative/report': (context) => marineInvestigativeReport.ReportScreen(), diff --git a/lib/models/marine_manual_npe_report_data.dart b/lib/models/marine_manual_npe_report_data.dart index 4326639..6b85d44 100644 --- a/lib/models/marine_manual_npe_report_data.dart +++ b/lib/models/marine_manual_npe_report_data.dart @@ -47,6 +47,15 @@ class MarineManualNpeReportData { File? image2; File? image3; File? image4; + String? image1Remark; + String? image2Remark; + String? image3Remark; + String? image4Remark; + + // --- START: Added Tarball Classification Fields --- + int? tarballClassificationId; + Map? selectedTarballClassification; + // --- END: Added Tarball Classification Fields --- // --- Submission Status --- String? submissionStatus; @@ -76,6 +85,14 @@ class MarineManualNpeReportData { 'fieldObservations': fieldObservations, 'othersObservationRemark': othersObservationRemark, 'possibleSource': possibleSource, + 'image1Remark': image1Remark, + 'image2Remark': image2Remark, + 'image3Remark': image3Remark, + 'image4Remark': image4Remark, + // --- Added Fields --- + 'tarballClassificationId': tarballClassificationId, + 'selectedTarballClassification': selectedTarballClassification, + // --- 'submissionStatus': submissionStatus, 'submissionMessage': submissionMessage, 'reportId': reportId, @@ -121,6 +138,15 @@ class MarineManualNpeReportData { } add('npe_possible_source', possibleSource); + add('npe_image_1_remarks', image1Remark); + add('npe_image_2_remarks', image2Remark); + add('npe_image_3_remarks', image3Remark); + add('npe_image_4_remarks', image4Remark); + + // --- Added Fields --- + add('classification_id', tarballClassificationId); + // --- + return map; } @@ -169,6 +195,28 @@ class MarineManualNpeReportData { ..writeln('*Possible Source:* $possibleSource'); } + // --- Added Tarball Classification to Telegram message --- + if (selectedTarballClassification != null) { + buffer + ..writeln() + ..writeln('*Tarball Classification:* ${selectedTarballClassification!['classification_name']}'); + } + // --- + + final remarks = [ + if (image1Remark != null && image1Remark!.isNotEmpty) '*Fig 1:* $image1Remark', + if (image2Remark != null && image2Remark!.isNotEmpty) '*Fig 2:* $image2Remark', + if (image3Remark != null && image3Remark!.isNotEmpty) '*Fig 3:* $image3Remark', + if (image4Remark != null && image4Remark!.isNotEmpty) '*Fig 4:* $image4Remark', + ]; + + if (remarks.isNotEmpty) { + buffer + ..writeln() + ..writeln('๐Ÿ“ธ *Attachment Remarks:*'); + buffer.writeAll(remarks, '\n'); + } + return buffer.toString(); } } \ No newline at end of file diff --git a/lib/screens/marine/investigative/marine_investigative_data_status_log.dart b/lib/screens/marine/investigative/marine_investigative_data_status_log.dart new file mode 100644 index 0000000..21ef57c --- /dev/null +++ b/lib/screens/marine/investigative/marine_investigative_data_status_log.dart @@ -0,0 +1,776 @@ +// lib/screens/marine/investigative/marine_investigative_data_status_log.dart + +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:environment_monitoring_app/auth_provider.dart'; +import 'package:environment_monitoring_app/models/marine_inves_manual_sampling_data.dart'; +import 'package:environment_monitoring_app/services/local_storage_service.dart'; +import 'package:environment_monitoring_app/services/marine_investigative_sampling_service.dart'; +import 'dart:convert'; + +/// A simple class to hold an image file and its associated remark. +class ImageLogEntry { + final File file; + final String? remark; + ImageLogEntry({required this.file, this.remark}); +} + +class SubmissionLogEntry { + final String type; + final String title; + final String stationCode; + final DateTime submissionDateTime; + final String? reportId; + final String status; + final String message; + final Map rawData; + final String serverName; + final String? apiStatusRaw; + final String? ftpStatusRaw; + bool isResubmitting; + + SubmissionLogEntry({ + required this.type, + required this.title, + required this.stationCode, + required this.submissionDateTime, + this.reportId, + required this.status, + required this.message, + required this.rawData, + required this.serverName, + this.apiStatusRaw, + this.ftpStatusRaw, + this.isResubmitting = false, + }); +} + +class MarineInvestigativeDataStatusLog extends StatefulWidget { + const MarineInvestigativeDataStatusLog({super.key}); + + @override + State createState() => + _MarineInvestigativeDataStatusLogState(); +} + +class _MarineInvestigativeDataStatusLogState + extends State { + final LocalStorageService _localStorageService = LocalStorageService(); + late MarineInvestigativeSamplingService _marineInvestigativeService; + + List _investigativeLogs = []; + List _filteredInvestigativeLogs = []; + + final TextEditingController _investigativeSearchController = + TextEditingController(); + + bool _isLoading = true; + final Map _isResubmitting = {}; + + @override + void initState() { + super.initState(); + _investigativeSearchController.addListener(_filterLogs); + _loadAllLogs(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Fetch the single, global instance of the service from the Provider tree. + _marineInvestigativeService = + Provider.of(context); + } + + @override + void dispose() { + _investigativeSearchController.dispose(); + super.dispose(); + } + + String _getStationName(Map log) { + String stationType = log['stationTypeSelection'] ?? ''; + if (stationType == 'Existing Manual Station') { + return log['selectedStation']?['man_station_name'] ?? 'Unknown Manual Station'; + } else if (stationType == 'Existing Tarball Station') { + return log['selectedTarballStation']?['tbl_station_name'] ?? + 'Unknown Tarball Station'; + } else if (stationType == 'New Location') { + return log['newStationName'] ?? 'New Location'; + } + return 'Unknown Station'; + } + + String _getStationCode(Map log) { + String stationType = log['stationTypeSelection'] ?? ''; + if (stationType == 'Existing Manual Station') { + return log['selectedStation']?['man_station_code'] ?? 'N/A'; + } else if (stationType == 'Existing Tarball Station') { + return log['selectedTarballStation']?['tbl_station_code'] ?? 'N/A'; + } else if (stationType == 'New Location') { + return log['newStationCode'] ?? 'NEW'; + } + return 'N/A'; + } + + Future _loadAllLogs() async { + setState(() => _isLoading = true); + + final investigativeLogs = + await _localStorageService.getAllInvestigativeLogs(); + + final List tempInvestigative = []; + + for (var log in investigativeLogs) { + final String dateStr = log['sampling_date'] ?? ''; + final String timeStr = log['sampling_time'] ?? ''; + + final dt = DateTime.tryParse('$dateStr $timeStr'); + + tempInvestigative.add(SubmissionLogEntry( + type: 'Investigative Sampling', + title: _getStationName(log), + stationCode: _getStationCode(log), + submissionDateTime: dt ?? DateTime.fromMillisecondsSinceEpoch(0), + reportId: log['reportId']?.toString(), + status: log['submissionStatus'] ?? 'L1', + message: log['submissionMessage'] ?? 'No status message.', + rawData: log, + serverName: log['serverConfigName'] ?? 'Unknown Server', + apiStatusRaw: log['api_status'], + ftpStatusRaw: log['ftp_status'], + )); + } + + tempInvestigative + .sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + + if (mounted) { + setState(() { + _investigativeLogs = tempInvestigative; + _isLoading = false; + }); + _filterLogs(); + } + } + + void _filterLogs() { + final investigativeQuery = + _investigativeSearchController.text.toLowerCase(); + + setState(() { + _filteredInvestigativeLogs = _investigativeLogs + .where((log) => _logMatchesQuery(log, investigativeQuery)) + .toList(); + }); + } + + bool _logMatchesQuery(SubmissionLogEntry log, String query) { + if (query.isEmpty) return true; + return log.title.toLowerCase().contains(query) || + log.stationCode.toLowerCase().contains(query) || + log.serverName.toLowerCase().contains(query) || + (log.reportId?.toLowerCase() ?? '').contains(query); + } + + Future _resubmitData(SubmissionLogEntry log) async { + final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); + if (mounted) { + setState(() { + _isResubmitting[logKey] = true; + }); + } + + try { + final authProvider = Provider.of(context, listen: false); + final appSettings = authProvider.appSettings; + + Map result = {}; + + if (log.type == 'Investigative Sampling') { + final dataToResubmit = + MarineInvesManualSamplingData.fromJson(log.rawData); + + result = await _marineInvestigativeService.submitInvestigativeSample( + data: dataToResubmit, + appSettings: appSettings, + context: context, + authProvider: authProvider, + logDirectory: log.rawData['logDirectory'] as String?, + ); + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text(result['message'] ?? 'Resubmission process completed.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Resubmission failed: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isResubmitting.remove(logKey); + }); + _loadAllLogs(); + } + } + } + + @override + Widget build(BuildContext context) { + final hasAnyLogs = _investigativeLogs.isNotEmpty; + + return Scaffold( + appBar: + AppBar(title: const Text('Marine Investigative Data Status Log')), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _loadAllLogs, + child: !hasAnyLogs + ? const Center(child: Text('No submission logs found.')) + : ListView( + padding: const EdgeInsets.all(8.0), + children: [ + _buildCategorySection('Investigative Sampling', + _filteredInvestigativeLogs, _investigativeSearchController), + ], + ), + ), + ); + } + + Widget _buildCategorySection(String category, List logs, + TextEditingController searchController) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 8.0), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(category, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold)), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: TextField( + controller: searchController, + decoration: InputDecoration( + hintText: 'Search in $category...', + prefixIcon: const Icon(Icons.search, size: 20), + isDense: true, + border: const OutlineInputBorder(), + suffixIcon: searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + searchController.clear(); + }, + ) + : null, + ), + ), + ), + const Divider(), + if (logs.isEmpty) + const Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: Text( + 'No logs match your search in this category.'))) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: logs.length, + itemBuilder: (context, index) { + return _buildLogListItem(logs[index]); + }, + ), + ], + ), + ), + ); + } + + Widget _buildLogListItem(SubmissionLogEntry log) { + final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); + final isResubmitting = _isResubmitting[logKey] ?? false; + + final bool isFullSuccess = log.status == 'S4'; + final bool isPartialSuccess = log.status == 'S3' || log.status == 'L4'; + final bool canResubmit = !isFullSuccess; + + IconData statusIcon; + Color statusColor; + + if (isFullSuccess) { + statusIcon = Icons.check_circle_outline; + statusColor = Colors.green; + } else if (isPartialSuccess) { + statusIcon = Icons.warning_amber_rounded; + statusColor = Colors.orange; + } else { + statusIcon = Icons.error_outline; + statusColor = Colors.red; + } + + final titleWidget = RichText( + text: TextSpan( + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith(fontWeight: FontWeight.w500), + children: [ + TextSpan(text: '${log.title} '), + TextSpan( + text: '(${log.stationCode})', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(fontWeight: FontWeight.normal), + ), + ], + ), + ); + + final bool isDateValid = !log.submissionDateTime + .isAtSameMomentAs(DateTime.fromMillisecondsSinceEpoch(0)); + final subtitle = isDateValid + ? '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}' + : '${log.serverName} - Invalid Date'; + + return ExpansionTile( + key: PageStorageKey(logKey), + leading: Icon(statusIcon, color: statusColor), + title: titleWidget, + subtitle: Text(subtitle), + trailing: canResubmit + ? (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), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton.icon( + icon: Icon(Icons.list_alt, + color: Theme.of(context).colorScheme.primary), + label: Text('View Data', + style: TextStyle( + color: Theme.of(context).colorScheme.primary)), + onPressed: () => _showDataDialog(context, log), + ), + TextButton.icon( + icon: Icon(Icons.photo_library_outlined, + color: Theme.of(context).colorScheme.secondary), + label: Text('View Images', + style: TextStyle( + color: + Theme.of(context).colorScheme.secondary)), + onPressed: () => _showImageDialog(context, log), + ), + ], + ), + ), + const Divider(height: 10), + _buildGranularStatus('API', log.apiStatusRaw), + _buildGranularStatus('FTP', log.ftpStatusRaw), + ], + ), + ) + ], + ); + } + + TableRow _buildCategoryRow( + BuildContext context, String title, IconData icon) { + return TableRow( + decoration: BoxDecoration( + color: Colors.grey.shade100, + ), + children: [ + Padding( + padding: + const EdgeInsets.only(top: 16.0, bottom: 8.0, left: 8.0, right: 8.0), + child: Row( + children: [ + Icon(icon, size: 20, color: Theme.of(context).primaryColor), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + ), + const SizedBox.shrink(), // Empty cell for the second column + ], + ); + } + + TableRow _buildDataTableRow(String label, String? value) { + String displayValue = + (value == null || value.isEmpty || value == 'null') ? 'N/A' : value; + + if (displayValue == '-999.0' || displayValue == '-999') { + displayValue = 'N/A'; + } + + return TableRow( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + child: Text(displayValue), + ), + ], + ); + } + + String? _getString(Map data, String key) { + final value = data[key]; + if (value == null) return null; + if (value is double && value == -999.0) return 'N/A'; + return value.toString(); + } + + void _showDataDialog(BuildContext context, SubmissionLogEntry log) { + final Map data = log.rawData; + final List tableRows = []; + + // --- 1. Sampling Info --- + tableRows.add( + _buildCategoryRow(context, 'Sampling Info', Icons.calendar_today)); + tableRows + .add(_buildDataTableRow('Date', _getString(data, 'sampling_date'))); + tableRows + .add(_buildDataTableRow('Time', _getString(data, 'sampling_time'))); + + String? firstSamplerName = _getString(data, 'first_sampler_name'); + tableRows.add(_buildDataTableRow('1st Sampler', firstSamplerName)); + + String? secondSamplerName; + if (data['secondSampler'] is Map) { + secondSamplerName = + (data['secondSampler'] as Map)['first_name']?.toString(); + } + tableRows.add(_buildDataTableRow('2nd Sampler', secondSamplerName)); + tableRows + .add(_buildDataTableRow('Sample ID', _getString(data, 'sample_id_code'))); + + // --- 2. Station & Location --- + tableRows.add( + _buildCategoryRow(context, 'Station & Location', Icons.location_on_outlined)); + tableRows.add( + _buildDataTableRow('Station', '${log.stationCode} - ${log.title}')); + tableRows.add(_buildDataTableRow( + 'Current Latitude', _getString(data, 'current_latitude'))); + tableRows.add(_buildDataTableRow( + 'Current Longitude', _getString(data, 'current_longitude'))); + tableRows.add(_buildDataTableRow( + 'Distance (km)', _getString(data, 'distance_difference_in_km'))); + tableRows.add(_buildDataTableRow( + 'Distance Remarks', _getString(data, 'distance_difference_remarks'))); + + // --- 3. Site Conditions --- + tableRows.add( + _buildCategoryRow(context, 'Site Conditions', Icons.wb_sunny_outlined)); + tableRows.add(_buildDataTableRow('Tide', _getString(data, 'tide_level'))); + tableRows.add(_buildDataTableRow('Sea', _getString(data, 'sea_condition'))); + tableRows.add(_buildDataTableRow('Weather', _getString(data, 'weather'))); + tableRows + .add(_buildDataTableRow('Event Remarks', _getString(data, 'event_remarks'))); + tableRows.add(_buildDataTableRow('Lab Remarks', _getString(data, 'lab_remarks'))); + + // --- 4. Parameters --- + tableRows + .add(_buildCategoryRow(context, 'Parameters', Icons.bar_chart)); + tableRows.add(_buildDataTableRow('Sonde ID', _getString(data, 'sonde_id'))); + tableRows.add( + _buildDataTableRow('Capture Date', _getString(data, 'data_capture_date'))); + tableRows.add( + _buildDataTableRow('Capture Time', _getString(data, 'data_capture_time'))); + tableRows.add(_buildDataTableRow( + 'Oxygen Conc (mg/L)', _getString(data, 'oxygen_concentration'))); + tableRows.add(_buildDataTableRow( + 'Oxygen Sat (%)', _getString(data, 'oxygen_saturation'))); + tableRows.add(_buildDataTableRow('pH', _getString(data, 'ph'))); + tableRows + .add(_buildDataTableRow('Salinity (ppt)', _getString(data, 'salinity'))); + tableRows.add(_buildDataTableRow( + 'Conductivity (ยตS/cm)', _getString(data, 'electrical_conductivity'))); + tableRows.add( + _buildDataTableRow('Temperature (ยฐC)', _getString(data, 'temperature'))); + tableRows.add(_buildDataTableRow('TDS (mg/L)', _getString(data, 'tds'))); + tableRows + .add(_buildDataTableRow('Turbidity (NTU)', _getString(data, 'turbidity'))); + tableRows.add( + _buildDataTableRow('Battery (V)', _getString(data, 'battery_voltage'))); + + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('${log.stationCode} - ${log.title}'), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: Table( + columnWidths: const { + 0: IntrinsicColumnWidth(), + 1: FlexColumnWidth(), + }, + border: TableBorder( + horizontalInside: BorderSide( + color: Colors.grey.shade300, + width: 0.5, + ), + ), + children: tableRows, + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); + } + + void _showImageDialog(BuildContext context, SubmissionLogEntry log) { + final List imageEntries = []; + + if (log.type == 'Investigative Sampling') { + const imageRemarkMap = { + 'inves_left_side_land_view': null, + 'inves_right_side_land_view': null, + 'inves_filling_water_into_sample_bottle': null, + 'inves_seawater_in_clear_glass_bottle': null, + 'inves_examine_preservative_ph_paper': null, + 'inves_optional_photo_01': 'inves_optional_photo_01_remarks', + 'inves_optional_photo_02': 'inves_optional_photo_02_remarks', + 'inves_optional_photo_03': 'inves_optional_photo_03_remarks', + 'inves_optional_photo_04': 'inves_optional_photo_04_remarks', + }; + + for (final entry in imageRemarkMap.entries) { + final imageKey = entry.key; + final remarkKey = entry.value; + + final path = log.rawData[imageKey]; + if (path != null && path is String && path.isNotEmpty) { + final file = File(path); + if (file.existsSync()) { + final remark = (remarkKey != null + ? log.rawData[remarkKey] as String? + : null); + imageEntries.add(ImageLogEntry(file: file, remark: remark)); + } + } + } + } + + if (imageEntries.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No images are attached to this log.'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Images for ${log.stationCode} - ${log.title}'), + content: SizedBox( + width: double.maxFinite, + child: GridView.builder( + shrinkWrap: true, + itemCount: imageEntries.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + final imageEntry = imageEntries[index]; + final bool hasRemark = + imageEntry.remark != null && imageEntry.remark!.isNotEmpty; + + return Card( + clipBehavior: Clip.antiAlias, + elevation: 2, + child: Stack( + fit: StackFit.expand, + children: [ + Image.file( + imageEntry.file, + fit: BoxFit.cover, + errorBuilder: (context, error, stack) { + return const Center( + child: Icon( + Icons.broken_image, + color: Colors.grey, + size: 40, + ), + ); + }, + ), + if (hasRemark) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(6.0), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withOpacity(0.8), + Colors.black.withOpacity(0.0) + ], + ), + ), + child: Text( + imageEntry.remark!, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); + } + + Widget _buildGranularStatus(String type, String? jsonStatus) { + if (jsonStatus == null || jsonStatus.isEmpty) { + return Container(); + } + + List statuses; + try { + statuses = jsonDecode(jsonStatus); + } catch (_) { + return _buildDetailRow('$type Status:', jsonStatus); + } + + if (statuses.isEmpty) { + return Container(); + } + + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$type Status:', + style: const TextStyle(fontWeight: FontWeight.bold)), + ...statuses.map((s) { + final serverName = + s['server_name'] ?? s['config_name'] ?? 'Server N/A'; + final status = s['message'] ?? 'N/A'; + final bool isSuccess = s['success'] as bool? ?? false; + final IconData icon = + isSuccess ? Icons.check_circle_outline : Icons.error_outline; + final Color color = isSuccess ? Colors.green : Colors.red; + 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)), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/investigative/marine_investigative_image_request.dart b/lib/screens/marine/investigative/marine_investigative_image_request.dart new file mode 100644 index 0000000..683853a --- /dev/null +++ b/lib/screens/marine/investigative/marine_investigative_image_request.dart @@ -0,0 +1,659 @@ +// lib/screens/marine/investigative/marine_investigative_image_request.dart + +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:dropdown_search/dropdown_search.dart'; +import 'package:intl/intl.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../../auth_provider.dart'; +import '../../../services/api_service.dart'; +import '../../../services/marine_api_service.dart'; + +// Keys for investigative images, matching the local DB/JSON keys +const List _investigativeImageKeys = [ + 'inves_left_side_land_view', + 'inves_right_side_land_view', + 'inves_filling_water_into_sample_bottle', + 'inves_seawater_in_clear_glass_bottle', + 'inves_examine_preservative_ph_paper', + 'inves_optional_photo_01', + 'inves_optional_photo_02', + 'inves_optional_photo_03', + 'inves_optional_photo_04', +]; + +class MarineInvestigativeImageRequestScreen extends StatefulWidget { + const MarineInvestigativeImageRequestScreen({super.key}); + + @override + State createState() => + _MarineInvestigativeImageRequestScreenState(); +} + +class _MarineInvestigativeImageRequestScreenState + extends State { + final _formKey = GlobalKey(); + final _dateController = TextEditingController(); + + // Based on the Investigative data model, users can sample at Manual or Tarball stations + String? _selectedStationType = 'Existing Manual Station'; + final List _stationTypes = [ + 'Existing Manual Station', + 'Existing Tarball Station' + ]; + + String? _selectedStateName; + String? _selectedCategoryName; + Map? _selectedStation; + DateTime? _selectedDate; + + List _statesList = []; + List _categoriesForState = []; + List> _stationsForCategory = []; + + bool _isLoading = false; + List _imageUrls = []; + final Set _selectedImageUrls = {}; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _initializeStationFilters(); + } + }); + } + + @override + void dispose() { + _dateController.dispose(); + super.dispose(); + } + + List> _getStationsForType(AuthProvider auth) { + switch (_selectedStationType) { + case 'Existing Manual Station': + return auth.manualStations ?? []; + case 'Existing Tarball Station': + return auth.tarballStations ?? []; + default: + return []; + } + } + + String _getStationIdKey() { + switch (_selectedStationType) { + case 'Existing Manual Station': + case 'Existing Tarball Station': + default: + return 'station_id'; // Both use 'station_id' + } + } + + String _getStationCodeKey() { + switch (_selectedStationType) { + case 'Existing Manual Station': + return 'man_station_code'; + case 'Existing Tarball Station': + return 'tbl_station_code'; + default: + return 'man_station_code'; + } + } + + String _getStationNameKey() { + switch (_selectedStationType) { + case 'Existing Manual Station': + return 'man_station_name'; + case 'Existing Tarball Station': + return 'tbl_station_name'; + default: + return 'man_station_name'; + } + } + + void _initializeStationFilters() { + final auth = Provider.of(context, listen: false); + final allStations = _getStationsForType(auth); + + if (allStations.isNotEmpty) { + final states = allStations + .map((s) => s['state_name'] as String?) + .whereType() + .toSet() + .toList(); + states.sort(); + setState(() { + _statesList = states; + _selectedStateName = null; + _selectedCategoryName = null; + _selectedStation = null; + _categoriesForState = []; + _stationsForCategory = []; + }); + } else { + setState(() { + _statesList = []; + _selectedStateName = null; + _selectedCategoryName = null; + _selectedStation = null; + _categoriesForState = []; + _stationsForCategory = []; + }); + } + } + + Future _selectDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedDate ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime.now(), + ); + + if (picked != null && picked != _selectedDate) { + setState(() { + _selectedDate = picked; + _dateController.text = DateFormat('yyyy-MM-dd').format(_selectedDate!); + }); + } + } + + Future _searchImages() async { + if (_formKey.currentState!.validate()) { + setState(() { + _isLoading = true; + _imageUrls = []; + _selectedImageUrls.clear(); + }); + + debugPrint("[Investigative Image Request] Search button pressed."); + debugPrint("[Investigative Image Request] Selected Station: ${_selectedStation}"); + debugPrint("[Investigative Image Request] Date: ${_selectedDate}"); + debugPrint("[Investigative Image Request] Station Type: $_selectedStationType"); + + if (_selectedStation == null || + _selectedDate == null || + _selectedStationType == null) { + debugPrint( + "[Investigative Image Request] ERROR: Station, date, or station type is missing."); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('Error: Station, date, or station type is missing.'), + backgroundColor: Colors.red, + ), + ); + setState(() => _isLoading = false); + } + return; + } + + final stationIdKey = _getStationIdKey(); + final stationId = _selectedStation![stationIdKey]; + + if (stationId == null) { + debugPrint("[Investigative Image Request] ERROR: Station ID is null."); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Error: Invalid station data.'), + backgroundColor: Colors.red, + ), + ); + setState(() => _isLoading = false); + } + return; + } + + final apiService = Provider.of(context, listen: false); + + try { + debugPrint( + "[Investigative Image Request] Calling API with Station ID: $stationId, Type: $_selectedStationType"); + + // *** NOTE: This assumes you have created 'getInvestigativeSamplingImages' in MarineApiService *** + final result = await apiService.marine.getInvestigativeSamplingImages( + stationId: stationId as int, + samplingDate: _selectedDate!, + stationType: _selectedStationType!, + ); + + if (mounted && result['success'] == true) { + final List> records = + List>.from(result['data'] ?? []); + final List fetchedUrls = []; + + // For investigative, we only use one set of keys + final List imageKeys = _investigativeImageKeys; + + for (final record in records) { + for (final key in imageKeys) { + if (record[key] != null && (record[key] as String).isNotEmpty) { + final String imagePathFromServer = record[key]; + + final fullUrl = imagePathFromServer.startsWith('http') + ? imagePathFromServer + : ApiService.imageBaseUrl + imagePathFromServer; + + fetchedUrls.add(fullUrl); + debugPrint( + "[Investigative Image Request] Found and constructed URL: $fullUrl"); + } + } + } + + setState(() { + _imageUrls = fetchedUrls.toSet().toList(); + }); + debugPrint( + "[Investigative Image Request] Successfully processed ${_imageUrls.length} image URLs."); + } else if (mounted) { + debugPrint( + "[Investigative Image Request] API call failed. Message: ${result['message']}"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message'] ?? 'Failed to fetch images.')), + ); + } + } catch (e) { + debugPrint( + "[Investigative Image Request] An exception occurred during API call: $e"); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('An error occurred: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } else { + debugPrint("[Investigative Image Request] Form validation failed."); + } + } + + Future _showEmailDialog() async { + final emailController = TextEditingController(); + final dialogFormKey = GlobalKey(); + + return showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return StatefulBuilder( + builder: (context, setDialogState) { + bool isSending = false; + + return AlertDialog( + title: const Text('Send Images via Email'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSending) + const Padding( + padding: EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(width: 24), + Text("Sending..."), + ], + ), + ) + else + Form( + key: dialogFormKey, + child: TextFormField( + controller: emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Recipient Email Address', + hintText: 'user@example.com', + ), + validator: (value) { + if (value == null || + value.isEmpty || + !RegExp(r'\S+@\S+\.\S+').hasMatch(value)) { + return 'Please enter a valid email address.'; + } + return null; + }, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: isSending + ? null + : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: isSending + ? null + : () async { + if (dialogFormKey.currentState!.validate()) { + setDialogState(() => isSending = true); + await _sendEmailRequestToServer( + emailController.text); + if (mounted) Navigator.of(context).pop(); + } + }, + child: const Text('Send'), + ), + ], + ); + }, + ); + }, + ); + } + + Future _sendEmailRequestToServer(String toEmail) async { + final apiService = Provider.of(context, listen: false); + + try { + debugPrint( + "[Investigative Image Request] Sending email request to server for recipient: $toEmail"); + + final stationCode = _selectedStation?[_getStationCodeKey()] ?? 'N/A'; + final stationName = _selectedStation?[_getStationNameKey()] ?? 'N/A'; + final fullStationIdentifier = '$stationCode - $stationName'; + + // *** NOTE: This assumes you have created 'sendInvestigativeImageRequestEmail' in MarineApiService *** + final result = await apiService.marine.sendInvestigativeImageRequestEmail( + recipientEmail: toEmail, + imageUrls: _selectedImageUrls.toList(), + stationName: fullStationIdentifier, + samplingDate: _dateController.text, + ); + + if (mounted) { + if (result['success'] == true) { + debugPrint( + "[Investigative Image Request] Server responded with success for email request."); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Success! Email is being sent by the server.'), + backgroundColor: Colors.green, + ), + ); + } else { + debugPrint( + "[Investigative Image Request] Server responded with failure for email request. Message: ${result['message']}"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: ${result['message']}'), + backgroundColor: Colors.red, + ), + ); + } + } + } catch (e) { + debugPrint( + "[Investigative Image Request] An exception occurred while sending email request: $e"); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('An error occurred: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("Marine Investigative Image Request")), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(24.0), + children: [ + Text("Image Search Filters", + style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 24), + DropdownButtonFormField( + value: _selectedStationType, + items: _stationTypes + .map((type) => DropdownMenuItem(value: type, child: Text(type))) + .toList(), + onChanged: (value) => setState(() { + _selectedStationType = value; + _initializeStationFilters(); + }), + decoration: const InputDecoration( + labelText: 'Station Type *', border: OutlineInputBorder()), + validator: (value) => value == null ? 'Please select a type' : null, + ), + const SizedBox(height: 16), + DropdownSearch( + items: _statesList, + selectedItem: _selectedStateName, + popupProps: const PopupProps.menu( + showSearchBox: true, + searchFieldProps: TextFieldProps( + decoration: InputDecoration(hintText: "Search State..."))), + dropdownDecoratorProps: const DropDownDecoratorProps( + dropdownSearchDecoration: InputDecoration( + labelText: "Select State *", + border: OutlineInputBorder())), + onChanged: (state) { + setState(() { + _selectedStateName = state; + _selectedCategoryName = null; + _selectedStation = null; + final auth = Provider.of(context, listen: false); + final allStations = _getStationsForType(auth); + final categories = state != null + ? allStations + .where((s) => s['state_name'] == state) + .map((s) => s['category_name'] as String?) + .whereType() + .toSet() + .toList() + : []; + categories.sort(); + _categoriesForState = categories; + _stationsForCategory = []; + }); + }, + validator: (val) => val == null ? "State is required" : null, + ), + const SizedBox(height: 16), + DropdownSearch( + items: _categoriesForState, + selectedItem: _selectedCategoryName, + enabled: _selectedStateName != null, + popupProps: const PopupProps.menu( + showSearchBox: true, + searchFieldProps: TextFieldProps( + decoration: + InputDecoration(hintText: "Search Category..."))), + dropdownDecoratorProps: const DropDownDecoratorProps( + dropdownSearchDecoration: InputDecoration( + labelText: "Select Category *", + border: OutlineInputBorder())), + onChanged: (category) { + setState(() { + _selectedCategoryName = category; + _selectedStation = null; + final auth = Provider.of(context, listen: false); + final allStations = _getStationsForType(auth); + final stationCodeKey = _getStationCodeKey(); + _stationsForCategory = category != null + ? (allStations + .where((s) => + s['state_name'] == _selectedStateName && + s['category_name'] == category) + .toList() + ..sort((a, b) => (a[stationCodeKey] ?? '') + .compareTo(b[stationCodeKey] ?? ''))) + : []; + }); + }, + validator: (val) => _selectedStateName != null && val == null + ? "Category is required" + : null, + ), + const SizedBox(height: 16), + DropdownSearch>( + items: _stationsForCategory, + selectedItem: _selectedStation, + enabled: _selectedCategoryName != null, + itemAsString: (station) { + final code = station[_getStationCodeKey()] ?? 'N/A'; + final name = station[_getStationNameKey()] ?? 'N/A'; + return "$code - $name"; + }, + popupProps: const PopupProps.menu( + showSearchBox: true, + searchFieldProps: TextFieldProps( + decoration: + InputDecoration(hintText: "Search Station..."))), + dropdownDecoratorProps: const DropDownDecoratorProps( + dropdownSearchDecoration: InputDecoration( + labelText: "Select Station *", + border: OutlineInputBorder())), + onChanged: (station) => setState(() => _selectedStation = station), + validator: (val) => _selectedCategoryName != null && val == null + ? "Station is required" + : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _dateController, + readOnly: true, + decoration: InputDecoration( + labelText: 'Select Date *', + hintText: 'Tap to pick a date', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: const Icon(Icons.calendar_today), + onPressed: _selectDate, + ), + ), + onTap: _selectDate, + validator: (val) => + val == null || val.isEmpty ? "Date is required" : null, + ), + const SizedBox(height: 32), + ElevatedButton.icon( + icon: const Icon(Icons.search), + label: const Text('Search Images'), + onPressed: _isLoading ? null : _searchImages, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + textStyle: + const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 24), + const Divider(thickness: 1), + const SizedBox(height: 16), + Text("Results", style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 16), + _buildResults(), + if (_selectedImageUrls.isNotEmpty) ...[ + const SizedBox(height: 24), + ElevatedButton.icon( + icon: const Icon(Icons.email_outlined), + label: + Text('Send (${_selectedImageUrls.length}) Selected Image(s)'), + onPressed: _showEmailDialog, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildResults() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_imageUrls.isEmpty) { + return const Center( + child: Text( + 'No images found. Please adjust your filters and search again.', + textAlign: TextAlign.center, + ), + ); + } + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 1.0, + ), + itemCount: _imageUrls.length, + itemBuilder: (context, index) { + final imageUrl = _imageUrls[index]; + final isSelected = _selectedImageUrls.contains(imageUrl); + + return GestureDetector( + onTap: () { + setState(() { + if (isSelected) { + _selectedImageUrls.remove(imageUrl); + } else { + _selectedImageUrls.add(imageUrl); + } + }); + }, + child: Card( + clipBehavior: Clip.antiAlias, + elevation: 2.0, + child: GridTile( + child: Stack( + fit: StackFit.expand, + children: [ + Image.network( + imageUrl, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return const Center( + child: CircularProgressIndicator(strokeWidth: 2)); + }, + errorBuilder: (context, error, stackTrace) { + return const Icon(Icons.broken_image, + color: Colors.grey, size: 40); + }, + ), + if (isSelected) + Container( + color: Colors.black.withOpacity(0.6), + child: const Icon(Icons.check_circle, + color: Colors.white, size: 40), + ), + ], + ), + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/screens/marine/manual/marine_manual_data_status_log.dart b/lib/screens/marine/manual/marine_manual_data_status_log.dart index 5591834..4be3c73 100644 --- a/lib/screens/marine/manual/marine_manual_data_status_log.dart +++ b/lib/screens/marine/manual/marine_manual_data_status_log.dart @@ -15,6 +15,15 @@ import 'package:environment_monitoring_app/services/marine_tarball_sampling_serv // END CHANGE import 'dart:convert'; +// --- START: ADDED HELPER CLASS --- +/// A simple class to hold an image file and its associated remark. +class ImageLogEntry { + final File file; + final String? remark; + ImageLogEntry({required this.file, this.remark}); +} +// --- END: ADDED HELPER CLASS --- + class SubmissionLogEntry { final String type; @@ -59,13 +68,21 @@ class _MarineManualDataStatusLogState extends State { late MarineInSituSamplingService _marineInSituService; late MarineTarballSamplingService _marineTarballService; + // --- START: MODIFIED STATE FOR DROPDOWN --- + String _selectedModule = 'Manual Sampling'; + final List _modules = ['Manual Sampling', 'Tarball Sampling', 'Pre-Sampling', 'Report']; + final TextEditingController _searchController = TextEditingController(); + List _manualLogs = []; List _tarballLogs = []; + List _preSamplingLogs = []; // No data source, will be empty + List _reportLogs = []; // Will hold NPE logs + List _filteredManualLogs = []; List _filteredTarballLogs = []; - - final TextEditingController _manualSearchController = TextEditingController(); - final TextEditingController _tarballSearchController = TextEditingController(); + List _filteredPreSamplingLogs = []; + List _filteredReportLogs = []; + // --- END: MODIFIED STATE --- bool _isLoading = true; final Map _isResubmitting = {}; @@ -75,8 +92,7 @@ class _MarineManualDataStatusLogState extends State { super.initState(); // MODIFIED: Service instantiations are removed from initState. // They will be initialized in didChangeDependencies. - _manualSearchController.addListener(_filterLogs); - _tarballSearchController.addListener(_filterLogs); + _searchController.addListener(_filterLogs); // Use single search controller _loadAllLogs(); } @@ -86,43 +102,46 @@ class _MarineManualDataStatusLogState extends State { void didChangeDependencies() { super.didChangeDependencies(); // Fetch the single, global instances of the services from the Provider tree. - _marineInSituService = Provider.of(context); - _marineTarballService = Provider.of(context); + _marineInSituService = Provider.of(context, listen: false); + _marineTarballService = Provider.of(context, listen: false); } @override void dispose() { - _manualSearchController.dispose(); - _tarballSearchController.dispose(); + _searchController.dispose(); // Dispose single search controller super.dispose(); } Future _loadAllLogs() async { setState(() => _isLoading = true); + // --- START MODIFICATION: Load logs sequentially to avoid parallel permission requests --- + // final [tarballLogs, inSituLogs, npeLogs] = await Future.wait([ + // _localStorageService.getAllTarballLogs(), + // _localStorageService.getAllInSituLogs(), + // _localStorageService.getAllNpeLogs(), // Fetch NPE logs for "Report" + // ]); final tarballLogs = await _localStorageService.getAllTarballLogs(); final inSituLogs = await _localStorageService.getAllInSituLogs(); + final npeLogs = await _localStorageService.getAllNpeLogs(); + // --- END MODIFICATION --- final List tempManual = []; final List tempTarball = []; + final List tempReport = []; + final List tempPreSampling = []; // Empty list + // Process In-Situ (Manual Sampling) for (var log in inSituLogs) { - // START FIX: Use backward-compatible keys to read the timestamp final String dateStr = log['sampling_date'] ?? log['man_date'] ?? ''; final String timeStr = log['sampling_time'] ?? log['man_time'] ?? ''; - // END FIX - - // --- START FIX: Prevent fallback to DateTime.now() to make errors visible --- final dt = DateTime.tryParse('$dateStr $timeStr'); - // --- END FIX --- tempManual.add(SubmissionLogEntry( type: 'Manual Sampling', title: log['selectedStation']?['man_station_name'] ?? 'Unknown Station', stationCode: log['selectedStation']?['man_station_code'] ?? 'N/A', - // --- START FIX: Use the parsed date or a placeholder for invalid entries --- submissionDateTime: dt ?? DateTime.fromMillisecondsSinceEpoch(0), - // --- END FIX --- reportId: log['reportId']?.toString(), status: log['submissionStatus'] ?? 'L1', message: log['submissionMessage'] ?? 'No status message.', @@ -133,15 +152,17 @@ class _MarineManualDataStatusLogState extends State { )); } + // Process Tarball for (var log in tarballLogs) { final dateStr = log['sampling_date'] ?? ''; final timeStr = log['sampling_time'] ?? ''; + final dt = DateTime.tryParse('$dateStr $timeStr'); tempTarball.add(SubmissionLogEntry( type: 'Tarball Sampling', title: log['selectedStation']?['tbl_station_name'] ?? 'Unknown Station', stationCode: log['selectedStation']?['tbl_station_code'] ?? 'N/A', - submissionDateTime: DateTime.tryParse('$dateStr $timeStr') ?? DateTime.fromMillisecondsSinceEpoch(0), + submissionDateTime: dt ?? DateTime.fromMillisecondsSinceEpoch(0), reportId: log['reportId']?.toString(), status: log['submissionStatus'] ?? 'L1', message: log['submissionMessage'] ?? 'No status message.', @@ -152,28 +173,74 @@ class _MarineManualDataStatusLogState extends State { )); } + // --- START: ADDED NPE LOG PROCESSING --- + // Process NPE (Report) + for (var log in npeLogs) { + final dateStr = log['eventDate'] ?? ''; + final timeStr = log['eventTime'] ?? ''; + final dt = DateTime.tryParse('$dateStr $timeStr'); + + String stationName = 'N/A'; + String stationCode = 'N/A'; + if (log['selectedStation'] != null) { + stationName = log['selectedStation']['man_station_name'] ?? + log['selectedStation']['tbl_station_name'] ?? + 'Unknown Station'; + stationCode = log['selectedStation']['man_station_code'] ?? + log['selectedStation']['tbl_station_code'] ?? + 'N/A'; + } else if (log['locationDescription'] != null) { + stationName = log['locationDescription']; + stationCode = 'New Location'; + } + + tempReport.add(SubmissionLogEntry( + type: 'NPE Report', + title: stationName, + stationCode: stationCode, + submissionDateTime: dt ?? DateTime.fromMillisecondsSinceEpoch(0), + reportId: log['reportId']?.toString(), + status: log['submissionStatus'] ?? 'L1', + message: log['submissionMessage'] ?? 'No status message.', + rawData: log, + serverName: log['serverConfigName'] ?? 'Unknown Server', + apiStatusRaw: log['api_status'], + ftpStatusRaw: log['ftp_status'], + )); + } + // --- END: ADDED NPE LOG PROCESSING --- + + // Sort all lists tempManual.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); tempTarball.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + tempReport.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); if (mounted) { setState(() { _manualLogs = tempManual; _tarballLogs = tempTarball; + _reportLogs = tempReport; + _preSamplingLogs = tempPreSampling; // Stays empty _isLoading = false; }); - _filterLogs(); + _filterLogs(); // Apply initial filter } } + // --- START: MODIFIED _filterLogs --- void _filterLogs() { - final manualQuery = _manualSearchController.text.toLowerCase(); - final tarballQuery = _tarballSearchController.text.toLowerCase(); + final query = _searchController.text.toLowerCase(); setState(() { - _filteredManualLogs = _manualLogs.where((log) => _logMatchesQuery(log, manualQuery)).toList(); - _filteredTarballLogs = _tarballLogs.where((log) => _logMatchesQuery(log, tarballQuery)).toList(); + // We filter all lists regardless of selection, so data is ready + // if the user switches modules. + _filteredManualLogs = _manualLogs.where((log) => _logMatchesQuery(log, query)).toList(); + _filteredTarballLogs = _tarballLogs.where((log) => _logMatchesQuery(log, query)).toList(); + _filteredPreSamplingLogs = _preSamplingLogs.where((log) => _logMatchesQuery(log, query)).toList(); + _filteredReportLogs = _reportLogs.where((log) => _logMatchesQuery(log, query)).toList(); }); } + // --- END: MODIFIED _filterLogs --- bool _logMatchesQuery(SubmissionLogEntry log, String query) { if (query.isEmpty) return true; @@ -255,6 +322,13 @@ class _MarineManualDataStatusLogState extends State { context: context, ); } + // --- ADD RESUBMIT FOR NPE --- + else if (log.type == 'NPE Report') { + // As of now, resubmission for NPE is not defined in the provided files. + // We will show a message and not attempt resubmission. + result = {'message': 'Resubmission for NPE Reports is not currently supported.'}; + } + // --- END ADD --- if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -277,75 +351,151 @@ class _MarineManualDataStatusLogState extends State { } } + // --- START: MODIFIED build --- @override Widget build(BuildContext context) { - final hasAnyLogs = _manualLogs.isNotEmpty || _tarballLogs.isNotEmpty; - return Scaffold( appBar: AppBar(title: const Text('Marine Manual Data Status Log')), body: _isLoading ? const Center(child: CircularProgressIndicator()) - : RefreshIndicator( - onRefresh: _loadAllLogs, - child: !hasAnyLogs - ? const Center(child: Text('No submission logs found.')) - : ListView( - padding: const EdgeInsets.all(8.0), - children: [ - _buildCategorySection('Manual Sampling', _filteredManualLogs, _manualSearchController), - _buildCategorySection('Tarball Sampling', _filteredTarballLogs, _tarballSearchController), - ], - ), - ), - ); - } - - Widget _buildCategorySection(String category, List logs, TextEditingController searchController) { - return Card( - margin: const EdgeInsets.symmetric(vertical: 8.0), - child: Padding( + : Padding( padding: const EdgeInsets.all(8.0), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: TextField( - controller: searchController, - decoration: InputDecoration( - hintText: 'Search in $category...', - prefixIcon: const Icon(Icons.search, size: 20), - isDense: true, - border: const OutlineInputBorder(), - suffixIcon: searchController.text.isNotEmpty ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - searchController.clear(); - }, - ) : null, + // --- WIDGET 1: DROPDOWN --- + DropdownButtonFormField( + value: _selectedModule, + items: _modules.map((module) { + return DropdownMenuItem(value: module, child: Text(module)); + }).toList(), + onChanged: (newValue) { + if (newValue != null) { + setState(() { + _selectedModule = newValue; + _searchController.clear(); // Clear search on module change + }); + _filterLogs(); // Apply filter for new module + } + }, + decoration: const InputDecoration( + labelText: 'Select Module', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 12.0), + ), + ), + const SizedBox(height: 8), + + // --- WIDGET 2: THE "BLACK BOX" CARD --- + Expanded( + child: Card( + // Use Card's color or specify black-ish if needed + // color: Theme.of(context).cardColor, + elevation: 2, + margin: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + children: [ + // --- SEARCH BAR (MOVED INSIDE CARD) --- + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search in $_selectedModule...', + prefixIcon: const Icon(Icons.search, size: 20), + isDense: true, + border: const OutlineInputBorder(), + suffixIcon: _searchController.text.isNotEmpty ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + }, + ) : null, + ), + ), + ), + const Divider(height: 1), + + // --- LOG LIST (MOVED INSIDE CARD) --- + Expanded( + child: RefreshIndicator( + onRefresh: _loadAllLogs, + child: _buildCurrentModuleList(), + ), + ), + ], ), ), ), - const Divider(), - if (logs.isEmpty) - const Padding( - padding: EdgeInsets.all(16.0), - child: Center(child: Text('No logs match your search in this category.'))) - else - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: logs.length, - itemBuilder: (context, index) { - return _buildLogListItem(logs[index]); - }, - ), ], ), ), ); } + // --- END: MODIFIED build --- + + // --- START: NEW WIDGET _buildCurrentModuleList --- + /// Builds the list view based on the currently selected dropdown module. + Widget _buildCurrentModuleList() { + switch (_selectedModule) { + case 'Manual Sampling': + return _buildLogList( + filteredLogs: _filteredManualLogs, + totalLogCount: _manualLogs.length, + ); + case 'Tarball Sampling': + return _buildLogList( + filteredLogs: _filteredTarballLogs, + totalLogCount: _tarballLogs.length, + ); + case 'Pre-Sampling': + return _buildLogList( + filteredLogs: _filteredPreSamplingLogs, + totalLogCount: _preSamplingLogs.length, + ); + case 'Report': + return _buildLogList( + filteredLogs: _filteredReportLogs, + totalLogCount: _reportLogs.length, + ); + default: + return const Center(child: Text('Please select a module.')); + } + } + // --- END: NEW WIDGET _buildCurrentModuleList --- + + // --- START: MODIFIED WIDGET _buildLogList --- + /// A generic list builder that simply shows all filtered logs in a scrollable list. + Widget _buildLogList({ + required List filteredLogs, + required int totalLogCount, + }) { + if (filteredLogs.isEmpty) { + final String message = _searchController.text.isNotEmpty + ? 'No logs match your search.' + : (totalLogCount == 0 ? 'No submission logs found.' : 'No logs match your search.'); + + // Use a ListView to allow RefreshIndicator to work even when empty + return ListView( + children: [ + Padding( + padding: const EdgeInsets.all(32.0), + child: Center(child: Text(message)), + ), + ], + ); + } + + // Standard ListView.builder renders all filtered logs. + // It inherently only builds visible items, so it is efficient. + return ListView.builder( + itemCount: filteredLogs.length, + itemBuilder: (context, index) { + final log = filteredLogs[index]; + return _buildLogListItem(log); + }, + ); + } + // --- END: MODIFIED WIDGET _buildLogList --- Widget _buildLogListItem(SubmissionLogEntry log) { final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); @@ -355,9 +505,9 @@ class _MarineManualDataStatusLogState extends State { // Define the different states based on the detailed status code. final bool isFullSuccess = log.status == 'S4'; final bool isPartialSuccess = log.status == 'S3' || log.status == 'L4'; - final bool canResubmit = !isFullSuccess; // Allow resubmission for partial success or failure. + final bool canResubmit = !isFullSuccess && log.type != 'NPE Report'; // --- MODIFIED: Disable resubmit for NPE + // --- END: MODIFICATION FOR GRANULAR STATUS ICONS --- - // Determine the icon and color based on the state. IconData statusIcon; Color statusColor; @@ -371,7 +521,6 @@ class _MarineManualDataStatusLogState extends State { statusIcon = Icons.error_outline; statusColor = Colors.red; } - // --- END: MODIFICATION FOR GRANULAR STATUS ICONS --- final titleWidget = RichText( text: TextSpan( @@ -411,6 +560,31 @@ class _MarineManualDataStatusLogState extends State { _buildDetailRow('Server:', log.serverName), _buildDetailRow('Report ID:', log.reportId ?? 'N/A'), _buildDetailRow('Submission Type:', log.type), + + // --- START: ADDED BUTTONS --- + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton.icon( + icon: Icon(Icons.list_alt, color: Theme.of(context).colorScheme.primary), + label: Text('View Data', style: TextStyle(color: Theme.of(context).colorScheme.primary)), + onPressed: () => _showDataDialog(context, log), + ), + TextButton.icon( + icon: Icon(Icons.photo_library_outlined, color: Theme.of(context).colorScheme.secondary), + label: Text('View Images', style: TextStyle(color: Theme.of(context).colorScheme.secondary)), + onPressed: () => _showImageDialog(context, log), + ), + ], + ), + ), + // --- END: ADDED BUTTONS --- + + const Divider(height: 10), // --- ADDED DIVIDER --- + _buildGranularStatus('API', log.apiStatusRaw), // --- ADDED --- + _buildGranularStatus('FTP', log.ftpStatusRaw), // --- ADDED --- ], ), ) @@ -418,6 +592,414 @@ class _MarineManualDataStatusLogState extends State { ); } + // --- START: NEW HELPER WIDGETS FOR CATEGORIZED DIALOG --- + + /// Builds a formatted category header row for the data table. + TableRow _buildCategoryRow(BuildContext context, String title, IconData icon) { + return TableRow( + decoration: BoxDecoration( + color: Colors.grey.shade100, + ), + children: [ + Padding( + padding: const EdgeInsets.only(top: 16.0, bottom: 8.0, left: 8.0, right: 8.0), + child: Row( + children: [ + Icon(icon, size: 20, color: Theme.of(context).primaryColor), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + ), + const SizedBox.shrink(), // Empty cell for the second column + ], + ); + } + + /// Builds a formatted row for the data dialog, gracefully handling null/empty values. + TableRow _buildDataTableRow(String label, String? value) { + String displayValue = (value == null || value.isEmpty || value == 'null') ? 'N/A' : value; + + // Format special "missing" values + if (displayValue == '-999.0' || displayValue == '-999') { + displayValue = 'N/A'; + } + + return TableRow( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + child: Text(displayValue), // Use Text, NOT SelectableText + ), + ], + ); + } + + /// Helper to safely get a string value from the raw data map. + String? _getString(Map data, String key) { + final value = data[key]; + if (value == null) return null; + if (value is double && value == -999.0) return 'N/A'; + return value.toString(); + } + // --- END: NEW HELPER WIDGETS --- + + // ========================================================================= + // --- START OF MODIFIED FUNCTION --- + // ========================================================================= + + /// Shows the categorized and formatted data log in a dialog + void _showDataDialog(BuildContext context, SubmissionLogEntry log) { + final Map data = log.rawData; + final List tableRows = []; + + // --- 1. Sampling Info --- + tableRows.add(_buildCategoryRow(context, 'Sampling Info', Icons.calendar_today)); + // --- MODIFIED: Added keys for NPE Report + tableRows.add(_buildDataTableRow('Date', _getString(data, 'sampling_date') ?? _getString(data, 'man_date') ?? _getString(data, 'eventDate'))); + tableRows.add(_buildDataTableRow('Time', _getString(data, 'sampling_time') ?? _getString(data, 'man_time') ?? _getString(data, 'eventTime'))); + + String? firstSamplerName = _getString(data, 'first_sampler_name') ?? _getString(data, 'firstSampler'); + tableRows.add(_buildDataTableRow('1st Sampler', firstSamplerName)); + + String? secondSamplerName; + if (data['secondSampler'] is Map) { + secondSamplerName = (data['secondSampler'] as Map)['first_name']?.toString(); + } + tableRows.add(_buildDataTableRow('2nd Sampler', secondSamplerName)); + tableRows.add(_buildDataTableRow('Sample ID', _getString(data, 'sample_id_code') ?? _getString(data, 'sampleIdCode'))); // For In-Situ + + // --- 2. Station & Location --- + tableRows.add(_buildCategoryRow(context, 'Station & Location', Icons.location_on_outlined)); + tableRows.add(_buildDataTableRow('Station', '${log.stationCode} - ${log.title}')); + + String? stationLat; + String? stationLon; + if (data['selectedStation'] is Map) { + final stationMap = data['selectedStation'] as Map; + stationLat = _getString(stationMap, 'man_latitude') ?? _getString(stationMap, 'tbl_latitude'); + stationLon = _getString(stationMap, 'man_longitude') ?? _getString(stationMap, 'tbl_longitude'); + } + tableRows.add(_buildDataTableRow('Station Latitude', stationLat)); + tableRows.add(_buildDataTableRow('Station Longitude', stationLon)); + + // --- MODIFIED: Added 'latitude'/'longitude' keys for NPE + tableRows.add(_buildDataTableRow('Current Latitude', _getString(data, 'current_latitude') ?? _getString(data, 'currentLatitude') ?? _getString(data, 'latitude'))); + tableRows.add(_buildDataTableRow('Current Longitude', _getString(data, 'current_longitude') ?? _getString(data, 'currentLongitude') ?? _getString(data, 'longitude'))); + + tableRows.add(_buildDataTableRow('Distance (km)', _getString(data, 'distance_difference') ?? _getString(data, 'distanceDifferenceInKm') ?? _getString(data, 'distance_difference_in_km'))); + tableRows.add(_buildDataTableRow('Distance Remarks', _getString(data, 'distance_remarks') ?? _getString(data, 'distanceDifferenceRemarks') ?? _getString(data, 'distance_difference_remarks'))); + + // --- 3. Site Conditions (In-Situ or NPE) --- + if (log.type == 'Manual Sampling' || log.type == 'NPE Report') { + tableRows.add(_buildCategoryRow(context, 'Site Conditions', Icons.wb_sunny_outlined)); + tableRows.add(_buildDataTableRow('Tide', _getString(data, 'tide_level') ?? _getString(data, 'tideLevel') ?? _getString(data, 'tide_level_manual'))); + tableRows.add(_buildDataTableRow('Sea', _getString(data, 'sea_condition') ?? _getString(data, 'seaCondition') ?? _getString(data, 'sea_condition_manual'))); + tableRows.add(_buildDataTableRow('Weather', _getString(data, 'weather') ?? _getString(data, 'weather_manual'))); + + // --- MODIFIED: Use correct plural keys first + tableRows.add(_buildDataTableRow('Event Remarks', _getString(data, 'event_remarks') ?? _getString(data, 'man_event_remark') ?? _getString(data, 'eventRemark'))); + tableRows.add(_buildDataTableRow('Lab Remarks', _getString(data, 'lab_remarks') ?? _getString(data, 'man_lab_remark') ?? _getString(data, 'labRemark'))); + } + + // --- 4. Tarball Classification (Tarball only) --- + if (log.type == 'Tarball Sampling') { + tableRows.add(_buildCategoryRow(context, 'Classification', Icons.category_outlined)); + String? classification = "N/A"; + if (data['selectedClassification'] is Map) { + classification = (data['selectedClassification'] as Map)['classification_name']?.toString(); + } + tableRows.add(_buildDataTableRow('Classification', classification)); + } + + // --- 5. Parameters (In-Situ or NPE) --- + if (log.type == 'Manual Sampling' || log.type == 'NPE Report') { + tableRows.add(_buildCategoryRow(context, 'Parameters', Icons.bar_chart)); + tableRows.add(_buildDataTableRow('Sonde ID', _getString(data, 'sonde_id') ?? _getString(data, 'sondeId'))); + tableRows.add(_buildDataTableRow('Capture Date', _getString(data, 'data_capture_date') ?? _getString(data, 'dataCaptureDate'))); + tableRows.add(_buildDataTableRow('Capture Time', _getString(data, 'data_capture_time') ?? _getString(data, 'dataCaptureTime'))); + tableRows.add(_buildDataTableRow('Oxygen Conc (mg/L)', _getString(data, 'oxygen_concentration') ?? _getString(data, 'oxygenConcentration'))); + tableRows.add(_buildDataTableRow('Oxygen Sat (%)', _getString(data, 'oxygen_saturation') ?? _getString(data, 'oxygenSaturation'))); + tableRows.add(_buildDataTableRow('pH', _getString(data, 'ph'))); + tableRows.add(_buildDataTableRow('Salinity (ppt)', _getString(data, 'salinity'))); + tableRows.add(_buildDataTableRow('Conductivity (ยตS/cm)', _getString(data, 'electrical_conductivity') ?? _getString(data, 'electricalConductivity'))); + tableRows.add(_buildDataTableRow('Temperature (ยฐC)', _getString(data, 'temperature'))); + tableRows.add(_buildDataTableRow('TDS (mg/L)', _getString(data, 'tds'))); + tableRows.add(_buildDataTableRow('Turbidity (NTU)', _getString(data, 'turbidity'))); + tableRows.add(_buildDataTableRow('Battery (V)', _getString(data, 'battery_voltage') ?? _getString(data, 'batteryVoltage'))); + } + + // --- 6. NPE Specific Fields --- + if (log.type == 'NPE Report') { + tableRows.add(_buildCategoryRow(context, 'NPE Details', Icons.warning_amber_rounded)); + tableRows.add(_buildDataTableRow('Possible Source', _getString(data, 'possibleSource'))); + tableRows.add(_buildDataTableRow('Other Remarks', _getString(data, 'othersObservationRemark'))); + + if(data['fieldObservations'] is Map) { + final observations = data['fieldObservations'] as Map; + String obsText = observations.entries + .where((e) => e.value == true) + .map((e) => e.key) + .join(', '); + tableRows.add(_buildDataTableRow('Observations', obsText.isEmpty ? 'N/A' : obsText)); + } + } + + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('${log.stationCode} - ${log.title}'), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: Table( + columnWidths: const { + 0: IntrinsicColumnWidth(), + 1: FlexColumnWidth(), + }, + border: TableBorder( + horizontalInside: BorderSide( + color: Colors.grey.shade300, + width: 0.5, + ), + ), + children: tableRows, + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); + } + + // ========================================================================= + // --- END OF MODIFIED FUNCTION --- + // ========================================================================= + + /// Shows the image gallery dialog + void _showImageDialog(BuildContext context, SubmissionLogEntry log) { + + // --- START: MODIFIED to handle all log types --- + final List imageEntries = []; + + if (log.type == 'Manual Sampling') { + // In-Situ Image Keys + const imageRemarkMap = { + 'man_left_side_land_view': null, + 'man_right_side_land_view': null, + 'man_filling_water_into_sample_bottle': null, + 'man_seawater_in_clear_glass_bottle': null, + 'man_examine_preservative_ph_paper': null, + 'man_optional_photo_01': 'man_optional_photo_01_remarks', + 'man_optional_photo_02': 'man_optional_photo_02_remarks', + 'man_optional_photo_03': 'man_optional_photo_03_remarks', + 'man_optional_photo_04': 'man_optional_photo_04_remarks', + }; + _addImagesToList(log, imageRemarkMap, imageEntries); + + } else if (log.type == 'Tarball Sampling') { + // Tarball Image Keys + const imageRemarkMap = { + 'left_side_coastal_view': null, + 'right_side_coastal_view': null, + 'drawing_vertical_lines': null, + 'drawing_horizontal_line': null, + 'optional_photo_01': 'optional_photo_remark_01', + 'optional_photo_02': 'optional_photo_remark_02', + 'optional_photo_03': 'optional_photo_remark_03', + 'optional_photo_04': 'optional_photo_remark_04', + }; + _addImagesToList(log, imageRemarkMap, imageEntries); + + } else if (log.type == 'NPE Report') { + // NPE Image Keys (remarks not stored in log) + const imageRemarkMap = { + 'image1': null, + 'image2': null, + 'image3': null, + 'image4': null, + }; + _addImagesToList(log, imageRemarkMap, imageEntries); + } + // --- END: MODIFIED --- + + + if (imageEntries.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No images are attached to this log.'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Images for ${log.stationCode} - ${log.title}'), + content: SizedBox( + width: double.maxFinite, + child: GridView.builder( + shrinkWrap: true, + itemCount: imageEntries.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + final imageEntry = imageEntries[index]; + final bool hasRemark = imageEntry.remark != null && imageEntry.remark!.isNotEmpty; + + return Card( + clipBehavior: Clip.antiAlias, + elevation: 2, + child: Stack( + fit: StackFit.expand, + children: [ + Image.file( + imageEntry.file, + fit: BoxFit.cover, + errorBuilder: (context, error, stack) { + return const Center( + child: Icon( + Icons.broken_image, + color: Colors.grey, + size: 40, + ), + ); + }, + ), + if (hasRemark) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(6.0), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withOpacity(0.8), + Colors.black.withOpacity(0.0) + ], + ), + ), + child: Text( + imageEntry.remark!, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); + } + + // --- START: NEW HELPER for _showImageDialog --- + void _addImagesToList(SubmissionLogEntry log, Map imageRemarkMap, List imageEntries) { + for (final entry in imageRemarkMap.entries) { + final imageKey = entry.key; + final remarkKey = entry.value; + + final path = log.rawData[imageKey]; + if (path != null && path is String && path.isNotEmpty) { + final file = File(path); + if (file.existsSync()) { + final remark = (remarkKey != null ? log.rawData[remarkKey] as String? : null); + imageEntries.add(ImageLogEntry(file: file, remark: remark)); + } + } + } + } + // --- END: NEW HELPER --- + + Widget _buildGranularStatus(String type, String? jsonStatus) { + if (jsonStatus == null || jsonStatus.isEmpty) { + return Container(); + } + + List statuses; + try { + statuses = jsonDecode(jsonStatus); + } catch (_) { + return _buildDetailRow('$type Status:', jsonStatus); + } + + if (statuses.isEmpty) { + return Container(); + } + + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$type Status:', style: const TextStyle(fontWeight: FontWeight.bold)), + ...statuses.map((s) { + final serverName = s['server_name'] ?? s['config_name'] ?? 'Server N/A'; + final status = s['message'] ?? 'N/A'; + final bool isSuccess = s['success'] as bool? ?? false; + final IconData icon = isSuccess ? Icons.check_circle_outline : Icons.error_outline; + final Color color = isSuccess ? Colors.green : Colors.red; + 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), diff --git a/lib/screens/marine/manual/marine_manual_report.dart b/lib/screens/marine/manual/marine_manual_report.dart index 8807de5..4ba0f5c 100644 --- a/lib/screens/marine/manual/marine_manual_report.dart +++ b/lib/screens/marine/manual/marine_manual_report.dart @@ -23,7 +23,7 @@ class MarineManualReportHomePage extends StatelessWidget { ReportItem( icon: Icons.warning_amber_rounded, label: "Notification of Pollution Event", - formCode: "F-MM06", + formCode: "F-MM07", route: '/marine/manual/report/npe', ), ReportItem( diff --git a/lib/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart b/lib/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart index b6ee192..c7ca7c7 100644 --- a/lib/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart +++ b/lib/screens/marine/manual/reports/marine_manual_equipment_maintenance_screen.dart @@ -30,12 +30,8 @@ class _MarineManualEquipmentMaintenanceScreenState // --- Controllers --- final _maintenanceDateController = TextEditingController(); final _lastMaintenanceDateController = TextEditingController(); - // Controller removed for Schedule Maintenance dropdown - - // Renamed controllers, moved to header final _timeStartController = TextEditingController(); final _timeEndController = TextEditingController(); - final _locationController = TextEditingController(); // YSI controllers final _ysiSondeCommentsController = TextEditingController(); @@ -52,6 +48,13 @@ class _MarineManualEquipmentMaintenanceScreenState final Map _vanDornLastDateControllers = {}; final Map _vanDornNewDateControllers = {}; + // --- MODIFICATION: FocusNodes for validation --- + final _maintenanceDateFocus = FocusNode(); + final _lastMaintenanceDateFocus = FocusNode(); + final _scheduleMaintenanceFocus = FocusNode(); + final _locationFocus = FocusNode(); + final _timeEndFocus = FocusNode(); + // --- State for Previous Record feature --- bool _showPreviousRecordDropdown = false; bool _isFetchingPreviousRecords = false; @@ -106,9 +109,8 @@ class _MarineManualEquipmentMaintenanceScreenState _connectivitySubscription.cancel(); _maintenanceDateController.dispose(); _lastMaintenanceDateController.dispose(); - _timeStartController.dispose(); // Renamed - _timeEndController.dispose(); // Renamed - _locationController.dispose(); // Renamed + _timeStartController.dispose(); + _timeEndController.dispose(); _ysiSondeCommentsController.dispose(); _ysiSensorCommentsController.dispose(); _vanDornCommentsController.dispose(); @@ -119,6 +121,14 @@ class _MarineManualEquipmentMaintenanceScreenState _ysiNewSerialControllers.values.forEach((c) => c.dispose()); _vanDornLastDateControllers.values.forEach((c) => c.dispose()); _vanDornNewDateControllers.values.forEach((c) => c.dispose()); + + // --- MODIFICATION: Dispose FocusNodes --- + _maintenanceDateFocus.dispose(); + _lastMaintenanceDateFocus.dispose(); + _scheduleMaintenanceFocus.dispose(); + _locationFocus.dispose(); + _timeEndFocus.dispose(); + super.dispose(); } @@ -268,16 +278,55 @@ class _MarineManualEquipmentMaintenanceScreenState // --- End Previous Record Logic --- + // --- MODIFICATION: Helper for validation dialog --- + Future _showErrorDialog(String fieldName, FocusNode focusNode) async { + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Missing Information'), + content: Text('The "$fieldName" field is required. Please fill it in.'), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () { + Navigator.of(ctx).pop(); // Close the dialog + // Request focus to navigate user to the field + focusNode.requestFocus(); + }, + ), + ], + ), + ); + } + Future _submit() async { + // --- MODIFICATION: Updated validation logic --- + + // 1. Save all fields (especially Dropdowns) to update their values + _formKey.currentState!.save(); + + // 2. Run validation. This highlights all invalid fields. if (!_formKey.currentState!.validate()) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text("Please fill in all required fields."), - backgroundColor: Colors.orange, - )); + // 3. Find the *first* invalid field in order and show the error dialog. + // This provides a guided user experience. + if (_maintenanceDateController.text.isEmpty) { + await _showErrorDialog('Maintenance Date', _maintenanceDateFocus); + } else if (_lastMaintenanceDateController.text.isEmpty) { + await _showErrorDialog('Last Maintenance Date', _lastMaintenanceDateFocus); + } else if (_data.scheduleMaintenance == null) { + await _showErrorDialog('Schedule Maintenance', _scheduleMaintenanceFocus); + } else if (_data.location == null || _data.location!.isEmpty) { + await _showErrorDialog('Location', _locationFocus); + } else if (_timeEndController.text.isEmpty) { + await _showErrorDialog('Time End', _timeEndFocus); + } + // If validation fails for some other reason, the fields will + // just be highlighted red, which is standard behavior. return; } - // Form validation passed, save the form fields to _data where applicable - _formKey.currentState!.save(); + // --- END MODIFICATION --- + + // Form validation passed setState(() => _isLoading = true); try { @@ -288,25 +337,11 @@ class _MarineManualEquipmentMaintenanceScreenState _data.conductedByUserId = auth.profileData?['user_id']; _data.maintenanceDate = _maintenanceDateController.text; _data.lastMaintenanceDate = _lastMaintenanceDateController.text; - // scheduleMaintenance is set via Dropdown onSaved + // scheduleMaintenance & location are set via Dropdown onSaved // Assign header fields _data.timeStart = _timeStartController.text; - _data.location = _locationController.text; - - // --- MODIFICATION START: Auto-populate Time End --- - // Check if Time End controller is empty - if (_timeEndController.text.isEmpty) { - // If empty, use current time - _data.timeEnd = DateFormat('HH:mm').format(DateTime.now()); - // Optionally update the controller as well, though not strictly necessary for submission - // _timeEndController.text = _data.timeEnd!; - } else { - // If not empty, use the value entered by the user - _data.timeEnd = _timeEndController.text; - } - // --- MODIFICATION END --- - + _data.timeEnd = _timeEndController.text; // Assign comments and serials _data.ysiSondeComments = _ysiSondeCommentsController.text; @@ -317,12 +352,12 @@ class _MarineManualEquipmentMaintenanceScreenState // Assign dynamic table values from their controllers _data.ysiReplacements.forEach((item, values) { - values['Current Serial'] = _ysiCurrentSerialControllers[item]?.text ?? ''; // Added null check - values['New Serial'] = _ysiNewSerialControllers[item]?.text ?? ''; // Added null check + values['Current Serial'] = _ysiCurrentSerialControllers[item]?.text ?? ''; + values['New Serial'] = _ysiNewSerialControllers[item]?.text ?? ''; }); _data.vanDornReplacements.forEach((part, values) { - values['Last Date'] = _vanDornLastDateControllers[part]?.text ?? ''; // Added null check - values['New Date'] = _vanDornNewDateControllers[part]?.text ?? ''; // Added null check + values['Last Date'] = _vanDornLastDateControllers[part]?.text ?? ''; + values['New Date'] = _vanDornNewDateControllers[part]?.text ?? ''; }); // Submit the data @@ -517,6 +552,7 @@ class _MarineManualEquipmentMaintenanceScreenState // --- END NEW FIELDS --- TextFormField( controller: _maintenanceDateController, + focusNode: _maintenanceDateFocus, // <-- MODIFICATION readOnly: true, decoration: const InputDecoration( labelText: 'Maintenance Date *', @@ -528,18 +564,21 @@ class _MarineManualEquipmentMaintenanceScreenState const SizedBox(height: 16), TextFormField( controller: _lastMaintenanceDateController, + focusNode: _lastMaintenanceDateFocus, // <-- MODIFICATION readOnly: true, decoration: const InputDecoration( - labelText: 'Last Maintenance Date', + labelText: 'Last Maintenance Date *', // <-- MODIFICATION border: OutlineInputBorder(), suffixIcon: Icon(Icons.calendar_month)), onTap: _isLoading ? null : () => _selectDate(_lastMaintenanceDateController), // Disable tap when loading + validator: (val) => val == null || val.isEmpty ? 'Date is required' : null, // <-- MODIFICATION ), const SizedBox(height: 16), // Changed to DropdownButtonFormField DropdownButtonFormField( + focusNode: _scheduleMaintenanceFocus, // <-- MODIFICATION decoration: const InputDecoration( - labelText: 'Schedule Maintenance', + labelText: 'Schedule Maintenance *', border: OutlineInputBorder(), ), value: _data.scheduleMaintenance, // Set from initState @@ -549,7 +588,7 @@ class _MarineManualEquipmentMaintenanceScreenState child: Text(value), ); }).toList(), - onChanged: _isLoading ? null : (val) { // Disable when loading + onChanged: _isLoading ? null : (val) { setState(() { _data.scheduleMaintenance = val; // Update data on change }); @@ -557,23 +596,38 @@ class _MarineManualEquipmentMaintenanceScreenState onSaved: (val) { _data.scheduleMaintenance = val; // Save data on form save }, - // Add validator if required - // validator: (val) => val == null ? 'Please select Yes or No' : null, + validator: (val) => val == null ? 'Please select an option' : null, ), const SizedBox(height: 16), - // Fields MOVED HERE - TextFormField( - controller: _locationController, // Renamed controller - decoration: const InputDecoration(labelText: 'Location', border: OutlineInputBorder()), - readOnly: _isLoading, // Disable when loading - onSaved: (val) => _data.location = val, + DropdownButtonFormField( + focusNode: _locationFocus, // <-- MODIFICATION + decoration: const InputDecoration( + labelText: 'Location *', + border: OutlineInputBorder(), + ), + value: _data.location, // Use the value from the data model + items: ['HQ', 'Regional'].map((String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + onChanged: _isLoading ? null : (val) { + setState(() { + _data.location = val; // Update data on change + }); + }, + onSaved: (val) { + _data.location = val; // Save data on form save + }, + validator: (val) => val == null || val.isEmpty ? 'Location is required' : null, ), const SizedBox(height: 16), Row( children: [ Expanded( child: TextFormField( - controller: _timeStartController, // Renamed controller + controller: _timeStartController, readOnly: true, // Always read-only as it's defaulted decoration: const InputDecoration( labelText: 'Time Start', @@ -585,14 +639,15 @@ class _MarineManualEquipmentMaintenanceScreenState const SizedBox(width: 16), Expanded( child: TextFormField( - controller: _timeEndController, // Renamed controller + controller: _timeEndController, + focusNode: _timeEndFocus, // <-- MODIFICATION readOnly: true, // Make readOnly to prevent manual edit after selection decoration: const InputDecoration( - labelText: 'Time End', + labelText: 'Time End *', border: OutlineInputBorder(), suffixIcon: Icon(Icons.access_time)), onTap: _isLoading ? null : () => _selectTime(_timeEndController), // Disable tap when loading - // onSaved is handled in _submit logic + validator: (val) => val == null || val.isEmpty ? 'Time End is required' : null, ), ), ], diff --git a/lib/screens/marine/manual/reports/marine_manual_npe_report_hub.dart b/lib/screens/marine/manual/reports/marine_manual_npe_report_hub.dart index 67e9897..ea99cd7 100644 --- a/lib/screens/marine/manual/reports/marine_manual_npe_report_hub.dart +++ b/lib/screens/marine/manual/reports/marine_manual_npe_report_hub.dart @@ -21,7 +21,7 @@ class MarineManualNPEReportHub extends StatelessWidget { context: context, icon: Icons.sync_alt, title: 'From Recent In-Situ Sample', - subtitle: 'Use data from a recent, nearby manual sampling event.', + subtitle: 'Use information & data from a recent in-situ sampling event .', onTap: () { Navigator.push( context, @@ -32,8 +32,8 @@ class MarineManualNPEReportHub extends StatelessWidget { _buildOptionCard( context: context, icon: Icons.public, - title: 'From Tarball Station', - subtitle: 'Select a tarball station to report a pollution event.', + title: 'From Recent Tarball Station', + subtitle: 'Use information from a recent tarball sampling event.', onTap: () { Navigator.push( context, diff --git a/lib/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart b/lib/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart index 235419f..196fa71 100644 --- a/lib/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart +++ b/lib/screens/marine/manual/reports/marine_manual_sonde_calibration_screen.dart @@ -25,14 +25,65 @@ class _MarineManualSondeCalibrationScreenState bool _isOnline = true; late StreamSubscription> _connectivitySubscription; + // Scroll controller to move to top on error + final _scrollController = ScrollController(); + + // --- Controllers for all fields --- final _sondeSerialController = TextEditingController(); final _firmwareController = TextEditingController(); final _korController = TextEditingController(); - final _locationController = TextEditingController(); + // Location is now a dropdown, no controller needed, will save to _data final _startDateTimeController = TextEditingController(); final _endDateTimeController = TextEditingController(); final _remarksController = TextEditingController(); + // pH Controllers + final _ph7MvController = TextEditingController(); + final _ph7BeforeController = TextEditingController(); + final _ph7AfterController = TextEditingController(); + final _ph10MvController = TextEditingController(); + final _ph10BeforeController = TextEditingController(); + final _ph10AfterController = TextEditingController(); + + // Other parameter controllers + final _condBeforeController = TextEditingController(); + final _condAfterController = TextEditingController(); + final _doBeforeController = TextEditingController(); + final _doAfterController = TextEditingController(); + final _turbidity0BeforeController = TextEditingController(); + final _turbidity0AfterController = TextEditingController(); + final _turbidity124BeforeController = TextEditingController(); + final _turbidity124AfterController = TextEditingController(); + + // --- Focus Nodes for all fields (to allow focus on error) --- + // We don't actively use them to redirect, but they are good practice + // and necessary for the form to manage focus. + final _sondeSerialFocusNode = FocusNode(); + final _firmwareFocusNode = FocusNode(); + final _korFocusNode = FocusNode(); + final _locationFocusNode = FocusNode(); + final _startDateTimeFocusNode = FocusNode(); + final _endDateTimeFocusNode = FocusNode(); + final _remarksFocusNode = FocusNode(); + final _statusFocusNode = FocusNode(); + + final _ph7MvFocusNode = FocusNode(); + final _ph7BeforeFocusNode = FocusNode(); + final _ph7AfterFocusNode = FocusNode(); + final _ph10MvFocusNode = FocusNode(); + final _ph10BeforeFocusNode = FocusNode(); + final _ph10AfterFocusNode = FocusNode(); + + final _condBeforeFocusNode = FocusNode(); + final _condAfterFocusNode = FocusNode(); + final _doBeforeFocusNode = FocusNode(); + final _doAfterFocusNode = FocusNode(); + final _turbidity0BeforeFocusNode = FocusNode(); + final _turbidity0AfterFocusNode = FocusNode(); + final _turbidity124BeforeFocusNode = FocusNode(); + final _turbidity124AfterFocusNode = FocusNode(); + // --- + @override void initState() { super.initState(); @@ -46,13 +97,54 @@ class _MarineManualSondeCalibrationScreenState @override void dispose() { _connectivitySubscription.cancel(); + _scrollController.dispose(); + + // Dispose all controllers _sondeSerialController.dispose(); _firmwareController.dispose(); _korController.dispose(); - _locationController.dispose(); _startDateTimeController.dispose(); _endDateTimeController.dispose(); _remarksController.dispose(); + _ph7MvController.dispose(); + _ph7BeforeController.dispose(); + _ph7AfterController.dispose(); + _ph10MvController.dispose(); + _ph10BeforeController.dispose(); + _ph10AfterController.dispose(); + _condBeforeController.dispose(); + _condAfterController.dispose(); + _doBeforeController.dispose(); + _doAfterController.dispose(); + _turbidity0BeforeController.dispose(); + _turbidity0AfterController.dispose(); + _turbidity124BeforeController.dispose(); + _turbidity124AfterController.dispose(); + + // Dispose all focus nodes + _sondeSerialFocusNode.dispose(); + _firmwareFocusNode.dispose(); + _korFocusNode.dispose(); + _locationFocusNode.dispose(); + _startDateTimeFocusNode.dispose(); + _endDateTimeFocusNode.dispose(); + _remarksFocusNode.dispose(); + _statusFocusNode.dispose(); + _ph7MvFocusNode.dispose(); + _ph7BeforeFocusNode.dispose(); + _ph7AfterFocusNode.dispose(); + _ph10MvFocusNode.dispose(); + _ph10BeforeFocusNode.dispose(); + _ph10AfterFocusNode.dispose(); + _condBeforeFocusNode.dispose(); + _condAfterFocusNode.dispose(); + _doBeforeFocusNode.dispose(); + _doAfterFocusNode.dispose(); + _turbidity0BeforeFocusNode.dispose(); + _turbidity0AfterFocusNode.dispose(); + _turbidity124BeforeFocusNode.dispose(); + _turbidity124AfterFocusNode.dispose(); + super.dispose(); } @@ -76,6 +168,55 @@ class _MarineManualSondeCalibrationScreenState } } + // --- Validation Helper Methods --- + String? _validateRequired(String? val) { + if (val == null || val.isEmpty) { + return 'This field is required'; + } + return null; + } + + String? _validateNumeric(String? val) { + if (val == null || val.isEmpty) { + return 'This field is required'; + } + if (double.tryParse(val) == null) { + return 'Must be a valid number'; + } + return null; + } + + String? _validateDropdown(String? val) { + if (val == null || val.isEmpty) { + return 'Please select an option'; + } + return null; + } + + // --- NEW: Error Dialog --- + Future _showErrorDialog() async { + return showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Submission Failed'), + content: const SingleChildScrollView( + child: Text( + 'Please fill in all required fields. Errors are highlighted in red.'), + ), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } + Future _selectDateTime(TextEditingController controller) async { final date = await showDatePicker( context: context, @@ -97,13 +238,19 @@ class _MarineManualSondeCalibrationScreenState } Future _submit() async { + // Check form validity if (!_formKey.currentState!.validate()) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text("Please fill in all required fields."), - backgroundColor: Colors.red, - )); + // If invalid, show dialog and scroll to top + _showErrorDialog(); + _scrollController.animateTo( + 0.0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); return; } + + // If valid, save form data and set loading state _formKey.currentState!.save(); setState(() => _isLoading = true); @@ -112,15 +259,36 @@ class _MarineManualSondeCalibrationScreenState final service = Provider.of(context, listen: false); + // Populate _data object from controllers _data.calibratedByUserId = auth.profileData?['user_id']; _data.sondeSerialNumber = _sondeSerialController.text; _data.firmwareVersion = _firmwareController.text; _data.korVersion = _korController.text; - _data.location = _locationController.text; + // _data.location is already set by onSaved _data.startDateTime = _startDateTimeController.text; _data.endDateTime = _endDateTimeController.text; + // _data.calibrationStatus is already set by onSaved _data.remarks = _remarksController.text; + // Populate numeric values + _data.ph7Mv = double.tryParse(_ph7MvController.text); + _data.ph7Before = double.tryParse(_ph7BeforeController.text); + _data.ph7After = double.tryParse(_ph7AfterController.text); + _data.ph10Mv = double.tryParse(_ph10MvController.text); + _data.ph10Before = double.tryParse(_ph10BeforeController.text); + _data.ph10After = double.tryParse(_ph10AfterController.text); + _data.condBefore = double.tryParse(_condBeforeController.text); + _data.condAfter = double.tryParse(_condAfterController.text); + _data.doBefore = double.tryParse(_doBeforeController.text); + _data.doAfter = double.tryParse(_doAfterController.text); + _data.turbidity0Before = + double.tryParse(_turbidity0BeforeController.text); + _data.turbidity0After = double.tryParse(_turbidity0AfterController.text); + _data.turbidity124Before = + double.tryParse(_turbidity124BeforeController.text); + _data.turbidity124After = + double.tryParse(_turbidity124AfterController.text); + final result = await service.submitCalibration(data: _data, authProvider: auth); @@ -175,7 +343,10 @@ class _MarineManualSondeCalibrationScreenState Expanded( child: Form( key: _formKey, + // Autovalidate after the first submit attempt + autovalidateMode: AutovalidateMode.disabled, child: SingleChildScrollView( + controller: _scrollController, // Added scroll controller padding: const EdgeInsets.all(16.0), child: Column( children: [ @@ -227,11 +398,11 @@ class _MarineManualSondeCalibrationScreenState const SizedBox(height: 16), TextFormField( controller: _sondeSerialController, + focusNode: _sondeSerialFocusNode, decoration: const InputDecoration( labelText: 'Sonde Serial Number *', border: OutlineInputBorder()), - validator: (val) => - val == null || val.isEmpty ? 'Serial Number is required' : null, + validator: _validateRequired, // Use helper ), const SizedBox(height: 16), Row( @@ -239,52 +410,65 @@ class _MarineManualSondeCalibrationScreenState Expanded( child: TextFormField( controller: _firmwareController, + focusNode: _firmwareFocusNode, decoration: const InputDecoration( - labelText: 'Firmware Version', + labelText: 'Firmware Version *', // Made required border: OutlineInputBorder()), + validator: _validateRequired, // Added validator ), ), const SizedBox(width: 16), Expanded( child: TextFormField( controller: _korController, + focusNode: _korFocusNode, decoration: const InputDecoration( - labelText: 'KOR Version', border: OutlineInputBorder()), + labelText: 'KOR Version *', // Made required + border: OutlineInputBorder()), + validator: _validateRequired, // Added validator ), ), ], ), const SizedBox(height: 16), - TextFormField( - controller: _locationController, + // --- MODIFIED: Location Dropdown --- + DropdownButtonFormField( + focusNode: _locationFocusNode, decoration: const InputDecoration( labelText: 'Location *', border: OutlineInputBorder()), - validator: (val) => - val == null || val.isEmpty ? 'Location is required' : null, + items: ['HQ', 'Regional'].map((String value) { + return DropdownMenuItem(value: value, child: Text(value)); + }).toList(), + onChanged: (val) { + _data.location = val; + }, + onSaved: (val) => _data.location = val, + validator: _validateDropdown, // Use dropdown validator ), + // --- const SizedBox(height: 16), TextFormField( controller: _startDateTimeController, + focusNode: _startDateTimeFocusNode, readOnly: true, decoration: const InputDecoration( labelText: 'Start Date/Time *', border: OutlineInputBorder(), suffixIcon: Icon(Icons.calendar_month)), onTap: () => _selectDateTime(_startDateTimeController), - validator: (val) => - val == null || val.isEmpty ? 'Start Time is required' : null, + validator: _validateRequired, // Use helper ), const SizedBox(height: 16), TextFormField( controller: _endDateTimeController, + focusNode: _endDateTimeFocusNode, readOnly: true, decoration: const InputDecoration( labelText: 'End Date/Time *', border: OutlineInputBorder(), suffixIcon: Icon(Icons.calendar_month)), onTap: () => _selectDateTime(_endDateTimeController), - validator: (val) => - val == null || val.isEmpty ? 'End Time is required' : null, + validator: _validateRequired, // Use helper ), ], ), @@ -305,47 +489,57 @@ class _MarineManualSondeCalibrationScreenState style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 16), _buildSectionHeader('pH'), - // MODIFIED: Renamed to clarify 3 columns _buildParameterRowThreeColumn( 'pH 7.00 (mV 0+30)', - onSaveMv: (val) => _data.ph7Mv = val, - onSaveBefore: (val) => _data.ph7Before = val, - onSaveAfter: (val) => _data.ph7After = val, + mvController: _ph7MvController, + beforeController: _ph7BeforeController, + afterController: _ph7AfterController, + mvFocusNode: _ph7MvFocusNode, + beforeFocusNode: _ph7BeforeFocusNode, + afterFocusNode: _ph7AfterFocusNode, ), _buildParameterRowThreeColumn( 'pH 10.00 (mV-180+30)', - onSaveMv: (val) => _data.ph10Mv = val, - onSaveBefore: (val) => _data.ph10Before = val, - onSaveAfter: (val) => _data.ph10After = val, + mvController: _ph10MvController, + beforeController: _ph10BeforeController, + afterController: _ph10AfterController, + mvFocusNode: _ph10MvFocusNode, + beforeFocusNode: _ph10BeforeFocusNode, + afterFocusNode: _ph10AfterFocusNode, ), const Divider(height: 24), _buildSectionHeader('SP Conductivity (ยตS/cm)'), - // NEW: Using 2-column widget _buildParameterRowTwoColumn( '50,000 (Marine)', - onSaveBefore: (val) => _data.condBefore = val, - onSaveAfter: (val) => _data.condAfter = val, + beforeController: _condBeforeController, + afterController: _condAfterController, + beforeFocusNode: _condBeforeFocusNode, + afterFocusNode: _condAfterFocusNode, ), const Divider(height: 24), _buildSectionHeader('Turbidity (NTU)'), - // NEW: Using 2-column widget _buildParameterRowTwoColumn( '0.0 (D.I.)', - onSaveBefore: (val) => _data.turbidity0Before = val, - onSaveAfter: (val) => _data.turbidity0After = val, + beforeController: _turbidity0BeforeController, + afterController: _turbidity0AfterController, + beforeFocusNode: _turbidity0BeforeFocusNode, + afterFocusNode: _turbidity0AfterFocusNode, ), _buildParameterRowTwoColumn( '124 (Marine)', - onSaveBefore: (val) => _data.turbidity124Before = val, - onSaveAfter: (val) => _data.turbidity124After = val, + beforeController: _turbidity124BeforeController, + afterController: _turbidity124AfterController, + beforeFocusNode: _turbidity124BeforeFocusNode, + afterFocusNode: _turbidity124AfterFocusNode, ), const Divider(height: 24), _buildSectionHeader('Dissolved Oxygen (%)'), - // NEW: Using 2-column widget _buildParameterRowTwoColumn( '100.0 (Air Saturated)', - onSaveBefore: (val) => _data.doBefore = val, - onSaveAfter: (val) => _data.doAfter = val, + beforeController: _doBeforeController, + afterController: _doAfterController, + beforeFocusNode: _doBeforeFocusNode, + afterFocusNode: _doAfterFocusNode, ), ], ), @@ -360,12 +554,14 @@ class _MarineManualSondeCalibrationScreenState ); } - // MODIFIED: Renamed to _buildParameterRowThreeColumn Widget _buildParameterRowThreeColumn( String label, { - required Function(double?) onSaveMv, - required Function(double?) onSaveBefore, - required Function(double?) onSaveAfter, + required TextEditingController mvController, + required TextEditingController beforeController, + required TextEditingController afterController, + required FocusNode mvFocusNode, + required FocusNode beforeFocusNode, + required FocusNode afterFocusNode, }) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), @@ -378,26 +574,32 @@ class _MarineManualSondeCalibrationScreenState children: [ Expanded( child: TextFormField( + controller: mvController, + focusNode: mvFocusNode, decoration: const InputDecoration( - labelText: 'MV Reading', border: OutlineInputBorder()), + labelText: 'MV Reading *', border: OutlineInputBorder()), keyboardType: TextInputType.number, - onSaved: (val) => onSaveMv(double.tryParse(val ?? '')), + validator: _validateNumeric, // Added validator )), const SizedBox(width: 8), Expanded( child: TextFormField( + controller: beforeController, + focusNode: beforeFocusNode, decoration: const InputDecoration( - labelText: 'Before Cal', border: OutlineInputBorder()), + labelText: 'Before Cal *', border: OutlineInputBorder()), keyboardType: TextInputType.number, - onSaved: (val) => onSaveBefore(double.tryParse(val ?? '')), + validator: _validateNumeric, // Added validator )), const SizedBox(width: 8), Expanded( child: TextFormField( + controller: afterController, + focusNode: afterFocusNode, decoration: const InputDecoration( - labelText: 'After Cal', border: OutlineInputBorder()), + labelText: 'After Cal *', border: OutlineInputBorder()), keyboardType: TextInputType.number, - onSaved: (val) => onSaveAfter(double.tryParse(val ?? '')), + validator: _validateNumeric, // Added validator )), ], ), @@ -406,11 +608,12 @@ class _MarineManualSondeCalibrationScreenState ); } - // NEW: Widget for parameters without MV Reading Widget _buildParameterRowTwoColumn( String label, { - required Function(double?) onSaveBefore, - required Function(double?) onSaveAfter, + required TextEditingController beforeController, + required TextEditingController afterController, + required FocusNode beforeFocusNode, + required FocusNode afterFocusNode, }) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), @@ -423,18 +626,22 @@ class _MarineManualSondeCalibrationScreenState children: [ Expanded( child: TextFormField( + controller: beforeController, + focusNode: beforeFocusNode, decoration: const InputDecoration( - labelText: 'Before Cal', border: OutlineInputBorder()), + labelText: 'Before Cal *', border: OutlineInputBorder()), keyboardType: TextInputType.number, - onSaved: (val) => onSaveBefore(double.tryParse(val ?? '')), + validator: _validateNumeric, // Added validator )), const SizedBox(width: 8), Expanded( child: TextFormField( + controller: afterController, + focusNode: afterFocusNode, decoration: const InputDecoration( - labelText: 'After Cal', border: OutlineInputBorder()), + labelText: 'After Cal *', border: OutlineInputBorder()), keyboardType: TextInputType.number, - onSaved: (val) => onSaveAfter(double.tryParse(val ?? '')), + validator: _validateNumeric, // Added validator )), ], ), @@ -455,25 +662,27 @@ class _MarineManualSondeCalibrationScreenState Text('Summary', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 16), DropdownButtonFormField( + focusNode: _statusFocusNode, decoration: const InputDecoration( labelText: 'Overall Status *', border: OutlineInputBorder()), - items: ['Pass', 'Fail', 'Pass with Issues'].map((String value) { + items: ['Pass', 'Fail', 'Pass with Warning'].map((String value) { return DropdownMenuItem(value: value, child: Text(value)); }).toList(), onChanged: (val) { _data.calibrationStatus = val; }, onSaved: (val) => _data.calibrationStatus = val, - validator: (val) => - val == null || val.isEmpty ? 'Status is required' : null, + validator: _validateDropdown, // Use dropdown validator ), const SizedBox(height: 16), TextFormField( controller: _remarksController, + focusNode: _remarksFocusNode, decoration: const InputDecoration( - labelText: 'Comment/Observation', + labelText: 'Comment/Observation *', // Made required border: OutlineInputBorder()), maxLines: 3, + validator: _validateRequired, // Added validator ), ], ), diff --git a/lib/screens/marine/manual/reports/npe_report_from_in_situ.dart b/lib/screens/marine/manual/reports/npe_report_from_in_situ.dart index 8e0912b..dec9fbc 100644 --- a/lib/screens/marine/manual/reports/npe_report_from_in_situ.dart +++ b/lib/screens/marine/manual/reports/npe_report_from_in_situ.dart @@ -33,12 +33,23 @@ class _NPEReportFromInSituState extends State { bool _isLoading = false; bool _isPickingImage = false; + // --- START: MODIFIED STATE VARIABLES --- // Data handling - bool _isLoadingRecentSamples = true; + bool? _useRecentSample; // To track Yes/No selection + bool _isLoadingRecentSamples = false; // Now triggered on-demand List _recentNearbySamples = []; InSituSamplingData? _selectedRecentSample; final MarineManualNpeReportData _npeData = MarineManualNpeReportData(); + // "No" path: Manual station selection + List _statesList = []; + List _categoriesForState = []; + List> _stationsForCategory = []; + String? _selectedState; + String? _selectedCategory; + Map? _selectedManualStation; + // --- END: MODIFIED STATE VARIABLES --- + // Controllers final _stationIdController = TextEditingController(); final _locationController = TextEditingController(); @@ -47,17 +58,19 @@ class _NPEReportFromInSituState extends State { final _longController = TextEditingController(); final _possibleSourceController = TextEditingController(); final _othersObservationController = TextEditingController(); - // ADDED: Controllers for in-situ measurements final _doPercentController = TextEditingController(); final _doMgLController = TextEditingController(); final _phController = TextEditingController(); final _condController = TextEditingController(); final _turbController = TextEditingController(); final _tempController = TextEditingController(); + final _image1RemarkController = TextEditingController(); + final _image2RemarkController = TextEditingController(); + final _image3RemarkController = TextEditingController(); + final _image4RemarkController = TextEditingController(); // In-Situ related late final MarineInSituSamplingService _samplingService; - // ADDED: State variables for device connection and reading StreamSubscription? _dataSubscription; bool _isAutoReading = false; Timer? _lockoutTimer; @@ -69,12 +82,12 @@ class _NPEReportFromInSituState extends State { void initState() { super.initState(); _samplingService = Provider.of(context, listen: false); - _fetchRecentNearbySamples(); + _loadAllStatesFromProvider(); // Load manual stations for "No" path + _setDefaultDateTime(); // Set default time for all paths } @override void dispose() { - // ADDED: Cancel subscriptions and timers, disconnect devices _dataSubscription?.cancel(); _lockoutTimer?.cancel(); if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { @@ -91,24 +104,79 @@ class _NPEReportFromInSituState extends State { _longController.dispose(); _possibleSourceController.dispose(); _othersObservationController.dispose(); - // ADDED: Dispose new controllers _doPercentController.dispose(); _doMgLController.dispose(); _phController.dispose(); _condController.dispose(); _turbController.dispose(); _tempController.dispose(); + _image1RemarkController.dispose(); + _image2RemarkController.dispose(); + _image3RemarkController.dispose(); + _image4RemarkController.dispose(); super.dispose(); } - // UPDATED: Method now includes permission checks + // --- START: ADDED HELPER METHODS --- + void _setDefaultDateTime() { + final now = DateTime.now(); + _eventDateTimeController.text = DateFormat('yyyy-MM-dd HH:mm').format(now); + } + + void _loadAllStatesFromProvider() { + final auth = Provider.of(context, listen: false); + // Only load manual stations as requested + final allStations = auth.manualStations ?? []; + final states = {}; + for (var station in allStations) { + if (station['state_name'] != null) states.add(station['state_name']); + } + setState(() => _statesList = states.toList()..sort()); + } + + /// Clears all fields related to the "Yes" path + void _clearRecentSampleSelection() { + setState(() { + _selectedRecentSample = null; + _npeData.selectedStation = null; + _stationIdController.clear(); + _locationController.clear(); + _latController.clear(); + _longController.clear(); + _doPercentController.clear(); + _doMgLController.clear(); + _phController.clear(); + _condController.clear(); + _turbController.clear(); + _tempController.clear(); + _setDefaultDateTime(); // Reset to 'now' + }); + } + + /// Clears all fields related to the "No" path + void _clearManualStationSelection() { + setState(() { + _selectedState = null; + _selectedCategory = null; + _selectedManualStation = null; + _npeData.selectedStation = null; + _categoriesForState = []; + _stationsForCategory = []; + _stationIdController.clear(); + _locationController.clear(); + _latController.clear(); + _longController.clear(); + _setDefaultDateTime(); // Reset to 'now' + }); + } + // --- END: ADDED HELPER METHODS --- + Future _fetchRecentNearbySamples() async { setState(() => _isLoadingRecentSamples = true); bool serviceEnabled; LocationPermission permission; try { - // 1. Check if location services are enabled. serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { _showSnackBar('Location services are disabled. Please enable them.', isError: true); @@ -116,10 +184,7 @@ class _NPEReportFromInSituState extends State { return; } - // 2. Check current permission status. permission = await Geolocator.checkPermission(); - - // 3. Request permission if denied or not determined. if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); if (permission == LocationPermission.denied) { @@ -129,16 +194,13 @@ class _NPEReportFromInSituState extends State { } } - // 4. Handle permanent denial. if (permission == LocationPermission.deniedForever) { _showSnackBar('Location permission permanently denied. Please enable it in app settings.', isError: true); - // Optionally, offer to open settings - await openAppSettings(); // Requires permission_handler package + await openAppSettings(); if (mounted) setState(() => _isLoadingRecentSamples = false); return; } - // 5. If permission is granted, get the location and fetch samples. final Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); final localDbService = Provider.of(context, listen: false); final samples = await localDbService.getRecentNearbySamples( @@ -161,7 +223,6 @@ class _NPEReportFromInSituState extends State { final now = DateTime.now(); _eventDateTimeController.text = DateFormat('yyyy-MM-dd HH:mm').format(now); - // ADDED: Populate in-situ measurement fields from selected sample _doPercentController.text = data.oxygenSaturation?.toStringAsFixed(5) ?? ''; _doMgLController.text = data.oxygenConcentration?.toStringAsFixed(5) ?? ''; _phController.text = data.ph?.toStringAsFixed(5) ?? ''; @@ -171,10 +232,22 @@ class _NPEReportFromInSituState extends State { } Future _submitNpeReport() async { + final bool atLeastOneObservation = _npeData.fieldObservations.values.any((isChecked) => isChecked == true); + if (!atLeastOneObservation) { + _showSnackBar('Please select at least one field observation.', isError: true); + return; + } + + if (_npeData.image1 == null || _npeData.image2 == null || _npeData.image3 == null || _npeData.image4 == null) { + _showSnackBar('Please attach all 4 figures.', isError: true); + return; + } + if (!_formKey.currentState!.validate()) { _showSnackBar('Please fill in all required fields.', isError: true); return; } + setState(() => _isLoading = true); final auth = Provider.of(context, listen: false); final service = Provider.of(context, listen: false); @@ -185,11 +258,13 @@ class _NPEReportFromInSituState extends State { _npeData.eventTime = _eventDateTimeController.text.split(' ').length > 1 ? _eventDateTimeController.text.split(' ')[1] : ''; _npeData.latitude = _latController.text; _npeData.longitude = _longController.text; - _npeData.selectedStation = _selectedRecentSample?.selectedStation; - _npeData.locationDescription = _locationController.text; + + // selectedStation is already set by either _populateFormFromData or the "No" path dropdown + // _npeData.selectedStation = _selectedRecentSample?.selectedStation; + + _npeData.locationDescription = _locationController.text; // Used by both paths _npeData.possibleSource = _possibleSourceController.text; _npeData.othersObservationRemark = _othersObservationController.text; - // ADDED: Read values from in-situ measurement controllers _npeData.oxygenSaturation = double.tryParse(_doPercentController.text); _npeData.electricalConductivity = double.tryParse(_condController.text); _npeData.oxygenConcentration = double.tryParse(_doMgLController.text); @@ -197,6 +272,11 @@ class _NPEReportFromInSituState extends State { _npeData.ph = double.tryParse(_phController.text); _npeData.temperature = double.tryParse(_tempController.text); + _npeData.image1Remark = _image1RemarkController.text; + _npeData.image2Remark = _image2RemarkController.text; + _npeData.image3Remark = _image3RemarkController.text; + _npeData.image4Remark = _image4RemarkController.text; + final result = await service.submitNpeReport(data: _npeData, authProvider: auth); setState(() => _isLoading = false); if (mounted) { @@ -214,6 +294,53 @@ class _NPEReportFromInSituState extends State { } } + Future _showImageErrorDialog(String message) async { + if (!mounted) return; + return showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: const Row( + children: [ + Icon(Icons.error_outline, color: Colors.red), + SizedBox(width: 10), + Text('Image Error'), + ], + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(message), + const SizedBox(height: 20), + const Text( + "Please ensure your device is held horizontally:", + style: TextStyle(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + const Icon( + Icons.stay_current_landscape, + size: 60, + color: Colors.blue, + ), + ], + ), + ), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () { + Navigator.of(dialogContext).pop(); + }, + ), + ], + ); + }, + ); + } + Future _processAndSetImage(ImageSource source, int imageNumber) async { if (_isPickingImage) return; setState(() => _isPickingImage = true); @@ -229,7 +356,7 @@ class _NPEReportFromInSituState extends State { source, data: watermarkData, imageInfo: 'NPE ATTACHMENT $imageNumber', - isRequired: false, + isRequired: true, ); if (file != null) { @@ -241,11 +368,16 @@ class _NPEReportFromInSituState extends State { case 4: _npeData.image4 = file; break; } }); + } else { + await _showImageErrorDialog( + "Image processing failed. Please ensure the photo is taken in landscape mode." + ); } + if (mounted) setState(() => _isPickingImage = false); } - // --- START: ADDED IN-SITU DEVICE CONNECTION AND READING METHODS --- + // --- START: IN-SITU DEVICE METHODS (Unchanged) --- void _updateTextFields(Map readings) { const defaultValue = -999.0; setState(() { @@ -408,11 +540,14 @@ class _NPEReportFromInSituState extends State { }); } } - // --- END: ADDED IN-SITU DEVICE CONNECTION AND READING METHODS --- + // --- END: IN-SITU DEVICE METHODS (Unchanged) --- @override Widget build(BuildContext context) { + final auth = Provider.of(context, listen: false); + final allManualStations = auth.manualStations ?? []; + return Scaffold( appBar: AppBar(title: const Text("NPE from In-Situ Sample")), body: Form( @@ -420,28 +555,143 @@ class _NPEReportFromInSituState extends State { child: ListView( padding: const EdgeInsets.all(20.0), children: [ - _buildSectionTitle("1. Select Recent Sample"), - if (_isLoadingRecentSamples) - const Center(child: Padding(padding: EdgeInsets.all(8.0), child: CircularProgressIndicator())) - else - DropdownSearch( - items: _recentNearbySamples, - itemAsString: (s) => "${s.selectedStation?['man_station_code']} at ${s.samplingDate} ${s.samplingTime}", - popupProps: PopupProps.menu( - showSearchBox: true, searchFieldProps: const TextFieldProps(decoration: InputDecoration(hintText: "Search..."))), - dropdownDecoratorProps: - const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select a recent sample *")), - onChanged: (sample) { - if (sample != null) { - setState(() { - _selectedRecentSample = sample; - _populateFormFromData(sample); - }); - } - }, - validator: (val) => val == null ? "Please select a sample" : null, - ), + // --- START: SECTION 1 (NEW) --- + _buildSectionTitle("1. Use Recent Sample?"), + Row( + children: [ + Expanded( + child: RadioListTile( + title: const Text('Yes'), + value: true, + groupValue: _useRecentSample, + onChanged: (value) { + setState(() { + _useRecentSample = value; + _clearManualStationSelection(); // Clear "No" path data + if (value == true && _recentNearbySamples.isEmpty) { + _fetchRecentNearbySamples(); // Fetch samples on-demand + } + }); + }, + ), + ), + Expanded( + child: RadioListTile( + title: const Text('No'), + value: false, + groupValue: _useRecentSample, + onChanged: (value) { + setState(() { + _useRecentSample = value; + _clearRecentSampleSelection(); // Clear "Yes" path data + }); + }, + ), + ), + ], + ), const SizedBox(height: 16), + // --- END: SECTION 1 (NEW) --- + + // --- START: SECTION 2 (CONDITIONAL) --- + // "YES" PATH: Select from recent samples + if (_useRecentSample == true) ...[ + _buildSectionTitle("2. Select Recent Sample"), + if (_isLoadingRecentSamples) + const Center(child: Padding(padding: EdgeInsets.all(8.0), child: CircularProgressIndicator())) + else + DropdownSearch( + items: _recentNearbySamples, + selectedItem: _selectedRecentSample, + itemAsString: (s) => "${s.selectedStation?['man_station_code']} at ${s.samplingDate} ${s.samplingTime}", + popupProps: PopupProps.menu( + showSearchBox: true, searchFieldProps: const TextFieldProps(decoration: InputDecoration(hintText: "Search..."))), + dropdownDecoratorProps: + const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select a recent sample *")), + onChanged: (sample) { + if (sample != null) { + setState(() { + _selectedRecentSample = sample; + _npeData.selectedStation = sample.selectedStation; // CRITICAL: Set station for submission + _populateFormFromData(sample); + }); + } + }, + validator: (val) => val == null ? "Please select a sample" : null, + ), + ], + + // "NO" PATH: Select from manual station list + if (_useRecentSample == false) ...[ + _buildSectionTitle("2. Select Manual Station"), + DropdownSearch( + items: _statesList, + selectedItem: _selectedState, + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *")), + onChanged: (state) { + setState(() { + _selectedState = state; + _selectedCategory = null; + _selectedManualStation = null; + _npeData.selectedStation = null; + _stationIdController.clear(); + _locationController.clear(); + _latController.clear(); + _longController.clear(); + _categoriesForState = state != null ? allManualStations.where((s) => s['state_name'] == state).map((s) => s['category_name'] as String).toSet().toList() : []; + _stationsForCategory = []; + }); + }, + validator: (val) => val == null ? "State is required" : null, + ), + const SizedBox(height: 12), + DropdownSearch( + items: _categoriesForState, + selectedItem: _selectedCategory, + enabled: _categoriesForState.isNotEmpty, + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Category..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Category *")), + onChanged: (category) { + setState(() { + _selectedCategory = category; + _selectedManualStation = null; + _npeData.selectedStation = null; + _stationIdController.clear(); + _locationController.clear(); + _latController.clear(); + _longController.clear(); + _stationsForCategory = category != null ? allManualStations.where((s) => s['state_name'] == _selectedState && s['category_name'] == category).toList() : []; + }); + }, + validator: (val) => val == null && _categoriesForState.isNotEmpty ? "Category is required" : null, + ), + const SizedBox(height: 12), + DropdownSearch>( + items: _stationsForCategory, + selectedItem: _selectedManualStation, + enabled: _stationsForCategory.isNotEmpty, + itemAsString: (s) => "${s['man_station_code']} - ${s['man_station_name']}", + popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Station..."))), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Station *")), + onChanged: (station) { + setState(() { + _selectedManualStation = station; + _npeData.selectedStation = station; // CRITICAL: Set station for submission + _stationIdController.text = station?['man_station_code'] ?? ''; + _locationController.text = station?['man_station_name'] ?? ''; + _latController.text = station?['man_latitude']?.toString() ?? ''; + _longController.text = station?['man_longitude']?.toString() ?? ''; + }); + }, + validator: (val) => val == null && _stationsForCategory.isNotEmpty ? "Station is required" : null, + ), + ], + // --- END: SECTION 2 (CONDITIONAL) --- + + // --- START: SHARED SECTIONS (NOW ALWAYS VISIBLE) --- + const SizedBox(height: 24), + _buildSectionTitle("Station Information"), _buildTextFormField(controller: _stationIdController, label: "Station ID", readOnly: true), const SizedBox(height: 12), _buildTextFormField(controller: _locationController, label: "Location", readOnly: true), @@ -453,21 +703,19 @@ class _NPEReportFromInSituState extends State { _buildTextFormField(controller: _eventDateTimeController, label: "Event Date/Time", readOnly: true), const SizedBox(height: 24), - // ADDED: In-Situ Measurements Section - _buildSectionTitle("2. In-situ Measurements (Optional)"), - _buildInSituSection(), // Calls the builder for device connection & parameters + _buildSectionTitle("3. In-situ Measurements"), + _buildInSituSection(), const SizedBox(height: 24), - // Sections renumbered - _buildSectionTitle("3. Field Observations*"), + _buildSectionTitle("4. Field Observations *"), ..._buildObservationsCheckboxes(), const SizedBox(height: 24), - _buildSectionTitle("4. Possible Source"), + _buildSectionTitle("5. Possible Source"), _buildTextFormField(controller: _possibleSourceController, label: "Possible Source", maxLines: 3), const SizedBox(height: 24), - _buildSectionTitle("5. Attachments (Figures)"), + _buildSectionTitle("6. Attachments (Figures) *"), _buildImageAttachmentSection(), const SizedBox(height: 32), @@ -476,10 +724,12 @@ class _NPEReportFromInSituState extends State { style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 15) ), - onPressed: _isLoading ? null : _submitNpeReport, + // Disable button if "Yes/No" hasn't been selected + onPressed: _isLoading || _useRecentSample == null ? null : _submitNpeReport, child: _isLoading ? const CircularProgressIndicator(color: Colors.white) : const Text("Submit Report"), ), ), + // --- END: SHARED SECTIONS --- ], ), ), @@ -493,9 +743,7 @@ class _NPEReportFromInSituState extends State { ); } - // FIXED: Correct implementation for _buildObservationsCheckboxes List _buildObservationsCheckboxes() { - // Use the correct pattern from npe_report_from_tarball.dart return [ for (final key in _npeData.fieldObservations.keys) CheckboxListTile( @@ -505,14 +753,12 @@ class _NPEReportFromInSituState extends State { controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, ), - // Conditionally add the 'Others' text field if (_npeData.fieldObservations['Others'] ?? false) Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: _buildTextFormField( controller: _othersObservationController, - label: "Please specify", - // Make it optional by removing '*' from the label here or adjust validator + label: "Please specify *", ), ), ]; @@ -522,15 +768,45 @@ class _NPEReportFromInSituState extends State { Widget _buildImageAttachmentSection() { return Column( children: [ - _buildNPEImagePicker(title: 'Figure 1', imageFile: _npeData.image1, onClear: () => setState(() => _npeData.image1 = null), imageNumber: 1), - _buildNPEImagePicker(title: 'Figure 2', imageFile: _npeData.image2, onClear: () => setState(() => _npeData.image2 = null), imageNumber: 2), - _buildNPEImagePicker(title: 'Figure 3', imageFile: _npeData.image3, onClear: () => setState(() => _npeData.image3 = null), imageNumber: 3), - _buildNPEImagePicker(title: 'Figure 4', imageFile: _npeData.image4, onClear: () => setState(() => _npeData.image4 = null), imageNumber: 4), + _buildNPEImagePicker( + title: 'Figure 1 *', + imageFile: _npeData.image1, + onClear: () => setState(() => _npeData.image1 = null), + imageNumber: 1, + remarkController: _image1RemarkController, + ), + _buildNPEImagePicker( + title: 'Figure 2 *', + imageFile: _npeData.image2, + onClear: () => setState(() => _npeData.image2 = null), + imageNumber: 2, + remarkController: _image2RemarkController, + ), + _buildNPEImagePicker( + title: 'Figure 3 *', + imageFile: _npeData.image3, + onClear: () => setState(() => _npeData.image3 = null), + imageNumber: 3, + remarkController: _image3RemarkController, + ), + _buildNPEImagePicker( + title: 'Figure 4 *', + imageFile: _npeData.image4, + onClear: () => setState(() => _npeData.image4 = null), + imageNumber: 4, + remarkController: _image4RemarkController, + ), ], ); } - Widget _buildNPEImagePicker({required String title, File? imageFile, required VoidCallback onClear, required int imageNumber}) { + Widget _buildNPEImagePicker({ + required String title, + File? imageFile, + required VoidCallback onClear, + required int imageNumber, + required TextEditingController remarkController, + }) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Column( @@ -549,7 +825,10 @@ class _NPEReportFromInSituState extends State { child: IconButton( visualDensity: VisualDensity.compact, icon: const Icon(Icons.close, color: Colors.white, size: 20), - onPressed: onClear, + onPressed: () { + onClear(); + remarkController.clear(); + }, ), ), ], @@ -561,6 +840,17 @@ class _NPEReportFromInSituState extends State { ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _processAndSetImage(ImageSource.gallery, imageNumber), icon: const Icon(Icons.photo_library), label: const Text("Gallery")), ], ), + if (imageFile != null) ...[ + const SizedBox(height: 8), + TextFormField( + controller: remarkController, + decoration: const InputDecoration( + labelText: 'Remarks (Optional)', + border: OutlineInputBorder(), + ), + maxLines: 1, + ), + ], ], ), ); @@ -576,20 +866,29 @@ class _NPEReportFromInSituState extends State { maxLines: maxLines, readOnly: readOnly, validator: (value) { - // Allow empty if not required (no '*') - if (!label.contains('*') && (value == null || value.trim().isEmpty)) return null; - // Require non-empty if required ('*') - if (label.contains('*') && !readOnly && (value == null || value.trim().isEmpty)) return 'This field is required'; + if (!label.contains('*')) return null; + if (!readOnly && (value == null || value.trim().isEmpty)) { + if (label.contains("Please specify")) { + return 'This field cannot be empty when "Others" is selected'; + } + if (label.contains('*')) { + return 'This field is required'; + } + } return null; }, ); } - // --- START: ADDED IN-SITU WIDGET BUILDERS --- + // --- START: WIDGET BUILDERS FOR IN-SITU (Unchanged) --- Widget _buildInSituSection() { final activeConnection = _getActiveConnectionDetails(); final String? activeType = activeConnection?['type'] as String?; + // For the "No" path, the in-situ fields must be editable. + // For the "Yes" path, they should be read-only as they come from the sample. + final bool areFieldsReadOnly = (_useRecentSample == true); + return Column( children: [ Row( @@ -611,12 +910,12 @@ class _NPEReportFromInSituState extends State { if (activeConnection != null) _buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']), const SizedBox(height: 16), - _buildParameterListItem(icon: Icons.percent, label: "DO", unit: "%", controller: _doPercentController), - _buildParameterListItem(icon: Icons.flash_on, label: "Cond", unit: "ยตS/cm", controller: _condController), - _buildParameterListItem(icon: Icons.air, label: "DO", unit: "mg/L", controller: _doMgLController), - _buildParameterListItem(icon: Icons.opacity, label: "Turb", unit: "NTU", controller: _turbController), - _buildParameterListItem(icon: Icons.science_outlined, label: "PH", unit: "", controller: _phController), - _buildParameterListItem(icon: Icons.thermostat, label: "Temp", unit: "ยฐC", controller: _tempController), + _buildParameterListItem(icon: Icons.air, label: "DO", unit: "mg/L", controller: _doMgLController, readOnly: areFieldsReadOnly), + _buildParameterListItem(icon: Icons.percent, label: "DO", unit: "%", controller: _doPercentController, readOnly: areFieldsReadOnly), + _buildParameterListItem(icon: Icons.science_outlined, label: "PH", unit: "", controller: _phController, readOnly: areFieldsReadOnly), + _buildParameterListItem(icon: Icons.flash_on, label: "Cond", unit: "ยตS/cm", controller: _condController, readOnly: areFieldsReadOnly), + _buildParameterListItem(icon: Icons.thermostat, label: "Temp", unit: "ยฐC", controller: _tempController, readOnly: areFieldsReadOnly), + _buildParameterListItem(icon: Icons.opacity, label: "Turb", unit: "NTU", controller: _turbController, readOnly: areFieldsReadOnly), ], ); } @@ -638,23 +937,25 @@ class _NPEReportFromInSituState extends State { children: [ Text(statusText, style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontSize: 16)), const SizedBox(height: 16), - if (isConnecting || _isLoading) // Show loading indicator during connection attempt OR general form loading + if (isConnecting || _isLoading) const CircularProgressIndicator() else if (isConnected) Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - ElevatedButton.icon( - icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined), - label: Text(_isAutoReading - ? (_isLockedOut ? 'Stop Reading ($_lockoutSecondsRemaining\s)' : 'Stop Reading') - : 'Start Reading'), - onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type), - style: ElevatedButton.styleFrom( - backgroundColor: _isAutoReading - ? (_isLockedOut ? Colors.grey.shade600 : Colors.orange) - : Colors.green, - foregroundColor: Colors.white, + Flexible( + child: ElevatedButton.icon( + icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined), + label: Text(_isAutoReading + ? (_isLockedOut ? 'Stop Reading (${_lockoutSecondsRemaining}s)' : 'Stop Reading') + : 'Start Reading'), + onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type), + style: ElevatedButton.styleFrom( + backgroundColor: _isAutoReading + ? (_isLockedOut ? Colors.grey.shade600 : Colors.orange) + : Colors.green, + foregroundColor: Colors.white, + ), ), ), TextButton.icon( @@ -665,17 +966,20 @@ class _NPEReportFromInSituState extends State { ) ], ) - // No button needed if disconnected and not loading ], ), ), ); } - Widget _buildParameterListItem({required IconData icon, required String label, required String unit, required TextEditingController controller}) { - // ReadOnly text field used to display the value, looks like standard text but allows copying. + Widget _buildParameterListItem({ + required IconData icon, + required String label, + required String unit, + required TextEditingController controller, + bool readOnly = false, // ADDED: readOnly parameter + }) { final bool isMissing = controller.text.isEmpty || controller.text.contains('-999'); - // Display value with 5 decimal places if not missing, otherwise '-.--' final String displayValue = isMissing ? '-.--' : (double.tryParse(controller.text)?.toStringAsFixed(5) ?? '-.--'); final String displayLabel = unit.isEmpty ? label : '$label ($unit)'; @@ -684,27 +988,30 @@ class _NPEReportFromInSituState extends State { child: ListTile( leading: Icon(icon, color: Theme.of(context).primaryColor, size: 32), title: Text(displayLabel), - trailing: SizedBox( // Use SizedBox to constrain width if needed - width: 120, // Adjust width as necessary + trailing: SizedBox( + width: 120, child: TextFormField( - // Use a unique key based on the controller to force rebuild when text changes - key: ValueKey(controller.text), - initialValue: displayValue, // Use initialValue instead of controller directly - readOnly: true, // Make it read-only + // --- START: MODIFIED to handle readOnly vs. editable --- + controller: readOnly ? null : controller, + initialValue: readOnly ? displayValue : null, + key: readOnly ? ValueKey(displayValue) : null, + // --- END: MODIFIED --- + readOnly: readOnly, textAlign: TextAlign.right, + keyboardType: readOnly ? null : const TextInputType.numberWithOptions(decimal: true), // Allow editing only if NOT readOnly style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: isMissing ? Colors.grey : Theme.of(context).colorScheme.primary, ), decoration: const InputDecoration( - border: InputBorder.none, // Remove underline/border - contentPadding: EdgeInsets.zero, // Remove padding + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, // Helps with alignment ), ), ), ), ); } -// --- END: ADDED IN-SITU WIDGET BUILDERS --- - +// --- END: WIDGET BUILDERS FOR IN-SITU --- } \ No newline at end of file diff --git a/lib/screens/marine/manual/reports/npe_report_from_tarball.dart b/lib/screens/marine/manual/reports/npe_report_from_tarball.dart index c253c9b..e4fc6b0 100644 --- a/lib/screens/marine/manual/reports/npe_report_from_tarball.dart +++ b/lib/screens/marine/manual/reports/npe_report_from_tarball.dart @@ -53,6 +53,11 @@ class _NPEReportFromTarballState extends State { final _condController = TextEditingController(); final _turbController = TextEditingController(); final _tempController = TextEditingController(); + // ADDED: Remark controllers for images + final _image1RemarkController = TextEditingController(); + final _image2RemarkController = TextEditingController(); + final _image3RemarkController = TextEditingController(); + final _image4RemarkController = TextEditingController(); // In-Situ late final MarineInSituSamplingService _samplingService; @@ -91,6 +96,11 @@ class _NPEReportFromTarballState extends State { _condController.dispose(); _turbController.dispose(); _tempController.dispose(); + // ADDED: Dispose remark controllers + _image1RemarkController.dispose(); + _image2RemarkController.dispose(); + _image3RemarkController.dispose(); + _image4RemarkController.dispose(); _dataSubscription?.cancel(); _lockoutTimer?.cancel(); super.dispose(); @@ -137,14 +147,37 @@ class _NPEReportFromTarballState extends State { _npeData.fieldObservations.clear(); _npeData.fieldObservations.addAll(data.fieldObservations); + + // ADDED: Populate image remarks if they exist + _image1RemarkController.text = data.image1Remark ?? ''; + _image2RemarkController.text = data.image2Remark ?? ''; + _image3RemarkController.text = data.image3Remark ?? ''; + _image4RemarkController.text = data.image4Remark ?? ''; }); } Future _submitNpeReport() async { + // --- START: VALIDATION CHECKS --- + // 1. Check for at least one field observation + final bool atLeastOneObservation = _npeData.fieldObservations.values.any((isChecked) => isChecked == true); + if (!atLeastOneObservation) { + _showSnackBar('Please select at least one field observation.', isError: true); + return; + } + + // 2. Check for all 4 required photos + if (_npeData.image1 == null || _npeData.image2 == null || _npeData.image3 == null || _npeData.image4 == null) { + _showSnackBar('Please attach all 4 figures.', isError: true); + return; + } + + // 3. Validate form fields (including "Others" remark) if (!_formKey.currentState!.validate()) { _showSnackBar('Please fill in all required fields.', isError: true); return; } + // --- END: VALIDATION CHECKS --- + setState(() => _isLoading = true); final auth = Provider.of(context, listen: false); final service = Provider.of(context, listen: false); @@ -165,6 +198,12 @@ class _NPEReportFromTarballState extends State { _npeData.ph = double.tryParse(_phController.text); _npeData.temperature = double.tryParse(_tempController.text); + // ADDED: Save image remarks + _npeData.image1Remark = _image1RemarkController.text; + _npeData.image2Remark = _image2RemarkController.text; + _npeData.image3Remark = _image3RemarkController.text; + _npeData.image4Remark = _image4RemarkController.text; + final result = await service.submitNpeReport(data: _npeData, authProvider: auth); setState(() => _isLoading = false); if (mounted) { @@ -199,7 +238,7 @@ class _NPEReportFromTarballState extends State { source, data: watermarkData, imageInfo: 'NPE ATTACHMENT $imageNumber', - isRequired: false, + isRequired: true, // MODIFIED: Watermark is now compulsory ); if (file != null) { @@ -451,11 +490,11 @@ class _NPEReportFromTarballState extends State { _buildTextFormField(controller: _eventDateTimeController, label: "Event Date/Time", readOnly: true), const SizedBox(height: 24), - _buildSectionTitle("2. In-situ Measurements (Optional)"), + _buildSectionTitle("2. In-situ Measurements"), _buildInSituSection(), const SizedBox(height: 24), - _buildSectionTitle("3. Field Observations*"), + _buildSectionTitle("3. Field Observations *"), ..._buildObservationsCheckboxes(), const SizedBox(height: 24), @@ -463,7 +502,7 @@ class _NPEReportFromTarballState extends State { _buildTextFormField(controller: _possibleSourceController, label: "Possible Source", maxLines: 3), const SizedBox(height: 24), - _buildSectionTitle("5. Attachments (Figures)"), + _buildSectionTitle("5. Attachments (Figures) *"), _buildImageAttachmentSection(), const SizedBox(height: 32), @@ -502,7 +541,7 @@ class _NPEReportFromTarballState extends State { padding: const EdgeInsets.symmetric(horizontal: 16.0), child: _buildTextFormField( controller: _othersObservationController, - label: "Please specify", + label: "Please specify *", // MODIFIED: Added * to make it required ), ), ]; @@ -511,15 +550,45 @@ class _NPEReportFromTarballState extends State { Widget _buildImageAttachmentSection() { return Column( children: [ - _buildNPEImagePicker(title: 'Figure 1', imageFile: _npeData.image1, onClear: () => setState(() => _npeData.image1 = null), imageNumber: 1), - _buildNPEImagePicker(title: 'Figure 2', imageFile: _npeData.image2, onClear: () => setState(() => _npeData.image2 = null), imageNumber: 2), - _buildNPEImagePicker(title: 'Figure 3', imageFile: _npeData.image3, onClear: () => setState(() => _npeData.image3 = null), imageNumber: 3), - _buildNPEImagePicker(title: 'Figure 4', imageFile: _npeData.image4, onClear: () => setState(() => _npeData.image4 = null), imageNumber: 4), + _buildNPEImagePicker( + title: 'Figure 1 *', + imageFile: _npeData.image1, + onClear: () => setState(() => _npeData.image1 = null), + imageNumber: 1, + remarkController: _image1RemarkController, + ), + _buildNPEImagePicker( + title: 'Figure 2 *', + imageFile: _npeData.image2, + onClear: () => setState(() => _npeData.image2 = null), + imageNumber: 2, + remarkController: _image2RemarkController, + ), + _buildNPEImagePicker( + title: 'Figure 3 *', + imageFile: _npeData.image3, + onClear: () => setState(() => _npeData.image3 = null), + imageNumber: 3, + remarkController: _image3RemarkController, + ), + _buildNPEImagePicker( + title: 'Figure 4 *', + imageFile: _npeData.image4, + onClear: () => setState(() => _npeData.image4 = null), + imageNumber: 4, + remarkController: _image4RemarkController, + ), ], ); } - Widget _buildNPEImagePicker({required String title, File? imageFile, required VoidCallback onClear, required int imageNumber}) { + Widget _buildNPEImagePicker({ + required String title, + File? imageFile, + required VoidCallback onClear, + required int imageNumber, + required TextEditingController remarkController, // ADDED + }) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Column( @@ -538,7 +607,10 @@ class _NPEReportFromTarballState extends State { child: IconButton( visualDensity: VisualDensity.compact, icon: const Icon(Icons.close, color: Colors.white, size: 20), - onPressed: onClear, + onPressed: () { // MODIFIED: Clear remarks controller + onClear(); + remarkController.clear(); + }, ), ), ], @@ -550,6 +622,19 @@ class _NPEReportFromTarballState extends State { ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _processAndSetImage(ImageSource.gallery, imageNumber), icon: const Icon(Icons.photo_library), label: const Text("Gallery")), ], ), + // --- ADDED: Conditional remarks field --- + if (imageFile != null) ...[ + const SizedBox(height: 8), + TextFormField( + controller: remarkController, + decoration: const InputDecoration( + labelText: 'Remarks (Optional)', + border: OutlineInputBorder(), + ), + maxLines: 1, + ), + ], + // --- END: Added section --- ], ), ); @@ -570,8 +655,13 @@ class _NPEReportFromTarballState extends State { maxLines: maxLines, readOnly: readOnly, validator: (value) { - if (!label.contains('*')) return null; + if (!label.contains('*')) return null; // Unchanged: handles optional fields + // MODIFIED: Validator for required fields (label contains '*') if (!readOnly && (value == null || value.trim().isEmpty)) { + // Custom message for "Others" + if (label.contains("Please specify")) { + return 'This field cannot be empty when "Others" is selected'; + } return 'This field cannot be empty'; } return null; @@ -604,12 +694,12 @@ class _NPEReportFromTarballState extends State { if (activeConnection != null) _buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']), const SizedBox(height: 16), - _buildParameterListItem(icon: Icons.percent, label: "DO", unit: "%", controller: _doPercentController), - _buildParameterListItem(icon: Icons.flash_on, label: "Cond", unit: "ยตS/cm", controller: _condController), _buildParameterListItem(icon: Icons.air, label: "DO", unit: "mg/L", controller: _doMgLController), - _buildParameterListItem(icon: Icons.opacity, label: "Turb", unit: "NTU", controller: _turbController), + _buildParameterListItem(icon: Icons.percent, label: "DO", unit: "%", controller: _doPercentController), _buildParameterListItem(icon: Icons.science_outlined, label: "PH", unit: "", controller: _phController), + _buildParameterListItem(icon: Icons.flash_on, label: "Cond", unit: "ยตS/cm", controller: _condController), _buildParameterListItem(icon: Icons.thermostat, label: "Temp", unit: "ยฐC", controller: _tempController), + _buildParameterListItem(icon: Icons.opacity, label: "Turb", unit: "NTU", controller: _turbController), ], ); } @@ -637,17 +727,19 @@ class _NPEReportFromTarballState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - ElevatedButton.icon( - icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined), - label: Text(_isAutoReading - ? (_isLockedOut ? 'Stop Reading ($_lockoutSecondsRemaining\s)' : 'Stop Reading') - : 'Start Reading'), - onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type), - style: ElevatedButton.styleFrom( - backgroundColor: _isAutoReading - ? (_isLockedOut ? Colors.grey.shade600 : Colors.orange) - : Colors.green, - foregroundColor: Colors.white, + Flexible( + child: ElevatedButton.icon( + icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined), + label: Text(_isAutoReading + ? (_isLockedOut ? 'Stop Reading (${_lockoutSecondsRemaining}s)' : 'Stop Reading') + : 'Start Reading'), + onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type), + style: ElevatedButton.styleFrom( + backgroundColor: _isAutoReading + ? (_isLockedOut ? Colors.grey.shade600 : Colors.orange) + : Colors.green, + foregroundColor: Colors.white, + ), ), ), TextButton.icon( diff --git a/lib/screens/marine/manual/reports/npe_report_new_location.dart b/lib/screens/marine/manual/reports/npe_report_new_location.dart index 8db0f24..1360770 100644 --- a/lib/screens/marine/manual/reports/npe_report_new_location.dart +++ b/lib/screens/marine/manual/reports/npe_report_new_location.dart @@ -50,6 +50,11 @@ class _NPEReportNewLocationState extends State { final _condController = TextEditingController(); final _turbController = TextEditingController(); final _tempController = TextEditingController(); + // ADDED: Remark controllers for images + final _image1RemarkController = TextEditingController(); + final _image2RemarkController = TextEditingController(); + final _image3RemarkController = TextEditingController(); + final _image4RemarkController = TextEditingController(); // In-Situ late final MarineInSituSamplingService _samplingService; @@ -89,6 +94,11 @@ class _NPEReportNewLocationState extends State { _condController.dispose(); _turbController.dispose(); _tempController.dispose(); + // ADDED: Dispose remark controllers + _image1RemarkController.dispose(); + _image2RemarkController.dispose(); + _image3RemarkController.dispose(); + _image4RemarkController.dispose(); super.dispose(); } @@ -137,10 +147,27 @@ class _NPEReportNewLocationState extends State { } Future _submitNpeReport() async { + // --- START: VALIDATION CHECKS --- + // 1. Check for at least one field observation + final bool atLeastOneObservation = _npeData.fieldObservations.values.any((isChecked) => isChecked == true); + if (!atLeastOneObservation) { + _showSnackBar('Please select at least one field observation.', isError: true); + return; + } + + // 2. Check for all 4 required photos + if (_npeData.image1 == null || _npeData.image2 == null || _npeData.image3 == null || _npeData.image4 == null) { + _showSnackBar('Please attach all 4 figures.', isError: true); + return; + } + + // 3. Validate form fields (including "Others" remark) if (!_formKey.currentState!.validate()) { _showSnackBar('Please fill in all required fields.', isError: true); return; } + // --- END: VALIDATION CHECKS --- + setState(() => _isLoading = true); final auth = Provider.of(context, listen: false); final service = Provider.of(context, listen: false); @@ -161,6 +188,12 @@ class _NPEReportNewLocationState extends State { _npeData.ph = double.tryParse(_phController.text); _npeData.temperature = double.tryParse(_tempController.text); + // ADDED: Save image remarks + _npeData.image1Remark = _image1RemarkController.text; + _npeData.image2Remark = _image2RemarkController.text; + _npeData.image3Remark = _image3RemarkController.text; + _npeData.image4Remark = _image4RemarkController.text; + final result = await service.submitNpeReport(data: _npeData, authProvider: auth); setState(() => _isLoading = false); if (mounted) { @@ -186,7 +219,12 @@ class _NPEReportNewLocationState extends State { ..currentLongitude = _longController.text ..selectedStation = {'man_station_name': _locationController.text}; - final file = await _samplingService.pickAndProcessImage(source, data: watermarkData, imageInfo: 'NPE ATTACHMENT $imageNumber', isRequired: false); + final file = await _samplingService.pickAndProcessImage( + source, + data: watermarkData, + imageInfo: 'NPE ATTACHMENT $imageNumber', + isRequired: true, // MODIFIED: Watermark is now compulsory + ); if (file != null) { setState(() { @@ -408,11 +446,11 @@ class _NPEReportNewLocationState extends State { _buildTextFormField(controller: _eventDateTimeController, label: "Event Date/Time", readOnly: true), const SizedBox(height: 24), - _buildSectionTitle("2. In-situ Measurements (Optional)"), + _buildSectionTitle("2. In-situ Measurements"), _buildInSituSection(), const SizedBox(height: 24), - _buildSectionTitle("3. Field Observations*"), + _buildSectionTitle("3. Field Observations *"), ..._buildObservationsCheckboxes(), const SizedBox(height: 24), @@ -420,7 +458,7 @@ class _NPEReportNewLocationState extends State { _buildTextFormField(controller: _possibleSourceController, label: "Possible Source", maxLines: 3), const SizedBox(height: 24), - _buildSectionTitle("5. Attachments (Figures)"), + _buildSectionTitle("5. Attachments (Figures) *"), _buildImageAttachmentSection(), const SizedBox(height: 32), @@ -462,7 +500,7 @@ class _NPEReportNewLocationState extends State { padding: const EdgeInsets.symmetric(horizontal: 16.0), child: _buildTextFormField( controller: _othersObservationController, - label: "Please specify", + label: "Please specify *", // MODIFIED: Added * to make it required ), ), ]; @@ -471,15 +509,45 @@ class _NPEReportNewLocationState extends State { Widget _buildImageAttachmentSection() { return Column( children: [ - _buildNPEImagePicker(title: 'Figure 1', imageFile: _npeData.image1, onClear: () => setState(() => _npeData.image1 = null), imageNumber: 1), - _buildNPEImagePicker(title: 'Figure 2', imageFile: _npeData.image2, onClear: () => setState(() => _npeData.image2 = null), imageNumber: 2), - _buildNPEImagePicker(title: 'Figure 3', imageFile: _npeData.image3, onClear: () => setState(() => _npeData.image3 = null), imageNumber: 3), - _buildNPEImagePicker(title: 'Figure 4', imageFile: _npeData.image4, onClear: () => setState(() => _npeData.image4 = null), imageNumber: 4), + _buildNPEImagePicker( + title: 'Figure 1 *', + imageFile: _npeData.image1, + onClear: () => setState(() => _npeData.image1 = null), + imageNumber: 1, + remarkController: _image1RemarkController, + ), + _buildNPEImagePicker( + title: 'Figure 2 *', + imageFile: _npeData.image2, + onClear: () => setState(() => _npeData.image2 = null), + imageNumber: 2, + remarkController: _image2RemarkController, + ), + _buildNPEImagePicker( + title: 'Figure 3 *', + imageFile: _npeData.image3, + onClear: () => setState(() => _npeData.image3 = null), + imageNumber: 3, + remarkController: _image3RemarkController, + ), + _buildNPEImagePicker( + title: 'Figure 4 *', + imageFile: _npeData.image4, + onClear: () => setState(() => _npeData.image4 = null), + imageNumber: 4, + remarkController: _image4RemarkController, + ), ], ); } - Widget _buildNPEImagePicker({required String title, File? imageFile, required VoidCallback onClear, required int imageNumber}) { + Widget _buildNPEImagePicker({ + required String title, + File? imageFile, + required VoidCallback onClear, + required int imageNumber, + required TextEditingController remarkController, // ADDED + }) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Column( @@ -498,7 +566,10 @@ class _NPEReportNewLocationState extends State { child: IconButton( visualDensity: VisualDensity.compact, icon: const Icon(Icons.close, color: Colors.white, size: 20), - onPressed: onClear, + onPressed: () { // MODIFIED: Clear remarks controller + onClear(); + remarkController.clear(); + }, ), ), ], @@ -510,6 +581,19 @@ class _NPEReportNewLocationState extends State { ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _processAndSetImage(ImageSource.gallery, imageNumber), icon: const Icon(Icons.photo_library), label: const Text("Gallery")), ], ), + // --- ADDED: Conditional remarks field --- + if (imageFile != null) ...[ + const SizedBox(height: 8), + TextFormField( + controller: remarkController, + decoration: const InputDecoration( + labelText: 'Remarks (Optional)', + border: OutlineInputBorder(), + ), + maxLines: 1, + ), + ], + // --- END: Added section --- ], ), ); @@ -532,8 +616,13 @@ class _NPEReportNewLocationState extends State { readOnly: readOnly, keyboardType: keyboardType, validator: (value) { - if (!label.contains('*')) return null; + if (!label.contains('*')) return null; // Unchanged: handles optional fields + // MODIFIED: Validator for required fields (label contains '*') if (!readOnly && (value == null || value.trim().isEmpty)) { + // Custom message for "Others" + if (label.contains("Please specify")) { + return 'This field cannot be empty when "Others" is selected'; + } return 'This field cannot be empty'; } return null; @@ -566,12 +655,12 @@ class _NPEReportNewLocationState extends State { if (activeConnection != null) _buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']), const SizedBox(height: 16), - _buildParameterListItem(icon: Icons.percent, label: "DO", unit: "%", controller: _doPercentController), - _buildParameterListItem(icon: Icons.flash_on, label: "Cond", unit: "ยตS/cm", controller: _condController), _buildParameterListItem(icon: Icons.air, label: "DO", unit: "mg/L", controller: _doMgLController), - _buildParameterListItem(icon: Icons.opacity, label: "Turb", unit: "NTU", controller: _turbController), + _buildParameterListItem(icon: Icons.percent, label: "DO", unit: "%", controller: _doPercentController), _buildParameterListItem(icon: Icons.science_outlined, label: "PH", unit: "", controller: _phController), + _buildParameterListItem(icon: Icons.flash_on, label: "Cond", unit: "ยตS/cm", controller: _condController), _buildParameterListItem(icon: Icons.thermostat, label: "Temp", unit: "ยฐC", controller: _tempController), + _buildParameterListItem(icon: Icons.opacity, label: "Turb", unit: "NTU", controller: _turbController), ], ); } @@ -599,17 +688,19 @@ class _NPEReportNewLocationState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - ElevatedButton.icon( - icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined), - label: Text(_isAutoReading - ? (_isLockedOut ? 'Stop Reading ($_lockoutSecondsRemaining\s)' : 'Stop Reading') - : 'Start Reading'), - onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type), - style: ElevatedButton.styleFrom( - backgroundColor: _isAutoReading - ? (_isLockedOut ? Colors.grey.shade600 : Colors.orange) - : Colors.green, - foregroundColor: Colors.white, + Flexible( + child: ElevatedButton.icon( + icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined), + label: Text(_isAutoReading + ? (_isLockedOut ? 'Stop Reading (${_lockoutSecondsRemaining}s)' : 'Stop Reading') + : 'Start Reading'), + onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type), + style: ElevatedButton.styleFrom( + backgroundColor: _isAutoReading + ? (_isLockedOut ? Colors.grey.shade600 : Colors.orange) + : Colors.green, + foregroundColor: Colors.white, + ), ), ), TextButton.icon( diff --git a/lib/screens/marine/manual/tarball_sampling_step1.dart b/lib/screens/marine/manual/tarball_sampling_step1.dart index 5941df8..c766af4 100644 --- a/lib/screens/marine/manual/tarball_sampling_step1.dart +++ b/lib/screens/marine/manual/tarball_sampling_step1.dart @@ -464,7 +464,9 @@ class _TarballSamplingStep1State extends State { children: [ const TextSpan(text: 'Distance from Station: '), TextSpan( - text: '${(_data.distanceDifference! * 1000).toStringAsFixed(0)} meters', + // --- THIS IS THE MODIFIED LINE --- + text: '${(_data.distanceDifference! * 1000).toStringAsFixed(0)} meters (${_data.distanceDifference!.toStringAsFixed(3)}km)', + // --- END OF MODIFIED LINE --- style: TextStyle( fontWeight: FontWeight.bold, color: ((_data.distanceDifference ?? 0) * 1000) > 50 ? Colors.red : Colors.green), diff --git a/lib/screens/marine/manual/tarball_sampling_step2.dart b/lib/screens/marine/manual/tarball_sampling_step2.dart index 4dfc78f..af0eb73 100644 --- a/lib/screens/marine/manual/tarball_sampling_step2.dart +++ b/lib/screens/marine/manual/tarball_sampling_step2.dart @@ -86,7 +86,7 @@ class _TarballSamplingStep2State extends State { builder: (BuildContext context) { return AlertDialog( title: const Text("Incorrect Image Orientation"), - content: const Text("All photos must be taken in a horizontal (landscape) orientation."), + content: const Text("All photos must be taken in a vertical (landscape) orientation."), actions: [ TextButton( child: const Text("OK"), diff --git a/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart b/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart index 5000432..91fc7a8 100644 --- a/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart +++ b/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart @@ -1,4 +1,4 @@ -//lib\screens\marine\manual\widgets\in_situ_step_1_sampling_info.dart +// lib\screens\marine\manual\widgets\in_situ_step_1_sampling_info.dart import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -519,13 +519,15 @@ class _InSituStep1SamplingInfoState extends State { style: Theme.of(context).textTheme.bodyLarge, children: [ const TextSpan(text: 'Distance from Station: '), + // --- START MODIFICATION --- TextSpan( - text: '${(widget.data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters', + text: '${(widget.data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters (${widget.data.distanceDifferenceInKm!.toStringAsFixed(3)} KM)', style: TextStyle( fontWeight: FontWeight.bold, color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red : Colors.green ), ), + // --- END MODIFICATION --- ], ), ), diff --git a/lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart b/lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart index 345e7c0..f7f9b05 100644 --- a/lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart +++ b/lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart @@ -1,6 +1,7 @@ // lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart import 'dart:io'; +import 'dart:typed_data'; // <-- Required for Uint8List import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:provider/provider.dart'; @@ -28,14 +29,11 @@ class _InSituStep2SiteInfoState extends State { final _formKey = GlobalKey(); bool _isPickingImage = false; - // --- START MODIFICATION: Removed optional remark controllers --- late final TextEditingController _eventRemarksController; late final TextEditingController _labRemarksController; - // --- END MODIFICATION --- - final List _weatherOptions = ['Clear', 'Cloudy', 'Drizzle', 'Rainy', 'Windy']; - final List _tideOptions = ['High', 'Low', 'Mid']; + final List _tideOptions = ['High', 'Low']; final List _seaConditionOptions = ['Calm', 'Moderate Wave', 'High Wave']; @override @@ -43,16 +41,12 @@ class _InSituStep2SiteInfoState extends State { super.initState(); _eventRemarksController = TextEditingController(text: widget.data.eventRemarks); _labRemarksController = TextEditingController(text: widget.data.labRemarks); - // --- START MODIFICATION: Removed initialization for optional remark controllers --- - // --- END MODIFICATION --- } @override void dispose() { _eventRemarksController.dispose(); _labRemarksController.dispose(); - // --- START MODIFICATION: Removed disposal of optional remark controllers --- - // --- END MODIFICATION --- super.dispose(); } @@ -63,12 +57,14 @@ class _InSituStep2SiteInfoState extends State { final service = Provider.of(context, listen: false); - final file = await service.pickAndProcessImage(source, data: widget.data, imageInfo: imageInfo, isRequired: isRequired); + // Always pass `isRequired: true` to the service to enforce landscape check + // and watermarking for ALL photos (required or optional). + final file = await service.pickAndProcessImage(source, data: widget.data, imageInfo: imageInfo, isRequired: true); if (file != null) { setState(() => setImageCallback(file)); } else if (mounted) { - _showSnackBar('Image selection failed. Please ensure all photos are taken in landscape mode.', isError: true); + _showSnackBar('Image selection failed. Please ensure all photos are taken in landscape (vertical) mode.', isError: true); } if (mounted) { @@ -78,7 +74,6 @@ class _InSituStep2SiteInfoState extends State { /// Validates the form and all required images before proceeding. void _goToNextStep() { - // --- START MODIFICATION: Updated validation logic --- if (widget.data.leftLandViewImage == null || widget.data.rightLandViewImage == null || widget.data.waterFillingImage == null || @@ -87,16 +82,12 @@ class _InSituStep2SiteInfoState extends State { return; } - // Form validation now handles the conditional requirement for Event Remarks if (!_formKey.currentState!.validate()) { return; } _formKey.currentState!.save(); - - // Removed saving of optional remarks as they are no longer present widget.onNext(); - // --- END MODIFICATION --- } void _showSnackBar(String message, {bool isError = false}) { @@ -110,13 +101,11 @@ class _InSituStep2SiteInfoState extends State { @override Widget build(BuildContext context) { - // --- START MODIFICATION: Logic to determine if Event Remarks are required --- final bool areAdditionalPhotosAttached = widget.data.phPaperImage != null || widget.data.optionalImage1 != null || widget.data.optionalImage2 != null || widget.data.optionalImage3 != null || widget.data.optionalImage4 != null; - // --- END MODIFICATION --- return Form( key: _formKey, @@ -153,7 +142,10 @@ class _InSituStep2SiteInfoState extends State { // --- Section: Required Photos --- Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge), - const Text("All photos must be taken in landscape (horizontal) orientation.", style: TextStyle(color: Colors.grey)), + const Text( + "All photos must be in landscape (vertical) orientation. A watermark will be applied automatically.", + style: TextStyle(color: Colors.grey) + ), const SizedBox(height: 8), _buildImagePicker('Left Side Land View', 'LEFT_LAND_VIEW', widget.data.leftLandViewImage, (file) => widget.data.leftLandViewImage = file, isRequired: true), _buildImagePicker('Right Side Land View', 'RIGHT_LAND_VIEW', widget.data.rightLandViewImage, (file) => widget.data.rightLandViewImage = file, isRequired: true), @@ -161,12 +153,10 @@ class _InSituStep2SiteInfoState extends State { _buildImagePicker('Seawater in Clear Glass Bottle', 'SEAWATER_COLOR', widget.data.seawaterColorImage, (file) => widget.data.seawaterColorImage = file, isRequired: true), const SizedBox(height: 24), - // --- START MODIFICATION: Section for additional photos and conditional remarks --- + // --- Section: Additional Photos & Remarks --- Text("Additional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), - // pH Paper photo is now the first optional photo _buildImagePicker('Examine Preservative (pH paper)', 'PH_PAPER', widget.data.phPaperImage, (file) => widget.data.phPaperImage = file, isRequired: false), - // Other optional photos no longer have remark fields _buildImagePicker('Optional Photo 1', 'OPTIONAL_1', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, isRequired: false), _buildImagePicker('Optional Photo 2', 'OPTIONAL_2', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, isRequired: false), _buildImagePicker('Optional Photo 3', 'OPTIONAL_3', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, isRequired: false), @@ -175,7 +165,6 @@ class _InSituStep2SiteInfoState extends State { Text("Remarks", style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 16), - // Event Remarks field is now conditionally required TextFormField( controller: _eventRemarksController, decoration: InputDecoration( @@ -191,7 +180,6 @@ class _InSituStep2SiteInfoState extends State { }, maxLines: 3, ), - // --- END MODIFICATION --- const SizedBox(height: 16), TextFormField( controller: _labRemarksController, @@ -210,10 +198,8 @@ class _InSituStep2SiteInfoState extends State { ); } - /// A reusable widget for picking and displaying an image, matching the tarball design. - // --- START MODIFICATION: Removed remarkController parameter --- + /// A reusable widget for picking and displaying an image. Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {bool isRequired = false}) { - // --- END MODIFICATION --- return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Column( @@ -225,7 +211,41 @@ class _InSituStep2SiteInfoState extends State { Stack( alignment: Alignment.topRight, children: [ - ClipRRect(borderRadius: BorderRadius.circular(8.0), child: Image.file(imageFile, key: UniqueKey(), height: 150, width: double.infinity, fit: BoxFit.cover)), + // --- START MODIFICATION: Use FutureBuilder to load bytes async --- + ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: FutureBuilder( + // Use ValueKey to ensure FutureBuilder refetches when the file path changes + key: ValueKey(imageFile.path), + future: imageFile.readAsBytes(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Container( + height: 150, + width: double.infinity, + alignment: Alignment.center, + child: const CircularProgressIndicator(), + ); + } + if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) { + return Container( + height: 150, + width: double.infinity, + alignment: Alignment.center, + child: const Icon(Icons.error, color: Colors.red, size: 40), + ); + } + // Display the image from memory + return Image.memory( + snapshot.data!, + height: 150, + width: double.infinity, + fit: BoxFit.cover, + ); + }, + ), + ), + // --- END MODIFICATION --- Container( margin: const EdgeInsets.all(4), decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle), @@ -245,8 +265,6 @@ class _InSituStep2SiteInfoState extends State { ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")), ], ), - // --- START MODIFICATION: Removed remark text field --- - // --- END MODIFICATION --- ], ), ); diff --git a/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart b/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart index f342719..32fca9e 100644 --- a/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart +++ b/lib/screens/marine/manual/widgets/in_situ_step_3_data_capture.dart @@ -851,10 +851,13 @@ class _InSituStep3DataCaptureState extends State with Wi if (isConnecting || _isLoading) const CircularProgressIndicator() else if (isConnected) - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + // --- START MODIFICATION: Replaced Row with Wrap to fix overflow --- + Wrap( + alignment: WrapAlignment.spaceEvenly, // Lays them out with space + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8.0, // Horizontal space between buttons + runSpacing: 4.0, // Vertical space if it wraps children: [ - // --- START MODIFICATION: Add countdown to Stop Reading button --- ElevatedButton.icon( icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined), label: Text(_isAutoReading @@ -868,7 +871,6 @@ class _InSituStep3DataCaptureState extends State with Wi foregroundColor: Colors.white, ), ), - // --- END MODIFICATION --- TextButton.icon( icon: const Icon(Icons.link_off), label: const Text('Disconnect'), @@ -877,6 +879,7 @@ class _InSituStep3DataCaptureState extends State with Wi ) ], ) + // --- END MODIFICATION --- ], ), ), diff --git a/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart b/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart index 86364c7..402ccea 100644 --- a/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart +++ b/lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart @@ -1,6 +1,7 @@ // lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart import 'dart:io'; +import 'dart:typed_data'; // <-- Required for Uint8List import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -77,12 +78,16 @@ class _InSituStep4SummaryState extends State { Map limitData = {}; if (stationId != null) { + // --- START FIX: Use type-safe comparison for station_id --- + // This ensures that the comparison works regardless of whether + // station_id is stored as a number (e.g., 123) or a string (e.g., "123"). limitData = marineLimits.firstWhere( (l) => l['param_parameter_list'] == limitName && - l['station_id'] == stationId, + l['station_id']?.toString() == stationId.toString(), orElse: () => {}, ); + // --- END FIX --- } if (limitData.isNotEmpty) { @@ -131,6 +136,7 @@ class _InSituStep4SummaryState extends State { final limitName = _parameterKeyToLimitName[key]; if (limitName == null) return; + // NPE limits are general and NOT station-specific, so this is correct. final limitData = npeLimits.firstWhere( (l) => l['param_parameter_list'] == limitName, orElse: () => {}, @@ -629,14 +635,39 @@ class _InSituStep4SummaryState extends State { const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), const SizedBox(height: 8), if (image != null) + // --- START MODIFICATION: Use FutureBuilder to load bytes async --- ClipRRect( borderRadius: BorderRadius.circular(8.0), - child: Image.file(image, - key: UniqueKey(), - height: 200, - width: double.infinity, - fit: BoxFit.cover), + child: FutureBuilder( + key: ValueKey(image.path), + future: image.readAsBytes(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Container( + height: 200, + width: double.infinity, + alignment: Alignment.center, + child: const CircularProgressIndicator(), + ); + } + if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) { + return Container( + height: 200, + width: double.infinity, + alignment: Alignment.center, + child: const Icon(Icons.error, color: Colors.red, size: 40), + ); + } + return Image.memory( + snapshot.data!, + height: 200, + width: double.infinity, + fit: BoxFit.cover, + ); + }, + ), ) + // --- END MODIFICATION --- else Container( height: 100, diff --git a/lib/screens/marine/marine_home_page.dart b/lib/screens/marine/marine_home_page.dart index 2c59fe8..08f2cc0 100644 --- a/lib/screens/marine/marine_home_page.dart +++ b/lib/screens/marine/marine_home_page.dart @@ -62,8 +62,11 @@ class MarineHomePage extends StatelessWidget { children: [ // MODIFIED: Updated label, icon, and route for the new Info Centre screen SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/investigative/info'), - // *** ADDED: New menu item for Investigative Manual Sampling *** SidebarItem(icon: Icons.science_outlined, label: "Investigative Sampling", route: '/marine/investigative/manual-sampling'), + // *** START: ADDED NEW MENU ITEMS *** + SidebarItem(icon: Icons.article, label: "Data Log", route: '/marine/investigative/data-log'), + SidebarItem(icon: Icons.image, label: "Image Request", route: '/marine/investigative/image-request'), + // *** END: ADDED NEW MENU ITEMS *** //SidebarItem(icon: Icons.info, label: "Overview", route: '/marine/investigative/overview'), //SidebarItem(icon: Icons.input, label: "Entry", route: '/marine/investigative/entry'), //SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/investigative/report'), diff --git a/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_3_data_capture.dart b/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_3_data_capture.dart index c95eb16..d688955 100644 --- a/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_3_data_capture.dart +++ b/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_3_data_capture.dart @@ -9,7 +9,8 @@ import 'package:intl/intl.dart'; import '../../../../auth_provider.dart'; import '../../../../models/river_inves_manual_sampling_data.dart'; // Updated model -import '../../../../services/api_service.dart'; +//import '../../../../services/api_service.dart'; +import 'package:environment_monitoring_app/services/database_helper.dart'; import '../../../../services/river_investigative_sampling_service.dart'; // Updated service import '../../../../bluetooth/bluetooth_manager.dart'; import '../../../../serial/serial_manager.dart'; diff --git a/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_4_additional_info.dart b/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_4_additional_info.dart index 024a73b..dcf167b 100644 --- a/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_4_additional_info.dart +++ b/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_4_additional_info.dart @@ -73,6 +73,7 @@ class _RiverInvesStep4AdditionalInfoState if (file != null) { setState(() => setImageCallback(file)); } else if (mounted) { + // โœ… CHANGE: Reverted. All photos (required and optional) must be landscape. _showSnackBar( 'Image selection failed. Please ensure all photos are taken in landscape mode.', isError: true); @@ -180,7 +181,10 @@ class _RiverInvesStep4AdditionalInfoState child: IconButton( visualDensity: VisualDensity.compact, icon: const Icon(Icons.close, color: Colors.white, size: 20), - onPressed: () => setState(() => setImageCallback(null)), // Clear the image file in the data model + onPressed: () { + remarkController?.clear(); + setState(() => setImageCallback(null)); + }, ), ), ], @@ -195,7 +199,7 @@ class _RiverInvesStep4AdditionalInfoState ], ), // Remarks field, linked via the passed controller - if (remarkController != null) + if (remarkController != null && imageFile != null) Padding( padding: const EdgeInsets.only(top: 8.0), child: TextFormField( diff --git a/lib/screens/river/investigative/river_investigative_data_status_log.dart b/lib/screens/river/investigative/river_investigative_data_status_log.dart new file mode 100644 index 0000000..75405cd --- /dev/null +++ b/lib/screens/river/investigative/river_investigative_data_status_log.dart @@ -0,0 +1,837 @@ +// lib/screens/river/investigative/river_investigative_data_status_log.dart + +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:environment_monitoring_app/auth_provider.dart'; +import 'package:environment_monitoring_app/models/river_inves_manual_sampling_data.dart'; +import 'package:environment_monitoring_app/services/local_storage_service.dart'; +import 'package:environment_monitoring_app/services/river_investigative_sampling_service.dart'; +import 'dart:convert'; + +/// A simple class to hold an image file and its associated remark. +class ImageLogEntry { + final File file; + final String? remark; + ImageLogEntry({required this.file, this.remark}); +} + +class SubmissionLogEntry { + final String type; + final String title; + final String stationCode; + final DateTime submissionDateTime; + final String? reportId; + final String status; + final String message; + final Map rawData; + final String serverName; + final String? apiStatusRaw; + final String? ftpStatusRaw; + bool isResubmitting; + + SubmissionLogEntry({ + required this.type, + required this.title, + required this.stationCode, + required this.submissionDateTime, + this.reportId, + required this.status, + required this.message, + required this.rawData, + required this.serverName, + this.apiStatusRaw, + this.ftpStatusRaw, + this.isResubmitting = false, + }); +} + +class RiverInvestigativeDataStatusLog extends StatefulWidget { + const RiverInvestigativeDataStatusLog({super.key}); + + @override + State createState() => + _RiverInvestigativeDataStatusLogState(); +} + +class _RiverInvestigativeDataStatusLogState + extends State { + final LocalStorageService _localStorageService = LocalStorageService(); + + late RiverInvestigativeSamplingService _riverInvestigativeService; + + List _investigativeLogs = []; + List _filteredInvestigativeLogs = []; + + final TextEditingController _investigativeSearchController = + TextEditingController(); + + bool _isLoading = true; + final Map _isResubmitting = {}; + + @override + void initState() { + super.initState(); + _investigativeSearchController.addListener(_filterLogs); + _loadAllLogs(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _riverInvestigativeService = + Provider.of(context); + } + + @override + void dispose() { + _investigativeSearchController.dispose(); + super.dispose(); + } + + Future _loadAllLogs() async { + setState(() => _isLoading = true); + + final investigativeLogs = + await _localStorageService.getAllRiverInvestigativeLogs(); + + final List tempInvestigative = []; + + for (var log in investigativeLogs) { + final entry = _createInvestigativeLogEntry(log); + if (entry != null) { + tempInvestigative.add(entry); + } + } + + tempInvestigative + .sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + + if (mounted) { + setState(() { + _investigativeLogs = tempInvestigative; + _isLoading = false; + }); + _filterLogs(); + } + } + + SubmissionLogEntry? _createInvestigativeLogEntry(Map log) { + // Use the data model to correctly determine station name/code + final data = RiverInvesManualSamplingData.fromJson(log); + + final String type = data.samplingType ?? 'Investigative'; + final String title = data.getDeterminedStationName() ?? 'Unknown River'; + final String stationCode = data.getDeterminedStationCode() ?? 'N/A'; + + final String? dateStr = data.samplingDate; + final String? timeStr = data.samplingTime; + + DateTime submissionDateTime = + DateTime.fromMillisecondsSinceEpoch(0); // Default to invalid + try { + if (dateStr != null && + timeStr != null && + dateStr.isNotEmpty && + timeStr.isNotEmpty) { + final String fullDateString = + '$dateStr ${timeStr.length == 5 ? "$timeStr:00" : timeStr}'; + submissionDateTime = DateTime.tryParse(fullDateString) ?? + DateTime.fromMillisecondsSinceEpoch(0); + } + } catch (_) { + // Keep default invalid date + } + + 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: data.reportId, + status: data.submissionStatus ?? 'L1', + message: data.submissionMessage ?? 'No status message.', + rawData: log, // Store the original raw map + serverName: log['serverConfigName'] ?? 'Unknown Server', + apiStatusRaw: apiStatusRaw, + ftpStatusRaw: ftpStatusRaw, + ); + } + + void _filterLogs() { + final investigativeQuery = + _investigativeSearchController.text.toLowerCase(); + + setState(() { + _filteredInvestigativeLogs = _investigativeLogs + .where((log) => _logMatchesQuery(log, investigativeQuery)) + .toList(); + }); + } + + bool _logMatchesQuery(SubmissionLogEntry log, String query) { + if (query.isEmpty) return true; + return log.title.toLowerCase().contains(query) || + log.stationCode.toLowerCase().contains(query) || + log.serverName.toLowerCase().contains(query) || + (log.reportId?.toLowerCase() ?? '').contains(query); + } + + Future _resubmitData(SubmissionLogEntry log) async { + final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); + if (mounted) { + setState(() { + _isResubmitting[logKey] = true; + }); + } + + try { + final authProvider = Provider.of(context, listen: false); + final appSettings = authProvider.appSettings; + Map result = {}; + + // This log only handles investigative types + final dataToResubmit = RiverInvesManualSamplingData.fromJson(log.rawData); + + result = await _riverInvestigativeService.submitData( + data: dataToResubmit, + appSettings: appSettings, + authProvider: authProvider, + logDirectory: log.rawData['logDirectory'], + ); + + if (mounted) { + final message = result['message'] ?? 'Resubmission process completed.'; + final isSuccess = result['success'] as bool? ?? false; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isSuccess + ? Colors.green + : (result['status'] == 'L1' ? Colors.red : Colors.orange), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Resubmission failed: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isResubmitting.remove(logKey); + }); + _loadAllLogs(); + } + } + } + + @override + Widget build(BuildContext context) { + final hasAnyLogs = _investigativeLogs.isNotEmpty; + + return Scaffold( + appBar: + AppBar(title: const Text('River Investigative Data Status Log')), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _loadAllLogs, + child: !hasAnyLogs + ? const Center(child: Text('No submission logs found.')) + : ListView( + padding: const EdgeInsets.all(8.0), + children: [ + _buildCategorySection('Investigative Sampling', + _filteredInvestigativeLogs, _investigativeSearchController), + ], + ), + ), + ); + } + + Widget _buildCategorySection(String category, List logs, + TextEditingController searchController) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 8.0), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(category, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold)), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: TextField( + controller: searchController, + decoration: InputDecoration( + hintText: 'Search in $category...', + prefixIcon: const Icon(Icons.search, size: 20), + isDense: true, + border: const OutlineInputBorder(), + suffixIcon: searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + searchController.clear(); + }, + ) + : null, + ), + ), + ), + const Divider(), + if (logs.isEmpty) + Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: Text(searchController.text.isEmpty + ? 'No logs found in this category.' + : 'No logs match your search in this category.'))) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: logs.length, + itemBuilder: (context, index) { + return _buildLogListItem(logs[index]); + }, + ), + ], + ), + ), + ); + } + + Widget _buildLogListItem(SubmissionLogEntry log) { + final bool isFullSuccess = log.status == 'S4'; + final bool isPartialSuccess = log.status == 'S3' || log.status == 'L4'; + final bool canResubmit = !isFullSuccess; + + IconData statusIcon; + Color statusColor; + + if (isFullSuccess) { + statusIcon = Icons.check_circle_outline; + statusColor = Colors.green; + } else if (isPartialSuccess) { + statusIcon = Icons.warning_amber_rounded; + statusColor = Colors.orange; + } else { + statusIcon = Icons.error_outline; + statusColor = Colors.red; + } + + final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); + final isResubmitting = _isResubmitting[logKey] ?? false; + + final titleWidget = RichText( + text: TextSpan( + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith(fontWeight: FontWeight.w500), + children: [ + TextSpan(text: '${log.title} '), + TextSpan( + text: '(${log.stationCode})', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(fontWeight: FontWeight.normal), + ), + ], + ), + ); + + final bool isDateValid = !log.submissionDateTime + .isAtSameMomentAs(DateTime.fromMillisecondsSinceEpoch(0)); + final subtitle = isDateValid + ? '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}' + : '${log.serverName} - Invalid Date'; + + return ExpansionTile( + key: PageStorageKey(logKey), + leading: Icon(statusIcon, color: statusColor), + title: titleWidget, + subtitle: Text(subtitle), + trailing: canResubmit + ? (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), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton.icon( + icon: Icon(Icons.list_alt, + color: Theme.of(context).colorScheme.primary), + label: Text('View Data', + style: TextStyle( + color: Theme.of(context).colorScheme.primary)), + onPressed: () => _showDataDialog(context, log), + ), + TextButton.icon( + icon: Icon(Icons.photo_library_outlined, + color: Theme.of(context).colorScheme.secondary), + label: Text('View Images', + style: TextStyle( + color: + Theme.of(context).colorScheme.secondary)), + onPressed: () => _showImageDialog(context, log), + ), + ], + ), + ), + const Divider(height: 10), + _buildGranularStatus('API', log.apiStatusRaw), + _buildGranularStatus('FTP', log.ftpStatusRaw), + ], + ), + ) + ], + ); + } + + /// Builds a formatted category header row for the data table. + TableRow _buildCategoryRow( + BuildContext context, String title, IconData icon) { + return TableRow( + decoration: BoxDecoration( + color: Colors.grey.shade100, + ), + children: [ + Padding( + padding: + const EdgeInsets.only(top: 16.0, bottom: 8.0, left: 8.0, right: 8.0), + child: Row( + children: [ + Icon(icon, size: 20, color: Theme.of(context).primaryColor), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + ), + const SizedBox.shrink(), // Empty cell for the second column + ], + ); + } + + /// Builds a formatted row for the data dialog, gracefully handling null/empty values. + TableRow _buildDataTableRow(String label, String? value) { + String displayValue = + (value == null || value.isEmpty || value == 'null') ? 'N/A' : value; + + // Format special "missing" values + if (displayValue == '-999.0' || displayValue == '-999') { + displayValue = 'N/A'; + } + + return TableRow( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + child: Text(displayValue), // Use Text, NOT SelectableText + ), + ], + ); + } + + /// Helper to safely get a string value from the raw data map. + String? _getString(Map data, String key) { + final value = data[key]; + if (value == null) return null; + if (value is double && value == -999.0) return 'N/A'; + return value.toString(); + } + + /// Shows the categorized and formatted data log in a dialog + void _showDataDialog(BuildContext context, SubmissionLogEntry log) { + final Map data = log.rawData; + final List tableRows = []; + + // --- 1. Sampling Info --- + tableRows.add( + _buildCategoryRow(context, 'Sampling Info', Icons.calendar_today)); + tableRows + .add(_buildDataTableRow('Date', _getString(data, 'samplingDate'))); + tableRows + .add(_buildDataTableRow('Time', _getString(data, 'samplingTime'))); + tableRows.add(_buildDataTableRow( + '1st Sampler', _getString(data, 'firstSamplerName'))); + + String? secondSamplerName; + if (data['secondSampler'] is Map) { + secondSamplerName = + (data['secondSampler'] as Map)['first_name']?.toString(); + } + tableRows.add(_buildDataTableRow('2nd Sampler', secondSamplerName)); + tableRows + .add(_buildDataTableRow('Sample ID', _getString(data, 'sampleIdCode'))); + + // --- 2. Station & Location --- + tableRows.add( + _buildCategoryRow(context, 'Station & Location', Icons.location_on_outlined)); + + tableRows.add(_buildDataTableRow( + 'Station Type', _getString(data, 'stationTypeSelection'))); + if (data['stationTypeSelection'] == 'New Location') { + tableRows + .add(_buildDataTableRow('New State', _getString(data, 'newStateName'))); + tableRows + .add(_buildDataTableRow('New Basin', _getString(data, 'newBasinName'))); + tableRows + .add(_buildDataTableRow('New River', _getString(data, 'newRiverName'))); + tableRows.add( + _buildDataTableRow('New Station Name', _getString(data, 'newStationName'))); + tableRows.add( + _buildDataTableRow('New Station Code', _getString(data, 'newStationCode'))); + tableRows.add(_buildDataTableRow( + 'Station Latitude', _getString(data, 'stationLatitude'))); + tableRows.add(_buildDataTableRow( + 'Station Longitude', _getString(data, 'stationLongitude'))); + } else { + // Show existing station info if it's not a new location + tableRows + .add(_buildDataTableRow('Station', '${log.stationCode} - ${log.title}')); + } + + tableRows.add(_buildDataTableRow( + 'Current Latitude', _getString(data, 'currentLatitude'))); + tableRows.add(_buildDataTableRow( + 'Current Longitude', _getString(data, 'currentLongitude'))); + tableRows.add(_buildDataTableRow( + 'Distance (km)', _getString(data, 'distanceDifferenceInKm'))); + tableRows.add(_buildDataTableRow( + 'Distance Remarks', _getString(data, 'distanceDifferenceRemarks'))); + + // --- 3. Site Conditions --- + tableRows.add( + _buildCategoryRow(context, 'Site Conditions', Icons.wb_sunny_outlined)); + tableRows.add(_buildDataTableRow('Weather', _getString(data, 'weather'))); + tableRows + .add(_buildDataTableRow('Event Remarks', _getString(data, 'eventRemarks'))); + tableRows + .add(_buildDataTableRow('Lab Remarks', _getString(data, 'labRemarks'))); + + // --- 4. Parameters --- + tableRows + .add(_buildCategoryRow(context, 'Parameters', Icons.bar_chart)); + tableRows.add(_buildDataTableRow('Sonde ID', _getString(data, 'sondeId'))); + tableRows.add( + _buildDataTableRow('Capture Date', _getString(data, 'dataCaptureDate'))); + tableRows.add( + _buildDataTableRow('Capture Time', _getString(data, 'dataCaptureTime'))); + tableRows.add(_buildDataTableRow( + 'Oxygen Conc (mg/L)', _getString(data, 'oxygenConcentration'))); + tableRows.add(_buildDataTableRow( + 'Oxygen Sat (%)', _getString(data, 'oxygenSaturation'))); + tableRows.add(_buildDataTableRow('pH', _getString(data, 'ph'))); + tableRows + .add(_buildDataTableRow('Salinity (ppt)', _getString(data, 'salinity'))); + tableRows.add(_buildDataTableRow( + 'Conductivity (ยตS/cm)', _getString(data, 'electricalConductivity'))); + tableRows.add( + _buildDataTableRow('Temperature (ยฐC)', _getString(data, 'temperature'))); + tableRows.add(_buildDataTableRow('TDS (mg/L)', _getString(data, 'tds'))); + tableRows + .add(_buildDataTableRow('Turbidity (NTU)', _getString(data, 'turbidity'))); + tableRows + .add(_buildDataTableRow('Ammonia (mg/L)', _getString(data, 'ammonia'))); + tableRows.add( + _buildDataTableRow('Battery (V)', _getString(data, 'batteryVoltage'))); + + // --- 5. Flowrate --- + if (data['flowrateMethod'] != null || data['flowrateValue'] != null) { + tableRows + .add(_buildCategoryRow(context, 'Flowrate', Icons.waves_outlined)); + tableRows + .add(_buildDataTableRow('Method', _getString(data, 'flowrateMethod'))); + tableRows.add( + _buildDataTableRow('Flowrate (m/s)', _getString(data, 'flowrateValue'))); + if (data['flowrateMethod'] == 'Surface Drifter') { + tableRows.add(_buildDataTableRow( + ' Height (m)', _getString(data, 'flowrateSurfaceDrifterHeight'))); + tableRows.add(_buildDataTableRow(' Distance (m)', + _getString(data, 'flowrateSurfaceDrifterDistance'))); + tableRows.add(_buildDataTableRow( + ' Time First', _getString(data, 'flowrateSurfaceDrifterTimeFirst'))); + tableRows.add(_buildDataTableRow( + ' Time Last', _getString(data, 'flowrateSurfaceDrifterTimeLast'))); + } + } + + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('${log.stationCode} - ${log.title}'), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: Table( + columnWidths: const { + 0: IntrinsicColumnWidth(), + 1: FlexColumnWidth(), + }, + border: TableBorder( + horizontalInside: BorderSide( + color: Colors.grey.shade300, + width: 0.5, + ), + ), + children: tableRows, + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); + } + + void _showImageDialog(BuildContext context, SubmissionLogEntry log) { + // Standard keys for river models + const imageRemarkMap = { + 'backgroundStationImage': null, + 'upstreamRiverImage': null, + 'downstreamRiverImage': null, + 'sampleTurbidityImage': null, + 'optionalImage1': 'optionalRemark1', + 'optionalImage2': 'optionalRemark2', + 'optionalImage3': 'optionalRemark3', + 'optionalImage4': 'optionalRemark4', + }; + + final List imageEntries = []; + for (final entry in imageRemarkMap.entries) { + final imageKey = entry.key; + final remarkKey = entry.value; + + final path = log.rawData[imageKey]; + if (path != null && path is String && path.isNotEmpty) { + final file = File(path); + if (file.existsSync()) { + final remark = + (remarkKey != null ? log.rawData[remarkKey] as String? : null); + imageEntries.add(ImageLogEntry(file: file, remark: remark)); + } + } + } + + if (imageEntries.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No images are attached to this log.'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Images for ${log.stationCode} - ${log.title}'), + content: SizedBox( + width: double.maxFinite, + child: GridView.builder( + shrinkWrap: true, + itemCount: imageEntries.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + final imageEntry = imageEntries[index]; + final bool hasRemark = + imageEntry.remark != null && imageEntry.remark!.isNotEmpty; + + return Card( + clipBehavior: Clip.antiAlias, + elevation: 2, + child: Stack( + fit: StackFit.expand, + children: [ + Image.file( + imageEntry.file, + fit: BoxFit.cover, + errorBuilder: (context, error, stack) { + return const Center( + child: Icon( + Icons.broken_image, + color: Colors.grey, + size: 40, + ), + ); + }, + ), + if (hasRemark) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(6.0), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withOpacity(0.8), + Colors.black.withOpacity(0.0) + ], + ), + ), + child: Text( + imageEntry.remark!, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); + } + + Widget _buildGranularStatus(String type, String? jsonStatus) { + if (jsonStatus == null || jsonStatus.isEmpty) { + return Container(); + } + + List statuses; + try { + statuses = jsonDecode(jsonStatus); + } catch (_) { + return _buildDetailRow('$type Status:', jsonStatus); + } + + if (statuses.isEmpty) { + return Container(); + } + + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$type Status:', + style: const TextStyle(fontWeight: FontWeight.bold)), + ...statuses.map((s) { + final serverName = + s['server_name'] ?? s['config_name'] ?? 'Server N/A'; + final status = s['message'] ?? 'N/A'; + final bool isSuccess = s['success'] as bool? ?? false; + final IconData icon = + isSuccess ? Icons.check_circle_outline : Icons.error_outline; + final Color color = isSuccess ? Colors.green : Colors.red; + 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)), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/investigative/river_investigative_image_request.dart b/lib/screens/river/investigative/river_investigative_image_request.dart new file mode 100644 index 0000000..db07ddb --- /dev/null +++ b/lib/screens/river/investigative/river_investigative_image_request.dart @@ -0,0 +1,609 @@ +// lib/screens/river/investigative/river_investigative_image_request.dart + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:dropdown_search/dropdown_search.dart'; +import 'package:intl/intl.dart'; +import 'dart:convert'; + +import '../../../auth_provider.dart'; +import '../../../services/api_service.dart'; + +class RiverInvestigativeImageRequest extends StatelessWidget { + const RiverInvestigativeImageRequest({super.key}); + + @override + Widget build(BuildContext context) { + return const RiverInvestigativeImageRequestScreen(); + } +} + +class RiverInvestigativeImageRequestScreen extends StatefulWidget { + const RiverInvestigativeImageRequestScreen({super.key}); + + @override + State createState() => + _RiverInvestigativeImageRequestScreenState(); +} + +class _RiverInvestigativeImageRequestScreenState + extends State { + final _formKey = GlobalKey(); + final _dateController = TextEditingController(); + + final String _selectedSamplingType = 'Investigative Sampling'; + + String? _selectedStateName; + String? _selectedBasinName; + Map? _selectedStation; + DateTime? _selectedDate; + + List _statesList = []; + List _basinsForState = []; + List> _stationsForBasin = []; + + bool _isLoading = false; + List _imageUrls = []; + final Set _selectedImageUrls = {}; + + // --- MODIFICATION: Added flag to track initialization --- + bool _filtersInitialized = false; + + @override + void initState() { + super.initState(); + // We NO LONGER initialize filters here, as data isn't ready. + } + + @override + void dispose() { + _dateController.dispose(); + super.dispose(); + } + + /// Gets the correct list of stations from AuthProvider. + List> _getStationsForType(AuthProvider auth) { + return auth.riverManualStations ?? []; + } + + /// Gets the key for the station's unique ID (for API calls). + String _getStationIdKey() { + return 'station_id'; + } + + /// Gets the key for the station's human-readable code. + String _getStationCodeKey() { + return 'sampling_station_code'; + } + + /// Gets the key for the station's name (river name). + String _getStationNameKey() { + return 'sampling_river'; + } + + /// Gets the key for the station's basin. + String _getStationBasinKey() { + return 'sampling_basin'; + } + + // --- MODIFICATION: This is now called by the build method --- + void _initializeStationFilters() { + final auth = Provider.of(context, listen: false); + final allStations = _getStationsForType(auth); + + if (allStations.isNotEmpty) { + final states = allStations + .map((s) => s['state_name'] as String?) + .whereType() + .toSet() + .toList(); + states.sort(); + setState(() { + _statesList = states; + _selectedStateName = null; + _selectedBasinName = null; + _selectedStation = null; + _basinsForState = []; + _stationsForBasin = []; + }); + } else { + setState(() { + _statesList = []; + _selectedStateName = null; + _selectedBasinName = null; + _selectedStation = null; + _basinsForState = []; + _stationsForBasin = []; + }); + } + // Set flag to prevent re-initialization + _filtersInitialized = true; + } + + Future _selectDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedDate ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime.now(), + ); + + if (picked != null && picked != _selectedDate) { + setState(() { + _selectedDate = picked; + _dateController.text = DateFormat('yyyy-MM-dd').format(_selectedDate!); + }); + } + } + + Future _searchImages() async { + if (_formKey.currentState!.validate()) { + setState(() { + _isLoading = true; + _imageUrls = []; + _selectedImageUrls.clear(); + }); + + if (_selectedStation == null || _selectedDate == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Error: Station and date are required.'), + backgroundColor: Colors.red), + ); + setState(() => _isLoading = false); + } + return; + } + + final stationIdKey = _getStationIdKey(); + final stationId = _selectedStation![stationIdKey]; + + final apiService = Provider.of(context, listen: false); + + try { + final result = await apiService.river.getRiverSamplingImages( + stationId: stationId, + samplingDate: _selectedDate!, + samplingType: _selectedSamplingType, // "Investigative Sampling" + ); + + if (mounted && result['success'] == true) { + final List fetchedUrls = + List.from(result['data'] ?? []); + + setState(() { + _imageUrls = + fetchedUrls.toSet().toList(); // Use toSet to remove duplicates + }); + + debugPrint( + "[Image Request] Successfully received and processed ${_imageUrls.length} image URLs."); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message'] ?? 'Failed to fetch images.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('An error occurred: $e'))); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + } + + Future _showEmailDialog() async { + final emailController = TextEditingController(); + final dialogFormKey = GlobalKey(); + + return showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return StatefulBuilder( + builder: (context, setDialogState) { + bool isSending = false; + + return AlertDialog( + title: const Text('Send Images via Email'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSending) + const Padding( + padding: EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(width: 24), + Text("Sending...") + ], + ), + ) + else + Form( + key: dialogFormKey, + child: TextFormField( + controller: emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Recipient Email Address', + hintText: 'user@example.com'), + validator: (value) { + if (value == null || + value.isEmpty || + !RegExp(r'\S+@\S+\.\S+').hasMatch(value)) { + return 'Please enter a valid email address.'; + } + return null; + }, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: + isSending ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: isSending + ? null + : () async { + if (dialogFormKey.currentState!.validate()) { + setDialogState(() => isSending = true); + await _sendEmailRequestToServer( + emailController.text); + if (mounted) Navigator.of(context).pop(); + } + }, + child: const Text('Send'), + ), + ], + ); + }, + ); + }, + ); + } + + Future _sendEmailRequestToServer(String toEmail) async { + final apiService = Provider.of(context, listen: false); + + try { + final stationCode = _selectedStation?[_getStationCodeKey()] ?? 'N/A'; + final stationName = _selectedStation?[_getStationNameKey()] ?? 'N/A'; + final fullStationIdentifier = '$stationCode - $stationName'; + + final result = await apiService.river.sendImageRequestEmail( + recipientEmail: toEmail, + imageUrls: _selectedImageUrls.toList(), + stationName: fullStationIdentifier, + samplingDate: _dateController.text, + ); + + if (mounted) { + if (result['success'] == true) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Success! Email is being sent by the server.'), + backgroundColor: Colors.green), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: ${result['message']}'), + backgroundColor: Colors.red), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('An error occurred: $e'), + backgroundColor: Colors.red), + ); + } + } + } + + @override + Widget build(BuildContext context) { + // --- MODIFICATION: Wrap with Consumer --- + return Consumer( + builder: (context, auth, child) { + + // --- 1. Show loading screen if data isn't ready --- + if (auth.isBackgroundLoading) { + return Scaffold( + appBar: AppBar(title: const Text("River Investigative Image Request")), + body: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text("Loading station data..."), + ], + ), + ), + ); + } + + // --- 2. Initialize filters ONLY when data is ready --- + if (!auth.isBackgroundLoading && !_filtersInitialized) { + // Data is loaded, but our local lists are empty. Initialize them. + // Schedule this for after the build pass to avoid errors. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _initializeStationFilters(); + } + }); + } + + // --- 3. Build the actual UI --- + return Scaffold( + appBar: AppBar(title: const Text("River Investigative Image Request")), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(24.0), + children: [ + Text("Image Search Filters", + style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 24), + + TextFormField( + initialValue: _selectedSamplingType, // "Investigative Sampling" + readOnly: true, + decoration: InputDecoration( + labelText: 'Sampling Type *', + border: const OutlineInputBorder(), + filled: true, + fillColor: Theme.of(context).inputDecorationTheme.fillColor?.withOpacity(0.5), // Make it look disabled + ), + ), + const SizedBox(height: 16), + + // State Dropdown + DropdownSearch( + items: _statesList, // This list will be populated + selectedItem: _selectedStateName, + popupProps: const PopupProps.menu( + showSearchBox: true, + searchFieldProps: TextFieldProps( + decoration: InputDecoration(hintText: "Search State..."))), + dropdownDecoratorProps: const DropDownDecoratorProps( + dropdownSearchDecoration: InputDecoration( + labelText: "Select State *", + border: OutlineInputBorder())), + onChanged: (state) { + setState(() { + _selectedStateName = state; + _selectedBasinName = null; + _selectedStation = null; + final auth = Provider.of(context, listen: false); + final allStations = _getStationsForType(auth); + final basinKey = _getStationBasinKey(); + final basins = state != null + ? allStations + .where((s) => s['state_name'] == state) + .map((s) => s[basinKey] as String?) + .whereType() + .toSet() + .toList() + : []; + basins.sort(); + _basinsForState = basins; + _stationsForBasin = []; + }); + }, + validator: (val) => val == null ? "State is required" : null, + ), + const SizedBox(height: 16), + + // Basin Dropdown + DropdownSearch( + items: _basinsForState, + selectedItem: _selectedBasinName, + enabled: _selectedStateName != null, + popupProps: const PopupProps.menu( + showSearchBox: true, + searchFieldProps: TextFieldProps( + decoration: InputDecoration(hintText: "Search Basin..."))), + dropdownDecoratorProps: const DropDownDecoratorProps( + dropdownSearchDecoration: InputDecoration( + labelText: "Select Basin *", + border: OutlineInputBorder())), + onChanged: (basin) { + setState(() { + _selectedBasinName = basin; + _selectedStation = null; + final auth = Provider.of(context, listen: false); + final allStations = _getStationsForType(auth); + final basinKey = _getStationBasinKey(); + final stationCodeKey = _getStationCodeKey(); + _stationsForBasin = basin != null + ? (allStations + .where((s) => + s['state_name'] == _selectedStateName && + s[basinKey] == basin) + .toList() + ..sort((a, b) => (a[stationCodeKey] ?? '') + .compareTo(b[stationCodeKey] ?? ''))) + : []; + }); + }, + validator: (val) => + _selectedStateName != null && val == null + ? "Basin is required" + : null, + ), + const SizedBox(height: 16), + + // Station Dropdown + DropdownSearch>( + items: _stationsForBasin, + selectedItem: _selectedStation, + enabled: _selectedBasinName != null, + itemAsString: (station) { + final code = station[_getStationCodeKey()] ?? 'N/A'; + final name = station[_getStationNameKey()] ?? 'N/A'; + return "$code - $name"; + }, + popupProps: const PopupProps.menu( + showSearchBox: true, + searchFieldProps: TextFieldProps( + decoration: + InputDecoration(hintText: "Search Station..."))), + dropdownDecoratorProps: const DropDownDecoratorProps( + dropdownSearchDecoration: InputDecoration( + labelText: "Select Station *", + border: OutlineInputBorder())), + onChanged: (station) => + setState(() => _selectedStation = station), + validator: (val) => + _selectedBasinName != null && val == null + ? "Station is required" + : null, + ), + const SizedBox(height: 16), + + // Date Picker + TextFormField( + controller: _dateController, + readOnly: true, + decoration: InputDecoration( + labelText: 'Select Date *', + hintText: 'Tap to pick a date', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: const Icon(Icons.calendar_today), + onPressed: _selectDate), + ), + onTap: _selectDate, + validator: (val) => + val == null || val.isEmpty ? "Date is required" : null, + ), + const SizedBox(height: 32), + + // Search Button + ElevatedButton.icon( + icon: const Icon(Icons.search), + label: const Text('Search Images'), + onPressed: _isLoading ? null : _searchImages, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + textStyle: + const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 24), + const Divider(thickness: 1), + const SizedBox(height: 16), + Text("Results", style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 16), + _buildResults(), + + // Send Email Button + if (_selectedImageUrls.isNotEmpty) ...[ + const SizedBox(height: 24), + ElevatedButton.icon( + icon: const Icon(Icons.email_outlined), + label: + Text('Send (${_selectedImageUrls.length}) Selected Image(s)'), + onPressed: _showEmailDialog, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Theme.of(context).colorScheme.onSecondary, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ], + ], + ), + ), + ); + }, + ); + } + + Widget _buildResults() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_imageUrls.isEmpty) { + return const Center( + child: Text( + 'No images found. Please adjust your filters and search again.', + textAlign: TextAlign.center), + ); + } + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 1.0, + ), + itemCount: _imageUrls.length, + itemBuilder: (context, index) { + final imageUrl = _imageUrls[index]; + final isSelected = _selectedImageUrls.contains(imageUrl); + + return GestureDetector( + onTap: () { + setState(() { + if (isSelected) { + _selectedImageUrls.remove(imageUrl); + } else { + _selectedImageUrls.add(imageUrl); + } + }); + }, + child: Card( + clipBehavior: Clip.antiAlias, + elevation: 2.0, + child: GridTile( + child: Stack( + fit: StackFit.expand, + children: [ + Image.network( + imageUrl, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return const Center( + child: CircularProgressIndicator(strokeWidth: 2)); + }, + errorBuilder: (context, error, stackTrace) { + return const Icon(Icons.broken_image, + color: Colors.grey, size: 40); + }, + ), + if (isSelected) + Container( + color: Colors.black.withOpacity(0.6), + child: const Icon(Icons.check_circle, + color: Colors.white, size: 40), + ), + ], + ), + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/screens/river/manual/river_manual_data_status_log.dart b/lib/screens/river/manual/river_manual_data_status_log.dart index a6cf00c..6e72c06 100644 --- a/lib/screens/river/manual/river_manual_data_status_log.dart +++ b/lib/screens/river/manual/river_manual_data_status_log.dart @@ -6,11 +6,21 @@ import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:environment_monitoring_app/auth_provider.dart'; import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.dart'; +import 'package:environment_monitoring_app/models/river_manual_triennial_sampling_data.dart'; +import 'package:environment_monitoring_app/models/river_inves_manual_sampling_data.dart'; import 'package:environment_monitoring_app/services/local_storage_service.dart'; -import 'package:environment_monitoring_app/services/api_service.dart'; import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart'; +import 'package:environment_monitoring_app/services/river_manual_triennial_sampling_service.dart'; +import 'package:environment_monitoring_app/services/river_investigative_sampling_service.dart'; import 'dart:convert'; +/// A simple class to hold an image file and its associated remark. +class ImageLogEntry { + final File file; + final String? remark; + ImageLogEntry({required this.file, this.remark}); +} + class SubmissionLogEntry { final String type; final String title; @@ -50,75 +60,119 @@ class RiverManualDataStatusLog extends StatefulWidget { class _RiverManualDataStatusLogState extends State { final LocalStorageService _localStorageService = LocalStorageService(); - late ApiService _apiService; - late RiverInSituSamplingService _riverInSituService; - List _allLogs = []; - List _filteredLogs = []; - final TextEditingController _searchController = TextEditingController(); + late RiverInSituSamplingService _riverInSituService; + late RiverManualTriennialSamplingService _riverTriennialService; + late RiverInvestigativeSamplingService _riverInvestigativeService; + + List _inSituLogs = []; + List _triennialLogs = []; + List _investigativeLogs = []; + List _filteredInSituLogs = []; + List _filteredTriennialLogs = []; + List _filteredInvestigativeLogs = []; + + final TextEditingController _inSituSearchController = TextEditingController(); + final TextEditingController _triennialSearchController = TextEditingController(); + final TextEditingController _investigativeSearchController = TextEditingController(); + bool _isLoading = true; final Map _isResubmitting = {}; @override void initState() { super.initState(); - _apiService = Provider.of(context, listen: false); - _riverInSituService = Provider.of(context, listen: false); - _searchController.addListener(_filterLogs); + _inSituSearchController.addListener(_filterLogs); + _triennialSearchController.addListener(_filterLogs); + _investigativeSearchController.addListener(_filterLogs); _loadAllLogs(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _riverInSituService = Provider.of(context); + _riverTriennialService = Provider.of(context); + _riverInvestigativeService = Provider.of(context); + } + @override void dispose() { - _searchController.dispose(); + _inSituSearchController.dispose(); + _triennialSearchController.dispose(); + _investigativeSearchController.dispose(); super.dispose(); } Future _loadAllLogs() async { setState(() => _isLoading = true); - final riverLogs = await _localStorageService.getAllRiverInSituLogs(); - final List tempLogs = []; + final inSituLogs = await _localStorageService.getAllRiverInSituLogs(); + final triennialLogs = await _localStorageService.getAllRiverManualTriennialLogs(); + final investigativeLogs = await _localStorageService.getAllRiverInvestigativeLogs(); - for (var log in riverLogs) { - final entry = _createLogEntry(log); + final List tempInSitu = []; + final List tempTriennial = []; + final List tempInvestigative = []; + + for (var log in inSituLogs) { + final entry = _createInSituLogEntry(log); if (entry != null) { - tempLogs.add(entry); + tempInSitu.add(entry); } } - tempLogs.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + for (var log in triennialLogs) { + final entry = _createTriennialLogEntry(log); + if (entry != null) { + tempTriennial.add(entry); + } + } + + for (var log in investigativeLogs) { + final entry = _createInvestigativeLogEntry(log); + if (entry != null) { + tempInvestigative.add(entry); + } + } + + tempInSitu.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + tempTriennial.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + tempInvestigative.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + if (mounted) { setState(() { - _allLogs = tempLogs; + _inSituLogs = tempInSitu; + _triennialLogs = tempTriennial; + _investigativeLogs = tempInvestigative; _isLoading = false; }); _filterLogs(); } } - SubmissionLogEntry? _createLogEntry(Map log) { + SubmissionLogEntry? _createInSituLogEntry(Map log) { final String type = log['samplingType'] ?? 'In-Situ Sampling'; final String title = log['selectedStation']?['sampling_river'] ?? 'Unknown River'; final String stationCode = log['selectedStation']?['sampling_station_code'] ?? 'N/A'; - DateTime submissionDateTime = DateTime.now(); + final String? dateStr = log['samplingDate'] ?? log['r_man_date']; final String? timeStr = log['samplingTime'] ?? log['r_man_time']; + DateTime submissionDateTime = DateTime.fromMillisecondsSinceEpoch(0); // Default to invalid try { if (dateStr != null && timeStr != null && dateStr.isNotEmpty && timeStr.isNotEmpty) { final String fullDateString = '$dateStr ${timeStr.length == 5 ? "$timeStr:00" : timeStr}'; - submissionDateTime = DateTime.tryParse(fullDateString) ?? DateTime.now(); + submissionDateTime = DateTime.tryParse(fullDateString) ?? DateTime.fromMillisecondsSinceEpoch(0); } } catch (_) { - submissionDateTime = DateTime.now(); + // Keep default invalid date } 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']); @@ -139,10 +193,102 @@ class _RiverManualDataStatusLogState extends State { ); } + SubmissionLogEntry? _createTriennialLogEntry(Map log) { + final String type = log['samplingType'] ?? 'Triennial'; + final String title = log['selectedStation']?['sampling_river'] ?? 'Unknown River'; + final String stationCode = log['selectedStation']?['sampling_station_code'] ?? 'N/A'; + + final String? dateStr = log['samplingDate'] ?? log['r_tri_date']; + final String? timeStr = log['samplingTime'] ?? log['r_tri_time']; + + DateTime submissionDateTime = DateTime.fromMillisecondsSinceEpoch(0); // Default to invalid + try { + if (dateStr != null && timeStr != null && dateStr.isNotEmpty && timeStr.isNotEmpty) { + final String fullDateString = '$dateStr ${timeStr.length == 5 ? "$timeStr:00" : timeStr}'; + submissionDateTime = DateTime.tryParse(fullDateString) ?? DateTime.fromMillisecondsSinceEpoch(0); + } + } catch (_) { + // Keep default invalid date + } + + 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, + ); + } + + SubmissionLogEntry? _createInvestigativeLogEntry(Map log) { + // Use the data model to correctly determine station name/code + final data = RiverInvesManualSamplingData.fromJson(log); + + final String type = data.samplingType ?? 'Investigative'; + final String title = data.getDeterminedStationName() ?? 'Unknown River'; + final String stationCode = data.getDeterminedStationCode() ?? 'N/A'; + + final String? dateStr = data.samplingDate; + final String? timeStr = data.samplingTime; + + DateTime submissionDateTime = DateTime.fromMillisecondsSinceEpoch(0); // Default to invalid + try { + if (dateStr != null && timeStr != null && dateStr.isNotEmpty && timeStr.isNotEmpty) { + final String fullDateString = '$dateStr ${timeStr.length == 5 ? "$timeStr:00" : timeStr}'; + submissionDateTime = DateTime.tryParse(fullDateString) ?? DateTime.fromMillisecondsSinceEpoch(0); + } + } catch (_) { + // Keep default invalid date + } + + 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: data.reportId, + status: data.submissionStatus ?? 'L1', + message: data.submissionMessage ?? 'No status message.', + rawData: log, // Store the original raw map + serverName: log['serverConfigName'] ?? 'Unknown Server', + apiStatusRaw: apiStatusRaw, + ftpStatusRaw: ftpStatusRaw, + ); + } + void _filterLogs() { - final query = _searchController.text.toLowerCase(); + final inSituQuery = _inSituSearchController.text.toLowerCase(); + final triennialQuery = _triennialSearchController.text.toLowerCase(); + final investigativeQuery = _investigativeSearchController.text.toLowerCase(); + setState(() { - _filteredLogs = _allLogs.where((log) => _logMatchesQuery(log, query)).toList(); + _filteredInSituLogs = _inSituLogs.where((log) => _logMatchesQuery(log, inSituQuery)).toList(); + _filteredTriennialLogs = _triennialLogs.where((log) => _logMatchesQuery(log, triennialQuery)).toList(); + _filteredInvestigativeLogs = _investigativeLogs.where((log) => _logMatchesQuery(log, investigativeQuery)).toList(); }); } @@ -151,7 +297,6 @@ class _RiverManualDataStatusLogState extends State { return log.title.toLowerCase().contains(query) || log.stationCode.toLowerCase().contains(query) || log.serverName.toLowerCase().contains(query) || - log.type.toLowerCase().contains(query) || (log.reportId?.toLowerCase() ?? '').contains(query); } @@ -166,15 +311,36 @@ class _RiverManualDataStatusLogState extends State { try { final authProvider = Provider.of(context, listen: false); final appSettings = authProvider.appSettings; + Map result = {}; - final dataToResubmit = RiverInSituSamplingData.fromJson(log.rawData); + if (log.type == 'In-Situ Sampling' || log.type == 'Schedule') { + final dataToResubmit = RiverInSituSamplingData.fromJson(log.rawData); - final result = await _riverInSituService.submitData( - data: dataToResubmit, - appSettings: appSettings, - authProvider: authProvider, - logDirectory: log.rawData['logDirectory'], // Pass the log directory for updating - ); + result = await _riverInSituService.submitData( + data: dataToResubmit, + appSettings: appSettings, + authProvider: authProvider, + logDirectory: log.rawData['logDirectory'], + ); + } else if (log.type == 'Triennial') { + final dataToResubmit = RiverManualTriennialSamplingData.fromJson(log.rawData); + + result = await _riverTriennialService.submitData( + data: dataToResubmit, + appSettings: appSettings, + authProvider: authProvider, + logDirectory: log.rawData['logDirectory'], + ); + } else if (log.type == 'Investigative') { + final dataToResubmit = RiverInvesManualSamplingData.fromJson(log.rawData); + + result = await _riverInvestigativeService.submitData( + data: dataToResubmit, + appSettings: appSettings, + authProvider: authProvider, + logDirectory: log.rawData['logDirectory'], + ); + } if (mounted) { final message = result['message'] ?? 'Resubmission process completed.'; @@ -182,7 +348,7 @@ class _RiverManualDataStatusLogState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), - backgroundColor: isSuccess ? Colors.green : Colors.orange, + backgroundColor: isSuccess ? Colors.green : (result['status'] == 'L1' ? Colors.red : Colors.orange), ), ); } @@ -204,16 +370,7 @@ class _RiverManualDataStatusLogState extends State { @override Widget build(BuildContext context) { - final hasAnyLogs = _allLogs.isNotEmpty; - final hasFilteredLogs = _filteredLogs.isNotEmpty; - - final Map> groupedLogs = {}; - for (var log in _filteredLogs) { - if (!groupedLogs.containsKey(log.type)) { - groupedLogs[log.type] = []; - } - groupedLogs[log.type]!.add(log); - } + final hasAnyLogs = _inSituLogs.isNotEmpty || _triennialLogs.isNotEmpty || _investigativeLogs.isNotEmpty; return Scaffold( appBar: AppBar(title: const Text('River Manual Data Status Log')), @@ -226,42 +383,28 @@ class _RiverManualDataStatusLogState extends State { : ListView( padding: const EdgeInsets.all(8.0), children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Search river, station code, or server name...', - prefixIcon: const Icon(Icons.search, size: 20), - isDense: true, - border: const OutlineInputBorder(), - suffixIcon: IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - _filterLogs(); - }, - ), - ), - ), + _buildCategorySection( + 'In-Situ Sampling', + _filteredInSituLogs, + _inSituSearchController + ), + _buildCategorySection( + 'Triennial Sampling', + _filteredTriennialLogs, + _triennialSearchController + ), + _buildCategorySection( + 'Investigative Sampling', + _filteredInvestigativeLogs, + _investigativeSearchController ), - const Divider(), - if (!hasFilteredLogs && hasAnyLogs && _searchController.text.isNotEmpty) - const Center( - child: Padding( - padding: EdgeInsets.all(24.0), - child: Text('No logs match your search.'), - ), - ) - else - ...groupedLogs.entries.map((entry) => _buildCategorySection(entry.key, entry.value)), ], ), ), ); } - Widget _buildCategorySection(String category, List logs) { + Widget _buildCategorySection(String category, List logs, TextEditingController searchController) { return Card( margin: const EdgeInsets.symmetric(vertical: 8.0), child: Padding( @@ -270,15 +413,42 @@ class _RiverManualDataStatusLogState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), - const Divider(), - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: logs.length, - itemBuilder: (context, index) { - return _buildLogListItem(logs[index]); - }, + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: TextField( + controller: searchController, + decoration: InputDecoration( + hintText: 'Search in $category...', + prefixIcon: const Icon(Icons.search, size: 20), + isDense: true, + border: const OutlineInputBorder(), + suffixIcon: searchController.text.isNotEmpty ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + searchController.clear(); + }, + ) : null, + ), + ), ), + const Divider(), + if (logs.isEmpty) + Padding( + padding: const EdgeInsets.all(16.0), + child: Center(child: Text( + searchController.text.isEmpty + ? 'No logs found in this category.' + : 'No logs match your search in this category.' + ))) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: logs.length, + itemBuilder: (context, index) { + return _buildLogListItem(logs[index]); + }, + ), ], ), ), @@ -286,7 +456,24 @@ class _RiverManualDataStatusLogState extends State { } Widget _buildLogListItem(SubmissionLogEntry log) { - final isFailed = !log.status.startsWith('S') && !log.status.startsWith('L4'); + final bool isFullSuccess = log.status == 'S4'; + final bool isPartialSuccess = log.status == 'S3' || log.status == 'L4'; + final bool canResubmit = !isFullSuccess; + + IconData statusIcon; + Color statusColor; + + if (isFullSuccess) { + statusIcon = Icons.check_circle_outline; + statusColor = Colors.green; + } else if (isPartialSuccess) { + statusIcon = Icons.warning_amber_rounded; + statusColor = Colors.orange; + } else { + statusIcon = Icons.error_outline; + statusColor = Colors.red; + } + final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); final isResubmitting = _isResubmitting[logKey] ?? false; @@ -302,41 +489,398 @@ class _RiverManualDataStatusLogState extends State { ], ), ); - 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), - ], - ), - ) - ], + final bool isDateValid = !log.submissionDateTime.isAtSameMomentAs(DateTime.fromMillisecondsSinceEpoch(0)); + final subtitle = isDateValid + ? '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}' + : '${log.serverName} - Invalid Date'; + + return ExpansionTile( + key: PageStorageKey(logKey), + leading: Icon(statusIcon, color: statusColor), + title: titleWidget, + subtitle: Text(subtitle), + trailing: canResubmit + ? (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), + + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton.icon( + icon: Icon(Icons.list_alt, color: Theme.of(context).colorScheme.primary), + label: Text('View Data', style: TextStyle(color: Theme.of(context).colorScheme.primary)), + onPressed: () => _showDataDialog(context, log), + ), + TextButton.icon( + icon: Icon(Icons.photo_library_outlined, color: Theme.of(context).colorScheme.secondary), + label: Text('View Images', style: TextStyle(color: Theme.of(context).colorScheme.secondary)), + onPressed: () => _showImageDialog(context, log), + ), + ], + ), + ), + + const Divider(height: 10), + _buildGranularStatus('API', log.apiStatusRaw), + _buildGranularStatus('FTP', log.ftpStatusRaw), + ], + ), + ) + ], + ); + } + + /// Builds a formatted category header row for the data table. + TableRow _buildCategoryRow(BuildContext context, String title, IconData icon) { + return TableRow( + decoration: BoxDecoration( + color: Colors.grey.shade100, ), + children: [ + Padding( + padding: const EdgeInsets.only(top: 16.0, bottom: 8.0, left: 8.0, right: 8.0), + child: Row( + children: [ + Icon(icon, size: 20, color: Theme.of(context).primaryColor), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + ), + const SizedBox.shrink(), // Empty cell for the second column + ], + ); + } + + /// Builds a formatted row for the data dialog, gracefully handling null/empty values. + TableRow _buildDataTableRow(String label, String? value) { + String displayValue = (value == null || value.isEmpty || value == 'null') ? 'N/A' : value; + + // Format special "missing" values + if (displayValue == '-999.0' || displayValue == '-999') { + displayValue = 'N/A'; + } + + return TableRow( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + child: Text(displayValue), // Use Text, NOT SelectableText + ), + ], + ); + } + + /// Formats a camelCase or snake_case key into a readable label. + String _formatLabel(String key) { + if (key.isEmpty) return ''; + + // Specific overrides for known keys + const keyOverrides = { + 'sondeId': 'Sonde ID', + 'ph': 'pH', + 'tds': 'TDS', + 'selectedStation': 'Station Details', + 'secondSampler': '2nd Sampler Details', + }; + + if (keyOverrides.containsKey(key)) { + return keyOverrides[key]!; + } + + // Handle snake_case (e.g., r_man_date) + key = key.replaceAllMapped(RegExp(r'_(.)'), (match) => ' ${match.group(1)!.toUpperCase()}'); + // Handle camelCase (e.g., firstSamplerName) + key = key.replaceAllMapped(RegExp(r'([A-Z])'), (match) => ' ${match.group(1)}'); + + // Remove common prefixes + key = key.replaceAll('r man ', '').replaceAll('r tri ', '').replaceAll('r inv ', ''); + key = key.replaceAll('selected ', ''); + + // Capitalize first letter and trim + key = key.trim(); + key = key.substring(0, 1).toUpperCase() + key.substring(1); + + return key; + } + + /// Helper to safely get a string value from the raw data map. + String? _getString(Map data, String key) { + final value = data[key]; + if (value == null) return null; + if (value is double && value == -999.0) return 'N/A'; + return value.toString(); + } + + /// Shows the categorized and formatted data log in a dialog + void _showDataDialog(BuildContext context, SubmissionLogEntry log) { + final Map data = log.rawData; + final List tableRows = []; + + // --- 1. Sampling Info --- + tableRows.add(_buildCategoryRow(context, 'Sampling Info', Icons.calendar_today)); + tableRows.add(_buildDataTableRow('Date', _getString(data, 'samplingDate'))); + tableRows.add(_buildDataTableRow('Time', _getString(data, 'samplingTime'))); + tableRows.add(_buildDataTableRow('1st Sampler', _getString(data, 'firstSamplerName'))); + + String? secondSamplerName; + if (data['secondSampler'] is Map) { + secondSamplerName = (data['secondSampler'] as Map)['first_name']?.toString(); + } + tableRows.add(_buildDataTableRow('2nd Sampler', secondSamplerName)); + tableRows.add(_buildDataTableRow('Sample ID', _getString(data, 'sampleIdCode'))); + + // --- 2. Station & Location --- + tableRows.add(_buildCategoryRow(context, 'Station & Location', Icons.location_on_outlined)); + + if (log.type == 'Investigative') { + tableRows.add(_buildDataTableRow('Station Type', _getString(data, 'stationTypeSelection'))); + if (data['stationTypeSelection'] == 'New Location') { + tableRows.add(_buildDataTableRow('New State', _getString(data, 'newStateName'))); + tableRows.add(_buildDataTableRow('New Basin', _getString(data, 'newBasinName'))); + tableRows.add(_buildDataTableRow('New River', _getString(data, 'newRiverName'))); + tableRows.add(_buildDataTableRow('New Station Name', _getString(data, 'newStationName'))); + tableRows.add(_buildDataTableRow('New Station Code', _getString(data, 'newStationCode'))); + tableRows.add(_buildDataTableRow('Station Latitude', _getString(data, 'stationLatitude'))); + tableRows.add(_buildDataTableRow('Station Longitude', _getString(data, 'stationLongitude'))); + } else { + // Show existing station info if it's not a new location + tableRows.add(_buildDataTableRow('Station', '${log.stationCode} - ${log.title}')); + } + } else { + // For In-Situ and Triennial + tableRows.add(_buildDataTableRow('Station', '${log.stationCode} - ${log.title}')); + } + + tableRows.add(_buildDataTableRow('Current Latitude', _getString(data, 'currentLatitude'))); + tableRows.add(_buildDataTableRow('Current Longitude', _getString(data, 'currentLongitude'))); + tableRows.add(_buildDataTableRow('Distance (km)', _getString(data, 'distanceDifferenceInKm'))); + tableRows.add(_buildDataTableRow('Distance Remarks', _getString(data, 'distanceDifferenceRemarks'))); + + // --- 3. Site Conditions --- + tableRows.add(_buildCategoryRow(context, 'Site Conditions', Icons.wb_sunny_outlined)); + tableRows.add(_buildDataTableRow('Weather', _getString(data, 'weather'))); + tableRows.add(_buildDataTableRow('Event Remarks', _getString(data, 'eventRemarks'))); + tableRows.add(_buildDataTableRow('Lab Remarks', _getString(data, 'labRemarks'))); + + // --- 4. Parameters --- + tableRows.add(_buildCategoryRow(context, 'Parameters', Icons.bar_chart)); + tableRows.add(_buildDataTableRow('Sonde ID', _getString(data, 'sondeId'))); + tableRows.add(_buildDataTableRow('Capture Date', _getString(data, 'dataCaptureDate'))); + tableRows.add(_buildDataTableRow('Capture Time', _getString(data, 'dataCaptureTime'))); + tableRows.add(_buildDataTableRow('Oxygen Conc (mg/L)', _getString(data, 'oxygenConcentration'))); + tableRows.add(_buildDataTableRow('Oxygen Sat (%)', _getString(data, 'oxygenSaturation'))); + tableRows.add(_buildDataTableRow('pH', _getString(data, 'ph'))); + tableRows.add(_buildDataTableRow('Salinity (ppt)', _getString(data, 'salinity'))); + tableRows.add(_buildDataTableRow('Conductivity (ยตS/cm)', _getString(data, 'electricalConductivity'))); + tableRows.add(_buildDataTableRow('Temperature (ยฐC)', _getString(data, 'temperature'))); + tableRows.add(_buildDataTableRow('TDS (mg/L)', _getString(data, 'tds'))); + tableRows.add(_buildDataTableRow('Turbidity (NTU)', _getString(data, 'turbidity'))); + tableRows.add(_buildDataTableRow('Ammonia (mg/L)', _getString(data, 'ammonia'))); + tableRows.add(_buildDataTableRow('Battery (V)', _getString(data, 'batteryVoltage'))); + + // --- 5. Flowrate --- + if (data['flowrateMethod'] != null || data['flowrateValue'] != null) { + tableRows.add(_buildCategoryRow(context, 'Flowrate', Icons.waves_outlined)); + tableRows.add(_buildDataTableRow('Method', _getString(data, 'flowrateMethod'))); + tableRows.add(_buildDataTableRow('Flowrate (m/s)', _getString(data, 'flowrateValue'))); + if (data['flowrateMethod'] == 'Surface Drifter') { + tableRows.add(_buildDataTableRow(' Height (m)', _getString(data, 'flowrateSurfaceDrifterHeight'))); + tableRows.add(_buildDataTableRow(' Distance (m)', _getString(data, 'flowrateSurfaceDrifterDistance'))); + tableRows.add(_buildDataTableRow(' Time First', _getString(data, 'flowrateSurfaceDrifterTimeFirst'))); + tableRows.add(_buildDataTableRow(' Time Last', _getString(data, 'flowrateSurfaceDrifterTimeLast'))); + } + } + + showDialog( + context: context, + builder: (context) { + return AlertDialog( + // --- MODIFIED: Use Station Code + Name for title --- + title: Text('${log.stationCode} - ${log.title}'), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: Table( + columnWidths: const { + 0: IntrinsicColumnWidth(), + 1: FlexColumnWidth(), + }, + border: TableBorder( + horizontalInside: BorderSide( + color: Colors.grey.shade300, + width: 0.5, + ), + ), + children: tableRows, + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); + } + + void _showImageDialog(BuildContext context, SubmissionLogEntry log) { + // These are the standard keys used in toMap() for all river models + // We map the image key to its corresponding remark key. + const imageRemarkMap = { + 'backgroundStationImage': null, // No remark for this + 'upstreamRiverImage': null, // No remark for this + 'downstreamRiverImage': null, // No remark for this + 'sampleTurbidityImage': null, // No remark for this + 'optionalImage1': 'optionalRemark1', + 'optionalImage2': 'optionalRemark2', + 'optionalImage3': 'optionalRemark3', + 'optionalImage4': 'optionalRemark4', + }; + + final List imageEntries = []; + for (final entry in imageRemarkMap.entries) { + final imageKey = entry.key; + final remarkKey = entry.value; + + final path = log.rawData[imageKey]; + if (path != null && path is String && path.isNotEmpty) { + final file = File(path); + if (file.existsSync()) { + // Now, find the remark + final remark = (remarkKey != null ? log.rawData[remarkKey] as String? : null) + // Also check for API-style remark keys just in case + ?? log.rawData['${imageKey}Remark'] as String? + ?? log.rawData['${imageKey}_remarks'] as String?; + + imageEntries.add(ImageLogEntry(file: file, remark: remark)); + } + } + } + + + if (imageEntries.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No images are attached to this log.'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + showDialog( + context: context, + builder: (context) { + return AlertDialog( + // --- START: MODIFIED TITLE --- + title: Text('Images for ${log.stationCode} - ${log.title}'), + // --- END: MODIFIED TITLE --- + content: SizedBox( + width: double.maxFinite, + child: GridView.builder( + shrinkWrap: true, + itemCount: imageEntries.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + final imageEntry = imageEntries[index]; + final bool hasRemark = imageEntry.remark != null && imageEntry.remark!.isNotEmpty; + + return Card( + clipBehavior: Clip.antiAlias, + elevation: 2, + child: Stack( + fit: StackFit.expand, + children: [ + Image.file( + imageEntry.file, + fit: BoxFit.cover, + errorBuilder: (context, error, stack) { + return const Center( + child: Icon( + Icons.broken_image, + color: Colors.grey, + size: 40, + ), + ); + }, + ), + if (hasRemark) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(6.0), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withOpacity(0.8), + Colors.black.withOpacity(0.0) + ], + ), + ), + child: Text( + imageEntry.remark!, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + }, ); } @@ -394,7 +938,7 @@ class _RiverManualDataStatusLogState extends State { children: [ Expanded(flex: 2, child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold))), const SizedBox(width: 8), - Expanded(flex: 3, child: Text(value)), + Expanded(flex: 3, child: Text(value)), // <-- This is the fixed line ], ), ); diff --git a/lib/screens/river/manual/river_manual_image_request.dart b/lib/screens/river/manual/river_manual_image_request.dart index 88ffc43..51c229f 100644 --- a/lib/screens/river/manual/river_manual_image_request.dart +++ b/lib/screens/river/manual/river_manual_image_request.dart @@ -9,6 +9,7 @@ import 'dart:convert'; import '../../../auth_provider.dart'; import '../../../services/api_service.dart'; + class RiverManualImageRequest extends StatelessWidget { const RiverManualImageRequest({super.key}); @@ -30,7 +31,10 @@ class _RiverImageRequestScreenState extends State { final _formKey = GlobalKey(); final _dateController = TextEditingController(); - final String _selectedSamplingType = 'In-Situ Sampling'; + // --- START: MODIFICATION --- + String? _selectedSamplingType = 'In-Situ Sampling'; // Default to one + final List _samplingTypes = ['In-Situ Sampling', 'Triennial Sampling', 'Investigative Sampling']; + // --- END: MODIFICATION --- String? _selectedStateName; String? _selectedBasinName; @@ -48,7 +52,12 @@ class _RiverImageRequestScreenState extends State { @override void initState() { super.initState(); - _initializeStationFilters(); + // Use addPostFrameCallback to ensure provider is ready + WidgetsBinding.instance.addPostFrameCallback((_) { + if(mounted) { + _initializeStationFilters(); + } + }); } @override @@ -57,14 +66,81 @@ class _RiverImageRequestScreenState extends State { super.dispose(); } + // --- START: ADDED HELPER FUNCTIONS (from marine_image_request pattern) --- + + /// Gets the correct list of stations from AuthProvider based on sampling type. + List> _getStationsForType(AuthProvider auth) { + switch (_selectedSamplingType) { + case 'In-Situ Sampling': + return auth.riverManualStations ?? []; + case 'Triennial Sampling': + return auth.riverTriennialStations ?? []; + // --- START: MODIFICATION --- + case 'Investigative Sampling': + // Assumes riverInvestigativeStations is loaded in AuthProvider + return auth.riverInvestigativeStations ?? []; + // --- END: MODIFICATION --- + default: + return []; + } + } + + /// Gets the key for the station's unique ID (for API calls). + String _getStationIdKey() { + // Both In-Situ and Triennial stations use 'station_id' in the DB + // Assuming Investigative stations list also uses 'station_id' + return 'station_id'; + } + + /// Gets the key for the station's human-readable code. + String _getStationCodeKey() { + // Both In-Situ and Triennial station maps use 'sampling_station_code' + // Assuming Investigative stations list also uses 'sampling_station_code' + return 'sampling_station_code'; + } + + /// Gets the key for the station's name (river name). + String _getStationNameKey() { + // Both In-Situ and Triennial station maps use 'sampling_river' + // Assuming Investigative stations list also uses 'sampling_river' + return 'sampling_river'; + } + + /// Gets the key for the station's basin. + String _getStationBasinKey() { + // Both In-Situ and Triennial station maps use 'sampling_basin' + // Assuming Investigative stations list also uses 'sampling_basin' + return 'sampling_basin'; + } + + // --- END: ADDED HELPER FUNCTIONS --- + void _initializeStationFilters() { final auth = Provider.of(context, listen: false); - final allStations = auth.riverManualStations ?? []; + // --- MODIFIED: Use helper to get dynamic station list --- + final allStations = _getStationsForType(auth); + if (allStations.isNotEmpty) { final states = allStations.map((s) => s['state_name'] as String?).whereType().toSet().toList(); states.sort(); setState(() { _statesList = states; + // Reset dependent fields on change + _selectedStateName = null; + _selectedBasinName = null; + _selectedStation = null; + _basinsForState = []; + _stationsForBasin = []; + }); + } else { + // Handle empty list + setState(() { + _statesList = []; + _selectedStateName = null; + _selectedBasinName = null; + _selectedStation = null; + _basinsForState = []; + _stationsForBasin = []; }); } } @@ -93,32 +169,38 @@ class _RiverImageRequestScreenState extends State { _selectedImageUrls.clear(); }); - if (_selectedStation == null || _selectedDate == null) { + // --- MODIFIED: Validate all required fields --- + if (_selectedStation == null || _selectedDate == null || _selectedSamplingType == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Error: Station and date are required.'), backgroundColor: Colors.red), + const SnackBar(content: Text('Error: Type, Station and date are required.'), backgroundColor: Colors.red), ); setState(() => _isLoading = false); } return; } - final stationId = _selectedStation!['station_id']; + // --- MODIFIED: Use dynamic keys --- + final stationIdKey = _getStationIdKey(); + final stationId = _selectedStation![stationIdKey]; + // --- END: MODIFIED --- + final apiService = Provider.of(context, listen: false); try { final result = await apiService.river.getRiverSamplingImages( stationId: stationId, samplingDate: _selectedDate!, - samplingType: _selectedSamplingType, + samplingType: _selectedSamplingType!, // <-- Pass dynamic type ); if (mounted && result['success'] == true) { - // The backend now returns a direct list of full URLs, so we can use it directly. + // The backend returns a direct list of full URLs, which is great. + // No need for frontend key-matching like in the marine file. final List fetchedUrls = List.from(result['data'] ?? []); setState(() { - _imageUrls = fetchedUrls; + _imageUrls = fetchedUrls.toSet().toList(); // Use toSet to remove duplicates }); debugPrint("[Image Request] Successfully received and processed ${_imageUrls.length} image URLs."); @@ -209,8 +291,10 @@ class _RiverImageRequestScreenState extends State { final apiService = Provider.of(context, listen: false); try { - final stationCode = _selectedStation?['sampling_station_code'] ?? 'N/A'; - final stationName = _selectedStation?['sampling_river'] ?? 'N/A'; + // --- MODIFIED: Use dynamic keys --- + final stationCode = _selectedStation?[_getStationCodeKey()] ?? 'N/A'; + final stationName = _selectedStation?[_getStationNameKey()] ?? 'N/A'; + // --- END: MODIFIED --- final fullStationIdentifier = '$stationCode - $stationName'; final result = await apiService.river.sendImageRequestEmail( @@ -252,6 +336,22 @@ class _RiverImageRequestScreenState extends State { Text("Image Search Filters", style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 24), + // --- START: MODIFIED --- + // Sampling Type Dropdown + DropdownButtonFormField( + value: _selectedSamplingType, + items: _samplingTypes.map((type) => DropdownMenuItem(value: type, child: Text(type))).toList(), + onChanged: (value) => setState(() { + _selectedSamplingType = value; + // Re-initialize filters when type changes + _initializeStationFilters(); + }), + decoration: const InputDecoration(labelText: 'Sampling Type *', border: OutlineInputBorder()), + validator: (value) => value == null ? 'Please select a type' : null, + ), + // --- END: MODIFIED --- + const SizedBox(height: 16), + // State Dropdown DropdownSearch( items: _statesList, @@ -264,8 +364,11 @@ class _RiverImageRequestScreenState extends State { _selectedBasinName = null; _selectedStation = null; final auth = Provider.of(context, listen: false); - final allStations = auth.riverManualStations ?? []; - final basins = state != null ? allStations.where((s) => s['state_name'] == state).map((s) => s['sampling_basin'] as String?).whereType().toSet().toList() : []; + // --- MODIFIED: Use dynamic helpers --- + final allStations = _getStationsForType(auth); + final basinKey = _getStationBasinKey(); + final basins = state != null ? allStations.where((s) => s['state_name'] == state).map((s) => s[basinKey] as String?).whereType().toSet().toList() : []; + // --- END: MODIFIED --- basins.sort(); _basinsForState = basins; _stationsForBasin = []; @@ -287,8 +390,12 @@ class _RiverImageRequestScreenState extends State { _selectedBasinName = basin; _selectedStation = null; final auth = Provider.of(context, listen: false); - final allStations = auth.riverManualStations ?? []; - _stationsForBasin = basin != null ? (allStations.where((s) => s['state_name'] == _selectedStateName && s['sampling_basin'] == basin).toList()..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? ''))) : []; + // --- MODIFIED: Use dynamic helpers --- + final allStations = _getStationsForType(auth); + final basinKey = _getStationBasinKey(); + final stationCodeKey = _getStationCodeKey(); + _stationsForBasin = basin != null ? (allStations.where((s) => s['state_name'] == _selectedStateName && s[basinKey] == basin).toList()..sort((a, b) => (a[stationCodeKey] ?? '').compareTo(b[stationCodeKey] ?? ''))) : []; + // --- END: MODIFIED --- }); }, validator: (val) => _selectedStateName != null && val == null ? "Basin is required" : null, @@ -300,7 +407,13 @@ class _RiverImageRequestScreenState extends State { items: _stationsForBasin, selectedItem: _selectedStation, enabled: _selectedBasinName != null, - itemAsString: (station) => "${station['sampling_station_code']} - ${station['sampling_river']}", + // --- MODIFIED: Use dynamic helpers --- + itemAsString: (station) { + final code = station[_getStationCodeKey()] ?? 'N/A'; + final name = station[_getStationNameKey()] ?? 'N/A'; + return "$code - $name"; + }, + // --- END: MODIFIED --- popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Station..."))), dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Station *", border: OutlineInputBorder())), onChanged: (station) => setState(() => _selectedStation = station), diff --git a/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_3_data_capture.dart b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_3_data_capture.dart index d262614..ff5e92c 100644 --- a/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_3_data_capture.dart +++ b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_3_data_capture.dart @@ -9,7 +9,8 @@ import 'package:intl/intl.dart'; import '../../../../../auth_provider.dart'; import '../../../../../models/river_manual_triennial_sampling_data.dart'; -import '../../../../../services/api_service.dart'; // Import to access DatabaseHelper +//import '../../../../../services/api_service.dart'; // Import to access DatabaseHelper +import 'package:environment_monitoring_app/services/database_helper.dart'; import '../../../../../services/river_in_situ_sampling_service.dart'; import '../../../../../bluetooth/bluetooth_manager.dart'; import '../../../../../serial/serial_manager.dart'; diff --git a/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_4_additional_info.dart b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_4_additional_info.dart index 2c89364..0aacde3 100644 --- a/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_4_additional_info.dart +++ b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_4_additional_info.dart @@ -70,7 +70,10 @@ class _RiverManualTriennialStep4AdditionalInfoState if (file != null) { setState(() => setImageCallback(file)); } else if (mounted) { - _showSnackBar('Image selection failed. Please ensure all photos are taken in landscape mode.', isError: true); + // โœ… CHANGE: Reverted. All photos (required and optional) must be landscape. + _showSnackBar( + 'Image selection failed. Please ensure all photos are taken in landscape mode.', + isError: true); } if (mounted) { @@ -160,7 +163,10 @@ class _RiverManualTriennialStep4AdditionalInfoState child: IconButton( visualDensity: VisualDensity.compact, icon: const Icon(Icons.close, color: Colors.white, size: 20), - onPressed: () => setState(() => setImageCallback(null)), + onPressed: () { + remarkController?.clear(); + setState(() => setImageCallback(null)); + }, ), ), ], @@ -173,7 +179,7 @@ class _RiverManualTriennialStep4AdditionalInfoState ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")), ], ), - if (remarkController != null) + if (remarkController != null && imageFile != null) Padding( padding: const EdgeInsets.only(top: 8.0), child: TextFormField( diff --git a/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart b/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart index 0ca39c4..caf8c32 100644 --- a/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart +++ b/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart @@ -9,7 +9,8 @@ import 'package:intl/intl.dart'; import '../../../../auth_provider.dart'; import '../../../../models/river_in_situ_sampling_data.dart'; -import '../../../../services/api_service.dart'; // Import to access DatabaseHelper +//import '../../../../services/api_service.dart'; // Import to access DatabaseHelper +import 'package:environment_monitoring_app/services/database_helper.dart'; import '../../../../services/river_in_situ_sampling_service.dart'; import '../../../../bluetooth/bluetooth_manager.dart'; import '../../../../serial/serial_manager.dart'; diff --git a/lib/screens/river/manual/widgets/river_in_situ_step_4_additional_info.dart b/lib/screens/river/manual/widgets/river_in_situ_step_4_additional_info.dart index 8b497a4..c6a7803 100644 --- a/lib/screens/river/manual/widgets/river_in_situ_step_4_additional_info.dart +++ b/lib/screens/river/manual/widgets/river_in_situ_step_4_additional_info.dart @@ -72,7 +72,10 @@ class _RiverInSituStep4AdditionalInfoState if (file != null) { setState(() => setImageCallback(file)); } else if (mounted) { - _showSnackBar('Image selection failed. Please ensure all photos are taken in landscape mode.', isError: true); + // โœ… CHANGE: Reverted. All photos (required and optional) must be landscape. + _showSnackBar( + 'Image selection failed. Please ensure all photos are taken in landscape mode.', + isError: true); } if (mounted) { @@ -162,7 +165,10 @@ class _RiverInSituStep4AdditionalInfoState child: IconButton( visualDensity: VisualDensity.compact, icon: const Icon(Icons.close, color: Colors.white, size: 20), - onPressed: () => setState(() => setImageCallback(null)), + onPressed: () { + remarkController?.clear(); + setState(() => setImageCallback(null)); + }, ), ), ], @@ -175,7 +181,7 @@ class _RiverInSituStep4AdditionalInfoState ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")), ], ), - if (remarkController != null) + if (remarkController != null && imageFile != null) Padding( padding: const EdgeInsets.only(top: 8.0), child: TextFormField( @@ -191,4 +197,4 @@ class _RiverInSituStep4AdditionalInfoState ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/river/river_home_page.dart b/lib/screens/river/river_home_page.dart index 66c39c2..893b60c 100644 --- a/lib/screens/river/river_home_page.dart +++ b/lib/screens/river/river_home_page.dart @@ -61,6 +61,12 @@ class RiverHomePage extends StatelessWidget { SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/river/investigative/info'), // *** ADDED: Link to River Investigative Manual Sampling *** SidebarItem(icon: Icons.biotech, label: "Investigative Sampling", route: '/river/investigative/manual-sampling'), // Added Icon + + // *** START: ADDED NEW ITEMS *** + SidebarItem(icon: Icons.article, label: "Data Log", route: '/river/investigative/data-log'), + SidebarItem(icon: Icons.image, label: "Image Request", route: '/river/investigative/image-request'), + // *** END: ADDED NEW ITEMS *** + // SidebarItem(icon: Icons.info, label: "Overview", route: '/river/investigative/overview'), // Keep placeholder/future items commented //SidebarItem(icon: Icons.input, label: "Entry", route: '/river/investigative/entry'), // Keep placeholder/future items commented //SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/investigative/report'), // Keep placeholder/future items commented diff --git a/lib/services/air_api_service.dart b/lib/services/air_api_service.dart new file mode 100644 index 0000000..c59106a --- /dev/null +++ b/lib/services/air_api_service.dart @@ -0,0 +1,64 @@ +// lib/services/air_api_service.dart + +import 'dart:io'; +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/services/base_api_service.dart'; +import 'package:environment_monitoring_app/services/server_config_service.dart'; +import 'package:environment_monitoring_app/services/telegram_service.dart'; + +class AirApiService { + final BaseApiService _baseService; + final TelegramService? _telegramService; // Kept optional for now + final ServerConfigService _serverConfigService; + + AirApiService(this._baseService, this._telegramService, this._serverConfigService); + + Future> getManualStations() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'air/manual-stations'); + } + + Future> getClients() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'air/clients'); + } + + // NOTE: Air submission logic is likely in AirSamplingService and might use generic services. + // These specific methods might be legacy or used differently. Keep them for now. + Future> submitInstallation(AirInstallationData data) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.post(baseUrl, 'air/manual/installation', data.toJsonForApi()); + } + + Future> submitCollection(AirCollectionData data) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.post(baseUrl, 'air/manual/collection', data.toJson()); + } + + Future> uploadInstallationImages({ + required String airManId, + required Map files, + }) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.postMultipart( + baseUrl: baseUrl, + endpoint: 'air/manual/installation-images', + fields: {'air_man_id': airManId}, + files: files, + ); + } + + Future> uploadCollectionImages({ + required String airManId, + required Map files, + }) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.postMultipart( + baseUrl: baseUrl, + endpoint: 'air/manual/collection-images', + fields: {'air_man_id': airManId}, + files: files, + ); + } +} \ No newline at end of file diff --git a/lib/services/air_sampling_service.dart b/lib/services/air_sampling_service.dart index 538da3c..127f5a4 100644 --- a/lib/services/air_sampling_service.dart +++ b/lib/services/air_sampling_service.dart @@ -13,6 +13,7 @@ import 'dart:convert'; import '../models/air_installation_data.dart'; import '../models/air_collection_data.dart'; import 'api_service.dart'; +import 'package:environment_monitoring_app/services/database_helper.dart'; import 'local_storage_service.dart'; import 'telegram_service.dart'; import 'server_config_service.dart'; diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 8419b3a..2b1675d 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -4,20 +4,24 @@ import 'dart:io'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; -import 'package:path/path.dart' as p; -import 'package:sqflite/sqflite.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:intl/intl.dart'; import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/telegram_service.dart'; -import 'package:environment_monitoring_app/models/air_collection_data.dart'; -import 'package:environment_monitoring_app/models/air_installation_data.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart'; -import 'package:environment_monitoring_app/models/marine_manual_pre_departure_checklist_data.dart'; -import 'package:environment_monitoring_app/models/marine_manual_sonde_calibration_data.dart'; -import 'package:environment_monitoring_app/models/marine_manual_equipment_maintenance_data.dart'; +// Import the new separated files +import 'package:environment_monitoring_app/services/database_helper.dart'; +import 'package:environment_monitoring_app/services/marine_api_service.dart'; +import 'package:environment_monitoring_app/services/river_api_service.dart'; +import 'package:environment_monitoring_app/services/air_api_service.dart'; + +// Removed: Models that are no longer directly used by this top-level class +// 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/marine_manual_pre_departure_checklist_data.dart'; +// import 'package:environment_monitoring_app/models/marine_manual_sonde_calibration_data.dart'; +// import 'package:environment_monitoring_app/models/marine_manual_equipment_maintenance_data.dart'; // ======================================================================= // Part 1: Unified API Service @@ -35,8 +39,11 @@ class ApiService { static const String imageBaseUrl = 'https://mms-apiv4.pstw.com.my/'; ApiService({required TelegramService telegramService}) { - marine = MarineApiService(_baseService, telegramService, _serverConfigService, dbHelper); - river = RiverApiService(_baseService, telegramService, _serverConfigService, dbHelper); + // --- MODIFIED CONSTRUCTOR --- + // Note that marine and river no longer take dbHelper, matching your new files + marine = MarineApiService(_baseService, telegramService, _serverConfigService); + river = RiverApiService(_baseService, telegramService, _serverConfigService); + // AirApiService also doesn't need the dbHelper air = AirApiService(_baseService, telegramService, _serverConfigService); } @@ -226,16 +233,10 @@ class ApiService { await dbHelper.deleteRiverTriennialStations(id); } }, - // --- ADDED: River Investigative Stations Sync --- - 'riverInvestigativeStations': { - // IMPORTANT: Make sure this endpoint matches your server's route - 'endpoint': 'river/investigative-stations', - 'handler': (d, id) async { - await dbHelper.upsertRiverInvestigativeStations(d); - await dbHelper.deleteRiverInvestigativeStations(id); - } - }, - // --- END ADDED --- + // --- REMOVED: River Investigative Stations Sync --- + // The 'riverInvestigativeStations' task has been removed + // as per the request to use river manual stations instead. + // --- END REMOVED --- 'departments': { 'endpoint': 'departments', 'handler': (d, id) async { @@ -343,9 +344,13 @@ class ApiService { await (syncTasks[key]!['handler'] as Function)([profileData.first], []); } } else { + // --- REVERTED TO ORIGINAL --- + // The special logic to handle List vs Map is no longer needed + // since the endpoint causing the problem is no longer being called. final updated = List>.from(result['data']['updated'] ?? []); final deleted = List.from(result['data']['deleted'] ?? []); await (syncTasks[key]!['handler'] as Function)(updated, deleted); + // --- END REVERTED --- } } else { debugPrint('ApiService: Failed to sync $key. Message: ${result['message']}'); @@ -436,881 +441,8 @@ class ApiService { } // ======================================================================= -// Part 2: Feature-Specific API Services (Refactored to include Telegram) -// ======================================================================= - -class AirApiService { - final BaseApiService _baseService; - final TelegramService? _telegramService; // Kept optional for now - final ServerConfigService _serverConfigService; - - AirApiService(this._baseService, this._telegramService, this._serverConfigService); - - Future> getManualStations() async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.get(baseUrl, 'air/manual-stations'); - } - - Future> getClients() async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.get(baseUrl, 'air/clients'); - } - - // NOTE: Air submission logic is likely in AirSamplingService and might use generic services. - // These specific methods might be legacy or used differently. Keep them for now. - Future> submitInstallation(AirInstallationData data) async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.post(baseUrl, 'air/manual/installation', data.toJsonForApi()); - } - - Future> submitCollection(AirCollectionData data) async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.post(baseUrl, 'air/manual/collection', data.toJson()); - } - - Future> uploadInstallationImages({ - required String airManId, - required Map files, - }) async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.postMultipart( - baseUrl: baseUrl, - endpoint: 'air/manual/installation-images', - fields: {'air_man_id': airManId}, - files: files, - ); - } - - Future> uploadCollectionImages({ - required String airManId, - required Map files, - }) async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.postMultipart( - baseUrl: baseUrl, - endpoint: 'air/manual/collection-images', - fields: {'air_man_id': airManId}, - files: files, - ); - } -} - -// ======================================================================= -// --- START OF MODIFIED SECTION --- -// The entire MarineApiService class is replaced with the corrected version. -// ======================================================================= -class MarineApiService { - final BaseApiService _baseService; - final TelegramService _telegramService; - final ServerConfigService _serverConfigService; - final DatabaseHelper _dbHelper; // Kept to match constructor - - MarineApiService(this._baseService, this._telegramService, this._serverConfigService, this._dbHelper); - - // --- KEPT METHODS (Unchanged) --- - Future> getTarballStations() async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.get(baseUrl, 'marine/tarball/stations'); - } - - Future> getManualStations() async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.get(baseUrl, 'marine/manual/stations'); - } - - Future> getTarballClassifications() async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.get(baseUrl, 'marine/tarball/classifications'); - } - - // --- REPLACED/FIXED METHOD --- - Future> getManualSamplingImages({ - required int stationId, - required DateTime samplingDate, - required String samplingType, // This parameter is NOW USED - }) async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate); - - String endpoint; - // Determine the correct endpoint based on the sampling type - switch (samplingType) { - case 'In-Situ Sampling': - endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr'; - break; - case 'Tarball Sampling': - // **IMPORTANT**: Please verify this is the correct endpoint for tarball records - endpoint = 'marine/tarball/records-by-station?station_id=$stationId&date=$dateStr'; - break; - case 'All Manual Sampling': - default: - // 'All' is complex. Defaulting to 'manual' (in-situ) as a fallback. - endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr'; - break; - } - - // This new debug print will help you confirm the fix is working - debugPrint("MarineApiService: Calling API endpoint: $endpoint"); - - final response = await _baseService.get(baseUrl, endpoint); - - // Adjusting response parsing based on observed structure - if (response['success'] == true && response['data'] is Map && response['data']['data'] is List) { - return { - 'success': true, - 'data': response['data']['data'], // Return the inner 'data' list - 'message': response['message'], - }; - } - // Return original response if structure doesn't match - return response; - } - - - // --- ADDED METHOD --- - Future> sendImageRequestEmail({ - required String recipientEmail, - required List imageUrls, - required String stationName, - required String samplingDate, - }) async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - - final Map fields = { - 'recipientEmail': recipientEmail, - 'imageUrls': jsonEncode(imageUrls), - 'stationName': stationName, - 'samplingDate': samplingDate, - }; - - return _baseService.postMultipart( - baseUrl: baseUrl, - endpoint: 'marine/images/send-email', // **IMPORTANT**: Verify this endpoint - fields: fields, - files: {}, - ); - } - - // --- KEPT METHODS (Unchanged) --- - Future> submitPreDepartureChecklist(MarineManualPreDepartureChecklistData data) async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.post(baseUrl, 'marine/checklist', data.toApiFormData()); - } - - Future> submitSondeCalibration(MarineManualSondeCalibrationData data) async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.post(baseUrl, 'marine/calibration', data.toApiFormData()); - } - - Future> submitMaintenanceLog(MarineManualEquipmentMaintenanceData data) async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.post(baseUrl, 'marine/maintenance', data.toApiFormData()); - } - - Future> getPreviousMaintenanceLogs() async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.get(baseUrl, 'marine/maintenance/previous'); - } -} -// ======================================================================= -// --- END OF MODIFIED SECTION --- -// ======================================================================= - -class RiverApiService { - final BaseApiService _baseService; - final TelegramService _telegramService; // Still needed if _handleAlerts were here - final ServerConfigService _serverConfigService; - final DatabaseHelper _dbHelper; // Still needed for parameter limit lookups if alerts were here - - RiverApiService(this._baseService, this._telegramService, this._serverConfigService, this._dbHelper); - - // --- KEPT METHODS --- - Future> getManualStations() async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.get(baseUrl, 'river/manual-stations'); - } - - Future> getTriennialStations() async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.get(baseUrl, 'river/triennial-stations'); - } - - Future> getRiverSamplingImages({ - required int stationId, - required DateTime samplingDate, - required String samplingType, // Parameter likely unused by current endpoint - }) async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate); - // Endpoint seems specific to 'manual', adjust if needed for 'triennial' or others - final String endpoint = 'river/manual/images-by-station?station_id=$stationId&date=$dateStr'; - - debugPrint("ApiService: Calling river image request API endpoint: $endpoint"); - - final response = await _baseService.get(baseUrl, endpoint); - return response; // Pass the raw response along - } - - Future> sendImageRequestEmail({ - required String recipientEmail, - required List imageUrls, - required String stationName, - required String samplingDate, - }) async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - final Map fields = { - 'recipientEmail': recipientEmail, - 'imageUrls': jsonEncode(imageUrls), - 'stationName': stationName, - 'samplingDate': samplingDate, - }; - - return _baseService.postMultipart( - baseUrl: baseUrl, - endpoint: 'river/images/send-email', // Endpoint for river email requests - fields: fields, - files: {}, - ); - } - -// --- REMOVED METHODS (Logic moved to feature services) --- -// - submitInSituSample -// - submitTriennialSample -// - _handleTriennialSuccessAlert -// - _handleInSituSuccessAlert -// - _generateInSituAlertMessage -// - _getOutOfBoundsAlertSection (River version) - -} - -// ======================================================================= -// Part 3: Local Database Helper (Original version - no compute mods) -// ======================================================================= - -class DatabaseHelper { - static Database? _database; - static const String _dbName = 'app_data.db'; - // --- MODIFIED: Incremented DB version --- - static const int _dbVersion = 24; // Keep version updated if schema changes - // --- END MODIFIED --- - - // compute-related static variables/methods REMOVED - - static const String _profileTable = 'user_profile'; - static const String _usersTable = 'all_users'; - static const String _tarballStationsTable = 'marine_tarball_stations'; - static const String _manualStationsTable = 'marine_manual_stations'; - static const String _riverManualStationsTable = 'river_manual_stations'; - static const String _riverTriennialStationsTable = 'river_triennial_stations'; - // --- ADDED: River Investigative Stations Table Name --- - static const String _riverInvestigativeStationsTable = 'river_investigative_stations'; - // --- END ADDED --- - static const String _tarballClassificationsTable = 'marine_tarball_classifications'; - static const String _departmentsTable = 'departments'; - static const String _companiesTable = 'companies'; - static const String _positionsTable = 'positions'; - static const String _alertQueueTable = 'alert_queue'; - static const String _airManualStationsTable = 'air_manual_stations'; - static const String _airClientsTable = 'air_clients'; - static const String _statesTable = 'states'; - static const String _appSettingsTable = 'app_settings'; - // static const String _parameterLimitsTable = 'manual_parameter_limits'; // REMOVED - static const String _npeParameterLimitsTable = 'npe_parameter_limits'; - static const String _marineParameterLimitsTable = 'marine_parameter_limits'; - static const String _riverParameterLimitsTable = 'river_parameter_limits'; - static const String _apiConfigsTable = 'api_configurations'; - static const String _ftpConfigsTable = 'ftp_configurations'; - static const String _retryQueueTable = 'retry_queue'; - static const String _submissionLogTable = 'submission_log'; - static const String _documentsTable = 'documents'; - - static const String _modulePreferencesTable = 'module_preferences'; - static const String _moduleApiLinksTable = 'module_api_links'; - static const String _moduleFtpLinksTable = 'module_ftp_links'; - - Future get database async { - if (_database != null) return _database!; - _database = await _initDB(); - return _database!; - } - - Future _initDB() async { - // Standard path retrieval - String dbPath = p.join(await getDatabasesPath(), _dbName); - - return await openDatabase(dbPath, version: _dbVersion, onCreate: _onCreate, onUpgrade: _onUpgrade); - } - - Future _onCreate(Database db, int version) async { - // Create all tables as defined in version 23 - await db.execute('CREATE TABLE $_profileTable(user_id INTEGER PRIMARY KEY, profile_json TEXT)'); - await db.execute(''' - CREATE TABLE $_usersTable( - user_id INTEGER PRIMARY KEY, - email TEXT UNIQUE, - password_hash TEXT, - user_json TEXT - ) - '''); - await db.execute('CREATE TABLE $_tarballStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); - await db.execute('CREATE TABLE $_manualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); - await db.execute('CREATE TABLE $_riverManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); - await db.execute('CREATE TABLE $_riverTriennialStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); - // --- ADDED: River Investigative Stations Table Create --- - await db.execute('CREATE TABLE $_riverInvestigativeStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); - // --- END ADDED --- - await db.execute('CREATE TABLE $_tarballClassificationsTable(classification_id INTEGER PRIMARY KEY, classification_json TEXT)'); - await db.execute('CREATE TABLE $_departmentsTable(department_id INTEGER PRIMARY KEY, department_json TEXT)'); - await db.execute('CREATE TABLE $_companiesTable(company_id INTEGER PRIMARY KEY, company_json TEXT)'); - await db.execute('CREATE TABLE $_positionsTable(position_id INTEGER PRIMARY KEY, position_json TEXT)'); - await db.execute('''CREATE TABLE $_alertQueueTable (id INTEGER PRIMARY KEY AUTOINCREMENT, chat_id TEXT NOT NULL, message TEXT NOT NULL, created_at TEXT NOT NULL)'''); - await db.execute('CREATE TABLE $_airManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); - await db.execute('CREATE TABLE $_airClientsTable(client_id INTEGER PRIMARY KEY, client_json TEXT)'); - await db.execute('CREATE TABLE $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)'); - await db.execute('CREATE TABLE $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)'); - // No generic _parameterLimitsTable creation - await db.execute('CREATE TABLE $_npeParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); - await db.execute('CREATE TABLE $_marineParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); - await db.execute('CREATE TABLE $_riverParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_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 $_retryQueueTable( - id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL, - endpoint_or_path TEXT NOT NULL, - payload TEXT, - timestamp TEXT NOT NULL, - status TEXT NOT NULL - ) - '''); - 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 - ) - '''); - await db.execute(''' - CREATE TABLE $_modulePreferencesTable ( - module_name TEXT PRIMARY KEY, - is_api_enabled INTEGER NOT NULL DEFAULT 1, - is_ftp_enabled INTEGER NOT NULL DEFAULT 1 - ) - '''); - await db.execute(''' - CREATE TABLE $_moduleApiLinksTable ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - module_name TEXT NOT NULL, - api_config_id INTEGER NOT NULL, - is_enabled INTEGER NOT NULL DEFAULT 1 - ) - '''); - await db.execute(''' - CREATE TABLE $_moduleFtpLinksTable ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - module_name TEXT NOT NULL, - ftp_config_id INTEGER NOT NULL, - is_enabled INTEGER NOT NULL DEFAULT 1 - ) - '''); - await db.execute('CREATE TABLE $_documentsTable(id INTEGER PRIMARY KEY, document_json TEXT)'); - } - - Future _onUpgrade(Database db, int oldVersion, int newVersion) async { - // Apply upgrades sequentially - if (oldVersion < 11) { - await db.execute('CREATE TABLE IF NOT EXISTS $_airManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); - await db.execute('CREATE TABLE IF NOT EXISTS $_airClientsTable(client_id INTEGER PRIMARY KEY, client_json TEXT)'); - } - if (oldVersion < 12) { - await db.execute('CREATE TABLE IF NOT EXISTS $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)'); - } - if (oldVersion < 13) { - await db.execute('CREATE TABLE IF NOT EXISTS $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)'); - } - if (oldVersion < 16) { - await db.execute('CREATE TABLE IF NOT EXISTS $_apiConfigsTable(api_config_id INTEGER PRIMARY KEY, config_json TEXT)'); - await db.execute('CREATE TABLE IF NOT EXISTS $_ftpConfigsTable(ftp_config_id INTEGER PRIMARY KEY, config_json TEXT)'); - } - if (oldVersion < 17) { - await db.execute(''' - CREATE TABLE IF NOT EXISTS $_retryQueueTable( - id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL, - endpoint_or_path TEXT NOT NULL, - payload TEXT, - timestamp TEXT NOT NULL, - status TEXT NOT NULL - ) - '''); - } - if (oldVersion < 18) { - 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 - ) - '''); - } - 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 (_) {} - await db.execute(''' - CREATE TABLE IF NOT EXISTS $_modulePreferencesTable ( - module_name TEXT PRIMARY KEY, - is_api_enabled INTEGER NOT NULL DEFAULT 1, - is_ftp_enabled INTEGER NOT NULL DEFAULT 1 - ) - '''); - await db.execute(''' - CREATE TABLE IF NOT EXISTS $_moduleApiLinksTable ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - module_name TEXT NOT NULL, - api_config_id INTEGER NOT NULL, - is_enabled INTEGER NOT NULL DEFAULT 1 - ) - '''); - await db.execute(''' - CREATE TABLE IF NOT EXISTS $_moduleFtpLinksTable ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - module_name TEXT NOT NULL, - ftp_config_id INTEGER NOT NULL, - is_enabled INTEGER NOT NULL DEFAULT 1 - ) - '''); - } - if (oldVersion < 20) { - await db.execute('CREATE TABLE IF NOT EXISTS $_documentsTable(id INTEGER PRIMARY KEY, document_json TEXT)'); - } - - if (oldVersion < 21) { - try { - await db.execute("ALTER TABLE $_usersTable ADD COLUMN email TEXT"); - await db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_user_email ON $_usersTable (email)"); - } catch (e) { - debugPrint("Upgrade warning: Failed to add email column/index to users table (may already exist): $e"); - } - try { - await db.execute("ALTER TABLE $_usersTable ADD COLUMN password_hash TEXT"); - } catch (e) { - debugPrint("Upgrade warning: Failed to add password_hash column to users table (may already exist): $e"); - } - } - if (oldVersion < 23) { - await db.execute('CREATE TABLE IF NOT EXISTS $_npeParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); - await db.execute('CREATE TABLE IF NOT EXISTS $_marineParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); - await db.execute('CREATE TABLE IF NOT EXISTS $_riverParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); - try { - // await db.execute('DROP TABLE IF EXISTS $_parameterLimitsTable'); // Keep commented - debugPrint("Old generic parameter limits table check/drop logic executed (if applicable)."); - } catch (e) { - debugPrint("Upgrade warning: Failed to drop old parameter limits table (may not exist): $e"); - } - } - // --- ADDED: Upgrade step for new table --- - if (oldVersion < 24) { - await db.execute('CREATE TABLE IF NOT EXISTS $_riverInvestigativeStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); - } - // --- END ADDED --- - } - - // --- Data Handling Methods --- - Future _upsertData(String table, String idKeyName, List> data, String jsonKeyName) async { - if (data.isEmpty) return; - final db = await database; - final batch = db.batch(); - for (var item in data) { - if (item[idKeyName] != null) { - batch.insert( - table, - {idKeyName: item[idKeyName], '${jsonKeyName}_json': jsonEncode(item)}, - conflictAlgorithm: ConflictAlgorithm.replace, - ); - } else { - debugPrint("Skipping upsert for item in $table due to null ID: $item"); - } - } - await batch.commit(noResult: true); - debugPrint("Upserted items into $table (skipped items with null IDs if any)"); - } - - Future _deleteData(String table, String idKeyName, List ids) async { - if (ids.isEmpty) return; - final db = await database; - final validIds = ids.where((id) => id != null).toList(); - if (validIds.isEmpty) return; - final placeholders = List.filled(validIds.length, '?').join(', '); - await db.delete( - table, - where: '$idKeyName IN ($placeholders)', - whereArgs: validIds, - ); - debugPrint("Deleted ${validIds.length} items from $table"); - } - - Future>?> _loadData(String table, String jsonKey) async { - final db = await database; - final List> maps = await db.query(table); - if (maps.isNotEmpty) { - try { - return maps.map((map) { - try { - return jsonDecode(map['${jsonKey}_json']) as Map; - } catch (e) { - final idKey = maps.first.keys.firstWhere((k) => k.endsWith('_id') || k == 'id' || k.endsWith('autoid'), orElse: () => 'unknown_id'); - debugPrint("Error decoding JSON from $table, ID ${map[idKey]}: $e"); - return {}; - } - }).where((item) => item.isNotEmpty).toList(); - } catch (e) { - debugPrint("General error loading data from $table: $e"); - return null; - } - } - return null; // Return null if table is empty - } - - Future saveProfile(Map profile) async { - final db = await database; - await db.insert(_profileTable, {'user_id': profile['user_id'], 'profile_json': jsonEncode(profile)}, - conflictAlgorithm: ConflictAlgorithm.replace); - } - - Future?> loadProfile() async { - final db = await database; - final List> maps = await db.query(_profileTable); - if (maps.isNotEmpty) { - try { - return jsonDecode(maps.first['profile_json']); - } catch (e) { - debugPrint("Error decoding profile: $e"); - return null; - } - } - return null; - } - - Future?> loadProfileByEmail(String email) async { - final db = await database; - final List> maps = await db.query( - _usersTable, - columns: ['user_json'], - where: 'email = ?', - whereArgs: [email], - ); - if (maps.isNotEmpty) { - try { - return jsonDecode(maps.first['user_json']) as Map; - } catch (e) { - debugPrint("Error decoding profile for email $email: $e"); - return null; - } - } - return null; - } - - Future upsertUserWithCredentials({ - required Map profile, - required String passwordHash, - }) async { - final db = await database; - await db.insert( - _usersTable, - { - 'user_id': profile['user_id'], - 'email': profile['email'], - 'password_hash': passwordHash, - 'user_json': jsonEncode(profile) - }, - conflictAlgorithm: ConflictAlgorithm.replace, - ); - debugPrint("Upserted user credentials for ${profile['email']}"); - } - - Future getUserPasswordHashByEmail(String email) async { - final db = await database; - final List> result = await db.query( - _usersTable, - columns: ['password_hash'], - where: 'email = ?', - whereArgs: [email], - ); - if (result.isNotEmpty && result.first['password_hash'] != null) { - return result.first['password_hash'] as String; - } - return null; - } - - Future upsertUsers(List> data) async { - if (data.isEmpty) return; - final db = await database; - final batch = db.batch(); - for (var item in data) { - String email = item['email'] ?? 'missing_email_${item['user_id']}@placeholder.com'; - if (item['email'] == null) { - debugPrint("Warning: User ID ${item['user_id']} is missing email during upsert."); - } - batch.insert( - _usersTable, - { - 'user_id': item['user_id'], - 'email': email, - 'user_json': jsonEncode(item), - }, - conflictAlgorithm: ConflictAlgorithm.replace, - ); - } - await batch.commit(noResult: true); - debugPrint("Upserted ${data.length} user items using batch."); - } - - - Future deleteUsers(List ids) => _deleteData(_usersTable, 'user_id', ids); - Future>?> loadUsers() => _loadData(_usersTable, 'user'); - - Future upsertDocuments(List> data) => _upsertData(_documentsTable, 'id', data, 'document'); - Future deleteDocuments(List ids) => _deleteData(_documentsTable, 'id', ids); - Future>?> loadDocuments() => _loadData(_documentsTable, 'document'); - - Future upsertTarballStations(List> data) => - _upsertData(_tarballStationsTable, 'station_id', data, 'station'); - Future deleteTarballStations(List ids) => _deleteData(_tarballStationsTable, 'station_id', ids); - Future>?> loadTarballStations() => _loadData(_tarballStationsTable, 'station'); - - Future upsertManualStations(List> data) => - _upsertData(_manualStationsTable, 'station_id', data, 'station'); - Future deleteManualStations(List ids) => _deleteData(_manualStationsTable, 'station_id', ids); - Future>?> loadManualStations() => _loadData(_manualStationsTable, 'station'); - - Future upsertRiverManualStations(List> data) => - _upsertData(_riverManualStationsTable, 'station_id', data, 'station'); - Future deleteRiverManualStations(List ids) => _deleteData(_riverManualStationsTable, 'station_id', ids); - Future>?> loadRiverManualStations() => _loadData(_riverManualStationsTable, 'station'); - - Future upsertRiverTriennialStations(List> data) => - _upsertData(_riverTriennialStationsTable, 'station_id', data, 'station'); - Future deleteRiverTriennialStations(List ids) => _deleteData(_riverTriennialStationsTable, 'station_id', ids); - Future>?> loadRiverTriennialStations() => _loadData(_riverTriennialStationsTable, 'station'); - - // --- ADDED: River Investigative Stations DB Methods --- - Future upsertRiverInvestigativeStations(List> data) => - _upsertData(_riverInvestigativeStationsTable, 'station_id', data, 'station'); - Future deleteRiverInvestigativeStations(List ids) => _deleteData(_riverInvestigativeStationsTable, 'station_id', ids); - Future>?> loadRiverInvestigativeStations() => _loadData(_riverInvestigativeStationsTable, 'station'); - // --- END ADDED --- - - Future upsertTarballClassifications(List> data) => - _upsertData(_tarballClassificationsTable, 'classification_id', data, 'classification'); - Future deleteTarballClassifications(List ids) => _deleteData(_tarballClassificationsTable, 'classification_id', ids); - Future>?> loadTarballClassifications() => _loadData(_tarballClassificationsTable, 'classification'); - - Future upsertDepartments(List> data) => _upsertData(_departmentsTable, 'department_id', data, 'department'); - Future deleteDepartments(List ids) => _deleteData(_departmentsTable, 'department_id', ids); - Future>?> loadDepartments() => _loadData(_departmentsTable, 'department'); - - Future upsertCompanies(List> data) => _upsertData(_companiesTable, 'company_id', data, 'company'); - Future deleteCompanies(List ids) => _deleteData(_companiesTable, 'company_id', ids); - Future>?> loadCompanies() => _loadData(_companiesTable, 'company'); - - Future upsertPositions(List> data) => _upsertData(_positionsTable, 'position_id', data, 'position'); - Future deletePositions(List ids) => _deleteData(_positionsTable, 'position_id', ids); - Future>?> loadPositions() => _loadData(_positionsTable, 'position'); - - Future upsertAirManualStations(List> data) => - _upsertData(_airManualStationsTable, 'station_id', data, 'station'); - Future deleteAirManualStations(List ids) => _deleteData(_airManualStationsTable, 'station_id', ids); - Future>?> loadAirManualStations() => _loadData(_airManualStationsTable, 'station'); - - Future upsertAirClients(List> data) => _upsertData(_airClientsTable, 'client_id', data, 'client'); - Future deleteAirClients(List ids) => _deleteData(_airClientsTable, 'client_id', ids); - Future>?> loadAirClients() => _loadData(_airClientsTable, 'client'); - - Future upsertStates(List> data) => _upsertData(_statesTable, 'state_id', data, 'state'); - Future deleteStates(List ids) => _deleteData(_statesTable, 'state_id', ids); - Future>?> loadStates() => _loadData(_statesTable, 'state'); - - Future upsertAppSettings(List> data) => _upsertData(_appSettingsTable, 'setting_id', data, 'setting'); - Future deleteAppSettings(List ids) => _deleteData(_appSettingsTable, 'setting_id', ids); - Future>?> loadAppSettings() => _loadData(_appSettingsTable, 'setting'); - - Future upsertNpeParameterLimits(List> data) => _upsertData(_npeParameterLimitsTable, 'param_autoid', data, 'limit'); - Future deleteNpeParameterLimits(List ids) => _deleteData(_npeParameterLimitsTable, 'param_autoid', ids); - Future>?> loadNpeParameterLimits() => _loadData(_npeParameterLimitsTable, 'limit'); - - Future upsertMarineParameterLimits(List> data) => _upsertData(_marineParameterLimitsTable, 'param_autoid', data, 'limit'); - Future deleteMarineParameterLimits(List ids) => _deleteData(_marineParameterLimitsTable, 'param_autoid', ids); - Future>?> loadMarineParameterLimits() => _loadData(_marineParameterLimitsTable, 'limit'); - - Future upsertRiverParameterLimits(List> data) => _upsertData(_riverParameterLimitsTable, 'param_autoid', data, 'limit'); - Future deleteRiverParameterLimits(List ids) => _deleteData(_riverParameterLimitsTable, 'param_autoid', ids); - Future>?> loadRiverParameterLimits() => _loadData(_riverParameterLimitsTable, 'limit'); - - Future upsertApiConfigs(List> data) => _upsertData(_apiConfigsTable, 'api_config_id', data, 'config'); - Future deleteApiConfigs(List ids) => _deleteData(_apiConfigsTable, 'api_config_id', ids); - Future>?> loadApiConfigs() => _loadData(_apiConfigsTable, 'config'); - - Future upsertFtpConfigs(List> data) => _upsertData(_ftpConfigsTable, 'ftp_config_id', data, 'config'); - Future deleteFtpConfigs(List ids) => _deleteData(_ftpConfigsTable, 'ftp_config_id', ids); - Future>?> loadFtpConfigs() => _loadData(_ftpConfigsTable, 'config'); - - Future queueFailedRequest(Map data) async { - final db = await database; - return await db.insert(_retryQueueTable, data, conflictAlgorithm: ConflictAlgorithm.replace); - } - - Future>> getPendingRequests() async { - final db = await database; - return await db.query(_retryQueueTable, where: 'status = ?', whereArgs: ['pending'], orderBy: 'timestamp ASC'); // Order by timestamp - } - - Future?> getRequestById(int id) async { - final db = await database; - final results = await db.query(_retryQueueTable, where: 'id = ?', whereArgs: [id]); - return results.isNotEmpty ? results.first : null; - } - - Future deleteRequestFromQueue(int id) async { - final db = await database; - await db.delete(_retryQueueTable, where: 'id = ?', whereArgs: [id]); - } - - Future saveSubmissionLog(Map data) async { - final db = await database; - await db.insert( - _submissionLogTable, - data, - conflictAlgorithm: ConflictAlgorithm.replace, // Replace if same ID exists - ); - } - - Future>?> loadSubmissionLogs({String? module}) async { - final db = await database; - List> maps; - - try { // Add try-catch for robustness - 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', - ); - } - return maps.isNotEmpty ? maps : null; // Return null if empty - } catch (e) { - debugPrint("Error loading submission logs: $e"); - return null; - } - } - - Future saveModulePreference({ - required String moduleName, - required bool isApiEnabled, - required bool isFtpEnabled, - }) async { - final db = await database; - await db.insert( - _modulePreferencesTable, - { - 'module_name': moduleName, - 'is_api_enabled': isApiEnabled ? 1 : 0, - 'is_ftp_enabled': isFtpEnabled ? 1 : 0, - }, - conflictAlgorithm: ConflictAlgorithm.replace, - ); - } - - Future?> getModulePreference(String moduleName) async { - final db = await database; - final result = await db.query( - _modulePreferencesTable, - where: 'module_name = ?', - whereArgs: [moduleName], - ); - if (result.isNotEmpty) { - final row = result.first; - return { - 'module_name': row['module_name'], - 'is_api_enabled': (row['is_api_enabled'] as int) == 1, - 'is_ftp_enabled': (row['is_ftp_enabled'] as int) == 1, - }; - } - // Return default values if no preference found - return {'module_name': moduleName, 'is_api_enabled': true, 'is_ftp_enabled': true}; - } - - Future saveApiLinksForModule(String moduleName, List> links) async { - final db = await database; - await db.transaction((txn) async { - await txn.delete(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); - for (final link in links) { - if (link['api_config_id'] != null) { // Ensure ID is not null - await txn.insert(_moduleApiLinksTable, { - 'module_name': moduleName, - 'api_config_id': link['api_config_id'], - 'is_enabled': (link['is_enabled'] as bool? ?? true) ? 1 : 0, - }); - } - } - }); - } - - Future saveFtpLinksForModule(String moduleName, List> links) async { - final db = await database; - await db.transaction((txn) async { - await txn.delete(_moduleFtpLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); - for (final link in links) { - if (link['ftp_config_id'] != null) { // Ensure ID is not null - await txn.insert(_moduleFtpLinksTable, { - 'module_name': moduleName, - 'ftp_config_id': link['ftp_config_id'], - 'is_enabled': (link['is_enabled'] as bool? ?? true) ? 1 : 0, - }); - } - } - }); - } - - Future>> getAllApiLinksForModule(String moduleName) async { - final db = await database; - final result = await db.query(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); - return result.map((row) => { - 'api_config_id': row['api_config_id'], - 'is_enabled': (row['is_enabled'] as int) == 1, - }).toList(); - } - - Future>> getAllFtpLinksForModule(String moduleName) async { - final db = await database; - final result = await db.query(_moduleFtpLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); - return result.map((row) => { - 'ftp_config_id': row['ftp_config_id'], - 'is_enabled': (row['is_enabled'] as int) == 1, - }).toList(); - } -} \ No newline at end of file +// Part 2 & 3: Marine, River, Air, and DatabaseHelper classes +// +// ... All of these class definitions have been REMOVED from this file +// and placed in their own respective files. +// ======================================================================= \ No newline at end of file diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart new file mode 100644 index 0000000..fb5b89e --- /dev/null +++ b/lib/services/database_helper.dart @@ -0,0 +1,640 @@ +// lib/services/database_helper.dart + +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; + +// ======================================================================= +// Part 3: Local Database Helper +// ======================================================================= + +class DatabaseHelper { + static Database? _database; + static const String _dbName = 'app_data.db'; + // --- MODIFIED: Incremented DB version --- + static const int _dbVersion = 24; // Keep version updated if schema changes + // --- END MODIFIED --- + + // compute-related static variables/methods REMOVED + + static const String _profileTable = 'user_profile'; + static const String _usersTable = 'all_users'; + static const String _tarballStationsTable = 'marine_tarball_stations'; + static const String _manualStationsTable = 'marine_manual_stations'; + static const String _riverManualStationsTable = 'river_manual_stations'; + static const String _riverTriennialStationsTable = 'river_triennial_stations'; + // --- ADDED: River Investigative Stations Table Name --- + static const String _riverInvestigativeStationsTable = 'river_investigative_stations'; + // --- END ADDED --- + static const String _tarballClassificationsTable = 'marine_tarball_classifications'; + static const String _departmentsTable = 'departments'; + static const String _companiesTable = 'companies'; + static const String _positionsTable = 'positions'; + static const String _alertQueueTable = 'alert_queue'; + static const String _airManualStationsTable = 'air_manual_stations'; + static const String _airClientsTable = 'air_clients'; + static const String _statesTable = 'states'; + static const String _appSettingsTable = 'app_settings'; + // static const String _parameterLimitsTable = 'manual_parameter_limits'; // REMOVED + static const String _npeParameterLimitsTable = 'npe_parameter_limits'; + static const String _marineParameterLimitsTable = 'marine_parameter_limits'; + static const String _riverParameterLimitsTable = 'river_parameter_limits'; + static const String _apiConfigsTable = 'api_configurations'; + static const String _ftpConfigsTable = 'ftp_configurations'; + static const String _retryQueueTable = 'retry_queue'; + static const String _submissionLogTable = 'submission_log'; + static const String _documentsTable = 'documents'; + + static const String _modulePreferencesTable = 'module_preferences'; + static const String _moduleApiLinksTable = 'module_api_links'; + static const String _moduleFtpLinksTable = 'module_ftp_links'; + + Future get database async { + if (_database != null) return _database!; + _database = await _initDB(); + return _database!; + } + + Future _initDB() async { + // Standard path retrieval + String dbPath = p.join(await getDatabasesPath(), _dbName); + + return await openDatabase(dbPath, version: _dbVersion, onCreate: _onCreate, onUpgrade: _onUpgrade); + } + + Future _onCreate(Database db, int version) async { + // Create all tables as defined in version 23 + await db.execute('CREATE TABLE $_profileTable(user_id INTEGER PRIMARY KEY, profile_json TEXT)'); + await db.execute(''' + CREATE TABLE $_usersTable( + user_id INTEGER PRIMARY KEY, + email TEXT UNIQUE, + password_hash TEXT, + user_json TEXT + ) + '''); + await db.execute('CREATE TABLE $_tarballStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); + await db.execute('CREATE TABLE $_manualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); + await db.execute('CREATE TABLE $_riverManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); + await db.execute('CREATE TABLE $_riverTriennialStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); + // --- ADDED: River Investigative Stations Table Create --- + await db.execute('CREATE TABLE $_riverInvestigativeStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); + // --- END ADDED --- + await db.execute('CREATE TABLE $_tarballClassificationsTable(classification_id INTEGER PRIMARY KEY, classification_json TEXT)'); + await db.execute('CREATE TABLE $_departmentsTable(department_id INTEGER PRIMARY KEY, department_json TEXT)'); + await db.execute('CREATE TABLE $_companiesTable(company_id INTEGER PRIMARY KEY, company_json TEXT)'); + await db.execute('CREATE TABLE $_positionsTable(position_id INTEGER PRIMARY KEY, position_json TEXT)'); + await db.execute('''CREATE TABLE $_alertQueueTable (id INTEGER PRIMARY KEY AUTOINCREMENT, chat_id TEXT NOT NULL, message TEXT NOT NULL, created_at TEXT NOT NULL)'''); + await db.execute('CREATE TABLE $_airManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); + await db.execute('CREATE TABLE $_airClientsTable(client_id INTEGER PRIMARY KEY, client_json TEXT)'); + await db.execute('CREATE TABLE $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)'); + await db.execute('CREATE TABLE $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)'); + // No generic _parameterLimitsTable creation + await db.execute('CREATE TABLE $_npeParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); + await db.execute('CREATE TABLE $_marineParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); + await db.execute('CREATE TABLE $_riverParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_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 $_retryQueueTable( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + endpoint_or_path TEXT NOT NULL, + payload TEXT, + timestamp TEXT NOT NULL, + status TEXT NOT NULL + ) + '''); + 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 + ) + '''); + await db.execute(''' + CREATE TABLE $_modulePreferencesTable ( + module_name TEXT PRIMARY KEY, + is_api_enabled INTEGER NOT NULL DEFAULT 1, + is_ftp_enabled INTEGER NOT NULL DEFAULT 1 + ) + '''); + await db.execute(''' + CREATE TABLE $_moduleApiLinksTable ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + module_name TEXT NOT NULL, + api_config_id INTEGER NOT NULL, + is_enabled INTEGER NOT NULL DEFAULT 1 + ) + '''); + await db.execute(''' + CREATE TABLE $_moduleFtpLinksTable ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + module_name TEXT NOT NULL, + ftp_config_id INTEGER NOT NULL, + is_enabled INTEGER NOT NULL DEFAULT 1 + ) + '''); + await db.execute('CREATE TABLE $_documentsTable(id INTEGER PRIMARY KEY, document_json TEXT)'); + } + + Future _onUpgrade(Database db, int oldVersion, int newVersion) async { + // Apply upgrades sequentially + if (oldVersion < 11) { + await db.execute('CREATE TABLE IF NOT EXISTS $_airManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); + await db.execute('CREATE TABLE IF NOT EXISTS $_airClientsTable(client_id INTEGER PRIMARY KEY, client_json TEXT)'); + } + if (oldVersion < 12) { + await db.execute('CREATE TABLE IF NOT EXISTS $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)'); + } + if (oldVersion < 13) { + await db.execute('CREATE TABLE IF NOT EXISTS $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)'); + } + if (oldVersion < 16) { + await db.execute('CREATE TABLE IF NOT EXISTS $_apiConfigsTable(api_config_id INTEGER PRIMARY KEY, config_json TEXT)'); + await db.execute('CREATE TABLE IF NOT EXISTS $_ftpConfigsTable(ftp_config_id INTEGER PRIMARY KEY, config_json TEXT)'); + } + if (oldVersion < 17) { + await db.execute(''' + CREATE TABLE IF NOT EXISTS $_retryQueueTable( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + endpoint_or_path TEXT NOT NULL, + payload TEXT, + timestamp TEXT NOT NULL, + status TEXT NOT NULL + ) + '''); + } + if (oldVersion < 18) { + 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 + ) + '''); + } + 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 (_) {} + await db.execute(''' + CREATE TABLE IF NOT EXISTS $_modulePreferencesTable ( + module_name TEXT PRIMARY KEY, + is_api_enabled INTEGER NOT NULL DEFAULT 1, + is_ftp_enabled INTEGER NOT NULL DEFAULT 1 + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS $_moduleApiLinksTable ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + module_name TEXT NOT NULL, + api_config_id INTEGER NOT NULL, + is_enabled INTEGER NOT NULL DEFAULT 1 + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS $_moduleFtpLinksTable ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + module_name TEXT NOT NULL, + ftp_config_id INTEGER NOT NULL, + is_enabled INTEGER NOT NULL DEFAULT 1 + ) + '''); + } + if (oldVersion < 20) { + await db.execute('CREATE TABLE IF NOT EXISTS $_documentsTable(id INTEGER PRIMARY KEY, document_json TEXT)'); + } + + if (oldVersion < 21) { + try { + await db.execute("ALTER TABLE $_usersTable ADD COLUMN email TEXT"); + await db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_user_email ON $_usersTable (email)"); + } catch (e) { + debugPrint("Upgrade warning: Failed to add email column/index to users table (may already exist): $e"); + } + try { + await db.execute("ALTER TABLE $_usersTable ADD COLUMN password_hash TEXT"); + } catch (e) { + debugPrint("Upgrade warning: Failed to add password_hash column to users table (may already exist): $e"); + } + } + if (oldVersion < 23) { + await db.execute('CREATE TABLE IF NOT EXISTS $_npeParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); + await db.execute('CREATE TABLE IF NOT EXISTS $_marineParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); + await db.execute('CREATE TABLE IF NOT EXISTS $_riverParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); + try { + // await db.execute('DROP TABLE IF EXISTS $_parameterLimitsTable'); // Keep commented + debugPrint("Old generic parameter limits table check/drop logic executed (if applicable)."); + } catch (e) { + debugPrint("Upgrade warning: Failed to drop old parameter limits table (may not exist): $e"); + } + } + // --- ADDED: Upgrade step for new table --- + if (oldVersion < 24) { + await db.execute('CREATE TABLE IF NOT EXISTS $_riverInvestigativeStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); + } + // --- END ADDED --- + } + + // --- Data Handling Methods --- + Future _upsertData(String table, String idKeyName, List> data, String jsonKeyName) async { + if (data.isEmpty) return; + final db = await database; + final batch = db.batch(); + for (var item in data) { + if (item[idKeyName] != null) { + batch.insert( + table, + {idKeyName: item[idKeyName], '${jsonKeyName}_json': jsonEncode(item)}, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } else { + debugPrint("Skipping upsert for item in $table due to null ID: $item"); + } + } + await batch.commit(noResult: true); + debugPrint("Upserted items into $table (skipped items with null IDs if any)"); + } + + Future _deleteData(String table, String idKeyName, List ids) async { + if (ids.isEmpty) return; + final db = await database; + final validIds = ids.where((id) => id != null).toList(); + if (validIds.isEmpty) return; + final placeholders = List.filled(validIds.length, '?').join(', '); + await db.delete( + table, + where: '$idKeyName IN ($placeholders)', + whereArgs: validIds, + ); + debugPrint("Deleted ${validIds.length} items from $table"); + } + + Future>?> _loadData(String table, String jsonKey) async { + final db = await database; + final List> maps = await db.query(table); + if (maps.isNotEmpty) { + try { + return maps.map((map) { + try { + return jsonDecode(map['${jsonKey}_json']) as Map; + } catch (e) { + final idKey = maps.first.keys.firstWhere((k) => k.endsWith('_id') || k == 'id' || k.endsWith('autoid'), orElse: () => 'unknown_id'); + debugPrint("Error decoding JSON from $table, ID ${map[idKey]}: $e"); + return {}; + } + }).where((item) => item.isNotEmpty).toList(); + } catch (e) { + debugPrint("General error loading data from $table: $e"); + return null; + } + } + return null; // Return null if table is empty + } + + Future saveProfile(Map profile) async { + final db = await database; + await db.insert(_profileTable, {'user_id': profile['user_id'], 'profile_json': jsonEncode(profile)}, + conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future?> loadProfile() async { + final db = await database; + final List> maps = await db.query(_profileTable); + if (maps.isNotEmpty) { + try { + return jsonDecode(maps.first['profile_json']); + } catch (e) { + debugPrint("Error decoding profile: $e"); + return null; + } + } + return null; + } + + Future?> loadProfileByEmail(String email) async { + final db = await database; + final List> maps = await db.query( + _usersTable, + columns: ['user_json'], + where: 'email = ?', + whereArgs: [email], + ); + if (maps.isNotEmpty) { + try { + return jsonDecode(maps.first['user_json']) as Map; + } catch (e) { + debugPrint("Error decoding profile for email $email: $e"); + return null; + } + } + return null; + } + + Future upsertUserWithCredentials({ + required Map profile, + required String passwordHash, + }) async { + final db = await database; + await db.insert( + _usersTable, + { + 'user_id': profile['user_id'], + 'email': profile['email'], + 'password_hash': passwordHash, + 'user_json': jsonEncode(profile) + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + debugPrint("Upserted user credentials for ${profile['email']}"); + } + + Future getUserPasswordHashByEmail(String email) async { + final db = await database; + final List> result = await db.query( + _usersTable, + columns: ['password_hash'], + where: 'email = ?', + whereArgs: [email], + ); + if (result.isNotEmpty && result.first['password_hash'] != null) { + return result.first['password_hash'] as String; + } + return null; + } + + Future upsertUsers(List> data) async { + if (data.isEmpty) return; + final db = await database; + final batch = db.batch(); + for (var item in data) { + String email = item['email'] ?? 'missing_email_${item['user_id']}@placeholder.com'; + if (item['email'] == null) { + debugPrint("Warning: User ID ${item['user_id']} is missing email during upsert."); + } + batch.insert( + _usersTable, + { + 'user_id': item['user_id'], + 'email': email, + 'user_json': jsonEncode(item), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + debugPrint("Upserted ${data.length} user items using batch."); + } + + + Future deleteUsers(List ids) => _deleteData(_usersTable, 'user_id', ids); + Future>?> loadUsers() => _loadData(_usersTable, 'user'); + + Future upsertDocuments(List> data) => _upsertData(_documentsTable, 'id', data, 'document'); + Future deleteDocuments(List ids) => _deleteData(_documentsTable, 'id', ids); + Future>?> loadDocuments() => _loadData(_documentsTable, 'document'); + + Future upsertTarballStations(List> data) => + _upsertData(_tarballStationsTable, 'station_id', data, 'station'); + Future deleteTarballStations(List ids) => _deleteData(_tarballStationsTable, 'station_id', ids); + Future>?> loadTarballStations() => _loadData(_tarballStationsTable, 'station'); + + Future upsertManualStations(List> data) => + _upsertData(_manualStationsTable, 'station_id', data, 'station'); + Future deleteManualStations(List ids) => _deleteData(_manualStationsTable, 'station_id', ids); + Future>?> loadManualStations() => _loadData(_manualStationsTable, 'station'); + + Future upsertRiverManualStations(List> data) => + _upsertData(_riverManualStationsTable, 'station_id', data, 'station'); + Future deleteRiverManualStations(List ids) => _deleteData(_riverManualStationsTable, 'station_id', ids); + Future>?> loadRiverManualStations() => _loadData(_riverManualStationsTable, 'station'); + + Future upsertRiverTriennialStations(List> data) => + _upsertData(_riverTriennialStationsTable, 'station_id', data, 'station'); + Future deleteRiverTriennialStations(List ids) => _deleteData(_riverTriennialStationsTable, 'station_id', ids); + Future>?> loadRiverTriennialStations() => _loadData(_riverTriennialStationsTable, 'station'); + + // --- ADDED: River Investigative Stations DB Methods --- + Future upsertRiverInvestigativeStations(List> data) => + _upsertData(_riverInvestigativeStationsTable, 'station_id', data, 'station'); + Future deleteRiverInvestigativeStations(List ids) => _deleteData(_riverInvestigativeStationsTable, 'station_id', ids); + Future>?> loadRiverInvestigativeStations() => _loadData(_riverInvestigativeStationsTable, 'station'); + // --- END ADDED --- + + Future upsertTarballClassifications(List> data) => + _upsertData(_tarballClassificationsTable, 'classification_id', data, 'classification'); + Future deleteTarballClassifications(List ids) => _deleteData(_tarballClassificationsTable, 'classification_id', ids); + Future>?> loadTarballClassifications() => _loadData(_tarballClassificationsTable, 'classification'); + + Future upsertDepartments(List> data) => _upsertData(_departmentsTable, 'department_id', data, 'department'); + Future deleteDepartments(List ids) => _deleteData(_departmentsTable, 'department_id', ids); + Future>?> loadDepartments() => _loadData(_departmentsTable, 'department'); + + Future upsertCompanies(List> data) => _upsertData(_companiesTable, 'company_id', data, 'company'); + Future deleteCompanies(List ids) => _deleteData(_companiesTable, 'company_id', ids); + Future>?> loadCompanies() => _loadData(_companiesTable, 'company'); + + Future upsertPositions(List> data) => _upsertData(_positionsTable, 'position_id', data, 'position'); + Future deletePositions(List ids) => _deleteData(_positionsTable, 'position_id', ids); + Future>?> loadPositions() => _loadData(_positionsTable, 'position'); + + Future upsertAirManualStations(List> data) => + _upsertData(_airManualStationsTable, 'station_id', data, 'station'); + Future deleteAirManualStations(List ids) => _deleteData(_airManualStationsTable, 'station_id', ids); + Future>?> loadAirManualStations() => _loadData(_airManualStationsTable, 'station'); + + Future upsertAirClients(List> data) => _upsertData(_airClientsTable, 'client_id', data, 'client'); + Future deleteAirClients(List ids) => _deleteData(_airClientsTable, 'client_id', ids); + Future>?> loadAirClients() => _loadData(_airClientsTable, 'client'); + + Future upsertStates(List> data) => _upsertData(_statesTable, 'state_id', data, 'state'); + Future deleteStates(List ids) => _deleteData(_statesTable, 'state_id', ids); + Future>?> loadStates() => _loadData(_statesTable, 'state'); + + Future upsertAppSettings(List> data) => _upsertData(_appSettingsTable, 'setting_id', data, 'setting'); + Future deleteAppSettings(List ids) => _deleteData(_appSettingsTable, 'setting_id', ids); + Future>?> loadAppSettings() => _loadData(_appSettingsTable, 'setting'); + + Future upsertNpeParameterLimits(List> data) => _upsertData(_npeParameterLimitsTable, 'param_autoid', data, 'limit'); + Future deleteNpeParameterLimits(List ids) => _deleteData(_npeParameterLimitsTable, 'param_autoid', ids); + Future>?> loadNpeParameterLimits() => _loadData(_npeParameterLimitsTable, 'limit'); + + Future upsertMarineParameterLimits(List> data) => _upsertData(_marineParameterLimitsTable, 'param_autoid', data, 'limit'); + Future deleteMarineParameterLimits(List ids) => _deleteData(_marineParameterLimitsTable, 'param_autoid', ids); + Future>?> loadMarineParameterLimits() => _loadData(_marineParameterLimitsTable, 'limit'); + + Future upsertRiverParameterLimits(List> data) => _upsertData(_riverParameterLimitsTable, 'param_autoid', data, 'limit'); + Future deleteRiverParameterLimits(List ids) => _deleteData(_riverParameterLimitsTable, 'param_autoid', ids); + Future>?> loadRiverParameterLimits() => _loadData(_riverParameterLimitsTable, 'limit'); + + Future upsertApiConfigs(List> data) => _upsertData(_apiConfigsTable, 'api_config_id', data, 'config'); + Future deleteApiConfigs(List ids) => _deleteData(_apiConfigsTable, 'api_config_id', ids); + Future>?> loadApiConfigs() => _loadData(_apiConfigsTable, 'config'); + + Future upsertFtpConfigs(List> data) => _upsertData(_ftpConfigsTable, 'ftp_config_id', data, 'config'); + Future deleteFtpConfigs(List ids) => _deleteData(_ftpConfigsTable, 'ftp_config_id', ids); + Future>?> loadFtpConfigs() => _loadData(_ftpConfigsTable, 'config'); + + Future queueFailedRequest(Map data) async { + final db = await database; + return await db.insert(_retryQueueTable, data, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future>> getPendingRequests() async { + final db = await database; + return await db.query(_retryQueueTable, where: 'status = ?', whereArgs: ['pending'], orderBy: 'timestamp ASC'); // Order by timestamp + } + + Future?> getRequestById(int id) async { + final db = await database; + final results = await db.query(_retryQueueTable, where: 'id = ?', whereArgs: [id]); + return results.isNotEmpty ? results.first : null; + } + + Future deleteRequestFromQueue(int id) async { + final db = await database; + await db.delete(_retryQueueTable, where: 'id = ?', whereArgs: [id]); + } + + Future saveSubmissionLog(Map data) async { + final db = await database; + await db.insert( + _submissionLogTable, + data, + conflictAlgorithm: ConflictAlgorithm.replace, // Replace if same ID exists + ); + } + + Future>?> loadSubmissionLogs({String? module}) async { + final db = await database; + List> maps; + + try { // Add try-catch for robustness + 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', + ); + } + return maps.isNotEmpty ? maps : null; // Return null if empty + } catch (e) { + debugPrint("Error loading submission logs: $e"); + return null; + } + } + + Future saveModulePreference({ + required String moduleName, + required bool isApiEnabled, + required bool isFtpEnabled, + }) async { + final db = await database; + await db.insert( + _modulePreferencesTable, + { + 'module_name': moduleName, + 'is_api_enabled': isApiEnabled ? 1 : 0, + 'is_ftp_enabled': isFtpEnabled ? 1 : 0, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future?> getModulePreference(String moduleName) async { + final db = await database; + final result = await db.query( + _modulePreferencesTable, + where: 'module_name = ?', + whereArgs: [moduleName], + ); + if (result.isNotEmpty) { + final row = result.first; + return { + 'module_name': row['module_name'], + 'is_api_enabled': (row['is_api_enabled'] as int) == 1, + 'is_ftp_enabled': (row['is_ftp_enabled'] as int) == 1, + }; + } + // Return default values if no preference found + return {'module_name': moduleName, 'is_api_enabled': true, 'is_ftp_enabled': true}; + } + + Future saveApiLinksForModule(String moduleName, List> links) async { + final db = await database; + await db.transaction((txn) async { + await txn.delete(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); + for (final link in links) { + if (link['api_config_id'] != null) { // Ensure ID is not null + await txn.insert(_moduleApiLinksTable, { + 'module_name': moduleName, + 'api_config_id': link['api_config_id'], + 'is_enabled': (link['is_enabled'] as bool? ?? true) ? 1 : 0, + }); + } + } + }); + } + + Future saveFtpLinksForModule(String moduleName, List> links) async { + final db = await database; + await db.transaction((txn) async { + await txn.delete(_moduleFtpLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); + for (final link in links) { + if (link['ftp_config_id'] != null) { // Ensure ID is not null + await txn.insert(_moduleFtpLinksTable, { + 'module_name': moduleName, + 'ftp_config_id': link['ftp_config_id'], + 'is_enabled': (link['is_enabled'] as bool? ?? true) ? 1 : 0, + }); + } + } + }); + } + + Future>> getAllApiLinksForModule(String moduleName) async { + final db = await database; + final result = await db.query(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); + return result.map((row) => { + 'api_config_id': row['api_config_id'], + 'is_enabled': (row['is_enabled'] as int) == 1, + }).toList(); + } + + Future>> getAllFtpLinksForModule(String moduleName) async { + final db = await database; + final result = await db.query(_moduleFtpLinksTable, where: 'module_name = ?', whereArgs: [moduleName]); + return result.map((row) => { + 'ftp_config_id': row['ftp_config_id'], + 'is_enabled': (row['is_enabled'] as int) == 1, + }).toList(); + } +} \ No newline at end of file diff --git a/lib/services/marine_api_service.dart b/lib/services/marine_api_service.dart index f48dfbb..5ffca5b 100644 --- a/lib/services/marine_api_service.dart +++ b/lib/services/marine_api_service.dart @@ -1,15 +1,18 @@ // lib/services/marine_api_service.dart -import 'dart:convert'; // Added: Necessary for jsonEncode -import 'package:flutter/foundation.dart'; // Added: Necessary for debugPrint -import 'package:intl/intl.dart'; // Added: Necessary for DateFormat +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/telegram_service.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart'; -// Added: Necessary for ApiService.imageBaseUrl, assuming it's defined there. -// If not, you may need to adjust this. -import 'package:environment_monitoring_app/services/api_service.dart'; +import 'package:environment_monitoring_app/services/api_service.dart'; // For imageBaseUrl + +// --- ADDED: Imports for data models --- +import 'package:environment_monitoring_app/models/marine_manual_pre_departure_checklist_data.dart'; +import 'package:environment_monitoring_app/models/marine_manual_sonde_calibration_data.dart'; +import 'package:environment_monitoring_app/models/marine_manual_equipment_maintenance_data.dart'; class MarineApiService { final BaseApiService _baseService; @@ -18,6 +21,7 @@ class MarineApiService { MarineApiService(this._baseService, this._telegramService, this._serverConfigService); + // --- METHODS YOU ALREADY MOVED --- Future> getTarballStations() async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'marine/tarball/stations'); @@ -33,8 +37,6 @@ class MarineApiService { return _baseService.get(baseUrl, 'marine/tarball/classifications'); } - // --- ADDED: Method to fetch images (Fixes the issue) --- - /// Fetches image records for either In-Situ or Tarball sampling. Future> getManualSamplingImages({ required int stationId, required DateTime samplingDate, @@ -44,28 +46,22 @@ class MarineApiService { final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate); String endpoint; - // Determine the correct endpoint based on the sampling type switch (samplingType) { case 'In-Situ Sampling': endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr'; break; case 'Tarball Sampling': - // **IMPORTANT**: Please verify this is the correct endpoint for tarball records endpoint = 'marine/tarball/records-by-station?station_id=$stationId&date=$dateStr'; break; case 'All Manual Sampling': default: - // 'All' is complex. Defaulting to 'manual' (in-situ) as a fallback. endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr'; break; } debugPrint("MarineApiService: Calling API endpoint: $endpoint"); - final response = await _baseService.get(baseUrl, endpoint); - // This parsing logic assumes the server nests the list inside {'data': {'data': [...]}} - // Adjust if your API response is different if (response['success'] == true && response['data'] is Map && response['data']['data'] is List) { return { 'success': true, @@ -73,13 +69,9 @@ class MarineApiService { 'message': response['message'], }; } - - // Return original response if structure doesn't match return response; } - // --- ADDED: Method to send email request (Fixes the issue) --- - /// Sends the selected image URLs to the server for emailing. Future> sendImageRequestEmail({ required String recipientEmail, required List imageUrls, @@ -90,17 +82,96 @@ class MarineApiService { final Map fields = { 'recipientEmail': recipientEmail, - 'imageUrls': jsonEncode(imageUrls), // Encode list as JSON string + 'imageUrls': jsonEncode(imageUrls), 'stationName': stationName, 'samplingDate': samplingDate, }; - // Use postMultipart (even with no files) as it's common for this kind of endpoint return _baseService.postMultipart( baseUrl: baseUrl, - endpoint: 'marine/images/send-email', // **IMPORTANT**: Verify this endpoint + endpoint: 'marine/images/send-email', fields: fields, - files: {}, // No files being uploaded, just data + files: {}, ); } + + // --- START: ADDED MISSING METHODS --- + Future> submitPreDepartureChecklist(MarineManualPreDepartureChecklistData data) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.post(baseUrl, 'marine/checklist', data.toApiFormData()); + } + + Future> submitSondeCalibration(MarineManualSondeCalibrationData data) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.post(baseUrl, 'marine/calibration', data.toApiFormData()); + } + + Future> submitMaintenanceLog(MarineManualEquipmentMaintenanceData data) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.post(baseUrl, 'marine/maintenance', data.toApiFormData()); + } + + Future> getPreviousMaintenanceLogs() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'marine/maintenance/previous'); + } +// --- END: ADDED MISSING METHODS --- + + // *** START: ADDED FOR INVESTIGATIVE IMAGE REQUEST *** + + /// Fetches investigative sampling records based on station and date. + /// This will check against investigative logs which could have used either a manual or tarball station. + Future> getInvestigativeSamplingImages({ + required int stationId, + required DateTime samplingDate, + required String stationType, // 'Existing Manual Station' or 'Existing Tarball Station' + }) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate); + + // Pass the station type to the API so it knows which foreign key to check (station_id vs tbl_station_id) + final String stationTypeParam = Uri.encodeComponent(stationType); + + final String endpoint = + 'marine/investigative/records-by-station?station_id=$stationId&date=$dateStr&station_type=$stationTypeParam'; + + debugPrint("MarineApiService: Calling API endpoint: $endpoint"); + final response = await _baseService.get(baseUrl, endpoint); + + // Assuming the response structure is the same as the manual/tarball endpoints + if (response['success'] == true && response['data'] is Map && response['data']['data'] is List) { + return { + 'success': true, + 'data': response['data']['data'], // Return the inner 'data' list + 'message': response['message'], + }; + } + return response; + } + + /// Sends an email request for investigative images. + Future> sendInvestigativeImageRequestEmail({ + required String recipientEmail, + required List imageUrls, + required String stationName, + required String samplingDate, + }) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + + final Map fields = { + 'recipientEmail': recipientEmail, + 'imageUrls': jsonEncode(imageUrls), + 'stationName': stationName, + 'samplingDate': samplingDate, + }; + + // Use a new endpoint dedicated to the investigative module + return _baseService.postMultipart( + baseUrl: baseUrl, + endpoint: 'marine/investigative/images/send-email', + fields: fields, + files: {}, + ); + } +// *** END: ADDED FOR INVESTIGATIVE IMAGE REQUEST *** } \ No newline at end of file diff --git a/lib/services/marine_in_situ_sampling_service.dart b/lib/services/marine_in_situ_sampling_service.dart index f5b798f..e4ce69a 100644 --- a/lib/services/marine_in_situ_sampling_service.dart +++ b/lib/services/marine_in_situ_sampling_service.dart @@ -14,6 +14,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:usb_serial/usb_serial.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:intl/intl.dart'; // Import intl import 'package:provider/provider.dart'; import '../auth_provider.dart'; @@ -25,6 +26,7 @@ import 'local_storage_service.dart'; import 'server_config_service.dart'; import 'zipping_service.dart'; import 'api_service.dart'; +import 'package:environment_monitoring_app/services/database_helper.dart'; import 'submission_api_service.dart'; import 'submission_ftp_service.dart'; import 'telegram_service.dart'; @@ -217,6 +219,10 @@ class MarineInSituSamplingService { required AuthProvider authProvider, // Still needed for session check inside this method String? logDirectory, }) async { + // --- START FIX: Capture the status before attempting submission --- + final String? previousStatus = data.submissionStatus; + // --- END FIX --- + final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default'; final imageFilesWithNulls = data.toApiImageFiles(); imageFilesWithNulls.removeWhere((key, value) => value == null); @@ -367,9 +373,12 @@ class MarineInSituSamplingService { ); // 6. Send Alert - if (overallSuccess) { + // --- START FIX: Check if log was already successful before sending alert --- + final bool wasAlreadySuccessful = previousStatus == 'S4' || previousStatus == 'S3' || previousStatus == 'L4'; + if (overallSuccess && !wasAlreadySuccessful) { _handleInSituSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired); } + // --- END FIX --- return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId}; } @@ -557,43 +566,61 @@ class MarineInSituSamplingService { Future _handleInSituSuccessAlert(InSituSamplingData data, List>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async { // --- START: Logic moved from data model --- - String generateInSituTelegramAlertMessage(InSituSamplingData data, {required bool isDataOnly}) { + Future generateInSituTelegramAlertMessage(InSituSamplingData data, {required bool isDataOnly}) async { final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final stationName = data.selectedStation?['man_station_name'] ?? 'N/A'; final stationCode = data.selectedStation?['man_station_code'] ?? 'N/A'; + // --- START MODIFICATION --- + final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now()); + final submissionTime = data.samplingTime ?? DateFormat('HH:mm:ss').format(DateTime.now()); + // --- END MODIFICATION --- + final submitter = data.firstSamplerName ?? 'N/A'; final buffer = StringBuffer() ..writeln('โœ… *In-Situ Sample $submissionType Submitted:*') ..writeln() ..writeln('*Station Name & Code:* $stationName ($stationCode)') - ..writeln('*Date of Submission:* ${data.samplingDate}') - ..writeln('*Submitted by User:* ${data.firstSamplerName}') + // --- START MODIFICATION --- + ..writeln('*Date & Time of Submission:* $submissionDate $submissionTime') + // --- END MODIFICATION --- + ..writeln('*Submitted by User:* $submitter') ..writeln('*Sonde ID:* ${data.sondeId ?? "N/A"}') ..writeln('*Status of Submission:* Successful'); final distanceKm = data.distanceDifferenceInKm ?? 0; - final distanceRemarks = data.distanceDifferenceRemarks ?? ''; - if (distanceKm * 1000 > 50) { // Check distance > 50m + final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); + final distanceRemarks = data.distanceDifferenceRemarks ?? 'N/A'; + if (distanceKm * 1000 > 50 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { // Check distance > 50m buffer ..writeln() ..writeln('๐Ÿ”” *Distance Alert:*') - ..writeln('*Distance from station:* ${(distanceKm * 1000).toStringAsFixed(0)} meters'); + ..writeln('*Distance from station:* $distanceMeters meters (${distanceKm.toStringAsFixed(3)} KM)'); - if (distanceRemarks.isNotEmpty) { + if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') { buffer.writeln('*Remarks for distance:* $distanceRemarks'); } } - // Note: The logic to check parameter limits requires async DB access, - // which cannot be done directly here without further refactoring. - // This part is omitted for now as per the previous refactor. + // --- START: MODIFICATION (Add both alert types) --- + // 1. Add station parameter limit check section + final outOfBoundsAlert = await _getOutOfBoundsAlertSection(data); + if (outOfBoundsAlert.isNotEmpty) { + buffer.write(outOfBoundsAlert); + } + + // 2. Add NPE parameter limit check section + final npeAlert = await _getNpeAlertSection(data); + if (npeAlert.isNotEmpty) { + buffer.write(npeAlert); + } + // --- END: MODIFICATION --- return buffer.toString(); } // --- END: Logic moved from data model --- try { - final message = generateInSituTelegramAlertMessage(data, isDataOnly: isDataOnly); // Call local function + final message = await generateInSituTelegramAlertMessage(data, isDataOnly: isDataOnly); // Call local function final alertKey = 'marine_in_situ'; // Correct key if (isSessionExpired) { @@ -609,4 +636,167 @@ class MarineInSituSamplingService { debugPrint("Failed to handle In-Situ Telegram alert: $e"); } } + + /// Helper to generate the station-specific parameter limit alert section for Telegram. + Future _getOutOfBoundsAlertSection(InSituSamplingData data) async { + // Define mapping from data model keys to parameter names used in limits table + const Map _parameterKeyToLimitName = { + 'oxygenConcentration': 'Oxygen Conc', 'oxygenSaturation': 'Oxygen Sat', 'ph': 'pH', + 'salinity': 'Salinity', 'electricalConductivity': 'Conductivity', 'temperature': 'Temperature', + 'tds': 'TDS', 'turbidity': 'Turbidity', 'tss': 'TSS', 'batteryVoltage': 'Battery', + }; + + // Load marine limits specific to the station + final allLimits = await _dbHelper.loadMarineParameterLimits() ?? []; + if (allLimits.isEmpty) return ""; + + final int? stationId = data.selectedStation?['station_id']; + if (stationId == null) return ""; // Cannot check limits without a station ID + + final readings = { + 'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation, + 'ph': data.ph, 'salinity': data.salinity, 'electricalConductivity': data.electricalConductivity, + 'temperature': data.temperature, 'tds': data.tds, 'turbidity': data.turbidity, + 'tss': data.tss, 'batteryVoltage': data.batteryVoltage, + }; + + final List outOfBoundsMessages = []; + + double? parseLimitValue(dynamic value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; + } + + readings.forEach((key, value) { + // This check handles "NPE" / null / invalid values + if (value == null || value == -999.0) return; + + final limitName = _parameterKeyToLimitName[key]; + if (limitName == null) return; + + // Find the limit data for this parameter AND this specific station + final limitData = allLimits.firstWhere( + (l) => l['param_parameter_list'] == limitName && l['station_id']?.toString() == stationId.toString(), + orElse: () => {}, // Use explicit type + ); + + if (limitData.isNotEmpty) { + final lowerLimit = parseLimitValue(limitData['param_lower_limit']); + final upperLimit = parseLimitValue(limitData['param_upper_limit']); + + if ((lowerLimit != null && value < lowerLimit) || (upperLimit != null && value > upperLimit)) { + final valueStr = value.toStringAsFixed(5); + final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A'; + final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/A'; + outOfBoundsMessages.add('- *$limitName*: `$valueStr` (Station Limit: `$lowerStr` - `$upperStr`)'); + } + } + }); + + if (outOfBoundsMessages.isEmpty) { + return ""; + } + + final buffer = StringBuffer() + ..writeln() + ..writeln('โš ๏ธ *Station Parameter Limit Alert:*') + ..writeln('The following parameters were outside their defined station limits:'); + buffer.writeAll(outOfBoundsMessages, '\n'); + + return buffer.toString(); + } + + // --- START: NEW METHOD --- + /// Helper to generate the NPE parameter limit alert section for Telegram. + Future _getNpeAlertSection(InSituSamplingData data) async { + // Define mapping from data model keys to parameter names used in limits table + const Map _parameterKeyToLimitName = { + 'oxygenConcentration': 'Oxygen Conc', 'oxygenSaturation': 'Oxygen Sat', 'ph': 'pH', + 'salinity': 'Salinity', 'electricalConductivity': 'Conductivity', 'temperature': 'Temperature', + 'tds': 'TDS', 'turbidity': 'Turbidity', 'tss': 'TSS', + // Note: Battery is usually not an NPE parameter + }; + + // Load general NPE limits + final npeLimits = await _dbHelper.loadNpeParameterLimits() ?? []; + if (npeLimits.isEmpty) return ""; + + final readings = { + 'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation, + 'ph': data.ph, 'salinity': data.salinity, 'electricalConductivity': data.electricalConductivity, + 'temperature': data.temperature, 'tds': data.tds, 'turbidity': data.turbidity, + 'tss': data.tss, + }; + + final List npeMessages = []; + + double? parseLimitValue(dynamic value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; + } + + readings.forEach((key, value) { + if (value == null || value == -999.0) return; + + final limitName = _parameterKeyToLimitName[key]; + if (limitName == null) return; + + // Find the general NPE limit for this parameter + final limitData = npeLimits.firstWhere( + (l) => l['param_parameter_list'] == limitName, + orElse: () => {}, + ); + + if (limitData.isNotEmpty) { + final lowerLimit = parseLimitValue(limitData['param_lower_limit']); + final upperLimit = parseLimitValue(limitData['param_upper_limit']); + bool isHit = false; + + // Check the different types of NPE limits + if (lowerLimit != null && upperLimit != null) { + // Range limit (e.g., pH 5-6) + if (value >= lowerLimit && value <= upperLimit) isHit = true; + } else if (lowerLimit != null && upperLimit == null) { + // Lower bound limit (e.g., Turbidity >= 100) + if (value >= lowerLimit) isHit = true; + } else if (upperLimit != null && lowerLimit == null) { + // Upper bound limit (e.g., DO <= 2) + if (value <= upperLimit) isHit = true; + } + + if (isHit) { + final valueStr = value.toStringAsFixed(5); + final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A'; + final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/A'; + String limitStr; + if (lowerStr != 'N/A' && upperStr != 'N/A') { + limitStr = '$lowerStr - $upperStr'; + } else if (lowerStr != 'N/A') { + limitStr = '>= $lowerStr'; + } else { + limitStr = '<= $upperStr'; + } + npeMessages.add('- *$limitName*: `$valueStr` (NPE Limit: `$limitStr`)'); + } + } + }); + + if (npeMessages.isEmpty) { + return ""; + } + + final buffer = StringBuffer() + ..writeln() + ..writeln(' ') + ..writeln('๐Ÿšจ *NPE Parameter Limit Detected:*') + ..writeln('The following parameters triggered an NPE alert:'); + buffer.writeAll(npeMessages, '\n'); + + return buffer.toString(); + } +// --- END: NEW METHOD --- } \ No newline at end of file diff --git a/lib/services/marine_investigative_sampling_service.dart b/lib/services/marine_investigative_sampling_service.dart index be746dd..5f03dba 100644 --- a/lib/services/marine_investigative_sampling_service.dart +++ b/lib/services/marine_investigative_sampling_service.dart @@ -29,6 +29,8 @@ import 'telegram_service.dart'; import 'retry_service.dart'; import 'base_api_service.dart'; // Import for SessionExpiredException import 'api_service.dart'; // Import for DatabaseHelper +import 'package:environment_monitoring_app/services/database_helper.dart'; + /// A dedicated service for the Marine Investigative Sampling feature. class MarineInvestigativeSamplingService { diff --git a/lib/services/marine_manual_equipment_maintenance_service.dart b/lib/services/marine_manual_equipment_maintenance_service.dart index 2e64064..fbf8be4 100644 --- a/lib/services/marine_manual_equipment_maintenance_service.dart +++ b/lib/services/marine_manual_equipment_maintenance_service.dart @@ -6,6 +6,8 @@ import 'dart:io'; import '../auth_provider.dart'; import '../models/marine_manual_equipment_maintenance_data.dart'; import 'api_service.dart'; +import 'package:environment_monitoring_app/services/database_helper.dart'; + import 'base_api_service.dart'; // Import for SessionExpiredException class MarineManualEquipmentMaintenanceService { diff --git a/lib/services/marine_manual_pre_departure_service.dart b/lib/services/marine_manual_pre_departure_service.dart index 4ebc277..ad0827d 100644 --- a/lib/services/marine_manual_pre_departure_service.dart +++ b/lib/services/marine_manual_pre_departure_service.dart @@ -6,6 +6,8 @@ import 'dart:io'; import '../auth_provider.dart'; import '../models/marine_manual_pre_departure_checklist_data.dart'; import 'api_service.dart'; +import 'package:environment_monitoring_app/services/database_helper.dart'; + import 'base_api_service.dart'; // Import for SessionExpiredException class MarineManualPreDepartureService { diff --git a/lib/services/marine_manual_sonde_calibration_service.dart b/lib/services/marine_manual_sonde_calibration_service.dart index 9dcaea6..690bb51 100644 --- a/lib/services/marine_manual_sonde_calibration_service.dart +++ b/lib/services/marine_manual_sonde_calibration_service.dart @@ -6,6 +6,8 @@ import 'dart:io'; import '../auth_provider.dart'; import '../models/marine_manual_sonde_calibration_data.dart'; import 'api_service.dart'; +import 'package:environment_monitoring_app/services/database_helper.dart'; + import 'base_api_service.dart'; // Import for SessionExpiredException class MarineManualSondeCalibrationService { diff --git a/lib/services/marine_npe_report_service.dart b/lib/services/marine_npe_report_service.dart index 2a109f3..16139e5 100644 --- a/lib/services/marine_npe_report_service.dart +++ b/lib/services/marine_npe_report_service.dart @@ -15,6 +15,8 @@ import 'submission_ftp_service.dart'; import 'telegram_service.dart'; import 'retry_service.dart'; import 'api_service.dart'; +import 'package:environment_monitoring_app/services/database_helper.dart'; + class MarineNpeReportService { final SubmissionApiService _submissionApiService = SubmissionApiService(); diff --git a/lib/services/marine_tarball_sampling_service.dart b/lib/services/marine_tarball_sampling_service.dart index e8cb729..1b6e07f 100644 --- a/lib/services/marine_tarball_sampling_service.dart +++ b/lib/services/marine_tarball_sampling_service.dart @@ -8,12 +8,15 @@ import 'package:path/path.dart' as p; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:provider/provider.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; // <-- Import intl import 'package:environment_monitoring_app/models/tarball_data.dart'; import 'package:environment_monitoring_app/services/local_storage_service.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart'; import 'package:environment_monitoring_app/services/zipping_service.dart'; -import 'package:environment_monitoring_app/services/api_service.dart'; +//import 'packagepackage:environment_monitoring_app/services/api_service.dart'; +import 'package:environment_monitoring_app/services/database_helper.dart'; + import 'package:environment_monitoring_app/services/submission_api_service.dart'; import 'package:environment_monitoring_app/services/submission_ftp_service.dart'; import 'package:environment_monitoring_app/services/telegram_service.dart'; @@ -449,23 +452,46 @@ class MarineTarballSamplingService { final stationName = data.selectedStation?['tbl_station_name'] ?? 'N/A'; final stationCode = data.selectedStation?['tbl_station_code'] ?? 'N/A'; final classification = data.selectedClassification?['classification_name'] ?? data.classificationId?.toString() ?? 'N/A'; + // --- START MODIFICATION: Add time --- + final submissionDate = data.samplingDate ?? DateFormat('yyyy-MM-dd').format(DateTime.now()); + final submissionTime = data.samplingTime ?? DateFormat('HH:mm:ss').format(DateTime.now()); + // --- END MODIFICATION --- final buffer = StringBuffer() ..writeln('โœ… *Tarball Sample $submissionType Submitted:*') ..writeln() ..writeln('*Station Name & Code:* $stationName ($stationCode)') - ..writeln('*Date of Submission:* ${data.samplingDate}') + // --- START MODIFICATION: Add time --- + ..writeln('*Date & Time of Submission:* $submissionDate $submissionTime') + // --- END MODIFICATION --- ..writeln('*Submitted by User:* ${data.firstSampler}') // Use firstSampler from data model - ..writeln('*Classification:* $classification') ..writeln('*Status of Submission:* Successful'); + // --- START MODIFICATION: Add Tarball Detected Alert --- + final bool isTarballDetected = classification.isNotEmpty && classification != 'N/A' && classification.toLowerCase() != 'none'; + if (isTarballDetected) { + buffer + ..writeln() + ..writeln('๐Ÿ”” *Tarball Detected:*') + ..writeln('*Classification:* $classification'); + } else { + // If not detected, still show classification + buffer.writeln('*Classification:* $classification'); + } + // --- END MODIFICATION --- + final distanceKm = data.distanceDifference ?? 0; // Use distanceDifference from data model + final distanceMeters = (distanceKm * 1000).toStringAsFixed(0); final distanceRemarks = data.distanceDifferenceRemarks ?? ''; - if (distanceKm * 1000 > 50) { // Check distance > 50m + + // Check distance > 50m OR if remarks were provided anyway + if (distanceKm * 1000 > 50 || (distanceRemarks.isNotEmpty)) { buffer ..writeln() ..writeln('๐Ÿ”” *Distance Alert:*') - ..writeln('*Distance from station:* ${(distanceKm * 1000).toStringAsFixed(0)} meters'); + // --- START MODIFICATION: Add KM --- + ..writeln('*Distance from station:* $distanceMeters meters (${distanceKm.toStringAsFixed(3)} KM)'); + // --- END MODIFICATION --- if (distanceRemarks.isNotEmpty) { buffer.writeln('*Remarks for distance:* $distanceRemarks'); diff --git a/lib/services/retry_service.dart b/lib/services/retry_service.dart index 15d8cbf..585acb4 100644 --- a/lib/services/retry_service.dart +++ b/lib/services/retry_service.dart @@ -17,7 +17,9 @@ import 'package:environment_monitoring_app/models/marine_inves_manual_sampling_d import 'package:environment_monitoring_app/services/marine_investigative_sampling_service.dart'; import 'package:environment_monitoring_app/models/tarball_data.dart'; import 'package:environment_monitoring_app/services/marine_tarball_sampling_service.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/database_helper.dart'; + import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/ftp_service.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart'; diff --git a/lib/services/river_api_service.dart b/lib/services/river_api_service.dart index bb8e7c7..16cb98f 100644 --- a/lib/services/river_api_service.dart +++ b/lib/services/river_api_service.dart @@ -1,5 +1,6 @@ // lib/services/river_api_service.dart +import 'dart:convert'; // <-- ADDED for jsonEncode import 'package:intl/intl.dart'; import 'package:flutter/foundation.dart'; import 'package:environment_monitoring_app/services/base_api_service.dart'; @@ -22,4 +23,60 @@ class RiverApiService { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'river/triennial-stations'); } + + // --- START: MODIFIED METHOD --- + Future> getRiverSamplingImages({ + required int stationId, + required DateTime samplingDate, + required String samplingType, // This parameter is now USED + }) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate); + + // Dynamically determine the API path based on sampling type + String apiPath; + if (samplingType == 'In-Situ Sampling') { + apiPath = 'river/manual/images-by-station'; + } else if (samplingType == 'Triennial Sampling') { + apiPath = 'river/triennial/images-by-station'; + } else if (samplingType == 'Investigative Sampling') { // <-- ADDED + apiPath = 'river-investigative/images-by-station'; // <-- ADDED (Points to new controller) + } else { + // Fallback or error + debugPrint("Unknown samplingType for image request: $samplingType"); + apiPath = 'river/manual/images-by-station'; // Default fallback + } + + // Build the final endpoint with the correct path + final String endpoint = '$apiPath?station_id=$stationId&date=$dateStr'; + + debugPrint("ApiService: Calling river image request API endpoint: $endpoint"); + + final response = await _baseService.get(baseUrl, endpoint); + return response; + } + // --- END: MODIFIED METHOD --- + + Future> sendImageRequestEmail({ + required String recipientEmail, + required List imageUrls, + required String stationName, + required String samplingDate, + }) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + final Map fields = { + 'recipientEmail': recipientEmail, + 'imageUrls': jsonEncode(imageUrls), + 'stationName': stationName, + 'samplingDate': samplingDate, + }; + + return _baseService.postMultipart( + baseUrl: baseUrl, + endpoint: 'river/images/send-email', // Endpoint for river email requests + fields: fields, + files: {}, + ); + } +// --- END: ADDED MISSING METHODS --- } \ No newline at end of file diff --git a/lib/services/river_in_situ_sampling_service.dart b/lib/services/river_in_situ_sampling_service.dart index aafce36..33e4b09 100644 --- a/lib/services/river_in_situ_sampling_service.dart +++ b/lib/services/river_in_situ_sampling_service.dart @@ -23,6 +23,8 @@ import '../models/river_in_situ_sampling_data.dart'; import '../bluetooth/bluetooth_manager.dart'; import '../serial/serial_manager.dart'; import 'api_service.dart'; +import 'package:environment_monitoring_app/services/database_helper.dart'; + import 'local_storage_service.dart'; import 'server_config_service.dart'; import 'zipping_service.dart'; @@ -72,7 +74,8 @@ class RiverInSituSamplingService { return null; } - if (isRequired && originalImage.height > originalImage.width) { + // โœ… FIX: Apply landscape check to ALL photos, not just required ones. + if (originalImage.height > originalImage.width) { debugPrint("Image rejected: Must be in landscape orientation."); return null; } @@ -213,6 +216,10 @@ class RiverInSituSamplingService { required AuthProvider authProvider, String? logDirectory, }) async { + // --- START FIX: Capture the status before attempting submission --- + final String? previousStatus = data.submissionStatus; + // --- END FIX --- + final serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default'; final imageFilesWithNulls = data.toApiImageFiles(); imageFilesWithNulls.removeWhere((key, value) => value == null); @@ -368,9 +375,12 @@ class RiverInSituSamplingService { ); // 6. Send Alert - if (overallSuccess) { + // --- START FIX: Check if log was already successful before sending alert --- + final bool wasAlreadySuccessful = previousStatus == 'S4' || previousStatus == 'S3' || previousStatus == 'L4'; + if (overallSuccess && !wasAlreadySuccessful) { _handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired); } + // --- END FIX --- // Return consistent format return { diff --git a/lib/services/river_investigative_sampling_service.dart b/lib/services/river_investigative_sampling_service.dart index aaca416..14d6e5a 100644 --- a/lib/services/river_investigative_sampling_service.dart +++ b/lib/services/river_investigative_sampling_service.dart @@ -23,6 +23,7 @@ import '../models/river_inves_manual_sampling_data.dart'; // Use Investigative m import '../bluetooth/bluetooth_manager.dart'; import '../serial/serial_manager.dart'; import 'api_service.dart'; // Keep ApiService import for DatabaseHelper access within service if needed, or remove if unused directly +import 'package:environment_monitoring_app/services/database_helper.dart'; import 'local_storage_service.dart'; import 'server_config_service.dart'; import 'zipping_service.dart'; @@ -73,8 +74,8 @@ class RiverInvestigativeSamplingService { // Renamed class return null; } - // Keep landscape requirement for required photos - if (isRequired && originalImage.height > originalImage.width) { + // โœ… FIX: Apply landscape check to ALL photos, not just required ones. + if (originalImage.height > originalImage.width) { debugPrint("Image rejected: Must be in landscape orientation."); return null; } @@ -290,7 +291,7 @@ class RiverInvestigativeSamplingService { // Renamed class isSessionKnownToBeExpired = true; anyApiSuccess = false; apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'}; - // Manually queue API calls if session expired during attempt + // Manually queue API calls // *** MODIFIED: Use Investigative endpoints for queueing *** await _retryService.addApiToQueue(endpoint: 'river/investigative/sample', method: 'POST', body: data.toApiFormData()); if (finalImageFiles.isNotEmpty && data.reportId != null) { diff --git a/lib/services/river_manual_triennial_sampling_service.dart b/lib/services/river_manual_triennial_sampling_service.dart index a30c835..992d2e6 100644 --- a/lib/services/river_manual_triennial_sampling_service.dart +++ b/lib/services/river_manual_triennial_sampling_service.dart @@ -23,6 +23,8 @@ import '../models/river_manual_triennial_sampling_data.dart'; import '../bluetooth/bluetooth_manager.dart'; import '../serial/serial_manager.dart'; import 'api_service.dart'; // Keep DatabaseHelper import +import 'package:environment_monitoring_app/services/database_helper.dart'; + import 'local_storage_service.dart'; import 'server_config_service.dart'; import 'zipping_service.dart'; @@ -72,7 +74,8 @@ class RiverManualTriennialSamplingService { return null; } - if (isRequired && originalImage.height > originalImage.width) { + // โœ… FIX: Apply landscape check to ALL photos, not just required ones. + if (originalImage.height > originalImage.width) { debugPrint("Image rejected: Must be in landscape orientation."); return null; } diff --git a/lib/services/submission_ftp_service.dart b/lib/services/submission_ftp_service.dart index fd96731..9dcdce3 100644 --- a/lib/services/submission_ftp_service.dart +++ b/lib/services/submission_ftp_service.dart @@ -10,7 +10,8 @@ import 'package:environment_monitoring_app/services/ftp_service.dart'; import 'package:environment_monitoring_app/services/retry_service.dart'; // Import necessary services and models if needed for queueFtpTasksForSkippedAttempt import 'package:environment_monitoring_app/services/zipping_service.dart'; -import 'package:environment_monitoring_app/services/api_service.dart'; // For DatabaseHelper +//import 'package:environment_monitoring_app/services/api_service.dart'; // For DatabaseHelper +import 'package:environment_monitoring_app/services/database_helper.dart'; /// A generic, reusable service for handling the FTP submission process. /// It respects user preferences for enabled destinations for any given module. diff --git a/lib/services/telegram_service.dart b/lib/services/telegram_service.dart index 5bf6f74..b01c3b7 100644 --- a/lib/services/telegram_service.dart +++ b/lib/services/telegram_service.dart @@ -3,6 +3,8 @@ import 'package:flutter/foundation.dart'; import 'package:sqflite/sqflite.dart'; import 'package:environment_monitoring_app/services/api_service.dart'; +import 'package:environment_monitoring_app/services/database_helper.dart'; + import 'package:environment_monitoring_app/services/settings_service.dart'; class TelegramService { diff --git a/lib/services/user_preferences_service.dart b/lib/services/user_preferences_service.dart index 65a64af..b0efd05 100644 --- a/lib/services/user_preferences_service.dart +++ b/lib/services/user_preferences_service.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:environment_monitoring_app/services/api_service.dart'; // Contains DatabaseHelper +import 'package:environment_monitoring_app/services/database_helper.dart'; /// A dedicated service to manage the user's local preferences for /// module-specific submission destinations.