fix air manual data status log

This commit is contained in:
ALim Aidrus 2025-08-14 16:18:56 +08:00
parent f7fefd3f45
commit a2d8b372e6
6 changed files with 736 additions and 316 deletions

View File

@ -1,4 +1,3 @@
// lib/models/air_collection_data.dart
import 'dart:io';
class AirCollectionData {
@ -189,7 +188,6 @@ class AirCollectionData {
Map<String, dynamic> 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,

View File

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

View File

@ -1,32 +1,429 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class AirManualDataStatusLog extends StatelessWidget {
final List<Map<String, String>> 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<String, dynamic> 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<AirManualDataStatusLog> createState() => _AirManualDataStatusLogState();
}
class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
final LocalStorageService _localStorageService = LocalStorageService();
final ApiService _apiService = ApiService();
List<SubmissionLogEntry> _installationLogs = [];
List<SubmissionLogEntry> _collectionLogs = [];
List<SubmissionLogEntry> _filteredInstallationLogs = [];
List<SubmissionLogEntry> _filteredCollectionLogs = [];
final Map<String, TextEditingController> _searchControllers = {};
bool _isLoading = true;
final Map<String, bool> _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<void> _loadAllLogs() async {
setState(() => _isLoading = true);
final airLogs = await _localStorageService.getAllAirSamplingLogs();
final List<SubmissionLogEntry> tempInstallation = [];
final List<SubmissionLogEntry> 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<String, dynamic> 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<String, dynamic>? 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<String, dynamic> 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<void> _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<Map<String, dynamic>> _submitInstallation(Map<String, dynamic> 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<Map<String, dynamic>> _submitCollection(Map<String, dynamic> 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<SubmissionLogEntry> 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)),
],
),
);
}
}

View File

