From a2d8b372e68244276e7e61ef0c1e169efd167b07 Mon Sep 17 00:00:00 2001 From: ALim Aidrus Date: Thu, 14 Aug 2025 16:18:56 +0800 Subject: [PATCH] fix air manual data status log --- lib/models/air_collection_data.dart | 2 - lib/models/river_in_situ_sampling_data.dart | 12 +- lib/screens/air/manual/data_status_log.dart | 437 +++++++++++++++- .../marine/manual/data_status_log.dart | 476 +++++++++--------- lib/screens/river/manual/data_status_log.dart | 117 +++-- lib/services/river_api_service.dart | 8 +- 6 files changed, 736 insertions(+), 316 deletions(-) diff --git a/lib/models/air_collection_data.dart b/lib/models/air_collection_data.dart index 0f9a693..23363a4 100644 --- a/lib/models/air_collection_data.dart +++ b/lib/models/air_collection_data.dart @@ -1,4 +1,3 @@ -// lib/models/air_collection_data.dart import 'dart:io'; class AirCollectionData { @@ -189,7 +188,6 @@ class AirCollectionData { Map toJson() { return { 'air_man_id': airManId?.toString(), - // **THE FIX**: Add the missing user ID to the JSON payload. 'air_man_collection_user_id': collectionUserId?.toString(), 'air_man_collection_date': collectionDate, 'air_man_collection_time': collectionTime, diff --git a/lib/models/river_in_situ_sampling_data.dart b/lib/models/river_in_situ_sampling_data.dart index 2a079cd..c7b79ac 100644 --- a/lib/models/river_in_situ_sampling_data.dart +++ b/lib/models/river_in_situ_sampling_data.dart @@ -91,9 +91,17 @@ class RiverInSituSamplingData { return null; } + // ADDED HELPER FUNCTION TO FIX THE ERROR + int? intFromJson(dynamic value) { + if (value is int) return value; + if (value is String) return int.tryParse(value); + return null; + } + return RiverInSituSamplingData() ..firstSamplerName = json['first_sampler_name'] - ..firstSamplerUserId = json['first_sampler_user_id'] + // MODIFIED THIS LINE TO USE THE HELPER FUNCTION + ..firstSamplerUserId = intFromJson(json['first_sampler_user_id']) ..secondSampler = json['secondSampler'] ..samplingDate = json['r_man_date'] ..samplingTime = json['r_man_time'] @@ -226,4 +234,4 @@ class RiverInSituSamplingData { 'r_man_optional_photo_04': optionalImage4, }; } -} +} \ No newline at end of file diff --git a/lib/screens/air/manual/data_status_log.dart b/lib/screens/air/manual/data_status_log.dart index 4961fae..8fc63cf 100644 --- a/lib/screens/air/manual/data_status_log.dart +++ b/lib/screens/air/manual/data_status_log.dart @@ -1,32 +1,429 @@ +import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; -class AirManualDataStatusLog extends StatelessWidget { - final List> logEntries = [ - {"Date": "2025-07-15", "Status": "Submitted", "User": "analyst_air"}, - {"Date": "2025-07-16", "Status": "Approved", "User": "supervisor_air"}, - ]; +import '../../../../models/air_installation_data.dart'; +import '../../../../models/air_collection_data.dart'; +import '../../../../services/local_storage_service.dart'; +import '../../../../services/api_service.dart'; + +class SubmissionLogEntry { + final String type; + final String title; + final String stationCode; + final DateTime submissionDateTime; + final String? reportId; + final String status; + final String message; + final Map rawData; + 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, + this.isResubmitting = false, + }); +} + +class AirManualDataStatusLog extends StatefulWidget { + const AirManualDataStatusLog({super.key}); + + @override + State createState() => _AirManualDataStatusLogState(); +} + +class _AirManualDataStatusLogState extends State { + final LocalStorageService _localStorageService = LocalStorageService(); + final ApiService _apiService = ApiService(); + + List _installationLogs = []; + List _collectionLogs = []; + List _filteredInstallationLogs = []; + List _filteredCollectionLogs = []; + + final Map _searchControllers = {}; + bool _isLoading = true; + final Map _isResubmitting = {}; + + @override + void initState() { + super.initState(); + _searchControllers['Installation'] = TextEditingController()..addListener(_filterLogs); + _searchControllers['Collection'] = TextEditingController()..addListener(_filterLogs); + _loadAllLogs(); + } + + @override + void dispose() { + _searchControllers['Installation']?.dispose(); + _searchControllers['Collection']?.dispose(); + super.dispose(); + } + + Future _loadAllLogs() async { + setState(() => _isLoading = true); + final airLogs = await _localStorageService.getAllAirSamplingLogs(); + + final List tempInstallation = []; + final List tempCollection = []; + + for (var log in airLogs) { + try { + // Determine if this is an installation or collection + final hasCollectionData = log['collectionData'] != null && (log['collectionData'] as Map).isNotEmpty; + final isInstallation = !hasCollectionData && log['air_man_id'] != null; + + // Get station info from the correct location + final stationInfo = isInstallation + ? log['stationInfo'] ?? {} + : log['collectionData']?['stationInfo'] ?? {}; + + final stationName = stationInfo['station_name'] ?? 'Station ${log['stationID'] ?? 'Unknown'}'; + final stationCode = stationInfo['station_code'] ?? log['stationID'] ?? 'N/A'; + + // Get correct timestamp + final submissionDateTime = isInstallation + ? _parseInstallationDateTime(log) + : _parseCollectionDateTime(log['collectionData']); + + debugPrint('Processed ${isInstallation ? 'Installation' : 'Collection'} log:'); + debugPrint(' Station: $stationName ($stationCode)'); + debugPrint(' Original Date: ${submissionDateTime.toString()}'); + + final entry = SubmissionLogEntry( + type: isInstallation ? 'Installation' : 'Collection', + title: stationName, + stationCode: stationCode, + submissionDateTime: submissionDateTime, + reportId: isInstallation ? log['air_man_id']?.toString() : log['collectionData']?['air_man_id']?.toString(), + status: log['status'] ?? 'L1', + message: _getStatusMessage(log), + rawData: log, + ); + + if (isInstallation) { + tempInstallation.add(entry); + } else { + tempCollection.add(entry); + } + } catch (e) { + debugPrint('Error processing log entry: $e'); + } + } + + // Sort by datetime descending + tempInstallation.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + tempCollection.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + + if (mounted) { + setState(() { + _installationLogs = tempInstallation; + _collectionLogs = tempCollection; + _isLoading = false; + }); + _filterLogs(); + } + } + + DateTime _parseInstallationDateTime(Map log) { + try { + // First try to get the installation date and time + if (log['installationDate'] != null) { + final date = log['installationDate']; + final time = log['installationTime'] ?? '00:00'; + return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time'); + } + + // Fallback to sampling date if installation date not available + if (log['samplingDate'] != null) { + final date = log['samplingDate']; + final time = log['samplingTime'] ?? '00:00'; + return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time'); + } + + // If no dates found, check in the raw data structure + if (log['data'] != null && log['data']['installationDate'] != null) { + final date = log['data']['installationDate']; + final time = log['data']['installationTime'] ?? '00:00'; + return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time'); + } + + debugPrint('No valid installation date found in log: ${log.keys}'); + return DateTime.now(); + } catch (e) { + debugPrint('Error parsing installation date: $e'); + return DateTime.now(); + } + } + + DateTime _parseCollectionDateTime(Map? collectionData) { + try { + if (collectionData == null) { + debugPrint('Collection data is null'); + return DateTime.now(); + } + + // First try the direct fields + if (collectionData['collectionDate'] != null) { + final date = collectionData['collectionDate']; + final time = collectionData['collectionTime'] ?? '00:00'; + return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time'); + } + + // Check for nested data structure + if (collectionData['data'] != null && collectionData['data']['collectionDate'] != null) { + final date = collectionData['data']['collectionDate']; + final time = collectionData['data']['collectionTime'] ?? '00:00'; + return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time'); + } + + debugPrint('No valid collection date found in data: ${collectionData.keys}'); + return DateTime.now(); + } catch (e) { + debugPrint('Error parsing collection date: $e'); + return DateTime.now(); + } + } + + String _getStatusMessage(Map log) { + if (log['status'] == 'S2' || log['status'] == 'S3') { + return 'Successfully submitted to server'; + } else if (log['status'] == 'L1' || log['status'] == 'L3') { + return 'Saved locally (pending submission)'; + } else if (log['status'] == 'L4') { + return 'Partial submission (images failed)'; + } + return 'Submission status unknown'; + } + + void _filterLogs() { + final installationQuery = _searchControllers['Installation']?.text.toLowerCase() ?? ''; + final collectionQuery = _searchControllers['Collection']?.text.toLowerCase() ?? ''; + + setState(() { + _filteredInstallationLogs = _installationLogs.where((log) => _logMatchesQuery(log, installationQuery)).toList(); + _filteredCollectionLogs = _collectionLogs.where((log) => _logMatchesQuery(log, collectionQuery)).toList(); + }); + } + + bool _logMatchesQuery(SubmissionLogEntry log, String query) { + if (query.isEmpty) return true; + return log.title.toLowerCase().contains(query) || + log.stationCode.toLowerCase().contains(query) || + (log.reportId?.toLowerCase() ?? '').contains(query); + } + + Future _resubmitData(SubmissionLogEntry log) async { + final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); + if (mounted) { + setState(() => _isResubmitting[logKey] = true); + } + + try { + final logData = log.rawData; + final result = log.type == 'Installation' + ? await _submitInstallation(logData) + : await _submitCollection(logData); + + await _localStorageService.saveAirSamplingRecord(logData, logData['refID']); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Resubmission successful!')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Resubmission failed: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isResubmitting.remove(logKey)); + await _loadAllLogs(); + } + } + } + + Future> _submitInstallation(Map data) async { + final dataToResubmit = AirInstallationData.fromJson(data); + final result = await _apiService.post('air/manual/installation', dataToResubmit.toJsonForApi()); + + final imageFiles = dataToResubmit.getImagesForUpload(); + if (imageFiles.isNotEmpty && result['success'] == true) { + await _apiService.air.uploadInstallationImages( + airManId: result['data']['air_man_id'].toString(), + files: imageFiles, + ); + } + + return result; + } + + Future> _submitCollection(Map data) async { + final collectionData = data['collectionData'] ?? {}; + final dataToResubmit = AirCollectionData.fromMap(collectionData); + final result = await _apiService.post('air/manual/collection', dataToResubmit.toJson()); + + final imageFiles = dataToResubmit.getImagesForUpload(); + if (imageFiles.isNotEmpty && result['success'] == true) { + await _apiService.air.uploadCollectionImages( + airManId: dataToResubmit.airManId.toString(), + files: imageFiles, + ); + } + + return result; + } @override Widget build(BuildContext context) { + final hasAnyLogs = _installationLogs.isNotEmpty || _collectionLogs.isNotEmpty; + final hasFilteredLogs = _filteredInstallationLogs.isNotEmpty || _filteredCollectionLogs.isNotEmpty; + + final logCategories = { + 'Installation': _filteredInstallationLogs, + 'Collection': _filteredCollectionLogs, + }; + return Scaffold( - appBar: AppBar(title: Text("Air Manual Data Status Log")), - body: Padding( - padding: const EdgeInsets.all(24), - child: DataTable( - columns: [ - DataColumn(label: Text("Date")), - DataColumn(label: Text("Status")), - DataColumn(label: Text("User")), + appBar: AppBar( + title: const Text('Air Sampling Status Log'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadAllLogs, + tooltip: 'Refresh logs', + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _loadAllLogs, + child: !hasAnyLogs + ? const Center(child: Text('No air sampling logs found.')) + : ListView( + padding: const EdgeInsets.all(8.0), + children: [ + ...logCategories.entries + .where((entry) => entry.value.isNotEmpty) + .map((entry) => _buildCategorySection(entry.key, entry.value)), + if (!hasFilteredLogs && hasAnyLogs) + const Center( + child: Padding( + padding: EdgeInsets.all(24.0), + child: Text('No logs match your search.'), + ), + ) ], - rows: logEntries.map((entry) { - return DataRow(cells: [ - DataCell(Text(entry["Date"]!)), - DataCell(Text(entry["Status"]!)), - DataCell(Text(entry["User"]!)), - ]); - }).toList(), ), ), ); } + + Widget _buildCategorySection(String category, List logs) { + final listHeight = (logs.length > 3 ? 3.5 : logs.length.toDouble()) * 75.0; + + 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: _searchControllers[category], + decoration: InputDecoration( + hintText: 'Search $category logs...', + prefixIcon: const Icon(Icons.search, size: 20), + isDense: true, + border: const OutlineInputBorder(), + ), + ), + ), + const Divider(), + logs.isEmpty + ? const Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: Text('No logs match your search in this category.'))) + : ConstrainedBox( + constraints: BoxConstraints(maxHeight: listHeight), + child: ListView.builder( + shrinkWrap: true, + itemCount: logs.length, + itemBuilder: (context, index) => _buildLogListItem(logs[index]), + ), + ), + ], + ), + ), + ); + } + + Widget _buildLogListItem(SubmissionLogEntry log) { + final isSuccess = log.status == 'S2' || log.status == 'S3'; + final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); + final isResubmitting = _isResubmitting[logKey] ?? false; + final title = log.title; + final subtitle = DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime); + + return ExpansionTile( + leading: Icon( + isSuccess ? Icons.check_circle_outline : Icons.error_outline, + color: isSuccess ? Colors.green : Colors.red, + ), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Text(subtitle), + trailing: !isSuccess + ? (isResubmitting + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 3)) + : IconButton( + icon: const Icon(Icons.sync, color: Colors.blue), + onPressed: () => _resubmitData(log), + )) + : null, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailRow('Station Code:', log.stationCode), + if (log.reportId != null) _buildDetailRow('Report ID:', log.reportId!), + _buildDetailRow('Status:', log.message), + _buildDetailRow('Type:', '${log.type} Sampling'), + if (log.type == 'Collection') + _buildDetailRow('Installation ID:', log.rawData['installationRefID']?.toString() ?? 'N/A'), + ], + ), + ) + ], + ); + } + + Widget _buildDetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$label ', style: const TextStyle(fontWeight: FontWeight.bold)), + Expanded(child: Text(value)), + ], + ), + ); + } } \ No newline at end of file diff --git a/lib/screens/marine/manual/data_status_log.dart b/lib/screens/marine/manual/data_status_log.dart index 45c81a9..1ccd411 100644 --- a/lib/screens/marine/manual/data_status_log.dart +++ b/lib/screens/marine/manual/data_status_log.dart @@ -2,13 +2,12 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:environment_monitoring_app/models/tarball_data.dart'; -import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart'; // Import In-Situ model +import 'package:environment_monitoring_app/models/in_situ_sampling_data.dart'; import 'package:environment_monitoring_app/services/local_storage_service.dart'; import 'package:environment_monitoring_app/services/marine_api_service.dart'; -// A unified model to represent any type of submission log entry. class SubmissionLogEntry { - final String type; // e.g., 'tarball', 'in-situ' + final String type; // e.g., 'Manual Sampling', 'Tarball Sampling' final String title; final String stationCode; final DateTime submissionDateTime; @@ -42,40 +41,64 @@ class _MarineManualDataStatusLogState extends State { final LocalStorageService _localStorageService = LocalStorageService(); final MarineApiService _marineApiService = MarineApiService(); - Map> _groupedLogs = {}; - Map> _filteredLogs = {}; - final Map _isCategoryExpanded = {}; + // Raw data lists + List _manualLogs = []; + List _tarballLogs = []; + + // Filtered lists for the UI + List _filteredManualLogs = []; + List _filteredTarballLogs = []; + + // Per-category search controllers + final Map _searchControllers = {}; bool _isLoading = true; - final TextEditingController _searchController = TextEditingController(); + final Map _isResubmitting = {}; @override void initState() { super.initState(); + _searchControllers['Manual Sampling'] = TextEditingController()..addListener(_filterLogs); + _searchControllers['Tarball Sampling'] = TextEditingController()..addListener(_filterLogs); _loadAllLogs(); - _searchController.addListener(_filterLogs); } @override void dispose() { - _searchController.dispose(); + _searchControllers['Manual Sampling']?.dispose(); + _searchControllers['Tarball Sampling']?.dispose(); super.dispose(); } - /// Loads logs from all available modules (Tarball, In-Situ, etc.) Future _loadAllLogs() async { setState(() => _isLoading = true); - // --- Fetch logs for all types --- final tarballLogs = await _localStorageService.getAllTarballLogs(); final inSituLogs = await _localStorageService.getAllInSituLogs(); - final Map> tempGroupedLogs = {}; + final List tempManual = []; + final List tempTarball = []; - // Map tarball logs (Unchanged) - final List tarballEntries = []; + // Map In-Situ logs to Manual Sampling + for (var log in inSituLogs) { + final String dateStr = log['data_capture_date'] ?? log['sampling_date'] ?? ''; + final String timeStr = log['data_capture_time'] ?? log['sampling_time'] ?? ''; + + tempManual.add(SubmissionLogEntry( + type: 'Manual Sampling', + title: log['selectedStation']?['man_station_name'] ?? 'Unknown Station', + stationCode: log['selectedStation']?['man_station_code'] ?? 'N/A', + submissionDateTime: DateTime.tryParse('$dateStr $timeStr') ?? DateTime.now(), + reportId: log['reportId']?.toString(), + status: log['submissionStatus'] ?? 'L1', + message: log['submissionMessage'] ?? 'No status message.', + rawData: log, + )); + } + + // Map Tarball logs for (var log in tarballLogs) { - tarballEntries.add(SubmissionLogEntry( + tempTarball.add(SubmissionLogEntry( type: 'Tarball Sampling', title: log['selectedStation']?['tbl_station_name'] ?? 'Unknown Station', stationCode: log['selectedStation']?['tbl_station_code'] ?? 'N/A', @@ -86,265 +109,244 @@ class _MarineManualDataStatusLogState extends State { rawData: log, )); } - if (tarballEntries.isNotEmpty) { - tarballEntries.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); - tempGroupedLogs['Tarball Sampling'] = tarballEntries; - } - // --- Map In-Situ logs --- - final List inSituEntries = []; - for (var log in inSituLogs) { - // REPAIRED: Use the correct date/time keys for In-Situ data. - final String dateStr = log['data_capture_date'] ?? log['sampling_date'] ?? ''; - final String timeStr = log['data_capture_time'] ?? log['sampling_time'] ?? ''; - - inSituEntries.add(SubmissionLogEntry( - type: 'In-Situ Sampling', - title: log['selectedStation']?['man_station_name'] ?? 'Unknown Station', - stationCode: log['selectedStation']?['man_station_code'] ?? 'N/A', - submissionDateTime: DateTime.tryParse('$dateStr $timeStr') ?? DateTime.now(), - reportId: log['reportId']?.toString(), - status: log['submissionStatus'] ?? 'L1', - message: log['submissionMessage'] ?? 'No status message.', - rawData: log, - )); - } - if (inSituEntries.isNotEmpty) { - inSituEntries.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); - tempGroupedLogs['In-Situ Sampling'] = inSituEntries; - } - // --- END of In-Situ mapping --- + tempManual.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); + tempTarball.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); if (mounted) { setState(() { - _groupedLogs = tempGroupedLogs; - _filteredLogs = tempGroupedLogs; + _manualLogs = tempManual; + _tarballLogs = tempTarball; _isLoading = false; }); + _filterLogs(); // Perform initial filter } } void _filterLogs() { - final query = _searchController.text.toLowerCase(); - final Map> tempFiltered = {}; - - _groupedLogs.forEach((category, logs) { - final filtered = logs.where((log) { - return log.title.toLowerCase().contains(query) || - log.stationCode.toLowerCase().contains(query) || - (log.reportId?.toLowerCase() ?? '').contains(query); - }).toList(); - - if (filtered.isNotEmpty) { - tempFiltered[category] = filtered; - } - }); + final manualQuery = _searchControllers['Manual Sampling']?.text.toLowerCase() ?? ''; + final tarballQuery = _searchControllers['Tarball Sampling']?.text.toLowerCase() ?? ''; setState(() { - _filteredLogs = tempFiltered; + _filteredManualLogs = _manualLogs.where((log) => _logMatchesQuery(log, manualQuery)).toList(); + _filteredTarballLogs = _tarballLogs.where((log) => _logMatchesQuery(log, tarballQuery)).toList(); }); } - /// Main router for resubmitting data based on its type. + bool _logMatchesQuery(SubmissionLogEntry log, String query) { + if (query.isEmpty) return true; + return log.title.toLowerCase().contains(query) || + log.stationCode.toLowerCase().contains(query) || + (log.reportId?.toLowerCase() ?? '').contains(query); + } + Future _resubmitData(SubmissionLogEntry log) async { - setState(() => log.isResubmitting = true); + final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); + if (mounted) { + setState(() { + _isResubmitting[logKey] = true; + }); + } - switch (log.type) { - case 'Tarball Sampling': - await _resubmitTarballData(log); - break; - case 'In-Situ Sampling': - await _resubmitInSituData(log); - break; - default: - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Resubmission for '${log.type}' is not implemented."), backgroundColor: Colors.orange), - ); - setState(() => log.isResubmitting = false); - } + try { + final result = await _performResubmission(log); + final logData = log.rawData; + + logData['submissionStatus'] = result['status']; + logData['submissionMessage'] = result['message']; + logData['reportId'] = result['reportId']?.toString() ?? logData['reportId']; + + if (log.type == 'Manual Sampling') { + await _localStorageService.updateInSituLog(logData); + } else if (log.type == 'Tarball Sampling') { + await _localStorageService.updateTarballLog(logData); + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Resubmission successful!')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Resubmission failed: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isResubmitting.remove(logKey); + }); + await _loadAllLogs(); + } } } - /// Handles resubmission for Tarball data. (Unchanged) - Future _resubmitTarballData(SubmissionLogEntry log) async { + Future> _performResubmission(SubmissionLogEntry log) async { final logData = log.rawData; - final int? firstSamplerId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? ''); - final int? classificationId = int.tryParse(logData['classification_id']?.toString() ?? ''); + if (log.type == 'Manual Sampling') { + final InSituSamplingData dataToResubmit = InSituSamplingData() + ..firstSamplerUserId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? '') + ..secondSampler = logData['secondSampler'] + ..samplingDate = logData['sampling_date'] + ..samplingTime = logData['sampling_time'] + ..samplingType = logData['sampling_type'] + ..sampleIdCode = logData['sample_id_code'] + ..selectedStation = logData['selectedStation'] + ..currentLatitude = logData['current_latitude']?.toString() + ..currentLongitude = logData['current_longitude']?.toString() + ..distanceDifferenceInKm = double.tryParse(logData['distance_difference']?.toString() ?? '0.0') + ..weather = logData['weather'] + ..tideLevel = logData['tide_level'] + ..seaCondition = logData['sea_condition'] + ..eventRemarks = logData['event_remarks'] + ..labRemarks = logData['lab_remarks'] + ..optionalRemark1 = logData['optional_photo_remark_1'] + ..optionalRemark2 = logData['optional_photo_remark_2'] + ..optionalRemark3 = logData['optional_photo_remark_3'] + ..optionalRemark4 = logData['optional_photo_remark_4'] + ..sondeId = logData['sonde_id'] + ..dataCaptureDate = logData['data_capture_date'] + ..dataCaptureTime = logData['data_capture_time'] + ..oxygenConcentration = double.tryParse(logData['oxygen_concentration_mg_l']?.toString() ?? '0.0') + ..oxygenSaturation = double.tryParse(logData['oxygen_saturation_percent']?.toString() ?? '0.0') + ..ph = double.tryParse(logData['ph']?.toString() ?? '0.0') + ..salinity = double.tryParse(logData['salinity_ppt']?.toString() ?? '0.0') + ..electricalConductivity = double.tryParse(logData['ec_us_cm']?.toString() ?? '0.0') + ..temperature = double.tryParse(logData['temperature_c']?.toString() ?? '0.0') + ..tds = double.tryParse(logData['tds_mg_l']?.toString() ?? '0.0') + ..turbidity = double.tryParse(logData['turbidity_ntu']?.toString() ?? '0.0') + ..tss = double.tryParse(logData['tss_mg_l']?.toString() ?? '0.0') + ..batteryVoltage = double.tryParse(logData['battery_v']?.toString() ?? '0.0'); - final TarballSamplingData dataToResubmit = TarballSamplingData() - ..selectedStation = logData['selectedStation'] - ..samplingDate = logData['sampling_date'] - ..samplingTime = logData['sampling_time'] - ..firstSamplerUserId = firstSamplerId - ..secondSampler = logData['secondSampler'] - ..classificationId = classificationId - ..currentLatitude = logData['current_latitude']?.toString() - ..currentLongitude = logData['current_longitude']?.toString() - ..distanceDifference = logData['distance_difference'] - ..optionalRemark1 = logData['optional_photo_remark_01'] - ..optionalRemark2 = logData['optional_photo_remark_02'] - ..optionalRemark3 = logData['optional_photo_remark_03'] - ..optionalRemark4 = logData['optional_photo_remark_04']; - - final Map imageFiles = {}; - final imageKeys = ['left_side_coastal_view', 'right_side_coastal_view', 'drawing_vertical_lines', 'drawing_horizontal_line', 'optional_photo_01', 'optional_photo_02', 'optional_photo_03', 'optional_photo_04']; - for (var key in imageKeys) { - final imagePath = logData[key]; - if (imagePath != null && imagePath.isNotEmpty) { - final file = File(imagePath); - if (await file.exists()) imageFiles[key] = file; - } - } - - final result = await _marineApiService.submitTarballSample(formData: dataToResubmit.toFormData(), imageFiles: imageFiles); - - logData['submissionStatus'] = result['status']; - logData['submissionMessage'] = result['message']; - logData['reportId'] = result['reportId']?.toString() ?? logData['reportId']; - await _localStorageService.updateTarballLog(logData); - - if (mounted) await _loadAllLogs(); - } - - /// Handles resubmission for In-Situ data. (Unchanged) - Future _resubmitInSituData(SubmissionLogEntry log) async { - final logData = log.rawData; - - // Reconstruct the InSituSamplingData object from the raw map - final InSituSamplingData dataToResubmit = InSituSamplingData() - ..firstSamplerUserId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? '') - ..secondSampler = logData['secondSampler'] - ..samplingDate = logData['sampling_date'] - ..samplingTime = logData['sampling_time'] - ..samplingType = logData['sampling_type'] - ..sampleIdCode = logData['sample_id_code'] - ..selectedStation = logData['selectedStation'] - ..currentLatitude = logData['current_latitude']?.toString() - ..currentLongitude = logData['current_longitude']?.toString() - ..distanceDifferenceInKm = double.tryParse(logData['distance_difference']?.toString() ?? '0.0') - ..weather = logData['weather'] - ..tideLevel = logData['tide_level'] - ..seaCondition = logData['sea_condition'] - ..eventRemarks = logData['event_remarks'] - ..labRemarks = logData['lab_remarks'] - ..optionalRemark1 = logData['optional_photo_remark_1'] - ..optionalRemark2 = logData['optional_photo_remark_2'] - ..optionalRemark3 = logData['optional_photo_remark_3'] - ..optionalRemark4 = logData['optional_photo_remark_4'] - ..sondeId = logData['sonde_id'] - ..dataCaptureDate = logData['data_capture_date'] - ..dataCaptureTime = logData['data_capture_time'] - ..oxygenConcentration = double.tryParse(logData['oxygen_concentration_mg_l']?.toString() ?? '0.0') - ..oxygenSaturation = double.tryParse(logData['oxygen_saturation_percent']?.toString() ?? '0.0') - ..ph = double.tryParse(logData['ph']?.toString() ?? '0.0') - ..salinity = double.tryParse(logData['salinity_ppt']?.toString() ?? '0.0') - ..electricalConductivity = double.tryParse(logData['ec_us_cm']?.toString() ?? '0.0') - ..temperature = double.tryParse(logData['temperature_c']?.toString() ?? '0.0') - ..tds = double.tryParse(logData['tds_mg_l']?.toString() ?? '0.0') - ..turbidity = double.tryParse(logData['turbidity_ntu']?.toString() ?? '0.0') - ..tss = double.tryParse(logData['tss_mg_l']?.toString() ?? '0.0') - ..batteryVoltage = double.tryParse(logData['battery_v']?.toString() ?? '0.0'); - - // Reconstruct image files - final Map imageFiles = {}; - // Use the keys from the model to ensure consistency - final imageKeys = dataToResubmit.toApiImageFiles().keys; - for (var key in imageKeys) { - final imagePath = logData[key]; - if (imagePath is String && imagePath.isNotEmpty) { - final file = File(imagePath); - if (await file.exists()) { - imageFiles[key] = file; + final Map imageFiles = {}; + final imageKeys = dataToResubmit.toApiImageFiles().keys; + for (var key in imageKeys) { + final imagePath = logData[key]; + if (imagePath is String && imagePath.isNotEmpty) { + final file = File(imagePath); + if (await file.exists()) { + imageFiles[key] = file; + } } } + + return _marineApiService.submitInSituSample( + formData: dataToResubmit.toApiFormData(), + imageFiles: imageFiles, + ); + } else if (log.type == 'Tarball Sampling') { + final int? firstSamplerId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? ''); + final int? classificationId = int.tryParse(logData['classification_id']?.toString() ?? ''); + + final TarballSamplingData dataToResubmit = TarballSamplingData() + ..selectedStation = logData['selectedStation'] + ..samplingDate = logData['sampling_date'] + ..samplingTime = logData['sampling_time'] + ..firstSamplerUserId = firstSamplerId + ..secondSampler = logData['secondSampler'] + ..classificationId = classificationId + ..currentLatitude = logData['current_latitude']?.toString() + ..currentLongitude = logData['current_longitude']?.toString() + ..distanceDifference = logData['distance_difference'] + ..optionalRemark1 = logData['optional_photo_remark_01'] + ..optionalRemark2 = logData['optional_photo_remark_02'] + ..optionalRemark3 = logData['optional_photo_remark_03'] + ..optionalRemark4 = logData['optional_photo_remark_04']; + + final Map imageFiles = {}; + final imageKeys = ['left_side_coastal_view', 'right_side_coastal_view', 'drawing_vertical_lines', 'drawing_horizontal_line', 'optional_photo_01', 'optional_photo_02', 'optional_photo_03', 'optional_photo_04']; + for (var key in imageKeys) { + final imagePath = logData[key]; + if (imagePath != null && imagePath.isNotEmpty) { + final file = File(imagePath); + if (await file.exists()) imageFiles[key] = file; + } + } + + return _marineApiService.submitTarballSample(formData: dataToResubmit.toFormData(), imageFiles: imageFiles); } - // Submit the data via the API service - final result = await _marineApiService.submitInSituSample( - formData: dataToResubmit.toApiFormData(), - imageFiles: imageFiles, - ); - - // Update the local log file with the new submission status - logData['submissionStatus'] = result['status']; - logData['submissionMessage'] = result['message']; - logData['reportId'] = result['reportId']?.toString() ?? logData['reportId']; - - // Use the correct update method for In-Situ logs - await _localStorageService.updateInSituLog(logData); - - // Reload logs to refresh the UI - if (mounted) await _loadAllLogs(); + throw Exception('Unknown submission type: ${log.type}'); } @override Widget build(BuildContext context) { + final hasAnyLogs = _manualLogs.isNotEmpty || _tarballLogs.isNotEmpty; + final hasFilteredLogs = _filteredManualLogs.isNotEmpty || _filteredTarballLogs.isNotEmpty; + + final logCategories = { + 'Manual Sampling': _filteredManualLogs, + 'Tarball Sampling': _filteredTarballLogs, + }; + return Scaffold( - appBar: AppBar(title: const Text('Marine Manual Data Status Log')), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - labelText: 'Search Logs...', - prefixIcon: const Icon(Icons.search), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12.0)), - ), - ), - ), - Expanded( - child: _isLoading - ? const Center(child: CircularProgressIndicator()) - : RefreshIndicator( - onRefresh: _loadAllLogs, - child: _filteredLogs.isEmpty - ? Center(child: Text(_groupedLogs.isEmpty ? 'No submission logs found.' : 'No logs match your search.')) - : ListView( - children: _filteredLogs.entries.map((entry) { - return _buildCategorySection(entry.key, entry.value); - }).toList(), - ), - ), - ), - ], + appBar: AppBar(title: const Text('Marine 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: [ + ...logCategories.entries + .where((entry) => entry.value.isNotEmpty) + .map((entry) => _buildCategorySection(entry.key, entry.value)), + if (!hasFilteredLogs && hasAnyLogs) + const Center( + child: Padding( + padding: EdgeInsets.all(24.0), + child: Text('No logs match your search.'), + ), + ) + ], + ), ), ); } Widget _buildCategorySection(String category, List logs) { - final bool isExpanded = _isCategoryExpanded[category] ?? false; - final int itemCount = isExpanded ? logs.length : (logs.length > 5 ? 5 : logs.length); + final listHeight = (logs.length > 5 ? 5.5 : logs.length.toDouble()) * 75.0; return Card( - margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + 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)), - const Divider(), - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: itemCount, - itemBuilder: (context, index) { - return _buildLogListItem(logs[index]); - }, - ), - if (logs.length > 5) - TextButton( - onPressed: () { - setState(() { - _isCategoryExpanded[category] = !isExpanded; - }); - }, - child: Text(isExpanded ? 'Show Less' : 'Show More (${logs.length - 5} more)'), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: TextField( + controller: _searchControllers[category], + decoration: InputDecoration( + hintText: 'Search in $category...', + prefixIcon: const Icon(Icons.search, size: 20), + isDense: true, + border: const OutlineInputBorder(), + ), ), + ), + const Divider(), + logs.isEmpty + ? const Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: Text('No logs match your search in this category.'))) + : ConstrainedBox( + constraints: BoxConstraints(maxHeight: listHeight), + child: ListView.builder( + shrinkWrap: true, + itemCount: logs.length, + itemBuilder: (context, index) { + return _buildLogListItem(logs[index]); + }, + ), + ), ], ), ), @@ -353,6 +355,8 @@ class _MarineManualDataStatusLogState extends State { Widget _buildLogListItem(SubmissionLogEntry log) { final isFailed = log.status != 'L3'; + final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); + final isResubmitting = _isResubmitting[logKey] ?? false; final title = '${log.title} (${log.stationCode})'; final subtitle = DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime); @@ -364,21 +368,13 @@ class _MarineManualDataStatusLogState extends State { title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Text(subtitle), trailing: isFailed - ? (log.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), - )) + ? (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.symmetric(horizontal: 16.0, vertical: 8.0), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/screens/river/manual/data_status_log.dart b/lib/screens/river/manual/data_status_log.dart index 821f2f3..34af0eb 100644 --- a/lib/screens/river/manual/data_status_log.dart +++ b/lib/screens/river/manual/data_status_log.dart @@ -55,18 +55,22 @@ class _RiverDataStatusLogState extends State { final Map _searchControllers = {}; bool _isLoading = true; + final Map _isResubmitting = {}; @override void initState() { super.initState(); + _searchControllers['Schedule'] = TextEditingController()..addListener(_filterLogs); + _searchControllers['Triennial'] = TextEditingController()..addListener(_filterLogs); + _searchControllers['Others'] = TextEditingController()..addListener(_filterLogs); _loadAllLogs(); } @override void dispose() { - for (var controller in _searchControllers.values) { - controller.dispose(); - } + _searchControllers['Schedule']?.dispose(); + _searchControllers['Triennial']?.dispose(); + _searchControllers['Others']?.dispose(); super.dispose(); } @@ -108,18 +112,6 @@ class _RiverDataStatusLogState extends State { tempTriennial.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); tempOthers.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime)); - // Initialize search controllers for categories that have data - final categories = {'Schedule': tempSchedule, 'Triennial': tempTriennial, 'Others': tempOthers}; - categories.forEach((key, value) { - if (value.isNotEmpty) { - _searchControllers.putIfAbsent(key, () { - final controller = TextEditingController(); - controller.addListener(() => _filterLogs()); - return controller; - }); - } - }); - if (mounted) { setState(() { _scheduleLogs = tempSchedule; @@ -151,31 +143,58 @@ class _RiverDataStatusLogState extends State { } Future _resubmitData(SubmissionLogEntry log) async { - setState(() => log.isResubmitting = true); - final logData = log.rawData; - final dataToResubmit = RiverInSituSamplingData.fromJson(logData); - final Map imageFiles = {}; - - final imageApiKeys = dataToResubmit.toApiImageFiles().keys; - for (var key in imageApiKeys) { - final imagePath = logData[key]; - if (imagePath is String && imagePath.isNotEmpty) { - final file = File(imagePath); - if (await file.exists()) { - imageFiles[key] = file; - } - } + final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); + if (mounted) { + setState(() { + _isResubmitting[logKey] = true; + }); } - final result = await _riverApiService.submitInSituSample( - formData: dataToResubmit.toApiFormData(), - imageFiles: imageFiles, - ); - logData['submissionStatus'] = result['status']; - logData['submissionMessage'] = result['message']; - logData['reportId'] = result['reportId']?.toString() ?? logData['reportId']; - await _localStorageService.updateRiverInSituLog(logData); - if (mounted) await _loadAllLogs(); + try { + final logData = log.rawData; + final dataToResubmit = RiverInSituSamplingData.fromJson(logData); + final Map imageFiles = {}; + + final imageApiKeys = dataToResubmit.toApiImageFiles().keys; + for (var key in imageApiKeys) { + final imagePath = logData[key]; + if (imagePath is String && imagePath.isNotEmpty) { + final file = File(imagePath); + if (await file.exists()) { + imageFiles[key] = file; + } + } + } + + final result = await _riverApiService.submitInSituSample( + formData: dataToResubmit.toApiFormData(), + imageFiles: imageFiles, + ); + + logData['submissionStatus'] = result['status']; + logData['submissionMessage'] = result['message']; + logData['reportId'] = result['reportId']?.toString() ?? logData['reportId']; + await _localStorageService.updateRiverInSituLog(logData); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Resubmission successful!')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Resubmission failed: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isResubmitting.remove(logKey); + }); + await _loadAllLogs(); + } + } } @override @@ -183,6 +202,12 @@ class _RiverDataStatusLogState extends State { final hasAnyLogs = _scheduleLogs.isNotEmpty || _triennialLogs.isNotEmpty || _otherLogs.isNotEmpty; final hasFilteredLogs = _filteredScheduleLogs.isNotEmpty || _filteredTriennialLogs.isNotEmpty || _filteredOtherLogs.isNotEmpty; + final logCategories = { + 'Schedule': _filteredScheduleLogs, + 'Triennial': _filteredTriennialLogs, + 'Others': _filteredOtherLogs, + }; + return Scaffold( appBar: AppBar(title: const Text('River Data Status Log')), body: _isLoading @@ -194,13 +219,9 @@ class _RiverDataStatusLogState extends State { : ListView( padding: const EdgeInsets.all(8.0), children: [ - // No global search bar here - if (_scheduleLogs.isNotEmpty) - _buildCategorySection('Schedule', _filteredScheduleLogs), - if (_triennialLogs.isNotEmpty) - _buildCategorySection('Triennial', _filteredTriennialLogs), - if (_otherLogs.isNotEmpty) - _buildCategorySection('Others', _filteredOtherLogs), + ...logCategories.entries + .where((entry) => entry.value.isNotEmpty) + .map((entry) => _buildCategorySection(entry.key, entry.value)), if (!hasFilteredLogs && hasAnyLogs) const Center( child: Padding( @@ -215,8 +236,6 @@ class _RiverDataStatusLogState extends State { } Widget _buildCategorySection(String category, List logs) { - // Calculate the height for the scrollable list. - // Each item is approx 75px high. Limit to 5 items height. final listHeight = (logs.length > 5 ? 5.5 : logs.length.toDouble()) * 75.0; return Card( @@ -262,6 +281,8 @@ class _RiverDataStatusLogState extends State { Widget _buildLogListItem(SubmissionLogEntry log) { final isFailed = log.status != 'L3'; + final logKey = log.reportId ?? log.submissionDateTime.toIso8601String(); + final isResubmitting = _isResubmitting[logKey] ?? false; final title = '${log.title} (${log.stationCode})'; final subtitle = DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime); @@ -273,7 +294,7 @@ class _RiverDataStatusLogState extends State { title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Text(subtitle), trailing: isFailed - ? (log.isResubmitting + ? (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, diff --git a/lib/services/river_api_service.dart b/lib/services/river_api_service.dart index 2c87e1e..59fd7f3 100644 --- a/lib/services/river_api_service.dart +++ b/lib/services/river_api_service.dart @@ -25,7 +25,8 @@ class RiverApiService { required Map formData, required Map imageFiles, }) async { - // --- Step 1: Submit Form Data --- + // --- Step 1: Submit Form Data as JSON --- + // The PHP backend for submitInSituSample expects JSON input. final dataResult = await _baseService.post('river/manual/sample', formData); if (dataResult['success'] != true) { @@ -64,8 +65,8 @@ class RiverApiService { } final imageResult = await _baseService.postMultipart( - endpoint: 'river/manual/images', - fields: {'r_man_id': recordId.toString()}, + endpoint: 'river/manual/images', // Separate endpoint for images + fields: {'r_man_id': recordId.toString()}, // Link images to the submitted record ID files: filesToUpload, ); @@ -111,7 +112,6 @@ class RiverApiService { ..writeln('*Status of Submission:* Successful'); if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) { - // CORRECTED: The cascade operator '..' was missing on the next line. buffer ..writeln() ..writeln('🔔 *Alert:*')