repair comment for marine department
This commit is contained in:
parent
d77a0ed8e9
commit
da892821a2
@ -8,8 +8,10 @@ import 'package:connectivity_plus/connectivity_plus.dart';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:bcrypt/bcrypt.dart'; // Import bcrypt
|
import 'package:bcrypt/bcrypt.dart'; // Import bcrypt
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; // Import secure storage
|
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/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/base_api_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/server_config_service.dart';
|
import 'package:environment_monitoring_app/services/server_config_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/retry_service.dart';
|
import 'package:environment_monitoring_app/services/retry_service.dart';
|
||||||
@ -57,9 +59,7 @@ class AuthProvider with ChangeNotifier {
|
|||||||
List<Map<String, dynamic>>? _tarballClassifications;
|
List<Map<String, dynamic>>? _tarballClassifications;
|
||||||
List<Map<String, dynamic>>? _riverManualStations;
|
List<Map<String, dynamic>>? _riverManualStations;
|
||||||
List<Map<String, dynamic>>? _riverTriennialStations;
|
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>>? _departments;
|
||||||
List<Map<String, dynamic>>? _companies;
|
List<Map<String, dynamic>>? _companies;
|
||||||
List<Map<String, dynamic>>? _positions;
|
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 riverManualStations => _riverManualStations;
|
||||||
List<Map<String, dynamic>>? get riverTriennialStations => _riverTriennialStations;
|
List<Map<String, dynamic>>? get riverTriennialStations => _riverTriennialStations;
|
||||||
// --- ADDED: Getter for River Investigative Stations ---
|
// --- ADDED: Getter for River Investigative Stations ---
|
||||||
List<Map<String, dynamic>>? get riverInvestigativeStations => _riverInvestigativeStations;
|
List<Map<String, dynamic>>? get riverInvestigativeStations => _riverManualStations;
|
||||||
// --- END ADDED ---
|
// --- END ADDED ---
|
||||||
List<Map<String, dynamic>>? get departments => _departments;
|
List<Map<String, dynamic>>? get departments => _departments;
|
||||||
List<Map<String, dynamic>>? get companies => _companies;
|
List<Map<String, dynamic>>? get companies => _companies;
|
||||||
@ -527,9 +527,7 @@ class AuthProvider with ChangeNotifier {
|
|||||||
_tarballClassifications = await _dbHelper.loadTarballClassifications();
|
_tarballClassifications = await _dbHelper.loadTarballClassifications();
|
||||||
_riverManualStations = await _dbHelper.loadRiverManualStations();
|
_riverManualStations = await _dbHelper.loadRiverManualStations();
|
||||||
_riverTriennialStations = await _dbHelper.loadRiverTriennialStations();
|
_riverTriennialStations = await _dbHelper.loadRiverTriennialStations();
|
||||||
// --- MODIFIED: Load River Investigative Stations ---
|
|
||||||
_riverInvestigativeStations = await _dbHelper.loadRiverInvestigativeStations();
|
|
||||||
// --- END MODIFIED ---
|
|
||||||
_departments = await _dbHelper.loadDepartments();
|
_departments = await _dbHelper.loadDepartments();
|
||||||
_companies = await _dbHelper.loadCompanies();
|
_companies = await _dbHelper.loadCompanies();
|
||||||
_positions = await _dbHelper.loadPositions();
|
_positions = await _dbHelper.loadPositions();
|
||||||
@ -658,9 +656,6 @@ class AuthProvider with ChangeNotifier {
|
|||||||
_tarballClassifications = null;
|
_tarballClassifications = null;
|
||||||
_riverManualStations = null;
|
_riverManualStations = null;
|
||||||
_riverTriennialStations = null;
|
_riverTriennialStations = null;
|
||||||
// --- MODIFIED: Clear River Investigative Stations ---
|
|
||||||
_riverInvestigativeStations = null;
|
|
||||||
// --- END MODIFIED ---
|
|
||||||
_departments = null;
|
_departments = null;
|
||||||
_companies = null;
|
_companies = null;
|
||||||
_positions = null;
|
_positions = null;
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import 'dart:async'; // Import Timer
|
|||||||
|
|
||||||
import 'package:provider/single_child_widget.dart';
|
import 'package:provider/single_child_widget.dart';
|
||||||
import 'package:environment_monitoring_app/services/api_service.dart';
|
import 'package:environment_monitoring_app/services/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/local_storage_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart';
|
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/river_manual_triennial_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';
|
import 'package:environment_monitoring_app/screens/river/investigative/river_investigative_info_centre_document.dart';
|
||||||
// *** ADDED: Import River Investigative Manual Sampling Screen ***
|
// *** ADDED: Import River Investigative Manual Sampling Screen ***
|
||||||
import 'package:environment_monitoring_app/screens/river/investigative/river_investigative_manual_sampling.dart' as riverInvestigativeManualSampling;
|
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/overview.dart' as riverInvestigativeOverview;
|
||||||
import 'package:environment_monitoring_app/screens/river/investigative/entry.dart' as riverInvestigativeEntry;
|
import 'package:environment_monitoring_app/screens/river/investigative/entry.dart' as riverInvestigativeEntry;
|
||||||
import 'package:environment_monitoring_app/screens/river/investigative/report.dart' as riverInvestigativeReport;
|
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_info_centre_document.dart';
|
||||||
import 'package:environment_monitoring_app/screens/marine/investigative/marine_investigative_manual_sampling.dart'
|
import 'package:environment_monitoring_app/screens/marine/investigative/marine_investigative_manual_sampling.dart'
|
||||||
as marineInvestigativeManualSampling;
|
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/overview.dart' as marineInvestigativeOverview;
|
||||||
import 'package:environment_monitoring_app/screens/marine/investigative/entry.dart' as marineInvestigativeEntry;
|
import 'package:environment_monitoring_app/screens/marine/investigative/entry.dart' as marineInvestigativeEntry;
|
||||||
import 'package:environment_monitoring_app/screens/marine/investigative/report.dart' as marineInvestigativeReport;
|
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 ***
|
// *** ADDED: Route for River Investigative Manual Sampling ***
|
||||||
'/river/investigative/manual-sampling': (context) =>
|
'/river/investigative/manual-sampling': (context) =>
|
||||||
riverInvestigativeManualSampling.RiverInvestigativeManualSamplingScreen(),
|
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) =>
|
'/river/investigative/overview': (context) =>
|
||||||
riverInvestigativeOverview.OverviewScreen(), // Keep placeholder/future routes
|
riverInvestigativeOverview.OverviewScreen(), // Keep placeholder/future routes
|
||||||
'/river/investigative/entry': (context) =>
|
'/river/investigative/entry': (context) =>
|
||||||
@ -452,6 +472,12 @@ class _RootAppState extends State<RootApp> {
|
|||||||
'/marine/investigative/info': (context) => const MarineInvestigativeInfoCentreDocument(),
|
'/marine/investigative/info': (context) => const MarineInvestigativeInfoCentreDocument(),
|
||||||
'/marine/investigative/manual-sampling': (context) =>
|
'/marine/investigative/manual-sampling': (context) =>
|
||||||
marineInvestigativeManualSampling.MarineInvestigativeManualSampling(),
|
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/overview': (context) => marineInvestigativeOverview.OverviewScreen(),
|
||||||
'/marine/investigative/entry': (context) => marineInvestigativeEntry.EntryScreen(),
|
'/marine/investigative/entry': (context) => marineInvestigativeEntry.EntryScreen(),
|
||||||
'/marine/investigative/report': (context) => marineInvestigativeReport.ReportScreen(),
|
'/marine/investigative/report': (context) => marineInvestigativeReport.ReportScreen(),
|
||||||
|
|||||||
@ -47,6 +47,15 @@ class MarineManualNpeReportData {
|
|||||||
File? image2;
|
File? image2;
|
||||||
File? image3;
|
File? image3;
|
||||||
File? image4;
|
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 ---
|
// --- Submission Status ---
|
||||||
String? submissionStatus;
|
String? submissionStatus;
|
||||||
@ -76,6 +85,14 @@ class MarineManualNpeReportData {
|
|||||||
'fieldObservations': fieldObservations,
|
'fieldObservations': fieldObservations,
|
||||||
'othersObservationRemark': othersObservationRemark,
|
'othersObservationRemark': othersObservationRemark,
|
||||||
'possibleSource': possibleSource,
|
'possibleSource': possibleSource,
|
||||||
|
'image1Remark': image1Remark,
|
||||||
|
'image2Remark': image2Remark,
|
||||||
|
'image3Remark': image3Remark,
|
||||||
|
'image4Remark': image4Remark,
|
||||||
|
// --- Added Fields ---
|
||||||
|
'tarballClassificationId': tarballClassificationId,
|
||||||
|
'selectedTarballClassification': selectedTarballClassification,
|
||||||
|
// ---
|
||||||
'submissionStatus': submissionStatus,
|
'submissionStatus': submissionStatus,
|
||||||
'submissionMessage': submissionMessage,
|
'submissionMessage': submissionMessage,
|
||||||
'reportId': reportId,
|
'reportId': reportId,
|
||||||
@ -121,6 +138,15 @@ class MarineManualNpeReportData {
|
|||||||
}
|
}
|
||||||
add('npe_possible_source', possibleSource);
|
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;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,6 +195,28 @@ class MarineManualNpeReportData {
|
|||||||
..writeln('*Possible Source:* $possibleSource');
|
..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();
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,15 @@ import 'package:environment_monitoring_app/services/marine_tarball_sampling_serv
|
|||||||
// END CHANGE
|
// END CHANGE
|
||||||
import 'dart:convert';
|
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 {
|
class SubmissionLogEntry {
|
||||||
final String type;
|
final String type;
|
||||||
@ -59,13 +68,21 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
|||||||
late MarineInSituSamplingService _marineInSituService;
|
late MarineInSituSamplingService _marineInSituService;
|
||||||
late MarineTarballSamplingService _marineTarballService;
|
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> _manualLogs = [];
|
||||||
List<SubmissionLogEntry> _tarballLogs = [];
|
List<SubmissionLogEntry> _tarballLogs = [];
|
||||||
|
List<SubmissionLogEntry> _preSamplingLogs = []; // No data source, will be empty
|
||||||
|
List<SubmissionLogEntry> _reportLogs = []; // Will hold NPE logs
|
||||||
|
|
||||||
List<SubmissionLogEntry> _filteredManualLogs = [];
|
List<SubmissionLogEntry> _filteredManualLogs = [];
|
||||||
List<SubmissionLogEntry> _filteredTarballLogs = [];
|
List<SubmissionLogEntry> _filteredTarballLogs = [];
|
||||||
|
List<SubmissionLogEntry> _filteredPreSamplingLogs = [];
|
||||||
final TextEditingController _manualSearchController = TextEditingController();
|
List<SubmissionLogEntry> _filteredReportLogs = [];
|
||||||
final TextEditingController _tarballSearchController = TextEditingController();
|
// --- END: MODIFIED STATE ---
|
||||||
|
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
final Map<String, bool> _isResubmitting = {};
|
final Map<String, bool> _isResubmitting = {};
|
||||||
@ -75,8 +92,7 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
// MODIFIED: Service instantiations are removed from initState.
|
// MODIFIED: Service instantiations are removed from initState.
|
||||||
// They will be initialized in didChangeDependencies.
|
// They will be initialized in didChangeDependencies.
|
||||||
_manualSearchController.addListener(_filterLogs);
|
_searchController.addListener(_filterLogs); // Use single search controller
|
||||||
_tarballSearchController.addListener(_filterLogs);
|
|
||||||
_loadAllLogs();
|
_loadAllLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,43 +102,46 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
|||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
// Fetch the single, global instances of the services from the Provider tree.
|
// Fetch the single, global instances of the services from the Provider tree.
|
||||||
_marineInSituService = Provider.of<MarineInSituSamplingService>(context);
|
_marineInSituService = Provider.of<MarineInSituSamplingService>(context, listen: false);
|
||||||
_marineTarballService = Provider.of<MarineTarballSamplingService>(context);
|
_marineTarballService = Provider.of<MarineTarballSamplingService>(context, listen: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_manualSearchController.dispose();
|
_searchController.dispose(); // Dispose single search controller
|
||||||
_tarballSearchController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadAllLogs() async {
|
Future<void> _loadAllLogs() async {
|
||||||
setState(() => _isLoading = true);
|
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 tarballLogs = await _localStorageService.getAllTarballLogs();
|
||||||
final inSituLogs = await _localStorageService.getAllInSituLogs();
|
final inSituLogs = await _localStorageService.getAllInSituLogs();
|
||||||
|
final npeLogs = await _localStorageService.getAllNpeLogs();
|
||||||
|
// --- END MODIFICATION ---
|
||||||
|
|
||||||
final List<SubmissionLogEntry> tempManual = [];
|
final List<SubmissionLogEntry> tempManual = [];
|
||||||
final List<SubmissionLogEntry> tempTarball = [];
|
final List<SubmissionLogEntry> tempTarball = [];
|
||||||
|
final List<SubmissionLogEntry> tempReport = [];
|
||||||
|
final List<SubmissionLogEntry> tempPreSampling = []; // Empty list
|
||||||
|
|
||||||
|
// Process In-Situ (Manual Sampling)
|
||||||
for (var log in inSituLogs) {
|
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 dateStr = log['sampling_date'] ?? log['man_date'] ?? '';
|
||||||
final String timeStr = log['sampling_time'] ?? log['man_time'] ?? '';
|
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');
|
final dt = DateTime.tryParse('$dateStr $timeStr');
|
||||||
// --- END FIX ---
|
|
||||||
|
|
||||||
tempManual.add(SubmissionLogEntry(
|
tempManual.add(SubmissionLogEntry(
|
||||||
type: 'Manual Sampling',
|
type: 'Manual Sampling',
|
||||||
title: log['selectedStation']?['man_station_name'] ?? 'Unknown Station',
|
title: log['selectedStation']?['man_station_name'] ?? 'Unknown Station',
|
||||||
stationCode: log['selectedStation']?['man_station_code'] ?? 'N/A',
|
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),
|
submissionDateTime: dt ?? DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
// --- END FIX ---
|
|
||||||
reportId: log['reportId']?.toString(),
|
reportId: log['reportId']?.toString(),
|
||||||
status: log['submissionStatus'] ?? 'L1',
|
status: log['submissionStatus'] ?? 'L1',
|
||||||
message: log['submissionMessage'] ?? 'No status message.',
|
message: log['submissionMessage'] ?? 'No status message.',
|
||||||
@ -133,15 +152,17 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process Tarball
|
||||||
for (var log in tarballLogs) {
|
for (var log in tarballLogs) {
|
||||||
final dateStr = log['sampling_date'] ?? '';
|
final dateStr = log['sampling_date'] ?? '';
|
||||||
final timeStr = log['sampling_time'] ?? '';
|
final timeStr = log['sampling_time'] ?? '';
|
||||||
|
final dt = DateTime.tryParse('$dateStr $timeStr');
|
||||||
|
|
||||||
tempTarball.add(SubmissionLogEntry(
|
tempTarball.add(SubmissionLogEntry(
|
||||||
type: 'Tarball Sampling',
|
type: 'Tarball Sampling',
|
||||||
title: log['selectedStation']?['tbl_station_name'] ?? 'Unknown Station',
|
title: log['selectedStation']?['tbl_station_name'] ?? 'Unknown Station',
|
||||||
stationCode: log['selectedStation']?['tbl_station_code'] ?? 'N/A',
|
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(),
|
reportId: log['reportId']?.toString(),
|
||||||
status: log['submissionStatus'] ?? 'L1',
|
status: log['submissionStatus'] ?? 'L1',
|
||||||
message: log['submissionMessage'] ?? 'No status message.',
|
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));
|
tempManual.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime));
|
||||||
tempTarball.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) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_manualLogs = tempManual;
|
_manualLogs = tempManual;
|
||||||
_tarballLogs = tempTarball;
|
_tarballLogs = tempTarball;
|
||||||
|
_reportLogs = tempReport;
|
||||||
|
_preSamplingLogs = tempPreSampling; // Stays empty
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
_filterLogs();
|
_filterLogs(); // Apply initial filter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- START: MODIFIED _filterLogs ---
|
||||||
void _filterLogs() {
|
void _filterLogs() {
|
||||||
final manualQuery = _manualSearchController.text.toLowerCase();
|
final query = _searchController.text.toLowerCase();
|
||||||
final tarballQuery = _tarballSearchController.text.toLowerCase();
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_filteredManualLogs = _manualLogs.where((log) => _logMatchesQuery(log, manualQuery)).toList();
|
// We filter all lists regardless of selection, so data is ready
|
||||||
_filteredTarballLogs = _tarballLogs.where((log) => _logMatchesQuery(log, tarballQuery)).toList();
|
// 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) {
|
bool _logMatchesQuery(SubmissionLogEntry log, String query) {
|
||||||
if (query.isEmpty) return true;
|
if (query.isEmpty) return true;
|
||||||
@ -255,6 +322,13 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
|||||||
context: context,
|
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) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@ -277,75 +351,151 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- START: MODIFIED build ---
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final hasAnyLogs = _manualLogs.isNotEmpty || _tarballLogs.isNotEmpty;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Marine Manual Data Status Log')),
|
appBar: AppBar(title: const Text('Marine Manual Data Status Log')),
|
||||||
body: _isLoading
|
body: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: RefreshIndicator(
|
: Padding(
|
||||||
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: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
// --- 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(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: searchController,
|
controller: _searchController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Search in $category...',
|
hintText: 'Search in $_selectedModule...',
|
||||||
prefixIcon: const Icon(Icons.search, size: 20),
|
prefixIcon: const Icon(Icons.search, size: 20),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
suffixIcon: searchController.text.isNotEmpty ? IconButton(
|
suffixIcon: _searchController.text.isNotEmpty ? IconButton(
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
searchController.clear();
|
_searchController.clear();
|
||||||
},
|
},
|
||||||
) : null,
|
) : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(height: 1),
|
||||||
if (logs.isEmpty)
|
|
||||||
const Padding(
|
// --- LOG LIST (MOVED INSIDE CARD) ---
|
||||||
padding: EdgeInsets.all(16.0),
|
Expanded(
|
||||||
child: Center(child: Text('No logs match your search in this category.')))
|
child: RefreshIndicator(
|
||||||
else
|
onRefresh: _loadAllLogs,
|
||||||
ListView.builder(
|
child: _buildCurrentModuleList(),
|
||||||
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) {
|
Widget _buildLogListItem(SubmissionLogEntry log) {
|
||||||
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
|
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.
|
// Define the different states based on the detailed status code.
|
||||||
final bool isFullSuccess = log.status == 'S4';
|
final bool isFullSuccess = log.status == 'S4';
|
||||||
final bool isPartialSuccess = log.status == 'S3' || log.status == 'L4';
|
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;
|
IconData statusIcon;
|
||||||
Color statusColor;
|
Color statusColor;
|
||||||
|
|
||||||
@ -371,7 +521,6 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
|||||||
statusIcon = Icons.error_outline;
|
statusIcon = Icons.error_outline;
|
||||||
statusColor = Colors.red;
|
statusColor = Colors.red;
|
||||||
}
|
}
|
||||||
// --- END: MODIFICATION FOR GRANULAR STATUS ICONS ---
|
|
||||||
|
|
||||||
final titleWidget = RichText(
|
final titleWidget = RichText(
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
@ -411,6 +560,31 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
|
|||||||
_buildDetailRow('Server:', log.serverName),
|
_buildDetailRow('Server:', log.serverName),
|
||||||
_buildDetailRow('Report ID:', log.reportId ?? 'N/A'),
|
_buildDetailRow('Report ID:', log.reportId ?? 'N/A'),
|
||||||
_buildDetailRow('Submission Type:', log.type),
|
_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) {
|
Widget _buildDetailRow(String label, String value) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
|
|||||||
@ -23,7 +23,7 @@ class MarineManualReportHomePage extends StatelessWidget {
|
|||||||
ReportItem(
|
ReportItem(
|
||||||
icon: Icons.warning_amber_rounded,
|
icon: Icons.warning_amber_rounded,
|
||||||
label: "Notification of Pollution Event",
|
label: "Notification of Pollution Event",
|
||||||
formCode: "F-MM06",
|
formCode: "F-MM07",
|
||||||
route: '/marine/manual/report/npe',
|
route: '/marine/manual/report/npe',
|
||||||
),
|
),
|
||||||
ReportItem(
|
ReportItem(
|
||||||
|
|||||||
@ -30,12 +30,8 @@ class _MarineManualEquipmentMaintenanceScreenState
|
|||||||
// --- Controllers ---
|
// --- Controllers ---
|
||||||
final _maintenanceDateController = TextEditingController();
|
final _maintenanceDateController = TextEditingController();
|
||||||
final _lastMaintenanceDateController = TextEditingController();
|
final _lastMaintenanceDateController = TextEditingController();
|
||||||
// Controller removed for Schedule Maintenance dropdown
|
|
||||||
|
|
||||||
// Renamed controllers, moved to header
|
|
||||||
final _timeStartController = TextEditingController();
|
final _timeStartController = TextEditingController();
|
||||||
final _timeEndController = TextEditingController();
|
final _timeEndController = TextEditingController();
|
||||||
final _locationController = TextEditingController();
|
|
||||||
|
|
||||||
// YSI controllers
|
// YSI controllers
|
||||||
final _ysiSondeCommentsController = TextEditingController();
|
final _ysiSondeCommentsController = TextEditingController();
|
||||||
@ -52,6 +48,13 @@ class _MarineManualEquipmentMaintenanceScreenState
|
|||||||
final Map<String, TextEditingController> _vanDornLastDateControllers = {};
|
final Map<String, TextEditingController> _vanDornLastDateControllers = {};
|
||||||
final Map<String, TextEditingController> _vanDornNewDateControllers = {};
|
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 ---
|
// --- State for Previous Record feature ---
|
||||||
bool _showPreviousRecordDropdown = false;
|
bool _showPreviousRecordDropdown = false;
|
||||||
bool _isFetchingPreviousRecords = false;
|
bool _isFetchingPreviousRecords = false;
|
||||||
@ -106,9 +109,8 @@ class _MarineManualEquipmentMaintenanceScreenState
|
|||||||
_connectivitySubscription.cancel();
|
_connectivitySubscription.cancel();
|
||||||
_maintenanceDateController.dispose();
|
_maintenanceDateController.dispose();
|
||||||
_lastMaintenanceDateController.dispose();
|
_lastMaintenanceDateController.dispose();
|
||||||
_timeStartController.dispose(); // Renamed
|
_timeStartController.dispose();
|
||||||
_timeEndController.dispose(); // Renamed
|
_timeEndController.dispose();
|
||||||
_locationController.dispose(); // Renamed
|
|
||||||
_ysiSondeCommentsController.dispose();
|
_ysiSondeCommentsController.dispose();
|
||||||
_ysiSensorCommentsController.dispose();
|
_ysiSensorCommentsController.dispose();
|
||||||
_vanDornCommentsController.dispose();
|
_vanDornCommentsController.dispose();
|
||||||
@ -119,6 +121,14 @@ class _MarineManualEquipmentMaintenanceScreenState
|
|||||||
_ysiNewSerialControllers.values.forEach((c) => c.dispose());
|
_ysiNewSerialControllers.values.forEach((c) => c.dispose());
|
||||||
_vanDornLastDateControllers.values.forEach((c) => c.dispose());
|
_vanDornLastDateControllers.values.forEach((c) => c.dispose());
|
||||||
_vanDornNewDateControllers.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();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,16 +278,55 @@ class _MarineManualEquipmentMaintenanceScreenState
|
|||||||
|
|
||||||
// --- End Previous Record Logic ---
|
// --- 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 {
|
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()) {
|
if (!_formKey.currentState!.validate()) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
// 3. Find the *first* invalid field in order and show the error dialog.
|
||||||
content: Text("Please fill in all required fields."),
|
// This provides a guided user experience.
|
||||||
backgroundColor: Colors.orange,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// Form validation passed, save the form fields to _data where applicable
|
// --- END MODIFICATION ---
|
||||||
_formKey.currentState!.save();
|
|
||||||
|
// Form validation passed
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -288,25 +337,11 @@ class _MarineManualEquipmentMaintenanceScreenState
|
|||||||
_data.conductedByUserId = auth.profileData?['user_id'];
|
_data.conductedByUserId = auth.profileData?['user_id'];
|
||||||
_data.maintenanceDate = _maintenanceDateController.text;
|
_data.maintenanceDate = _maintenanceDateController.text;
|
||||||
_data.lastMaintenanceDate = _lastMaintenanceDateController.text;
|
_data.lastMaintenanceDate = _lastMaintenanceDateController.text;
|
||||||
// scheduleMaintenance is set via Dropdown onSaved
|
// scheduleMaintenance & location are set via Dropdown onSaved
|
||||||
|
|
||||||
// Assign header fields
|
// Assign header fields
|
||||||
_data.timeStart = _timeStartController.text;
|
_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;
|
_data.timeEnd = _timeEndController.text;
|
||||||
}
|
|
||||||
// --- MODIFICATION END ---
|
|
||||||
|
|
||||||
|
|
||||||
// Assign comments and serials
|
// Assign comments and serials
|
||||||
_data.ysiSondeComments = _ysiSondeCommentsController.text;
|
_data.ysiSondeComments = _ysiSondeCommentsController.text;
|
||||||
@ -317,12 +352,12 @@ class _MarineManualEquipmentMaintenanceScreenState
|
|||||||
|
|
||||||
// Assign dynamic table values from their controllers
|
// Assign dynamic table values from their controllers
|
||||||
_data.ysiReplacements.forEach((item, values) {
|
_data.ysiReplacements.forEach((item, values) {
|
||||||
values['Current Serial'] = _ysiCurrentSerialControllers[item]?.text ?? ''; // Added null check
|
values['Current Serial'] = _ysiCurrentSerialControllers[item]?.text ?? '';
|
||||||
values['New Serial'] = _ysiNewSerialControllers[item]?.text ?? ''; // Added null check
|
values['New Serial'] = _ysiNewSerialControllers[item]?.text ?? '';
|
||||||
});
|
});
|
||||||
_data.vanDornReplacements.forEach((part, values) {
|
_data.vanDornReplacements.forEach((part, values) {
|
||||||
values['Last Date'] = _vanDornLastDateControllers[part]?.text ?? ''; // Added null check
|
values['Last Date'] = _vanDornLastDateControllers[part]?.text ?? '';
|
||||||
values['New Date'] = _vanDornNewDateControllers[part]?.text ?? ''; // Added null check
|
values['New Date'] = _vanDornNewDateControllers[part]?.text ?? '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Submit the data
|
// Submit the data
|
||||||
@ -517,6 +552,7 @@ class _MarineManualEquipmentMaintenanceScreenState
|
|||||||
// --- END NEW FIELDS ---
|
// --- END NEW FIELDS ---
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _maintenanceDateController,
|
controller: _maintenanceDateController,
|
||||||
|
focusNode: _maintenanceDateFocus, // <-- MODIFICATION
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Maintenance Date *',
|
labelText: 'Maintenance Date *',
|
||||||
@ -528,18 +564,21 @@ class _MarineManualEquipmentMaintenanceScreenState
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _lastMaintenanceDateController,
|
controller: _lastMaintenanceDateController,
|
||||||
|
focusNode: _lastMaintenanceDateFocus, // <-- MODIFICATION
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Last Maintenance Date',
|
labelText: 'Last Maintenance Date *', // <-- MODIFICATION
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
suffixIcon: Icon(Icons.calendar_month)),
|
suffixIcon: Icon(Icons.calendar_month)),
|
||||||
onTap: _isLoading ? null : () => _selectDate(_lastMaintenanceDateController), // Disable tap when loading
|
onTap: _isLoading ? null : () => _selectDate(_lastMaintenanceDateController), // Disable tap when loading
|
||||||
|
validator: (val) => val == null || val.isEmpty ? 'Date is required' : null, // <-- MODIFICATION
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// Changed to DropdownButtonFormField
|
// Changed to DropdownButtonFormField
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
|
focusNode: _scheduleMaintenanceFocus, // <-- MODIFICATION
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Schedule Maintenance',
|
labelText: 'Schedule Maintenance *',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
value: _data.scheduleMaintenance, // Set from initState
|
value: _data.scheduleMaintenance, // Set from initState
|
||||||
@ -549,7 +588,7 @@ class _MarineManualEquipmentMaintenanceScreenState
|
|||||||
child: Text(value),
|
child: Text(value),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
onChanged: _isLoading ? null : (val) { // Disable when loading
|
onChanged: _isLoading ? null : (val) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_data.scheduleMaintenance = val; // Update data on change
|
_data.scheduleMaintenance = val; // Update data on change
|
||||||
});
|
});
|
||||||
@ -557,23 +596,38 @@ class _MarineManualEquipmentMaintenanceScreenState
|
|||||||
onSaved: (val) {
|
onSaved: (val) {
|
||||||
_data.scheduleMaintenance = val; // Save data on form save
|
_data.scheduleMaintenance = val; // Save data on form save
|
||||||
},
|
},
|
||||||
// Add validator if required
|
validator: (val) => val == null ? 'Please select an option' : null,
|
||||||
// validator: (val) => val == null ? 'Please select Yes or No' : null,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// Fields MOVED HERE
|
DropdownButtonFormField<String>(
|
||||||
TextFormField(
|
focusNode: _locationFocus, // <-- MODIFICATION
|
||||||
controller: _locationController, // Renamed controller
|
decoration: const InputDecoration(
|
||||||
decoration: const InputDecoration(labelText: 'Location', border: OutlineInputBorder()),
|
labelText: 'Location *',
|
||||||
readOnly: _isLoading, // Disable when loading
|
border: OutlineInputBorder(),
|
||||||
onSaved: (val) => _data.location = val,
|
),
|
||||||
|
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),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: _timeStartController, // Renamed controller
|
controller: _timeStartController,
|
||||||
readOnly: true, // Always read-only as it's defaulted
|
readOnly: true, // Always read-only as it's defaulted
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Time Start',
|
labelText: 'Time Start',
|
||||||
@ -585,14 +639,15 @@ class _MarineManualEquipmentMaintenanceScreenState
|
|||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: _timeEndController, // Renamed controller
|
controller: _timeEndController,
|
||||||
|
focusNode: _timeEndFocus, // <-- MODIFICATION
|
||||||
readOnly: true, // Make readOnly to prevent manual edit after selection
|
readOnly: true, // Make readOnly to prevent manual edit after selection
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Time End',
|
labelText: 'Time End *',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
suffixIcon: Icon(Icons.access_time)),
|
suffixIcon: Icon(Icons.access_time)),
|
||||||
onTap: _isLoading ? null : () => _selectTime(_timeEndController), // Disable tap when loading
|
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -21,7 +21,7 @@ class MarineManualNPEReportHub extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
icon: Icons.sync_alt,
|
icon: Icons.sync_alt,
|
||||||
title: 'From Recent In-Situ Sample',
|
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: () {
|
onTap: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
@ -32,8 +32,8 @@ class MarineManualNPEReportHub extends StatelessWidget {
|
|||||||
_buildOptionCard(
|
_buildOptionCard(
|
||||||
context: context,
|
context: context,
|
||||||
icon: Icons.public,
|
icon: Icons.public,
|
||||||
title: 'From Tarball Station',
|
title: 'From Recent Tarball Station',
|
||||||
subtitle: 'Select a tarball station to report a pollution event.',
|
subtitle: 'Use information from a recent tarball sampling event.',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@ -25,14 +25,65 @@ class _MarineManualSondeCalibrationScreenState
|
|||||||
bool _isOnline = true;
|
bool _isOnline = true;
|
||||||
late StreamSubscription<List<ConnectivityResult>> _connectivitySubscription;
|
late StreamSubscription<List<ConnectivityResult>> _connectivitySubscription;
|
||||||
|
|
||||||
|
// Scroll controller to move to top on error
|
||||||
|
final _scrollController = ScrollController();
|
||||||
|
|
||||||
|
// --- Controllers for all fields ---
|
||||||
final _sondeSerialController = TextEditingController();
|
final _sondeSerialController = TextEditingController();
|
||||||
final _firmwareController = TextEditingController();
|
final _firmwareController = TextEditingController();
|
||||||
final _korController = TextEditingController();
|
final _korController = TextEditingController();
|
||||||
final _locationController = TextEditingController();
|
// Location is now a dropdown, no controller needed, will save to _data
|
||||||
final _startDateTimeController = TextEditingController();
|
final _startDateTimeController = TextEditingController();
|
||||||
final _endDateTimeController = TextEditingController();
|
final _endDateTimeController = TextEditingController();
|
||||||
final _remarksController = 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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@ -46,13 +97,54 @@ class _MarineManualSondeCalibrationScreenState
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_connectivitySubscription.cancel();
|
_connectivitySubscription.cancel();
|
||||||
|
_scrollController.dispose();
|
||||||
|
|
||||||
|
// Dispose all controllers
|
||||||
_sondeSerialController.dispose();
|
_sondeSerialController.dispose();
|
||||||
_firmwareController.dispose();
|
_firmwareController.dispose();
|
||||||
_korController.dispose();
|
_korController.dispose();
|
||||||
_locationController.dispose();
|
|
||||||
_startDateTimeController.dispose();
|
_startDateTimeController.dispose();
|
||||||
_endDateTimeController.dispose();
|
_endDateTimeController.dispose();
|
||||||
_remarksController.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();
|
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 {
|
Future<void> _selectDateTime(TextEditingController controller) async {
|
||||||
final date = await showDatePicker(
|
final date = await showDatePicker(
|
||||||
context: context,
|
context: context,
|
||||||
@ -97,13 +238,19 @@ class _MarineManualSondeCalibrationScreenState
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submit() async {
|
Future<void> _submit() async {
|
||||||
|
// Check form validity
|
||||||
if (!_formKey.currentState!.validate()) {
|
if (!_formKey.currentState!.validate()) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
// If invalid, show dialog and scroll to top
|
||||||
content: Text("Please fill in all required fields."),
|
_showErrorDialog();
|
||||||
backgroundColor: Colors.red,
|
_scrollController.animateTo(
|
||||||
));
|
0.0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If valid, save form data and set loading state
|
||||||
_formKey.currentState!.save();
|
_formKey.currentState!.save();
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
@ -112,15 +259,36 @@ class _MarineManualSondeCalibrationScreenState
|
|||||||
final service =
|
final service =
|
||||||
Provider.of<MarineManualSondeCalibrationService>(context, listen: false);
|
Provider.of<MarineManualSondeCalibrationService>(context, listen: false);
|
||||||
|
|
||||||
|
// Populate _data object from controllers
|
||||||
_data.calibratedByUserId = auth.profileData?['user_id'];
|
_data.calibratedByUserId = auth.profileData?['user_id'];
|
||||||
_data.sondeSerialNumber = _sondeSerialController.text;
|
_data.sondeSerialNumber = _sondeSerialController.text;
|
||||||
_data.firmwareVersion = _firmwareController.text;
|
_data.firmwareVersion = _firmwareController.text;
|
||||||
_data.korVersion = _korController.text;
|
_data.korVersion = _korController.text;
|
||||||
_data.location = _locationController.text;
|
// _data.location is already set by onSaved
|
||||||
_data.startDateTime = _startDateTimeController.text;
|
_data.startDateTime = _startDateTimeController.text;
|
||||||
_data.endDateTime = _endDateTimeController.text;
|
_data.endDateTime = _endDateTimeController.text;
|
||||||
|
// _data.calibrationStatus is already set by onSaved
|
||||||
_data.remarks = _remarksController.text;
|
_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 =
|
final result =
|
||||||
await service.submitCalibration(data: _data, authProvider: auth);
|
await service.submitCalibration(data: _data, authProvider: auth);
|
||||||
|
|
||||||
@ -175,7 +343,10 @@ class _MarineManualSondeCalibrationScreenState
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
|
// Autovalidate after the first submit attempt
|
||||||
|
autovalidateMode: AutovalidateMode.disabled,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
|
controller: _scrollController, // Added scroll controller
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@ -227,11 +398,11 @@ class _MarineManualSondeCalibrationScreenState
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _sondeSerialController,
|
controller: _sondeSerialController,
|
||||||
|
focusNode: _sondeSerialFocusNode,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Sonde Serial Number *',
|
labelText: 'Sonde Serial Number *',
|
||||||
border: OutlineInputBorder()),
|
border: OutlineInputBorder()),
|
||||||
validator: (val) =>
|
validator: _validateRequired, // Use helper
|
||||||
val == null || val.isEmpty ? 'Serial Number is required' : null,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
@ -239,52 +410,65 @@ class _MarineManualSondeCalibrationScreenState
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: _firmwareController,
|
controller: _firmwareController,
|
||||||
|
focusNode: _firmwareFocusNode,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Firmware Version',
|
labelText: 'Firmware Version *', // Made required
|
||||||
border: OutlineInputBorder()),
|
border: OutlineInputBorder()),
|
||||||
|
validator: _validateRequired, // Added validator
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: _korController,
|
controller: _korController,
|
||||||
|
focusNode: _korFocusNode,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'KOR Version', border: OutlineInputBorder()),
|
labelText: 'KOR Version *', // Made required
|
||||||
|
border: OutlineInputBorder()),
|
||||||
|
validator: _validateRequired, // Added validator
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
// --- MODIFIED: Location Dropdown ---
|
||||||
controller: _locationController,
|
DropdownButtonFormField<String>(
|
||||||
|
focusNode: _locationFocusNode,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Location *', border: OutlineInputBorder()),
|
labelText: 'Location *', border: OutlineInputBorder()),
|
||||||
validator: (val) =>
|
items: ['HQ', 'Regional'].map((String value) {
|
||||||
val == null || val.isEmpty ? 'Location is required' : null,
|
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),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _startDateTimeController,
|
controller: _startDateTimeController,
|
||||||
|
focusNode: _startDateTimeFocusNode,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Start Date/Time *',
|
labelText: 'Start Date/Time *',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
suffixIcon: Icon(Icons.calendar_month)),
|
suffixIcon: Icon(Icons.calendar_month)),
|
||||||
onTap: () => _selectDateTime(_startDateTimeController),
|
onTap: () => _selectDateTime(_startDateTimeController),
|
||||||
validator: (val) =>
|
validator: _validateRequired, // Use helper
|
||||||
val == null || val.isEmpty ? 'Start Time is required' : null,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _endDateTimeController,
|
controller: _endDateTimeController,
|
||||||
|
focusNode: _endDateTimeFocusNode,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'End Date/Time *',
|
labelText: 'End Date/Time *',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
suffixIcon: Icon(Icons.calendar_month)),
|
suffixIcon: Icon(Icons.calendar_month)),
|
||||||
onTap: () => _selectDateTime(_endDateTimeController),
|
onTap: () => _selectDateTime(_endDateTimeController),
|
||||||
validator: (val) =>
|
validator: _validateRequired, // Use helper
|
||||||
val == null || val.isEmpty ? 'End Time is required' : null,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -305,47 +489,57 @@ class _MarineManualSondeCalibrationScreenState
|
|||||||
style: Theme.of(context).textTheme.titleLarge),
|
style: Theme.of(context).textTheme.titleLarge),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildSectionHeader('pH'),
|
_buildSectionHeader('pH'),
|
||||||
// MODIFIED: Renamed to clarify 3 columns
|
|
||||||
_buildParameterRowThreeColumn(
|
_buildParameterRowThreeColumn(
|
||||||
'pH 7.00 (mV 0+30)',
|
'pH 7.00 (mV 0+30)',
|
||||||
onSaveMv: (val) => _data.ph7Mv = val,
|
mvController: _ph7MvController,
|
||||||
onSaveBefore: (val) => _data.ph7Before = val,
|
beforeController: _ph7BeforeController,
|
||||||
onSaveAfter: (val) => _data.ph7After = val,
|
afterController: _ph7AfterController,
|
||||||
|
mvFocusNode: _ph7MvFocusNode,
|
||||||
|
beforeFocusNode: _ph7BeforeFocusNode,
|
||||||
|
afterFocusNode: _ph7AfterFocusNode,
|
||||||
),
|
),
|
||||||
_buildParameterRowThreeColumn(
|
_buildParameterRowThreeColumn(
|
||||||
'pH 10.00 (mV-180+30)',
|
'pH 10.00 (mV-180+30)',
|
||||||
onSaveMv: (val) => _data.ph10Mv = val,
|
mvController: _ph10MvController,
|
||||||
onSaveBefore: (val) => _data.ph10Before = val,
|
beforeController: _ph10BeforeController,
|
||||||
onSaveAfter: (val) => _data.ph10After = val,
|
afterController: _ph10AfterController,
|
||||||
|
mvFocusNode: _ph10MvFocusNode,
|
||||||
|
beforeFocusNode: _ph10BeforeFocusNode,
|
||||||
|
afterFocusNode: _ph10AfterFocusNode,
|
||||||
),
|
),
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
_buildSectionHeader('SP Conductivity (µS/cm)'),
|
_buildSectionHeader('SP Conductivity (µS/cm)'),
|
||||||
// NEW: Using 2-column widget
|
|
||||||
_buildParameterRowTwoColumn(
|
_buildParameterRowTwoColumn(
|
||||||
'50,000 (Marine)',
|
'50,000 (Marine)',
|
||||||
onSaveBefore: (val) => _data.condBefore = val,
|
beforeController: _condBeforeController,
|
||||||
onSaveAfter: (val) => _data.condAfter = val,
|
afterController: _condAfterController,
|
||||||
|
beforeFocusNode: _condBeforeFocusNode,
|
||||||
|
afterFocusNode: _condAfterFocusNode,
|
||||||
),
|
),
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
_buildSectionHeader('Turbidity (NTU)'),
|
_buildSectionHeader('Turbidity (NTU)'),
|
||||||
// NEW: Using 2-column widget
|
|
||||||
_buildParameterRowTwoColumn(
|
_buildParameterRowTwoColumn(
|
||||||
'0.0 (D.I.)',
|
'0.0 (D.I.)',
|
||||||
onSaveBefore: (val) => _data.turbidity0Before = val,
|
beforeController: _turbidity0BeforeController,
|
||||||
onSaveAfter: (val) => _data.turbidity0After = val,
|
afterController: _turbidity0AfterController,
|
||||||
|
beforeFocusNode: _turbidity0BeforeFocusNode,
|
||||||
|
afterFocusNode: _turbidity0AfterFocusNode,
|
||||||
),
|
),
|
||||||
_buildParameterRowTwoColumn(
|
_buildParameterRowTwoColumn(
|
||||||
'124 (Marine)',
|
'124 (Marine)',
|
||||||
onSaveBefore: (val) => _data.turbidity124Before = val,
|
beforeController: _turbidity124BeforeController,
|
||||||
onSaveAfter: (val) => _data.turbidity124After = val,
|
afterController: _turbidity124AfterController,
|
||||||
|
beforeFocusNode: _turbidity124BeforeFocusNode,
|
||||||
|
afterFocusNode: _turbidity124AfterFocusNode,
|
||||||
),
|
),
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
_buildSectionHeader('Dissolved Oxygen (%)'),
|
_buildSectionHeader('Dissolved Oxygen (%)'),
|
||||||
// NEW: Using 2-column widget
|
|
||||||
_buildParameterRowTwoColumn(
|
_buildParameterRowTwoColumn(
|
||||||
'100.0 (Air Saturated)',
|
'100.0 (Air Saturated)',
|
||||||
onSaveBefore: (val) => _data.doBefore = val,
|
beforeController: _doBeforeController,
|
||||||
onSaveAfter: (val) => _data.doAfter = val,
|
afterController: _doAfterController,
|
||||||
|
beforeFocusNode: _doBeforeFocusNode,
|
||||||
|
afterFocusNode: _doAfterFocusNode,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -360,12 +554,14 @@ class _MarineManualSondeCalibrationScreenState
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// MODIFIED: Renamed to _buildParameterRowThreeColumn
|
|
||||||
Widget _buildParameterRowThreeColumn(
|
Widget _buildParameterRowThreeColumn(
|
||||||
String label, {
|
String label, {
|
||||||
required Function(double?) onSaveMv,
|
required TextEditingController mvController,
|
||||||
required Function(double?) onSaveBefore,
|
required TextEditingController beforeController,
|
||||||
required Function(double?) onSaveAfter,
|
required TextEditingController afterController,
|
||||||
|
required FocusNode mvFocusNode,
|
||||||
|
required FocusNode beforeFocusNode,
|
||||||
|
required FocusNode afterFocusNode,
|
||||||
}) {
|
}) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
@ -378,26 +574,32 @@ class _MarineManualSondeCalibrationScreenState
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
|
controller: mvController,
|
||||||
|
focusNode: mvFocusNode,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'MV Reading', border: OutlineInputBorder()),
|
labelText: 'MV Reading *', border: OutlineInputBorder()),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
onSaved: (val) => onSaveMv(double.tryParse(val ?? '')),
|
validator: _validateNumeric, // Added validator
|
||||||
)),
|
)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
|
controller: beforeController,
|
||||||
|
focusNode: beforeFocusNode,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Before Cal', border: OutlineInputBorder()),
|
labelText: 'Before Cal *', border: OutlineInputBorder()),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
onSaved: (val) => onSaveBefore(double.tryParse(val ?? '')),
|
validator: _validateNumeric, // Added validator
|
||||||
)),
|
)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
|
controller: afterController,
|
||||||
|
focusNode: afterFocusNode,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'After Cal', border: OutlineInputBorder()),
|
labelText: 'After Cal *', border: OutlineInputBorder()),
|
||||||
keyboardType: TextInputType.number,
|
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(
|
Widget _buildParameterRowTwoColumn(
|
||||||
String label, {
|
String label, {
|
||||||
required Function(double?) onSaveBefore,
|
required TextEditingController beforeController,
|
||||||
required Function(double?) onSaveAfter,
|
required TextEditingController afterController,
|
||||||
|
required FocusNode beforeFocusNode,
|
||||||
|
required FocusNode afterFocusNode,
|
||||||
}) {
|
}) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
@ -423,18 +626,22 @@ class _MarineManualSondeCalibrationScreenState
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
|
controller: beforeController,
|
||||||
|
focusNode: beforeFocusNode,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Before Cal', border: OutlineInputBorder()),
|
labelText: 'Before Cal *', border: OutlineInputBorder()),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
onSaved: (val) => onSaveBefore(double.tryParse(val ?? '')),
|
validator: _validateNumeric, // Added validator
|
||||||
)),
|
)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
|
controller: afterController,
|
||||||
|
focusNode: afterFocusNode,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'After Cal', border: OutlineInputBorder()),
|
labelText: 'After Cal *', border: OutlineInputBorder()),
|
||||||
keyboardType: TextInputType.number,
|
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),
|
Text('Summary', style: Theme.of(context).textTheme.titleLarge),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
|
focusNode: _statusFocusNode,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Overall Status *', border: OutlineInputBorder()),
|
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));
|
return DropdownMenuItem<String>(value: value, child: Text(value));
|
||||||
}).toList(),
|
}).toList(),
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
_data.calibrationStatus = val;
|
_data.calibrationStatus = val;
|
||||||
},
|
},
|
||||||
onSaved: (val) => _data.calibrationStatus = val,
|
onSaved: (val) => _data.calibrationStatus = val,
|
||||||
validator: (val) =>
|
validator: _validateDropdown, // Use dropdown validator
|
||||||
val == null || val.isEmpty ? 'Status is required' : null,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _remarksController,
|
controller: _remarksController,
|
||||||
|
focusNode: _remarksFocusNode,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Comment/Observation',
|
labelText: 'Comment/Observation *', // Made required
|
||||||
border: OutlineInputBorder()),
|
border: OutlineInputBorder()),
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
|
validator: _validateRequired, // Added validator
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -33,12 +33,23 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isPickingImage = false;
|
bool _isPickingImage = false;
|
||||||
|
|
||||||
|
// --- START: MODIFIED STATE VARIABLES ---
|
||||||
// Data handling
|
// Data handling
|
||||||
bool _isLoadingRecentSamples = true;
|
bool? _useRecentSample; // To track Yes/No selection
|
||||||
|
bool _isLoadingRecentSamples = false; // Now triggered on-demand
|
||||||
List<InSituSamplingData> _recentNearbySamples = [];
|
List<InSituSamplingData> _recentNearbySamples = [];
|
||||||
InSituSamplingData? _selectedRecentSample;
|
InSituSamplingData? _selectedRecentSample;
|
||||||
final MarineManualNpeReportData _npeData = MarineManualNpeReportData();
|
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
|
// Controllers
|
||||||
final _stationIdController = TextEditingController();
|
final _stationIdController = TextEditingController();
|
||||||
final _locationController = TextEditingController();
|
final _locationController = TextEditingController();
|
||||||
@ -47,17 +58,19 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
final _longController = TextEditingController();
|
final _longController = TextEditingController();
|
||||||
final _possibleSourceController = TextEditingController();
|
final _possibleSourceController = TextEditingController();
|
||||||
final _othersObservationController = TextEditingController();
|
final _othersObservationController = TextEditingController();
|
||||||
// ADDED: Controllers for in-situ measurements
|
|
||||||
final _doPercentController = TextEditingController();
|
final _doPercentController = TextEditingController();
|
||||||
final _doMgLController = TextEditingController();
|
final _doMgLController = TextEditingController();
|
||||||
final _phController = TextEditingController();
|
final _phController = TextEditingController();
|
||||||
final _condController = TextEditingController();
|
final _condController = TextEditingController();
|
||||||
final _turbController = TextEditingController();
|
final _turbController = TextEditingController();
|
||||||
final _tempController = TextEditingController();
|
final _tempController = TextEditingController();
|
||||||
|
final _image1RemarkController = TextEditingController();
|
||||||
|
final _image2RemarkController = TextEditingController();
|
||||||
|
final _image3RemarkController = TextEditingController();
|
||||||
|
final _image4RemarkController = TextEditingController();
|
||||||
|
|
||||||
// In-Situ related
|
// In-Situ related
|
||||||
late final MarineInSituSamplingService _samplingService;
|
late final MarineInSituSamplingService _samplingService;
|
||||||
// ADDED: State variables for device connection and reading
|
|
||||||
StreamSubscription? _dataSubscription;
|
StreamSubscription? _dataSubscription;
|
||||||
bool _isAutoReading = false;
|
bool _isAutoReading = false;
|
||||||
Timer? _lockoutTimer;
|
Timer? _lockoutTimer;
|
||||||
@ -69,12 +82,12 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_samplingService = Provider.of<MarineInSituSamplingService>(context, listen: false);
|
_samplingService = Provider.of<MarineInSituSamplingService>(context, listen: false);
|
||||||
_fetchRecentNearbySamples();
|
_loadAllStatesFromProvider(); // Load manual stations for "No" path
|
||||||
|
_setDefaultDateTime(); // Set default time for all paths
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
// ADDED: Cancel subscriptions and timers, disconnect devices
|
|
||||||
_dataSubscription?.cancel();
|
_dataSubscription?.cancel();
|
||||||
_lockoutTimer?.cancel();
|
_lockoutTimer?.cancel();
|
||||||
if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
|
if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) {
|
||||||
@ -91,24 +104,79 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
_longController.dispose();
|
_longController.dispose();
|
||||||
_possibleSourceController.dispose();
|
_possibleSourceController.dispose();
|
||||||
_othersObservationController.dispose();
|
_othersObservationController.dispose();
|
||||||
// ADDED: Dispose new controllers
|
|
||||||
_doPercentController.dispose();
|
_doPercentController.dispose();
|
||||||
_doMgLController.dispose();
|
_doMgLController.dispose();
|
||||||
_phController.dispose();
|
_phController.dispose();
|
||||||
_condController.dispose();
|
_condController.dispose();
|
||||||
_turbController.dispose();
|
_turbController.dispose();
|
||||||
_tempController.dispose();
|
_tempController.dispose();
|
||||||
|
_image1RemarkController.dispose();
|
||||||
|
_image2RemarkController.dispose();
|
||||||
|
_image3RemarkController.dispose();
|
||||||
|
_image4RemarkController.dispose();
|
||||||
super.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 {
|
Future<void> _fetchRecentNearbySamples() async {
|
||||||
setState(() => _isLoadingRecentSamples = true);
|
setState(() => _isLoadingRecentSamples = true);
|
||||||
bool serviceEnabled;
|
bool serviceEnabled;
|
||||||
LocationPermission permission;
|
LocationPermission permission;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Check if location services are enabled.
|
|
||||||
serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||||
if (!serviceEnabled) {
|
if (!serviceEnabled) {
|
||||||
_showSnackBar('Location services are disabled. Please enable them.', isError: true);
|
_showSnackBar('Location services are disabled. Please enable them.', isError: true);
|
||||||
@ -116,10 +184,7 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check current permission status.
|
|
||||||
permission = await Geolocator.checkPermission();
|
permission = await Geolocator.checkPermission();
|
||||||
|
|
||||||
// 3. Request permission if denied or not determined.
|
|
||||||
if (permission == LocationPermission.denied) {
|
if (permission == LocationPermission.denied) {
|
||||||
permission = await Geolocator.requestPermission();
|
permission = await Geolocator.requestPermission();
|
||||||
if (permission == LocationPermission.denied) {
|
if (permission == LocationPermission.denied) {
|
||||||
@ -129,16 +194,13 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Handle permanent denial.
|
|
||||||
if (permission == LocationPermission.deniedForever) {
|
if (permission == LocationPermission.deniedForever) {
|
||||||
_showSnackBar('Location permission permanently denied. Please enable it in app settings.', isError: true);
|
_showSnackBar('Location permission permanently denied. Please enable it in app settings.', isError: true);
|
||||||
// Optionally, offer to open settings
|
await openAppSettings();
|
||||||
await openAppSettings(); // Requires permission_handler package
|
|
||||||
if (mounted) setState(() => _isLoadingRecentSamples = false);
|
if (mounted) setState(() => _isLoadingRecentSamples = false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. If permission is granted, get the location and fetch samples.
|
|
||||||
final Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
|
final Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
|
||||||
final localDbService = Provider.of<LocalStorageService>(context, listen: false);
|
final localDbService = Provider.of<LocalStorageService>(context, listen: false);
|
||||||
final samples = await localDbService.getRecentNearbySamples(
|
final samples = await localDbService.getRecentNearbySamples(
|
||||||
@ -161,7 +223,6 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
_eventDateTimeController.text = DateFormat('yyyy-MM-dd HH:mm').format(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) ?? '';
|
_doPercentController.text = data.oxygenSaturation?.toStringAsFixed(5) ?? '';
|
||||||
_doMgLController.text = data.oxygenConcentration?.toStringAsFixed(5) ?? '';
|
_doMgLController.text = data.oxygenConcentration?.toStringAsFixed(5) ?? '';
|
||||||
_phController.text = data.ph?.toStringAsFixed(5) ?? '';
|
_phController.text = data.ph?.toStringAsFixed(5) ?? '';
|
||||||
@ -171,10 +232,22 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submitNpeReport() async {
|
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()) {
|
if (!_formKey.currentState!.validate()) {
|
||||||
_showSnackBar('Please fill in all required fields.', isError: true);
|
_showSnackBar('Please fill in all required fields.', isError: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||||
final service = Provider.of<MarineNpeReportService>(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.eventTime = _eventDateTimeController.text.split(' ').length > 1 ? _eventDateTimeController.text.split(' ')[1] : '';
|
||||||
_npeData.latitude = _latController.text;
|
_npeData.latitude = _latController.text;
|
||||||
_npeData.longitude = _longController.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.possibleSource = _possibleSourceController.text;
|
||||||
_npeData.othersObservationRemark = _othersObservationController.text;
|
_npeData.othersObservationRemark = _othersObservationController.text;
|
||||||
// ADDED: Read values from in-situ measurement controllers
|
|
||||||
_npeData.oxygenSaturation = double.tryParse(_doPercentController.text);
|
_npeData.oxygenSaturation = double.tryParse(_doPercentController.text);
|
||||||
_npeData.electricalConductivity = double.tryParse(_condController.text);
|
_npeData.electricalConductivity = double.tryParse(_condController.text);
|
||||||
_npeData.oxygenConcentration = double.tryParse(_doMgLController.text);
|
_npeData.oxygenConcentration = double.tryParse(_doMgLController.text);
|
||||||
@ -197,6 +272,11 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
_npeData.ph = double.tryParse(_phController.text);
|
_npeData.ph = double.tryParse(_phController.text);
|
||||||
_npeData.temperature = double.tryParse(_tempController.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);
|
final result = await service.submitNpeReport(data: _npeData, authProvider: auth);
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
if (mounted) {
|
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 {
|
Future<void> _processAndSetImage(ImageSource source, int imageNumber) async {
|
||||||
if (_isPickingImage) return;
|
if (_isPickingImage) return;
|
||||||
setState(() => _isPickingImage = true);
|
setState(() => _isPickingImage = true);
|
||||||
@ -229,7 +356,7 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
source,
|
source,
|
||||||
data: watermarkData,
|
data: watermarkData,
|
||||||
imageInfo: 'NPE ATTACHMENT $imageNumber',
|
imageInfo: 'NPE ATTACHMENT $imageNumber',
|
||||||
isRequired: false,
|
isRequired: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
@ -241,11 +368,16 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
case 4: _npeData.image4 = file; break;
|
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);
|
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) {
|
void _updateTextFields(Map<String, double> readings) {
|
||||||
const defaultValue = -999.0;
|
const defaultValue = -999.0;
|
||||||
setState(() {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||||
|
final allManualStations = auth.manualStations ?? [];
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text("NPE from In-Situ Sample")),
|
appBar: AppBar(title: const Text("NPE from In-Situ Sample")),
|
||||||
body: Form(
|
body: Form(
|
||||||
@ -420,12 +555,54 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(20.0),
|
padding: const EdgeInsets.all(20.0),
|
||||||
children: [
|
children: [
|
||||||
_buildSectionTitle("1. Select Recent Sample"),
|
// --- 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)
|
if (_isLoadingRecentSamples)
|
||||||
const Center(child: Padding(padding: EdgeInsets.all(8.0), child: CircularProgressIndicator()))
|
const Center(child: Padding(padding: EdgeInsets.all(8.0), child: CircularProgressIndicator()))
|
||||||
else
|
else
|
||||||
DropdownSearch<InSituSamplingData>(
|
DropdownSearch<InSituSamplingData>(
|
||||||
items: _recentNearbySamples,
|
items: _recentNearbySamples,
|
||||||
|
selectedItem: _selectedRecentSample,
|
||||||
itemAsString: (s) => "${s.selectedStation?['man_station_code']} at ${s.samplingDate} ${s.samplingTime}",
|
itemAsString: (s) => "${s.selectedStation?['man_station_code']} at ${s.samplingDate} ${s.samplingTime}",
|
||||||
popupProps: PopupProps.menu(
|
popupProps: PopupProps.menu(
|
||||||
showSearchBox: true, searchFieldProps: const TextFieldProps(decoration: InputDecoration(hintText: "Search..."))),
|
showSearchBox: true, searchFieldProps: const TextFieldProps(decoration: InputDecoration(hintText: "Search..."))),
|
||||||
@ -435,13 +612,86 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
if (sample != null) {
|
if (sample != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedRecentSample = sample;
|
_selectedRecentSample = sample;
|
||||||
|
_npeData.selectedStation = sample.selectedStation; // CRITICAL: Set station for submission
|
||||||
_populateFormFromData(sample);
|
_populateFormFromData(sample);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
validator: (val) => val == null ? "Please select a sample" : null,
|
validator: (val) => val == null ? "Please select a sample" : null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
],
|
||||||
|
|
||||||
|
// "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),
|
_buildTextFormField(controller: _stationIdController, label: "Station ID", readOnly: true),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildTextFormField(controller: _locationController, label: "Location", readOnly: true),
|
_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),
|
_buildTextFormField(controller: _eventDateTimeController, label: "Event Date/Time", readOnly: true),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// ADDED: In-Situ Measurements Section
|
_buildSectionTitle("3. In-situ Measurements"),
|
||||||
_buildSectionTitle("2. In-situ Measurements (Optional)"),
|
_buildInSituSection(),
|
||||||
_buildInSituSection(), // Calls the builder for device connection & parameters
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Sections renumbered
|
_buildSectionTitle("4. Field Observations *"),
|
||||||
_buildSectionTitle("3. Field Observations*"),
|
|
||||||
..._buildObservationsCheckboxes(),
|
..._buildObservationsCheckboxes(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
_buildSectionTitle("4. Possible Source"),
|
_buildSectionTitle("5. Possible Source"),
|
||||||
_buildTextFormField(controller: _possibleSourceController, label: "Possible Source", maxLines: 3),
|
_buildTextFormField(controller: _possibleSourceController, label: "Possible Source", maxLines: 3),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
_buildSectionTitle("5. Attachments (Figures)"),
|
_buildSectionTitle("6. Attachments (Figures) *"),
|
||||||
_buildImageAttachmentSection(),
|
_buildImageAttachmentSection(),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
@ -476,10 +724,12 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 15)
|
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"),
|
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() {
|
List<Widget> _buildObservationsCheckboxes() {
|
||||||
// Use the correct pattern from npe_report_from_tarball.dart
|
|
||||||
return [
|
return [
|
||||||
for (final key in _npeData.fieldObservations.keys)
|
for (final key in _npeData.fieldObservations.keys)
|
||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
@ -505,14 +753,12 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
controlAffinity: ListTileControlAffinity.leading,
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
// Conditionally add the 'Others' text field
|
|
||||||
if (_npeData.fieldObservations['Others'] ?? false)
|
if (_npeData.fieldObservations['Others'] ?? false)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
child: _buildTextFormField(
|
child: _buildTextFormField(
|
||||||
controller: _othersObservationController,
|
controller: _othersObservationController,
|
||||||
label: "Please specify",
|
label: "Please specify *",
|
||||||
// Make it optional by removing '*' from the label here or adjust validator
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@ -522,15 +768,45 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
Widget _buildImageAttachmentSection() {
|
Widget _buildImageAttachmentSection() {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
_buildNPEImagePicker(title: 'Figure 1', imageFile: _npeData.image1, onClear: () => setState(() => _npeData.image1 = null), imageNumber: 1),
|
_buildNPEImagePicker(
|
||||||
_buildNPEImagePicker(title: 'Figure 2', imageFile: _npeData.image2, onClear: () => setState(() => _npeData.image2 = null), imageNumber: 2),
|
title: 'Figure 1 *',
|
||||||
_buildNPEImagePicker(title: 'Figure 3', imageFile: _npeData.image3, onClear: () => setState(() => _npeData.image3 = null), imageNumber: 3),
|
imageFile: _npeData.image1,
|
||||||
_buildNPEImagePicker(title: 'Figure 4', imageFile: _npeData.image4, onClear: () => setState(() => _npeData.image4 = null), imageNumber: 4),
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -549,7 +825,10 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
child: IconButton(
|
child: IconButton(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
icon: const Icon(Icons.close, color: Colors.white, size: 20),
|
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")),
|
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,
|
maxLines: maxLines,
|
||||||
readOnly: readOnly,
|
readOnly: readOnly,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
// Allow empty if not required (no '*')
|
if (!label.contains('*')) return null;
|
||||||
if (!label.contains('*') && (value == null || value.trim().isEmpty)) return null;
|
if (!readOnly && (value == null || value.trim().isEmpty)) {
|
||||||
// Require non-empty if required ('*')
|
if (label.contains("Please specify")) {
|
||||||
if (label.contains('*') && !readOnly && (value == null || value.trim().isEmpty)) return 'This field is required';
|
return 'This field cannot be empty when "Others" is selected';
|
||||||
|
}
|
||||||
|
if (label.contains('*')) {
|
||||||
|
return 'This field is required';
|
||||||
|
}
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- START: ADDED IN-SITU WIDGET BUILDERS ---
|
// --- START: WIDGET BUILDERS FOR IN-SITU (Unchanged) ---
|
||||||
Widget _buildInSituSection() {
|
Widget _buildInSituSection() {
|
||||||
final activeConnection = _getActiveConnectionDetails();
|
final activeConnection = _getActiveConnectionDetails();
|
||||||
final String? activeType = activeConnection?['type'] as String?;
|
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(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
@ -611,12 +910,12 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
if (activeConnection != null)
|
if (activeConnection != null)
|
||||||
_buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']),
|
_buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildParameterListItem(icon: Icons.percent, label: "DO", unit: "%", controller: _doPercentController),
|
_buildParameterListItem(icon: Icons.air, label: "DO", unit: "mg/L", controller: _doMgLController, readOnly: areFieldsReadOnly),
|
||||||
_buildParameterListItem(icon: Icons.flash_on, label: "Cond", unit: "µS/cm", controller: _condController),
|
_buildParameterListItem(icon: Icons.percent, label: "DO", unit: "%", controller: _doPercentController, readOnly: areFieldsReadOnly),
|
||||||
_buildParameterListItem(icon: Icons.air, label: "DO", unit: "mg/L", controller: _doMgLController),
|
_buildParameterListItem(icon: Icons.science_outlined, label: "PH", unit: "", controller: _phController, readOnly: areFieldsReadOnly),
|
||||||
_buildParameterListItem(icon: Icons.opacity, label: "Turb", unit: "NTU", controller: _turbController),
|
_buildParameterListItem(icon: Icons.flash_on, label: "Cond", unit: "µS/cm", controller: _condController, readOnly: areFieldsReadOnly),
|
||||||
_buildParameterListItem(icon: Icons.science_outlined, label: "PH", unit: "", controller: _phController),
|
_buildParameterListItem(icon: Icons.thermostat, label: "Temp", unit: "°C", controller: _tempController, readOnly: areFieldsReadOnly),
|
||||||
_buildParameterListItem(icon: Icons.thermostat, label: "Temp", unit: "°C", controller: _tempController),
|
_buildParameterListItem(icon: Icons.opacity, label: "Turb", unit: "NTU", controller: _turbController, readOnly: areFieldsReadOnly),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -638,16 +937,17 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
children: [
|
children: [
|
||||||
Text(statusText, style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontSize: 16)),
|
Text(statusText, style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (isConnecting || _isLoading) // Show loading indicator during connection attempt OR general form loading
|
if (isConnecting || _isLoading)
|
||||||
const CircularProgressIndicator()
|
const CircularProgressIndicator()
|
||||||
else if (isConnected)
|
else if (isConnected)
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton.icon(
|
Flexible(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
|
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
|
||||||
label: Text(_isAutoReading
|
label: Text(_isAutoReading
|
||||||
? (_isLockedOut ? 'Stop Reading ($_lockoutSecondsRemaining\s)' : 'Stop Reading')
|
? (_isLockedOut ? 'Stop Reading (${_lockoutSecondsRemaining}s)' : 'Stop Reading')
|
||||||
: 'Start Reading'),
|
: 'Start Reading'),
|
||||||
onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type),
|
onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
@ -657,6 +957,7 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
icon: const Icon(Icons.link_off),
|
icon: const Icon(Icons.link_off),
|
||||||
label: const Text('Disconnect'),
|
label: const Text('Disconnect'),
|
||||||
@ -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}) {
|
Widget _buildParameterListItem({
|
||||||
// ReadOnly text field used to display the value, looks like standard text but allows copying.
|
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');
|
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 displayValue = isMissing ? '-.--' : (double.tryParse(controller.text)?.toStringAsFixed(5) ?? '-.--');
|
||||||
final String displayLabel = unit.isEmpty ? label : '$label ($unit)';
|
final String displayLabel = unit.isEmpty ? label : '$label ($unit)';
|
||||||
|
|
||||||
@ -684,27 +988,30 @@ class _NPEReportFromInSituState extends State<NPEReportFromInSitu> {
|
|||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(icon, color: Theme.of(context).primaryColor, size: 32),
|
leading: Icon(icon, color: Theme.of(context).primaryColor, size: 32),
|
||||||
title: Text(displayLabel),
|
title: Text(displayLabel),
|
||||||
trailing: SizedBox( // Use SizedBox to constrain width if needed
|
trailing: SizedBox(
|
||||||
width: 120, // Adjust width as necessary
|
width: 120,
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
// Use a unique key based on the controller to force rebuild when text changes
|
// --- START: MODIFIED to handle readOnly vs. editable ---
|
||||||
key: ValueKey(controller.text),
|
controller: readOnly ? null : controller,
|
||||||
initialValue: displayValue, // Use initialValue instead of controller directly
|
initialValue: readOnly ? displayValue : null,
|
||||||
readOnly: true, // Make it read-only
|
key: readOnly ? ValueKey(displayValue) : null,
|
||||||
|
// --- END: MODIFIED ---
|
||||||
|
readOnly: readOnly,
|
||||||
textAlign: TextAlign.right,
|
textAlign: TextAlign.right,
|
||||||
|
keyboardType: readOnly ? null : const TextInputType.numberWithOptions(decimal: true), // Allow editing only if NOT readOnly
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: isMissing ? Colors.grey : Theme.of(context).colorScheme.primary,
|
color: isMissing ? Colors.grey : Theme.of(context).colorScheme.primary,
|
||||||
),
|
),
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
border: InputBorder.none, // Remove underline/border
|
border: InputBorder.none,
|
||||||
contentPadding: EdgeInsets.zero, // Remove padding
|
contentPadding: EdgeInsets.zero,
|
||||||
|
isDense: true, // Helps with alignment
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// --- END: ADDED IN-SITU WIDGET BUILDERS ---
|
// --- END: WIDGET BUILDERS FOR IN-SITU ---
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -53,6 +53,11 @@ class _NPEReportFromTarballState extends State<NPEReportFromTarball> {
|
|||||||
final _condController = TextEditingController();
|
final _condController = TextEditingController();
|
||||||
final _turbController = TextEditingController();
|
final _turbController = TextEditingController();
|
||||||
final _tempController = TextEditingController();
|
final _tempController = TextEditingController();
|
||||||
|
// ADDED: Remark controllers for images
|
||||||
|
final _image1RemarkController = TextEditingController();
|
||||||
|
final _image2RemarkController = TextEditingController();
|
||||||
|
final _image3RemarkController = TextEditingController();
|
||||||
|
final _image4RemarkController = TextEditingController();
|
||||||
|
|
||||||
// In-Situ
|
// In-Situ
|
||||||
late final MarineInSituSamplingService _samplingService;
|
late final MarineInSituSamplingService _samplingService;
|
||||||
@ -91,6 +96,11 @@ class _NPEReportFromTarballState extends State<NPEReportFromTarball> {
|
|||||||
_condController.dispose();
|
_condController.dispose();
|
||||||
_turbController.dispose();
|
_turbController.dispose();
|
||||||
_tempController.dispose();
|
_tempController.dispose();
|
||||||
|
// ADDED: Dispose remark controllers
|
||||||
|
_image1RemarkController.dispose();
|
||||||
|
_image2RemarkController.dispose();
|
||||||
|
_image3RemarkController.dispose();
|
||||||
|
_image4RemarkController.dispose();
|
||||||
_dataSubscription?.cancel();
|
_dataSubscription?.cancel();
|
||||||
_lockoutTimer?.cancel();
|
_lockoutTimer?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@ -137,14 +147,37 @@ class _NPEReportFromTarballState extends State<NPEReportFromTarball> {
|
|||||||
|
|
||||||
_npeData.fieldObservations.clear();
|
_npeData.fieldObservations.clear();
|
||||||
_npeData.fieldObservations.addAll(data.fieldObservations);
|
_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 {
|
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()) {
|
if (!_formKey.currentState!.validate()) {
|
||||||
_showSnackBar('Please fill in all required fields.', isError: true);
|
_showSnackBar('Please fill in all required fields.', isError: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// --- END: VALIDATION CHECKS ---
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||||
final service = Provider.of<MarineNpeReportService>(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.ph = double.tryParse(_phController.text);
|
||||||
_npeData.temperature = double.tryParse(_tempController.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);
|
final result = await service.submitNpeReport(data: _npeData, authProvider: auth);
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@ -199,7 +238,7 @@ class _NPEReportFromTarballState extends State<NPEReportFromTarball> {
|
|||||||
source,
|
source,
|
||||||
data: watermarkData,
|
data: watermarkData,
|
||||||
imageInfo: 'NPE ATTACHMENT $imageNumber',
|
imageInfo: 'NPE ATTACHMENT $imageNumber',
|
||||||
isRequired: false,
|
isRequired: true, // MODIFIED: Watermark is now compulsory
|
||||||
);
|
);
|
||||||
|
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
@ -451,11 +490,11 @@ class _NPEReportFromTarballState extends State<NPEReportFromTarball> {
|
|||||||
_buildTextFormField(controller: _eventDateTimeController, label: "Event Date/Time", readOnly: true),
|
_buildTextFormField(controller: _eventDateTimeController, label: "Event Date/Time", readOnly: true),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
_buildSectionTitle("2. In-situ Measurements (Optional)"),
|
_buildSectionTitle("2. In-situ Measurements"),
|
||||||
_buildInSituSection(),
|
_buildInSituSection(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
_buildSectionTitle("3. Field Observations*"),
|
_buildSectionTitle("3. Field Observations *"),
|
||||||
..._buildObservationsCheckboxes(),
|
..._buildObservationsCheckboxes(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
@ -463,7 +502,7 @@ class _NPEReportFromTarballState extends State<NPEReportFromTarball> {
|
|||||||
_buildTextFormField(controller: _possibleSourceController, label: "Possible Source", maxLines: 3),
|
_buildTextFormField(controller: _possibleSourceController, label: "Possible Source", maxLines: 3),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
_buildSectionTitle("5. Attachments (Figures)"),
|
_buildSectionTitle("5. Attachments (Figures) *"),
|
||||||
_buildImageAttachmentSection(),
|
_buildImageAttachmentSection(),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
@ -502,7 +541,7 @@ class _NPEReportFromTarballState extends State<NPEReportFromTarball> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
child: _buildTextFormField(
|
child: _buildTextFormField(
|
||||||
controller: _othersObservationController,
|
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() {
|
Widget _buildImageAttachmentSection() {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
_buildNPEImagePicker(title: 'Figure 1', imageFile: _npeData.image1, onClear: () => setState(() => _npeData.image1 = null), imageNumber: 1),
|
_buildNPEImagePicker(
|
||||||
_buildNPEImagePicker(title: 'Figure 2', imageFile: _npeData.image2, onClear: () => setState(() => _npeData.image2 = null), imageNumber: 2),
|
title: 'Figure 1 *',
|
||||||
_buildNPEImagePicker(title: 'Figure 3', imageFile: _npeData.image3, onClear: () => setState(() => _npeData.image3 = null), imageNumber: 3),
|
imageFile: _npeData.image1,
|
||||||
_buildNPEImagePicker(title: 'Figure 4', imageFile: _npeData.image4, onClear: () => setState(() => _npeData.image4 = null), imageNumber: 4),
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -538,7 +607,10 @@ class _NPEReportFromTarballState extends State<NPEReportFromTarball> {
|
|||||||
child: IconButton(
|
child: IconButton(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
icon: const Icon(Icons.close, color: Colors.white, size: 20),
|
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")),
|
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,
|
maxLines: maxLines,
|
||||||
readOnly: readOnly,
|
readOnly: readOnly,
|
||||||
validator: (value) {
|
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)) {
|
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 'This field cannot be empty';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -604,12 +694,12 @@ class _NPEReportFromTarballState extends State<NPEReportFromTarball> {
|
|||||||
if (activeConnection != null)
|
if (activeConnection != null)
|
||||||
_buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']),
|
_buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']),
|
||||||
const SizedBox(height: 16),
|
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.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.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.thermostat, label: "Temp", unit: "°C", controller: _tempController),
|
||||||
|
_buildParameterListItem(icon: Icons.opacity, label: "Turb", unit: "NTU", controller: _turbController),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -637,10 +727,11 @@ class _NPEReportFromTarballState extends State<NPEReportFromTarball> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton.icon(
|
Flexible(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
|
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
|
||||||
label: Text(_isAutoReading
|
label: Text(_isAutoReading
|
||||||
? (_isLockedOut ? 'Stop Reading ($_lockoutSecondsRemaining\s)' : 'Stop Reading')
|
? (_isLockedOut ? 'Stop Reading (${_lockoutSecondsRemaining}s)' : 'Stop Reading')
|
||||||
: 'Start Reading'),
|
: 'Start Reading'),
|
||||||
onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type),
|
onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
@ -650,6 +741,7 @@ class _NPEReportFromTarballState extends State<NPEReportFromTarball> {
|
|||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
icon: const Icon(Icons.link_off),
|
icon: const Icon(Icons.link_off),
|
||||||
label: const Text('Disconnect'),
|
label: const Text('Disconnect'),
|
||||||
|
|||||||
@ -50,6 +50,11 @@ class _NPEReportNewLocationState extends State<NPEReportNewLocation> {
|
|||||||
final _condController = TextEditingController();
|
final _condController = TextEditingController();
|
||||||
final _turbController = TextEditingController();
|
final _turbController = TextEditingController();
|
||||||
final _tempController = TextEditingController();
|
final _tempController = TextEditingController();
|
||||||
|
// ADDED: Remark controllers for images
|
||||||
|
final _image1RemarkController = TextEditingController();
|
||||||
|
final _image2RemarkController = TextEditingController();
|
||||||
|
final _image3RemarkController = TextEditingController();
|
||||||
|
final _image4RemarkController = TextEditingController();
|
||||||
|
|
||||||
// In-Situ
|
// In-Situ
|
||||||
late final MarineInSituSamplingService _samplingService;
|
late final MarineInSituSamplingService _samplingService;
|
||||||
@ -89,6 +94,11 @@ class _NPEReportNewLocationState extends State<NPEReportNewLocation> {
|
|||||||
_condController.dispose();
|
_condController.dispose();
|
||||||
_turbController.dispose();
|
_turbController.dispose();
|
||||||
_tempController.dispose();
|
_tempController.dispose();
|
||||||
|
// ADDED: Dispose remark controllers
|
||||||
|
_image1RemarkController.dispose();
|
||||||
|
_image2RemarkController.dispose();
|
||||||
|
_image3RemarkController.dispose();
|
||||||
|
_image4RemarkController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,10 +147,27 @@ class _NPEReportNewLocationState extends State<NPEReportNewLocation> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submitNpeReport() async {
|
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()) {
|
if (!_formKey.currentState!.validate()) {
|
||||||
_showSnackBar('Please fill in all required fields.', isError: true);
|
_showSnackBar('Please fill in all required fields.', isError: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// --- END: VALIDATION CHECKS ---
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||||
final service = Provider.of<MarineNpeReportService>(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.ph = double.tryParse(_phController.text);
|
||||||
_npeData.temperature = double.tryParse(_tempController.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);
|
final result = await service.submitNpeReport(data: _npeData, authProvider: auth);
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@ -186,7 +219,12 @@ class _NPEReportNewLocationState extends State<NPEReportNewLocation> {
|
|||||||
..currentLongitude = _longController.text
|
..currentLongitude = _longController.text
|
||||||
..selectedStation = {'man_station_name': _locationController.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) {
|
if (file != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@ -408,11 +446,11 @@ class _NPEReportNewLocationState extends State<NPEReportNewLocation> {
|
|||||||
_buildTextFormField(controller: _eventDateTimeController, label: "Event Date/Time", readOnly: true),
|
_buildTextFormField(controller: _eventDateTimeController, label: "Event Date/Time", readOnly: true),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
_buildSectionTitle("2. In-situ Measurements (Optional)"),
|
_buildSectionTitle("2. In-situ Measurements"),
|
||||||
_buildInSituSection(),
|
_buildInSituSection(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
_buildSectionTitle("3. Field Observations*"),
|
_buildSectionTitle("3. Field Observations *"),
|
||||||
..._buildObservationsCheckboxes(),
|
..._buildObservationsCheckboxes(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
@ -420,7 +458,7 @@ class _NPEReportNewLocationState extends State<NPEReportNewLocation> {
|
|||||||
_buildTextFormField(controller: _possibleSourceController, label: "Possible Source", maxLines: 3),
|
_buildTextFormField(controller: _possibleSourceController, label: "Possible Source", maxLines: 3),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
_buildSectionTitle("5. Attachments (Figures)"),
|
_buildSectionTitle("5. Attachments (Figures) *"),
|
||||||
_buildImageAttachmentSection(),
|
_buildImageAttachmentSection(),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
@ -462,7 +500,7 @@ class _NPEReportNewLocationState extends State<NPEReportNewLocation> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
child: _buildTextFormField(
|
child: _buildTextFormField(
|
||||||
controller: _othersObservationController,
|
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() {
|
Widget _buildImageAttachmentSection() {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
_buildNPEImagePicker(title: 'Figure 1', imageFile: _npeData.image1, onClear: () => setState(() => _npeData.image1 = null), imageNumber: 1),
|
_buildNPEImagePicker(
|
||||||
_buildNPEImagePicker(title: 'Figure 2', imageFile: _npeData.image2, onClear: () => setState(() => _npeData.image2 = null), imageNumber: 2),
|
title: 'Figure 1 *',
|
||||||
_buildNPEImagePicker(title: 'Figure 3', imageFile: _npeData.image3, onClear: () => setState(() => _npeData.image3 = null), imageNumber: 3),
|
imageFile: _npeData.image1,
|
||||||
_buildNPEImagePicker(title: 'Figure 4', imageFile: _npeData.image4, onClear: () => setState(() => _npeData.image4 = null), imageNumber: 4),
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -498,7 +566,10 @@ class _NPEReportNewLocationState extends State<NPEReportNewLocation> {
|
|||||||
child: IconButton(
|
child: IconButton(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
icon: const Icon(Icons.close, color: Colors.white, size: 20),
|
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")),
|
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,
|
readOnly: readOnly,
|
||||||
keyboardType: keyboardType,
|
keyboardType: keyboardType,
|
||||||
validator: (value) {
|
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)) {
|
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 'This field cannot be empty';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -566,12 +655,12 @@ class _NPEReportNewLocationState extends State<NPEReportNewLocation> {
|
|||||||
if (activeConnection != null)
|
if (activeConnection != null)
|
||||||
_buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']),
|
_buildConnectionCard(type: activeConnection['type'], connectionState: activeConnection['state'], deviceName: activeConnection['name']),
|
||||||
const SizedBox(height: 16),
|
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.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.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.thermostat, label: "Temp", unit: "°C", controller: _tempController),
|
||||||
|
_buildParameterListItem(icon: Icons.opacity, label: "Turb", unit: "NTU", controller: _turbController),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -599,10 +688,11 @@ class _NPEReportNewLocationState extends State<NPEReportNewLocation> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton.icon(
|
Flexible(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
|
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
|
||||||
label: Text(_isAutoReading
|
label: Text(_isAutoReading
|
||||||
? (_isLockedOut ? 'Stop Reading ($_lockoutSecondsRemaining\s)' : 'Stop Reading')
|
? (_isLockedOut ? 'Stop Reading (${_lockoutSecondsRemaining}s)' : 'Stop Reading')
|
||||||
: 'Start Reading'),
|
: 'Start Reading'),
|
||||||
onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type),
|
onPressed: (_isAutoReading && _isLockedOut) ? null : () => _toggleAutoReading(type),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
@ -612,6 +702,7 @@ class _NPEReportNewLocationState extends State<NPEReportNewLocation> {
|
|||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
icon: const Icon(Icons.link_off),
|
icon: const Icon(Icons.link_off),
|
||||||
label: const Text('Disconnect'),
|
label: const Text('Disconnect'),
|
||||||
|
|||||||
@ -464,7 +464,9 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
|
|||||||
children: <TextSpan>[
|
children: <TextSpan>[
|
||||||
const TextSpan(text: 'Distance from Station: '),
|
const TextSpan(text: 'Distance from Station: '),
|
||||||
TextSpan(
|
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(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: ((_data.distanceDifference ?? 0) * 1000) > 50 ? Colors.red : Colors.green),
|
color: ((_data.distanceDifference ?? 0) * 1000) > 50 ? Colors.red : Colors.green),
|
||||||
|
|||||||
@ -86,7 +86,7 @@ class _TarballSamplingStep2State extends State<TarballSamplingStep2> {
|
|||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text("Incorrect Image Orientation"),
|
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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
child: const Text("OK"),
|
child: const Text("OK"),
|
||||||
|
|||||||
@ -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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@ -519,13 +519,15 @@ class _InSituStep1SamplingInfoState extends State<InSituStep1SamplingInfo> {
|
|||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
children: <TextSpan>[
|
children: <TextSpan>[
|
||||||
const TextSpan(text: 'Distance from Station: '),
|
const TextSpan(text: 'Distance from Station: '),
|
||||||
|
// --- START MODIFICATION ---
|
||||||
TextSpan(
|
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(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red : Colors.green
|
color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red : Colors.green
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// --- END MODIFICATION ---
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
// lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart
|
// lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data'; // <-- Required for Uint8List
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@ -28,14 +29,11 @@ class _InSituStep2SiteInfoState extends State<InSituStep2SiteInfo> {
|
|||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
bool _isPickingImage = false;
|
bool _isPickingImage = false;
|
||||||
|
|
||||||
// --- START MODIFICATION: Removed optional remark controllers ---
|
|
||||||
late final TextEditingController _eventRemarksController;
|
late final TextEditingController _eventRemarksController;
|
||||||
late final TextEditingController _labRemarksController;
|
late final TextEditingController _labRemarksController;
|
||||||
// --- END MODIFICATION ---
|
|
||||||
|
|
||||||
|
|
||||||
final List<String> _weatherOptions = ['Clear', 'Cloudy', 'Drizzle', 'Rainy', 'Windy'];
|
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'];
|
final List<String> _seaConditionOptions = ['Calm', 'Moderate Wave', 'High Wave'];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -43,16 +41,12 @@ class _InSituStep2SiteInfoState extends State<InSituStep2SiteInfo> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_eventRemarksController = TextEditingController(text: widget.data.eventRemarks);
|
_eventRemarksController = TextEditingController(text: widget.data.eventRemarks);
|
||||||
_labRemarksController = TextEditingController(text: widget.data.labRemarks);
|
_labRemarksController = TextEditingController(text: widget.data.labRemarks);
|
||||||
// --- START MODIFICATION: Removed initialization for optional remark controllers ---
|
|
||||||
// --- END MODIFICATION ---
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_eventRemarksController.dispose();
|
_eventRemarksController.dispose();
|
||||||
_labRemarksController.dispose();
|
_labRemarksController.dispose();
|
||||||
// --- START MODIFICATION: Removed disposal of optional remark controllers ---
|
|
||||||
// --- END MODIFICATION ---
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,12 +57,14 @@ class _InSituStep2SiteInfoState extends State<InSituStep2SiteInfo> {
|
|||||||
|
|
||||||
final service = Provider.of<MarineInSituSamplingService>(context, listen: false);
|
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) {
|
if (file != null) {
|
||||||
setState(() => setImageCallback(file));
|
setState(() => setImageCallback(file));
|
||||||
} else if (mounted) {
|
} 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) {
|
if (mounted) {
|
||||||
@ -78,7 +74,6 @@ class _InSituStep2SiteInfoState extends State<InSituStep2SiteInfo> {
|
|||||||
|
|
||||||
/// Validates the form and all required images before proceeding.
|
/// Validates the form and all required images before proceeding.
|
||||||
void _goToNextStep() {
|
void _goToNextStep() {
|
||||||
// --- START MODIFICATION: Updated validation logic ---
|
|
||||||
if (widget.data.leftLandViewImage == null ||
|
if (widget.data.leftLandViewImage == null ||
|
||||||
widget.data.rightLandViewImage == null ||
|
widget.data.rightLandViewImage == null ||
|
||||||
widget.data.waterFillingImage == null ||
|
widget.data.waterFillingImage == null ||
|
||||||
@ -87,16 +82,12 @@ class _InSituStep2SiteInfoState extends State<InSituStep2SiteInfo> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Form validation now handles the conditional requirement for Event Remarks
|
|
||||||
if (!_formKey.currentState!.validate()) {
|
if (!_formKey.currentState!.validate()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_formKey.currentState!.save();
|
_formKey.currentState!.save();
|
||||||
|
|
||||||
// Removed saving of optional remarks as they are no longer present
|
|
||||||
widget.onNext();
|
widget.onNext();
|
||||||
// --- END MODIFICATION ---
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showSnackBar(String message, {bool isError = false}) {
|
void _showSnackBar(String message, {bool isError = false}) {
|
||||||
@ -110,13 +101,11 @@ class _InSituStep2SiteInfoState extends State<InSituStep2SiteInfo> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// --- START MODIFICATION: Logic to determine if Event Remarks are required ---
|
|
||||||
final bool areAdditionalPhotosAttached = widget.data.phPaperImage != null ||
|
final bool areAdditionalPhotosAttached = widget.data.phPaperImage != null ||
|
||||||
widget.data.optionalImage1 != null ||
|
widget.data.optionalImage1 != null ||
|
||||||
widget.data.optionalImage2 != null ||
|
widget.data.optionalImage2 != null ||
|
||||||
widget.data.optionalImage3 != null ||
|
widget.data.optionalImage3 != null ||
|
||||||
widget.data.optionalImage4 != null;
|
widget.data.optionalImage4 != null;
|
||||||
// --- END MODIFICATION ---
|
|
||||||
|
|
||||||
return Form(
|
return Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
@ -153,7 +142,10 @@ class _InSituStep2SiteInfoState extends State<InSituStep2SiteInfo> {
|
|||||||
|
|
||||||
// --- Section: Required Photos ---
|
// --- Section: Required Photos ---
|
||||||
Text("Required Photos *", style: Theme.of(context).textTheme.titleLarge),
|
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),
|
const SizedBox(height: 8),
|
||||||
_buildImagePicker('Left Side Land View', 'LEFT_LAND_VIEW', widget.data.leftLandViewImage, (file) => widget.data.leftLandViewImage = file, isRequired: true),
|
_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),
|
_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),
|
_buildImagePicker('Seawater in Clear Glass Bottle', 'SEAWATER_COLOR', widget.data.seawaterColorImage, (file) => widget.data.seawaterColorImage = file, isRequired: true),
|
||||||
const SizedBox(height: 24),
|
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),
|
Text("Additional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge),
|
||||||
const SizedBox(height: 8),
|
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),
|
_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 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 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),
|
_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),
|
Text("Remarks", style: Theme.of(context).textTheme.titleLarge),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// Event Remarks field is now conditionally required
|
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _eventRemarksController,
|
controller: _eventRemarksController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@ -191,7 +180,6 @@ class _InSituStep2SiteInfoState extends State<InSituStep2SiteInfo> {
|
|||||||
},
|
},
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
),
|
),
|
||||||
// --- END MODIFICATION ---
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _labRemarksController,
|
controller: _labRemarksController,
|
||||||
@ -210,10 +198,8 @@ class _InSituStep2SiteInfoState extends State<InSituStep2SiteInfo> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A reusable widget for picking and displaying an image, matching the tarball design.
|
/// A reusable widget for picking and displaying an image.
|
||||||
// --- START MODIFICATION: Removed remarkController parameter ---
|
|
||||||
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {bool isRequired = false}) {
|
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {bool isRequired = false}) {
|
||||||
// --- END MODIFICATION ---
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -225,7 +211,41 @@ class _InSituStep2SiteInfoState extends State<InSituStep2SiteInfo> {
|
|||||||
Stack(
|
Stack(
|
||||||
alignment: Alignment.topRight,
|
alignment: Alignment.topRight,
|
||||||
children: [
|
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(
|
Container(
|
||||||
margin: const EdgeInsets.all(4),
|
margin: const EdgeInsets.all(4),
|
||||||
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
|
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")),
|
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 ---
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -851,10 +851,13 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
|
|||||||
if (isConnecting || _isLoading)
|
if (isConnecting || _isLoading)
|
||||||
const CircularProgressIndicator()
|
const CircularProgressIndicator()
|
||||||
else if (isConnected)
|
else if (isConnected)
|
||||||
Row(
|
// --- START MODIFICATION: Replaced Row with Wrap to fix overflow ---
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
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: [
|
children: [
|
||||||
// --- START MODIFICATION: Add countdown to Stop Reading button ---
|
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
|
icon: Icon(_isAutoReading ? Icons.stop_circle_outlined : Icons.play_circle_outlined),
|
||||||
label: Text(_isAutoReading
|
label: Text(_isAutoReading
|
||||||
@ -868,7 +871,6 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
|
|||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// --- END MODIFICATION ---
|
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
icon: const Icon(Icons.link_off),
|
icon: const Icon(Icons.link_off),
|
||||||
label: const Text('Disconnect'),
|
label: const Text('Disconnect'),
|
||||||
@ -877,6 +879,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
// --- END MODIFICATION ---
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
// lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart
|
// lib/screens/marine/manual/widgets/in_situ_step_4_summary.dart
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data'; // <-- Required for Uint8List
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@ -77,12 +78,16 @@ class _InSituStep4SummaryState extends State<InSituStep4Summary> {
|
|||||||
Map<String, dynamic> limitData = {};
|
Map<String, dynamic> limitData = {};
|
||||||
|
|
||||||
if (stationId != null) {
|
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(
|
limitData = marineLimits.firstWhere(
|
||||||
(l) =>
|
(l) =>
|
||||||
l['param_parameter_list'] == limitName &&
|
l['param_parameter_list'] == limitName &&
|
||||||
l['station_id'] == stationId,
|
l['station_id']?.toString() == stationId.toString(),
|
||||||
orElse: () => {},
|
orElse: () => {},
|
||||||
);
|
);
|
||||||
|
// --- END FIX ---
|
||||||
}
|
}
|
||||||
|
|
||||||
if (limitData.isNotEmpty) {
|
if (limitData.isNotEmpty) {
|
||||||
@ -131,6 +136,7 @@ class _InSituStep4SummaryState extends State<InSituStep4Summary> {
|
|||||||
final limitName = _parameterKeyToLimitName[key];
|
final limitName = _parameterKeyToLimitName[key];
|
||||||
if (limitName == null) return;
|
if (limitName == null) return;
|
||||||
|
|
||||||
|
// NPE limits are general and NOT station-specific, so this is correct.
|
||||||
final limitData = npeLimits.firstWhere(
|
final limitData = npeLimits.firstWhere(
|
||||||
(l) => l['param_parameter_list'] == limitName,
|
(l) => l['param_parameter_list'] == limitName,
|
||||||
orElse: () => {},
|
orElse: () => {},
|
||||||
@ -629,14 +635,39 @@ class _InSituStep4SummaryState extends State<InSituStep4Summary> {
|
|||||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (image != null)
|
if (image != null)
|
||||||
|
// --- START MODIFICATION: Use FutureBuilder to load bytes async ---
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8.0),
|
borderRadius: BorderRadius.circular(8.0),
|
||||||
child: Image.file(image,
|
child: FutureBuilder<Uint8List>(
|
||||||
key: UniqueKey(),
|
key: ValueKey(image.path),
|
||||||
|
future: image.readAsBytes(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return Container(
|
||||||
height: 200,
|
height: 200,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
fit: BoxFit.cover),
|
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
|
else
|
||||||
Container(
|
Container(
|
||||||
height: 100,
|
height: 100,
|
||||||
|
|||||||
@ -62,8 +62,11 @@ class MarineHomePage extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// MODIFIED: Updated label, icon, and route for the new Info Centre screen
|
// MODIFIED: Updated label, icon, and route for the new Info Centre screen
|
||||||
SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/marine/investigative/info'),
|
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'),
|
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.info, label: "Overview", route: '/marine/investigative/overview'),
|
||||||
//SidebarItem(icon: Icons.input, label: "Entry", route: '/marine/investigative/entry'),
|
//SidebarItem(icon: Icons.input, label: "Entry", route: '/marine/investigative/entry'),
|
||||||
//SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/investigative/report'),
|
//SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/investigative/report'),
|
||||||
|
|||||||
@ -9,7 +9,8 @@ import 'package:intl/intl.dart';
|
|||||||
|
|
||||||
import '../../../../auth_provider.dart';
|
import '../../../../auth_provider.dart';
|
||||||
import '../../../../models/river_inves_manual_sampling_data.dart'; // Updated model
|
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 '../../../../services/river_investigative_sampling_service.dart'; // Updated service
|
||||||
import '../../../../bluetooth/bluetooth_manager.dart';
|
import '../../../../bluetooth/bluetooth_manager.dart';
|
||||||
import '../../../../serial/serial_manager.dart';
|
import '../../../../serial/serial_manager.dart';
|
||||||
|
|||||||
@ -73,6 +73,7 @@ class _RiverInvesStep4AdditionalInfoState
|
|||||||
if (file != null) {
|
if (file != null) {
|
||||||
setState(() => setImageCallback(file));
|
setState(() => setImageCallback(file));
|
||||||
} else if (mounted) {
|
} else if (mounted) {
|
||||||
|
// ✅ CHANGE: Reverted. All photos (required and optional) must be landscape.
|
||||||
_showSnackBar(
|
_showSnackBar(
|
||||||
'Image selection failed. Please ensure all photos are taken in landscape mode.',
|
'Image selection failed. Please ensure all photos are taken in landscape mode.',
|
||||||
isError: true);
|
isError: true);
|
||||||
@ -180,7 +181,10 @@ class _RiverInvesStep4AdditionalInfoState
|
|||||||
child: IconButton(
|
child: IconButton(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
icon: const Icon(Icons.close, color: Colors.white, size: 20),
|
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
|
// Remarks field, linked via the passed controller
|
||||||
if (remarkController != null)
|
if (remarkController != null && imageFile != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
|
|||||||
@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,11 +6,21 @@ import 'package:intl/intl.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:environment_monitoring_app/auth_provider.dart';
|
import 'package:environment_monitoring_app/auth_provider.dart';
|
||||||
import 'package:environment_monitoring_app/models/river_in_situ_sampling_data.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/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_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';
|
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 {
|
class SubmissionLogEntry {
|
||||||
final String type;
|
final String type;
|
||||||
final String title;
|
final String title;
|
||||||
@ -50,75 +60,119 @@ class RiverManualDataStatusLog extends StatefulWidget {
|
|||||||
|
|
||||||
class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
|
class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
|
||||||
final LocalStorageService _localStorageService = LocalStorageService();
|
final LocalStorageService _localStorageService = LocalStorageService();
|
||||||
late ApiService _apiService;
|
|
||||||
late RiverInSituSamplingService _riverInSituService;
|
|
||||||
|
|
||||||
List<SubmissionLogEntry> _allLogs = [];
|
late RiverInSituSamplingService _riverInSituService;
|
||||||
List<SubmissionLogEntry> _filteredLogs = [];
|
late RiverManualTriennialSamplingService _riverTriennialService;
|
||||||
final TextEditingController _searchController = TextEditingController();
|
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;
|
bool _isLoading = true;
|
||||||
final Map<String, bool> _isResubmitting = {};
|
final Map<String, bool> _isResubmitting = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_apiService = Provider.of<ApiService>(context, listen: false);
|
_inSituSearchController.addListener(_filterLogs);
|
||||||
_riverInSituService = Provider.of<RiverInSituSamplingService>(context, listen: false);
|
_triennialSearchController.addListener(_filterLogs);
|
||||||
_searchController.addListener(_filterLogs);
|
_investigativeSearchController.addListener(_filterLogs);
|
||||||
_loadAllLogs();
|
_loadAllLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
_riverInSituService = Provider.of<RiverInSituSamplingService>(context);
|
||||||
|
_riverTriennialService = Provider.of<RiverManualTriennialSamplingService>(context);
|
||||||
|
_riverInvestigativeService = Provider.of<RiverInvestigativeSamplingService>(context);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
_inSituSearchController.dispose();
|
||||||
|
_triennialSearchController.dispose();
|
||||||
|
_investigativeSearchController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadAllLogs() async {
|
Future<void> _loadAllLogs() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
final riverLogs = await _localStorageService.getAllRiverInSituLogs();
|
final inSituLogs = await _localStorageService.getAllRiverInSituLogs();
|
||||||
final List<SubmissionLogEntry> tempLogs = [];
|
final triennialLogs = await _localStorageService.getAllRiverManualTriennialLogs();
|
||||||
|
final investigativeLogs = await _localStorageService.getAllRiverInvestigativeLogs();
|
||||||
|
|
||||||
for (var log in riverLogs) {
|
final List<SubmissionLogEntry> tempInSitu = [];
|
||||||
final entry = _createLogEntry(log);
|
final List<SubmissionLogEntry> tempTriennial = [];
|
||||||
|
final List<SubmissionLogEntry> tempInvestigative = [];
|
||||||
|
|
||||||
|
for (var log in inSituLogs) {
|
||||||
|
final entry = _createInSituLogEntry(log);
|
||||||
if (entry != null) {
|
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) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_allLogs = tempLogs;
|
_inSituLogs = tempInSitu;
|
||||||
|
_triennialLogs = tempTriennial;
|
||||||
|
_investigativeLogs = tempInvestigative;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
_filterLogs();
|
_filterLogs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SubmissionLogEntry? _createLogEntry(Map<String, dynamic> log) {
|
SubmissionLogEntry? _createInSituLogEntry(Map<String, dynamic> log) {
|
||||||
final String type = log['samplingType'] ?? 'In-Situ Sampling';
|
final String type = log['samplingType'] ?? 'In-Situ Sampling';
|
||||||
final String title = log['selectedStation']?['sampling_river'] ?? 'Unknown River';
|
final String title = log['selectedStation']?['sampling_river'] ?? 'Unknown River';
|
||||||
final String stationCode = log['selectedStation']?['sampling_station_code'] ?? 'N/A';
|
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? dateStr = log['samplingDate'] ?? log['r_man_date'];
|
||||||
final String? timeStr = log['samplingTime'] ?? log['r_man_time'];
|
final String? timeStr = log['samplingTime'] ?? log['r_man_time'];
|
||||||
|
|
||||||
|
DateTime submissionDateTime = DateTime.fromMillisecondsSinceEpoch(0); // Default to invalid
|
||||||
try {
|
try {
|
||||||
if (dateStr != null && timeStr != null && dateStr.isNotEmpty && timeStr.isNotEmpty) {
|
if (dateStr != null && timeStr != null && dateStr.isNotEmpty && timeStr.isNotEmpty) {
|
||||||
final String fullDateString = '$dateStr ${timeStr.length == 5 ? "$timeStr:00" : timeStr}';
|
final String fullDateString = '$dateStr ${timeStr.length == 5 ? "$timeStr:00" : timeStr}';
|
||||||
submissionDateTime = DateTime.tryParse(fullDateString) ?? DateTime.now();
|
submissionDateTime = DateTime.tryParse(fullDateString) ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
submissionDateTime = DateTime.now();
|
// Keep default invalid date
|
||||||
}
|
}
|
||||||
|
|
||||||
String? apiStatusRaw;
|
String? apiStatusRaw;
|
||||||
if (log['api_status'] != null) {
|
if (log['api_status'] != null) {
|
||||||
apiStatusRaw = log['api_status'] is String ? log['api_status'] : jsonEncode(log['api_status']);
|
apiStatusRaw = log['api_status'] is String ? log['api_status'] : jsonEncode(log['api_status']);
|
||||||
}
|
}
|
||||||
|
|
||||||
String? ftpStatusRaw;
|
String? ftpStatusRaw;
|
||||||
if (log['ftp_status'] != null) {
|
if (log['ftp_status'] != null) {
|
||||||
ftpStatusRaw = log['ftp_status'] is String ? log['ftp_status'] : jsonEncode(log['ftp_status']);
|
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() {
|
void _filterLogs() {
|
||||||
final query = _searchController.text.toLowerCase();
|
final inSituQuery = _inSituSearchController.text.toLowerCase();
|
||||||
|
final triennialQuery = _triennialSearchController.text.toLowerCase();
|
||||||
|
final investigativeQuery = _investigativeSearchController.text.toLowerCase();
|
||||||
|
|
||||||
setState(() {
|
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) ||
|
return log.title.toLowerCase().contains(query) ||
|
||||||
log.stationCode.toLowerCase().contains(query) ||
|
log.stationCode.toLowerCase().contains(query) ||
|
||||||
log.serverName.toLowerCase().contains(query) ||
|
log.serverName.toLowerCase().contains(query) ||
|
||||||
log.type.toLowerCase().contains(query) ||
|
|
||||||
(log.reportId?.toLowerCase() ?? '').contains(query);
|
(log.reportId?.toLowerCase() ?? '').contains(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,15 +311,36 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
|
|||||||
try {
|
try {
|
||||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||||
final appSettings = authProvider.appSettings;
|
final appSettings = authProvider.appSettings;
|
||||||
|
Map<String, dynamic> result = {};
|
||||||
|
|
||||||
|
if (log.type == 'In-Situ Sampling' || log.type == 'Schedule') {
|
||||||
final dataToResubmit = RiverInSituSamplingData.fromJson(log.rawData);
|
final dataToResubmit = RiverInSituSamplingData.fromJson(log.rawData);
|
||||||
|
|
||||||
final result = await _riverInSituService.submitData(
|
result = await _riverInSituService.submitData(
|
||||||
data: dataToResubmit,
|
data: dataToResubmit,
|
||||||
appSettings: appSettings,
|
appSettings: appSettings,
|
||||||
authProvider: authProvider,
|
authProvider: authProvider,
|
||||||
logDirectory: log.rawData['logDirectory'], // Pass the log directory for updating
|
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) {
|
if (mounted) {
|
||||||
final message = result['message'] ?? 'Resubmission process completed.';
|
final message = result['message'] ?? 'Resubmission process completed.';
|
||||||
@ -182,7 +348,7 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(message),
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final hasAnyLogs = _allLogs.isNotEmpty;
|
final hasAnyLogs = _inSituLogs.isNotEmpty || _triennialLogs.isNotEmpty || _investigativeLogs.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('River Manual Data Status Log')),
|
appBar: AppBar(title: const Text('River Manual Data Status Log')),
|
||||||
@ -226,42 +383,28 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
|
|||||||
: ListView(
|
: ListView(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
_buildCategorySection(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0),
|
'In-Situ Sampling',
|
||||||
child: TextField(
|
_filteredInSituLogs,
|
||||||
controller: _searchController,
|
_inSituSearchController
|
||||||
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(
|
||||||
|
'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(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@ -270,7 +413,34 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
Text(category, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: TextField(
|
||||||
|
controller: 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(),
|
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(
|
ListView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
@ -286,7 +456,24 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildLogListItem(SubmissionLogEntry log) {
|
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 logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
|
||||||
final isResubmitting = _isResubmitting[logKey] ?? false;
|
final isResubmitting = _isResubmitting[logKey] ?? false;
|
||||||
|
|
||||||
@ -302,19 +489,18 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final subtitle = '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}';
|
|
||||||
|
|
||||||
return Card(
|
final bool isDateValid = !log.submissionDateTime.isAtSameMomentAs(DateTime.fromMillisecondsSinceEpoch(0));
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
final subtitle = isDateValid
|
||||||
child: ExpansionTile(
|
? '${log.serverName} - ${DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime)}'
|
||||||
|
: '${log.serverName} - Invalid Date';
|
||||||
|
|
||||||
|
return ExpansionTile(
|
||||||
key: PageStorageKey(logKey),
|
key: PageStorageKey(logKey),
|
||||||
leading: Icon(
|
leading: Icon(statusIcon, color: statusColor),
|
||||||
isFailed ? Icons.error_outline : Icons.check_circle_outline,
|
|
||||||
color: isFailed ? Colors.red : Colors.green,
|
|
||||||
),
|
|
||||||
title: titleWidget,
|
title: titleWidget,
|
||||||
subtitle: Text(subtitle),
|
subtitle: Text(subtitle),
|
||||||
trailing: isFailed
|
trailing: canResubmit
|
||||||
? (isResubmitting
|
? (isResubmitting
|
||||||
? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 3))
|
? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 3))
|
||||||
: IconButton(icon: const Icon(Icons.sync, color: Colors.blue), tooltip: 'Resubmit', onPressed: () => _resubmitData(log)))
|
: IconButton(icon: const Icon(Icons.sync, color: Colors.blue), tooltip: 'Resubmit', onPressed: () => _resubmitData(log)))
|
||||||
@ -329,6 +515,26 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
|
|||||||
_buildDetailRow('Server:', log.serverName),
|
_buildDetailRow('Server:', log.serverName),
|
||||||
_buildDetailRow('Report ID:', log.reportId ?? 'N/A'),
|
_buildDetailRow('Report ID:', log.reportId ?? 'N/A'),
|
||||||
_buildDetailRow('Submission Type:', log.type),
|
_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),
|
const Divider(height: 10),
|
||||||
_buildGranularStatus('API', log.apiStatusRaw),
|
_buildGranularStatus('API', log.apiStatusRaw),
|
||||||
_buildGranularStatus('FTP', log.ftpStatusRaw),
|
_buildGranularStatus('FTP', log.ftpStatusRaw),
|
||||||
@ -336,7 +542,345 @@ class _RiverManualDataStatusLogState extends State<RiverManualDataStatusLog> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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: [
|
children: [
|
||||||
Expanded(flex: 2, child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold))),
|
Expanded(flex: 2, child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold))),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(flex: 3, child: Text(value)),
|
Expanded(flex: 3, child: Text(value)), // <-- This is the fixed line
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import 'dart:convert';
|
|||||||
import '../../../auth_provider.dart';
|
import '../../../auth_provider.dart';
|
||||||
import '../../../services/api_service.dart';
|
import '../../../services/api_service.dart';
|
||||||
|
|
||||||
|
|
||||||
class RiverManualImageRequest extends StatelessWidget {
|
class RiverManualImageRequest extends StatelessWidget {
|
||||||
const RiverManualImageRequest({super.key});
|
const RiverManualImageRequest({super.key});
|
||||||
|
|
||||||
@ -30,7 +31,10 @@ class _RiverImageRequestScreenState extends State<RiverImageRequestScreen> {
|
|||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _dateController = TextEditingController();
|
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? _selectedStateName;
|
||||||
String? _selectedBasinName;
|
String? _selectedBasinName;
|
||||||
@ -48,8 +52,13 @@ class _RiverImageRequestScreenState extends State<RiverImageRequestScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// Use addPostFrameCallback to ensure provider is ready
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if(mounted) {
|
||||||
_initializeStationFilters();
|
_initializeStationFilters();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@ -57,14 +66,81 @@ class _RiverImageRequestScreenState extends State<RiverImageRequestScreen> {
|
|||||||
super.dispose();
|
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() {
|
void _initializeStationFilters() {
|
||||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
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) {
|
if (allStations.isNotEmpty) {
|
||||||
final states = allStations.map((s) => s['state_name'] as String?).whereType<String>().toSet().toList();
|
final states = allStations.map((s) => s['state_name'] as String?).whereType<String>().toSet().toList();
|
||||||
states.sort();
|
states.sort();
|
||||||
setState(() {
|
setState(() {
|
||||||
_statesList = states;
|
_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();
|
_selectedImageUrls.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (_selectedStation == null || _selectedDate == null) {
|
// --- MODIFIED: Validate all required fields ---
|
||||||
|
if (_selectedStation == null || _selectedDate == null || _selectedSamplingType == null) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
return;
|
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);
|
final apiService = Provider.of<ApiService>(context, listen: false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await apiService.river.getRiverSamplingImages(
|
final result = await apiService.river.getRiverSamplingImages(
|
||||||
stationId: stationId,
|
stationId: stationId,
|
||||||
samplingDate: _selectedDate!,
|
samplingDate: _selectedDate!,
|
||||||
samplingType: _selectedSamplingType,
|
samplingType: _selectedSamplingType!, // <-- Pass dynamic type
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mounted && result['success'] == true) {
|
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'] ?? []);
|
final List<String> fetchedUrls = List<String>.from(result['data'] ?? []);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_imageUrls = fetchedUrls;
|
_imageUrls = fetchedUrls.toSet().toList(); // Use toSet to remove duplicates
|
||||||
});
|
});
|
||||||
|
|
||||||
debugPrint("[Image Request] Successfully received and processed ${_imageUrls.length} image URLs.");
|
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);
|
final apiService = Provider.of<ApiService>(context, listen: false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final stationCode = _selectedStation?['sampling_station_code'] ?? 'N/A';
|
// --- MODIFIED: Use dynamic keys ---
|
||||||
final stationName = _selectedStation?['sampling_river'] ?? 'N/A';
|
final stationCode = _selectedStation?[_getStationCodeKey()] ?? 'N/A';
|
||||||
|
final stationName = _selectedStation?[_getStationNameKey()] ?? 'N/A';
|
||||||
|
// --- END: MODIFIED ---
|
||||||
final fullStationIdentifier = '$stationCode - $stationName';
|
final fullStationIdentifier = '$stationCode - $stationName';
|
||||||
|
|
||||||
final result = await apiService.river.sendImageRequestEmail(
|
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),
|
Text("Image Search Filters", style: Theme.of(context).textTheme.headlineSmall),
|
||||||
const SizedBox(height: 24),
|
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
|
// State Dropdown
|
||||||
DropdownSearch<String>(
|
DropdownSearch<String>(
|
||||||
items: _statesList,
|
items: _statesList,
|
||||||
@ -264,8 +364,11 @@ class _RiverImageRequestScreenState extends State<RiverImageRequestScreen> {
|
|||||||
_selectedBasinName = null;
|
_selectedBasinName = null;
|
||||||
_selectedStation = null;
|
_selectedStation = null;
|
||||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||||
final allStations = auth.riverManualStations ?? [];
|
// --- MODIFIED: Use dynamic helpers ---
|
||||||
final basins = state != null ? allStations.where((s) => s['state_name'] == state).map((s) => s['sampling_basin'] as String?).whereType<String>().toSet().toList() : <String>[];
|
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();
|
basins.sort();
|
||||||
_basinsForState = basins;
|
_basinsForState = basins;
|
||||||
_stationsForBasin = [];
|
_stationsForBasin = [];
|
||||||
@ -287,8 +390,12 @@ class _RiverImageRequestScreenState extends State<RiverImageRequestScreen> {
|
|||||||
_selectedBasinName = basin;
|
_selectedBasinName = basin;
|
||||||
_selectedStation = null;
|
_selectedStation = null;
|
||||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||||
final allStations = auth.riverManualStations ?? [];
|
// --- MODIFIED: Use dynamic helpers ---
|
||||||
_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'] ?? ''))) : [];
|
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,
|
validator: (val) => _selectedStateName != null && val == null ? "Basin is required" : null,
|
||||||
@ -300,7 +407,13 @@ class _RiverImageRequestScreenState extends State<RiverImageRequestScreen> {
|
|||||||
items: _stationsForBasin,
|
items: _stationsForBasin,
|
||||||
selectedItem: _selectedStation,
|
selectedItem: _selectedStation,
|
||||||
enabled: _selectedBasinName != null,
|
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..."))),
|
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Station..."))),
|
||||||
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Station *", border: OutlineInputBorder())),
|
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Station *", border: OutlineInputBorder())),
|
||||||
onChanged: (station) => setState(() => _selectedStation = station),
|
onChanged: (station) => setState(() => _selectedStation = station),
|
||||||
|
|||||||
@ -9,7 +9,8 @@ import 'package:intl/intl.dart';
|
|||||||
|
|
||||||
import '../../../../../auth_provider.dart';
|
import '../../../../../auth_provider.dart';
|
||||||
import '../../../../../models/river_manual_triennial_sampling_data.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 '../../../../../services/river_in_situ_sampling_service.dart';
|
||||||
import '../../../../../bluetooth/bluetooth_manager.dart';
|
import '../../../../../bluetooth/bluetooth_manager.dart';
|
||||||
import '../../../../../serial/serial_manager.dart';
|
import '../../../../../serial/serial_manager.dart';
|
||||||
|
|||||||
@ -70,7 +70,10 @@ class _RiverManualTriennialStep4AdditionalInfoState
|
|||||||
if (file != null) {
|
if (file != null) {
|
||||||
setState(() => setImageCallback(file));
|
setState(() => setImageCallback(file));
|
||||||
} else if (mounted) {
|
} 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) {
|
if (mounted) {
|
||||||
@ -160,7 +163,10 @@ class _RiverManualTriennialStep4AdditionalInfoState
|
|||||||
child: IconButton(
|
child: IconButton(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
icon: const Icon(Icons.close, color: Colors.white, size: 20),
|
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")),
|
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(
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
|
|||||||
@ -9,7 +9,8 @@ import 'package:intl/intl.dart';
|
|||||||
|
|
||||||
import '../../../../auth_provider.dart';
|
import '../../../../auth_provider.dart';
|
||||||
import '../../../../models/river_in_situ_sampling_data.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 '../../../../services/river_in_situ_sampling_service.dart';
|
||||||
import '../../../../bluetooth/bluetooth_manager.dart';
|
import '../../../../bluetooth/bluetooth_manager.dart';
|
||||||
import '../../../../serial/serial_manager.dart';
|
import '../../../../serial/serial_manager.dart';
|
||||||
|
|||||||
@ -72,7 +72,10 @@ class _RiverInSituStep4AdditionalInfoState
|
|||||||
if (file != null) {
|
if (file != null) {
|
||||||
setState(() => setImageCallback(file));
|
setState(() => setImageCallback(file));
|
||||||
} else if (mounted) {
|
} 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) {
|
if (mounted) {
|
||||||
@ -162,7 +165,10 @@ class _RiverInSituStep4AdditionalInfoState
|
|||||||
child: IconButton(
|
child: IconButton(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
icon: const Icon(Icons.close, color: Colors.white, size: 20),
|
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")),
|
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(
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
|
|||||||
@ -61,6 +61,12 @@ class RiverHomePage extends StatelessWidget {
|
|||||||
SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/river/investigative/info'),
|
SidebarItem(icon: Icons.description, label: "Info Centre Document", route: '/river/investigative/info'),
|
||||||
// *** ADDED: Link to River Investigative Manual Sampling ***
|
// *** ADDED: Link to River Investigative Manual Sampling ***
|
||||||
SidebarItem(icon: Icons.biotech, label: "Investigative Sampling", route: '/river/investigative/manual-sampling'), // Added Icon
|
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.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.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
|
//SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/river/investigative/report'), // Keep placeholder/future items commented
|
||||||
|
|||||||
64
lib/services/air_api_service.dart
Normal file
64
lib/services/air_api_service.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ import 'dart:convert';
|
|||||||
import '../models/air_installation_data.dart';
|
import '../models/air_installation_data.dart';
|
||||||
import '../models/air_collection_data.dart';
|
import '../models/air_collection_data.dart';
|
||||||
import 'api_service.dart';
|
import 'api_service.dart';
|
||||||
|
import 'package:environment_monitoring_app/services/database_helper.dart';
|
||||||
import 'local_storage_service.dart';
|
import 'local_storage_service.dart';
|
||||||
import 'telegram_service.dart';
|
import 'telegram_service.dart';
|
||||||
import 'server_config_service.dart';
|
import 'server_config_service.dart';
|
||||||
|
|||||||
@ -4,20 +4,24 @@ import 'dart:io';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
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:intl/intl.dart';
|
||||||
|
|
||||||
import 'package:environment_monitoring_app/services/base_api_service.dart';
|
import 'package:environment_monitoring_app/services/base_api_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/telegram_service.dart';
|
import 'package:environment_monitoring_app/services/telegram_service.dart';
|
||||||
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/services/server_config_service.dart';
|
||||||
|
|
||||||
import 'package:environment_monitoring_app/models/marine_manual_pre_departure_checklist_data.dart';
|
// Import the new separated files
|
||||||
import 'package:environment_monitoring_app/models/marine_manual_sonde_calibration_data.dart';
|
import 'package:environment_monitoring_app/services/database_helper.dart';
|
||||||
import 'package:environment_monitoring_app/models/marine_manual_equipment_maintenance_data.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
|
// Part 1: Unified API Service
|
||||||
@ -35,8 +39,11 @@ class ApiService {
|
|||||||
static const String imageBaseUrl = 'https://mms-apiv4.pstw.com.my/';
|
static const String imageBaseUrl = 'https://mms-apiv4.pstw.com.my/';
|
||||||
|
|
||||||
ApiService({required TelegramService telegramService}) {
|
ApiService({required TelegramService telegramService}) {
|
||||||
marine = MarineApiService(_baseService, telegramService, _serverConfigService, dbHelper);
|
// --- MODIFIED CONSTRUCTOR ---
|
||||||
river = RiverApiService(_baseService, telegramService, _serverConfigService, dbHelper);
|
// 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);
|
air = AirApiService(_baseService, telegramService, _serverConfigService);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -226,16 +233,10 @@ class ApiService {
|
|||||||
await dbHelper.deleteRiverTriennialStations(id);
|
await dbHelper.deleteRiverTriennialStations(id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// --- ADDED: River Investigative Stations Sync ---
|
// --- REMOVED: River Investigative Stations Sync ---
|
||||||
'riverInvestigativeStations': {
|
// The 'riverInvestigativeStations' task has been removed
|
||||||
// IMPORTANT: Make sure this endpoint matches your server's route
|
// as per the request to use river manual stations instead.
|
||||||
'endpoint': 'river/investigative-stations',
|
// --- END REMOVED ---
|
||||||
'handler': (d, id) async {
|
|
||||||
await dbHelper.upsertRiverInvestigativeStations(d);
|
|
||||||
await dbHelper.deleteRiverInvestigativeStations(id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// --- END ADDED ---
|
|
||||||
'departments': {
|
'departments': {
|
||||||
'endpoint': 'departments',
|
'endpoint': 'departments',
|
||||||
'handler': (d, id) async {
|
'handler': (d, id) async {
|
||||||
@ -343,9 +344,13 @@ class ApiService {
|
|||||||
await (syncTasks[key]!['handler'] as Function)([profileData.first], []);
|
await (syncTasks[key]!['handler'] as Function)([profileData.first], []);
|
||||||
}
|
}
|
||||||
} else {
|
} 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 updated = List<Map<String, dynamic>>.from(result['data']['updated'] ?? []);
|
||||||
final deleted = List<dynamic>.from(result['data']['deleted'] ?? []);
|
final deleted = List<dynamic>.from(result['data']['deleted'] ?? []);
|
||||||
await (syncTasks[key]!['handler'] as Function)(updated, deleted);
|
await (syncTasks[key]!['handler'] as Function)(updated, deleted);
|
||||||
|
// --- END REVERTED ---
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
debugPrint('ApiService: Failed to sync $key. Message: ${result['message']}');
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
640
lib/services/database_helper.dart
Normal file
640
lib/services/database_helper.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,15 +1,18 @@
|
|||||||
// lib/services/marine_api_service.dart
|
// lib/services/marine_api_service.dart
|
||||||
|
|
||||||
import 'dart:convert'; // Added: Necessary for jsonEncode
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart'; // Added: Necessary for debugPrint
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:intl/intl.dart'; // Added: Necessary for DateFormat
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import 'package:environment_monitoring_app/services/base_api_service.dart';
|
import 'package:environment_monitoring_app/services/base_api_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/telegram_service.dart';
|
import 'package:environment_monitoring_app/services/telegram_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/server_config_service.dart';
|
import 'package:environment_monitoring_app/services/server_config_service.dart';
|
||||||
// Added: Necessary for ApiService.imageBaseUrl, assuming it's defined there.
|
import 'package:environment_monitoring_app/services/api_service.dart'; // For imageBaseUrl
|
||||||
// If not, you may need to adjust this.
|
|
||||||
import 'package:environment_monitoring_app/services/api_service.dart';
|
// --- 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 {
|
class MarineApiService {
|
||||||
final BaseApiService _baseService;
|
final BaseApiService _baseService;
|
||||||
@ -18,6 +21,7 @@ class MarineApiService {
|
|||||||
|
|
||||||
MarineApiService(this._baseService, this._telegramService, this._serverConfigService);
|
MarineApiService(this._baseService, this._telegramService, this._serverConfigService);
|
||||||
|
|
||||||
|
// --- METHODS YOU ALREADY MOVED ---
|
||||||
Future<Map<String, dynamic>> getTarballStations() async {
|
Future<Map<String, dynamic>> getTarballStations() async {
|
||||||
final baseUrl = await _serverConfigService.getActiveApiUrl();
|
final baseUrl = await _serverConfigService.getActiveApiUrl();
|
||||||
return _baseService.get(baseUrl, 'marine/tarball/stations');
|
return _baseService.get(baseUrl, 'marine/tarball/stations');
|
||||||
@ -33,8 +37,6 @@ class MarineApiService {
|
|||||||
return _baseService.get(baseUrl, 'marine/tarball/classifications');
|
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({
|
Future<Map<String, dynamic>> getManualSamplingImages({
|
||||||
required int stationId,
|
required int stationId,
|
||||||
required DateTime samplingDate,
|
required DateTime samplingDate,
|
||||||
@ -44,28 +46,22 @@ class MarineApiService {
|
|||||||
final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate);
|
final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate);
|
||||||
|
|
||||||
String endpoint;
|
String endpoint;
|
||||||
// Determine the correct endpoint based on the sampling type
|
|
||||||
switch (samplingType) {
|
switch (samplingType) {
|
||||||
case 'In-Situ Sampling':
|
case 'In-Situ Sampling':
|
||||||
endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr';
|
endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr';
|
||||||
break;
|
break;
|
||||||
case 'Tarball Sampling':
|
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';
|
endpoint = 'marine/tarball/records-by-station?station_id=$stationId&date=$dateStr';
|
||||||
break;
|
break;
|
||||||
case 'All Manual Sampling':
|
case 'All Manual Sampling':
|
||||||
default:
|
default:
|
||||||
// 'All' is complex. Defaulting to 'manual' (in-situ) as a fallback.
|
|
||||||
endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr';
|
endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint("MarineApiService: Calling API endpoint: $endpoint");
|
debugPrint("MarineApiService: Calling API endpoint: $endpoint");
|
||||||
|
|
||||||
final response = await _baseService.get(baseUrl, 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) {
|
if (response['success'] == true && response['data'] is Map && response['data']['data'] is List) {
|
||||||
return {
|
return {
|
||||||
'success': true,
|
'success': true,
|
||||||
@ -73,13 +69,9 @@ class MarineApiService {
|
|||||||
'message': response['message'],
|
'message': response['message'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return original response if structure doesn't match
|
|
||||||
return response;
|
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({
|
Future<Map<String, dynamic>> sendImageRequestEmail({
|
||||||
required String recipientEmail,
|
required String recipientEmail,
|
||||||
required List<String> imageUrls,
|
required List<String> imageUrls,
|
||||||
@ -90,17 +82,96 @@ class MarineApiService {
|
|||||||
|
|
||||||
final Map<String, String> fields = {
|
final Map<String, String> fields = {
|
||||||
'recipientEmail': recipientEmail,
|
'recipientEmail': recipientEmail,
|
||||||
'imageUrls': jsonEncode(imageUrls), // Encode list as JSON string
|
'imageUrls': jsonEncode(imageUrls),
|
||||||
'stationName': stationName,
|
'stationName': stationName,
|
||||||
'samplingDate': samplingDate,
|
'samplingDate': samplingDate,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use postMultipart (even with no files) as it's common for this kind of endpoint
|
|
||||||
return _baseService.postMultipart(
|
return _baseService.postMultipart(
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
endpoint: 'marine/images/send-email', // **IMPORTANT**: Verify this endpoint
|
endpoint: 'marine/images/send-email',
|
||||||
fields: fields,
|
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 ***
|
||||||
}
|
}
|
||||||
@ -14,6 +14,7 @@ import 'package:permission_handler/permission_handler.dart';
|
|||||||
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';
|
||||||
import 'package:usb_serial/usb_serial.dart';
|
import 'package:usb_serial/usb_serial.dart';
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
|
import 'package:intl/intl.dart'; // Import intl
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../auth_provider.dart';
|
import '../auth_provider.dart';
|
||||||
@ -25,6 +26,7 @@ import 'local_storage_service.dart';
|
|||||||
import 'server_config_service.dart';
|
import 'server_config_service.dart';
|
||||||
import 'zipping_service.dart';
|
import 'zipping_service.dart';
|
||||||
import 'api_service.dart';
|
import 'api_service.dart';
|
||||||
|
import 'package:environment_monitoring_app/services/database_helper.dart';
|
||||||
import 'submission_api_service.dart';
|
import 'submission_api_service.dart';
|
||||||
import 'submission_ftp_service.dart';
|
import 'submission_ftp_service.dart';
|
||||||
import 'telegram_service.dart';
|
import 'telegram_service.dart';
|
||||||
@ -217,6 +219,10 @@ class MarineInSituSamplingService {
|
|||||||
required AuthProvider authProvider, // Still needed for session check inside this method
|
required AuthProvider authProvider, // Still needed for session check inside this method
|
||||||
String? logDirectory,
|
String? logDirectory,
|
||||||
}) async {
|
}) 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 serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
|
||||||
final imageFilesWithNulls = data.toApiImageFiles();
|
final imageFilesWithNulls = data.toApiImageFiles();
|
||||||
imageFilesWithNulls.removeWhere((key, value) => value == null);
|
imageFilesWithNulls.removeWhere((key, value) => value == null);
|
||||||
@ -367,9 +373,12 @@ class MarineInSituSamplingService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 6. Send Alert
|
// 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);
|
_handleInSituSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
||||||
}
|
}
|
||||||
|
// --- END FIX ---
|
||||||
|
|
||||||
return {'success': overallSuccess, 'message': finalMessage, 'reportId': data.reportId};
|
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 {
|
Future<void> _handleInSituSuccessAlert(InSituSamplingData data, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly, bool isSessionExpired = false}) async {
|
||||||
|
|
||||||
// --- START: Logic moved from data model ---
|
// --- 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 submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||||
final stationName = data.selectedStation?['man_station_name'] ?? 'N/A';
|
final stationName = data.selectedStation?['man_station_name'] ?? 'N/A';
|
||||||
final stationCode = data.selectedStation?['man_station_code'] ?? '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()
|
final buffer = StringBuffer()
|
||||||
..writeln('✅ *In-Situ Sample $submissionType Submitted:*')
|
..writeln('✅ *In-Situ Sample $submissionType Submitted:*')
|
||||||
..writeln()
|
..writeln()
|
||||||
..writeln('*Station Name & Code:* $stationName ($stationCode)')
|
..writeln('*Station Name & Code:* $stationName ($stationCode)')
|
||||||
..writeln('*Date of Submission:* ${data.samplingDate}')
|
// --- START MODIFICATION ---
|
||||||
..writeln('*Submitted by User:* ${data.firstSamplerName}')
|
..writeln('*Date & Time of Submission:* $submissionDate $submissionTime')
|
||||||
|
// --- END MODIFICATION ---
|
||||||
|
..writeln('*Submitted by User:* $submitter')
|
||||||
..writeln('*Sonde ID:* ${data.sondeId ?? "N/A"}')
|
..writeln('*Sonde ID:* ${data.sondeId ?? "N/A"}')
|
||||||
..writeln('*Status of Submission:* Successful');
|
..writeln('*Status of Submission:* Successful');
|
||||||
|
|
||||||
final distanceKm = data.distanceDifferenceInKm ?? 0;
|
final distanceKm = data.distanceDifferenceInKm ?? 0;
|
||||||
final distanceRemarks = data.distanceDifferenceRemarks ?? '';
|
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
|
||||||
if (distanceKm * 1000 > 50) { // Check distance > 50m
|
final distanceRemarks = data.distanceDifferenceRemarks ?? 'N/A';
|
||||||
|
if (distanceKm * 1000 > 50 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { // Check distance > 50m
|
||||||
buffer
|
buffer
|
||||||
..writeln()
|
..writeln()
|
||||||
..writeln('🔔 *Distance Alert:*')
|
..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');
|
buffer.writeln('*Remarks for distance:* $distanceRemarks');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: The logic to check parameter limits requires async DB access,
|
// --- START: MODIFICATION (Add both alert types) ---
|
||||||
// which cannot be done directly here without further refactoring.
|
// 1. Add station parameter limit check section
|
||||||
// This part is omitted for now as per the previous refactor.
|
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();
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
// --- END: Logic moved from data model ---
|
// --- END: Logic moved from data model ---
|
||||||
|
|
||||||
try {
|
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
|
final alertKey = 'marine_in_situ'; // Correct key
|
||||||
|
|
||||||
if (isSessionExpired) {
|
if (isSessionExpired) {
|
||||||
@ -609,4 +636,167 @@ class MarineInSituSamplingService {
|
|||||||
debugPrint("Failed to handle In-Situ Telegram alert: $e");
|
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 ---
|
||||||
}
|
}
|
||||||
@ -29,6 +29,8 @@ import 'telegram_service.dart';
|
|||||||
import 'retry_service.dart';
|
import 'retry_service.dart';
|
||||||
import 'base_api_service.dart'; // Import for SessionExpiredException
|
import 'base_api_service.dart'; // Import for SessionExpiredException
|
||||||
import 'api_service.dart'; // Import for DatabaseHelper
|
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.
|
/// A dedicated service for the Marine Investigative Sampling feature.
|
||||||
class MarineInvestigativeSamplingService {
|
class MarineInvestigativeSamplingService {
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import 'dart:io';
|
|||||||
import '../auth_provider.dart';
|
import '../auth_provider.dart';
|
||||||
import '../models/marine_manual_equipment_maintenance_data.dart';
|
import '../models/marine_manual_equipment_maintenance_data.dart';
|
||||||
import 'api_service.dart';
|
import 'api_service.dart';
|
||||||
|
import 'package:environment_monitoring_app/services/database_helper.dart';
|
||||||
|
|
||||||
import 'base_api_service.dart'; // Import for SessionExpiredException
|
import 'base_api_service.dart'; // Import for SessionExpiredException
|
||||||
|
|
||||||
class MarineManualEquipmentMaintenanceService {
|
class MarineManualEquipmentMaintenanceService {
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import 'dart:io';
|
|||||||
import '../auth_provider.dart';
|
import '../auth_provider.dart';
|
||||||
import '../models/marine_manual_pre_departure_checklist_data.dart';
|
import '../models/marine_manual_pre_departure_checklist_data.dart';
|
||||||
import 'api_service.dart';
|
import 'api_service.dart';
|
||||||
|
import 'package:environment_monitoring_app/services/database_helper.dart';
|
||||||
|
|
||||||
import 'base_api_service.dart'; // Import for SessionExpiredException
|
import 'base_api_service.dart'; // Import for SessionExpiredException
|
||||||
|
|
||||||
class MarineManualPreDepartureService {
|
class MarineManualPreDepartureService {
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import 'dart:io';
|
|||||||
import '../auth_provider.dart';
|
import '../auth_provider.dart';
|
||||||
import '../models/marine_manual_sonde_calibration_data.dart';
|
import '../models/marine_manual_sonde_calibration_data.dart';
|
||||||
import 'api_service.dart';
|
import 'api_service.dart';
|
||||||
|
import 'package:environment_monitoring_app/services/database_helper.dart';
|
||||||
|
|
||||||
import 'base_api_service.dart'; // Import for SessionExpiredException
|
import 'base_api_service.dart'; // Import for SessionExpiredException
|
||||||
|
|
||||||
class MarineManualSondeCalibrationService {
|
class MarineManualSondeCalibrationService {
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import 'submission_ftp_service.dart';
|
|||||||
import 'telegram_service.dart';
|
import 'telegram_service.dart';
|
||||||
import 'retry_service.dart';
|
import 'retry_service.dart';
|
||||||
import 'api_service.dart';
|
import 'api_service.dart';
|
||||||
|
import 'package:environment_monitoring_app/services/database_helper.dart';
|
||||||
|
|
||||||
|
|
||||||
class MarineNpeReportService {
|
class MarineNpeReportService {
|
||||||
final SubmissionApiService _submissionApiService = SubmissionApiService();
|
final SubmissionApiService _submissionApiService = SubmissionApiService();
|
||||||
|
|||||||
@ -8,12 +8,15 @@ import 'package:path/path.dart' as p;
|
|||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:flutter/material.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/models/tarball_data.dart';
|
||||||
import 'package:environment_monitoring_app/services/local_storage_service.dart';
|
import 'package:environment_monitoring_app/services/local_storage_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/server_config_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/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_api_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/submission_ftp_service.dart';
|
import 'package:environment_monitoring_app/services/submission_ftp_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/telegram_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 stationName = data.selectedStation?['tbl_station_name'] ?? 'N/A';
|
||||||
final stationCode = data.selectedStation?['tbl_station_code'] ?? 'N/A';
|
final stationCode = data.selectedStation?['tbl_station_code'] ?? 'N/A';
|
||||||
final classification = data.selectedClassification?['classification_name'] ?? data.classificationId?.toString() ?? '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()
|
final buffer = StringBuffer()
|
||||||
..writeln('✅ *Tarball Sample $submissionType Submitted:*')
|
..writeln('✅ *Tarball Sample $submissionType Submitted:*')
|
||||||
..writeln()
|
..writeln()
|
||||||
..writeln('*Station Name & Code:* $stationName ($stationCode)')
|
..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('*Submitted by User:* ${data.firstSampler}') // Use firstSampler from data model
|
||||||
..writeln('*Classification:* $classification')
|
|
||||||
..writeln('*Status of Submission:* Successful');
|
..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 distanceKm = data.distanceDifference ?? 0; // Use distanceDifference from data model
|
||||||
|
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
|
||||||
final distanceRemarks = data.distanceDifferenceRemarks ?? '';
|
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
|
buffer
|
||||||
..writeln()
|
..writeln()
|
||||||
..writeln('🔔 *Distance Alert:*')
|
..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) {
|
if (distanceRemarks.isNotEmpty) {
|
||||||
buffer.writeln('*Remarks for distance:* $distanceRemarks');
|
buffer.writeln('*Remarks for distance:* $distanceRemarks');
|
||||||
|
|||||||
@ -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/services/marine_investigative_sampling_service.dart';
|
||||||
import 'package:environment_monitoring_app/models/tarball_data.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/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/base_api_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/ftp_service.dart';
|
import 'package:environment_monitoring_app/services/ftp_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/server_config_service.dart';
|
import 'package:environment_monitoring_app/services/server_config_service.dart';
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
// lib/services/river_api_service.dart
|
// lib/services/river_api_service.dart
|
||||||
|
|
||||||
|
import 'dart:convert'; // <-- ADDED for jsonEncode
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:environment_monitoring_app/services/base_api_service.dart';
|
import 'package:environment_monitoring_app/services/base_api_service.dart';
|
||||||
@ -22,4 +23,60 @@ class RiverApiService {
|
|||||||
final baseUrl = await _serverConfigService.getActiveApiUrl();
|
final baseUrl = await _serverConfigService.getActiveApiUrl();
|
||||||
return _baseService.get(baseUrl, 'river/triennial-stations');
|
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 ---
|
||||||
}
|
}
|
||||||
@ -23,6 +23,8 @@ import '../models/river_in_situ_sampling_data.dart';
|
|||||||
import '../bluetooth/bluetooth_manager.dart';
|
import '../bluetooth/bluetooth_manager.dart';
|
||||||
import '../serial/serial_manager.dart';
|
import '../serial/serial_manager.dart';
|
||||||
import 'api_service.dart';
|
import 'api_service.dart';
|
||||||
|
import 'package:environment_monitoring_app/services/database_helper.dart';
|
||||||
|
|
||||||
import 'local_storage_service.dart';
|
import 'local_storage_service.dart';
|
||||||
import 'server_config_service.dart';
|
import 'server_config_service.dart';
|
||||||
import 'zipping_service.dart';
|
import 'zipping_service.dart';
|
||||||
@ -72,7 +74,8 @@ class RiverInSituSamplingService {
|
|||||||
return null;
|
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.");
|
debugPrint("Image rejected: Must be in landscape orientation.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -213,6 +216,10 @@ class RiverInSituSamplingService {
|
|||||||
required AuthProvider authProvider,
|
required AuthProvider authProvider,
|
||||||
String? logDirectory,
|
String? logDirectory,
|
||||||
}) async {
|
}) 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 serverName = (await _serverConfigService.getActiveApiConfig())?['config_name'] as String? ?? 'Default';
|
||||||
final imageFilesWithNulls = data.toApiImageFiles();
|
final imageFilesWithNulls = data.toApiImageFiles();
|
||||||
imageFilesWithNulls.removeWhere((key, value) => value == null);
|
imageFilesWithNulls.removeWhere((key, value) => value == null);
|
||||||
@ -368,9 +375,12 @@ class RiverInSituSamplingService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 6. Send Alert
|
// 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);
|
_handleSuccessAlert(data, appSettings, isDataOnly: finalImageFiles.isEmpty, isSessionExpired: isSessionKnownToBeExpired);
|
||||||
}
|
}
|
||||||
|
// --- END FIX ---
|
||||||
|
|
||||||
// Return consistent format
|
// Return consistent format
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import '../models/river_inves_manual_sampling_data.dart'; // Use Investigative m
|
|||||||
import '../bluetooth/bluetooth_manager.dart';
|
import '../bluetooth/bluetooth_manager.dart';
|
||||||
import '../serial/serial_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 '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 'local_storage_service.dart';
|
||||||
import 'server_config_service.dart';
|
import 'server_config_service.dart';
|
||||||
import 'zipping_service.dart';
|
import 'zipping_service.dart';
|
||||||
@ -73,8 +74,8 @@ class RiverInvestigativeSamplingService { // Renamed class
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep landscape requirement for required photos
|
// ✅ FIX: Apply landscape check to ALL photos, not just required ones.
|
||||||
if (isRequired && originalImage.height > originalImage.width) {
|
if (originalImage.height > originalImage.width) {
|
||||||
debugPrint("Image rejected: Must be in landscape orientation.");
|
debugPrint("Image rejected: Must be in landscape orientation.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -290,7 +291,7 @@ class RiverInvestigativeSamplingService { // Renamed class
|
|||||||
isSessionKnownToBeExpired = true;
|
isSessionKnownToBeExpired = true;
|
||||||
anyApiSuccess = false;
|
anyApiSuccess = false;
|
||||||
apiDataResult = {'success': false, 'message': 'Session expired and re-login failed. API submission queued.'};
|
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 ***
|
// *** MODIFIED: Use Investigative endpoints for queueing ***
|
||||||
await _retryService.addApiToQueue(endpoint: 'river/investigative/sample', method: 'POST', body: data.toApiFormData());
|
await _retryService.addApiToQueue(endpoint: 'river/investigative/sample', method: 'POST', body: data.toApiFormData());
|
||||||
if (finalImageFiles.isNotEmpty && data.reportId != null) {
|
if (finalImageFiles.isNotEmpty && data.reportId != null) {
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import '../models/river_manual_triennial_sampling_data.dart';
|
|||||||
import '../bluetooth/bluetooth_manager.dart';
|
import '../bluetooth/bluetooth_manager.dart';
|
||||||
import '../serial/serial_manager.dart';
|
import '../serial/serial_manager.dart';
|
||||||
import 'api_service.dart'; // Keep DatabaseHelper import
|
import 'api_service.dart'; // Keep DatabaseHelper import
|
||||||
|
import 'package:environment_monitoring_app/services/database_helper.dart';
|
||||||
|
|
||||||
import 'local_storage_service.dart';
|
import 'local_storage_service.dart';
|
||||||
import 'server_config_service.dart';
|
import 'server_config_service.dart';
|
||||||
import 'zipping_service.dart';
|
import 'zipping_service.dart';
|
||||||
@ -72,7 +74,8 @@ class RiverManualTriennialSamplingService {
|
|||||||
return null;
|
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.");
|
debugPrint("Image rejected: Must be in landscape orientation.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,8 @@ import 'package:environment_monitoring_app/services/ftp_service.dart';
|
|||||||
import 'package:environment_monitoring_app/services/retry_service.dart';
|
import 'package:environment_monitoring_app/services/retry_service.dart';
|
||||||
// Import necessary services and models if needed for queueFtpTasksForSkippedAttempt
|
// Import necessary services and models if needed for queueFtpTasksForSkippedAttempt
|
||||||
import 'package:environment_monitoring_app/services/zipping_service.dart';
|
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.
|
/// A generic, reusable service for handling the FTP submission process.
|
||||||
/// It respects user preferences for enabled destinations for any given module.
|
/// It respects user preferences for enabled destinations for any given module.
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
import 'package:environment_monitoring_app/services/api_service.dart';
|
import 'package:environment_monitoring_app/services/api_service.dart';
|
||||||
|
import 'package:environment_monitoring_app/services/database_helper.dart';
|
||||||
|
|
||||||
import 'package:environment_monitoring_app/services/settings_service.dart';
|
import 'package:environment_monitoring_app/services/settings_service.dart';
|
||||||
|
|
||||||
class TelegramService {
|
class TelegramService {
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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/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
|
/// A dedicated service to manage the user's local preferences for
|
||||||
/// module-specific submission destinations.
|
/// module-specific submission destinations.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user