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 'dart:io';
import 'air_installation_data.dart';
class AirCollectionData { class AirCollectionData {
// Link to the original installation // Link to the original installation
@ -33,6 +34,7 @@ class AirCollectionData {
// General // General
String? remarks; String? remarks;
int? collectionUserId; int? collectionUserId;
String? collectionUserName; // To hold the user's name for alerts
String? status; String? status;
// Image Files for Collection // Image Files for Collection
@ -75,6 +77,7 @@ class AirCollectionData {
this.pm25Vstd, this.pm25Vstd,
this.remarks, this.remarks,
this.collectionUserId, this.collectionUserId,
this.collectionUserName,
this.status, this.status,
this.imageFront, this.imageFront,
this.imageBack, this.imageBack,
@ -92,6 +95,19 @@ class AirCollectionData {
this.optionalRemark4, this.optionalRemark4,
}); });
String generateCollectionTelegramAlert(AirInstallationData installationData, {required bool isDataOnly}) {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final buffer = StringBuffer()
..writeln('✅ *Air Manual Collection $submissionType Submitted:*')
..writeln()
..writeln('*Station ID:* ${installationData.stationID ?? 'N/A'}')
..writeln('*Location:* ${installationData.locationName ?? 'N/A'}')
..writeln('*Collection Date:* ${collectionDate ?? 'N/A'} at ${collectionTime ?? 'N/A'}')
..writeln('*Submitted by User:* ${collectionUserName ?? 'N/A'}')
..writeln('*Status of Submission:* Successful');
return buffer.toString();
}
factory AirCollectionData.fromMap(Map<String, dynamic> map) { factory AirCollectionData.fromMap(Map<String, dynamic> map) {
File? fileFromPath(String? path) => path != null ? File(path) : null; File? fileFromPath(String? path) => path != null ? File(path) : null;
@ -125,6 +141,7 @@ class AirCollectionData {
pm25PressureResult: map['air_man_collection_pm25_pressure_result'], pm25PressureResult: map['air_man_collection_pm25_pressure_result'],
pm25Vstd: parseDouble(map['air_man_collection_pm25_vstd']), pm25Vstd: parseDouble(map['air_man_collection_pm25_vstd']),
remarks: map['air_man_collection_remarks'], remarks: map['air_man_collection_remarks'],
collectionUserName: map['collectionUserName'],
status: map['status'], status: map['status'],
optionalRemark1: map['optionalRemark1'], optionalRemark1: map['optionalRemark1'],
optionalRemark2: map['optionalRemark2'], optionalRemark2: map['optionalRemark2'],
@ -167,6 +184,7 @@ class AirCollectionData {
'air_man_collection_pm25_pressure_result': pm25PressureResult, 'air_man_collection_pm25_pressure_result': pm25PressureResult,
'air_man_collection_pm25_vstd': pm25Vstd, 'air_man_collection_pm25_vstd': pm25Vstd,
'air_man_collection_remarks': remarks, 'air_man_collection_remarks': remarks,
'collectionUserName': collectionUserName,
'status': status, 'status': status,
'optionalRemark1': optionalRemark1, 'optionalRemark1': optionalRemark1,
'optionalRemark2': optionalRemark2, 'optionalRemark2': optionalRemark2,

View File

@ -21,6 +21,7 @@ class AirInstallationData {
String? pm25FilterId; String? pm25FilterId;
String? remark; String? remark;
int? installationUserId; int? installationUserId;
String? installationUserName; // To hold the user's name for alerts
File? imageFront; File? imageFront;
File? imageBack; File? imageBack;
@ -47,7 +48,6 @@ class AirInstallationData {
String? status; String? status;
// NECESSARY ADDITION: For handling nested collection data during offline saves.
AirCollectionData? collectionData; AirCollectionData? collectionData;
AirInstallationData({ AirInstallationData({
@ -68,6 +68,7 @@ class AirInstallationData {
this.pm25FilterId, this.pm25FilterId,
this.remark, this.remark,
this.installationUserId, this.installationUserId,
this.installationUserName,
this.imageFront, this.imageFront,
this.imageBack, this.imageBack,
this.imageLeft, this.imageLeft,
@ -89,12 +90,22 @@ class AirInstallationData {
this.optionalImage3Path, this.optionalImage3Path,
this.optionalImage4Path, this.optionalImage4Path,
this.status, this.status,
this.collectionData, // NECESSARY ADDITION: Add to constructor this.collectionData,
}); });
// **CRITICAL FIX**: This method now correctly includes the raw File objects String generateInstallationTelegramAlert({required bool isDataOnly}) {
// instead of just their paths. This allows the local storage service to find final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
// and copy the installation images. final buffer = StringBuffer()
..writeln('✅ *Air Manual Installation $submissionType Submitted:*')
..writeln()
..writeln('*Station ID:* ${stationID ?? 'N/A'}')
..writeln('*Location:* ${locationName ?? 'N/A'}')
..writeln('*Installation Date:* ${installationDate ?? 'N/A'} at ${installationTime ?? 'N/A'}')
..writeln('*Submitted by User:* ${installationUserName ?? 'N/A'}')
..writeln('*Status of Submission:* Successful');
return buffer.toString();
}
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'refID': refID, 'refID': refID,
@ -114,12 +125,12 @@ class AirInstallationData {
'pm25FilterId': pm25FilterId, 'pm25FilterId': pm25FilterId,
'remark': remark, 'remark': remark,
'installationUserId': installationUserId, 'installationUserId': installationUserId,
'installationUserName': installationUserName,
'status': status, 'status': status,
'optionalRemark1': optionalRemark1, 'optionalRemark1': optionalRemark1,
'optionalRemark2': optionalRemark2, 'optionalRemark2': optionalRemark2,
'optionalRemark3': optionalRemark3, 'optionalRemark3': optionalRemark3,
'optionalRemark4': optionalRemark4, 'optionalRemark4': optionalRemark4,
// Pass the actual File objects so they can be copied
'imageFront': imageFront, 'imageFront': imageFront,
'imageBack': imageBack, 'imageBack': imageBack,
'imageLeft': imageLeft, 'imageLeft': imageLeft,
@ -128,7 +139,6 @@ class AirInstallationData {
'optionalImage2': optionalImage2, 'optionalImage2': optionalImage2,
'optionalImage3': optionalImage3, 'optionalImage3': optionalImage3,
'optionalImage4': optionalImage4, 'optionalImage4': optionalImage4,
// Pass the nested collection data if it exists
'collectionData': collectionData?.toMap(), 'collectionData': collectionData?.toMap(),
}; };
} }
@ -154,8 +164,8 @@ class AirInstallationData {
pm25FilterId: json['pm25FilterId'], pm25FilterId: json['pm25FilterId'],
remark: json['remark'], remark: json['remark'],
installationUserId: json['installationUserId'], installationUserId: json['installationUserId'],
installationUserName: json['installationUserName'],
status: json['status'], status: json['status'],
// When deserializing, we use the '...Path' keys created by the local storage service
imageFront: fileFromPath(json['imageFrontPath']), imageFront: fileFromPath(json['imageFrontPath']),
imageBack: fileFromPath(json['imageBackPath']), imageBack: fileFromPath(json['imageBackPath']),
imageLeft: fileFromPath(json['imageLeftPath']), imageLeft: fileFromPath(json['imageLeftPath']),
@ -168,7 +178,6 @@ class AirInstallationData {
optionalRemark2: json['optionalRemark2'], optionalRemark2: json['optionalRemark2'],
optionalRemark3: json['optionalRemark3'], optionalRemark3: json['optionalRemark3'],
optionalRemark4: json['optionalRemark4'], optionalRemark4: json['optionalRemark4'],
// NECESSARY ADDITION: Deserialize nested collection data
collectionData: json['collectionData'] != null && json['collectionData'] is Map collectionData: json['collectionData'] != null && json['collectionData'] is Map
? AirCollectionData.fromMap(json['collectionData']) ? AirCollectionData.fromMap(json['collectionData'])
: null, : null,
@ -179,7 +188,7 @@ class AirInstallationData {
return { return {
'air_man_station_code': stationID, 'air_man_station_code': stationID,
'air_man_sampling_date': samplingDate, 'air_man_sampling_date': samplingDate,
'air_man_client_id': clientId?.toString(), // Ensure client ID is a string for API 'air_man_client_id': clientId?.toString(),
'air_man_installation_date': installationDate, 'air_man_installation_date': installationDate,
'air_man_installation_time': installationTime, 'air_man_installation_time': installationTime,
'air_man_installation_weather': weather, 'air_man_installation_weather': weather,

View File

@ -30,10 +30,10 @@ class AirHomePage extends StatelessWidget {
label: "Manual", label: "Manual",
isParent: true, isParent: true,
children: [ children: [
SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/manual/dashboard'), //SidebarItem(icon: Icons.dashboard, label: "Dashboard", route: '/air/manual/dashboard'),
SidebarItem(icon: Icons.construction, label: "Installation", route: '/air/manual/installation'), SidebarItem(icon: Icons.construction, label: "Installation", route: '/air/manual/installation'),
SidebarItem(icon: Icons.inventory_2, label: "Collection", route: '/air/manual/collection'), SidebarItem(icon: Icons.inventory_2, label: "Collection", route: '/air/manual/collection'),
SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/manual/report'), //SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/air/manual/report'),
SidebarItem(icon: Icons.article, label: "Data Log", route: '/air/manual/data-log'), SidebarItem(icon: Icons.article, label: "Data Log", route: '/air/manual/data-log'),
SidebarItem(icon: Icons.image, label: "Image Request", route: '/air/manual/image-request'), SidebarItem(icon: Icons.image, label: "Image Request", route: '/air/manual/image-request'),
], ],

View File

@ -43,7 +43,8 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
setState(() => _isLoading = true); setState(() => _isLoading = true);
final service = Provider.of<AirSamplingService>(context, listen: false); final service = Provider.of<AirSamplingService>(context, listen: false);
final result = await service.submitCollection(_collectionData); // MODIFIED: Pass the selected installation data to the service for the Telegram alert.
final result = await service.submitCollection(_collectionData, _selectedInstallation!);
setState(() => _isLoading = false); setState(() => _isLoading = false);
@ -91,7 +92,6 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: DropdownSearch<AirInstallationData>( child: DropdownSearch<AirInstallationData>(
items: pendingInstallations, items: pendingInstallations,
// **THE FIX**: Changed u.samplingDate to u.installationDate
itemAsString: (AirInstallationData u) => itemAsString: (AirInstallationData u) =>
"${u.stationID} - ${u.locationName} (${u.installationDate} ${u.installationTime ?? ''})", "${u.stationID} - ${u.locationName} (${u.installationDate} ${u.installationTime ?? ''})",
popupProps: const PopupProps.menu( popupProps: const PopupProps.menu(

View File

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

View File

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../../../auth_provider.dart';
import '../../../../models/air_collection_data.dart'; import '../../../../models/air_collection_data.dart';
import '../../../../services/air_sampling_service.dart'; import '../../../../services/air_sampling_service.dart';
@ -31,6 +32,7 @@ class AirManualCollectionWidget extends StatefulWidget {
class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> { class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
bool _isPickingImage = false;
// General Controllers // General Controllers
final _collectionDateController = TextEditingController(); final _collectionDateController = TextEditingController();
@ -73,6 +75,13 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
_collectionDateController.text = widget.data.collectionDate!; _collectionDateController.text = widget.data.collectionDate!;
_collectionTimeController.text = widget.data.collectionTime!; _collectionTimeController.text = widget.data.collectionTime!;
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final currentUser = authProvider.profileData;
if (currentUser != null) {
widget.data.collectionUserId = currentUser['user_id'];
widget.data.collectionUserName = currentUser['first_name'];
}
} }
@override @override
@ -129,7 +138,28 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
} }
} }
Future<void> _pickImage(ImageSource source, String imageInfo) async { void _showOrientationDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text("Incorrect Image Orientation"),
content: const Text("Required photos must be taken in a horizontal (landscape) orientation."),
actions: [
TextButton(
child: const Text("OK"),
onPressed: () => Navigator.of(context).pop(),
),
],
);
},
);
}
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
if (_isPickingImage) return;
setState(() => _isPickingImage = true);
final service = Provider.of<AirSamplingService>(context, listen: false); final service = Provider.of<AirSamplingService>(context, listen: false);
final stationCode = widget.stationCode; final stationCode = widget.stationCode;
@ -138,24 +168,17 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
stationCode: stationCode, stationCode: stationCode,
imageInfo: imageInfo.toUpperCase(), imageInfo: imageInfo.toUpperCase(),
processType: 'COLLECT', processType: 'COLLECT',
isRequired: isRequired,
); );
if (imageFile != null) { if (imageFile == null && isRequired) {
_showOrientationDialog();
} else if (imageFile != null) {
setState(() { setState(() {
switch (imageInfo) { setImageCallback(imageFile);
case 'front': widget.data.imageFront = imageFile; break;
case 'back': widget.data.imageBack = imageFile; break;
case 'left': widget.data.imageLeft = imageFile; break;
case 'right': widget.data.imageRight = imageFile; break;
case 'chart': widget.data.imageChart = imageFile; break;
case 'filter_paper': widget.data.imageFilterPaper = imageFile; break;
case 'optional_01': widget.data.optionalImage1 = imageFile; break;
case 'optional_02': widget.data.optionalImage2 = imageFile; break;
case 'optional_03': widget.data.optionalImage3 = imageFile; break;
case 'optional_04': widget.data.optionalImage4 = imageFile; break;
}
}); });
} }
setState(() => _isPickingImage = false);
} }
void _calculateVstd(int type) { void _calculateVstd(int type) {
@ -183,36 +206,28 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
const double hpaToinHg = 0.0295; const double hpaToinHg = 0.0295;
const double inHgToMmHg = 25.4; const double inHgToMmHg = 25.4;
// 1. Get time and user-input flowrate (cfm)
double totalTime_min = double.parse(timeResultCtrl.text); double totalTime_min = double.parse(timeResultCtrl.text);
// **CRITICAL FIX**: Use the actual flowrate keyed in by the user (in cfm)
double qa_cfm_actual = double.parse(flowRateCtrl.text); double qa_cfm_actual = double.parse(flowRateCtrl.text);
// 2. Calculate actual pressure
double pressureHpa = double.parse(pressureCtrl.text); double pressureHpa = double.parse(pressureCtrl.text);
double pressureInHg = pressureHpa * hpaToinHg; double pressureInHg = pressureHpa * hpaToinHg;
double p_actual_mmHg = pressureInHg * inHgToMmHg; // result pressure double p_actual_mmHg = pressureInHg * inHgToMmHg;
pressureResultCtrl.text = pressureInHg.toStringAsFixed(2); pressureResultCtrl.text = pressureInHg.toStringAsFixed(2);
// 3. Calculate average temperature in Celsius double t_avg_celsius = (widget.initialTemp + double.parse(_finalTempController.text)) / 2.0;
double t_avg_celsius = (widget.initialTemp + double.parse(_finalTempController.text)) / 2.0; // ambient temperature
tAvgCtrl.text = t_avg_celsius.toStringAsFixed(2); tAvgCtrl.text = t_avg_celsius.toStringAsFixed(2);
// 4. Calculate Standard Flowrate (QSTD) using your exact formula
// Formula: (flowrate * result pressure * 298) / (760 * (273 * ambient temperature))
double q_std_numerator = qa_cfm_actual * pressureInHg * 298; double q_std_numerator = qa_cfm_actual * pressureInHg * 298;
double q_std_denominator = (760 * (273 + t_avg_celsius)); double q_std_denominator = (760 * (273 + t_avg_celsius));
double q_std = (q_std_denominator == 0) ? 0 : q_std_numerator / q_std_denominator; double q_std = (q_std_denominator == 0) ? 0 : q_std_numerator / q_std_denominator;
qStdCtrl.text = q_std.toStringAsFixed(5); qStdCtrl.text = q_std.toStringAsFixed(5);
// 5. Final VSTD
// Formula: QSTD * Total time
double v_std = q_std * totalTime_min; double v_std = q_std * totalTime_min;
if (type == 1) { // PM10 if (type == 1) {
_pm10VstdController.text = v_std.toStringAsFixed(3); _pm10VstdController.text = v_std.toStringAsFixed(3);
widget.data.pm10Vstd = v_std; widget.data.pm10Vstd = v_std;
} else { // PM2.5 } else {
_pm25VstdController.text = v_std.toStringAsFixed(3); _pm25VstdController.text = v_std.toStringAsFixed(3);
widget.data.pm25Vstd = v_std; widget.data.pm25Vstd = v_std;
} }
@ -227,6 +242,21 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
_formKey.currentState!.save(); _formKey.currentState!.save();
if (widget.data.imageFront == null ||
widget.data.imageBack == null ||
widget.data.imageLeft == null ||
widget.data.imageRight == null ||
widget.data.imageChart == null ||
widget.data.imageFilterPaper == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please attach all required photos before proceeding.'),
backgroundColor: Colors.red,
),
);
return;
}
widget.data.pm10FlowrateResult = _pm10FlowRateResultController.text; widget.data.pm10FlowrateResult = _pm10FlowRateResultController.text;
widget.data.pm10TotalTimeResult = _pm10TimeResultController.text; widget.data.pm10TotalTimeResult = _pm10TimeResultController.text;
widget.data.pm10PressureResult = _pm10PressureResultController.text; widget.data.pm10PressureResult = _pm10PressureResultController.text;
@ -355,19 +385,19 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
const SizedBox(height: 24), const SizedBox(height: 24),
const Text("Required Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const Text("Required Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
_buildImagePicker('Site Picture (Front)', 'front', widget.data.imageFront), _buildImagePicker('Site Picture (Front)', 'front', widget.data.imageFront, (file) => widget.data.imageFront = file, isRequired: true),
_buildImagePicker('Site Picture (Back)', 'back', widget.data.imageBack), _buildImagePicker('Site Picture (Back)', 'back', widget.data.imageBack, (file) => widget.data.imageBack = file, isRequired: true),
_buildImagePicker('Site Picture (Left)', 'left', widget.data.imageLeft), _buildImagePicker('Site Picture (Left)', 'left', widget.data.imageLeft, (file) => widget.data.imageLeft = file, isRequired: true),
_buildImagePicker('Site Picture (Right)', 'right', widget.data.imageRight), _buildImagePicker('Site Picture (Right)', 'right', widget.data.imageRight, (file) => widget.data.imageRight = file, isRequired: true),
_buildImagePicker('Chart', 'chart', widget.data.imageChart), _buildImagePicker('Chart', 'chart', widget.data.imageChart, (file) => widget.data.imageChart = file, isRequired: true),
_buildImagePicker('Filter Paper', 'filter_paper', widget.data.imageFilterPaper), _buildImagePicker('Filter Paper', 'filter_paper', widget.data.imageFilterPaper, (file) => widget.data.imageFilterPaper = file, isRequired: true),
const SizedBox(height: 24), const SizedBox(height: 24),
const Text("Optional Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const Text("Optional Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
_buildImagePicker('Optional Photo 1', 'optional_01', widget.data.optionalImage1, remarkController: _optionalRemark1Controller), _buildImagePicker('Optional Photo 1', 'optional_01', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, remarkController: _optionalRemark1Controller),
_buildImagePicker('Optional Photo 2', 'optional_02', widget.data.optionalImage2, remarkController: _optionalRemark2Controller), _buildImagePicker('Optional Photo 2', 'optional_02', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, remarkController: _optionalRemark2Controller),
_buildImagePicker('Optional Photo 3', 'optional_03', widget.data.optionalImage3, remarkController: _optionalRemark3Controller), _buildImagePicker('Optional Photo 3', 'optional_03', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, remarkController: _optionalRemark3Controller),
_buildImagePicker('Optional Photo 4', 'optional_04', widget.data.optionalImage4, remarkController: _optionalRemark4Controller), _buildImagePicker('Optional Photo 4', 'optional_04', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, remarkController: _optionalRemark4Controller),
const SizedBox(height: 30), const SizedBox(height: 30),
SizedBox( SizedBox(
@ -389,26 +419,41 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
); );
} }
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, {TextEditingController? remarkController}) { Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) {
return Card( return Padding(
margin: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
const SizedBox(height: 8), const SizedBox(height: 8),
if (imageFile != null)
Stack(
alignment: Alignment.topRight,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.file(imageFile, key: UniqueKey(), height: 150, width: double.infinity, fit: BoxFit.cover)),
Container( Container(
height: 150, margin: const EdgeInsets.all(4),
width: double.infinity, decoration: BoxDecoration(
decoration: BoxDecoration(border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(8)), color: Colors.black.withOpacity(0.6),
child: imageFile != null ? Image.file(imageFile, fit: BoxFit.cover, key: UniqueKey()) : const Center(child: Icon(Icons.image, size: 50, color: Colors.grey)), shape: BoxShape.circle,
), ),
child: IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close, color: Colors.white, size: 20),
onPressed: () => setState(() => setImageCallback(null)),
),
),
],
)
else
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
ElevatedButton.icon(icon: const Icon(Icons.photo_library), label: const Text('Gallery'), onPressed: () => _pickImage(ImageSource.gallery, imageInfo)), ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")),
ElevatedButton.icon(icon: const Icon(Icons.camera_alt), label: const Text('Camera'), onPressed: () => _pickImage(ImageSource.camera, imageInfo)), ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")),
], ],
), ),
if (remarkController != null) if (remarkController != null)
@ -416,12 +461,15 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: TextFormField( child: TextFormField(
controller: remarkController, controller: remarkController,
decoration: InputDecoration(labelText: 'Remarks for $title', border: const OutlineInputBorder()), decoration: InputDecoration(
labelText: 'Remarks for $title',
hintText: 'Add an optional remark...',
border: const OutlineInputBorder(),
),
), ),
), ),
], ],
), ),
),
); );
} }

