From d60e6cd60f14be7b15d7772eb2e128a136a887dc Mon Sep 17 00:00:00 2001 From: ALim Aidrus Date: Mon, 11 Aug 2025 14:45:55 +0800 Subject: [PATCH] add in air manual module --- lib/main.dart | 19 +- lib/models/air_collection_data.dart | 100 +++++ lib/models/air_installation_data.dart | 83 +++++ lib/screens/air/air_home_page.dart | 10 +- .../manual/air_manual_collection_screen.dart | 141 ++++++++ .../air_manual_installation_screen.dart | 78 ++++ lib/screens/air/manual/manual_sampling.dart | 61 ---- .../manual/widgets/air_manual_collection.dart | 219 +++++++++++ .../widgets/air_manual_installation.dart | 341 ++++++++++++++++++ lib/services/air_sampling_service.dart | 207 +++++++++++ 10 files changed, 1189 insertions(+), 70 deletions(-) create mode 100644 lib/models/air_collection_data.dart create mode 100644 lib/models/air_installation_data.dart create mode 100644 lib/screens/air/manual/air_manual_collection_screen.dart create mode 100644 lib/screens/air/manual/air_manual_installation_screen.dart delete mode 100644 lib/screens/air/manual/manual_sampling.dart create mode 100644 lib/screens/air/manual/widgets/air_manual_collection.dart create mode 100644 lib/screens/air/manual/widgets/air_manual_installation.dart create mode 100644 lib/services/air_sampling_service.dart diff --git a/lib/main.dart b/lib/main.dart index 208e164..6bb9016 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,8 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:provider/single_child_widget.dart'; import 'package:environment_monitoring_app/services/local_storage_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/theme.dart'; @@ -29,7 +31,9 @@ import 'package:environment_monitoring_app/screens/marine/marine_home_page.dart' // Air Screens 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/data_status_log.dart' as airManualDataStatusLog; import 'package:environment_monitoring_app/screens/air/manual/image_request.dart' as airManualImageRequest; @@ -100,7 +104,8 @@ void main() async { Provider(create: (_) => LocalStorageService()), // Provider for the River In-Situ Sampling Service 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(), ), @@ -160,8 +165,8 @@ class RootApp extends StatelessWidget { return TarballSamplingStep3Summary(data: args); }); } - // NOTE: The River In-Situ form uses an internal stepper, - // so it does not require onGenerateRoute logic for its steps. + // NOTE: The River and Air In-Situ forms use an internal stepper, + // so they do not require onGenerateRoute logic for their steps. return null; }, routes: { @@ -180,7 +185,9 @@ class RootApp extends StatelessWidget { // Air Manual '/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/data-log': (context) => airManualDataStatusLog.AirManualDataStatusLog(), '/air/manual/image-request': (context) => airManualImageRequest.AirManualImageRequest(), @@ -266,4 +273,4 @@ class SplashScreen extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/models/air_collection_data.dart b/lib/models/air_collection_data.dart new file mode 100644 index 0000000..83c8539 --- /dev/null +++ b/lib/models/air_collection_data.dart @@ -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 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, + }; + } +} diff --git a/lib/models/air_installation_data.dart b/lib/models/air_installation_data.dart new file mode 100644 index 0000000..d9a210b --- /dev/null +++ b/lib/models/air_installation_data.dart @@ -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 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, + }; + } +} diff --git a/lib/screens/air/air_home_page.dart b/lib/screens/air/air_home_page.dart index f47e117..aeb44eb 100644 --- a/lib/screens/air/air_home_page.dart +++ b/lib/screens/air/air_home_page.dart @@ -32,7 +32,9 @@ class AirHomePage extends StatelessWidget { isParent: true, children: [ 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.article, label: "Data Log", route: '/air/manual/data-log'), SidebarItem(icon: Icons.image, label: "Image Request", route: '/air/manual/image-request'), @@ -118,10 +120,12 @@ class AirHomePage extends StatelessWidget { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), 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 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, itemBuilder: (context, index) { diff --git a/lib/screens/air/manual/air_manual_collection_screen.dart b/lib/screens/air/manual/air_manual_collection_screen.dart new file mode 100644 index 0000000..5640baa --- /dev/null +++ b/lib/screens/air/manual/air_manual_collection_screen.dart @@ -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 createState() => + _AirManualCollectionScreenState(); +} + +class _AirManualCollectionScreenState extends State { + late Future> _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(context, listen: false) + .getPendingInstallations(); + } + + Future _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(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>( + 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( + 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.')), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/screens/air/manual/air_manual_installation_screen.dart b/lib/screens/air/manual/air_manual_installation_screen.dart new file mode 100644 index 0000000..93e2224 --- /dev/null +++ b/lib/screens/air/manual/air_manual_installation_screen.dart @@ -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 createState() => + _AirManualInstallationScreenState(); +} + +class _AirManualInstallationScreenState + extends State { + 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 _submitInstallation() async { + setState(() => _isLoading = true); + + final service = Provider.of(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, + ), + ); + } +} diff --git a/lib/screens/air/manual/manual_sampling.dart b/lib/screens/air/manual/manual_sampling.dart deleted file mode 100644 index 2d85ce4..0000000 --- a/lib/screens/air/manual/manual_sampling.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; - -class AirManualSampling extends StatefulWidget { - @override - State createState() => _AirManualSamplingState(); -} - -class _AirManualSamplingState extends State { - final _formKey = GlobalKey(); - 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"), - ), - ], - ), - ), - ), - ); - } -} \ No newline at end of file diff --git a/lib/screens/air/manual/widgets/air_manual_collection.dart b/lib/screens/air/manual/widgets/air_manual_collection.dart new file mode 100644 index 0000000..84f1abe --- /dev/null +++ b/lib/screens/air/manual/widgets/air_manual_collection.dart @@ -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 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 { + final _formKey = GlobalKey(); + + // 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( + 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), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/air/manual/widgets/air_manual_installation.dart b/lib/screens/air/manual/widgets/air_manual_installation.dart new file mode 100644 index 0000000..494493e --- /dev/null +++ b/lib/screens/air/manual/widgets/air_manual_installation.dart @@ -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 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 { + final _formKey = GlobalKey(); + + // Local state for this step + bool _isLocationVisible = false; + List _states = []; + localState? _selectedState; + List _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 _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 _loadLocations(String? stateID) async { + if (stateID == null) return; + + // This now uses placeholder data. + List 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 _pickImage(ImageSource source, int imageNumber) async { + final service = Provider.of(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( + 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( + 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( + 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)), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/services/air_sampling_service.dart b/lib/services/air_sampling_service.dart new file mode 100644 index 0000000..9aff09e --- /dev/null +++ b/lib/services/air_sampling_service.dart @@ -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> submitInstallation({ + required Map installationJson, + required List 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> submitCollection({ + required Map collectionJson, + required List 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 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> submitInstallation(AirInstallationData data) async { + try { + // Prepare image files for upload + final List 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> submitCollection(AirCollectionData data) async { + try { + // Prepare image files for upload + final List 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> 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 + } +}