fix air manual logic error and data log status
This commit is contained in:
parent
475e645d25
commit
c2c4d785e5
@ -1,4 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'air_installation_data.dart';
|
||||||
|
|
||||||
class AirCollectionData {
|
class AirCollectionData {
|
||||||
// Link to the original installation
|
// Link to the original installation
|
||||||
@ -33,6 +34,7 @@ class AirCollectionData {
|
|||||||
// General
|
// General
|
||||||
String? remarks;
|
String? remarks;
|
||||||
int? collectionUserId;
|
int? collectionUserId;
|
||||||
|
String? collectionUserName; // To hold the user's name for alerts
|
||||||
String? status;
|
String? status;
|
||||||
|
|
||||||
// Image Files for Collection
|
// Image Files for Collection
|
||||||
@ -75,6 +77,7 @@ class AirCollectionData {
|
|||||||
this.pm25Vstd,
|
this.pm25Vstd,
|
||||||
this.remarks,
|
this.remarks,
|
||||||
this.collectionUserId,
|
this.collectionUserId,
|
||||||
|
this.collectionUserName,
|
||||||
this.status,
|
this.status,
|
||||||
this.imageFront,
|
this.imageFront,
|
||||||
this.imageBack,
|
this.imageBack,
|
||||||
@ -92,6 +95,19 @@ class AirCollectionData {
|
|||||||
this.optionalRemark4,
|
this.optionalRemark4,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
String generateCollectionTelegramAlert(AirInstallationData installationData, {required bool isDataOnly}) {
|
||||||
|
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||||
|
final buffer = StringBuffer()
|
||||||
|
..writeln('✅ *Air Manual Collection $submissionType Submitted:*')
|
||||||
|
..writeln()
|
||||||
|
..writeln('*Station ID:* ${installationData.stationID ?? 'N/A'}')
|
||||||
|
..writeln('*Location:* ${installationData.locationName ?? 'N/A'}')
|
||||||
|
..writeln('*Collection Date:* ${collectionDate ?? 'N/A'} at ${collectionTime ?? 'N/A'}')
|
||||||
|
..writeln('*Submitted by User:* ${collectionUserName ?? 'N/A'}')
|
||||||
|
..writeln('*Status of Submission:* Successful');
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
factory AirCollectionData.fromMap(Map<String, dynamic> map) {
|
factory AirCollectionData.fromMap(Map<String, dynamic> map) {
|
||||||
File? fileFromPath(String? path) => path != null ? File(path) : null;
|
File? fileFromPath(String? path) => path != null ? File(path) : null;
|
||||||
|
|
||||||
@ -125,6 +141,7 @@ class AirCollectionData {
|
|||||||
pm25PressureResult: map['air_man_collection_pm25_pressure_result'],
|
pm25PressureResult: map['air_man_collection_pm25_pressure_result'],
|
||||||
pm25Vstd: parseDouble(map['air_man_collection_pm25_vstd']),
|
pm25Vstd: parseDouble(map['air_man_collection_pm25_vstd']),
|
||||||
remarks: map['air_man_collection_remarks'],
|
remarks: map['air_man_collection_remarks'],
|
||||||
|
collectionUserName: map['collectionUserName'],
|
||||||
status: map['status'],
|
status: map['status'],
|
||||||
optionalRemark1: map['optionalRemark1'],
|
optionalRemark1: map['optionalRemark1'],
|
||||||
optionalRemark2: map['optionalRemark2'],
|
optionalRemark2: map['optionalRemark2'],
|
||||||
@ -167,6 +184,7 @@ class AirCollectionData {
|
|||||||
'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,
|
||||||
'air_man_collection_remarks': remarks,
|
'air_man_collection_remarks': remarks,
|
||||||
|
'collectionUserName': collectionUserName,
|
||||||
'status': status,
|
'status': status,
|
||||||
'optionalRemark1': optionalRemark1,
|
'optionalRemark1': optionalRemark1,
|
||||||
'optionalRemark2': optionalRemark2,
|
'optionalRemark2': optionalRemark2,
|
||||||
|
|||||||
@ -21,6 +21,7 @@ class AirInstallationData {
|
|||||||
String? pm25FilterId;
|
String? pm25FilterId;
|
||||||
String? remark;
|
String? remark;
|
||||||
int? installationUserId;
|
int? installationUserId;
|
||||||
|
String? installationUserName; // To hold the user's name for alerts
|
||||||
|
|
||||||
File? imageFront;
|
File? imageFront;
|
||||||
File? imageBack;
|
File? imageBack;
|
||||||
@ -47,7 +48,6 @@ class AirInstallationData {
|
|||||||
|
|
||||||
String? status;
|
String? status;
|
||||||
|
|
||||||
// NECESSARY ADDITION: For handling nested collection data during offline saves.
|
|
||||||
AirCollectionData? collectionData;
|
AirCollectionData? collectionData;
|
||||||
|
|
||||||
AirInstallationData({
|
AirInstallationData({
|
||||||
@ -68,6 +68,7 @@ class AirInstallationData {
|
|||||||
this.pm25FilterId,
|
this.pm25FilterId,
|
||||||
this.remark,
|
this.remark,
|
||||||
this.installationUserId,
|
this.installationUserId,
|
||||||
|
this.installationUserName,
|
||||||
this.imageFront,
|
this.imageFront,
|
||||||
this.imageBack,
|
this.imageBack,
|
||||||
this.imageLeft,
|
this.imageLeft,
|
||||||
@ -89,12 +90,22 @@ class AirInstallationData {
|
|||||||
this.optionalImage3Path,
|
this.optionalImage3Path,
|
||||||
this.optionalImage4Path,
|
this.optionalImage4Path,
|
||||||
this.status,
|
this.status,
|
||||||
this.collectionData, // NECESSARY ADDITION: Add to constructor
|
this.collectionData,
|
||||||
});
|
});
|
||||||
|
|
||||||
// **CRITICAL FIX**: This method now correctly includes the raw File objects
|
String generateInstallationTelegramAlert({required bool isDataOnly}) {
|
||||||
// instead of just their paths. This allows the local storage service to find
|
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
|
||||||
// and copy the installation images.
|
final buffer = StringBuffer()
|
||||||
|
..writeln('✅ *Air Manual Installation $submissionType Submitted:*')
|
||||||
|
..writeln()
|
||||||
|
..writeln('*Station ID:* ${stationID ?? 'N/A'}')
|
||||||
|
..writeln('*Location:* ${locationName ?? 'N/A'}')
|
||||||
|
..writeln('*Installation Date:* ${installationDate ?? 'N/A'} at ${installationTime ?? 'N/A'}')
|
||||||
|
..writeln('*Submitted by User:* ${installationUserName ?? 'N/A'}')
|
||||||
|
..writeln('*Status of Submission:* Successful');
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
return {
|
return {
|
||||||
'refID': refID,
|
'refID': refID,
|
||||||
@ -114,12 +125,12 @@ class AirInstallationData {
|
|||||||
'pm25FilterId': pm25FilterId,
|
'pm25FilterId': pm25FilterId,
|
||||||
'remark': remark,
|
'remark': remark,
|
||||||
'installationUserId': installationUserId,
|
'installationUserId': installationUserId,
|
||||||
|
'installationUserName': installationUserName,
|
||||||
'status': status,
|
'status': status,
|
||||||
'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,
|
'imageFront': imageFront,
|
||||||
'imageBack': imageBack,
|
'imageBack': imageBack,
|
||||||
'imageLeft': imageLeft,
|
'imageLeft': imageLeft,
|
||||||
@ -128,7 +139,6 @@ class AirInstallationData {
|
|||||||
'optionalImage2': optionalImage2,
|
'optionalImage2': optionalImage2,
|
||||||
'optionalImage3': optionalImage3,
|
'optionalImage3': optionalImage3,
|
||||||
'optionalImage4': optionalImage4,
|
'optionalImage4': optionalImage4,
|
||||||
// Pass the nested collection data if it exists
|
|
||||||
'collectionData': collectionData?.toMap(),
|
'collectionData': collectionData?.toMap(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -154,8 +164,8 @@ class AirInstallationData {
|
|||||||
pm25FilterId: json['pm25FilterId'],
|
pm25FilterId: json['pm25FilterId'],
|
||||||
remark: json['remark'],
|
remark: json['remark'],
|
||||||
installationUserId: json['installationUserId'],
|
installationUserId: json['installationUserId'],
|
||||||
|
installationUserName: json['installationUserName'],
|
||||||
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']),
|
||||||
@ -168,7 +178,6 @@ 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
|
collectionData: json['collectionData'] != null && json['collectionData'] is Map
|
||||||
? AirCollectionData.fromMap(json['collectionData'])
|
? AirCollectionData.fromMap(json['collectionData'])
|
||||||
: null,
|
: null,
|
||||||
@ -179,7 +188,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?.toString(), // Ensure client ID is a string for API
|
'air_man_client_id': clientId?.toString(),
|
||||||
'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,
|
||||||
|
|||||||
@ -30,10 +30,10 @@ class AirHomePage extends StatelessWidget {
|
|||||||
label: "Manual",
|
label: "Manual",
|
||||||
isParent: true,
|
isParent: true,
|
||||||
children: [
|
children: [
|
||||||
SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/manual/dashboard'),
|
//SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/manual/dashboard'),
|
||||||
SidebarItem(icon: Icons.construction, label: "Installation", route: '/air/manual/installation'),
|
SidebarItem(icon: Icons.construction, label: "Installation", route: '/air/manual/installation'),
|
||||||
SidebarItem(icon: Icons.inventory_2, label: "Collection", route: '/air/manual/collection'),
|
SidebarItem(icon: Icons.inventory_2, label: "Collection", route: '/air/manual/collection'),
|
||||||
SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/manual/report'),
|
//SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/manual/report'),
|
||||||
SidebarItem(icon: Icons.article, label: "Data Log", route: '/air/manual/data-log'),
|
SidebarItem(icon: Icons.article, label: "Data Log", route: '/air/manual/data-log'),
|
||||||
SidebarItem(icon: Icons.image, label: "Image Request", route: '/air/manual/image-request'),
|
SidebarItem(icon: Icons.image, label: "Image Request", route: '/air/manual/image-request'),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -43,7 +43,8 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
|
|||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
final service = Provider.of<AirSamplingService>(context, listen: false);
|
final service = Provider.of<AirSamplingService>(context, listen: false);
|
||||||
final result = await service.submitCollection(_collectionData);
|
// MODIFIED: Pass the selected installation data to the service for the Telegram alert.
|
||||||
|
final result = await service.submitCollection(_collectionData, _selectedInstallation!);
|
||||||
|
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
|
|
||||||
@ -91,7 +92,6 @@ 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.installationDate} ${u.installationTime ?? ''})",
|
"${u.stationID} - ${u.locationName} (${u.installationDate} ${u.installationTime ?? ''})",
|
||||||
popupProps: const PopupProps.menu(
|
popupProps: const PopupProps.menu(
|
||||||
|
|||||||
@ -42,12 +42,17 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
final LocalStorageService _localStorageService = LocalStorageService();
|
final LocalStorageService _localStorageService = LocalStorageService();
|
||||||
final ApiService _apiService = ApiService();
|
final ApiService _apiService = ApiService();
|
||||||
|
|
||||||
|
// Raw data lists
|
||||||
List<SubmissionLogEntry> _installationLogs = [];
|
List<SubmissionLogEntry> _installationLogs = [];
|
||||||
List<SubmissionLogEntry> _collectionLogs = [];
|
List<SubmissionLogEntry> _collectionLogs = [];
|
||||||
|
|
||||||
|
// Filtered lists for the UI
|
||||||
List<SubmissionLogEntry> _filteredInstallationLogs = [];
|
List<SubmissionLogEntry> _filteredInstallationLogs = [];
|
||||||
List<SubmissionLogEntry> _filteredCollectionLogs = [];
|
List<SubmissionLogEntry> _filteredCollectionLogs = [];
|
||||||
|
|
||||||
|
// Per-category search controllers
|
||||||
final Map<String, TextEditingController> _searchControllers = {};
|
final Map<String, TextEditingController> _searchControllers = {};
|
||||||
|
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
final Map<String, bool> _isResubmitting = {};
|
final Map<String, bool> _isResubmitting = {};
|
||||||
|
|
||||||
@ -75,11 +80,9 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
|
|
||||||
for (var log in airLogs) {
|
for (var log in airLogs) {
|
||||||
try {
|
try {
|
||||||
// Determine if this is an installation or collection
|
|
||||||
final hasCollectionData = log['collectionData'] != null && (log['collectionData'] as Map).isNotEmpty;
|
final hasCollectionData = log['collectionData'] != null && (log['collectionData'] as Map).isNotEmpty;
|
||||||
final isInstallation = !hasCollectionData && log['air_man_id'] != null;
|
final isInstallation = !hasCollectionData && log['air_man_id'] != null;
|
||||||
|
|
||||||
// Get station info from the correct location
|
|
||||||
final stationInfo = isInstallation
|
final stationInfo = isInstallation
|
||||||
? log['stationInfo'] ?? {}
|
? log['stationInfo'] ?? {}
|
||||||
: log['collectionData']?['stationInfo'] ?? {};
|
: log['collectionData']?['stationInfo'] ?? {};
|
||||||
@ -87,15 +90,10 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
final stationName = stationInfo['station_name'] ?? 'Station ${log['stationID'] ?? 'Unknown'}';
|
final stationName = stationInfo['station_name'] ?? 'Station ${log['stationID'] ?? 'Unknown'}';
|
||||||
final stationCode = stationInfo['station_code'] ?? log['stationID'] ?? 'N/A';
|
final stationCode = stationInfo['station_code'] ?? log['stationID'] ?? 'N/A';
|
||||||
|
|
||||||
// Get correct timestamp
|
|
||||||
final submissionDateTime = isInstallation
|
final submissionDateTime = isInstallation
|
||||||
? _parseInstallationDateTime(log)
|
? _parseInstallationDateTime(log)
|
||||||
: _parseCollectionDateTime(log['collectionData']);
|
: _parseCollectionDateTime(log['collectionData']);
|
||||||
|
|
||||||
debugPrint('Processed ${isInstallation ? 'Installation' : 'Collection'} log:');
|
|
||||||
debugPrint(' Station: $stationName ($stationCode)');
|
|
||||||
debugPrint(' Original Date: ${submissionDateTime.toString()}');
|
|
||||||
|
|
||||||
final entry = SubmissionLogEntry(
|
final entry = SubmissionLogEntry(
|
||||||
type: isInstallation ? 'Installation' : 'Collection',
|
type: isInstallation ? 'Installation' : 'Collection',
|
||||||
title: stationName,
|
title: stationName,
|
||||||
@ -117,7 +115,6 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by datetime descending
|
|
||||||
tempInstallation.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime));
|
tempInstallation.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime));
|
||||||
tempCollection.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime));
|
tempCollection.sort((a, b) => b.submissionDateTime.compareTo(a.submissionDateTime));
|
||||||
|
|
||||||
@ -133,28 +130,11 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
|
|
||||||
DateTime _parseInstallationDateTime(Map<String, dynamic> log) {
|
DateTime _parseInstallationDateTime(Map<String, dynamic> log) {
|
||||||
try {
|
try {
|
||||||
// First try to get the installation date and time
|
|
||||||
if (log['installationDate'] != null) {
|
if (log['installationDate'] != null) {
|
||||||
final date = log['installationDate'];
|
final date = log['installationDate'];
|
||||||
final time = log['installationTime'] ?? '00:00';
|
final time = log['installationTime'] ?? '00:00';
|
||||||
return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time');
|
return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to sampling date if installation date not available
|
|
||||||
if (log['samplingDate'] != null) {
|
|
||||||
final date = log['samplingDate'];
|
|
||||||
final time = log['samplingTime'] ?? '00:00';
|
|
||||||
return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no dates found, check in the raw data structure
|
|
||||||
if (log['data'] != null && log['data']['installationDate'] != null) {
|
|
||||||
final date = log['data']['installationDate'];
|
|
||||||
final time = log['data']['installationTime'] ?? '00:00';
|
|
||||||
return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time');
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('No valid installation date found in log: ${log.keys}');
|
|
||||||
return DateTime.now();
|
return DateTime.now();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error parsing installation date: $e');
|
debugPrint('Error parsing installation date: $e');
|
||||||
@ -164,26 +144,15 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
|
|
||||||
DateTime _parseCollectionDateTime(Map<String, dynamic>? collectionData) {
|
DateTime _parseCollectionDateTime(Map<String, dynamic>? collectionData) {
|
||||||
try {
|
try {
|
||||||
if (collectionData == null) {
|
if (collectionData == null) return DateTime.now();
|
||||||
debugPrint('Collection data is null');
|
final dateKey = 'air_man_collection_date';
|
||||||
return DateTime.now();
|
final timeKey = 'air_man_collection_time';
|
||||||
}
|
|
||||||
|
|
||||||
// First try the direct fields
|
if (collectionData[dateKey] != null) {
|
||||||
if (collectionData['collectionDate'] != null) {
|
final date = collectionData[dateKey];
|
||||||
final date = collectionData['collectionDate'];
|
final time = collectionData[timeKey] ?? '00:00';
|
||||||
final time = collectionData['collectionTime'] ?? '00:00';
|
|
||||||
return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time');
|
return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for nested data structure
|
|
||||||
if (collectionData['data'] != null && collectionData['data']['collectionDate'] != null) {
|
|
||||||
final date = collectionData['data']['collectionDate'];
|
|
||||||
final time = collectionData['data']['collectionTime'] ?? '00:00';
|
|
||||||
return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time');
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('No valid collection date found in data: ${collectionData.keys}');
|
|
||||||
return DateTime.now();
|
return DateTime.now();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error parsing collection date: $e');
|
debugPrint('Error parsing collection date: $e');
|
||||||
@ -192,15 +161,19 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _getStatusMessage(Map<String, dynamic> log) {
|
String _getStatusMessage(Map<String, dynamic> log) {
|
||||||
if (log['status'] == 'S2' || log['status'] == 'S3') {
|
switch (log['status']) {
|
||||||
|
case 'S2':
|
||||||
|
case 'S3':
|
||||||
return 'Successfully submitted to server';
|
return 'Successfully submitted to server';
|
||||||
} else if (log['status'] == 'L1' || log['status'] == 'L3') {
|
case 'L1':
|
||||||
|
case 'L3':
|
||||||
return 'Saved locally (pending submission)';
|
return 'Saved locally (pending submission)';
|
||||||
} else if (log['status'] == 'L4') {
|
case 'L4':
|
||||||
return 'Partial submission (images failed)';
|
return 'Partial submission (images failed)';
|
||||||
}
|
default:
|
||||||
return 'Submission status unknown';
|
return 'Submission status unknown';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _filterLogs() {
|
void _filterLogs() {
|
||||||
final installationQuery = _searchControllers['Installation']?.text.toLowerCase() ?? '';
|
final installationQuery = _searchControllers['Installation']?.text.toLowerCase() ?? '';
|
||||||
@ -221,13 +194,11 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
|
|
||||||
Future<void> _resubmitData(SubmissionLogEntry log) async {
|
Future<void> _resubmitData(SubmissionLogEntry log) async {
|
||||||
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
|
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
|
||||||
if (mounted) {
|
if (mounted) setState(() => _isResubmitting[logKey] = true);
|
||||||
setState(() => _isResubmitting[logKey] = true);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final logData = log.rawData;
|
final logData = log.rawData;
|
||||||
final result = log.type == 'Installation'
|
log.type == 'Installation'
|
||||||
? await _submitInstallation(logData)
|
? await _submitInstallation(logData)
|
||||||
: await _submitCollection(logData);
|
: await _submitCollection(logData);
|
||||||
|
|
||||||
@ -251,7 +222,7 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> _submitInstallation(Map<String, dynamic> data) async {
|
Future<void> _submitInstallation(Map<String, dynamic> data) async {
|
||||||
final dataToResubmit = AirInstallationData.fromJson(data);
|
final dataToResubmit = AirInstallationData.fromJson(data);
|
||||||
final result = await _apiService.post('air/manual/installation', dataToResubmit.toJsonForApi());
|
final result = await _apiService.post('air/manual/installation', dataToResubmit.toJsonForApi());
|
||||||
|
|
||||||
@ -262,11 +233,9 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
files: imageFiles,
|
files: imageFiles,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> _submitCollection(Map<String, dynamic> data) async {
|
Future<void> _submitCollection(Map<String, dynamic> data) async {
|
||||||
final collectionData = data['collectionData'] ?? {};
|
final collectionData = data['collectionData'] ?? {};
|
||||||
final dataToResubmit = AirCollectionData.fromMap(collectionData);
|
final dataToResubmit = AirCollectionData.fromMap(collectionData);
|
||||||
final result = await _apiService.post('air/manual/collection', dataToResubmit.toJson());
|
final result = await _apiService.post('air/manual/collection', dataToResubmit.toJson());
|
||||||
@ -278,8 +247,6 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
files: imageFiles,
|
files: imageFiles,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -293,27 +260,18 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: const Text('Air Sampling Status Log')),
|
||||||
title: const Text('Air Sampling Status Log'),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
onPressed: _loadAllLogs,
|
|
||||||
tooltip: 'Refresh logs',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: _isLoading
|
body: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: _loadAllLogs,
|
onRefresh: _loadAllLogs,
|
||||||
child: !hasAnyLogs
|
child: !hasAnyLogs
|
||||||
? const Center(child: Text('No air sampling logs found.'))
|
? const Center(child: Text('No submission logs found.'))
|
||||||
: ListView(
|
: ListView(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
children: [
|
children: [
|
||||||
...logCategories.entries
|
...logCategories.entries
|
||||||
.where((entry) => entry.value.isNotEmpty)
|
.where((entry) => entry.value.isNotEmpty) // Only show categories with logs
|
||||||
.map((entry) => _buildCategorySection(entry.key, entry.value)),
|
.map((entry) => _buildCategorySection(entry.key, entry.value)),
|
||||||
if (!hasFilteredLogs && hasAnyLogs)
|
if (!hasFilteredLogs && hasAnyLogs)
|
||||||
const Center(
|
const Center(
|
||||||
@ -328,8 +286,10 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// THIS SECTION IS UPDATED TO MATCH THE MARINE LOG UI
|
||||||
Widget _buildCategorySection(String category, List<SubmissionLogEntry> logs) {
|
Widget _buildCategorySection(String category, List<SubmissionLogEntry> logs) {
|
||||||
final listHeight = (logs.length > 3 ? 3.5 : logs.length.toDouble()) * 75.0;
|
// Calculate height to show 5.5 items, indicating scrollability
|
||||||
|
final listHeight = (logs.length > 5 ? 5.5 : logs.length.toDouble()) * 75.0;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
@ -344,7 +304,7 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _searchControllers[category],
|
controller: _searchControllers[category],
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Search $category logs...',
|
hintText: 'Search in $category...',
|
||||||
prefixIcon: const Icon(Icons.search, size: 20),
|
prefixIcon: const Icon(Icons.search, size: 20),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
@ -361,7 +321,9 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
itemCount: logs.length,
|
itemCount: logs.length,
|
||||||
itemBuilder: (context, index) => _buildLogListItem(logs[index]),
|
itemBuilder: (context, index) {
|
||||||
|
return _buildLogListItem(logs[index]);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -370,11 +332,12 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// THIS ITEM BUILDER IS UPDATED TO MATCH THE MARINE LOG UI
|
||||||
Widget _buildLogListItem(SubmissionLogEntry log) {
|
Widget _buildLogListItem(SubmissionLogEntry log) {
|
||||||
final isSuccess = log.status == 'S2' || log.status == 'S3';
|
final isSuccess = log.status == 'S2' || log.status == 'S3';
|
||||||
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
|
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
|
||||||
final isResubmitting = _isResubmitting[logKey] ?? false;
|
final isResubmitting = _isResubmitting[logKey] ?? false;
|
||||||
final title = log.title;
|
final title = '${log.title} (${log.stationCode})'; // Consistent title format
|
||||||
final subtitle = DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime);
|
final subtitle = DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime);
|
||||||
|
|
||||||
return ExpansionTile(
|
return ExpansionTile(
|
||||||
@ -386,12 +349,10 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
subtitle: Text(subtitle),
|
subtitle: Text(subtitle),
|
||||||
trailing: !isSuccess
|
trailing: !isSuccess
|
||||||
? (isResubmitting
|
? (isResubmitting
|
||||||
? const SizedBox(
|
? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 3))
|
||||||
height: 24,
|
|
||||||
width: 24,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 3))
|
|
||||||
: IconButton(
|
: IconButton(
|
||||||
icon: const Icon(Icons.sync, color: Colors.blue),
|
icon: const Icon(Icons.sync, color: Colors.blue),
|
||||||
|
tooltip: 'Resubmit',
|
||||||
onPressed: () => _resubmitData(log),
|
onPressed: () => _resubmitData(log),
|
||||||
))
|
))
|
||||||
: null,
|
: null,
|
||||||
@ -401,12 +362,9 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildDetailRow('Station Code:', log.stationCode),
|
_buildDetailRow('Report ID:', log.reportId ?? 'N/A'),
|
||||||
if (log.reportId != null) _buildDetailRow('Report ID:', log.reportId!),
|
|
||||||
_buildDetailRow('Status:', log.message),
|
_buildDetailRow('Status:', log.message),
|
||||||
_buildDetailRow('Type:', '${log.type} Sampling'),
|
_buildDetailRow('Submission Type:', log.type),
|
||||||
if (log.type == 'Collection')
|
|
||||||
_buildDetailRow('Installation ID:', log.rawData['installationRefID']?.toString() ?? 'N/A'),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../../../auth_provider.dart';
|
||||||
import '../../../../models/air_collection_data.dart';
|
import '../../../../models/air_collection_data.dart';
|
||||||
import '../../../../services/air_sampling_service.dart';
|
import '../../../../services/air_sampling_service.dart';
|
||||||
|
|
||||||
@ -31,6 +32,7 @@ class AirManualCollectionWidget extends StatefulWidget {
|
|||||||
|
|
||||||
class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
bool _isPickingImage = false;
|
||||||
|
|
||||||
// General Controllers
|
// General Controllers
|
||||||
final _collectionDateController = TextEditingController();
|
final _collectionDateController = TextEditingController();
|
||||||
@ -73,6 +75,13 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
|||||||
|
|
||||||
_collectionDateController.text = widget.data.collectionDate!;
|
_collectionDateController.text = widget.data.collectionDate!;
|
||||||
_collectionTimeController.text = widget.data.collectionTime!;
|
_collectionTimeController.text = widget.data.collectionTime!;
|
||||||
|
|
||||||
|
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||||
|
final currentUser = authProvider.profileData;
|
||||||
|
if (currentUser != null) {
|
||||||
|
widget.data.collectionUserId = currentUser['user_id'];
|
||||||
|
widget.data.collectionUserName = currentUser['first_name'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -129,7 +138,28 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickImage(ImageSource source, String imageInfo) async {
|
void _showOrientationDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text("Incorrect Image Orientation"),
|
||||||
|
content: const Text("Required photos must be taken in a horizontal (landscape) orientation."),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
child: const Text("OK"),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
|
||||||
|
if (_isPickingImage) return;
|
||||||
|
setState(() => _isPickingImage = true);
|
||||||
|
|
||||||
final service = Provider.of<AirSamplingService>(context, listen: false);
|
final service = Provider.of<AirSamplingService>(context, listen: false);
|
||||||
final stationCode = widget.stationCode;
|
final stationCode = widget.stationCode;
|
||||||
|
|
||||||
@ -138,24 +168,17 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
|||||||
stationCode: stationCode,
|
stationCode: stationCode,
|
||||||
imageInfo: imageInfo.toUpperCase(),
|
imageInfo: imageInfo.toUpperCase(),
|
||||||
processType: 'COLLECT',
|
processType: 'COLLECT',
|
||||||
|
isRequired: isRequired,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (imageFile != null) {
|
if (imageFile == null && isRequired) {
|
||||||
|
_showOrientationDialog();
|
||||||
|
} else if (imageFile != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
switch (imageInfo) {
|
setImageCallback(imageFile);
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
setState(() => _isPickingImage = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _calculateVstd(int type) {
|
void _calculateVstd(int type) {
|
||||||
@ -183,36 +206,28 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
|||||||
const double hpaToinHg = 0.0295;
|
const double hpaToinHg = 0.0295;
|
||||||
const double inHgToMmHg = 25.4;
|
const double inHgToMmHg = 25.4;
|
||||||
|
|
||||||
// 1. Get time and user-input flowrate (cfm)
|
|
||||||
double totalTime_min = double.parse(timeResultCtrl.text);
|
double totalTime_min = double.parse(timeResultCtrl.text);
|
||||||
// **CRITICAL FIX**: Use the actual flowrate keyed in by the user (in cfm)
|
|
||||||
double qa_cfm_actual = double.parse(flowRateCtrl.text);
|
double qa_cfm_actual = double.parse(flowRateCtrl.text);
|
||||||
|
|
||||||
// 2. Calculate actual pressure
|
|
||||||
double pressureHpa = double.parse(pressureCtrl.text);
|
double pressureHpa = double.parse(pressureCtrl.text);
|
||||||
double pressureInHg = pressureHpa * hpaToinHg;
|
double pressureInHg = pressureHpa * hpaToinHg;
|
||||||
double p_actual_mmHg = pressureInHg * inHgToMmHg; // result pressure
|
double p_actual_mmHg = pressureInHg * inHgToMmHg;
|
||||||
pressureResultCtrl.text = pressureInHg.toStringAsFixed(2);
|
pressureResultCtrl.text = pressureInHg.toStringAsFixed(2);
|
||||||
|
|
||||||
// 3. Calculate average temperature in Celsius
|
double t_avg_celsius = (widget.initialTemp + double.parse(_finalTempController.text)) / 2.0;
|
||||||
double t_avg_celsius = (widget.initialTemp + double.parse(_finalTempController.text)) / 2.0; // ambient temperature
|
|
||||||
tAvgCtrl.text = t_avg_celsius.toStringAsFixed(2);
|
tAvgCtrl.text = t_avg_celsius.toStringAsFixed(2);
|
||||||
|
|
||||||
// 4. Calculate Standard Flowrate (QSTD) using your exact formula
|
|
||||||
// Formula: (flowrate * result pressure * 298) / (760 * (273 * ambient temperature))
|
|
||||||
double q_std_numerator = qa_cfm_actual * pressureInHg * 298;
|
double q_std_numerator = qa_cfm_actual * pressureInHg * 298;
|
||||||
double q_std_denominator = (760 * (273 + t_avg_celsius));
|
double q_std_denominator = (760 * (273 + t_avg_celsius));
|
||||||
double q_std = (q_std_denominator == 0) ? 0 : q_std_numerator / q_std_denominator;
|
double q_std = (q_std_denominator == 0) ? 0 : q_std_numerator / q_std_denominator;
|
||||||
qStdCtrl.text = q_std.toStringAsFixed(5);
|
qStdCtrl.text = q_std.toStringAsFixed(5);
|
||||||
|
|
||||||
// 5. Final VSTD
|
|
||||||
// Formula: QSTD * Total time
|
|
||||||
double v_std = q_std * totalTime_min;
|
double v_std = q_std * totalTime_min;
|
||||||
|
|
||||||
if (type == 1) { // PM10
|
if (type == 1) {
|
||||||
_pm10VstdController.text = v_std.toStringAsFixed(3);
|
_pm10VstdController.text = v_std.toStringAsFixed(3);
|
||||||
widget.data.pm10Vstd = v_std;
|
widget.data.pm10Vstd = v_std;
|
||||||
} else { // PM2.5
|
} else {
|
||||||
_pm25VstdController.text = v_std.toStringAsFixed(3);
|
_pm25VstdController.text = v_std.toStringAsFixed(3);
|
||||||
widget.data.pm25Vstd = v_std;
|
widget.data.pm25Vstd = v_std;
|
||||||
}
|
}
|
||||||
@ -227,6 +242,21 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
|||||||
if (_formKey.currentState!.validate()) {
|
if (_formKey.currentState!.validate()) {
|
||||||
_formKey.currentState!.save();
|
_formKey.currentState!.save();
|
||||||
|
|
||||||
|
if (widget.data.imageFront == null ||
|
||||||
|
widget.data.imageBack == null ||
|
||||||
|
widget.data.imageLeft == null ||
|
||||||
|
widget.data.imageRight == null ||
|
||||||
|
widget.data.imageChart == null ||
|
||||||
|
widget.data.imageFilterPaper == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Please attach all required photos before proceeding.'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
widget.data.pm10FlowrateResult = _pm10FlowRateResultController.text;
|
widget.data.pm10FlowrateResult = _pm10FlowRateResultController.text;
|
||||||
widget.data.pm10TotalTimeResult = _pm10TimeResultController.text;
|
widget.data.pm10TotalTimeResult = _pm10TimeResultController.text;
|
||||||
widget.data.pm10PressureResult = _pm10PressureResultController.text;
|
widget.data.pm10PressureResult = _pm10PressureResultController.text;
|
||||||
@ -355,19 +385,19 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
|||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Text("Required Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
const Text("Required Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
_buildImagePicker('Site Picture (Front)', 'front', widget.data.imageFront),
|
_buildImagePicker('Site Picture (Front)', 'front', widget.data.imageFront, (file) => widget.data.imageFront = file, isRequired: true),
|
||||||
_buildImagePicker('Site Picture (Back)', 'back', widget.data.imageBack),
|
_buildImagePicker('Site Picture (Back)', 'back', widget.data.imageBack, (file) => widget.data.imageBack = file, isRequired: true),
|
||||||
_buildImagePicker('Site Picture (Left)', 'left', widget.data.imageLeft),
|
_buildImagePicker('Site Picture (Left)', 'left', widget.data.imageLeft, (file) => widget.data.imageLeft = file, isRequired: true),
|
||||||
_buildImagePicker('Site Picture (Right)', 'right', widget.data.imageRight),
|
_buildImagePicker('Site Picture (Right)', 'right', widget.data.imageRight, (file) => widget.data.imageRight = file, isRequired: true),
|
||||||
_buildImagePicker('Chart', 'chart', widget.data.imageChart),
|
_buildImagePicker('Chart', 'chart', widget.data.imageChart, (file) => widget.data.imageChart = file, isRequired: true),
|
||||||
_buildImagePicker('Filter Paper', 'filter_paper', widget.data.imageFilterPaper),
|
_buildImagePicker('Filter Paper', 'filter_paper', widget.data.imageFilterPaper, (file) => widget.data.imageFilterPaper = file, isRequired: true),
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Text("Optional Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
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 1', 'optional_01', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, remarkController: _optionalRemark1Controller),
|
||||||
_buildImagePicker('Optional Photo 2', 'optional_02', widget.data.optionalImage2, remarkController: _optionalRemark2Controller),
|
_buildImagePicker('Optional Photo 2', 'optional_02', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, remarkController: _optionalRemark2Controller),
|
||||||
_buildImagePicker('Optional Photo 3', 'optional_03', widget.data.optionalImage3, remarkController: _optionalRemark3Controller),
|
_buildImagePicker('Optional Photo 3', 'optional_03', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, remarkController: _optionalRemark3Controller),
|
||||||
_buildImagePicker('Optional Photo 4', 'optional_04', widget.data.optionalImage4, remarkController: _optionalRemark4Controller),
|
_buildImagePicker('Optional Photo 4', 'optional_04', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, remarkController: _optionalRemark4Controller),
|
||||||
|
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@ -389,26 +419,41 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, {TextEditingController? remarkController}) {
|
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) {
|
||||||
return Card(
|
return Padding(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
if (imageFile != null)
|
||||||
|
Stack(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8.0),
|
||||||
|
child: Image.file(imageFile, key: UniqueKey(), height: 150, width: double.infinity, fit: BoxFit.cover)),
|
||||||
Container(
|
Container(
|
||||||
height: 150,
|
margin: const EdgeInsets.all(4),
|
||||||
width: double.infinity,
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(8)),
|
color: Colors.black.withOpacity(0.6),
|
||||||
child: imageFile != null ? Image.file(imageFile, fit: BoxFit.cover, key: UniqueKey()) : const Center(child: Icon(Icons.image, size: 50, color: Colors.grey)),
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
|
child: IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white, size: 20),
|
||||||
|
onPressed: () => setState(() => setImageCallback(null)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton.icon(icon: const Icon(Icons.photo_library), label: const Text('Gallery'), onPressed: () => _pickImage(ImageSource.gallery, imageInfo)),
|
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")),
|
||||||
ElevatedButton.icon(icon: const Icon(Icons.camera_alt), label: const Text('Camera'), onPressed: () => _pickImage(ImageSource.camera, imageInfo)),
|
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (remarkController != null)
|
if (remarkController != null)
|
||||||
@ -416,12 +461,15 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
|||||||
padding: const EdgeInsets.only(top: 8.0),
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: remarkController,
|
controller: remarkController,
|
||||||
decoration: InputDecoration(labelText: 'Remarks for $title', border: const OutlineInputBorder()),
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Remarks for $title',
|
||||||
|
hintText: 'Add an optional remark...',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,7 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
|
|||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
bool _isDataInitialized = false;
|
bool _isDataInitialized = false;
|
||||||
|
bool _isPickingImage = false;
|
||||||
|
|
||||||
List<Map<String, dynamic>> _allClients = [];
|
List<Map<String, dynamic>> _allClients = [];
|
||||||
List<Map<String, dynamic>> _allStations = [];
|
List<Map<String, dynamic>> _allStations = [];
|
||||||
@ -92,6 +93,7 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
|
|||||||
|
|
||||||
if (currentUser != null) {
|
if (currentUser != null) {
|
||||||
widget.data.installationUserId = currentUser['user_id'];
|
widget.data.installationUserId = currentUser['user_id'];
|
||||||
|
widget.data.installationUserName = currentUser['first_name'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clients != null && clients.isNotEmpty) {
|
if (clients != null && clients.isNotEmpty) {
|
||||||
@ -169,8 +171,28 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickImage(ImageSource source, String imageInfo) async {
|
void _showOrientationDialog() {
|
||||||
// --- FIX: Prevent picking image if station is not selected ---
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text("Incorrect Image Orientation"),
|
||||||
|
content: const Text("Required photos must be taken in a horizontal (landscape) orientation."),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
child: const Text("OK"),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
|
||||||
|
if (_isPickingImage) return;
|
||||||
|
setState(() => _isPickingImage = true);
|
||||||
|
|
||||||
if (widget.data.stationID == null || widget.data.stationID!.isEmpty) {
|
if (widget.data.stationID == null || widget.data.stationID!.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@ -178,6 +200,7 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
|
|||||||
backgroundColor: Colors.orange,
|
backgroundColor: Colors.orange,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
setState(() => _isPickingImage = false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,44 +211,54 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
|
|||||||
source,
|
source,
|
||||||
stationCode: stationCode,
|
stationCode: stationCode,
|
||||||
imageInfo: imageInfo.toUpperCase(),
|
imageInfo: imageInfo.toUpperCase(),
|
||||||
|
isRequired: isRequired,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (imageFile != null) {
|
if (imageFile == null && isRequired) {
|
||||||
|
_showOrientationDialog();
|
||||||
|
} else if (imageFile != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
switch (imageInfo) {
|
setImageCallback(imageFile);
|
||||||
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 '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;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
setState(() => _isPickingImage = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, {TextEditingController? remarkController}) {
|
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) {
|
||||||
return Card(
|
return Padding(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
if (imageFile != null)
|
||||||
|
Stack(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8.0),
|
||||||
|
child: Image.file(imageFile, key: UniqueKey(), height: 150, width: double.infinity, fit: BoxFit.cover)),
|
||||||
Container(
|
Container(
|
||||||
height: 150,
|
margin: const EdgeInsets.all(4),
|
||||||
width: double.infinity,
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(8)),
|
color: Colors.black.withOpacity(0.6),
|
||||||
child: imageFile != null ? Image.file(imageFile, fit: BoxFit.cover, key: UniqueKey()) : const Center(child: Icon(Icons.image, size: 50, color: Colors.grey)),
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
|
child: IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white, size: 20),
|
||||||
|
onPressed: () => setState(() => setImageCallback(null)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton.icon(icon: const Icon(Icons.photo_library), label: const Text('Gallery'), onPressed: () => _pickImage(ImageSource.gallery, imageInfo)),
|
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")),
|
||||||
ElevatedButton.icon(icon: const Icon(Icons.camera_alt), label: const Text('Camera'), onPressed: () => _pickImage(ImageSource.camera, imageInfo)),
|
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (remarkController != null)
|
if (remarkController != null)
|
||||||
@ -233,18 +266,35 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
|
|||||||
padding: const EdgeInsets.only(top: 8.0),
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: remarkController,
|
controller: remarkController,
|
||||||
decoration: InputDecoration(labelText: 'Remarks for $title', border: const OutlineInputBorder()),
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Remarks for $title',
|
||||||
|
hintText: 'Add an optional remark...',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSubmitPressed() {
|
void _onSubmitPressed() {
|
||||||
if (_formKey.currentState!.validate()) {
|
if (_formKey.currentState!.validate()) {
|
||||||
_formKey.currentState!.save();
|
_formKey.currentState!.save();
|
||||||
|
|
||||||
|
if (widget.data.imageFront == null ||
|
||||||
|
widget.data.imageBack == null ||
|
||||||
|
widget.data.imageLeft == null ||
|
||||||
|
widget.data.imageRight == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Please attach all required photos before proceeding.'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
widget.data.optionalRemark1 = _optionalRemark1Controller.text;
|
widget.data.optionalRemark1 = _optionalRemark1Controller.text;
|
||||||
widget.data.optionalRemark2 = _optionalRemark2Controller.text;
|
widget.data.optionalRemark2 = _optionalRemark2Controller.text;
|
||||||
widget.data.optionalRemark3 = _optionalRemark3Controller.text;
|
widget.data.optionalRemark3 = _optionalRemark3Controller.text;
|
||||||
@ -356,7 +406,6 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
|
|||||||
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Station ID / Location", border: OutlineInputBorder())),
|
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Station ID / Location", border: OutlineInputBorder())),
|
||||||
onChanged: (value) => setState(() {
|
onChanged: (value) => setState(() {
|
||||||
_selectedLocation = value;
|
_selectedLocation = value;
|
||||||
// Directly update the data object here to ensure it's available for image naming
|
|
||||||
widget.data.stationID = value?['station_code'];
|
widget.data.stationID = value?['station_code'];
|
||||||
widget.data.locationName = value?['station_name'];
|
widget.data.locationName = value?['station_name'];
|
||||||
}),
|
}),
|
||||||
@ -384,17 +433,17 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
const Text("Required Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
const Text("Required Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
_buildImagePicker('Site Picture (Front)', 'front', widget.data.imageFront),
|
_buildImagePicker('Site Picture (Front)', 'front', widget.data.imageFront, (file) => widget.data.imageFront = file, isRequired: true),
|
||||||
_buildImagePicker('Site Picture (Back)', 'back', widget.data.imageBack),
|
_buildImagePicker('Site Picture (Back)', 'back', widget.data.imageBack, (file) => widget.data.imageBack = file, isRequired: true),
|
||||||
_buildImagePicker('Site Picture (Left)', 'left', widget.data.imageLeft),
|
_buildImagePicker('Site Picture (Left)', 'left', widget.data.imageLeft, (file) => widget.data.imageLeft = file, isRequired: true),
|
||||||
_buildImagePicker('Site Picture (Right)', 'right', widget.data.imageRight),
|
_buildImagePicker('Site Picture (Right)', 'right', widget.data.imageRight, (file) => widget.data.imageRight = file, isRequired: true),
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Text("Optional Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
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 1', 'optional_01', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, remarkController: _optionalRemark1Controller),
|
||||||
_buildImagePicker('Optional Photo 2', 'optional_02', widget.data.optionalImage2, remarkController: _optionalRemark2Controller),
|
_buildImagePicker('Optional Photo 2', 'optional_02', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, remarkController: _optionalRemark2Controller),
|
||||||
_buildImagePicker('Optional Photo 3', 'optional_03', widget.data.optionalImage3, remarkController: _optionalRemark3Controller),
|
_buildImagePicker('Optional Photo 3', 'optional_03', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, remarkController: _optionalRemark3Controller),
|
||||||
_buildImagePicker('Optional Photo 4', 'optional_04', widget.data.optionalImage4, remarkController: _optionalRemark4Controller),
|
_buildImagePicker('Optional Photo 4', 'optional_04', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, remarkController: _optionalRemark4Controller),
|
||||||
|
|
||||||
|
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// lib/services/air_sampling_service.dart
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
@ -11,11 +13,13 @@ import '../models/air_installation_data.dart';
|
|||||||
import '../models/air_collection_data.dart';
|
import '../models/air_collection_data.dart';
|
||||||
import 'api_service.dart';
|
import 'api_service.dart';
|
||||||
import 'local_storage_service.dart';
|
import 'local_storage_service.dart';
|
||||||
|
import 'telegram_service.dart';
|
||||||
|
|
||||||
/// A dedicated service to handle all business logic for the Air Manual Sampling feature.
|
/// A dedicated service to handle all business logic for the Air Manual Sampling feature.
|
||||||
class AirSamplingService {
|
class AirSamplingService {
|
||||||
final ApiService _apiService = ApiService();
|
final ApiService _apiService = ApiService();
|
||||||
final LocalStorageService _localStorageService = LocalStorageService();
|
final LocalStorageService _localStorageService = LocalStorageService();
|
||||||
|
final TelegramService _telegramService = TelegramService();
|
||||||
|
|
||||||
/// Picks an image from the specified source, adds a timestamp watermark,
|
/// Picks an image from the specified source, adds a timestamp watermark,
|
||||||
/// and saves it to a temporary directory with a standardized name.
|
/// and saves it to a temporary directory with a standardized name.
|
||||||
@ -23,7 +27,8 @@ 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
|
String processType = 'INSTALL',
|
||||||
|
required bool isRequired,
|
||||||
}) async {
|
}) async {
|
||||||
final picker = ImagePicker();
|
final picker = ImagePicker();
|
||||||
final XFile? photo = await picker.pickImage(
|
final XFile? photo = await picker.pickImage(
|
||||||
@ -34,6 +39,12 @@ class AirSamplingService {
|
|||||||
img.Image? originalImage = img.decodeImage(bytes);
|
img.Image? originalImage = img.decodeImage(bytes);
|
||||||
if (originalImage == null) return null;
|
if (originalImage == null) return null;
|
||||||
|
|
||||||
|
// MODIFIED: Enforce landscape orientation for required photos
|
||||||
|
if (isRequired && originalImage.height > originalImage.width) {
|
||||||
|
debugPrint("Image orientation check failed: Image must be in landscape mode.");
|
||||||
|
return null; // Return null to indicate failure
|
||||||
|
}
|
||||||
|
|
||||||
final String watermarkTimestamp =
|
final String watermarkTimestamp =
|
||||||
DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
|
DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
|
||||||
final font = img.arial24;
|
final font = img.arial24;
|
||||||
@ -59,17 +70,44 @@ class AirSamplingService {
|
|||||||
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
|
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleInstallationSuccessAlert(AirInstallationData data, {required bool isDataOnly}) async {
|
||||||
|
try {
|
||||||
|
final message = data.generateInstallationTelegramAlert(isDataOnly: isDataOnly);
|
||||||
|
final bool wasSent = await _telegramService.sendAlertImmediately('air_manual', message);
|
||||||
|
if (!wasSent) {
|
||||||
|
await _telegramService.queueMessage('air_manual', message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Failed to handle Air Manual Installation Telegram alert: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleCollectionSuccessAlert(AirCollectionData data, AirInstallationData installationData, {required bool isDataOnly}) async {
|
||||||
|
try {
|
||||||
|
final message = data.generateCollectionTelegramAlert(installationData, isDataOnly: isDataOnly);
|
||||||
|
final bool wasSent = await _telegramService.sendAlertImmediately('air_manual', message);
|
||||||
|
if (!wasSent) {
|
||||||
|
await _telegramService.queueMessage('air_manual', message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Failed to handle Air Manual Collection Telegram alert: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Orchestrates a two-step submission process for air installation samples.
|
/// Orchestrates a two-step submission process for air installation samples.
|
||||||
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data) async {
|
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data) async {
|
||||||
// --- OFFLINE-FIRST HELPER ---
|
// --- OFFLINE-FIRST HELPER ---
|
||||||
Future<Map<String, dynamic>> saveLocally() async {
|
Future<Map<String, dynamic>> saveLocally(String status, String message) async {
|
||||||
debugPrint("Saving installation locally...");
|
debugPrint("Saving installation locally with status: $status");
|
||||||
data.status = 'L1'; // Mark as Locally Saved, Pending Submission
|
data.status = status; // Use the provided status
|
||||||
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
|
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
|
||||||
return {
|
return {'status': status, 'message': message};
|
||||||
'status': 'L1',
|
}
|
||||||
'message': 'No connection or server error. Installation data saved locally.',
|
|
||||||
};
|
// If the record's text data is already on the server, skip directly to image upload.
|
||||||
|
if (data.status == 'L2_PENDING_IMAGES' && data.airManId != null) {
|
||||||
|
debugPrint("Retrying image upload for existing record ID: ${data.airManId}");
|
||||||
|
return await _uploadInstallationImagesAndUpdate(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- STEP 1: SUBMIT TEXT DATA ---
|
// --- STEP 1: SUBMIT TEXT DATA ---
|
||||||
@ -78,61 +116,69 @@ class AirSamplingService {
|
|||||||
|
|
||||||
if (textDataResult['success'] != true) {
|
if (textDataResult['success'] != true) {
|
||||||
debugPrint("Failed to submit text data. Reason: ${textDataResult['message']}");
|
debugPrint("Failed to submit text data. Reason: ${textDataResult['message']}");
|
||||||
return await saveLocally();
|
return await saveLocally('L1', 'No connection or server error. Installation data saved locally.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- NECESSARY FIX: Safely parse the record ID from the server response ---
|
// --- NECESSARY FIX: Safely parse the record ID from the server response ---
|
||||||
final dynamic recordIdFromServer = textDataResult['data']?['air_man_id'];
|
final dynamic recordIdFromServer = textDataResult['data']?['air_man_id'];
|
||||||
if (recordIdFromServer == null) {
|
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('L1', 'Data submitted, but server response was invalid.');
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint("Text data submitted successfully. Received record ID: $recordIdFromServer");
|
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());
|
final int? parsedRecordId = int.tryParse(recordIdFromServer.toString());
|
||||||
|
|
||||||
if (parsedRecordId == null) {
|
if (parsedRecordId == null) {
|
||||||
debugPrint("Could not parse the received record ID: $recordIdFromServer");
|
debugPrint("Could not parse the received record ID: $recordIdFromServer");
|
||||||
return await saveLocally(); // Treat as a failure if ID is invalid
|
return await saveLocally('L1', 'Data submitted, but server response was invalid.');
|
||||||
}
|
}
|
||||||
|
|
||||||
data.airManId = parsedRecordId; // Assign the correctly typed integer ID
|
data.airManId = parsedRecordId;
|
||||||
|
|
||||||
// --- STEP 2: UPLOAD IMAGE FILES ---
|
// --- STEP 2: UPLOAD IMAGE FILES ---
|
||||||
|
return await _uploadInstallationImagesAndUpdate(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reusable function for handling the image upload and local data update logic.
|
||||||
|
Future<Map<String, dynamic>> _uploadInstallationImagesAndUpdate(AirInstallationData data) async {
|
||||||
final filesToUpload = data.getImagesForUpload();
|
final filesToUpload = data.getImagesForUpload();
|
||||||
if (filesToUpload.isEmpty) {
|
if (filesToUpload.isEmpty) {
|
||||||
debugPrint("No images to upload. Submission complete.");
|
debugPrint("No images to upload. Submission complete.");
|
||||||
data.status = 'S1'; // Server Pending (no images needed)
|
data.status = 'S1'; // Server Pending (no images needed)
|
||||||
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
|
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
|
||||||
|
_handleInstallationSuccessAlert(data, isDataOnly: true);
|
||||||
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 $parsedRecordId...");
|
debugPrint("Step 2: Uploading ${filesToUpload.length} images for record ID ${data.airManId}...");
|
||||||
final imageUploadResult = await _apiService.air.uploadInstallationImages(
|
final imageUploadResult = await _apiService.air.uploadInstallationImages(
|
||||||
airManId: parsedRecordId.toString(), // The API itself needs a string
|
airManId: data.airManId.toString(),
|
||||||
files: filesToUpload,
|
files: filesToUpload,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (imageUploadResult['success'] != true) {
|
if (imageUploadResult['success'] != true) {
|
||||||
debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}");
|
debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}");
|
||||||
return await saveLocally();
|
data.status = 'L2_PENDING_IMAGES';
|
||||||
|
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
|
||||||
|
return {
|
||||||
|
'status': 'L2_PENDING_IMAGES',
|
||||||
|
'message': 'Data submitted, but image upload failed. Saved locally for retry.',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint("Images uploaded successfully.");
|
debugPrint("Images uploaded successfully.");
|
||||||
data.status = 'S2'; // Server Pending (images uploaded)
|
data.status = 'S2'; // Server Pending (images uploaded)
|
||||||
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
|
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
|
||||||
|
_handleInstallationSuccessAlert(data, isDataOnly: false);
|
||||||
return {
|
return {
|
||||||
'status': 'S2',
|
'status': 'S2',
|
||||||
'message': 'Installation data and images submitted successfully.',
|
'message': 'Installation data and images submitted successfully.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// 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, AirInstallationData installationData) async {
|
||||||
// --- OFFLINE-FIRST HELPER (CORRECTED) ---
|
// --- OFFLINE-FIRST HELPER (CORRECTED) ---
|
||||||
Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async {
|
Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async {
|
||||||
debugPrint("Saving collection data locally with status: $newStatus");
|
debugPrint("Saving collection data locally with status: $newStatus");
|
||||||
@ -152,6 +198,12 @@ class AirSamplingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the record's text data is already on the server, skip directly to image upload.
|
||||||
|
if (data.status == 'L4_PENDING_IMAGES' && data.airManId != null) {
|
||||||
|
debugPrint("Retrying collection image upload for existing record ID: ${data.airManId}");
|
||||||
|
return await _uploadCollectionImagesAndUpdate(data, installationData);
|
||||||
|
}
|
||||||
|
|
||||||
// --- STEP 1: SUBMIT TEXT DATA ---
|
// --- STEP 1: SUBMIT TEXT DATA ---
|
||||||
debugPrint("Step 1: Submitting collection text data...");
|
debugPrint("Step 1: Submitting collection text data...");
|
||||||
final textDataResult = await _apiService.post('air/manual/collection', data.toJson());
|
final textDataResult = await _apiService.post('air/manual/collection', data.toJson());
|
||||||
@ -163,10 +215,34 @@ class AirSamplingService {
|
|||||||
debugPrint("Collection text data submitted successfully.");
|
debugPrint("Collection text data submitted successfully.");
|
||||||
|
|
||||||
// --- STEP 2: UPLOAD IMAGE FILES ---
|
// --- STEP 2: UPLOAD IMAGE FILES ---
|
||||||
|
return await _uploadCollectionImagesAndUpdate(data, installationData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reusable function for handling the collection image upload and local data update logic.
|
||||||
|
Future<Map<String, dynamic>> _uploadCollectionImagesAndUpdate(AirCollectionData data, AirInstallationData installationData) async {
|
||||||
|
// --- 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 (logIndex != -1) {
|
||||||
|
final installationLog = allLogs[logIndex];
|
||||||
|
installationLog['collectionData'] = data.toMap();
|
||||||
|
installationLog['status'] = newStatus;
|
||||||
|
await _localStorageService.saveAirSamplingRecord(installationLog, data.installationRefID!);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'status': newStatus,
|
||||||
|
'message': message ?? 'No connection or server error. Data saved locally.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
final filesToUpload = data.getImagesForUpload();
|
final filesToUpload = data.getImagesForUpload();
|
||||||
if (filesToUpload.isEmpty) {
|
if (filesToUpload.isEmpty) {
|
||||||
debugPrint("No collection images to upload. Submission complete.");
|
debugPrint("No collection images to upload. Submission complete.");
|
||||||
await updateAndSaveLocally('S3'); // S3 = Server Completed
|
await updateAndSaveLocally('S3'); // S3 = Server Completed
|
||||||
|
_handleCollectionSuccessAlert(data, installationData, isDataOnly: true);
|
||||||
return {'status': 'S3', 'message': 'Collection data submitted successfully.'};
|
return {'status': 'S3', 'message': 'Collection data submitted successfully.'};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,18 +254,20 @@ class AirSamplingService {
|
|||||||
|
|
||||||
if (imageUploadResult['success'] != true) {
|
if (imageUploadResult['success'] != true) {
|
||||||
debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}");
|
debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}");
|
||||||
// Use a new status 'L4' to indicate text submitted but images failed
|
// Use status 'L4_PENDING_IMAGES' to indicate text submitted but images failed
|
||||||
return await updateAndSaveLocally('L4', message: 'Data submitted, but image upload failed. Saved locally for retry.');
|
return await updateAndSaveLocally('L4_PENDING_IMAGES', message: 'Data submitted, but image upload failed. Saved locally for retry.');
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint("Images uploaded successfully.");
|
debugPrint("Images uploaded successfully.");
|
||||||
await updateAndSaveLocally('S3'); // S3 = Server Completed
|
await updateAndSaveLocally('S3'); // S3 = Server Completed
|
||||||
|
_handleCollectionSuccessAlert(data, installationData, isDataOnly: false);
|
||||||
return {
|
return {
|
||||||
'status': 'S3',
|
'status': 'S3',
|
||||||
'message': 'Collection data and images submitted successfully.',
|
'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.
|
||||||
Future<List<AirInstallationData>> getPendingInstallations() async {
|
Future<List<AirInstallationData>> getPendingInstallations() async {
|
||||||
debugPrint("Fetching pending installations from local storage...");
|
debugPrint("Fetching pending installations from local storage...");
|
||||||
|
|||||||
@ -10,16 +10,24 @@ class TelegramService {
|
|||||||
|
|
||||||
bool _isProcessing = false;
|
bool _isProcessing = false;
|
||||||
|
|
||||||
|
Future<String> _getChatIdForModule(String module) async {
|
||||||
|
switch (module) {
|
||||||
|
case 'marine_in_situ':
|
||||||
|
return await _settingsService.getInSituChatId();
|
||||||
|
case 'marine_tarball':
|
||||||
|
return await _settingsService.getTarballChatId();
|
||||||
|
case 'air_manual': // ADDED THIS CASE
|
||||||
|
return await _settingsService.getAirManualChatId();
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tries to send an alert immediately over the network.
|
/// Tries to send an alert immediately over the network.
|
||||||
/// Returns `true` on success, `false` on failure.
|
/// Returns `true` on success, `false` on failure.
|
||||||
Future<bool> sendAlertImmediately(String module, String message) async {
|
Future<bool> sendAlertImmediately(String module, String message) async {
|
||||||
debugPrint("[TelegramService] Attempting to send alert immediately...");
|
debugPrint("[TelegramService] Attempting to send alert immediately for module: $module");
|
||||||
String chatId = '';
|
String chatId = await _getChatIdForModule(module);
|
||||||
if (module == 'marine_in_situ') {
|
|
||||||
chatId = await _settingsService.getInSituChatId();
|
|
||||||
} else if (module == 'marine_tarball') {
|
|
||||||
chatId = await _settingsService.getTarballChatId();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chatId.isEmpty) {
|
if (chatId.isEmpty) {
|
||||||
debugPrint("[TelegramService] ❌ Cannot send immediately. Chat ID for module '$module' is not configured.");
|
debugPrint("[TelegramService] ❌ Cannot send immediately. Chat ID for module '$module' is not configured.");
|
||||||
@ -42,12 +50,7 @@ class TelegramService {
|
|||||||
|
|
||||||
/// Saves an alert to the local database queue. (This is now the fallback)
|
/// Saves an alert to the local database queue. (This is now the fallback)
|
||||||
Future<void> queueMessage(String module, String message) async {
|
Future<void> queueMessage(String module, String message) async {
|
||||||
String chatId = '';
|
String chatId = await _getChatIdForModule(module);
|
||||||
if (module == 'marine_in_situ') {
|
|
||||||
chatId = await _settingsService.getInSituChatId();
|
|
||||||
} else if (module == 'marine_tarball') {
|
|
||||||
chatId = await _settingsService.getTarballChatId();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chatId.isEmpty) {
|
if (chatId.isEmpty) {
|
||||||
debugPrint("[TelegramService] ❌ ERROR: Cannot queue alert. Chat ID for module '$module' is not configured.");
|
debugPrint("[TelegramService] ❌ ERROR: Cannot queue alert. Chat ID for module '$module' is not configured.");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user