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