fix air manual logic error and data log status

This commit is contained in:
ALim Aidrus 2025-08-18 20:53:09 +08:00
parent 475e645d25
commit c2c4d785e5
9 changed files with 410 additions and 247 deletions

View File

@ -1,4 +1,5 @@
import 'dart:io';
import 'air_installation_data.dart';
class AirCollectionData {
// Link to the original installation
@ -33,6 +34,7 @@ class AirCollectionData {
// General
String? remarks;
int? collectionUserId;
String? collectionUserName; // To hold the user's name for alerts
String? status;
// Image Files for Collection
@ -75,6 +77,7 @@ class AirCollectionData {
this.pm25Vstd,
this.remarks,
this.collectionUserId,
this.collectionUserName,
this.status,
this.imageFront,
this.imageBack,
@ -92,6 +95,19 @@ class AirCollectionData {
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) {
File? fileFromPath(String? path) => path != null ? File(path) : null;
@ -125,6 +141,7 @@ class AirCollectionData {
pm25PressureResult: map['air_man_collection_pm25_pressure_result'],
pm25Vstd: parseDouble(map['air_man_collection_pm25_vstd']),
remarks: map['air_man_collection_remarks'],
collectionUserName: map['collectionUserName'],
status: map['status'],
optionalRemark1: map['optionalRemark1'],
optionalRemark2: map['optionalRemark2'],
@ -167,6 +184,7 @@ class AirCollectionData {
'air_man_collection_pm25_pressure_result': pm25PressureResult,
'air_man_collection_pm25_vstd': pm25Vstd,
'air_man_collection_remarks': remarks,
'collectionUserName': collectionUserName,
'status': status,
'optionalRemark1': optionalRemark1,
'optionalRemark2': optionalRemark2,
@ -230,4 +248,4 @@ class AirCollectionData {
if (optionalImage4 != null) files['optional_04'] = optionalImage4!;
return files;
}
}
}

View File

@ -21,6 +21,7 @@ class AirInstallationData {
String? pm25FilterId;
String? remark;
int? installationUserId;
String? installationUserName; // To hold the user's name for alerts
File? imageFront;
File? imageBack;
@ -47,7 +48,6 @@ class AirInstallationData {
String? status;
// NECESSARY ADDITION: For handling nested collection data during offline saves.
AirCollectionData? collectionData;
AirInstallationData({
@ -68,6 +68,7 @@ class AirInstallationData {
this.pm25FilterId,
this.remark,
this.installationUserId,
this.installationUserName,
this.imageFront,
this.imageBack,
this.imageLeft,
@ -89,12 +90,22 @@ class AirInstallationData {
this.optionalImage3Path,
this.optionalImage4Path,
this.status,
this.collectionData, // NECESSARY ADDITION: Add to constructor
this.collectionData,
});
// **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.
String generateInstallationTelegramAlert({required bool isDataOnly}) {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & 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() {
return {
'refID': refID,
@ -114,12 +125,12 @@ class AirInstallationData {
'pm25FilterId': pm25FilterId,
'remark': remark,
'installationUserId': installationUserId,
'installationUserName': installationUserName,
'status': status,
'optionalRemark1': optionalRemark1,
'optionalRemark2': optionalRemark2,
'optionalRemark3': optionalRemark3,
'optionalRemark4': optionalRemark4,
// Pass the actual File objects so they can be copied
'imageFront': imageFront,
'imageBack': imageBack,
'imageLeft': imageLeft,
@ -128,7 +139,6 @@ class AirInstallationData {
'optionalImage2': optionalImage2,
'optionalImage3': optionalImage3,
'optionalImage4': optionalImage4,
// Pass the nested collection data if it exists
'collectionData': collectionData?.toMap(),
};
}
@ -154,8 +164,8 @@ class AirInstallationData {
pm25FilterId: json['pm25FilterId'],
remark: json['remark'],
installationUserId: json['installationUserId'],
installationUserName: json['installationUserName'],
status: json['status'],
// When deserializing, we use the '...Path' keys created by the local storage service
imageFront: fileFromPath(json['imageFrontPath']),
imageBack: fileFromPath(json['imageBackPath']),
imageLeft: fileFromPath(json['imageLeftPath']),
@ -168,7 +178,6 @@ class AirInstallationData {
optionalRemark2: json['optionalRemark2'],
optionalRemark3: json['optionalRemark3'],
optionalRemark4: json['optionalRemark4'],
// NECESSARY ADDITION: Deserialize nested collection data
collectionData: json['collectionData'] != null && json['collectionData'] is Map
? AirCollectionData.fromMap(json['collectionData'])
: null,
@ -179,7 +188,7 @@ class AirInstallationData {
return {
'air_man_station_code': stationID,
'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_time': installationTime,
'air_man_installation_weather': weather,
@ -207,4 +216,4 @@ class AirInstallationData {
if (optionalImage4 != null) files['optional_04'] = optionalImage4!;
return files;
}
}
}

View File

@ -30,10 +30,10 @@ class AirHomePage extends StatelessWidget {
label: "Manual",
isParent: true,
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.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.image, label: "Image Request", route: '/air/manual/image-request'),
],

View File

@ -43,7 +43,8 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
setState(() => _isLoading = true);
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);
@ -91,7 +92,6 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
padding: const EdgeInsets.all(16.0),
child: DropdownSearch<AirInstallationData>(
items: pendingInstallations,
// **THE FIX**: Changed u.samplingDate to u.installationDate
itemAsString: (AirInstallationData u) =>
"${u.stationID} - ${u.locationName} (${u.installationDate} ${u.installationTime ?? ''})",
popupProps: const PopupProps.menu(
@ -152,4 +152,4 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
),
);
}
}
}

View File

@ -42,12 +42,17 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
final LocalStorageService _localStorageService = LocalStorageService();
final ApiService _apiService = ApiService();
// Raw data lists
List<SubmissionLogEntry> _installationLogs = [];
List<SubmissionLogEntry> _collectionLogs = [];
// Filtered lists for the UI
List<SubmissionLogEntry> _filteredInstallationLogs = [];
List<SubmissionLogEntry> _filteredCollectionLogs = [];
// Per-category search controllers
final Map<String, TextEditingController> _searchControllers = {};
bool _isLoading = true;
final Map<String, bool> _isResubmitting = {};
@ -75,11 +80,9 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
for (var log in airLogs) {
try {
// Determine if this is an installation or collection
final hasCollectionData = log['collectionData'] != null && (log['collectionData'] as Map).isNotEmpty;
final isInstallation = !hasCollectionData && log['air_man_id'] != null;
// Get station info from the correct location
final stationInfo = isInstallation
? log['stationInfo'] ?? {}
: log['collectionData']?['stationInfo'] ?? {};
@ -87,15 +90,10 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
final stationName = stationInfo['station_name'] ?? 'Station ${log['stationID'] ?? 'Unknown'}';
final stationCode = stationInfo['station_code'] ?? log['stationID'] ?? 'N/A';
// Get correct timestamp
final submissionDateTime = isInstallation
? _parseInstallationDateTime(log)
: _parseCollectionDateTime(log['collectionData']);
debugPrint('Processed ${isInstallation ? 'Installation' : 'Collection'} log:');
debugPrint(' Station: $stationName ($stationCode)');
debugPrint(' Original Date: ${submissionDateTime.toString()}');
final entry = SubmissionLogEntry(
type: isInstallation ? 'Installation' : 'Collection',
title: stationName,
@ -117,7 +115,6 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
}
}
// Sort by datetime descending
tempInstallation.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) {
try {
// First try to get the installation date and time
if (log['installationDate'] != null) {
final date = log['installationDate'];
final time = log['installationTime'] ?? '00:00';
return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time');
}
// Fallback to sampling date if installation date not available
if (log['samplingDate'] != null) {
final date = log['samplingDate'];
final time = log['samplingTime'] ?? '00:00';
return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time');
}
// If no dates found, check in the raw data structure
if (log['data'] != null && log['data']['installationDate'] != null) {
final date = log['data']['installationDate'];
final time = log['data']['installationTime'] ?? '00:00';
return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time');
}
debugPrint('No valid installation date found in log: ${log.keys}');
return DateTime.now();
} catch (e) {
debugPrint('Error parsing installation date: $e');
@ -164,26 +144,15 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
DateTime _parseCollectionDateTime(Map<String, dynamic>? collectionData) {
try {
if (collectionData == null) {
debugPrint('Collection data is null');
return DateTime.now();
}
if (collectionData == null) return DateTime.now();
final dateKey = 'air_man_collection_date';
final timeKey = 'air_man_collection_time';
// First try the direct fields
if (collectionData['collectionDate'] != null) {
final date = collectionData['collectionDate'];
final time = collectionData['collectionTime'] ?? '00:00';
if (collectionData[dateKey] != null) {
final date = collectionData[dateKey];
final time = collectionData[timeKey] ?? '00:00';
return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time');
}
// Check for nested data structure
if (collectionData['data'] != null && collectionData['data']['collectionDate'] != null) {
final date = collectionData['data']['collectionDate'];
final time = collectionData['data']['collectionTime'] ?? '00:00';
return DateFormat('yyyy-MM-dd HH:mm').parse('$date $time');
}
debugPrint('No valid collection date found in data: ${collectionData.keys}');
return DateTime.now();
} catch (e) {
debugPrint('Error parsing collection date: $e');
@ -192,14 +161,18 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
}
String _getStatusMessage(Map<String, dynamic> log) {
if (log['status'] == 'S2' || log['status'] == 'S3') {
return 'Successfully submitted to server';
} else if (log['status'] == 'L1' || log['status'] == 'L3') {
return 'Saved locally (pending submission)';
} else if (log['status'] == 'L4') {
return 'Partial submission (images failed)';
switch (log['status']) {
case 'S2':
case 'S3':
return 'Successfully submitted to server';
case 'L1':
case 'L3':
return 'Saved locally (pending submission)';
case 'L4':
return 'Partial submission (images failed)';
default:
return 'Submission status unknown';
}
return 'Submission status unknown';
}
void _filterLogs() {
@ -221,13 +194,11 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
Future<void> _resubmitData(SubmissionLogEntry log) async {
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
if (mounted) {
setState(() => _isResubmitting[logKey] = true);
}
if (mounted) setState(() => _isResubmitting[logKey] = true);
try {
final logData = log.rawData;
final result = log.type == 'Installation'
log.type == 'Installation'
? await _submitInstallation(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 result = await _apiService.post('air/manual/installation', dataToResubmit.toJsonForApi());
@ -262,11 +233,9 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
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 dataToResubmit = AirCollectionData.fromMap(collectionData);
final result = await _apiService.post('air/manual/collection', dataToResubmit.toJson());
@ -278,8 +247,6 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
files: imageFiles,
);
}
return result;
}
@override
@ -293,27 +260,18 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
};
return Scaffold(
appBar: AppBar(
title: const Text('Air Sampling Status Log'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadAllLogs,
tooltip: 'Refresh logs',
),
],
),
appBar: AppBar(title: const Text('Air Sampling Status Log')),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _loadAllLogs,
child: !hasAnyLogs
? const Center(child: Text('No air sampling logs found.'))
? const Center(child: Text('No submission logs found.'))
: ListView(
padding: const EdgeInsets.all(8.0),
children: [
...logCategories.entries
.where((entry) => entry.value.isNotEmpty)
.where((entry) => entry.value.isNotEmpty) // Only show categories with logs
.map((entry) => _buildCategorySection(entry.key, entry.value)),
if (!hasFilteredLogs && hasAnyLogs)
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) {
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(
margin: const EdgeInsets.symmetric(vertical: 8.0),
@ -344,7 +304,7 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
child: TextField(
controller: _searchControllers[category],
decoration: InputDecoration(
hintText: 'Search $category logs...',
hintText: 'Search in $category...',
prefixIcon: const Icon(Icons.search, size: 20),
isDense: true,
border: const OutlineInputBorder(),
@ -361,7 +321,9 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
child: ListView.builder(
shrinkWrap: true,
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) {
final isSuccess = log.status == 'S2' || log.status == 'S3';
final logKey = log.reportId ?? log.submissionDateTime.toIso8601String();
final isResubmitting = _isResubmitting[logKey] ?? false;
final title = log.title;
final title = '${log.title} (${log.stationCode})'; // Consistent title format
final subtitle = DateFormat('yyyy-MM-dd HH:mm').format(log.submissionDateTime);
return ExpansionTile(
@ -386,12 +349,10 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
subtitle: Text(subtitle),
trailing: !isSuccess
? (isResubmitting
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 3))
? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 3))
: IconButton(
icon: const Icon(Icons.sync, color: Colors.blue),
tooltip: 'Resubmit',
onPressed: () => _resubmitData(log),
))
: null,
@ -401,12 +362,9 @@ class _AirManualDataStatusLogState extends State<AirManualDataStatusLog> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailRow('Station Code:', log.stationCode),
if (log.reportId != null) _buildDetailRow('Report ID:', log.reportId!),
_buildDetailRow('Report ID:', log.reportId ?? 'N/A'),
_buildDetailRow('Status:', log.message),
_buildDetailRow('Type:', '${log.type} Sampling'),
if (log.type == 'Collection')
_buildDetailRow('Installation ID:', log.rawData['installationRefID']?.toString() ?? 'N/A'),
_buildDetailRow('Submission Type:', log.type),
],
),
)

View File

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../../../../auth_provider.dart';
import '../../../../models/air_collection_data.dart';
import '../../../../services/air_sampling_service.dart';
@ -31,6 +32,7 @@ class AirManualCollectionWidget extends StatefulWidget {
class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
final _formKey = GlobalKey<FormState>();
bool _isPickingImage = false;
// General Controllers
final _collectionDateController = TextEditingController();
@ -73,6 +75,13 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
_collectionDateController.text = widget.data.collectionDate!;
_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
@ -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 stationCode = widget.stationCode;
@ -138,24 +168,17 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
stationCode: stationCode,
imageInfo: imageInfo.toUpperCase(),
processType: 'COLLECT',
isRequired: isRequired,
);
if (imageFile != null) {
if (imageFile == null && isRequired) {
_showOrientationDialog();
} else 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;
}
setImageCallback(imageFile);
});
}
setState(() => _isPickingImage = false);
}
void _calculateVstd(int type) {
@ -183,36 +206,28 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
const double hpaToinHg = 0.0295;
const double inHgToMmHg = 25.4;
// 1. Get time and user-input flowrate (cfm)
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);
// 2. Calculate actual pressure
double pressureHpa = double.parse(pressureCtrl.text);
double pressureInHg = pressureHpa * hpaToinHg;
double p_actual_mmHg = pressureInHg * inHgToMmHg; // result pressure
double p_actual_mmHg = pressureInHg * inHgToMmHg;
pressureResultCtrl.text = pressureInHg.toStringAsFixed(2);
// 3. Calculate average temperature in Celsius
double t_avg_celsius = (widget.initialTemp + double.parse(_finalTempController.text)) / 2.0; // ambient temperature
double t_avg_celsius = (widget.initialTemp + double.parse(_finalTempController.text)) / 2.0;
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_denominator = (760 * (273 + t_avg_celsius));
double q_std = (q_std_denominator == 0) ? 0 : q_std_numerator / q_std_denominator;
qStdCtrl.text = q_std.toStringAsFixed(5);
// 5. Final VSTD
// Formula: QSTD * Total time
double v_std = q_std * totalTime_min;
if (type == 1) { // PM10
if (type == 1) {
_pm10VstdController.text = v_std.toStringAsFixed(3);
widget.data.pm10Vstd = v_std;
} else { // PM2.5
} else {
_pm25VstdController.text = v_std.toStringAsFixed(3);
widget.data.pm25Vstd = v_std;
}
@ -227,6 +242,21 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
if (_formKey.currentState!.validate()) {
_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.pm10TotalTimeResult = _pm10TimeResultController.text;
widget.data.pm10PressureResult = _pm10PressureResultController.text;
@ -355,19 +385,19 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
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),
_buildImagePicker('Site Picture (Front)', 'front', widget.data.imageFront, (file) => widget.data.imageFront = file, isRequired: true),
_buildImagePicker('Site Picture (Back)', 'back', widget.data.imageBack, (file) => widget.data.imageBack = file, isRequired: true),
_buildImagePicker('Site Picture (Left)', 'left', widget.data.imageLeft, (file) => widget.data.imageLeft = file, isRequired: true),
_buildImagePicker('Site Picture (Right)', 'right', widget.data.imageRight, (file) => widget.data.imageRight = file, isRequired: true),
_buildImagePicker('Chart', 'chart', widget.data.imageChart, (file) => widget.data.imageChart = file, isRequired: true),
_buildImagePicker('Filter Paper', 'filter_paper', widget.data.imageFilterPaper, (file) => widget.data.imageFilterPaper = file, isRequired: true),
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),
_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, (file) => widget.data.optionalImage2 = file, remarkController: _optionalRemark2Controller),
_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, (file) => widget.data.optionalImage4 = file, remarkController: _optionalRemark4Controller),
const SizedBox(height: 30),
SizedBox(
@ -389,38 +419,56 @@ 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)),
),
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
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(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
shape: BoxShape.circle,
),
child: IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close, color: Colors.white, size: 20),
onPressed: () => setState(() => setImageCallback(null)),
),
),
],
)
else
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)),
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")),
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)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TextFormField(
controller: remarkController,
decoration: InputDecoration(labelText: 'Remarks for $title', border: const OutlineInputBorder()),
if (remarkController != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TextFormField(
controller: remarkController,
decoration: InputDecoration(
labelText: 'Remarks for $title',
hintText: 'Add an optional remark...',
border: const OutlineInputBorder(),
),
),
],
),
),
],
),
);
}
@ -452,4 +500,4 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
),
);
}
}
}

