From 1b1b869a1d7c39a95b9365a0958b195d79a261ab Mon Sep 17 00:00:00 2001 From: ALim Aidrus Date: Wed, 13 Aug 2025 11:17:05 +0800 Subject: [PATCH] fix air manual module for collection screen --- lib/models/air_collection_data.dart | 138 ++++++++++++++++-- lib/models/air_installation_data.dart | 46 +++--- .../manual/air_manual_collection_screen.dart | 20 ++- .../manual/widgets/air_manual_collection.dart | 121 ++++++++++++++- lib/services/air_sampling_service.dart | 108 ++++++++++---- lib/services/api_service.dart | 17 ++- lib/services/local_storage_service.dart | 72 ++++++--- 7 files changed, 428 insertions(+), 94 deletions(-) diff --git a/lib/models/air_collection_data.dart b/lib/models/air_collection_data.dart index 563100b..0f9a693 100644 --- a/lib/models/air_collection_data.dart +++ b/lib/models/air_collection_data.dart @@ -1,4 +1,5 @@ // lib/models/air_collection_data.dart +import 'dart:io'; class AirCollectionData { // Link to the original installation @@ -35,6 +36,22 @@ class AirCollectionData { int? collectionUserId; String? status; + // Image Files for Collection + File? imageFront; + File? imageBack; + File? imageLeft; + File? imageRight; + File? imageChart; + File? imageFilterPaper; + File? optionalImage1; + File? optionalImage2; + File? optionalImage3; + File? optionalImage4; + String? optionalRemark1; + String? optionalRemark2; + String? optionalRemark3; + String? optionalRemark4; + AirCollectionData({ this.installationRefID, this.airManId, @@ -60,9 +77,73 @@ class AirCollectionData { this.remarks, this.collectionUserId, this.status, + this.imageFront, + this.imageBack, + this.imageLeft, + this.imageRight, + this.imageChart, + this.imageFilterPaper, + this.optionalImage1, + this.optionalImage2, + this.optionalImage3, + this.optionalImage4, + this.optionalRemark1, + this.optionalRemark2, + this.optionalRemark3, + this.optionalRemark4, }); - /// Creates a map for saving all data to local storage. + factory AirCollectionData.fromMap(Map map) { + File? fileFromPath(String? path) => path != null ? File(path) : null; + + double? parseDouble(dynamic value) { + if (value is double) return value; + if (value is int) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; + } + + return AirCollectionData( + installationRefID: map['installationRefID'], + airManId: map['air_man_id'], + collectionDate: map['air_man_collection_date'], + collectionTime: map['air_man_collection_time'], + weather: map['air_man_collection_weather'], + temperature: parseDouble(map['air_man_collection_temperature']), + powerFailure: map['air_man_collection_power_failure'], + pm10Flowrate: parseDouble(map['air_man_collection_pm10_flowrate']), + pm10FlowrateResult: map['air_man_collection_pm10_flowrate_result'], + pm10TotalTime: map['air_man_collection_pm10_total_time'], + pm10TotalTimeResult: map['air_man_collection_total_time_result'], + pm10Pressure: parseDouble(map['air_man_collection_pm10_pressure']), + pm10PressureResult: map['air_man_collection_pm10_pressure_result'], + pm10Vstd: parseDouble(map['air_man_collection_pm10_vstd']), + pm25Flowrate: parseDouble(map['air_man_collection_pm25_flowrate']), + pm25FlowrateResult: map['air_man_collection_pm25_flowrate_result'], + pm25TotalTime: map['air_man_collection_pm25_total_time'], + pm25TotalTimeResult: map['air_man_collection_pm25_total_time_result'], + pm25Pressure: parseDouble(map['air_man_collection_pm25_pressure']), + pm25PressureResult: map['air_man_collection_pm25_pressure_result'], + pm25Vstd: parseDouble(map['air_man_collection_pm25_vstd']), + remarks: map['air_man_collection_remarks'], + status: map['status'], + optionalRemark1: map['optionalRemark1'], + optionalRemark2: map['optionalRemark2'], + optionalRemark3: map['optionalRemark3'], + optionalRemark4: map['optionalRemark4'], + imageFront: fileFromPath(map['imageFrontPath']), + imageBack: fileFromPath(map['imageBackPath']), + imageLeft: fileFromPath(map['imageLeftPath']), + imageRight: fileFromPath(map['imageRightPath']), + imageChart: fileFromPath(map['imageChartPath']), + imageFilterPaper: fileFromPath(map['imageFilterPaperPath']), + optionalImage1: fileFromPath(map['optionalImage1Path']), + optionalImage2: fileFromPath(map['optionalImage2Path']), + optionalImage3: fileFromPath(map['optionalImage3Path']), + optionalImage4: fileFromPath(map['optionalImage4Path']), + ); + } + Map toMap() { return { 'installationRefID': installationRefID, @@ -88,34 +169,67 @@ class AirCollectionData { 'air_man_collection_pm25_vstd': pm25Vstd, 'air_man_collection_remarks': remarks, 'status': status, + 'optionalRemark1': optionalRemark1, + 'optionalRemark2': optionalRemark2, + 'optionalRemark3': optionalRemark3, + 'optionalRemark4': optionalRemark4, + 'imageFront': imageFront, + 'imageBack': imageBack, + 'imageLeft': imageLeft, + 'imageRight': imageRight, + 'imageChart': imageChart, + 'imageFilterPaper': imageFilterPaper, + 'optionalImage1': optionalImage1, + 'optionalImage2': optionalImage2, + 'optionalImage3': optionalImage3, + 'optionalImage4': optionalImage4, }; } - /// Creates a JSON map with keys that match the backend API. Map toJson() { - // This method is now designed to match the API structure perfectly. return { - 'air_man_id': airManId, + '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, 'air_man_collection_weather': weather, - 'air_man_collection_temperature': temperature, + 'air_man_collection_temperature': temperature?.toString(), 'air_man_collection_power_failure': powerFailure, - 'air_man_collection_pm10_flowrate': pm10Flowrate, + 'air_man_collection_pm10_flowrate': pm10Flowrate?.toString(), 'air_man_collection_pm10_flowrate_result': pm10FlowrateResult, 'air_man_collection_pm10_total_time': pm10TotalTime, 'air_man_collection_total_time_result': pm10TotalTimeResult, - 'air_man_collection_pm10_pressure': pm10Pressure, + 'air_man_collection_pm10_pressure': pm10Pressure?.toString(), 'air_man_collection_pm10_pressure_result': pm10PressureResult, - 'air_man_collection_pm10_vstd': pm10Vstd, - 'air_man_collection_pm25_flowrate': pm25Flowrate, + 'air_man_collection_pm10_vstd': pm10Vstd?.toString(), + 'air_man_collection_pm25_flowrate': pm25Flowrate?.toString(), 'air_man_collection_pm25_flowrate_result': pm25FlowrateResult, 'air_man_collection_pm25_total_time': pm25TotalTime, 'air_man_collection_pm25_total_time_result': pm25TotalTimeResult, - 'air_man_collection_pm25_pressure': pm25Pressure, + 'air_man_collection_pm25_pressure': pm25Pressure?.toString(), 'air_man_collection_pm25_pressure_result': pm25PressureResult, - 'air_man_collection_pm25_vstd': pm25Vstd, + 'air_man_collection_pm25_vstd': pm25Vstd?.toString(), 'air_man_collection_remarks': remarks, + 'air_man_collection_image_optional_01_remarks': optionalRemark1, + 'air_man_collection_image_optional_02_remarks': optionalRemark2, + 'air_man_collection_image_optional_03_remarks': optionalRemark3, + 'air_man_collection_image_optional_04_remarks': optionalRemark4, }; } -} + + Map getImagesForUpload() { + final Map files = {}; + if (imageFront != null) files['front'] = imageFront!; + if (imageBack != null) files['back'] = imageBack!; + if (imageLeft != null) files['left'] = imageLeft!; + if (imageRight != null) files['right'] = imageRight!; + if (imageChart != null) files['chart'] = imageChart!; + if (imageFilterPaper != null) files['filter_paper'] = imageFilterPaper!; + if (optionalImage1 != null) files['optional_01'] = optionalImage1!; + if (optionalImage2 != null) files['optional_02'] = optionalImage2!; + if (optionalImage3 != null) files['optional_03'] = optionalImage3!; + if (optionalImage4 != null) files['optional_04'] = optionalImage4!; + return files; + } +} \ No newline at end of file diff --git a/lib/models/air_installation_data.dart b/lib/models/air_installation_data.dart index fc134e4..efcce34 100644 --- a/lib/models/air_installation_data.dart +++ b/lib/models/air_installation_data.dart @@ -1,5 +1,7 @@ // lib/models/air_installation_data.dart + import 'dart:io'; +import 'air_collection_data.dart'; // Import the collection data model class AirInstallationData { String? refID; @@ -45,6 +47,9 @@ class AirInstallationData { String? status; + // NECESSARY ADDITION: For handling nested collection data during offline saves. + AirCollectionData? collectionData; + AirInstallationData({ this.refID, this.airManId, @@ -84,18 +89,13 @@ class AirInstallationData { this.optionalImage3Path, this.optionalImage4Path, this.status, + this.collectionData, // NECESSARY ADDITION: Add to constructor }); + // **CRITICAL FIX**: This method now correctly includes the raw File objects + // instead of just their paths. This allows the local storage service to find + // and copy the installation images. Map toMap() { - imageFrontPath = imageFront?.path; - imageBackPath = imageBack?.path; - imageLeftPath = imageLeft?.path; - imageRightPath = imageRight?.path; - optionalImage1Path = optionalImage1?.path; - optionalImage2Path = optionalImage2?.path; - optionalImage3Path = optionalImage3?.path; - optionalImage4Path = optionalImage4?.path; - return { 'refID': refID, 'air_man_id': airManId, @@ -115,18 +115,21 @@ class AirInstallationData { 'remark': remark, 'installationUserId': installationUserId, 'status': status, - 'imageFrontPath': imageFrontPath, - 'imageBackPath': imageBackPath, - 'imageLeftPath': imageLeftPath, - 'imageRightPath': imageRightPath, - 'optionalImage1Path': optionalImage1Path, - 'optionalImage2Path': optionalImage2Path, - 'optionalImage3Path': optionalImage3Path, - 'optionalImage4Path': optionalImage4Path, 'optionalRemark1': optionalRemark1, 'optionalRemark2': optionalRemark2, 'optionalRemark3': optionalRemark3, 'optionalRemark4': optionalRemark4, + // Pass the actual File objects so they can be copied + 'imageFront': imageFront, + 'imageBack': imageBack, + 'imageLeft': imageLeft, + 'imageRight': imageRight, + 'optionalImage1': optionalImage1, + 'optionalImage2': optionalImage2, + 'optionalImage3': optionalImage3, + 'optionalImage4': optionalImage4, + // Pass the nested collection data if it exists + 'collectionData': collectionData?.toMap(), }; } @@ -152,6 +155,7 @@ class AirInstallationData { remark: json['remark'], installationUserId: json['installationUserId'], status: json['status'], + // When deserializing, we use the '...Path' keys created by the local storage service imageFront: fileFromPath(json['imageFrontPath']), imageBack: fileFromPath(json['imageBackPath']), imageLeft: fileFromPath(json['imageLeftPath']), @@ -164,6 +168,10 @@ class AirInstallationData { optionalRemark2: json['optionalRemark2'], optionalRemark3: json['optionalRemark3'], optionalRemark4: json['optionalRemark4'], + // NECESSARY ADDITION: Deserialize nested collection data + collectionData: json['collectionData'] != null && json['collectionData'] is Map + ? AirCollectionData.fromMap(json['collectionData']) + : null, ); } @@ -171,7 +179,7 @@ class AirInstallationData { return { 'air_man_station_code': stationID, 'air_man_sampling_date': samplingDate, - 'air_man_client_id': clientId, + 'air_man_client_id': clientId?.toString(), // Ensure client ID is a string for API 'air_man_installation_date': installationDate, 'air_man_installation_time': installationTime, 'air_man_installation_weather': weather, @@ -199,4 +207,4 @@ class AirInstallationData { if (optionalImage4 != null) files['optional_04'] = optionalImage4!; return files; } -} +} \ No newline at end of file diff --git a/lib/screens/air/manual/air_manual_collection_screen.dart b/lib/screens/air/manual/air_manual_collection_screen.dart index 878be06..2c7a07e 100644 --- a/lib/screens/air/manual/air_manual_collection_screen.dart +++ b/lib/screens/air/manual/air_manual_collection_screen.dart @@ -27,6 +27,7 @@ class _AirManualCollectionScreenState extends State { @override void initState() { super.initState(); + // Fetch the list of pending installations when the screen loads _pendingInstallationsFuture = Provider.of(context, listen: false) .getPendingInstallations(); @@ -90,8 +91,9 @@ class _AirManualCollectionScreenState extends State { padding: const EdgeInsets.all(16.0), child: DropdownSearch( items: pendingInstallations, + // **THE FIX**: Changed u.samplingDate to u.installationDate itemAsString: (AirInstallationData u) => - "${u.stationID} - ${u.locationName} (${u.samplingDate})", + "${u.stationID} - ${u.locationName} (${u.installationDate} ${u.installationTime ?? ''})", popupProps: const PopupProps.menu( showSearchBox: true, searchFieldProps: TextFieldProps( @@ -115,7 +117,8 @@ class _AirManualCollectionScreenState extends State { _collectionData = AirCollectionData( installationRefID: data.refID, airManId: data.airManId, // Pass the server ID - collectionUserId: authProvider.profileData?['user_id'], + collectionUserId: + authProvider.profileData?['user_id'], ); } else { _collectionData = AirCollectionData(); @@ -129,14 +132,19 @@ class _AirManualCollectionScreenState extends State { Expanded( child: _selectedInstallation != null ? AirManualCollectionWidget( - key: ValueKey(_selectedInstallation!.refID), + key: ValueKey(_selectedInstallation! + .refID), // Ensures widget rebuilds + stationCode: _selectedInstallation!.stationID!, data: _collectionData, - initialTemp: double.tryParse(_selectedInstallation!.temp ?? '0.0') ?? 0.0, + initialTemp: double.tryParse( + _selectedInstallation!.temp ?? '0.0') ?? + 0.0, onSubmit: _submitCollection, isLoading: _isLoading, ) : const Center( - child: Text('Please select an installation to proceed.')), + child: + Text('Please select an installation to proceed.')), ), ], ); @@ -144,4 +152,4 @@ class _AirManualCollectionScreenState extends State { ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/air/manual/widgets/air_manual_collection.dart b/lib/screens/air/manual/widgets/air_manual_collection.dart index ad2656e..b1da607 100644 --- a/lib/screens/air/manual/widgets/air_manual_collection.dart +++ b/lib/screens/air/manual/widgets/air_manual_collection.dart @@ -1,10 +1,15 @@ // lib/screens/air/manual/widgets/air_manual_collection.dart +import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; import '../../../../models/air_collection_data.dart'; +import '../../../../services/air_sampling_service.dart'; class AirManualCollectionWidget extends StatefulWidget { + final String stationCode; final AirCollectionData data; final double initialTemp; final Future Function() onSubmit; @@ -12,6 +17,7 @@ class AirManualCollectionWidget extends StatefulWidget { const AirManualCollectionWidget({ super.key, + required this.stationCode, required this.data, required this.initialTemp, required this.onSubmit, @@ -31,6 +37,10 @@ class _AirManualCollectionWidgetState extends State { final _collectionTimeController = TextEditingController(); final _finalTempController = TextEditingController(); final _remarkController = TextEditingController(); + final _optionalRemark1Controller = TextEditingController(); + final _optionalRemark2Controller = TextEditingController(); + final _optionalRemark3Controller = TextEditingController(); + final _optionalRemark4Controller = TextEditingController(); // PM10 Controllers final _pm10FlowRateController = TextEditingController(); @@ -63,10 +73,15 @@ class _AirManualCollectionWidgetState extends State { @override void dispose() { + // Dispose all controllers _collectionDateController.dispose(); _collectionTimeController.dispose(); _finalTempController.dispose(); _remarkController.dispose(); + _optionalRemark1Controller.dispose(); + _optionalRemark2Controller.dispose(); + _optionalRemark3Controller.dispose(); + _optionalRemark4Controller.dispose(); _pm10FlowRateController.dispose(); _pm10FlowRateResultController.dispose(); _pm10TimeController.dispose(); @@ -106,6 +121,35 @@ class _AirManualCollectionWidgetState extends State { } } + Future _pickImage(ImageSource source, String imageInfo) async { + final service = Provider.of(context, listen: false); + final stationCode = widget.stationCode; + + final File? imageFile = await service.pickAndProcessImage( + source, + stationCode: stationCode, + imageInfo: imageInfo.toUpperCase(), + processType: 'COLLECT', + ); + + if (imageFile != null) { + setState(() { + switch (imageInfo) { + case 'front': widget.data.imageFront = imageFile; break; + case 'back': widget.data.imageBack = imageFile; break; + case 'left': widget.data.imageLeft = imageFile; break; + case 'right': widget.data.imageRight = imageFile; break; + case 'chart': widget.data.imageChart = imageFile; break; + case 'filter_paper': widget.data.imageFilterPaper = imageFile; break; + case 'optional_01': widget.data.optionalImage1 = imageFile; break; + case 'optional_02': widget.data.optionalImage2 = imageFile; break; + case 'optional_03': widget.data.optionalImage3 = imageFile; break; + case 'optional_04': widget.data.optionalImage4 = imageFile; break; + } + }); + } + } + void _calculateVstd(int type) { setState(() { try { @@ -148,9 +192,27 @@ class _AirManualCollectionWidgetState extends State { }); } + // **CRITICAL FIX IS HERE** void _onSubmitPressed() { if (_formKey.currentState!.validate()) { - _formKey.currentState!.save(); + _formKey.currentState!.save(); // Saves data from fields with an onSaved function + + // **THE FIX**: Manually save the values from the read-only result controllers + // into the data model before submitting, as they don't have onSaved functions. + widget.data.pm10FlowrateResult = _pm10FlowRateResultController.text; + widget.data.pm10TotalTimeResult = _pm10TimeResultController.text; + widget.data.pm10PressureResult = _pm10PressureResultController.text; + widget.data.pm25FlowrateResult = _pm25FlowRateResultController.text; + widget.data.pm25TotalTimeResult = _pm25TimeResultController.text; + widget.data.pm25PressureResult = _pm25PressureResultController.text; + + // Save optional remarks + widget.data.optionalRemark1 = _optionalRemark1Controller.text; + widget.data.optionalRemark2 = _optionalRemark2Controller.text; + widget.data.optionalRemark3 = _optionalRemark3Controller.text; + widget.data.optionalRemark4 = _optionalRemark4Controller.text; + + // Now, call the submission function with the fully populated data model widget.onSubmit(); } } @@ -170,7 +232,6 @@ class _AirManualCollectionWidgetState extends State { ), const Divider(height: 30), - // Collection specific fields TextFormField(controller: _collectionDateController, readOnly: true, decoration: const InputDecoration(labelText: 'Collection Date', border: OutlineInputBorder(), suffixIcon: Icon(Icons.calendar_today)), onTap: () => _selectDate(context, _collectionDateController), onSaved: (v) => widget.data.collectionDate = v, validator: (v) => v!.isEmpty ? 'Date is required' : null), const SizedBox(height: 16), TextFormField(controller: _collectionTimeController, readOnly: true, decoration: const InputDecoration(labelText: 'Collection Time', border: OutlineInputBorder(), suffixIcon: Icon(Icons.access_time)), onTap: () => _selectTime(context, _collectionTimeController), onSaved: (v) => widget.data.collectionTime = v, validator: (v) => v!.isEmpty ? 'Time is required' : null), @@ -201,14 +262,12 @@ class _AirManualCollectionWidgetState extends State { onSaved: (v) => widget.data.powerFailure = v, ), - // PM10 Section const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Text("PM10 Data", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16))), _buildResultRow(_pm10FlowRateController, "Flow Rate (cfm)", _pm10FlowRateResultController, "Result (m³/min)", (v) => setState(() => _pm10FlowRateResultController.text = ((double.tryParse(v) ?? 0) * 0.0283).toStringAsFixed(4)), (v) => widget.data.pm10Flowrate = double.tryParse(v!)), _buildResultRow(_pm10TimeController, "Total Time (hours)", _pm10TimeResultController, "Result (mins)", (v) => setState(() => _pm10TimeResultController.text = ((double.tryParse(v) ?? 0) * 60).toStringAsFixed(2)), (v) => widget.data.pm10TotalTime = v), _buildResultRow(_pm10PressureController, "Pressure (hPa)", _pm10PressureResultController, "Result (inHg)", (v) => _calculateVstd(1), (v) => widget.data.pm10Pressure = double.tryParse(v!)), TextFormField(controller: _pm10VstdController, readOnly: true, decoration: const InputDecoration(labelText: 'VSTD PM10 (m³)', border: OutlineInputBorder(), filled: true, fillColor: Colors.black12)), - // PM2.5 Section const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Text("PM2.5 Data", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16))), _buildResultRow(_pm25FlowRateController, "Flow Rate (cfm)", _pm25FlowRateResultController, "Result (m³/min)", (v) => setState(() => _pm25FlowRateResultController.text = ((double.tryParse(v) ?? 0) * 0.0283).toStringAsFixed(4)), (v) => widget.data.pm25Flowrate = double.tryParse(v!)), _buildResultRow(_pm25TimeController, "Total Time (hours)", _pm25TimeResultController, "Result (mins)", (v) => setState(() => _pm25TimeResultController.text = ((double.tryParse(v) ?? 0) * 60).toStringAsFixed(2)), (v) => widget.data.pm25TotalTime = v), @@ -223,6 +282,22 @@ class _AirManualCollectionWidgetState extends State { onSaved: (v) => widget.data.remarks = v, ), + const SizedBox(height: 24), + const Text("Required Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + _buildImagePicker('Site Picture (Front)', 'front', widget.data.imageFront), + _buildImagePicker('Site Picture (Back)', 'back', widget.data.imageBack), + _buildImagePicker('Site Picture (Left)', 'left', widget.data.imageLeft), + _buildImagePicker('Site Picture (Right)', 'right', widget.data.imageRight), + _buildImagePicker('Chart', 'chart', widget.data.imageChart), + _buildImagePicker('Filter Paper', 'filter_paper', widget.data.imageFilterPaper), + + const SizedBox(height: 24), + const Text("Optional Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + _buildImagePicker('Optional Photo 1', 'optional_01', widget.data.optionalImage1, remarkController: _optionalRemark1Controller), + _buildImagePicker('Optional Photo 2', 'optional_02', widget.data.optionalImage2, remarkController: _optionalRemark2Controller), + _buildImagePicker('Optional Photo 3', 'optional_03', widget.data.optionalImage3, remarkController: _optionalRemark3Controller), + _buildImagePicker('Optional Photo 4', 'optional_04', widget.data.optionalImage4, remarkController: _optionalRemark4Controller), + const SizedBox(height: 30), SizedBox( width: double.infinity, @@ -243,6 +318,42 @@ class _AirManualCollectionWidgetState extends State { ); } + Widget _buildImagePicker(String title, String imageInfo, File? imageFile, {TextEditingController? remarkController}) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 8), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Container( + height: 150, + width: double.infinity, + decoration: BoxDecoration(border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(8)), + child: imageFile != null ? Image.file(imageFile, fit: BoxFit.cover, key: UniqueKey()) : const Center(child: Icon(Icons.image, size: 50, color: Colors.grey)), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton.icon(icon: const Icon(Icons.photo_library), label: const Text('Gallery'), onPressed: () => _pickImage(ImageSource.gallery, imageInfo)), + ElevatedButton.icon(icon: const Icon(Icons.camera_alt), label: const Text('Camera'), onPressed: () => _pickImage(ImageSource.camera, imageInfo)), + ], + ), + if (remarkController != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: TextFormField( + controller: remarkController, + decoration: InputDecoration(labelText: 'Remarks for $title', border: const OutlineInputBorder()), + ), + ), + ], + ), + ), + ); + } + Widget _buildResultRow(TextEditingController inputCtrl, String inputLabel, TextEditingController resultCtrl, String resultLabel, Function(String) onChanged, Function(String?) onSaved) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), @@ -270,4 +381,4 @@ class _AirManualCollectionWidgetState extends State { ), ); } -} +} \ No newline at end of file diff --git a/lib/services/air_sampling_service.dart b/lib/services/air_sampling_service.dart index 80e2f98..dededc9 100644 --- a/lib/services/air_sampling_service.dart +++ b/lib/services/air_sampling_service.dart @@ -23,6 +23,7 @@ class AirSamplingService { ImageSource source, { required String stationCode, required String imageInfo, + String processType = 'INSTALL', // Defaults to INSTALL for backward compatibility }) async { final picker = ImagePicker(); final XFile? photo = await picker.pickImage( @@ -49,8 +50,10 @@ class AirSamplingService { final tempDir = await getTemporaryDirectory(); final fileTimestamp = watermarkTimestamp.replaceAll(':', '-').replaceAll(' ', '_'); + final newFileName = - "${stationCode}_${fileTimestamp}_INSTALL_${imageInfo.replaceAll(' ', '')}.jpg"; + "${stationCode}_${fileTimestamp}_${processType.toUpperCase()}_${imageInfo.replaceAll(' ', '')}.jpg"; + final filePath = path.join(tempDir.path, newFileName); return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); @@ -78,12 +81,25 @@ class AirSamplingService { return await saveLocally(); } - final recordId = textDataResult['data']?['air_man_id']; - if (recordId == null) { + // --- NECESSARY FIX: Safely parse the record ID from the server response --- + final dynamic recordIdFromServer = textDataResult['data']?['air_man_id']; + if (recordIdFromServer == null) { debugPrint("Text data submitted, but did not receive a record ID."); return await saveLocally(); } - debugPrint("Text data submitted successfully. Received record ID: $recordId"); + + debugPrint("Text data submitted successfully. Received record ID: $recordIdFromServer"); + + // The ID from JSON can be a String or int, but our model needs an int. + // Use int.tryParse for safe conversion. + final int? parsedRecordId = int.tryParse(recordIdFromServer.toString()); + + if (parsedRecordId == null) { + debugPrint("Could not parse the received record ID: $recordIdFromServer"); + return await saveLocally(); // Treat as a failure if ID is invalid + } + + data.airManId = parsedRecordId; // Assign the correctly typed integer ID // --- STEP 2: UPLOAD IMAGE FILES --- final filesToUpload = data.getImagesForUpload(); @@ -94,9 +110,9 @@ class AirSamplingService { return {'status': 'S1', 'message': 'Installation data submitted successfully.'}; } - debugPrint("Step 2: Uploading ${filesToUpload.length} images for record ID $recordId..."); + debugPrint("Step 2: Uploading ${filesToUpload.length} images for record ID $parsedRecordId..."); final imageUploadResult = await _apiService.air.uploadInstallationImages( - airManId: recordId.toString(), + airManId: parsedRecordId.toString(), // The API itself needs a string files: filesToUpload, ); @@ -117,32 +133,61 @@ class AirSamplingService { /// Submits only the collection data, linked to a previous installation. Future> submitCollection(AirCollectionData data) async { - try { - final result = await _apiService.post( - 'air/manual/collection', - data.toJson() - ); + // --- OFFLINE-FIRST HELPER (CORRECTED) --- + Future> updateAndSaveLocally(String newStatus, {String? message}) async { + debugPrint("Saving collection data locally with status: $newStatus"); + final allLogs = await _localStorageService.getAllAirSamplingLogs(); + final logIndex = allLogs.indexWhere((log) => log['refID'] == data.installationRefID); - if (result['success'] == true) { - data.status = 'S3'; // Server Completed - // Also save the completed record locally - await _localStorageService.saveAirSamplingRecord(data.toMap(), data.installationRefID!); - return {'status': 'S3', 'message': 'Collection submitted successfully.'}; - } else { - throw Exception(result['message'] ?? 'Unknown server error'); + if (logIndex != -1) { + final installationLog = allLogs[logIndex]; + // FIX: Nest collection data to prevent overwriting installation fields. + installationLog['collectionData'] = data.toMap(); + installationLog['status'] = newStatus; // Update the overall status + await _localStorageService.saveAirSamplingRecord(installationLog, data.installationRefID!); } - } catch (e) { - debugPrint("API submission failed: $e. Saving collection locally."); - data.status = 'L3'; // Mark as completed locally - - // CORRECTED: Use toMap() to ensure all data is saved locally on failure. - await _localStorageService.saveAirSamplingRecord(data.toMap(), data.installationRefID!); - return { - 'status': 'L3', - 'message': 'No connection. Collection data saved locally.', + 'status': newStatus, + 'message': message ?? 'No connection or server error. Data saved locally.', }; } + + // --- STEP 1: SUBMIT TEXT DATA --- + debugPrint("Step 1: Submitting collection text data..."); + final textDataResult = await _apiService.post('air/manual/collection', data.toJson()); + + if (textDataResult['success'] != true) { + debugPrint("Failed to submit collection text data. Reason: ${textDataResult['message']}"); + return await updateAndSaveLocally('L3', message: 'No connection or server error. Collection data saved locally.'); + } + debugPrint("Collection text data submitted successfully."); + + // --- STEP 2: UPLOAD IMAGE FILES --- + final filesToUpload = data.getImagesForUpload(); + if (filesToUpload.isEmpty) { + debugPrint("No collection images to upload. Submission complete."); + await updateAndSaveLocally('S3'); // S3 = Server Completed + return {'status': 'S3', 'message': 'Collection data submitted successfully.'}; + } + + debugPrint("Step 2: Uploading ${filesToUpload.length} collection images..."); + final imageUploadResult = await _apiService.air.uploadCollectionImages( + airManId: data.airManId.toString(), + files: filesToUpload, + ); + + if (imageUploadResult['success'] != true) { + debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}"); + // Use a new status 'L4' to indicate text submitted but images failed + return await updateAndSaveLocally('L4', message: 'Data submitted, but image upload failed. Saved locally for retry.'); + } + + debugPrint("Images uploaded successfully."); + await updateAndSaveLocally('S3'); // S3 = Server Completed + return { + 'status': 'S3', + 'message': 'Collection data and images submitted successfully.', + }; } /// Fetches installations that are pending collection from local storage. @@ -154,9 +199,10 @@ class AirSamplingService { final pendingInstallations = logs .where((log) { final status = log['status']; - // CORRECTED: Include 'S2' to show records that have successfully uploaded images - // but are still pending collection. - return status == 'L1' || status == 'S1' || status == 'S2'; + // --- CORRECTED --- + // Only show installations that have been synced to the server (S1, S2). + // 'L1' (Local only) records cannot be collected until they are synced. + return status == 'S1' || status == 'S2'; }) .map((log) => AirInstallationData.fromJson(log)) .toList(); @@ -167,4 +213,4 @@ class AirSamplingService { void dispose() { // Clean up any resources if necessary } -} +} \ No newline at end of file diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index bb6807d..b8485a2 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1,3 +1,5 @@ +// lib/services/api_service.dart + import 'dart:io'; import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -202,6 +204,19 @@ class AirApiService { files: files, ); } + + // NECESSARY FIX: Added dedicated method for uploading collection images + Future> uploadCollectionImages({ + required String airManId, + required Map files, + }) { + return _baseService.postMultipart( + // Note: Please verify this endpoint path with your backend developer. + endpoint: 'air/manual/collection-images', + fields: {'air_man_id': airManId}, + files: files, + ); + } } @@ -369,4 +384,4 @@ class DatabaseHelper { Future saveStates(List> states) => _saveData(_statesTable, 'state', states); Future>?> loadStates() => _loadData(_statesTable, 'state'); -} +} \ No newline at end of file diff --git a/lib/services/local_storage_service.dart b/lib/services/local_storage_service.dart index b6b183d..a8c5cf3 100644 --- a/lib/services/local_storage_service.dart +++ b/lib/services/local_storage_service.dart @@ -8,6 +8,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:path/path.dart' as p; import '../models/air_installation_data.dart'; +import '../models/air_collection_data.dart'; import '../models/tarball_data.dart'; import '../models/in_situ_sampling_data.dart'; import '../models/river_in_situ_sampling_data.dart'; @@ -41,7 +42,7 @@ class LocalStorageService { } // ======================================================================= - // --- UPDATED: Part 2: Air Manual Sampling Methods --- + // Part 2: Air Manual Sampling Methods // ======================================================================= Future _getAirManualBaseDir() async { @@ -55,8 +56,9 @@ class LocalStorageService { return airDir; } - /// Saves or updates an air sampling record to a local JSON file within a folder named by its refID. - /// CORRECTED: This now accepts a generic Map, which is what the service layer provides. + /// Saves or updates an air sampling record, including copying all associated images to permanent local storage. + /// CORRECTED: This now robustly handles maps with File objects from both installation and collection, + /// preventing type errors that caused the installation screen to freeze. Future saveAirSamplingRecord(Map data, String refID) async { final baseDir = await _getAirManualBaseDir(); if (baseDir == null) { @@ -70,10 +72,14 @@ class LocalStorageService { await eventDir.create(recursive: true); } - // Helper function to copy a file and return its new path - Future copyImageToLocal(File? imageFile) async { - if (imageFile == null) return null; + // Helper function to copy a file and return its new, permanent path + Future copyImageToLocal(dynamic imageFile) async { + if (imageFile is! File) return null; // Gracefully handle non-File types try { + // Check if the file is already in the permanent directory to avoid re-copying + if (p.dirname(imageFile.path) == eventDir.path) { + return imageFile.path; + } final String fileName = p.basename(imageFile.path); final File newFile = await imageFile.copy(p.join(eventDir.path, fileName)); return newFile.path; @@ -83,30 +89,56 @@ class LocalStorageService { } } - // This logic is now more robust; it checks for File objects and copies them if they exist. - final AirInstallationData tempInstallationData = AirInstallationData.fromJson(data); - data['imageFrontPath'] = await copyImageToLocal(tempInstallationData.imageFront); - data['imageBackPath'] = await copyImageToLocal(tempInstallationData.imageBack); - data['imageLeftPath'] = await copyImageToLocal(tempInstallationData.imageLeft); - data['imageRightPath'] = await copyImageToLocal(tempInstallationData.imageRight); - data['optionalImage1Path'] = await copyImageToLocal(tempInstallationData.optionalImage1); - data['optionalImage2Path'] = await copyImageToLocal(tempInstallationData.optionalImage2); - data['optionalImage3Path'] = await copyImageToLocal(tempInstallationData.optionalImage3); - data['optionalImage4Path'] = await copyImageToLocal(tempInstallationData.optionalImage4); + // Create a mutable copy of the data map to avoid modifying the original + final Map serializableData = Map.from(data); + // Define the keys for installation images to look for in the map + final installationImageKeys = ['imageFront', 'imageBack', 'imageLeft', 'imageRight', 'optionalImage1', 'optionalImage2', 'optionalImage3', 'optionalImage4']; + // Process top-level (installation) images + for (final key in installationImageKeys) { + // Check if the key exists and the value is a File object + if (serializableData.containsKey(key) && serializableData[key] is File) { + final newPath = await copyImageToLocal(serializableData[key]); + serializableData['${key}Path'] = newPath; // Creates 'imageFrontPath', etc. + // ** THE FIX **: Only remove the key if it was a File object that we have processed. + serializableData.remove(key); + } + } + + // Process nested collection images, if they exist + if (serializableData['collectionData'] is Map) { + final collectionMap = Map.from(serializableData['collectionData']); + final collectionImageKeys = ['imageFront', 'imageBack', 'imageLeft', 'imageRight', 'imageChart', 'imageFilterPaper', 'optionalImage1', 'optionalImage2', 'optionalImage3', 'optionalImage4']; + + for (final key in collectionImageKeys) { + // Check if the key exists and the value is a File object + if (collectionMap.containsKey(key) && collectionMap[key] is File) { + final newPath = await copyImageToLocal(collectionMap[key]); + collectionMap['${key}Path'] = newPath; + // ** THE FIX **: Only remove the key if it was a File object that we have processed. + collectionMap.remove(key); + } + } + // Put the cleaned, serializable collection map back into the main data object + serializableData['collectionData'] = collectionMap; + } + + // Now that all File objects have been replaced with String paths, save the clean data. final jsonFile = File(p.join(eventDir.path, 'data.json')); - await jsonFile.writeAsString(jsonEncode(data)); + await jsonFile.writeAsString(jsonEncode(serializableData)); debugPrint("Air sampling log and images saved to: ${eventDir.path}"); return eventDir.path; - } catch (e) { + } catch (e, s) { debugPrint("Error saving air sampling log to local storage: $e"); + debugPrint("Stack trace: $s"); return null; } } + Future>> getAllAirSamplingLogs() async { final baseDir = await _getAirManualBaseDir(); if (baseDir == null || !await baseDir.exists()) return []; @@ -342,7 +374,7 @@ class LocalStorageService { } // ======================================================================= - // UPDATED: Part 5: River In-Situ Specific Methods + // Part 5: River In-Situ Specific Methods // ======================================================================= Future _getRiverInSituBaseDir(String? samplingType) async { @@ -458,4 +490,4 @@ class LocalStorageService { debugPrint("Error updating river in-situ log: $e"); } } -} +} \ No newline at end of file