add in air manual module

This commit is contained in:
ALim Aidrus 2025-08-11 14:45:55 +08:00
parent e90f4972cc
commit d60e6cd60f
10 changed files with 1189 additions and 70 deletions

View File

@ -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 {
),
);
}
}
}

View 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,
};
}
}

View 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,
};
}
}

View File

@ -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) {

View 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.')),
),
],
);
},
),
);
}
}

View 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,
),
);
}
}

View File

@ -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"),
),
],
),
),
),
);
}
}

View 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),
),
),
],
),
);
}
}

View 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)),
),
),
],
),
),
);
}
}

View 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
}
}