View File

@ -31,6 +31,7 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
final _formKey = GlobalKey<FormState>();
bool _isDataInitialized = false;
bool _isPickingImage = false;
List<Map<String, dynamic>> _allClients = [];
List<Map<String, dynamic>> _allStations = [];
@ -92,6 +93,7 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
if (currentUser != null) {
widget.data.installationUserId = currentUser['user_id'];
widget.data.installationUserName = currentUser['first_name'];
}
if (clients != null && clients.isNotEmpty) {
@ -169,8 +171,28 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
}
}
Future<void> _pickImage(ImageSource source, String imageInfo) async {
// --- FIX: Prevent picking image if station is not selected ---
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);
if (widget.data.stationID == null || widget.data.stationID!.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@ -178,6 +200,7 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
backgroundColor: Colors.orange,
),
);
setState(() => _isPickingImage = false);
return;
}
@ -188,56 +211,69 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
source,
stationCode: stationCode,
imageInfo: imageInfo.toUpperCase(),
isRequired: isRequired,
);
if (imageFile != null) {
if (imageFile == null && isRequired) {
_showOrientationDialog();
} else 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 '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;
}
setImageCallback(imageFile);
});
}
setState(() => _isPickingImage = false);
}
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)),
),
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
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(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
shape: BoxShape.circle,
),
child: IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close, color: Colors.white, size: 20),
onPressed: () => setState(() => setImageCallback(null)),
),
),
],
)
else
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)),
ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")),
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)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TextFormField(
controller: remarkController,
decoration: InputDecoration(labelText: 'Remarks for $title', border: const OutlineInputBorder()),
if (remarkController != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TextFormField(
controller: remarkController,
decoration: InputDecoration(
labelText: 'Remarks for $title',
hintText: 'Add an optional remark...',
border: const OutlineInputBorder(),
),
),
],
),
),
],
),
);
}
@ -245,6 +281,20 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
void _onSubmitPressed() {
if (_formKey.currentState!.validate()) {
_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.optionalRemark2 = _optionalRemark2Controller.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())),
onChanged: (value) => setState(() {
_selectedLocation = value;
// Directly update the data object here to ensure it's available for image naming
widget.data.stationID = value?['station_code'];
widget.data.locationName = value?['station_name'];
}),
@ -384,17 +433,17 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
const SizedBox(height: 16),
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('Site Picture (Front)', 'front', widget.data.imageFront, (file) => widget.data.imageFront = file, isRequired: true),
_buildImagePicker('Site Picture (Back)', 'back', widget.data.imageBack, (file) => widget.data.imageBack = file, isRequired: true),
_buildImagePicker('Site Picture (Left)', 'left', widget.data.imageLeft, (file) => widget.data.imageLeft = file, isRequired: true),
_buildImagePicker('Site Picture (Right)', 'right', widget.data.imageRight, (file) => widget.data.imageRight = file, isRequired: true),
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),
_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, (file) => widget.data.optionalImage2 = file, remarkController: _optionalRemark2Controller),
_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, (file) => widget.data.optionalImage4 = file, remarkController: _optionalRemark4Controller),
const SizedBox(height: 30),

