fix air manual module for collection screen
This commit is contained in:
parent
e5af5a3f03
commit
1b1b869a1d
@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, File> getImagesForUpload() {
|
||||
final Map<String, File> 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;
|
||||
}
|
||||
}
|
||||
@ -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<String, dynamic> 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,
|
||||
|
||||
@ -27,6 +27,7 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Fetch the list of pending installations when the screen loads
|
||||
_pendingInstallationsFuture =
|
||||
Provider.of<AirSamplingService>(context, listen: false)
|
||||
.getPendingInstallations();
|
||||
@ -90,8 +91,9 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: DropdownSearch<AirInstallationData>(
|
||||
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<AirManualCollectionScreen> {
|
||||
_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<AirManualCollectionScreen> {
|
||||
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.')),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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<void> 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<AirManualCollectionWidget> {
|
||||
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<AirManualCollectionWidget> {
|
||||
|
||||
@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<AirManualCollectionWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickImage(ImageSource source, String imageInfo) async {
|
||||
final service = Provider.of<AirSamplingService>(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<AirManualCollectionWidget> {
|
||||
});
|
||||
}
|
||||
|
||||
// **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<AirManualCollectionWidget> {
|
||||
),
|
||||
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<AirManualCollectionWidget> {
|
||||
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<AirManualCollectionWidget> {
|
||||
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<AirManualCollectionWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
|
||||
@ -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<Map<String, dynamic>> submitCollection(AirCollectionData data) async {
|
||||
try {
|
||||
final result = await _apiService.post(
|
||||
'air/manual/collection',
|
||||
data.toJson()
|
||||
);
|
||||
// --- OFFLINE-FIRST HELPER (CORRECTED) ---
|
||||
Future<Map<String, dynamic>> 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();
|
||||
|
||||
@ -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<Map<String, dynamic>> uploadCollectionImages({
|
||||
required String airManId,
|
||||
required Map<String, File> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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<Directory?> _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<String?> saveAirSamplingRecord(Map<String, dynamic> 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<String?> copyImageToLocal(File? imageFile) async {
|
||||
if (imageFile == null) return null;
|
||||
// Helper function to copy a file and return its new, permanent path
|
||||
Future<String?> 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<String, dynamic> 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<String, dynamic>.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<List<Map<String, dynamic>>> 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<Directory?> _getRiverInSituBaseDir(String? samplingType) async {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user