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
|
// lib/models/air_collection_data.dart
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
class AirCollectionData {
|
class AirCollectionData {
|
||||||
// Link to the original installation
|
// Link to the original installation
|
||||||
@ -35,6 +36,22 @@ class AirCollectionData {
|
|||||||
int? collectionUserId;
|
int? collectionUserId;
|
||||||
String? status;
|
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({
|
AirCollectionData({
|
||||||
this.installationRefID,
|
this.installationRefID,
|
||||||
this.airManId,
|
this.airManId,
|
||||||
@ -60,9 +77,73 @@ class AirCollectionData {
|
|||||||
this.remarks,
|
this.remarks,
|
||||||
this.collectionUserId,
|
this.collectionUserId,
|
||||||
this.status,
|
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() {
|
Map<String, dynamic> toMap() {
|
||||||
return {
|
return {
|
||||||
'installationRefID': installationRefID,
|
'installationRefID': installationRefID,
|
||||||
@ -88,34 +169,67 @@ class AirCollectionData {
|
|||||||
'air_man_collection_pm25_vstd': pm25Vstd,
|
'air_man_collection_pm25_vstd': pm25Vstd,
|
||||||
'air_man_collection_remarks': remarks,
|
'air_man_collection_remarks': remarks,
|
||||||
'status': status,
|
'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() {
|
Map<String, dynamic> toJson() {
|
||||||
// This method is now designed to match the API structure perfectly.
|
|
||||||
return {
|
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_date': collectionDate,
|
||||||
'air_man_collection_time': collectionTime,
|
'air_man_collection_time': collectionTime,
|
||||||
'air_man_collection_weather': weather,
|
'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_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_flowrate_result': pm10FlowrateResult,
|
||||||
'air_man_collection_pm10_total_time': pm10TotalTime,
|
'air_man_collection_pm10_total_time': pm10TotalTime,
|
||||||
'air_man_collection_total_time_result': pm10TotalTimeResult,
|
'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_pressure_result': pm10PressureResult,
|
||||||
'air_man_collection_pm10_vstd': pm10Vstd,
|
'air_man_collection_pm10_vstd': pm10Vstd?.toString(),
|
||||||
'air_man_collection_pm25_flowrate': pm25Flowrate,
|
'air_man_collection_pm25_flowrate': pm25Flowrate?.toString(),
|
||||||
'air_man_collection_pm25_flowrate_result': pm25FlowrateResult,
|
'air_man_collection_pm25_flowrate_result': pm25FlowrateResult,
|
||||||
'air_man_collection_pm25_total_time': pm25TotalTime,
|
'air_man_collection_pm25_total_time': pm25TotalTime,
|
||||||
'air_man_collection_pm25_total_time_result': pm25TotalTimeResult,
|
'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_pressure_result': pm25PressureResult,
|
||||||
'air_man_collection_pm25_vstd': pm25Vstd,
|
'air_man_collection_pm25_vstd': pm25Vstd?.toString(),
|
||||||
'air_man_collection_remarks': remarks,
|
'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
|
// lib/models/air_installation_data.dart
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'air_collection_data.dart'; // Import the collection data model
|
||||||
|
|
||||||
class AirInstallationData {
|
class AirInstallationData {
|
||||||
String? refID;
|
String? refID;
|
||||||
@ -45,6 +47,9 @@ class AirInstallationData {
|
|||||||
|
|
||||||
String? status;
|
String? status;
|
||||||
|
|
||||||
|
// NECESSARY ADDITION: For handling nested collection data during offline saves.
|
||||||
|
AirCollectionData? collectionData;
|
||||||
|
|
||||||
AirInstallationData({
|
AirInstallationData({
|
||||||
this.refID,
|
this.refID,
|
||||||
this.airManId,
|
this.airManId,
|
||||||
@ -84,18 +89,13 @@ class AirInstallationData {
|
|||||||
this.optionalImage3Path,
|
this.optionalImage3Path,
|
||||||
this.optionalImage4Path,
|
this.optionalImage4Path,
|
||||||
this.status,
|
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() {
|
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 {
|
return {
|
||||||
'refID': refID,
|
'refID': refID,
|
||||||
'air_man_id': airManId,
|
'air_man_id': airManId,
|
||||||
@ -115,18 +115,21 @@ class AirInstallationData {
|
|||||||
'remark': remark,
|
'remark': remark,
|
||||||
'installationUserId': installationUserId,
|
'installationUserId': installationUserId,
|
||||||
'status': status,
|
'status': status,
|
||||||
'imageFrontPath': imageFrontPath,
|
|
||||||
'imageBackPath': imageBackPath,
|
|
||||||
'imageLeftPath': imageLeftPath,
|
|
||||||
'imageRightPath': imageRightPath,
|
|
||||||
'optionalImage1Path': optionalImage1Path,
|
|
||||||
'optionalImage2Path': optionalImage2Path,
|
|
||||||
'optionalImage3Path': optionalImage3Path,
|
|
||||||
'optionalImage4Path': optionalImage4Path,
|
|
||||||
'optionalRemark1': optionalRemark1,
|
'optionalRemark1': optionalRemark1,
|
||||||
'optionalRemark2': optionalRemark2,
|
'optionalRemark2': optionalRemark2,
|
||||||
'optionalRemark3': optionalRemark3,
|
'optionalRemark3': optionalRemark3,
|
||||||
'optionalRemark4': optionalRemark4,
|
'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'],
|
remark: json['remark'],
|
||||||
installationUserId: json['installationUserId'],
|
installationUserId: json['installationUserId'],
|
||||||
status: json['status'],
|
status: json['status'],
|
||||||
|
// When deserializing, we use the '...Path' keys created by the local storage service
|
||||||
imageFront: fileFromPath(json['imageFrontPath']),
|
imageFront: fileFromPath(json['imageFrontPath']),
|
||||||
imageBack: fileFromPath(json['imageBackPath']),
|
imageBack: fileFromPath(json['imageBackPath']),
|
||||||
imageLeft: fileFromPath(json['imageLeftPath']),
|
imageLeft: fileFromPath(json['imageLeftPath']),
|
||||||
@ -164,6 +168,10 @@ class AirInstallationData {
|
|||||||
optionalRemark2: json['optionalRemark2'],
|
optionalRemark2: json['optionalRemark2'],
|
||||||
optionalRemark3: json['optionalRemark3'],
|
optionalRemark3: json['optionalRemark3'],
|
||||||
optionalRemark4: json['optionalRemark4'],
|
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 {
|
return {
|
||||||
'air_man_station_code': stationID,
|
'air_man_station_code': stationID,
|
||||||
'air_man_sampling_date': samplingDate,
|
'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_date': installationDate,
|
||||||
'air_man_installation_time': installationTime,
|
'air_man_installation_time': installationTime,
|
||||||
'air_man_installation_weather': weather,
|
'air_man_installation_weather': weather,
|
||||||
|
|||||||
@ -27,6 +27,7 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// Fetch the list of pending installations when the screen loads
|
||||||
_pendingInstallationsFuture =
|
_pendingInstallationsFuture =
|
||||||
Provider.of<AirSamplingService>(context, listen: false)
|
Provider.of<AirSamplingService>(context, listen: false)
|
||||||
.getPendingInstallations();
|
.getPendingInstallations();
|
||||||
@ -90,8 +91,9 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
|
|||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: DropdownSearch<AirInstallationData>(
|
child: DropdownSearch<AirInstallationData>(
|
||||||
items: pendingInstallations,
|
items: pendingInstallations,
|
||||||
|
// **THE FIX**: Changed u.samplingDate to u.installationDate
|
||||||
itemAsString: (AirInstallationData u) =>
|
itemAsString: (AirInstallationData u) =>
|
||||||
"${u.stationID} - ${u.locationName} (${u.samplingDate})",
|
"${u.stationID} - ${u.locationName} (${u.installationDate} ${u.installationTime ?? ''})",
|
||||||
popupProps: const PopupProps.menu(
|
popupProps: const PopupProps.menu(
|
||||||
showSearchBox: true,
|
showSearchBox: true,
|
||||||
searchFieldProps: TextFieldProps(
|
searchFieldProps: TextFieldProps(
|
||||||
@ -115,7 +117,8 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
|
|||||||
_collectionData = AirCollectionData(
|
_collectionData = AirCollectionData(
|
||||||
installationRefID: data.refID,
|
installationRefID: data.refID,
|
||||||
airManId: data.airManId, // Pass the server ID
|
airManId: data.airManId, // Pass the server ID
|
||||||
collectionUserId: authProvider.profileData?['user_id'],
|
collectionUserId:
|
||||||
|
authProvider.profileData?['user_id'],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
_collectionData = AirCollectionData();
|
_collectionData = AirCollectionData();
|
||||||
@ -129,14 +132,19 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: _selectedInstallation != null
|
child: _selectedInstallation != null
|
||||||
? AirManualCollectionWidget(
|
? AirManualCollectionWidget(
|
||||||
key: ValueKey(_selectedInstallation!.refID),
|
key: ValueKey(_selectedInstallation!
|
||||||
|
.refID), // Ensures widget rebuilds
|
||||||
|
stationCode: _selectedInstallation!.stationID!,
|
||||||
data: _collectionData,
|
data: _collectionData,
|
||||||
initialTemp: double.tryParse(_selectedInstallation!.temp ?? '0.0') ?? 0.0,
|
initialTemp: double.tryParse(
|
||||||
|
_selectedInstallation!.temp ?? '0.0') ??
|
||||||
|
0.0,
|
||||||
onSubmit: _submitCollection,
|
onSubmit: _submitCollection,
|
||||||
isLoading: _isLoading,
|
isLoading: _isLoading,
|
||||||
)
|
)
|
||||||
: const Center(
|
: 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
|
// lib/screens/air/manual/widgets/air_manual_collection.dart
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../../../../models/air_collection_data.dart';
|
import '../../../../models/air_collection_data.dart';
|
||||||
|
import '../../../../services/air_sampling_service.dart';
|
||||||
|
|
||||||
class AirManualCollectionWidget extends StatefulWidget {
|
class AirManualCollectionWidget extends StatefulWidget {
|
||||||
|
final String stationCode;
|
||||||
final AirCollectionData data;
|
final AirCollectionData data;
|
||||||
final double initialTemp;
|
final double initialTemp;
|
||||||
final Future<void> Function() onSubmit;
|
final Future<void> Function() onSubmit;
|
||||||
@ -12,6 +17,7 @@ class AirManualCollectionWidget extends StatefulWidget {
|
|||||||
|
|
||||||
const AirManualCollectionWidget({
|
const AirManualCollectionWidget({
|
||||||
super.key,
|
super.key,
|
||||||
|
required this.stationCode,
|
||||||
required this.data,
|
required this.data,
|
||||||
required this.initialTemp,
|
required this.initialTemp,
|
||||||
required this.onSubmit,
|
required this.onSubmit,
|
||||||
@ -31,6 +37,10 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
|||||||
final _collectionTimeController = TextEditingController();
|
final _collectionTimeController = TextEditingController();
|
||||||
final _finalTempController = TextEditingController();
|
final _finalTempController = TextEditingController();
|
||||||
final _remarkController = TextEditingController();
|
final _remarkController = TextEditingController();
|
||||||
|
final _optionalRemark1Controller = TextEditingController();
|
||||||
|
final _optionalRemark2Controller = TextEditingController();
|
||||||
|
final _optionalRemark3Controller = TextEditingController();
|
||||||
|
final _optionalRemark4Controller = TextEditingController();
|
||||||
|
|
||||||
// PM10 Controllers
|
// PM10 Controllers
|
||||||
final _pm10FlowRateController = TextEditingController();
|
final _pm10FlowRateController = TextEditingController();
|
||||||
@ -63,10 +73,15 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
// Dispose all controllers
|
||||||
_collectionDateController.dispose();
|
_collectionDateController.dispose();
|
||||||
_collectionTimeController.dispose();
|
_collectionTimeController.dispose();
|
||||||
_finalTempController.dispose();
|
_finalTempController.dispose();
|
||||||
_remarkController.dispose();
|
_remarkController.dispose();
|
||||||
|
_optionalRemark1Controller.dispose();
|
||||||
|
_optionalRemark2Controller.dispose();
|
||||||
|
_optionalRemark3Controller.dispose();
|
||||||
|
_optionalRemark4Controller.dispose();
|
||||||
_pm10FlowRateController.dispose();
|
_pm10FlowRateController.dispose();
|
||||||
_pm10FlowRateResultController.dispose();
|
_pm10FlowRateResultController.dispose();
|
||||||
_pm10TimeController.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) {
|
void _calculateVstd(int type) {
|
||||||
setState(() {
|
setState(() {
|
||||||
try {
|
try {
|
||||||
@ -148,9 +192,27 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **CRITICAL FIX IS HERE**
|
||||||
void _onSubmitPressed() {
|
void _onSubmitPressed() {
|
||||||
if (_formKey.currentState!.validate()) {
|
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();
|
widget.onSubmit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -170,7 +232,6 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
|||||||
),
|
),
|
||||||
const Divider(height: 30),
|
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),
|
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),
|
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),
|
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,
|
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))),
|
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(_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(_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!)),
|
_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)),
|
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))),
|
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(_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),
|
_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,
|
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),
|
const SizedBox(height: 30),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
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) {
|
Widget _buildResultRow(TextEditingController inputCtrl, String inputLabel, TextEditingController resultCtrl, String resultLabel, Function(String) onChanged, Function(String?) onSaved) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
|||||||
@ -23,6 +23,7 @@ class AirSamplingService {
|
|||||||
ImageSource source, {
|
ImageSource source, {
|
||||||
required String stationCode,
|
required String stationCode,
|
||||||
required String imageInfo,
|
required String imageInfo,
|
||||||
|
String processType = 'INSTALL', // Defaults to INSTALL for backward compatibility
|
||||||
}) async {
|
}) async {
|
||||||
final picker = ImagePicker();
|
final picker = ImagePicker();
|
||||||
final XFile? photo = await picker.pickImage(
|
final XFile? photo = await picker.pickImage(
|
||||||
@ -49,8 +50,10 @@ class AirSamplingService {
|
|||||||
|
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
final fileTimestamp = watermarkTimestamp.replaceAll(':', '-').replaceAll(' ', '_');
|
final fileTimestamp = watermarkTimestamp.replaceAll(':', '-').replaceAll(' ', '_');
|
||||||
|
|
||||||
final newFileName =
|
final newFileName =
|
||||||
"${stationCode}_${fileTimestamp}_INSTALL_${imageInfo.replaceAll(' ', '')}.jpg";
|
"${stationCode}_${fileTimestamp}_${processType.toUpperCase()}_${imageInfo.replaceAll(' ', '')}.jpg";
|
||||||
|
|
||||||
final filePath = path.join(tempDir.path, newFileName);
|
final filePath = path.join(tempDir.path, newFileName);
|
||||||
|
|
||||||
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
|
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
|
||||||
@ -78,12 +81,25 @@ class AirSamplingService {
|
|||||||
return await saveLocally();
|
return await saveLocally();
|
||||||
}
|
}
|
||||||
|
|
||||||
final recordId = textDataResult['data']?['air_man_id'];
|
// --- NECESSARY FIX: Safely parse the record ID from the server response ---
|
||||||
if (recordId == null) {
|
final dynamic recordIdFromServer = textDataResult['data']?['air_man_id'];
|
||||||
|
if (recordIdFromServer == null) {
|
||||||
debugPrint("Text data submitted, but did not receive a record ID.");
|
debugPrint("Text data submitted, but did not receive a record ID.");
|
||||||
return await saveLocally();
|
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 ---
|
// --- STEP 2: UPLOAD IMAGE FILES ---
|
||||||
final filesToUpload = data.getImagesForUpload();
|
final filesToUpload = data.getImagesForUpload();
|
||||||
@ -94,9 +110,9 @@ class AirSamplingService {
|
|||||||
return {'status': 'S1', 'message': 'Installation data submitted successfully.'};
|
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(
|
final imageUploadResult = await _apiService.air.uploadInstallationImages(
|
||||||
airManId: recordId.toString(),
|
airManId: parsedRecordId.toString(), // The API itself needs a string
|
||||||
files: filesToUpload,
|
files: filesToUpload,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -117,32 +133,61 @@ class AirSamplingService {
|
|||||||
|
|
||||||
/// Submits only the collection data, linked to a previous installation.
|
/// Submits only the collection data, linked to a previous installation.
|
||||||
Future<Map<String, dynamic>> submitCollection(AirCollectionData data) async {
|
Future<Map<String, dynamic>> submitCollection(AirCollectionData data) async {
|
||||||
try {
|
// --- OFFLINE-FIRST HELPER (CORRECTED) ---
|
||||||
final result = await _apiService.post(
|
Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async {
|
||||||
'air/manual/collection',
|
debugPrint("Saving collection data locally with status: $newStatus");
|
||||||
data.toJson()
|
final allLogs = await _localStorageService.getAllAirSamplingLogs();
|
||||||
);
|
final logIndex = allLogs.indexWhere((log) => log['refID'] == data.installationRefID);
|
||||||
|
|
||||||
if (result['success'] == true) {
|
if (logIndex != -1) {
|
||||||
data.status = 'S3'; // Server Completed
|
final installationLog = allLogs[logIndex];
|
||||||
// Also save the completed record locally
|
// FIX: Nest collection data to prevent overwriting installation fields.
|
||||||
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.installationRefID!);
|
installationLog['collectionData'] = data.toMap();
|
||||||
return {'status': 'S3', 'message': 'Collection submitted successfully.'};
|
installationLog['status'] = newStatus; // Update the overall status
|
||||||
} else {
|
await _localStorageService.saveAirSamplingRecord(installationLog, data.installationRefID!);
|
||||||
throw Exception(result['message'] ?? 'Unknown server error');
|
|
||||||
}
|
}
|
||||||
} 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 {
|
return {
|
||||||
'status': 'L3',
|
'status': newStatus,
|
||||||
'message': 'No connection. Collection data saved locally.',
|
'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.
|
/// Fetches installations that are pending collection from local storage.
|
||||||
@ -154,9 +199,10 @@ class AirSamplingService {
|
|||||||
final pendingInstallations = logs
|
final pendingInstallations = logs
|
||||||
.where((log) {
|
.where((log) {
|
||||||
final status = log['status'];
|
final status = log['status'];
|
||||||
// CORRECTED: Include 'S2' to show records that have successfully uploaded images
|
// --- CORRECTED ---
|
||||||
// but are still pending collection.
|
// Only show installations that have been synced to the server (S1, S2).
|
||||||
return status == 'L1' || status == 'S1' || status == 'S2';
|
// 'L1' (Local only) records cannot be collected until they are synced.
|
||||||
|
return status == 'S1' || status == 'S2';
|
||||||
})
|
})
|
||||||
.map((log) => AirInstallationData.fromJson(log))
|
.map((log) => AirInstallationData.fromJson(log))
|
||||||
.toList();
|
.toList();
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// lib/services/api_service.dart
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
@ -202,6 +204,19 @@ class AirApiService {
|
|||||||
files: files,
|
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 'package:path/path.dart' as p;
|
||||||
|
|
||||||
import '../models/air_installation_data.dart';
|
import '../models/air_installation_data.dart';
|
||||||
|
import '../models/air_collection_data.dart';
|
||||||
import '../models/tarball_data.dart';
|
import '../models/tarball_data.dart';
|
||||||
import '../models/in_situ_sampling_data.dart';
|
import '../models/in_situ_sampling_data.dart';
|
||||||
import '../models/river_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 {
|
Future<Directory?> _getAirManualBaseDir() async {
|
||||||
@ -55,8 +56,9 @@ class LocalStorageService {
|
|||||||
return airDir;
|
return airDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves or updates an air sampling record to a local JSON file within a folder named by its refID.
|
/// Saves or updates an air sampling record, including copying all associated images to permanent local storage.
|
||||||
/// CORRECTED: This now accepts a generic Map, which is what the service layer provides.
|
/// 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 {
|
Future<String?> saveAirSamplingRecord(Map<String, dynamic> data, String refID) async {
|
||||||
final baseDir = await _getAirManualBaseDir();
|
final baseDir = await _getAirManualBaseDir();
|
||||||
if (baseDir == null) {
|
if (baseDir == null) {
|
||||||
@ -70,10 +72,14 @@ class LocalStorageService {
|
|||||||
await eventDir.create(recursive: true);
|
await eventDir.create(recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to copy a file and return its new path
|
// Helper function to copy a file and return its new, permanent path
|
||||||
Future<String?> copyImageToLocal(File? imageFile) async {
|
Future<String?> copyImageToLocal(dynamic imageFile) async {
|
||||||
if (imageFile == null) return null;
|
if (imageFile is! File) return null; // Gracefully handle non-File types
|
||||||
try {
|
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 String fileName = p.basename(imageFile.path);
|
||||||
final File newFile = await imageFile.copy(p.join(eventDir.path, fileName));
|
final File newFile = await imageFile.copy(p.join(eventDir.path, fileName));
|
||||||
return newFile.path;
|
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.
|
// Create a mutable copy of the data map to avoid modifying the original
|
||||||
final AirInstallationData tempInstallationData = AirInstallationData.fromJson(data);
|
final Map<String, dynamic> serializableData = Map.from(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);
|
|
||||||
|
|
||||||
|
// 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'));
|
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}");
|
debugPrint("Air sampling log and images saved to: ${eventDir.path}");
|
||||||
|
|
||||||
return eventDir.path;
|
return eventDir.path;
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e, s) {
|
||||||
debugPrint("Error saving air sampling log to local storage: $e");
|
debugPrint("Error saving air sampling log to local storage: $e");
|
||||||
|
debugPrint("Stack trace: $s");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<List<Map<String, dynamic>>> getAllAirSamplingLogs() async {
|
Future<List<Map<String, dynamic>>> getAllAirSamplingLogs() async {
|
||||||
final baseDir = await _getAirManualBaseDir();
|
final baseDir = await _getAirManualBaseDir();
|
||||||
if (baseDir == null || !await baseDir.exists()) return [];
|
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 {
|
Future<Directory?> _getRiverInSituBaseDir(String? samplingType) async {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user