View File

@ -1,3 +1,5 @@
// lib/services/air_sampling_service.dart
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
@ -11,11 +13,13 @@ import '../models/air_installation_data.dart';
import '../models/air_collection_data.dart';
import 'api_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.
class AirSamplingService {
final ApiService _apiService = ApiService();
final LocalStorageService _localStorageService = LocalStorageService();
final TelegramService _telegramService = TelegramService();
/// Picks an image from the specified source, adds a timestamp watermark,
/// and saves it to a temporary directory with a standardized name.
@ -23,7 +27,8 @@ class AirSamplingService {
ImageSource source, {
required String stationCode,
required String imageInfo,
String processType = 'INSTALL', // Defaults to INSTALL for backward compatibility
String processType = 'INSTALL',
required bool isRequired,
}) async {
final picker = ImagePicker();
final XFile? photo = await picker.pickImage(
@ -34,6 +39,12 @@ class AirSamplingService {
img.Image? originalImage = img.decodeImage(bytes);
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 =
DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
final font = img.arial24;
@ -59,17 +70,44 @@ class AirSamplingService {
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.
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data) async {
// --- OFFLINE-FIRST HELPER ---
Future<Map<String, dynamic>> saveLocally() async {
debugPrint("Saving installation locally...");
data.status = 'L1'; // Mark as Locally Saved, Pending Submission
Future<Map<String, dynamic>> saveLocally(String status, String message) async {
debugPrint("Saving installation locally with status: $status");
data.status = status; // Use the provided status
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
return {
'status': 'L1',
'message': 'No connection or server error. Installation data saved locally.',
};
return {'status': status, 'message': message};
}
// 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 ---
@ -78,61 +116,69 @@ class AirSamplingService {
if (textDataResult['success'] != true) {
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 ---
final dynamic recordIdFromServer = textDataResult['data']?['air_man_id'];
if (recordIdFromServer == null) {
debugPrint("Text data submitted, but did not receive a record ID.");
return await saveLocally();
return await saveLocally('L1', 'Data submitted, but server response was invalid.');
}
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
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 ---
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();
if (filesToUpload.isEmpty) {
debugPrint("No images to upload. Submission complete.");
data.status = 'S1'; // Server Pending (no images needed)
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
_handleInstallationSuccessAlert(data, isDataOnly: true);
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(
airManId: parsedRecordId.toString(), // The API itself needs a string
airManId: data.airManId.toString(),
files: filesToUpload,
);
if (imageUploadResult['success'] != true) {
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.");
data.status = 'S2'; // Server Pending (images uploaded)
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
_handleInstallationSuccessAlert(data, isDataOnly: false);
return {
'status': 'S2',
'message': 'Installation data and images submitted successfully.',
};
}
/// 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) ---
Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async {
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 ---
debugPrint("Step 1: Submitting collection text data...");
final textDataResult = await _apiService.post('air/manual/collection', data.toJson());
@ -163,10 +215,34 @@ class AirSamplingService {
debugPrint("Collection text data submitted successfully.");
// --- 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();
if (filesToUpload.isEmpty) {
debugPrint("No collection images to upload. Submission complete.");
await updateAndSaveLocally('S3'); // S3 = Server Completed
_handleCollectionSuccessAlert(data, installationData, isDataOnly: true);
return {'status': 'S3', 'message': 'Collection data submitted successfully.'};
}
@ -178,18 +254,20 @@ class AirSamplingService {
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.');
// Use status 'L4_PENDING_IMAGES' to indicate text submitted but images failed
return await updateAndSaveLocally('L4_PENDING_IMAGES', message: 'Data submitted, but image upload failed. Saved locally for retry.');
}
debugPrint("Images uploaded successfully.");
await updateAndSaveLocally('S3'); // S3 = Server Completed
_handleCollectionSuccessAlert(data, installationData, isDataOnly: false);
return {
'status': 'S3',
'message': 'Collection data and images submitted successfully.',
};
}
/// Fetches installations that are pending collection from local storage.
Future<List<AirInstallationData>> getPendingInstallations() async {
debugPrint("Fetching pending installations from local storage...");
@ -213,4 +291,4 @@ class AirSamplingService {
void dispose() {
// Clean up any resources if necessary
}
}
}

View File

@ -10,16 +10,24 @@ class TelegramService {
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.
/// Returns `true` on success, `false` on failure.
Future<bool> sendAlertImmediately(String module, String message) async {
debugPrint("[TelegramService] Attempting to send alert immediately...");
String chatId = '';
if (module == 'marine_in_situ') {
chatId = await _settingsService.getInSituChatId();
} else if (module == 'marine_tarball') {
chatId = await _settingsService.getTarballChatId();
}
debugPrint("[TelegramService] Attempting to send alert immediately for module: $module");
String chatId = await _getChatIdForModule(module);
if (chatId.isEmpty) {
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)
Future<void> queueMessage(String module, String message) async {
String chatId = '';
if (module == 'marine_in_situ') {
chatId = await _settingsService.getInSituChatId();
} else if (module == 'marine_tarball') {
chatId = await _settingsService.getTarballChatId();
}
String chatId = await _getChatIdForModule(module);
if (chatId.isEmpty) {
debugPrint("[TelegramService] ❌ ERROR: Cannot queue alert. Chat ID for module '$module' is not configured.");
@ -109,4 +112,4 @@ class TelegramService {
debugPrint("[TelegramService] ⏹️ Finished processing alert queue.");
_isProcessing = false;
}
}
}