From c2c4d785e5bf846ba63aa2052b9559bc88fdf2dc Mon Sep 17 00:00:00 2001 From: ALim Aidrus Date: Mon, 18 Aug 2025 20:53:09 +0800 Subject: [PATCH] fix air manual logic error and data log status --- lib/models/air_collection_data.dart | 20 +- lib/models/air_installation_data.dart | 31 ++-- lib/screens/air/air_home_page.dart | 4 +- .../manual/air_manual_collection_screen.dart | 6 +- lib/screens/air/manual/data_status_log.dart | 126 +++++-------- .../manual/widgets/air_manual_collection.dart | 172 +++++++++++------- .../widgets/air_manual_installation.dart | 143 ++++++++++----- lib/services/air_sampling_service.dart | 124 ++++++++++--- lib/services/telegram_service.dart | 31 ++-- 9 files changed, 410 insertions(+), 247 deletions(-) diff --git a/lib/models/air_collection_data.dart b/lib/models/air_collection_data.dart index 23363a4..1732251 100644 --- a/lib/models/air_collection_data.dart +++ b/lib/models/air_collection_data.dart @@ -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 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; } -} \ No newline at end of file +} diff --git a/lib/models/air_installation_data.dart b/lib/models/air_installation_data.dart index efcce34..10b6532 100644 --- a/lib/models/air_installation_data.dart +++ b/lib/models/air_installation_data.dart @@ -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 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; } -} \ No newline at end of file +} diff --git a/lib/screens/air/air_home_page.dart b/lib/screens/air/air_home_page.dart index ca2dae4..9cc3c1b 100644 --- a/lib/screens/air/air_home_page.dart +++ b/lib/screens/air/air_home_page.dart @@ -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'), ], diff --git a/lib/screens/air/manual/air_manual_collection_screen.dart b/lib/screens/air/manual/air_manual_collection_screen.dart index 2c7a07e..935bbb7 100644 --- a/lib/screens/air/manual/air_manual_collection_screen.dart +++ b/lib/screens/air/manual/air_manual_collection_screen.dart @@ -43,7 +43,8 @@ class _AirManualCollectionScreenState extends State { setState(() => _isLoading = true); final service = Provider.of(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 { padding: const EdgeInsets.all(16.0), child: DropdownSearch( items: pendingInstallations, - // **THE FIX**: Changed u.samplingDate to u.installationDate itemAsString: (AirInstallationData u) => "${u.stationID} - ${u.locationName} (${u.installationDate} ${u.installationTime ?? ''})", popupProps: const PopupProps.menu( @@ -152,4 +152,4 @@ class _AirManualCollectionScreenState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/air/manual/data_status_log.dart b/lib/screens/air/manual/data_status_log.dart index 8fc63cf..0f38098 100644 --- a/lib/screens/air/manual/data_status_log.dart +++ b/lib/screens/air/manual/data_status_log.dart @@ -42,12 +42,17 @@ class _AirManualDataStatusLogState extends State { final LocalStorageService _localStorageService = LocalStorageService(); final ApiService _apiService = ApiService(); + // Raw data lists List _installationLogs = []; List _collectionLogs = []; + + // Filtered lists for the UI List _filteredInstallationLogs = []; List _filteredCollectionLogs = []; + // Per-category search controllers final Map _searchControllers = {}; + bool _isLoading = true; final Map _isResubmitting = {}; @@ -75,11 +80,9 @@ class _AirManualDataStatusLogState extends State { 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 { 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 { } } - // 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 { DateTime _parseInstallationDateTime(Map 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 { DateTime _parseCollectionDateTime(Map? 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 { } String _getStatusMessage(Map 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 { Future _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 { } } - Future> _submitInstallation(Map data) async { + Future _submitInstallation(Map 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 { files: imageFiles, ); } - - return result; } - Future> _submitCollection(Map data) async { + Future _submitCollection(Map 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 { files: imageFiles, ); } - - return result; } @override @@ -293,27 +260,18 @@ class _AirManualDataStatusLogState extends State { }; 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 { ); } + // THIS SECTION IS UPDATED TO MATCH THE MARINE LOG UI Widget _buildCategorySection(String category, List 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 { 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 { 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 { ); } + // 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 { 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 { 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), ], ), ) diff --git a/lib/screens/air/manual/widgets/air_manual_collection.dart b/lib/screens/air/manual/widgets/air_manual_collection.dart index 957e56d..21ea33b 100644 --- a/lib/screens/air/manual/widgets/air_manual_collection.dart +++ b/lib/screens/air/manual/widgets/air_manual_collection.dart @@ -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 { final _formKey = GlobalKey(); + bool _isPickingImage = false; // General Controllers final _collectionDateController = TextEditingController(); @@ -73,6 +75,13 @@ class _AirManualCollectionWidgetState extends State { _collectionDateController.text = widget.data.collectionDate!; _collectionTimeController.text = widget.data.collectionTime!; + + final authProvider = Provider.of(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 { } } - Future _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(context, listen: false); final stationCode = widget.stationCode; @@ -138,24 +168,17 @@ class _AirManualCollectionWidgetState extends State { 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 { 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 { 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 { 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 { ); } - 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 { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/air/manual/widgets/air_manual_installation.dart b/lib/screens/air/manual/widgets/air_manual_installation.dart index 056f250..e7e65ff 100644 --- a/lib/screens/air/manual/widgets/air_manual_installation.dart +++ b/lib/screens/air/manual/widgets/air_manual_installation.dart @@ -31,6 +31,7 @@ class _AirManualInstallationWidgetState extends State(); bool _isDataInitialized = false; + bool _isPickingImage = false; List> _allClients = []; List> _allStations = []; @@ -92,6 +93,7 @@ class _AirManualInstallationWidgetState extends State _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 _isPickingImage = false); return; } @@ -188,56 +211,69 @@ class _AirManualInstallationWidgetState extends State _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 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 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), diff --git a/lib/services/air_sampling_service.dart b/lib/services/air_sampling_service.dart index dededc9..9218619 100644 --- a/lib/services/air_sampling_service.dart +++ b/lib/services/air_sampling_service.dart @@ -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 _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 _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> submitInstallation(AirInstallationData data) async { // --- OFFLINE-FIRST HELPER --- - Future> saveLocally() async { - debugPrint("Saving installation locally..."); - data.status = 'L1'; // Mark as Locally Saved, Pending Submission + Future> 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> _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> submitCollection(AirCollectionData data) async { + Future> submitCollection(AirCollectionData data, AirInstallationData installationData) async { // --- OFFLINE-FIRST HELPER (CORRECTED) --- Future> 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> _uploadCollectionImagesAndUpdate(AirCollectionData data, AirInstallationData installationData) async { + // --- OFFLINE-FIRST HELPER (CORRECTED) --- + Future> updateAndSaveLocally(String newStatus, {String? message}) async { + debugPrint("Saving collection data locally with status: $newStatus"); + final allLogs = await _localStorageService.getAllAirSamplingLogs(); + final logIndex = allLogs.indexWhere((log) => log['refID'] == data.installationRefID); + + if (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> getPendingInstallations() async { debugPrint("Fetching pending installations from local storage..."); @@ -213,4 +291,4 @@ class AirSamplingService { void dispose() { // Clean up any resources if necessary } -} \ No newline at end of file +} diff --git a/lib/services/telegram_service.dart b/lib/services/telegram_service.dart index a7fd416..be1e518 100644 --- a/lib/services/telegram_service.dart +++ b/lib/services/telegram_service.dart @@ -10,16 +10,24 @@ class TelegramService { bool _isProcessing = false; + Future _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 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 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; } -} \ No newline at end of file +}