repair comment for marine department

This commit is contained in:
ALim Aidrus 2025-11-11 21:15:29 +08:00
parent d77a0ed8e9
commit da892821a2
51 changed files with 6719 additions and 1481 deletions

View File

@ -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<Map<String, dynamic>>? _tarballClassifications;
List<Map<String, dynamic>>? _riverManualStations;
List<Map<String, dynamic>>? _riverTriennialStations;
// --- ADDED: River Investigative Stations ---
List<Map<String, dynamic>>? _riverInvestigativeStations;
// --- END ADDED ---
List<Map<String, dynamic>>? _departments;
List<Map<String, dynamic>>? _companies;
List<Map<String, dynamic>>? _positions;
@ -83,7 +83,7 @@ class AuthProvider with ChangeNotifier {
List<Map<String, dynamic>>? get riverManualStations => _riverManualStations;
List<Map<String, dynamic>>? get riverTriennialStations => _riverTriennialStations;
// --- ADDED: Getter for River Investigative Stations ---
List<Map<String, dynamic>>? get riverInvestigativeStations => _riverInvestigativeStations;
List<Map<String, dynamic>>? get riverInvestigativeStations => _riverManualStations;
// --- END ADDED ---
List<Map<String, dynamic>>? get departments => _departments;
List<Map<String, dynamic>>? 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;

View File

@ -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<RootApp> {
// *** 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<RootApp> {
'/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(),

View File

@ -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<String, dynamic>? 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();
}
}

View File

@ -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<String, dynamic> 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<MarineInvestigativeDataStatusLog> createState() =>
_MarineInvestigativeDataStatusLogState();
}
class _MarineInvestigativeDataStatusLogState
extends State<MarineInvestigativeDataStatusLog> {
final LocalStorageService _localStorageService = LocalStorageService();
late MarineInvestigativeSamplingService _marineInvestigativeService;
List<SubmissionLogEntry> _investigativeLogs = [];
List<SubmissionLogEntry> _filteredInvestigativeLogs = [];
final TextEditingController _investigativeSearchController =
TextEditingController();
bool _isLoading = true;
final Map<String, bool> _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<MarineInvestigativeSamplingService>(context);
}
@override
void dispose() {
_investigativeSearchController.dispose();
super.dispose();
}
String _getStationName(Map<String, dynamic> 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<String, dynamic> 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<void> _loadAllLogs() async {
setState(() => _isLoading = true);
final investigativeLogs =
await _localStorageService.getAllInvestigativeLogs();
final List<SubmissionLogEntry> 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<void> _resubmitData(SubmissionLogEntry log) async {
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
if (mounted) {
setState(() {
_isResubmitting[logKey] = true;
});
}
try {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings;
Map<String, dynamic> 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<SubmissionLogEntry> 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>[
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<String, dynamic> 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<String, dynamic> data = log.rawData;
final List<TableRow> 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<ImageLogEntry> 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<dynamic> statuses;
try {
statuses = jsonDecode(jsonStatus);
} catch (_) {
return _buildDetailRow('$type Status:', jsonStatus);
}
if (statuses.isEmpty) {
return Container();
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$type Status:',
style: const TextStyle(fontWeight: FontWeight.bold)),
...statuses.map((s) {
final serverName =
s['server_name'] ?? 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)),
],
),
);
}
}

View File

@ -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<String> _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<MarineInvestigativeImageRequestScreen> createState() =>
_MarineInvestigativeImageRequestScreenState();
}
class _MarineInvestigativeImageRequestScreenState
extends State<MarineInvestigativeImageRequestScreen> {
final _formKey = GlobalKey<FormState>();
final _dateController = TextEditingController();
// Based on the Investigative data model, users can sample at Manual or Tarball stations
String? _selectedStationType = 'Existing Manual Station';
final List<String> _stationTypes = [
'Existing Manual Station',
'Existing Tarball Station'
];
String? _selectedStateName;
String? _selectedCategoryName;
Map<String, dynamic>? _selectedStation;
DateTime? _selectedDate;
List<String> _statesList = [];
List<String> _categoriesForState = [];
List<Map<String, dynamic>> _stationsForCategory = [];
bool _isLoading = false;
List<String> _imageUrls = [];
final Set<String> _selectedImageUrls = {};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_initializeStationFilters();
}
});
}
@override
void dispose() {
_dateController.dispose();
super.dispose();
}
List<Map<String, dynamic>> _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<AuthProvider>(context, listen: false);
final allStations = _getStationsForType(auth);
if (allStations.isNotEmpty) {
final states = allStations
.map((s) => s['state_name'] as String?)
.whereType<String>()
.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<void> _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<void> _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<ApiService>(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<Map<String, dynamic>> records =
List<Map<String, dynamic>>.from(result['data'] ?? []);
final List<String> fetchedUrls = [];
// For investigative, we only use one set of keys
final List<String> 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<void> _showEmailDialog() async {
final emailController = TextEditingController();
final dialogFormKey = GlobalKey<FormState>();
return showDialog<void>(
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: <Widget>[
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<void> _sendEmailRequestToServer(String toEmail) async {
final apiService = Provider.of<ApiService>(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<String>(
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<String>(
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<AuthProvider>(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<String>()
.toSet()
.toList()
: <String>[];
categories.sort();
_categoriesForState = categories;
_stationsForCategory = [];
});
},
validator: (val) => val == null ? "State is required" : null,
),
const SizedBox(height: 16),
DropdownSearch<String>(
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<AuthProvider>(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<Map<String, dynamic>>(
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),
),
],
),
),
),
);
},
);
}
}

View File

