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