add in air manual module
This commit is contained in:
parent
e90f4972cc
commit
d60e6cd60f
@ -8,6 +8,8 @@ import 'package:connectivity_plus/connectivity_plus.dart';
|
|||||||
import 'package:provider/single_child_widget.dart';
|
import 'package:provider/single_child_widget.dart';
|
||||||
import 'package:environment_monitoring_app/services/local_storage_service.dart';
|
import 'package:environment_monitoring_app/services/local_storage_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart';
|
import 'package:environment_monitoring_app/services/river_in_situ_sampling_service.dart';
|
||||||
|
// --- ADDED: Import for the new AirSamplingService ---
|
||||||
|
import 'package:environment_monitoring_app/services/air_sampling_service.dart';
|
||||||
import 'package:environment_monitoring_app/services/telegram_service.dart';
|
import 'package:environment_monitoring_app/services/telegram_service.dart';
|
||||||
|
|
||||||
import 'package:environment_monitoring_app/theme.dart';
|
import 'package:environment_monitoring_app/theme.dart';
|
||||||
@ -29,7 +31,9 @@ import 'package:environment_monitoring_app/screens/marine/marine_home_page.dart'
|
|||||||
|
|
||||||
// Air Screens
|
// Air Screens
|
||||||
import 'package:environment_monitoring_app/screens/air/manual/air_manual_dashboard.dart';
|
import 'package:environment_monitoring_app/screens/air/manual/air_manual_dashboard.dart';
|
||||||
import 'package:environment_monitoring_app/screens/air/manual/manual_sampling.dart' as airManualSampling;
|
// --- UPDATED: Imports now point to the new separate screens ---
|
||||||
|
import 'package:environment_monitoring_app/screens/air/manual/air_manual_installation_screen.dart';
|
||||||
|
import 'package:environment_monitoring_app/screens/air/manual/air_manual_collection_screen.dart';
|
||||||
import 'package:environment_monitoring_app/screens/air/manual/report.dart' as airManualReport;
|
import 'package:environment_monitoring_app/screens/air/manual/report.dart' as airManualReport;
|
||||||
import 'package:environment_monitoring_app/screens/air/manual/data_status_log.dart' as airManualDataStatusLog;
|
import 'package:environment_monitoring_app/screens/air/manual/data_status_log.dart' as airManualDataStatusLog;
|
||||||
import 'package:environment_monitoring_app/screens/air/manual/image_request.dart' as airManualImageRequest;
|
import 'package:environment_monitoring_app/screens/air/manual/image_request.dart' as airManualImageRequest;
|
||||||
@ -100,7 +104,8 @@ void main() async {
|
|||||||
Provider(create: (_) => LocalStorageService()),
|
Provider(create: (_) => LocalStorageService()),
|
||||||
// Provider for the River In-Situ Sampling Service
|
// Provider for the River In-Situ Sampling Service
|
||||||
Provider(create: (_) => RiverInSituSamplingService()),
|
Provider(create: (_) => RiverInSituSamplingService()),
|
||||||
// NOTE: You would also add your Marine InSituSamplingService here if needed by Provider.
|
// --- ADDED: Provider for the new AirSamplingService ---
|
||||||
|
Provider(create: (_) => AirSamplingService()),
|
||||||
],
|
],
|
||||||
child: const RootApp(),
|
child: const RootApp(),
|
||||||
),
|
),
|
||||||
@ -160,8 +165,8 @@ class RootApp extends StatelessWidget {
|
|||||||
return TarballSamplingStep3Summary(data: args);
|
return TarballSamplingStep3Summary(data: args);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// NOTE: The River In-Situ form uses an internal stepper,
|
// NOTE: The River and Air In-Situ forms use an internal stepper,
|
||||||
// so it does not require onGenerateRoute logic for its steps.
|
// so they do not require onGenerateRoute logic for their steps.
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
routes: {
|
routes: {
|
||||||
@ -180,7 +185,9 @@ class RootApp extends StatelessWidget {
|
|||||||
|
|
||||||
// Air Manual
|
// Air Manual
|
||||||
'/air/manual/dashboard': (context) => AirManualDashboard(),
|
'/air/manual/dashboard': (context) => AirManualDashboard(),
|
||||||
'/air/manual/manual-sampling': (context) => airManualSampling.AirManualSampling(),
|
// --- UPDATED: Routes now point to the new separate screens ---
|
||||||
|
'/air/manual/installation': (context) => const AirManualInstallationScreen(),
|
||||||
|
'/air/manual/collection': (context) => const AirManualCollectionScreen(),
|
||||||
'/air/manual/report': (context) => airManualReport.AirManualReport(),
|
'/air/manual/report': (context) => airManualReport.AirManualReport(),
|
||||||
'/air/manual/data-log': (context) => airManualDataStatusLog.AirManualDataStatusLog(),
|
'/air/manual/data-log': (context) => airManualDataStatusLog.AirManualDataStatusLog(),
|
||||||
'/air/manual/image-request': (context) => airManualImageRequest.AirManualImageRequest(),
|
'/air/manual/image-request': (context) => airManualImageRequest.AirManualImageRequest(),
|
||||||
@ -266,4 +273,4 @@ class SplashScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
100
lib/models/air_collection_data.dart
Normal file
100
lib/models/air_collection_data.dart
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
// lib/models/air_collection_data.dart
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
class AirCollectionData {
|
||||||
|
String? installationRefID; // Foreign key to link with AirInstallationData
|
||||||
|
String? initialTemp; // Needed for VSTD calculation
|
||||||
|
String? weather;
|
||||||
|
String? finalTemp;
|
||||||
|
String? powerFailureStatus;
|
||||||
|
String? remark;
|
||||||
|
|
||||||
|
// PM10 Data
|
||||||
|
String? pm10FlowRate;
|
||||||
|
String? pm10TotalTime;
|
||||||
|
String? pm10Pressure;
|
||||||
|
String? pm10Vstd;
|
||||||
|
|
||||||
|
// PM2.5 Data
|
||||||
|
String? pm25FlowRate;
|
||||||
|
String? pm25TotalTime;
|
||||||
|
String? pm25Pressure;
|
||||||
|
String? pm25Vstd;
|
||||||
|
|
||||||
|
// Image files (6 as per the flowchart)
|
||||||
|
File? imageSiteLeft;
|
||||||
|
File? imageSiteRight;
|
||||||
|
File? imageSiteFront;
|
||||||
|
File? imageSiteBack;
|
||||||
|
File? imageChart;
|
||||||
|
File? imageFilterPaper;
|
||||||
|
|
||||||
|
// Local paths after saving
|
||||||
|
String? imageSiteLeftPath;
|
||||||
|
String? imageSiteRightPath;
|
||||||
|
String? imageSiteFrontPath;
|
||||||
|
String? imageSiteBackPath;
|
||||||
|
String? imageChartPath;
|
||||||
|
String? imageFilterPaperPath;
|
||||||
|
|
||||||
|
// Submission status
|
||||||
|
String? status;
|
||||||
|
|
||||||
|
AirCollectionData({
|
||||||
|
this.installationRefID,
|
||||||
|
this.initialTemp,
|
||||||
|
this.weather,
|
||||||
|
this.finalTemp,
|
||||||
|
this.powerFailureStatus,
|
||||||
|
this.remark,
|
||||||
|
this.pm10FlowRate,
|
||||||
|
this.pm10TotalTime,
|
||||||
|
this.pm10Pressure,
|
||||||
|
this.pm10Vstd,
|
||||||
|
this.pm25FlowRate,
|
||||||
|
this.pm25TotalTime,
|
||||||
|
this.pm25Pressure,
|
||||||
|
this.pm25Vstd,
|
||||||
|
this.imageSiteLeft,
|
||||||
|
this.imageSiteRight,
|
||||||
|
this.imageSiteFront,
|
||||||
|
this.imageSiteBack,
|
||||||
|
this.imageChart,
|
||||||
|
this.imageFilterPaper,
|
||||||
|
this.imageSiteLeftPath,
|
||||||
|
this.imageSiteRightPath,
|
||||||
|
this.imageSiteFrontPath,
|
||||||
|
this.imageSiteBackPath,
|
||||||
|
this.imageChartPath,
|
||||||
|
this.imageFilterPaperPath,
|
||||||
|
this.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Method to convert the data to a JSON-like Map
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'installationRefID': installationRefID,
|
||||||
|
'initialTemp': initialTemp,
|
||||||
|
'weather': weather,
|
||||||
|
'finalTemp': finalTemp,
|
||||||
|
'powerFailureStatus': powerFailureStatus,
|
||||||
|
'remark': remark,
|
||||||
|
'pm10FlowRate': pm10FlowRate,
|
||||||
|
'pm10TotalTime': pm10TotalTime,
|
||||||
|
'pm10Pressure': pm10Pressure,
|
||||||
|
'pm10Vstd': pm10Vstd,
|
||||||
|
'pm25FlowRate': pm25FlowRate,
|
||||||
|
'pm25TotalTime': pm25TotalTime,
|
||||||
|
'pm25Pressure': pm25Pressure,
|
||||||
|
'pm25Vstd': pm25Vstd,
|
||||||
|
'imageSiteLeftPath': imageSiteLeftPath,
|
||||||
|
'imageSiteRightPath': imageSiteRightPath,
|
||||||
|
'imageSiteFrontPath': imageSiteFrontPath,
|
||||||
|
'imageSiteBackPath': imageSiteBackPath,
|
||||||
|
'imageChartPath': imageChartPath,
|
||||||
|
'imageFilterPaperPath': imageFilterPaperPath,
|
||||||
|
'status': status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
83
lib/models/air_installation_data.dart
Normal file
83
lib/models/air_installation_data.dart
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
// lib/models/air_installation_data.dart
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
class AirInstallationData {
|
||||||
|
String? refID;
|
||||||
|
String? samplingDate;
|
||||||
|
String? clientID;
|
||||||
|
String? stateID;
|
||||||
|
String? locationName;
|
||||||
|
String? stationID;
|
||||||
|
String? region;
|
||||||
|
String? weather;
|
||||||
|
String? temp;
|
||||||
|
bool powerFailure;
|
||||||
|
String? pm10FilterId;
|
||||||
|
String? pm25FilterId;
|
||||||
|
String? remark;
|
||||||
|
|
||||||
|
// For handling image files during the process
|
||||||
|
File? image1;
|
||||||
|
File? image2;
|
||||||
|
File? image3;
|
||||||
|
File? image4;
|
||||||
|
|
||||||
|
// For storing local paths after saving
|
||||||
|
String? image1Path;
|
||||||
|
String? image2Path;
|
||||||
|
String? image3Path;
|
||||||
|
String? image4Path;
|
||||||
|
|
||||||
|
// To track submission status (e.g., 'L1', 'S1')
|
||||||
|
String? status;
|
||||||
|
|
||||||
|
AirInstallationData({
|
||||||
|
this.refID,
|
||||||
|
this.samplingDate,
|
||||||
|
this.clientID,
|
||||||
|
this.stateID,
|
||||||
|
this.locationName,
|
||||||
|
this.stationID,
|
||||||
|
this.region,
|
||||||
|
this.weather,
|
||||||
|
this.temp,
|
||||||
|
this.powerFailure = false,
|
||||||
|
this.pm10FilterId,
|
||||||
|
this.pm25FilterId,
|
||||||
|
this.remark,
|
||||||
|
this.image1,
|
||||||
|
this.image2,
|
||||||
|
this.image3,
|
||||||
|
this.image4,
|
||||||
|
this.image1Path,
|
||||||
|
this.image2Path,
|
||||||
|
this.image3Path,
|
||||||
|
this.image4Path,
|
||||||
|
this.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Method to convert the data to a JSON-like Map for APIs or local DB
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'refID': refID,
|
||||||
|
'samplingDate': samplingDate,
|
||||||
|
'clientID': clientID,
|
||||||
|
'stateID': stateID,
|
||||||
|
'locationName': locationName,
|
||||||
|
'stationID': stationID,
|
||||||
|
'region': region,
|
||||||
|
'weather': weather,
|
||||||
|
'temp': temp,
|
||||||
|
'powerFailure': powerFailure,
|
||||||
|
'pm10FilterId': pm10FilterId,
|
||||||
|
'pm25FilterId': pm25FilterId,
|
||||||
|
'remark': remark,
|
||||||
|
'image1Path': image1Path,
|
||||||
|
'image2Path': image2Path,
|
||||||
|
'image3Path': image3Path,
|
||||||
|
'image4Path': image4Path,
|
||||||
|
'status': status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,7 +32,9 @@ class AirHomePage extends StatelessWidget {
|
|||||||
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.edit_note, label: "Manual Sampling", route: '/air/manual/manual-sampling'),
|
// --- UPDATED: Replaced 'Manual Sampling' with 'Installation' and 'Collection' ---
|
||||||
|
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.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'),
|
||||||
@ -118,10 +120,12 @@ class AirHomePage extends StatelessWidget {
|
|||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 3, // 3 columns for sub-menu items
|
// --- UPDATED: Changed from 3 columns to 2 ---
|
||||||
|
crossAxisCount: 2, // 2 columns for sub-menu items
|
||||||
crossAxisSpacing: 0.0, // Removed horizontal spacing
|
crossAxisSpacing: 0.0, // Removed horizontal spacing
|
||||||
mainAxisSpacing: 0.0, // Removed vertical spacing
|
mainAxisSpacing: 0.0, // Removed vertical spacing
|
||||||
childAspectRatio: 2.8, // Adjusted aspect ratio for horizontal icon-label layout with bigger content
|
// --- UPDATED: Adjusted aspect ratio for a 2-column layout ---
|
||||||
|
childAspectRatio: 3.5, // Adjusted for a 2-column horizontal layout
|
||||||
),
|
),
|
||||||
itemCount: category.children?.length ?? 0,
|
itemCount: category.children?.length ?? 0,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
|||||||
141
lib/screens/air/manual/air_manual_collection_screen.dart
Normal file
141
lib/screens/air/manual/air_manual_collection_screen.dart
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
// lib/screens/air/manual/air_manual_collection_screen.dart
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:dropdown_search/dropdown_search.dart';
|
||||||
|
|
||||||
|
import '../../../models/air_installation_data.dart';
|
||||||
|
import '../../../models/air_collection_data.dart';
|
||||||
|
import '../../../services/air_sampling_service.dart';
|
||||||
|
import 'widgets/air_manual_collection.dart'; // The form widget
|
||||||
|
|
||||||
|
class AirManualCollectionScreen extends StatefulWidget {
|
||||||
|
const AirManualCollectionScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AirManualCollectionScreen> createState() =>
|
||||||
|
_AirManualCollectionScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
|
||||||
|
late Future<List<AirInstallationData>> _pendingInstallationsFuture;
|
||||||
|
AirInstallationData? _selectedInstallation;
|
||||||
|
AirCollectionData _collectionData = AirCollectionData();
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Fetch the list of pending installations when the screen loads
|
||||||
|
_pendingInstallationsFuture =
|
||||||
|
Provider.of<AirSamplingService>(context, listen: false)
|
||||||
|
.getPendingInstallations();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitCollection() async {
|
||||||
|
if (_selectedInstallation == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Please select an installation first.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
// Link the collection data to the selected installation
|
||||||
|
_collectionData.installationRefID = _selectedInstallation!.refID;
|
||||||
|
_collectionData.initialTemp = _selectedInstallation!.temp;
|
||||||
|
|
||||||
|
final service = Provider.of<AirSamplingService>(context, listen: false);
|
||||||
|
final result = await service.submitCollection(_collectionData);
|
||||||
|
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final message = result['message'] ?? 'An unknown error occurred.';
|
||||||
|
final color = (result['status'] == 'L3' || result['status'] == 'S3')
|
||||||
|
? Colors.green
|
||||||
|
: Colors.red;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message), backgroundColor: color),
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Air Sampling Collection'),
|
||||||
|
),
|
||||||
|
body: FutureBuilder<List<AirInstallationData>>(
|
||||||
|
future: _pendingInstallationsFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Center(child: Text('Error: ${snapshot.error}'));
|
||||||
|
}
|
||||||
|
if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Text('No pending installations found.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
final pendingInstallations = snapshot.data!;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: DropdownSearch<AirInstallationData>(
|
||||||
|
items: pendingInstallations,
|
||||||
|
itemAsString: (AirInstallationData u) =>
|
||||||
|
"${u.stationID} - ${u.locationName} (${u.samplingDate})",
|
||||||
|
popupProps: const PopupProps.menu(
|
||||||
|
showSearchBox: true,
|
||||||
|
searchFieldProps: TextFieldProps(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Search Installation',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dropdownDecoratorProps: const DropDownDecoratorProps(
|
||||||
|
dropdownSearchDecoration: InputDecoration(
|
||||||
|
labelText: "Select Pending Installation",
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (AirInstallationData? data) {
|
||||||
|
setState(() {
|
||||||
|
_selectedInstallation = data;
|
||||||
|
// Reset collection data when selection changes
|
||||||
|
_collectionData = AirCollectionData();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectedItem: _selectedInstallation,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
Expanded(
|
||||||
|
child: _selectedInstallation != null
|
||||||
|
? AirManualCollectionWidget(
|
||||||
|
key: ValueKey(_selectedInstallation!.refID), // Ensures widget rebuilds
|
||||||
|
data: _collectionData,
|
||||||
|
initialTemp: double.tryParse(_selectedInstallation!.temp ?? '0.0') ?? 0.0,
|
||||||
|
onSubmit: _submitCollection,
|
||||||
|
isLoading: _isLoading,
|
||||||
|
)
|
||||||
|
: const Center(
|
||||||
|
child: Text('Please select an installation to proceed.')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
lib/screens/air/manual/air_manual_installation_screen.dart
Normal file
78
lib/screens/air/manual/air_manual_installation_screen.dart
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
// lib/screens/air/manual/air_manual_installation_screen.dart
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import '../../../models/air_installation_data.dart';
|
||||||
|
import '../../../services/air_sampling_service.dart';
|
||||||
|
import 'widgets/air_manual_installation.dart'; // The form widget
|
||||||
|
|
||||||
|
class AirManualInstallationScreen extends StatefulWidget {
|
||||||
|
const AirManualInstallationScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AirManualInstallationScreen> createState() =>
|
||||||
|
_AirManualInstallationScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AirManualInstallationScreenState
|
||||||
|
extends State<AirManualInstallationScreen> {
|
||||||
|
late AirInstallationData _installationData;
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_installationData = AirInstallationData(
|
||||||
|
refID: _generateRandomId(10), // Generate a unique ID for this installation
|
||||||
|
samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()),
|
||||||
|
// TODO: Get clientID from an auth provider
|
||||||
|
clientID: 'FlutterUser',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _generateRandomId(int length) {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
final rnd = Random();
|
||||||
|
return String.fromCharCodes(Iterable.generate(
|
||||||
|
length, (_) => chars.codeUnitAt(rnd.nextInt(chars.length))));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitInstallation() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
final service = Provider.of<AirSamplingService>(context, listen: false);
|
||||||
|
final result = await service.submitInstallation(_installationData);
|
||||||
|
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final message = result['message'] ?? 'An unknown error occurred.';
|
||||||
|
final color = (result['status'] == 'L1' || result['status'] == 'S1')
|
||||||
|
? Colors.green
|
||||||
|
: Colors.red;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message), backgroundColor: color),
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Air Sampling Installation'),
|
||||||
|
),
|
||||||
|
body: AirManualInstallationWidget(
|
||||||
|
data: _installationData,
|
||||||
|
onSubmit: _submitInstallation,
|
||||||
|
isLoading: _isLoading,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,61 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class AirManualSampling extends StatefulWidget {
|
|
||||||
@override
|
|
||||||
State<AirManualSampling> createState() => _AirManualSamplingState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AirManualSamplingState extends State<AirManualSampling> {
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
String station = '';
|
|
||||||
String parameter = '';
|
|
||||||
String value = '';
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: Text("Air Manual Sampling")),
|
|
||||||
body: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Form(
|
|
||||||
key: _formKey,
|
|
||||||
child: ListView(
|
|
||||||
children: [
|
|
||||||
Text("Enter Sampling Data", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
|
||||||
SizedBox(height: 24),
|
|
||||||
TextFormField(
|
|
||||||
decoration: InputDecoration(labelText: "Station"),
|
|
||||||
onChanged: (val) => station = val,
|
|
||||||
validator: (val) => val == null || val.isEmpty ? "Required" : null,
|
|
||||||
),
|
|
||||||
SizedBox(height: 16),
|
|
||||||
TextFormField(
|
|
||||||
decoration: InputDecoration(labelText: "Parameter"),
|
|
||||||
onChanged: (val) => parameter = val,
|
|
||||||
validator: (val) => val == null || val.isEmpty ? "Required" : null,
|
|
||||||
),
|
|
||||||
SizedBox(height: 16),
|
|
||||||
TextFormField(
|
|
||||||
decoration: InputDecoration(labelText: "Value"),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
onChanged: (val) => value = val,
|
|
||||||
validator: (val) => val == null || val.isEmpty ? "Required" : null,
|
|
||||||
),
|
|
||||||
SizedBox(height: 24),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
if (_formKey.currentState!.validate()) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text("Air sampling data submitted")),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Text("Submit"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
219
lib/screens/air/manual/widgets/air_manual_collection.dart
Normal file
219
lib/screens/air/manual/widgets/air_manual_collection.dart
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
// lib/screens/air/manual/widgets/air_manual_collection.dart
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../models/air_collection_data.dart'; // Import the actual data model
|
||||||
|
|
||||||
|
class AirManualCollectionWidget extends StatefulWidget {
|
||||||
|
final AirCollectionData data;
|
||||||
|
final double initialTemp;
|
||||||
|
final Future<void> Function() onSubmit;
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
const AirManualCollectionWidget({
|
||||||
|
super.key,
|
||||||
|
required this.data,
|
||||||
|
required this.initialTemp,
|
||||||
|
required this.onSubmit,
|
||||||
|
required this.isLoading,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_AirManualCollectionWidgetState createState() =>
|
||||||
|
_AirManualCollectionWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
// Controllers for text fields
|
||||||
|
final _finalTempController = TextEditingController();
|
||||||
|
final _powerFailureController = TextEditingController();
|
||||||
|
final _remarkController = TextEditingController();
|
||||||
|
|
||||||
|
// PM10 Controllers
|
||||||
|
final _pm10FlowRateController = TextEditingController();
|
||||||
|
final _pm10FlowRateResultController = TextEditingController();
|
||||||
|
final _pm10TimeController = TextEditingController();
|
||||||
|
final _pm10TimeResultController = TextEditingController();
|
||||||
|
final _pm10PressureController = TextEditingController();
|
||||||
|
final _pm10PressureResultController = TextEditingController();
|
||||||
|
final _pm10VstdController = TextEditingController();
|
||||||
|
|
||||||
|
// PM2.5 Controllers
|
||||||
|
final _pm25FlowRateController = TextEditingController();
|
||||||
|
final _pm25FlowRateResultController = TextEditingController();
|
||||||
|
final _pm25TimeController = TextEditingController();
|
||||||
|
final _pm25TimeResultController = TextEditingController();
|
||||||
|
final _pm25PressureController = TextEditingController();
|
||||||
|
final _pm25PressureResultController = TextEditingController();
|
||||||
|
final _pm25VstdController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Pre-fill controllers if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
void _calculateVstd(int type) {
|
||||||
|
setState(() {
|
||||||
|
try {
|
||||||
|
final pressureCtrl = type == 1 ? _pm10PressureController : _pm25PressureController;
|
||||||
|
final flowResultCtrl = type == 1 ? _pm10FlowRateResultController : _pm25FlowRateResultController;
|
||||||
|
final timeResultCtrl = type == 1 ? _pm10TimeResultController : _pm25TimeResultController;
|
||||||
|
// --- BUG FIX START ---
|
||||||
|
final vstdCtrl = type == 1 ? _pm10VstdController : _pm25VstdController; // Correctly assign PM2.5 controller
|
||||||
|
// --- BUG FIX END ---
|
||||||
|
final pressureResultCtrl = type == 1 ? _pm10PressureResultController : _pm25PressureResultController;
|
||||||
|
|
||||||
|
if (pressureCtrl.text.isEmpty || _finalTempController.text.isEmpty || flowResultCtrl.text.isEmpty || timeResultCtrl.text.isEmpty) {
|
||||||
|
vstdCtrl.text = "0.000";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double pressureHpa = double.parse(pressureCtrl.text);
|
||||||
|
double pressureInHg = pressureHpa * 0.02953;
|
||||||
|
pressureResultCtrl.text = pressureInHg.toStringAsFixed(2);
|
||||||
|
|
||||||
|
double qa_m3min = double.parse(flowResultCtrl.text);
|
||||||
|
double totalTime_min = double.parse(timeResultCtrl.text);
|
||||||
|
double pa_mmHg = pressureInHg * 25.4;
|
||||||
|
double t_avg_celsius = (widget.initialTemp + double.parse(_finalTempController.text)) / 2.0;
|
||||||
|
|
||||||
|
double numerator = qa_m3min * pa_mmHg * 298.15;
|
||||||
|
double denominator = 760 * (273.15 + t_avg_celsius);
|
||||||
|
double q_std = numerator / denominator;
|
||||||
|
double v_std = q_std * totalTime_min;
|
||||||
|
|
||||||
|
if (type == 1) {
|
||||||
|
_pm10VstdController.text = v_std.toStringAsFixed(3);
|
||||||
|
widget.data.pm10Vstd = _pm10VstdController.text;
|
||||||
|
} else {
|
||||||
|
_pm25VstdController.text = v_std.toStringAsFixed(3);
|
||||||
|
widget.data.pm25Vstd = _pm25VstdController.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
(type == 1 ? _pm10VstdController : _pm25VstdController).text = "Error";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSubmitPressed() {
|
||||||
|
if (_formKey.currentState!.validate()) {
|
||||||
|
_formKey.currentState!.save();
|
||||||
|
widget.onSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Center(
|
||||||
|
child: Text('Step 2: Collection Data',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
const Divider(height: 30),
|
||||||
|
|
||||||
|
// Collection specific fields
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
decoration: const InputDecoration(labelText: 'Weather', border: OutlineInputBorder()),
|
||||||
|
value: widget.data.weather,
|
||||||
|
items: ['Clear', 'Raining'].map((v) => DropdownMenuItem(value: v, child: Text(v))).toList(),
|
||||||
|
onChanged: (v) => setState(() => widget.data.weather = v),
|
||||||
|
validator: (v) => v == null ? 'Required' : null,
|
||||||
|
onSaved: (v) => widget.data.weather = v,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _finalTempController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Final Temperature (°C)', border: OutlineInputBorder()),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
validator: (v) => v!.isEmpty ? 'Required' : null,
|
||||||
|
onSaved: (v) => widget.data.finalTemp = v,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _powerFailureController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Power Failure Status', border: OutlineInputBorder()),
|
||||||
|
validator: (v) => v!.isEmpty ? 'Required' : null,
|
||||||
|
onSaved: (v) => widget.data.powerFailureStatus = v,
|
||||||
|
),
|
||||||
|
|
||||||
|
// PM10 Section
|
||||||
|
const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Text("PM10 Data", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16))),
|
||||||
|
_buildResultRow(_pm10FlowRateController, "Flow Rate (cfm)", _pm10FlowRateResultController, "Result (m³/min)", (v) => setState(() => _pm10FlowRateResultController.text = (double.tryParse(v) ?? 0 * 0.0283).toStringAsFixed(4)), (v) => widget.data.pm10FlowRate = v),
|
||||||
|
_buildResultRow(_pm10TimeController, "Total Time (hours)", _pm10TimeResultController, "Result (mins)", (v) => setState(() => _pm10TimeResultController.text = (double.tryParse(v) ?? 0 * 60).toStringAsFixed(2)), (v) => widget.data.pm10TotalTime = v),
|
||||||
|
_buildResultRow(_pm10PressureController, "Pressure (hPa)", _pm10PressureResultController, "Result (inHg)", (v) => _calculateVstd(1), (v) => widget.data.pm10Pressure = v),
|
||||||
|
TextFormField(controller: _pm10VstdController, readOnly: true, decoration: const InputDecoration(labelText: 'VSTD PM10 (m³)', border: OutlineInputBorder(), filled: true, fillColor: Colors.black12)),
|
||||||
|
|
||||||
|
// PM2.5 Section
|
||||||
|
const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Text("PM2.5 Data", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16))),
|
||||||
|
_buildResultRow(_pm25FlowRateController, "Flow Rate (cfm)", _pm25FlowRateResultController, "Result (m³/min)", (v) => setState(() => _pm25FlowRateResultController.text = (double.tryParse(v) ?? 0 * 0.0283).toStringAsFixed(4)), (v) => widget.data.pm25FlowRate = v),
|
||||||
|
_buildResultRow(_pm25TimeController, "Total Time (hours)", _pm25TimeResultController, "Result (mins)", (v) => setState(() => _pm25TimeResultController.text = (double.tryParse(v) ?? 0 * 60).toStringAsFixed(2)), (v) => widget.data.pm25TotalTime = v),
|
||||||
|
_buildResultRow(_pm25PressureController, "Pressure (hPa)", _pm25PressureResultController, "Result (inHg)", (v) => _calculateVstd(2), (v) => widget.data.pm25Pressure = v),
|
||||||
|
TextFormField(controller: _pm25VstdController, readOnly: true, decoration: const InputDecoration(labelText: 'VSTD PM2.5 (m³)', border: OutlineInputBorder(), filled: true, fillColor: Colors.black12)),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _remarkController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Remark (Optional)', border: OutlineInputBorder()),
|
||||||
|
onSaved: (v) => widget.data.remark = v,
|
||||||
|
),
|
||||||
|
|
||||||
|
// TODO: Add 6 Image Picker Widgets Here, relabeled as per the PDF
|
||||||
|
|
||||||
|
const SizedBox(height: 30),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: widget.isLoading ? null : _onSubmitPressed,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
),
|
||||||
|
child: widget.isLoading
|
||||||
|
? const CircularProgressIndicator(color: Colors.white)
|
||||||
|
: const Text('Submit All Data', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for building rows with input and result fields
|
||||||
|
Widget _buildResultRow(TextEditingController inputCtrl, String inputLabel, TextEditingController resultCtrl, String resultLabel, Function(String) onChanged, Function(String?) onSaved) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: inputCtrl,
|
||||||
|
decoration: InputDecoration(labelText: inputLabel, border: const OutlineInputBorder()),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
onChanged: onChanged,
|
||||||
|
onSaved: onSaved,
|
||||||
|
validator: (v) => v!.isEmpty ? 'Required' : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: resultCtrl,
|
||||||
|
readOnly: true,
|
||||||
|
decoration: InputDecoration(labelText: resultLabel, border: const OutlineInputBorder(), filled: true, fillColor: Colors.black12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
341
lib/screens/air/manual/widgets/air_manual_installation.dart
Normal file
341
lib/screens/air/manual/widgets/air_manual_installation.dart
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
// lib/screens/air/manual/widgets/air_manual_installation.dart
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:dropdown_search/dropdown_search.dart';
|
||||||
|
import '../../../../models/air_installation_data.dart';
|
||||||
|
import '../../../../services/air_sampling_service.dart';
|
||||||
|
|
||||||
|
// --- Placeholder classes to replace old dependencies ---
|
||||||
|
class localState {
|
||||||
|
final String stateID;
|
||||||
|
final String stateName;
|
||||||
|
localState({required this.stateID, required this.stateName});
|
||||||
|
}
|
||||||
|
|
||||||
|
class localLocation {
|
||||||
|
final String stationID;
|
||||||
|
final String locationName;
|
||||||
|
localLocation({required this.stationID, required this.locationName});
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AirManualInstallationWidget extends StatefulWidget {
|
||||||
|
final AirInstallationData data;
|
||||||
|
// --- UPDATED: Parameters now match the collection widget ---
|
||||||
|
final Future<void> Function() onSubmit;
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
const AirManualInstallationWidget({
|
||||||
|
super.key,
|
||||||
|
required this.data,
|
||||||
|
required this.onSubmit,
|
||||||
|
required this.isLoading,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_AirManualInstallationWidgetState createState() =>
|
||||||
|
_AirManualInstallationWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AirManualInstallationWidgetState extends State<AirManualInstallationWidget> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
// Local state for this step
|
||||||
|
bool _isLocationVisible = false;
|
||||||
|
List<localState> _states = [];
|
||||||
|
localState? _selectedState;
|
||||||
|
List<localLocation> _locations = [];
|
||||||
|
localLocation? _selectedLocation;
|
||||||
|
|
||||||
|
// Controllers for text fields
|
||||||
|
final TextEditingController _regionController = TextEditingController();
|
||||||
|
final TextEditingController _tempController = TextEditingController();
|
||||||
|
final TextEditingController _pm10Controller = TextEditingController();
|
||||||
|
final TextEditingController _pm25Controller = TextEditingController();
|
||||||
|
final TextEditingController _remarkController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadStates();
|
||||||
|
// Pre-fill controllers if data already exists
|
||||||
|
_regionController.text = widget.data.region ?? '';
|
||||||
|
_tempController.text = widget.data.temp ?? '';
|
||||||
|
_pm10Controller.text = widget.data.pm10FilterId ?? '';
|
||||||
|
_pm25Controller.text = widget.data.pm25FilterId ?? '';
|
||||||
|
_remarkController.text = widget.data.remark ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadStates() async {
|
||||||
|
// This now uses placeholder data.
|
||||||
|
final data = [
|
||||||
|
localState(stateID: 'SGR', stateName: 'Selangor'),
|
||||||
|
localState(stateID: 'JHR', stateName: 'Johor'),
|
||||||
|
localState(stateID: 'KDH', stateName: 'Kedah'),
|
||||||
|
localState(stateID: 'PRK', stateName: 'Perak'),
|
||||||
|
];
|
||||||
|
setState(() {
|
||||||
|
_states = data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadLocations(String? stateID) async {
|
||||||
|
if (stateID == null) return;
|
||||||
|
|
||||||
|
// This now uses placeholder data.
|
||||||
|
List<localLocation> data = [];
|
||||||
|
if (stateID == 'SGR') {
|
||||||
|
data = [
|
||||||
|
localLocation(stationID: 'SGR01', locationName: 'Shah Alam'),
|
||||||
|
localLocation(stationID: 'SGR02', locationName: 'Klang'),
|
||||||
|
localLocation(stationID: 'SGR03', locationName: 'Petaling Jaya'),
|
||||||
|
];
|
||||||
|
} else if (stateID == 'JHR') {
|
||||||
|
data = [
|
||||||
|
localLocation(stationID: 'JHR01', locationName: 'Johor Bahru'),
|
||||||
|
localLocation(stationID: 'JHR02', locationName: 'Muar'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_locations = data;
|
||||||
|
_isLocationVisible = true;
|
||||||
|
_selectedLocation = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSubmitPressed() {
|
||||||
|
if (_formKey.currentState!.validate()) {
|
||||||
|
_formKey.currentState!.save();
|
||||||
|
widget.onSubmit(); // Use the passed onSubmit function
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Image Handling Logic ---
|
||||||
|
Future<void> _pickImage(ImageSource source, int imageNumber) async {
|
||||||
|
final service = Provider.of<AirSamplingService>(context, listen: false);
|
||||||
|
final stationCode = widget.data.stationID ?? 'UNKNOWN';
|
||||||
|
|
||||||
|
String imageInfo;
|
||||||
|
switch (imageNumber) {
|
||||||
|
case 1: imageInfo = 'INSTALLATION_LEFT'; break;
|
||||||
|
case 2: imageInfo = 'INSTALLATION_RIGHT'; break;
|
||||||
|
case 3: imageInfo = 'INSTALLATION_FRONT'; break;
|
||||||
|
case 4: imageInfo = 'INSTALLATION_BACK'; break;
|
||||||
|
default: return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final File? imageFile = await service.pickAndProcessImage(
|
||||||
|
source,
|
||||||
|
stationCode: stationCode,
|
||||||
|
imageInfo: imageInfo,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (imageFile != null) {
|
||||||
|
setState(() {
|
||||||
|
switch (imageNumber) {
|
||||||
|
case 1: widget.data.image1 = imageFile; break;
|
||||||
|
case 2: widget.data.image2 = imageFile; break;
|
||||||
|
case 3: widget.data.image3 = imageFile; break;
|
||||||
|
case 4: widget.data.image4 = imageFile; break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImagePicker(String title, File? imageFile, int imageNumber) {
|
||||||
|
// ... (This helper widget remains the same)
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Divider(height: 20),
|
||||||
|
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (imageFile != null)
|
||||||
|
Stack(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
children: [
|
||||||
|
Image.file(imageFile, fit: BoxFit.cover, width: double.infinity, height: 200),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete, color: Colors.white, size: 30),
|
||||||
|
style: IconButton.styleFrom(backgroundColor: Colors.red.withOpacity(0.7)),
|
||||||
|
onPressed: () => setState(() {
|
||||||
|
switch (imageNumber) {
|
||||||
|
case 1: widget.data.image1 = null; break;
|
||||||
|
case 2: widget.data.image2 = null; break;
|
||||||
|
case 3: widget.data.image3 = null; break;
|
||||||
|
case 4: widget.data.image4 = null; break;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
height: 150,
|
||||||
|
width: double.infinity,
|
||||||
|
color: Colors.grey[200],
|
||||||
|
child: const Center(child: Text('No Image Selected')),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.camera_alt),
|
||||||
|
label: const Text('Camera'),
|
||||||
|
onPressed: () => _pickImage(ImageSource.camera, imageNumber),
|
||||||
|
),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.photo_library),
|
||||||
|
label: const Text('Gallery'),
|
||||||
|
onPressed: () => _pickImage(ImageSource.gallery, imageNumber),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// ... (All form fields remain the same)
|
||||||
|
const Center(
|
||||||
|
child: Text('Installation Details',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
const Divider(height: 30),
|
||||||
|
DropdownSearch<localState>(
|
||||||
|
items: _states,
|
||||||
|
selectedItem: _selectedState,
|
||||||
|
itemAsString: (localState s) => s.stateName,
|
||||||
|
popupProps: const PopupProps.menu(showSearchBox: true),
|
||||||
|
dropdownDecoratorProps: const DropDownDecoratorProps(
|
||||||
|
dropdownSearchDecoration: InputDecoration(
|
||||||
|
labelText: "Select State",
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (localState? value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedState = value;
|
||||||
|
widget.data.stateID = value?.stateID;
|
||||||
|
_loadLocations(value?.stateID);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
validator: (v) => v == null ? 'State is required' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (_isLocationVisible)
|
||||||
|
DropdownSearch<localLocation>(
|
||||||
|
items: _locations,
|
||||||
|
selectedItem: _selectedLocation,
|
||||||
|
itemAsString: (localLocation l) => "${l.stationID} - ${l.locationName}",
|
||||||
|
// --- FIXED: Removed 'const' from PopupProps.menu ---
|
||||||
|
popupProps: PopupProps.menu(showSearchBox: true,
|
||||||
|
emptyBuilder: (context, searchEntry) => const Center(child: Text("No locations found for this state")),
|
||||||
|
),
|
||||||
|
dropdownDecoratorProps: const DropDownDecoratorProps(
|
||||||
|
dropdownSearchDecoration: InputDecoration(
|
||||||
|
labelText: "Select Location",
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (localLocation? value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedLocation = value;
|
||||||
|
widget.data.stationID = value?.stationID;
|
||||||
|
widget.data.locationName = value?.locationName;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
validator: (v) => v == null ? 'Location is required' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _regionController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Region', border: OutlineInputBorder()),
|
||||||
|
validator: (v) => v!.isEmpty ? 'Region is required' : null,
|
||||||
|
onSaved: (v) => widget.data.region = v,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
decoration: const InputDecoration(labelText: 'Weather', border: OutlineInputBorder()),
|
||||||
|
value: widget.data.weather,
|
||||||
|
items: ['Clear', 'Raining'].map((v) => DropdownMenuItem(value: v, child: Text(v))).toList(),
|
||||||
|
onChanged: (v) => setState(() => widget.data.weather = v),
|
||||||
|
onSaved: (v) => widget.data.weather = v,
|
||||||
|
validator: (v) => v == null ? 'Weather is required' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _tempController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Temperature (°C)', border: OutlineInputBorder()),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
validator: (v) => v!.isEmpty ? 'Temperature is required' : null,
|
||||||
|
onSaved: (v) => widget.data.temp = v,
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text("Power Failure?"),
|
||||||
|
value: widget.data.powerFailure,
|
||||||
|
onChanged: (bool value) => setState(() => widget.data.powerFailure = value),
|
||||||
|
),
|
||||||
|
TextFormField(
|
||||||
|
controller: _pm10Controller,
|
||||||
|
enabled: !widget.data.powerFailure,
|
||||||
|
decoration: const InputDecoration(labelText: 'PM10 Filter ID', border: OutlineInputBorder()),
|
||||||
|
validator: (v) => !widget.data.powerFailure && v!.isEmpty ? 'Required' : null,
|
||||||
|
onSaved: (v) => widget.data.pm10FilterId = v,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _pm25Controller,
|
||||||
|
enabled: !widget.data.powerFailure,
|
||||||
|
decoration: const InputDecoration(labelText: 'PM2.5 Filter ID', border: OutlineInputBorder()),
|
||||||
|
validator: (v) => !widget.data.powerFailure && v!.isEmpty ? 'Required' : null,
|
||||||
|
onSaved: (v) => widget.data.pm25FilterId = v,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _remarkController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Remark (Optional)', border: OutlineInputBorder()),
|
||||||
|
onSaved: (v) => widget.data.remark = v,
|
||||||
|
),
|
||||||
|
|
||||||
|
_buildImagePicker('Site Picture (Left)', widget.data.image1, 1),
|
||||||
|
_buildImagePicker('Site Picture (Right)', widget.data.image2, 2),
|
||||||
|
_buildImagePicker('Site Picture (Front)', widget.data.image3, 3),
|
||||||
|
_buildImagePicker('Site Picture (Back)', widget.data.image4, 4),
|
||||||
|
|
||||||
|
const SizedBox(height: 30),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
// --- UPDATED: Use isLoading and call _onSubmitPressed ---
|
||||||
|
onPressed: widget.isLoading ? null : _onSubmitPressed,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
),
|
||||||
|
child: widget.isLoading
|
||||||
|
? const CircularProgressIndicator(color: Colors.white)
|
||||||
|
: const Text('Submit Installation', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
207
lib/services/air_sampling_service.dart
Normal file
207
lib/services/air_sampling_service.dart
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
// lib/services/air_sampling_service.dart
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
// Import your actual data models
|
||||||
|
import '../models/air_installation_data.dart';
|
||||||
|
import '../models/air_collection_data.dart';
|
||||||
|
|
||||||
|
// Import a local storage service (you would create this)
|
||||||
|
// import 'local_storage_service.dart';
|
||||||
|
|
||||||
|
// A placeholder for your actual API service
|
||||||
|
class AirApiService {
|
||||||
|
Future<Map<String, dynamic>> submitInstallation({
|
||||||
|
required Map<String, dynamic> installationJson,
|
||||||
|
required List<File> imageFiles,
|
||||||
|
}) async {
|
||||||
|
// In a real app, you would build an http.MultipartRequest here
|
||||||
|
// to send the JSON data and image files to your server.
|
||||||
|
print("Submitting Installation to API...");
|
||||||
|
print("Data: $installationJson");
|
||||||
|
print("Image count: ${imageFiles.length}");
|
||||||
|
|
||||||
|
// Simulate a network call
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
|
||||||
|
// Simulate a successful response
|
||||||
|
return {
|
||||||
|
'status': 'S1', // 'S1' for Server Success (Installation Pending Collection)
|
||||||
|
'message': 'Installation data successfully submitted to the server.',
|
||||||
|
'refID': installationJson['refID'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> submitCollection({
|
||||||
|
required Map<String, dynamic> collectionJson,
|
||||||
|
required List<File> imageFiles,
|
||||||
|
}) async {
|
||||||
|
// In a real app, this would update the existing record linked by 'installationRefID'
|
||||||
|
print("Submitting Collection to API...");
|
||||||
|
print("Data: $collectionJson");
|
||||||
|
print("Image count: ${imageFiles.length}");
|
||||||
|
|
||||||
|
// Simulate a network call
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
|
||||||
|
// Simulate a successful response
|
||||||
|
return {
|
||||||
|
'status': 'S3', // 'S3' for Server Success (Completed)
|
||||||
|
'message': 'Collection data successfully linked and submitted.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// A dedicated service to handle all business logic for the Air Manual Sampling feature.
|
||||||
|
class AirSamplingService {
|
||||||
|
final AirApiService _apiService = AirApiService();
|
||||||
|
// final LocalStorageService _localStorageService = LocalStorageService();
|
||||||
|
|
||||||
|
/// Picks an image from the specified source, adds a timestamp watermark,
|
||||||
|
/// and saves it to a temporary directory with a standardized name.
|
||||||
|
Future<File?> pickAndProcessImage(
|
||||||
|
ImageSource source, {
|
||||||
|
required String stationCode,
|
||||||
|
required String imageInfo, // e.g., "INSTALLATION_LEFT", "COLLECTION_CHART"
|
||||||
|
}) async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final XFile? photo = await picker.pickImage(
|
||||||
|
source: source, imageQuality: 85, maxWidth: 1024);
|
||||||
|
if (photo == null) return null;
|
||||||
|
|
||||||
|
final bytes = await photo.readAsBytes();
|
||||||
|
img.Image? originalImage = img.decodeImage(bytes);
|
||||||
|
if (originalImage == null) return null;
|
||||||
|
|
||||||
|
// Prepare watermark text
|
||||||
|
final String watermarkTimestamp =
|
||||||
|
DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
|
||||||
|
final font = img.arial24;
|
||||||
|
|
||||||
|
// Add a white background for better text visibility
|
||||||
|
final textWidth = watermarkTimestamp.length * 12; // Estimate width
|
||||||
|
img.fillRect(originalImage,
|
||||||
|
x1: 5,
|
||||||
|
y1: 5,
|
||||||
|
x2: textWidth + 15,
|
||||||
|
y2: 35,
|
||||||
|
color: img.ColorRgb8(255, 255, 255));
|
||||||
|
img.drawString(originalImage, watermarkTimestamp,
|
||||||
|
font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0));
|
||||||
|
|
||||||
|
// Create a standardized file name
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final fileTimestamp = watermarkTimestamp.replaceAll(':', '-').replaceAll(' ', '_');
|
||||||
|
final newFileName =
|
||||||
|
"${stationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg";
|
||||||
|
final filePath = path.join(tempDir.path, newFileName);
|
||||||
|
|
||||||
|
// Save the processed image and return the file
|
||||||
|
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submits only the installation data.
|
||||||
|
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data) async {
|
||||||
|
try {
|
||||||
|
// Prepare image files for upload
|
||||||
|
final List<File> images = [];
|
||||||
|
if (data.image1 != null) images.add(data.image1!);
|
||||||
|
if (data.image2 != null) images.add(data.image2!);
|
||||||
|
if (data.image3 != null) images.add(data.image3!);
|
||||||
|
if (data.image4 != null) images.add(data.image4!);
|
||||||
|
|
||||||
|
// In a real app, you would check connectivity here before attempting the API call
|
||||||
|
final result = await _apiService.submitInstallation(
|
||||||
|
installationJson: data.toJson(),
|
||||||
|
imageFiles: images,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
print("API submission failed: $e. Saving installation locally.");
|
||||||
|
// --- Fallback to Local Storage ---
|
||||||
|
// TODO: Implement local DB save for installation data
|
||||||
|
data.status = 'L1'; // Mark as saved locally, pending collection
|
||||||
|
// await _localStorageService.saveAirInstallationData(data);
|
||||||
|
return {
|
||||||
|
'status': 'L1',
|
||||||
|
'message': 'Installation data saved locally.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submits only the collection data, linked to a previous installation.
|
||||||
|
Future<Map<String, dynamic>> submitCollection(AirCollectionData data) async {
|
||||||
|
try {
|
||||||
|
// Prepare image files for upload
|
||||||
|
final List<File> images = [];
|
||||||
|
if (data.imageSiteLeft != null) images.add(data.imageSiteLeft!);
|
||||||
|
if (data.imageSiteRight != null) images.add(data.imageSiteRight!);
|
||||||
|
// ... add all other collection images
|
||||||
|
|
||||||
|
// In a real app, you would check connectivity here
|
||||||
|
final result = await _apiService.submitCollection(
|
||||||
|
collectionJson: data.toJson(),
|
||||||
|
imageFiles: images,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
print("API submission failed: $e. Saving collection locally.");
|
||||||
|
// --- Fallback to Local Storage ---
|
||||||
|
// TODO: Implement local DB save for collection data
|
||||||
|
data.status = 'L3'; // Mark as completed locally
|
||||||
|
// await _localStorageService.saveAirCollectionData(data);
|
||||||
|
return {
|
||||||
|
'status': 'L3',
|
||||||
|
'message': 'Collection data saved locally.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches installations that are pending collection.
|
||||||
|
Future<List<AirInstallationData>> getPendingInstallations() async {
|
||||||
|
// In a real app, this would query your local database or a server endpoint
|
||||||
|
// for records with status 'L1' (local, pending) or 'S1' (server, pending).
|
||||||
|
print("Fetching pending installations...");
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500)); // Simulate network/DB delay
|
||||||
|
|
||||||
|
// Return placeholder data for demonstration
|
||||||
|
return [
|
||||||
|
AirInstallationData(
|
||||||
|
refID: 'ABC1234567',
|
||||||
|
stationID: 'SGR01',
|
||||||
|
locationName: 'Shah Alam',
|
||||||
|
samplingDate: '2025-08-10',
|
||||||
|
temp: '28.5',
|
||||||
|
status: 'L1', // Example of a locally saved installation
|
||||||
|
),
|
||||||
|
AirInstallationData(
|
||||||
|
refID: 'DEF8901234',
|
||||||
|
stationID: 'JHR02',
|
||||||
|
locationName: 'Muar',
|
||||||
|
samplingDate: '2025-08-09',
|
||||||
|
temp: '29.1',
|
||||||
|
status: 'S1', // Example of a server-saved installation
|
||||||
|
),
|
||||||
|
AirInstallationData(
|
||||||
|
refID: 'GHI5678901',
|
||||||
|
stationID: 'PRK01',
|
||||||
|
locationName: 'Ipoh',
|
||||||
|
samplingDate: '2025-08-11',
|
||||||
|
temp: '27.9',
|
||||||
|
status: 'S1',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
// Clean up any resources if necessary
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user