@ -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<MarineManualDataStatusLog> {
late MarineInSituSamplingService _marineInSituService;
late MarineTarballSamplingService _marineTarballService;
// --- START: MODIFIED STATE FOR DROPDOWN ---
String _selectedModule = 'Manual Sampling';
final List<String> _modules = ['Manual Sampling', 'Tarball Sampling', 'Pre-Sampling', 'Report'];
final TextEditingController _searchController = TextEditingController();
List<SubmissionLogEntry> _manualLogs = [];
List<SubmissionLogEntry> _tarballLogs = [];
List<SubmissionLogEntry> _preSamplingLogs = []; // No data source, will be empty
List<SubmissionLogEntry> _reportLogs = []; // Will hold NPE logs
List<SubmissionLogEntry> _filteredManualLogs = [];
List<SubmissionLogEntry> _filteredTarballLogs = [];
final TextEditingController _manualSearchController = TextEditingController();
final TextEditingController _tarballSearchController = TextEditingController();
List<SubmissionLogEntry> _filteredPreSamplingLogs = [];
List<SubmissionLogEntry> _filteredReportLogs = [];
// --- END: MODIFIED STATE ---
bool _isLoading = true;
final Map<String, bool> _isResubmitting = {};
@ -75,8 +92,7 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
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<MarineManualDataStatusLog> {
void didChangeDependencies() {
super.didChangeDependencies();
// Fetch the single, global instances of the services from the Provider tree.
_marineInSituService = Provider.of<MarineInSituSamplingService>(context);
_marineTarballService = Provider.of<MarineTarballSamplingService>(context);
_marineInSituService = Provider.of<MarineInSituSamplingService>(context, listen: false);
_marineTarballService = Provider.of<MarineTarballSamplingService>(context, listen: false);
}
@override
void dispose() {
_manualSearchController.dispose();
_tarballSearchController.dispose();
_searchController.dispose(); // Dispose single search controller
super.dispose();
}
Future<void> _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<SubmissionLogEntry> tempManual = [];
final List<SubmissionLogEntry> tempTarball = [];
final List<SubmissionLogEntry> tempReport = [];
final List<SubmissionLogEntry> 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<MarineManualDataStatusLog> {
));
}
// 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<MarineManualDataStatusLog> {
));
}
// --- 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<MarineManualDataStatusLog> {
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<MarineManualDataStatusLog> {
}
}
// --- 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<SubmissionLogEntry> 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<String>(
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<SubmissionLogEntry> 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<MarineManualDataStatusLog> {
// 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<MarineManualDataStatusLog> {
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<MarineManualDataStatusLog> {
_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<MarineManualDataStatusLog> {
);
}
// --- 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<String, dynamic> 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<String, dynamic> data = log.rawData;
final List<TableRow> 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<String, dynamic>;
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, dynamic>;
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<ImageLogEntry> 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<String, String?> imageRemarkMap, List<ImageLogEntry> 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<dynamic> statuses;
try {
statuses = jsonDecode(jsonStatus);
} catch (_) {
return _buildDetailRow('$type Status:', jsonStatus);
}
if (statuses.isEmpty) {
return Container();
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$type Status:', style: const TextStyle(fontWeight: FontWeight.bold)),
...statuses.map((s) {
final serverName = s['server_name'] ?? 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),

View File

@ -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(

View File

@ -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<String, TextEditingController> _vanDornLastDateControllers = {};
final Map<String, TextEditingController> _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<void> _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<void> _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<String>(
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<String>(
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<String>(
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,
),
),
],

View File

@ -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,

View File

@ -25,14 +25,65 @@ class _MarineManualSondeCalibrationScreenState
bool _isOnline = true;
late StreamSubscription<List<ConnectivityResult>> _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<void> _showErrorDialog() async {
return showDialog<void>(
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: <Widget>[
TextButton(
child: const Text('OK'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}
Future<void> _selectDateTime(TextEditingController controller) async {
final date = await showDatePicker(
context: context,
@ -97,13 +238,19 @@ class _MarineManualSondeCalibrationScreenState
}
Future<void> _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<MarineManualSondeCalibrationService>(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<String>(
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<String>(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<String>(
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<String>(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
),
],
),

View File

@ -33,12 +33,23 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
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<InSituSamplingData> _recentNearbySamples = [];
InSituSamplingData? _selectedRecentSample;
final MarineManualNpeReportData _npeData = MarineManualNpeReportData();
// "No" path: Manual station selection
List<String> _statesList = [];
List<String> _categoriesForState = [];
List<Map<String, dynamic>> _stationsForCategory = [];
String? _selectedState;
String? _selectedCategory;
Map<String, dynamic>? _selectedManualStation;
// --- END: MODIFIED STATE VARIABLES ---
// Controllers
final _stationIdController = TextEditingController();
final _locationController = TextEditingController();
@ -47,17 +58,19 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
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<NPEReportFromInSitu> {
void initState() {
super.initState();
_samplingService = Provider.of<MarineInSituSamplingService>(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<NPEReportFromInSitu> {
_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<AuthProvider>(context, listen: false);
// Only load manual stations as requested
final allStations = auth.manualStations ?? [];
final states = <String>{};
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<void> _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<NPEReportFromInSitu> {
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<NPEReportFromInSitu> {
}
}
// 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<LocalStorageService>(context, listen: false);
final samples = await localDbService.getRecentNearbySamples(
@ -161,7 +223,6 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
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<NPEReportFromInSitu> {
}
Future<void> _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<AuthProvider>(context, listen: false);
final service = Provider.of<MarineNpeReportService>(context, listen: false);
@ -185,11 +258,13 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
_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<NPEReportFromInSitu> {
_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<NPEReportFromInSitu> {
}
}
Future<void> _showImageErrorDialog(String message) async {
if (!mounted) return;
return showDialog<void>(
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: <Widget>[
TextButton(
child: const Text('OK'),
onPressed: () {
Navigator.of(dialogContext).pop();
},
),
],
);
},
);
}
Future<void> _processAndSetImage(ImageSource source, int imageNumber) async {
if (_isPickingImage) return;
setState(() => _isPickingImage = true);
@ -229,7 +356,7 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
source,
data: watermarkData,
imageInfo: 'NPE ATTACHMENT $imageNumber',
isRequired: false,
isRequired: true,
);
if (file != null) {
@ -241,11 +368,16 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
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<String, double> readings) {
const defaultValue = -999.0;
setState(() {
@ -408,11 +540,14 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
});
}
}
// --- END: ADDED IN-SITU DEVICE CONNECTION AND READING METHODS ---
// --- END: IN-SITU DEVICE METHODS (Unchanged) ---
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthProvider>(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<NPEReportFromInSitu> {
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<InSituSamplingData>(
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<bool>(
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<bool>(
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<InSituSamplingData>(
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<String>(
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<String>(
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<Map<String, dynamic>>(
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<NPEReportFromInSitu> {
_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<NPEReportFromInSitu> {
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<NPEReportFromInSitu> {
);
}
// FIXED: Correct implementation for _buildObservationsCheckboxes
List<Widget> _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<NPEReportFromInSitu> {
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<NPEReportFromInSitu> {
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<NPEReportFromInSitu> {
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<NPEReportFromInSitu> {
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<NPEReportFromInSitu> {
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<NPEReportFromInSitu> {
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<NPEReportFromInSitu> {
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<NPEReportFromInSitu> {
)
],
)
// 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<NPEReportFromInSitu> {
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 ---
}

View File

@ -53,6 +53,11 @@ class _NPEReportFromTarballState extends State<NPEReportFromTarball> {
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<NPEReportFromTarball> {
_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<NPEReportFromTarball> {
_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<void> _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<AuthProvider>(context, listen: false);
final service = Provider.of<MarineNpeReportService>(context, listen: false);
@ -165,6 +198,12 @@ class _NPEReportFromTarballState extends State<NPEReportFromTarball> {
_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<NPEReportFromTarball> {
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<NPEReportFromTarball> {
_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<NPEReportFromTarball> {
_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<NPEReportFromTarball> {
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<NPEReportFromTarball> {
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<NPEReportFromTarball> {
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<NPEReportFromTarball> {
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<NPEReportFromTarball> {
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<NPEReportFromTarball> {
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<NPEReportFromTarball> {
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(

View File

@ -50,6 +50,11 @@ class _NPEReportNewLocationState extends State<NPEReportNewLocation> {
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<NPEReportNewLocation> {
_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<NPEReportNewLocation> {
}
Future<void> _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<AuthProvider>(context, listen: false);
final service = Provider.of<MarineNpeReportService>(context, listen: false);
@ -161,6 +188,12 @@ class _NPEReportNewLocationState extends State<NPEReportNewLocation> {
_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<NPEReportNewLocation> {
..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<NPEReportNewLocation> {
_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<NPEReportNewLocation> {
_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<NPEReportNewLocation> {
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<NPEReportNewLocation> {
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<NPEReportNewLocation> {
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<NPEReportNewLocation> {
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<NPEReportNewLocation> {
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<NPEReportNewLocation> {
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<NPEReportNewLocation> {
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(

View File

@ -464,7 +464,9 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
children: <TextSpan>[
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),

View File

@ -86,7 +86,7 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
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"),

View File

@ -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<InSituStep1SamplingInfo> {
style: Theme.of(context).textTheme.bodyLarge,
children: <TextSpan>[
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 ---
],
),
),

View File

@ -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<InSituStep2SiteInfo> {
final _formKey = GlobalKey<FormState>();
bool _isPickingImage = false;
// --- START MODIFICATION: Removed optional remark controllers ---
late final TextEditingController _eventRemarksController;
late final TextEditingController _labRemarksController;
// --- END MODIFICATION ---
final List<String> _weatherOptions = ['Clear', 'Cloudy', 'Drizzle', 'Rainy', 'Windy'];
final List<String> _tideOptions = ['High', 'Low', 'Mid'];
final List<String> _tideOptions = ['High', 'Low'];
final List<String> _seaConditionOptions = ['Calm', 'Moderate Wave', 'High Wave'];
@override
@ -43,16 +41,12 @@ class _InSituStep2SiteInfoState extends State<InSituStep2SiteInfo> {
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<InSituStep2SiteInfo> {
final service = Provider.of<MarineInSituSamplingService>(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<InSituStep2SiteInfo> {
/// 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<InSituStep2SiteInfo> {
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<InSituStep2SiteInfo> {
@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<InSituStep2SiteInfo> {
// --- 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<InSituStep2SiteInfo> {
_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<InSituStep2SiteInfo> {
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<InSituStep2SiteInfo> {
},
maxLines: 3,
),
// --- END MODIFICATION ---
const SizedBox(height: 16),
TextFormField(
controller: _labRemarksController,
@ -210,10 +198,8 @@ class _InSituStep2SiteInfoState extends State<InSituStep2SiteInfo> {
);
}
/// 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<InSituStep2SiteInfo> {
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<Uint8List>(
// 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<InSituStep2SiteInfo> {
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 ---
],
),
);

View File

@ -851,10 +851,13 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> 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<InSituStep3DataCapture> 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<InSituStep3DataCapture> with Wi
)
],
)
// --- END MODIFICATION ---
],
),
),

View File

@ -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<InSituStep4Summary> {
Map<String, dynamic> 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<InSituStep4Summary> {
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<InSituStep4Summary> {
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<Uint8List>(
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,

View File

@ -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'),

View File

@ -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';

View File

@ -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(

View File

@ -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<String, dynamic> 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<RiverInvestigativeDataStatusLog> createState() =>
_RiverInvestigativeDataStatusLogState();
}
class _RiverInvestigativeDataStatusLogState
extends State<RiverInvestigativeDataStatusLog> {
final LocalStorageService _localStorageService = LocalStorageService();
late RiverInvestigativeSamplingService _riverInvestigativeService;
List<SubmissionLogEntry> _investigativeLogs = [];
List<SubmissionLogEntry> _filteredInvestigativeLogs = [];
final TextEditingController _investigativeSearchController =
TextEditingController();
bool _isLoading = true;
final Map<String, bool> _isResubmitting = {};
@override
void initState() {
super.initState();
_investigativeSearchController.addListener(_filterLogs);
_loadAllLogs();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_riverInvestigativeService =
Provider.of<RiverInvestigativeSamplingService>(context);
}
@override
void dispose() {
_investigativeSearchController.dispose();
super.dispose();
}
Future<void> _loadAllLogs() async {
setState(() => _isLoading = true);
final investigativeLogs =
await _localStorageService.getAllRiverInvestigativeLogs();
final List<SubmissionLogEntry> 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<String, dynamic> 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<void> _resubmitData(SubmissionLogEntry log) async {
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
if (mounted) {
setState(() {
_isResubmitting[logKey] = true;
});
}
try {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings;
Map<String, dynamic> 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<SubmissionLogEntry> 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>[
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<String, dynamic> 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<String, dynamic> data = log.rawData;
final List<TableRow> 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<ImageLogEntry> 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<dynamic> statuses;
try {
statuses = jsonDecode(jsonStatus);
} catch (_) {
return _buildDetailRow('$type Status:', jsonStatus);
}
if (statuses.isEmpty) {
return Container();
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$type Status:',
style: const TextStyle(fontWeight: FontWeight.bold)),
...statuses.map((s) {
final serverName =
s['server_name'] ?? 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)),
],
),
);
}
}

View File

@ -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<RiverInvestigativeImageRequestScreen> createState() =>
_RiverInvestigativeImageRequestScreenState();
}
class _RiverInvestigativeImageRequestScreenState
extends State<RiverInvestigativeImageRequestScreen> {
final _formKey = GlobalKey<FormState>();
final _dateController = TextEditingController();
final String _selectedSamplingType = 'Investigative Sampling';
String? _selectedStateName;
String? _selectedBasinName;
Map<String, dynamic>? _selectedStation;
DateTime? _selectedDate;
List<String> _statesList = [];
List<String> _basinsForState = [];
List<Map<String, dynamic>> _stationsForBasin = [];
bool _isLoading = false;
List<String> _imageUrls = [];
final Set<String> _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<Map<String, dynamic>> _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<AuthProvider>(context, listen: false);
final allStations = _getStationsForType(auth);
if (allStations.isNotEmpty) {
final states = allStations
.map((s) => s['state_name'] as String?)
.whereType<String>()
.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<void> _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<void> _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<ApiService>(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<String> fetchedUrls =
List<String>.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<void> _showEmailDialog() async {
final emailController = TextEditingController();
final dialogFormKey = GlobalKey<FormState>();
return showDialog<void>(
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: <Widget>[
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<void> _sendEmailRequestToServer(String toEmail) async {
final apiService = Provider.of<ApiService>(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<AuthProvider>(
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<String>(
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<AuthProvider>(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<String>()
.toSet()
.toList()
: <String>[];
basins.sort();
_basinsForState = basins;
_stationsForBasin = [];
});
},
validator: (val) => val == null ? "State is required" : null,
),
const SizedBox(height: 16),
// Basin Dropdown
DropdownSearch<String>(
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<AuthProvider>(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<Map<String, dynamic>>(
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),
),
],
),
),
),
);
},
);
}
}

View File

@ -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<RiverManualDataStatusLog> {
final LocalStorageService _localStorageService = LocalStorageService();
late ApiService _apiService;
late RiverInSituSamplingService _riverInSituService;
List<SubmissionLogEntry> _allLogs = [];
List<SubmissionLogEntry> _filteredLogs = [];
final TextEditingController _searchController = TextEditingController();
late RiverInSituSamplingService _riverInSituService;
late RiverManualTriennialSamplingService _riverTriennialService;
late RiverInvestigativeSamplingService _riverInvestigativeService;
List<SubmissionLogEntry> _inSituLogs = [];
List<SubmissionLogEntry> _triennialLogs = [];
List<SubmissionLogEntry> _investigativeLogs = [];
List<SubmissionLogEntry> _filteredInSituLogs = [];
List<SubmissionLogEntry> _filteredTriennialLogs = [];
List<SubmissionLogEntry> _filteredInvestigativeLogs = [];
final TextEditingController _inSituSearchController = TextEditingController();
final TextEditingController _triennialSearchController = TextEditingController();
final TextEditingController _investigativeSearchController = TextEditingController();
bool _isLoading = true;
final Map<String, bool> _isResubmitting = {};
@override
void initState() {
super.initState();
_apiService = Provider.of<ApiService>(context, listen: false);
_riverInSituService = Provider.of<RiverInSituSamplingService>(context, listen: false);
_searchController.addListener(_filterLogs);
_inSituSearchController.addListener(_filterLogs);
_triennialSearchController.addListener(_filterLogs);
_investigativeSearchController.addListener(_filterLogs);
_loadAllLogs();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_riverInSituService = Provider.of<RiverInSituSamplingService>(context);
_riverTriennialService = Provider.of<RiverManualTriennialSamplingService>(context);
_riverInvestigativeService = Provider.of<RiverInvestigativeSamplingService>(context);
}
@override
void dispose() {
_searchController.dispose();
_inSituSearchController.dispose();
_triennialSearchController.dispose();
_investigativeSearchController.dispose();
super.dispose();
}
Future<void> _loadAllLogs() async {
setState(() => _isLoading = true);
final riverLogs = await _localStorageService.getAllRiverInSituLogs();
final List<SubmissionLogEntry> 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<SubmissionLogEntry> tempInSitu = [];
final List<SubmissionLogEntry> tempTriennial = [];
final List<SubmissionLogEntry> 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<String, dynamic> log) {
SubmissionLogEntry? _createInSituLogEntry(Map<String, dynamic> 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<RiverManualDataStatusLog> {
);
}
SubmissionLogEntry? _createTriennialLogEntry(Map<String, dynamic> 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<String, dynamic> 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<RiverManualDataStatusLog> {
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<RiverManualDataStatusLog> {
try {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final appSettings = authProvider.appSettings;
Map<String, dynamic> 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<RiverManualDataStatusLog> {
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<RiverManualDataStatusLog> {
@override
Widget build(BuildContext context) {
final hasAnyLogs = _allLogs.isNotEmpty;
final hasFilteredLogs = _filteredLogs.isNotEmpty;
final Map<String, List<SubmissionLogEntry>> 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<RiverManualDataStatusLog> {
: 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<SubmissionLogEntry> logs) {
Widget _buildCategorySection(String category, List<SubmissionLogEntry> logs, TextEditingController searchController) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8.0),
child: Padding(
@ -270,15 +413,42 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
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<RiverManualDataStatusLog> {
}
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<RiverManualDataStatusLog> {
],
),
);
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<String, dynamic> 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<String, dynamic> data = log.rawData;
final List<TableRow> 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<ImageLogEntry> 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<RiverManualDataStatusLog> {
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
],
),
);

View File

@ -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<RiverImageRequestScreen> {
final _formKey = GlobalKey<FormState>();
final _dateController = TextEditingController();
final String _selectedSamplingType = 'In-Situ Sampling';
// --- START: MODIFICATION ---
String? _selectedSamplingType = 'In-Situ Sampling'; // Default to one
final List<String> _samplingTypes = ['In-Situ Sampling', 'Triennial Sampling', 'Investigative Sampling'];
// --- END: MODIFICATION ---
String? _selectedStateName;
String? _selectedBasinName;
@ -48,7 +52,12 @@ class _RiverImageRequestScreenState extends State<RiverImageRequestScreen> {
@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<RiverImageRequestScreen> {
super.dispose();
}
// --- START: ADDED HELPER FUNCTIONS (from marine_image_request pattern) ---
/// Gets the correct list of stations from AuthProvider based on sampling type.
List<Map<String, dynamic>> _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<AuthProvider>(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<String>().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<RiverImageRequestScreen> {
_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<ApiService>(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<String> fetchedUrls = List<String>.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<RiverImageRequestScreen> {
final apiService = Provider.of<ApiService>(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<RiverImageRequestScreen> {
Text("Image Search Filters", style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 24),
// --- START: MODIFIED ---
// Sampling Type Dropdown
DropdownButtonFormField<String>(
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<String>(
items: _statesList,
@ -264,8 +364,11 @@ class _RiverImageRequestScreenState extends State<RiverImageRequestScreen> {
_selectedBasinName = null;
_selectedStation = null;
final auth = Provider.of<AuthProvider>(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<String>().toSet().toList() : <String>[];
// --- 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<String>().toSet().toList() : <String>[];
// --- END: MODIFIED ---
basins.sort();
_basinsForState = basins;
_stationsForBasin = [];
@ -287,8 +390,12 @@ class _RiverImageRequestScreenState extends State<RiverImageRequestScreen> {
_selectedBasinName = basin;
_selectedStation = null;
final auth = Provider.of<AuthProvider>(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<RiverImageRequestScreen> {
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),

View File

@ -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';

View File

@ -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(

View File

@ -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';

View File

@ -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(

View File

@ -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

View File

@ -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<Map<String, dynamic>> getManualStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'air/manual-stations');
}
Future<Map<String, dynamic>> getClients() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'air/clients');
}
// 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<Map<String, dynamic>> submitInstallation(AirInstallationData data) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.post(baseUrl, 'air/manual/installation', data.toJsonForApi());
}
Future<Map<String, dynamic>> submitCollection(AirCollectionData data) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.post(baseUrl, 'air/manual/collection', data.toJson());
}
Future<Map<String, dynamic>> uploadInstallationImages({
required String airManId,
required Map<String, File> files,
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.postMultipart(
baseUrl: baseUrl,
endpoint: 'air/manual/installation-images',
fields: {'air_man_id': airManId},
files: files,
);
}
Future<Map<String, dynamic>> uploadCollectionImages({
required String airManId,
required Map<String, File> files,
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.postMultipart(
baseUrl: baseUrl,
endpoint: 'air/manual/collection-images',
fields: {'air_man_id': airManId},
files: files,
);
}
}

View File

@ -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';

View File

@ -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<Map<String, dynamic>>.from(result['data']['updated'] ?? []);
final deleted = List<dynamic>.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)
// 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.
// =======================================================================
class AirApiService {
final BaseApiService _baseService;
final TelegramService? _telegramService; // Kept optional for now
final ServerConfigService _serverConfigService;
AirApiService(this._baseService, this._telegramService, this._serverConfigService);
Future<Map<String, dynamic>> getManualStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'air/manual-stations');
}
Future<Map<String, dynamic>> getClients() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'air/clients');
}
// 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<Map<String, dynamic>> submitInstallation(AirInstallationData data) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.post(baseUrl, 'air/manual/installation', data.toJsonForApi());
}
Future<Map<String, dynamic>> submitCollection(AirCollectionData data) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.post(baseUrl, 'air/manual/collection', data.toJson());
}
Future<Map<String, dynamic>> uploadInstallationImages({
required String airManId,
required Map<String, File> files,
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.postMultipart(
baseUrl: baseUrl,
endpoint: 'air/manual/installation-images',
fields: {'air_man_id': airManId},
files: files,
);
}
Future<Map<String, dynamic>> uploadCollectionImages({
required String airManId,
required Map<String, File> files,
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.postMultipart(
baseUrl: baseUrl,
endpoint: 'air/manual/collection-images',
fields: {'air_man_id': airManId},
files: files,
);
}
}
// =======================================================================
// --- 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<Map<String, dynamic>> getTarballStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/tarball/stations');
}
Future<Map<String, dynamic>> getManualStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/manual/stations');
}
Future<Map<String, dynamic>> getTarballClassifications() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'marine/tarball/classifications');
}
// --- REPLACED/FIXED METHOD ---
Future<Map<String, dynamic>> 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<Map<String, dynamic>> sendImageRequestEmail({
required String recipientEmail,
required List<String> imageUrls,
required String stationName,
required String samplingDate,
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
final Map<String, String> 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<Map<String, dynamic>> submitPreDepartureChecklist(MarineManualPreDepartureChecklistData data) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.post(baseUrl, 'marine/checklist', data.toApiFormData());
}
Future<Map<String, dynamic>> submitSondeCalibration(MarineManualSondeCalibrationData data) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.post(baseUrl, 'marine/calibration', data.toApiFormData());
}
Future<Map<String, dynamic>> submitMaintenanceLog(MarineManualEquipmentMaintenanceData data) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.post(baseUrl, 'marine/maintenance', data.toApiFormData());
}
Future<Map<String, dynamic>> 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<Map<String, dynamic>> getManualStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'river/manual-stations');
}
Future<Map<String, dynamic>> getTriennialStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.get(baseUrl, 'river/triennial-stations');
}
Future<Map<String, dynamic>> 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<Map<String, dynamic>> sendImageRequestEmail({
required String recipientEmail,
required List<String> imageUrls,
required String stationName,
required String samplingDate,
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
final Map<String, String> 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<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB();
return _database!;
}
Future<Database> _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<void> _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<void> _upsertData(String table, String idKeyName, List<Map<String, dynamic>> 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<void> _deleteData(String table, String idKeyName, List<dynamic> 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<List<Map<String, dynamic>>?> _loadData(String table, String jsonKey) async {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(table);
if (maps.isNotEmpty) {
try {
return maps.map((map) {
try {
return jsonDecode(map['${jsonKey}_json']) as Map<String, dynamic>;
} 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 <String, dynamic>{};
}
}).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<void> saveProfile(Map<String, dynamic> profile) async {
final db = await database;
await db.insert(_profileTable, {'user_id': profile['user_id'], 'profile_json': jsonEncode(profile)},
conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<Map<String, dynamic>?> loadProfile() async {
final db = await database;
final List<Map<String, dynamic>> 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<Map<String, dynamic>?> loadProfileByEmail(String email) async {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(
_usersTable,
columns: ['user_json'],
where: 'email = ?',
whereArgs: [email],
);
if (maps.isNotEmpty) {
try {
return jsonDecode(maps.first['user_json']) as Map<String, dynamic>;
} catch (e) {
debugPrint("Error decoding profile for email $email: $e");
return null;
}
}
return null;
}
Future<void> upsertUserWithCredentials({
required Map<String, dynamic> 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<String?> getUserPasswordHashByEmail(String email) async {
final db = await database;
final List<Map<String, dynamic>> 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<void> upsertUsers(List<Map<String, dynamic>> 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<void> deleteUsers(List<dynamic> ids) => _deleteData(_usersTable, 'user_id', ids);
Future<List<Map<String, dynamic>>?> loadUsers() => _loadData(_usersTable, 'user');
Future<void> upsertDocuments(List<Map<String, dynamic>> data) => _upsertData(_documentsTable, 'id', data, 'document');
Future<void> deleteDocuments(List<dynamic> ids) => _deleteData(_documentsTable, 'id', ids);
Future<List<Map<String, dynamic>>?> loadDocuments() => _loadData(_documentsTable, 'document');
Future<void> upsertTarballStations(List<Map<String, dynamic>> data) =>
_upsertData(_tarballStationsTable, 'station_id', data, 'station');
Future<void> deleteTarballStations(List<dynamic> ids) => _deleteData(_tarballStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadTarballStations() => _loadData(_tarballStationsTable, 'station');
Future<void> upsertManualStations(List<Map<String, dynamic>> data) =>
_upsertData(_manualStationsTable, 'station_id', data, 'station');
Future<void> deleteManualStations(List<dynamic> ids) => _deleteData(_manualStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadManualStations() => _loadData(_manualStationsTable, 'station');
Future<void> upsertRiverManualStations(List<Map<String, dynamic>> data) =>
_upsertData(_riverManualStationsTable, 'station_id', data, 'station');
Future<void> deleteRiverManualStations(List<dynamic> ids) => _deleteData(_riverManualStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadRiverManualStations() => _loadData(_riverManualStationsTable, 'station');
Future<void> upsertRiverTriennialStations(List<Map<String, dynamic>> data) =>
_upsertData(_riverTriennialStationsTable, 'station_id', data, 'station');
Future<void> deleteRiverTriennialStations(List<dynamic> ids) => _deleteData(_riverTriennialStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadRiverTriennialStations() => _loadData(_riverTriennialStationsTable, 'station');
// --- ADDED: River Investigative Stations DB Methods ---
Future<void> upsertRiverInvestigativeStations(List<Map<String, dynamic>> data) =>
_upsertData(_riverInvestigativeStationsTable, 'station_id', data, 'station');
Future<void> deleteRiverInvestigativeStations(List<dynamic> ids) => _deleteData(_riverInvestigativeStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadRiverInvestigativeStations() => _loadData(_riverInvestigativeStationsTable, 'station');
// --- END ADDED ---
Future<void> upsertTarballClassifications(List<Map<String, dynamic>> data) =>
_upsertData(_tarballClassificationsTable, 'classification_id', data, 'classification');
Future<void> deleteTarballClassifications(List<dynamic> ids) => _deleteData(_tarballClassificationsTable, 'classification_id', ids);
Future<List<Map<String, dynamic>>?> loadTarballClassifications() => _loadData(_tarballClassificationsTable, 'classification');
Future<void> upsertDepartments(List<Map<String, dynamic>> data) => _upsertData(_departmentsTable, 'department_id', data, 'department');
Future<void> deleteDepartments(List<dynamic> ids) => _deleteData(_departmentsTable, 'department_id', ids);
Future<List<Map<String, dynamic>>?> loadDepartments() => _loadData(_departmentsTable, 'department');
Future<void> upsertCompanies(List<Map<String, dynamic>> data) => _upsertData(_companiesTable, 'company_id', data, 'company');
Future<void> deleteCompanies(List<dynamic> ids) => _deleteData(_companiesTable, 'company_id', ids);
Future<List<Map<String, dynamic>>?> loadCompanies() => _loadData(_companiesTable, 'company');
Future<void> upsertPositions(List<Map<String, dynamic>> data) => _upsertData(_positionsTable, 'position_id', data, 'position');
Future<void> deletePositions(List<dynamic> ids) => _deleteData(_positionsTable, 'position_id', ids);
Future<List<Map<String, dynamic>>?> loadPositions() => _loadData(_positionsTable, 'position');
Future<void> upsertAirManualStations(List<Map<String, dynamic>> data) =>
_upsertData(_airManualStationsTable, 'station_id', data, 'station');
Future<void> deleteAirManualStations(List<dynamic> ids) => _deleteData(_airManualStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadAirManualStations() => _loadData(_airManualStationsTable, 'station');
Future<void> upsertAirClients(List<Map<String, dynamic>> data) => _upsertData(_airClientsTable, 'client_id', data, 'client');
Future<void> deleteAirClients(List<dynamic> ids) => _deleteData(_airClientsTable, 'client_id', ids);
Future<List<Map<String, dynamic>>?> loadAirClients() => _loadData(_airClientsTable, 'client');
Future<void> upsertStates(List<Map<String, dynamic>> data) => _upsertData(_statesTable, 'state_id', data, 'state');
Future<void> deleteStates(List<dynamic> ids) => _deleteData(_statesTable, 'state_id', ids);
Future<List<Map<String, dynamic>>?> loadStates() => _loadData(_statesTable, 'state');
Future<void> upsertAppSettings(List<Map<String, dynamic>> data) => _upsertData(_appSettingsTable, 'setting_id', data, 'setting');
Future<void> deleteAppSettings(List<dynamic> ids) => _deleteData(_appSettingsTable, 'setting_id', ids);
Future<List<Map<String, dynamic>>?> loadAppSettings() => _loadData(_appSettingsTable, 'setting');
Future<void> upsertNpeParameterLimits(List<Map<String, dynamic>> data) => _upsertData(_npeParameterLimitsTable, 'param_autoid', data, 'limit');
Future<void> deleteNpeParameterLimits(List<dynamic> ids) => _deleteData(_npeParameterLimitsTable, 'param_autoid', ids);
Future<List<Map<String, dynamic>>?> loadNpeParameterLimits() => _loadData(_npeParameterLimitsTable, 'limit');
Future<void> upsertMarineParameterLimits(List<Map<String, dynamic>> data) => _upsertData(_marineParameterLimitsTable, 'param_autoid', data, 'limit');
Future<void> deleteMarineParameterLimits(List<dynamic> ids) => _deleteData(_marineParameterLimitsTable, 'param_autoid', ids);
Future<List<Map<String, dynamic>>?> loadMarineParameterLimits() => _loadData(_marineParameterLimitsTable, 'limit');
Future<void> upsertRiverParameterLimits(List<Map<String, dynamic>> data) => _upsertData(_riverParameterLimitsTable, 'param_autoid', data, 'limit');
Future<void> deleteRiverParameterLimits(List<dynamic> ids) => _deleteData(_riverParameterLimitsTable, 'param_autoid', ids);
Future<List<Map<String, dynamic>>?> loadRiverParameterLimits() => _loadData(_riverParameterLimitsTable, 'limit');
Future<void> upsertApiConfigs(List<Map<String, dynamic>> data) => _upsertData(_apiConfigsTable, 'api_config_id', data, 'config');
Future<void> deleteApiConfigs(List<dynamic> ids) => _deleteData(_apiConfigsTable, 'api_config_id', ids);
Future<List<Map<String, dynamic>>?> loadApiConfigs() => _loadData(_apiConfigsTable, 'config');
Future<void> upsertFtpConfigs(List<Map<String, dynamic>> data) => _upsertData(_ftpConfigsTable, 'ftp_config_id', data, 'config');
Future<void> deleteFtpConfigs(List<dynamic> ids) => _deleteData(_ftpConfigsTable, 'ftp_config_id', ids);
Future<List<Map<String, dynamic>>?> loadFtpConfigs() => _loadData(_ftpConfigsTable, 'config');
Future<int> queueFailedRequest(Map<String, dynamic> data) async {
final db = await database;
return await db.insert(_retryQueueTable, data, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<List<Map<String, dynamic>>> getPendingRequests() async {
final db = await database;
return await db.query(_retryQueueTable, where: 'status = ?', whereArgs: ['pending'], orderBy: 'timestamp ASC'); // Order by timestamp
}
Future<Map<String, dynamic>?> 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<void> deleteRequestFromQueue(int id) async {
final db = await database;
await db.delete(_retryQueueTable, where: 'id = ?', whereArgs: [id]);
}
Future<void> saveSubmissionLog(Map<String, dynamic> data) async {
final db = await database;
await db.insert(
_submissionLogTable,
data,
conflictAlgorithm: ConflictAlgorithm.replace, // Replace if same ID exists
);
}
Future<List<Map<String, dynamic>>?> loadSubmissionLogs({String? module}) async {
final db = await database;
List<Map<String, dynamic>> 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<void> saveModulePreference({
required String moduleName,
required bool isApiEnabled,
required bool isFtpEnabled,
}) async {
final db = await database;
await db.insert(
_modulePreferencesTable,
{
'module_name': moduleName,
'is_api_enabled': isApiEnabled ? 1 : 0,
'is_ftp_enabled': isFtpEnabled ? 1 : 0,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<Map<String, dynamic>?> getModulePreference(String moduleName) async {
final db = await database;
final result = await db.query(
_modulePreferencesTable,
where: 'module_name = ?',
whereArgs: [moduleName],
);
if (result.isNotEmpty) {
final row = result.first;
return {
'module_name': row['module_name'],
'is_api_enabled': (row['is_api_enabled'] as int) == 1,
'is_ftp_enabled': (row['is_ftp_enabled'] as int) == 1,
};
}
// Return default values if no preference found
return {'module_name': moduleName, 'is_api_enabled': true, 'is_ftp_enabled': true};
}
Future<void> saveApiLinksForModule(String moduleName, List<Map<String, dynamic>> 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<void> saveFtpLinksForModule(String moduleName, List<Map<String, dynamic>> links) async {
final db = await database;
await db.transaction((txn) async {
await txn.delete(_moduleFtpLinksTable, where: 'module_name = ?', whereArgs: [moduleName]);
for (final link in links) {
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<List<Map<String, dynamic>>> getAllApiLinksForModule(String moduleName) async {
final db = await database;
final result = await db.query(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]);
return result.map((row) => {
'api_config_id': row['api_config_id'],
'is_enabled': (row['is_enabled'] as int) == 1,
}).toList();
}
Future<List<Map<String, dynamic>>> getAllFtpLinksForModule(String moduleName) async {
final db = await database;
final result = await db.query(_moduleFtpLinksTable, where: 'module_name = ?', whereArgs: [moduleName]);
return result.map((row) => {
'ftp_config_id': row['ftp_config_id'],
'is_enabled': (row['is_enabled'] as int) == 1,
}).toList();
}
}

View File

@ -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<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB();
return _database!;
}
Future<Database> _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<void> _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<void> _upsertData(String table, String idKeyName, List<Map<String, dynamic>> 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<void> _deleteData(String table, String idKeyName, List<dynamic> 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<List<Map<String, dynamic>>?> _loadData(String table, String jsonKey) async {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(table);
if (maps.isNotEmpty) {
try {
return maps.map((map) {
try {
return jsonDecode(map['${jsonKey}_json']) as Map<String, dynamic>;
} 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 <String, dynamic>{};
}
}).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<void> saveProfile(Map<String, dynamic> profile) async {
final db = await database;
await db.insert(_profileTable, {'user_id': profile['user_id'], 'profile_json': jsonEncode(profile)},
conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<Map<String, dynamic>?> loadProfile() async {
final db = await database;
final List<Map<String, dynamic>> 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<Map<String, dynamic>?> loadProfileByEmail(String email) async {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(
_usersTable,
columns: ['user_json'],
where: 'email = ?',
whereArgs: [email],
);
if (maps.isNotEmpty) {
try {
return jsonDecode(maps.first['user_json']) as Map<String, dynamic>;
} catch (e) {
debugPrint("Error decoding profile for email $email: $e");
return null;
}
}
return null;
}
Future<void> upsertUserWithCredentials({
required Map<String, dynamic> 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<String?> getUserPasswordHashByEmail(String email) async {
final db = await database;
final List<Map<String, dynamic>> 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<void> upsertUsers(List<Map<String, dynamic>> 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<void> deleteUsers(List<dynamic> ids) => _deleteData(_usersTable, 'user_id', ids);
Future<List<Map<String, dynamic>>?> loadUsers() => _loadData(_usersTable, 'user');
Future<void> upsertDocuments(List<Map<String, dynamic>> data) => _upsertData(_documentsTable, 'id', data, 'document');
Future<void> deleteDocuments(List<dynamic> ids) => _deleteData(_documentsTable, 'id', ids);
Future<List<Map<String, dynamic>>?> loadDocuments() => _loadData(_documentsTable, 'document');
Future<void> upsertTarballStations(List<Map<String, dynamic>> data) =>
_upsertData(_tarballStationsTable, 'station_id', data, 'station');
Future<void> deleteTarballStations(List<dynamic> ids) => _deleteData(_tarballStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadTarballStations() => _loadData(_tarballStationsTable, 'station');
Future<void> upsertManualStations(List<Map<String, dynamic>> data) =>
_upsertData(_manualStationsTable, 'station_id', data, 'station');
Future<void> deleteManualStations(List<dynamic> ids) => _deleteData(_manualStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadManualStations() => _loadData(_manualStationsTable, 'station');
Future<void> upsertRiverManualStations(List<Map<String, dynamic>> data) =>
_upsertData(_riverManualStationsTable, 'station_id', data, 'station');
Future<void> deleteRiverManualStations(List<dynamic> ids) => _deleteData(_riverManualStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadRiverManualStations() => _loadData(_riverManualStationsTable, 'station');
Future<void> upsertRiverTriennialStations(List<Map<String, dynamic>> data) =>
_upsertData(_riverTriennialStationsTable, 'station_id', data, 'station');
Future<void> deleteRiverTriennialStations(List<dynamic> ids) => _deleteData(_riverTriennialStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadRiverTriennialStations() => _loadData(_riverTriennialStationsTable, 'station');
// --- ADDED: River Investigative Stations DB Methods ---
Future<void> upsertRiverInvestigativeStations(List<Map<String, dynamic>> data) =>
_upsertData(_riverInvestigativeStationsTable, 'station_id', data, 'station');
Future<void> deleteRiverInvestigativeStations(List<dynamic> ids) => _deleteData(_riverInvestigativeStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadRiverInvestigativeStations() => _loadData(_riverInvestigativeStationsTable, 'station');
// --- END ADDED ---
Future<void> upsertTarballClassifications(List<Map<String, dynamic>> data) =>
_upsertData(_tarballClassificationsTable, 'classification_id', data, 'classification');
Future<void> deleteTarballClassifications(List<dynamic> ids) => _deleteData(_tarballClassificationsTable, 'classification_id', ids);
Future<List<Map<String, dynamic>>?> loadTarballClassifications() => _loadData(_tarballClassificationsTable, 'classification');
Future<void> upsertDepartments(List<Map<String, dynamic>> data) => _upsertData(_departmentsTable, 'department_id', data, 'department');
Future<void> deleteDepartments(List<dynamic> ids) => _deleteData(_departmentsTable, 'department_id', ids);
Future<List<Map<String, dynamic>>?> loadDepartments() => _loadData(_departmentsTable, 'department');
Future<void> upsertCompanies(List<Map<String, dynamic>> data) => _upsertData(_companiesTable, 'company_id', data, 'company');
Future<void> deleteCompanies(List<dynamic> ids) => _deleteData(_companiesTable, 'company_id', ids);
Future<List<Map<String, dynamic>>?> loadCompanies() => _loadData(_companiesTable, 'company');
Future<void> upsertPositions(List<Map<String, dynamic>> data) => _upsertData(_positionsTable, 'position_id', data, 'position');
Future<void> deletePositions(List<dynamic> ids) => _deleteData(_positionsTable, 'position_id', ids);
Future<List<Map<String, dynamic>>?> loadPositions() => _loadData(_positionsTable, 'position');
Future<void> upsertAirManualStations(List<Map<String, dynamic>> data) =>
_upsertData(_airManualStationsTable, 'station_id', data, 'station');
Future<void> deleteAirManualStations(List<dynamic> ids) => _deleteData(_airManualStationsTable, 'station_id', ids);
Future<List<Map<String, dynamic>>?> loadAirManualStations() => _loadData(_airManualStationsTable, 'station');
Future<void> upsertAirClients(List<Map<String, dynamic>> data) => _upsertData(_airClientsTable, 'client_id', data, 'client');
Future<void> deleteAirClients(List<dynamic> ids) => _deleteData(_airClientsTable, 'client_id', ids);
Future<List<Map<String, dynamic>>?> loadAirClients() => _loadData(_airClientsTable, 'client');
Future<void> upsertStates(List<Map<String, dynamic>> data) => _upsertData(_statesTable, 'state_id', data, 'state');
Future<void> deleteStates(List<dynamic> ids) => _deleteData(_statesTable, 'state_id', ids);
Future<List<Map<String, dynamic>>?> loadStates() => _loadData(_statesTable, 'state');
Future<void> upsertAppSettings(List<Map<String, dynamic>> data) => _upsertData(_appSettingsTable, 'setting_id', data, 'setting');
Future<void> deleteAppSettings(List<dynamic> ids) => _deleteData(_appSettingsTable, 'setting_id', ids);
Future<List<Map<String, dynamic>>?> loadAppSettings() => _loadData(_appSettingsTable, 'setting');
Future<void> upsertNpeParameterLimits(List<Map<String, dynamic>> data) => _upsertData(_npeParameterLimitsTable, 'param_autoid', data, 'limit');
Future<void> deleteNpeParameterLimits(List<dynamic> ids) => _deleteData(_npeParameterLimitsTable, 'param_autoid', ids);
Future<List<Map<String, dynamic>>?> loadNpeParameterLimits() => _loadData(_npeParameterLimitsTable, 'limit');
Future<void> upsertMarineParameterLimits(List<Map<String, dynamic>> data) => _upsertData(_marineParameterLimitsTable, 'param_autoid', data, 'limit');
Future<void> deleteMarineParameterLimits(List<dynamic> ids) => _deleteData(_marineParameterLimitsTable, 'param_autoid', ids);
Future<List<Map<String, dynamic>>?> loadMarineParameterLimits() => _loadData(_marineParameterLimitsTable, 'limit');
Future<void> upsertRiverParameterLimits(List<Map<String, dynamic>> data) => _upsertData(_riverParameterLimitsTable, 'param_autoid', data, 'limit');
Future<void> deleteRiverParameterLimits(List<dynamic> ids) => _deleteData(_riverParameterLimitsTable, 'param_autoid', ids);
Future<List<Map<String, dynamic>>?> loadRiverParameterLimits() => _loadData(_riverParameterLimitsTable, 'limit');
Future<void> upsertApiConfigs(List<Map<String, dynamic>> data) => _upsertData(_apiConfigsTable, 'api_config_id', data, 'config');
Future<void> deleteApiConfigs(List<dynamic> ids) => _deleteData(_apiConfigsTable, 'api_config_id', ids);
Future<List<Map<String, dynamic>>?> loadApiConfigs() => _loadData(_apiConfigsTable, 'config');
Future<void> upsertFtpConfigs(List<Map<String, dynamic>> data) => _upsertData(_ftpConfigsTable, 'ftp_config_id', data, 'config');
Future<void> deleteFtpConfigs(List<dynamic> ids) => _deleteData(_ftpConfigsTable, 'ftp_config_id', ids);
Future<List<Map<String, dynamic>>?> loadFtpConfigs() => _loadData(_ftpConfigsTable, 'config');
Future<int> queueFailedRequest(Map<String, dynamic> data) async {
final db = await database;
return await db.insert(_retryQueueTable, data, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<List<Map<String, dynamic>>> getPendingRequests() async {
final db = await database;
return await db.query(_retryQueueTable, where: 'status = ?', whereArgs: ['pending'], orderBy: 'timestamp ASC'); // Order by timestamp
}
Future<Map<String, dynamic>?> 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<void> deleteRequestFromQueue(int id) async {
final db = await database;
await db.delete(_retryQueueTable, where: 'id = ?', whereArgs: [id]);
}
Future<void> saveSubmissionLog(Map<String, dynamic> data) async {
final db = await database;
await db.insert(
_submissionLogTable,
data,
conflictAlgorithm: ConflictAlgorithm.replace, // Replace if same ID exists
);
}
Future<List<Map<String, dynamic>>?> loadSubmissionLogs({String? module}) async {
final db = await database;
List<Map<String, dynamic>> 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<void> saveModulePreference({
required String moduleName,
required bool isApiEnabled,
required bool isFtpEnabled,
}) async {
final db = await database;
await db.insert(
_modulePreferencesTable,
{
'module_name': moduleName,
'is_api_enabled': isApiEnabled ? 1 : 0,
'is_ftp_enabled': isFtpEnabled ? 1 : 0,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<Map<String, dynamic>?> getModulePreference(String moduleName) async {
final db = await database;
final result = await db.query(
_modulePreferencesTable,
where: 'module_name = ?',
whereArgs: [moduleName],
);
if (result.isNotEmpty) {
final row = result.first;
return {
'module_name': row['module_name'],
'is_api_enabled': (row['is_api_enabled'] as int) == 1,
'is_ftp_enabled': (row['is_ftp_enabled'] as int) == 1,
};
}
// Return default values if no preference found
return {'module_name': moduleName, 'is_api_enabled': true, 'is_ftp_enabled': true};
}
Future<void> saveApiLinksForModule(String moduleName, List<Map<String, dynamic>> 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<void> saveFtpLinksForModule(String moduleName, List<Map<String, dynamic>> links) async {
final db = await database;
await db.transaction((txn) async {
await txn.delete(_moduleFtpLinksTable, where: 'module_name = ?', whereArgs: [moduleName]);
for (final link in links) {
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<List<Map<String, dynamic>>> getAllApiLinksForModule(String moduleName) async {
final db = await database;
final result = await db.query(_moduleApiLinksTable, where: 'module_name = ?', whereArgs: [moduleName]);
return result.map((row) => {
'api_config_id': row['api_config_id'],
'is_enabled': (row['is_enabled'] as int) == 1,
}).toList();
}
Future<List<Map<String, dynamic>>> getAllFtpLinksForModule(String moduleName) async {
final db = await database;
final result = await db.query(_moduleFtpLinksTable, where: 'module_name = ?', whereArgs: [moduleName]);
return result.map((row) => {
'ftp_config_id': row['ftp_config_id'],
'is_enabled': (row['is_enabled'] as int) == 1,
}).toList();
}
}

View File

@ -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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> sendImageRequestEmail({
required String recipientEmail,
required List<String> imageUrls,
@ -90,17 +82,96 @@ class MarineApiService {
final Map<String, String> 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<Map<String, dynamic>> submitPreDepartureChecklist(MarineManualPreDepartureChecklistData data) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.post(baseUrl, 'marine/checklist', data.toApiFormData());
}
Future<Map<String, dynamic>> submitSondeCalibration(MarineManualSondeCalibrationData data) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.post(baseUrl, 'marine/calibration', data.toApiFormData());
}
Future<Map<String, dynamic>> submitMaintenanceLog(MarineManualEquipmentMaintenanceData data) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
return _baseService.post(baseUrl, 'marine/maintenance', data.toApiFormData());
}
Future<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> sendInvestigativeImageRequestEmail({
required String recipientEmail,
required List<String> imageUrls,
required String stationName,
required String samplingDate,
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
final Map<String, String> 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 ***
}

View File

@ -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<void> _handleInSituSuccessAlert(InSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
// --- START: Logic moved from data model ---
String generateInSituTelegramAlertMessage(InSituSamplingData data, {required bool isDataOnly}) {
Future<String> 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<String> _getOutOfBoundsAlertSection(InSituSamplingData data) async {
// Define mapping from data model keys to parameter names used in limits table
const Map<String, String> _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<String> 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: () => <String, dynamic>{}, // 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<String> _getNpeAlertSection(InSituSamplingData data) async {
// Define mapping from data model keys to parameter names used in limits table
const Map<String, String> _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<String> 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: () => <String, dynamic>{},
);
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 ---
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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();

View File

@ -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');

View File

@ -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';

View File

@ -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<Map<String, dynamic>> 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<Map<String, dynamic>> sendImageRequestEmail({
required String recipientEmail,
required List<String> imageUrls,
required String stationName,
required String samplingDate,
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
final Map<String, String> 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 ---
}

View File

@ -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 {

View File

@ -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) {

View File

@ -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;
}

View File

@ -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.

View File

@ -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 {

View File

@ -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.