@ -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<MarineManualDataStatusLog> {
final LocalStorageService _localStorageService = LocalStorageService();
final MarineApiService _marineApiService = MarineApiService();
Map<String, List<SubmissionLogEntry>> _groupedLogs = {};
Map<String, List<SubmissionLogEntry>> _filteredLogs = {};
final Map<String, bool> _isCategoryExpanded = {};
// Raw data lists
List<SubmissionLogEntry> _manualLogs = [];
List<SubmissionLogEntry> _tarballLogs = [];
// Filtered lists for the UI
List<SubmissionLogEntry> _filteredManualLogs = [];
List<SubmissionLogEntry> _filteredTarballLogs = [];
// Per-category search controllers
final Map<String, TextEditingController> _searchControllers = {};
bool _isLoading = true;
final TextEditingController _searchController = TextEditingController();
final Map<String, bool> _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<void> _loadAllLogs() async {
setState(() => _isLoading = true);
// --- Fetch logs for all types ---
final tarballLogs = await _localStorageService.getAllTarballLogs();
final inSituLogs = await _localStorageService.getAllInSituLogs();
final Map<String, List<SubmissionLogEntry>> tempGroupedLogs = {};
final List<SubmissionLogEntry> tempManual = [];
final List<SubmissionLogEntry> tempTarball = [];
// Map tarball logs (Unchanged)
final List<SubmissionLogEntry> 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,133 +109,84 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
rawData: log,
));
}
if (tarballEntries.isNotEmpty) {
tarballEntries.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime));
tempGroupedLogs['Tarball Sampling'] = tarballEntries;
}
// --- Map In-Situ logs ---
final List<SubmissionLogEntry> 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<String, List<SubmissionLogEntry>> tempFiltered = {};
final manualQuery = _searchControllers['Manual Sampling']?.text.toLowerCase() ?? '';
final tarballQuery = _searchControllers['Tarball Sampling']?.text.toLowerCase() ?? '';
_groupedLogs.forEach((category, logs) {
final filtered = logs.where((log) {
setState(() {
_filteredManualLogs = _manualLogs.where((log) => _logMatchesQuery(log, manualQuery)).toList();
_filteredTarballLogs = _tarballLogs.where((log) => _logMatchesQuery(log, tarballQuery)).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);
}).toList();
if (filtered.isNotEmpty) {
tempFiltered[category] = filtered;
}
});
setState(() {
_filteredLogs = tempFiltered;
});
}
/// Main router for resubmitting data based on its type.
Future<void> _resubmitData(SubmissionLogEntry log) async {
setState(() => log.isResubmitting = true);
switch (log.type) {
case 'Tarball Sampling':
await _resubmitTarballData(log);
break;
case 'In-Situ Sampling':
await _resubmitInSituData(log);
break;
default:
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Resubmission for '${log.type}' is not implemented."), backgroundColor: Colors.orange),
);
setState(() => log.isResubmitting = false);
}
}
setState(() {
_isResubmitting[logKey] = true;
});
}
/// Handles resubmission for Tarball data. (Unchanged)
Future<void> _resubmitTarballData(SubmissionLogEntry log) async {
try {
final result = await _performResubmission(log);
final logData = log.rawData;
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<String, File?> 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();
if (log.type == 'Manual Sampling') {
await _localStorageService.updateInSituLog(logData);
} else if (log.type == 'Tarball Sampling') {
await _localStorageService.updateTarballLog(logData);
}
/// Handles resubmission for In-Situ data. (Unchanged)
Future<void> _resubmitInSituData(SubmissionLogEntry log) async {
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<Map<String, dynamic>> _performResubmission(SubmissionLogEntry log) async {
final logData = log.rawData;
// Reconstruct the InSituSamplingData object from the raw map
if (log.type == 'Manual Sampling') {
final InSituSamplingData dataToResubmit = InSituSamplingData()
..firstSamplerUserId = int.tryParse(logData['first_sampler_user_id']?.toString() ?? '')
..secondSampler = logData['secondSampler']
@ -247,9 +221,7 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
..tss = double.tryParse(logData['tss_mg_l']?.toString() ?? '0.0')
..batteryVoltage = double.tryParse(logData['battery_v']?.toString() ?? '0.0');
// Reconstruct image files
final Map<String, File?> imageFiles = {};
// Use the keys from the model to ensure consistency
final imageKeys = dataToResubmit.toApiImageFiles().keys;
for (var key in imageKeys) {
final imagePath = logData[key];
@ -261,89 +233,119 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
}
}
// Submit the data via the API service
final result = await _marineApiService.submitInSituSample(
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() ?? '');
// 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'];
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'];
// Use the correct update method for In-Situ logs
await _localStorageService.updateInSituLog(logData);
final Map<String, File?> 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;
}
}
// Reload logs to refresh the UI
if (mounted) await _loadAllLogs();
return _marineApiService.submitTarballSample(formData: dataToResubmit.toFormData(), imageFiles: imageFiles);
}
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
appBar: AppBar(title: const Text('Marine Data Status Log')),
body: _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.'))
child: !hasAnyLogs
? const Center(child: Text('No submission logs found.'))
: ListView(
children: _filteredLogs.entries.map((entry) {
return _buildCategorySection(entry.key, entry.value);
}).toList(),
),
),
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<SubmissionLogEntry> 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)),
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(),
ListView.builder(
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,
physics: const NeverScrollableScrollPhysics(),
itemCount: itemCount,
itemCount: logs.length,
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)'),
),
],
),
@ -353,6 +355,8 @@ class _MarineManualDataStatusLogState extends State<MarineManualDataStatusLog> {
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<MarineManualDataStatusLog> {
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: [

View File

@ -55,18 +55,22 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
final Map<String, TextEditingController> _searchControllers = {};
bool _isLoading = true;
final Map<String, bool> _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<RiverDataStatusLog> {
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,7 +143,14 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
}
Future<void> _resubmitData(SubmissionLogEntry log) async {
setState(() => log.isResubmitting = true);
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
if (mounted) {
setState(() {
_isResubmitting[logKey] = true;
});
}
try {
final logData = log.rawData;
final dataToResubmit = RiverInSituSamplingData.fromJson(logData);
final Map<String, File?> imageFiles = {};
@ -171,11 +170,31 @@ class _RiverDataStatusLogState extends State<RiverDataStatusLog> {
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();
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<RiverDataStatusLog> {
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<RiverDataStatusLog> {
: 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<RiverDataStatusLog> {
}
Widget _buildCategorySection(String category, List<SubmissionLogEntry> 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<RiverDataStatusLog> {
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<RiverDataStatusLog> {
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,

View File

@ -25,7 +25,8 @@ class RiverApiService {
required Map<String, String> formData,
required Map<String, File?> 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:*')