View File

@ -31,6 +31,7 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
bool _isDataInitialized = false; bool _isDataInitialized = false;
bool _isPickingImage = false;
List<Map<String, dynamic>> _allClients = []; List<Map<String, dynamic>> _allClients = [];
List<Map<String, dynamic>> _allStations = []; List<Map<String, dynamic>> _allStations = [];
@ -92,6 +93,7 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
if (currentUser != null) { if (currentUser != null) {
widget.data.installationUserId = currentUser['user_id']; widget.data.installationUserId = currentUser['user_id'];
widget.data.installationUserName = currentUser['first_name'];
} }
if (clients != null && clients.isNotEmpty) { if (clients != null && clients.isNotEmpty) {
@ -169,8 +171,28 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
} }
} }
Future<void> _pickImage(ImageSource source, String imageInfo) async { void _showOrientationDialog() {
// --- FIX: Prevent picking image if station is not selected --- showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text("Incorrect Image Orientation"),
content: const Text("Required photos must be taken in a horizontal (landscape) orientation."),
actions: [
TextButton(
child: const Text("OK"),
onPressed: () => Navigator.of(context).pop(),
),
],
);
},
);
}
void _setImage(Function(File?) setImageCallback, ImageSource source, String imageInfo, {required bool isRequired}) async {
if (_isPickingImage) return;
setState(() => _isPickingImage = true);
if (widget.data.stationID == null || widget.data.stationID!.isEmpty) { if (widget.data.stationID == null || widget.data.stationID!.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@ -178,6 +200,7 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
), ),
); );
setState(() => _isPickingImage = false);
return; return;
} }
@ -188,44 +211,54 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
source, source,
stationCode: stationCode, stationCode: stationCode,
imageInfo: imageInfo.toUpperCase(), imageInfo: imageInfo.toUpperCase(),
isRequired: isRequired,
); );
if (imageFile != null) { if (imageFile == null && isRequired) {
_showOrientationDialog();
} else if (imageFile != null) {
setState(() { setState(() {
switch (imageInfo) { setImageCallback(imageFile);
case 'front': widget.data.imageFront = imageFile; break;
case 'back': widget.data.imageBack = imageFile; break;
case 'left': widget.data.imageLeft = imageFile; break;
case 'right': widget.data.imageRight = imageFile; break;
case 'optional_01': widget.data.optionalImage1 = imageFile; break;
case 'optional_02': widget.data.optionalImage2 = imageFile; break;
case 'optional_03': widget.data.optionalImage3 = imageFile; break;
case 'optional_04': widget.data.optionalImage4 = imageFile; break;
}
}); });
} }
setState(() => _isPickingImage = false);
} }
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, {TextEditingController? remarkController}) { Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) {
return Card( return Padding(
margin: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), Text(title + (isRequired ? ' *' : ''), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
const SizedBox(height: 8), const SizedBox(height: 8),
if (imageFile != null)
Stack(
alignment: Alignment.topRight,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.file(imageFile, key: UniqueKey(), height: 150, width: double.infinity, fit: BoxFit.cover)),
Container( Container(
height: 150, margin: const EdgeInsets.all(4),
width: double.infinity, decoration: BoxDecoration(
decoration: BoxDecoration(border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(8)), color: Colors.black.withOpacity(0.6),
child: imageFile != null ? Image.file(imageFile, fit: BoxFit.cover, key: UniqueKey()) : const Center(child: Icon(Icons.image, size: 50, color: Colors.grey)), shape: BoxShape.circle,
), ),
child: IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close, color: Colors.white, size: 20),
onPressed: () => setState(() => setImageCallback(null)),
),
),
],
)
else
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
ElevatedButton.icon(icon: const Icon(Icons.photo_library), label: const Text('Gallery'), onPressed: () => _pickImage(ImageSource.gallery, imageInfo)), ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.camera, imageInfo, isRequired: isRequired), icon: const Icon(Icons.camera_alt), label: const Text("Camera")),
ElevatedButton.icon(icon: const Icon(Icons.camera_alt), label: const Text('Camera'), onPressed: () => _pickImage(ImageSource.camera, imageInfo)), ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")),
], ],
), ),
if (remarkController != null) if (remarkController != null)
@ -233,18 +266,35 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: TextFormField( child: TextFormField(
controller: remarkController, controller: remarkController,
decoration: InputDecoration(labelText: 'Remarks for $title', border: const OutlineInputBorder()), decoration: InputDecoration(
labelText: 'Remarks for $title',
hintText: 'Add an optional remark...',
border: const OutlineInputBorder(),
),
), ),
), ),
], ],
), ),
),
); );
} }
void _onSubmitPressed() { void _onSubmitPressed() {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
_formKey.currentState!.save(); _formKey.currentState!.save();
if (widget.data.imageFront == null ||
widget.data.imageBack == null ||
widget.data.imageLeft == null ||
widget.data.imageRight == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please attach all required photos before proceeding.'),
backgroundColor: Colors.red,
),
);
return;
}
widget.data.optionalRemark1 = _optionalRemark1Controller.text; widget.data.optionalRemark1 = _optionalRemark1Controller.text;
widget.data.optionalRemark2 = _optionalRemark2Controller.text; widget.data.optionalRemark2 = _optionalRemark2Controller.text;
widget.data.optionalRemark3 = _optionalRemark3Controller.text; widget.data.optionalRemark3 = _optionalRemark3Controller.text;
@ -356,7 +406,6 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Station ID / Location", border: OutlineInputBorder())), dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Station ID / Location", border: OutlineInputBorder())),
onChanged: (value) => setState(() { onChanged: (value) => setState(() {
_selectedLocation = value; _selectedLocation = value;
// Directly update the data object here to ensure it's available for image naming
widget.data.stationID = value?['station_code']; widget.data.stationID = value?['station_code'];
widget.data.locationName = value?['station_name']; widget.data.locationName = value?['station_name'];
}), }),
@ -384,17 +433,17 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
const SizedBox(height: 16), const SizedBox(height: 16),
const Text("Required Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const Text("Required Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
_buildImagePicker('Site Picture (Front)', 'front', widget.data.imageFront), _buildImagePicker('Site Picture (Front)', 'front', widget.data.imageFront, (file) => widget.data.imageFront = file, isRequired: true),
_buildImagePicker('Site Picture (Back)', 'back', widget.data.imageBack), _buildImagePicker('Site Picture (Back)', 'back', widget.data.imageBack, (file) => widget.data.imageBack = file, isRequired: true),
_buildImagePicker('Site Picture (Left)', 'left', widget.data.imageLeft), _buildImagePicker('Site Picture (Left)', 'left', widget.data.imageLeft, (file) => widget.data.imageLeft = file, isRequired: true),
_buildImagePicker('Site Picture (Right)', 'right', widget.data.imageRight), _buildImagePicker('Site Picture (Right)', 'right', widget.data.imageRight, (file) => widget.data.imageRight = file, isRequired: true),
const SizedBox(height: 24), const SizedBox(height: 24),
const Text("Optional Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const Text("Optional Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
_buildImagePicker('Optional Photo 1', 'optional_01', widget.data.optionalImage1, remarkController: _optionalRemark1Controller), _buildImagePicker('Optional Photo 1', 'optional_01', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, remarkController: _optionalRemark1Controller),
_buildImagePicker('Optional Photo 2', 'optional_02', widget.data.optionalImage2, remarkController: _optionalRemark2Controller), _buildImagePicker('Optional Photo 2', 'optional_02', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, remarkController: _optionalRemark2Controller),
_buildImagePicker('Optional Photo 3', 'optional_03', widget.data.optionalImage3, remarkController: _optionalRemark3Controller), _buildImagePicker('Optional Photo 3', 'optional_03', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, remarkController: _optionalRemark3Controller),
_buildImagePicker('Optional Photo 4', 'optional_04', widget.data.optionalImage4, remarkController: _optionalRemark4Controller), _buildImagePicker('Optional Photo 4', 'optional_04', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, remarkController: _optionalRemark4Controller),
const SizedBox(height: 30), const SizedBox(height: 30),

View File

@ -1,3 +1,5 @@
// lib/services/air_sampling_service.dart
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -11,11 +13,13 @@ import '../models/air_installation_data.dart';
import '../models/air_collection_data.dart'; import '../models/air_collection_data.dart';
import 'api_service.dart'; import 'api_service.dart';
import 'local_storage_service.dart'; import 'local_storage_service.dart';
import 'telegram_service.dart';
/// A dedicated service to handle all business logic for the Air Manual Sampling feature. /// A dedicated service to handle all business logic for the Air Manual Sampling feature.
class AirSamplingService { class AirSamplingService {
final ApiService _apiService = ApiService(); final ApiService _apiService = ApiService();
final LocalStorageService _localStorageService = LocalStorageService(); final LocalStorageService _localStorageService = LocalStorageService();
final TelegramService _telegramService = TelegramService();
/// Picks an image from the specified source, adds a timestamp watermark, /// Picks an image from the specified source, adds a timestamp watermark,
/// and saves it to a temporary directory with a standardized name. /// and saves it to a temporary directory with a standardized name.
@ -23,7 +27,8 @@ class AirSamplingService {
ImageSource source, { ImageSource source, {
required String stationCode, required String stationCode,
required String imageInfo, required String imageInfo,
String processType = 'INSTALL', // Defaults to INSTALL for backward compatibility String processType = 'INSTALL',
required bool isRequired,
}) async { }) async {
final picker = ImagePicker(); final picker = ImagePicker();
final XFile? photo = await picker.pickImage( final XFile? photo = await picker.pickImage(
@ -34,6 +39,12 @@ class AirSamplingService {
img.Image? originalImage = img.decodeImage(bytes); img.Image? originalImage = img.decodeImage(bytes);
if (originalImage == null) return null; if (originalImage == null) return null;
// MODIFIED: Enforce landscape orientation for required photos
if (isRequired && originalImage.height > originalImage.width) {
debugPrint("Image orientation check failed: Image must be in landscape mode.");
return null; // Return null to indicate failure
}
final String watermarkTimestamp = final String watermarkTimestamp =
DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()); DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
final font = img.arial24; final font = img.arial24;
@ -59,17 +70,44 @@ class AirSamplingService {
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
} }
Future<void> _handleInstallationSuccessAlert(AirInstallationData data, {required bool isDataOnly}) async {
try {
final message = data.generateInstallationTelegramAlert(isDataOnly: isDataOnly);
final bool wasSent = await _telegramService.sendAlertImmediately('air_manual', message);
if (!wasSent) {
await _telegramService.queueMessage('air_manual', message);
}
} catch (e) {
debugPrint("Failed to handle Air Manual Installation Telegram alert: $e");
}
}
Future<void> _handleCollectionSuccessAlert(AirCollectionData data, AirInstallationData installationData, {required bool isDataOnly}) async {
try {
final message = data.generateCollectionTelegramAlert(installationData, isDataOnly: isDataOnly);
final bool wasSent = await _telegramService.sendAlertImmediately('air_manual', message);
if (!wasSent) {
await _telegramService.queueMessage('air_manual', message);
}
} catch (e) {
debugPrint("Failed to handle Air Manual Collection Telegram alert: $e");
}
}
/// Orchestrates a two-step submission process for air installation samples. /// Orchestrates a two-step submission process for air installation samples.
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data) async { Future<Map<String, dynamic>> submitInstallation(AirInstallationData data) async {
// --- OFFLINE-FIRST HELPER --- // --- OFFLINE-FIRST HELPER ---
Future<Map<String, dynamic>> saveLocally() async { Future<Map<String, dynamic>> saveLocally(String status, String message) async {
debugPrint("Saving installation locally..."); debugPrint("Saving installation locally with status: $status");
data.status = 'L1'; // Mark as Locally Saved, Pending Submission data.status = status; // Use the provided status
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!); await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
return { return {'status': status, 'message': message};
'status': 'L1', }
'message': 'No connection or server error. Installation data saved locally.',
}; // If the record's text data is already on the server, skip directly to image upload.
if (data.status == 'L2_PENDING_IMAGES' && data.airManId != null) {
debugPrint("Retrying image upload for existing record ID: ${data.airManId}");
return await _uploadInstallationImagesAndUpdate(data);
} }
// --- STEP 1: SUBMIT TEXT DATA --- // --- STEP 1: SUBMIT TEXT DATA ---
@ -78,61 +116,69 @@ class AirSamplingService {
if (textDataResult['success'] != true) { if (textDataResult['success'] != true) {
debugPrint("Failed to submit text data. Reason: ${textDataResult['message']}"); debugPrint("Failed to submit text data. Reason: ${textDataResult['message']}");
return await saveLocally(); return await saveLocally('L1', 'No connection or server error. Installation data saved locally.');
} }
// --- NECESSARY FIX: Safely parse the record ID from the server response --- // --- NECESSARY FIX: Safely parse the record ID from the server response ---
final dynamic recordIdFromServer = textDataResult['data']?['air_man_id']; final dynamic recordIdFromServer = textDataResult['data']?['air_man_id'];
if (recordIdFromServer == null) { if (recordIdFromServer == null) {
debugPrint("Text data submitted, but did not receive a record ID."); debugPrint("Text data submitted, but did not receive a record ID.");
return await saveLocally(); return await saveLocally('L1', 'Data submitted, but server response was invalid.');
} }
debugPrint("Text data submitted successfully. Received record ID: $recordIdFromServer"); debugPrint("Text data submitted successfully. Received record ID: $recordIdFromServer");
// The ID from JSON can be a String or int, but our model needs an int.
// Use int.tryParse for safe conversion.
final int? parsedRecordId = int.tryParse(recordIdFromServer.toString()); final int? parsedRecordId = int.tryParse(recordIdFromServer.toString());
if (parsedRecordId == null) { if (parsedRecordId == null) {
debugPrint("Could not parse the received record ID: $recordIdFromServer"); debugPrint("Could not parse the received record ID: $recordIdFromServer");
return await saveLocally(); // Treat as a failure if ID is invalid return await saveLocally('L1', 'Data submitted, but server response was invalid.');
} }
data.airManId = parsedRecordId; // Assign the correctly typed integer ID data.airManId = parsedRecordId;
// --- STEP 2: UPLOAD IMAGE FILES --- // --- STEP 2: UPLOAD IMAGE FILES ---
return await _uploadInstallationImagesAndUpdate(data);
}
/// A reusable function for handling the image upload and local data update logic.
Future<Map<String, dynamic>> _uploadInstallationImagesAndUpdate(AirInstallationData data) async {
final filesToUpload = data.getImagesForUpload(); final filesToUpload = data.getImagesForUpload();
if (filesToUpload.isEmpty) { if (filesToUpload.isEmpty) {
debugPrint("No images to upload. Submission complete."); debugPrint("No images to upload. Submission complete.");
data.status = 'S1'; // Server Pending (no images needed) data.status = 'S1'; // Server Pending (no images needed)
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!); await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
_handleInstallationSuccessAlert(data, isDataOnly: true);
return {'status': 'S1', 'message': 'Installation data submitted successfully.'}; return {'status': 'S1', 'message': 'Installation data submitted successfully.'};
} }
debugPrint("Step 2: Uploading ${filesToUpload.length} images for record ID $parsedRecordId..."); debugPrint("Step 2: Uploading ${filesToUpload.length} images for record ID ${data.airManId}...");
final imageUploadResult = await _apiService.air.uploadInstallationImages( final imageUploadResult = await _apiService.air.uploadInstallationImages(
airManId: parsedRecordId.toString(), // The API itself needs a string airManId: data.airManId.toString(),
files: filesToUpload, files: filesToUpload,
); );
if (imageUploadResult['success'] != true) { if (imageUploadResult['success'] != true) {
debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}"); debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}");
return await saveLocally(); data.status = 'L2_PENDING_IMAGES';
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
return {
'status': 'L2_PENDING_IMAGES',
'message': 'Data submitted, but image upload failed. Saved locally for retry.',
};
} }
debugPrint("Images uploaded successfully."); debugPrint("Images uploaded successfully.");
data.status = 'S2'; // Server Pending (images uploaded) data.status = 'S2'; // Server Pending (images uploaded)
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!); await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
_handleInstallationSuccessAlert(data, isDataOnly: false);
return { return {
'status': 'S2', 'status': 'S2',
'message': 'Installation data and images submitted successfully.', 'message': 'Installation data and images submitted successfully.',
}; };
} }
/// Submits only the collection data, linked to a previous installation. /// Submits only the collection data, linked to a previous installation.
Future<Map<String, dynamic>> submitCollection(AirCollectionData data) async { Future<Map<String, dynamic>> submitCollection(AirCollectionData data, AirInstallationData installationData) async {
// --- OFFLINE-FIRST HELPER (CORRECTED) --- // --- OFFLINE-FIRST HELPER (CORRECTED) ---
Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async { Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async {
debugPrint("Saving collection data locally with status: $newStatus"); debugPrint("Saving collection data locally with status: $newStatus");
@ -152,6 +198,12 @@ class AirSamplingService {
}; };
} }
// If the record's text data is already on the server, skip directly to image upload.
if (data.status == 'L4_PENDING_IMAGES' && data.airManId != null) {
debugPrint("Retrying collection image upload for existing record ID: ${data.airManId}");
return await _uploadCollectionImagesAndUpdate(data, installationData);
}
// --- STEP 1: SUBMIT TEXT DATA --- // --- STEP 1: SUBMIT TEXT DATA ---
debugPrint("Step 1: Submitting collection text data..."); debugPrint("Step 1: Submitting collection text data...");
final textDataResult = await _apiService.post('air/manual/collection', data.toJson()); final textDataResult = await _apiService.post('air/manual/collection', data.toJson());
@ -163,10 +215,34 @@ class AirSamplingService {
debugPrint("Collection text data submitted successfully."); debugPrint("Collection text data submitted successfully.");
// --- STEP 2: UPLOAD IMAGE FILES --- // --- STEP 2: UPLOAD IMAGE FILES ---
return await _uploadCollectionImagesAndUpdate(data, installationData);
}
/// A reusable function for handling the collection image upload and local data update logic.
Future<Map<String, dynamic>> _uploadCollectionImagesAndUpdate(AirCollectionData data, AirInstallationData installationData) async {
// --- OFFLINE-FIRST HELPER (CORRECTED) ---
Future<Map<String, dynamic>> updateAndSaveLocally(String newStatus, {String? message}) async {
debugPrint("Saving collection data locally with status: $newStatus");
final allLogs = await _localStorageService.getAllAirSamplingLogs();
final logIndex = allLogs.indexWhere((log) => log['refID'] == data.installationRefID);
if (logIndex != -1) {
final installationLog = allLogs[logIndex];
installationLog['collectionData'] = data.toMap();
installationLog['status'] = newStatus;
await _localStorageService.saveAirSamplingRecord(installationLog, data.installationRefID!);
}
return {
'status': newStatus,
'message': message ?? 'No connection or server error. Data saved locally.',
};
}
final filesToUpload = data.getImagesForUpload(); final filesToUpload = data.getImagesForUpload();
if (filesToUpload.isEmpty) { if (filesToUpload.isEmpty) {
debugPrint("No collection images to upload. Submission complete."); debugPrint("No collection images to upload. Submission complete.");
await updateAndSaveLocally('S3'); // S3 = Server Completed await updateAndSaveLocally('S3'); // S3 = Server Completed
_handleCollectionSuccessAlert(data, installationData, isDataOnly: true);
return {'status': 'S3', 'message': 'Collection data submitted successfully.'}; return {'status': 'S3', 'message': 'Collection data submitted successfully.'};
} }
@ -178,18 +254,20 @@ class AirSamplingService {
if (imageUploadResult['success'] != true) { if (imageUploadResult['success'] != true) {
debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}"); debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}");
// Use a new status 'L4' to indicate text submitted but images failed // Use status 'L4_PENDING_IMAGES' to indicate text submitted but images failed
return await updateAndSaveLocally('L4', message: 'Data submitted, but image upload failed. Saved locally for retry.'); return await updateAndSaveLocally('L4_PENDING_IMAGES', message: 'Data submitted, but image upload failed. Saved locally for retry.');
} }
debugPrint("Images uploaded successfully."); debugPrint("Images uploaded successfully.");
await updateAndSaveLocally('S3'); // S3 = Server Completed await updateAndSaveLocally('S3'); // S3 = Server Completed
_handleCollectionSuccessAlert(data, installationData, isDataOnly: false);
return { return {
'status': 'S3', 'status': 'S3',
'message': 'Collection data and images submitted successfully.', 'message': 'Collection data and images submitted successfully.',
}; };
} }
/// Fetches installations that are pending collection from local storage. /// Fetches installations that are pending collection from local storage.
Future<List<AirInstallationData>> getPendingInstallations() async { Future<List<AirInstallationData>> getPendingInstallations() async {
debugPrint("Fetching pending installations from local storage..."); debugPrint("Fetching pending installations from local storage...");

View File

@ -10,16 +10,24 @@ class TelegramService {
bool _isProcessing = false; bool _isProcessing = false;
Future<String> _getChatIdForModule(String module) async {
switch (module) {
case 'marine_in_situ':
return await _settingsService.getInSituChatId();
case 'marine_tarball':
return await _settingsService.getTarballChatId();
case 'air_manual': // ADDED THIS CASE
return await _settingsService.getAirManualChatId();
default:
return '';
}
}
/// Tries to send an alert immediately over the network. /// Tries to send an alert immediately over the network.
/// Returns `true` on success, `false` on failure. /// Returns `true` on success, `false` on failure.
Future<bool> sendAlertImmediately(String module, String message) async { Future<bool> sendAlertImmediately(String module, String message) async {
debugPrint("[TelegramService] Attempting to send alert immediately..."); debugPrint("[TelegramService] Attempting to send alert immediately for module: $module");
String chatId = ''; String chatId = await _getChatIdForModule(module);
if (module == 'marine_in_situ') {
chatId = await _settingsService.getInSituChatId();
} else if (module == 'marine_tarball') {
chatId = await _settingsService.getTarballChatId();
}
if (chatId.isEmpty) { if (chatId.isEmpty) {
debugPrint("[TelegramService] ❌ Cannot send immediately. Chat ID for module '$module' is not configured."); debugPrint("[TelegramService] ❌ Cannot send immediately. Chat ID for module '$module' is not configured.");
@ -42,12 +50,7 @@ class TelegramService {
/// Saves an alert to the local database queue. (This is now the fallback) /// Saves an alert to the local database queue. (This is now the fallback)
Future<void> queueMessage(String module, String message) async { Future<void> queueMessage(String module, String message) async {
String chatId = ''; String chatId = await _getChatIdForModule(module);
if (module == 'marine_in_situ') {
chatId = await _settingsService.getInSituChatId();
} else if (module == 'marine_tarball') {
chatId = await _settingsService.getTarballChatId();
}
if (chatId.isEmpty) { if (chatId.isEmpty) {
debugPrint("[TelegramService] ❌ ERROR: Cannot queue alert. Chat ID for module '$module' is not configured."); debugPrint("[TelegramService] ❌ ERROR: Cannot queue alert. Chat ID for module '$module' is not configured.");