From e5af5a3f036879dac2ea6a3a389c3f39fc99178a Mon Sep 17 00:00:00 2001 From: ALim Aidrus Date: Tue, 12 Aug 2025 21:12:10 +0800 Subject: [PATCH] add in air manual sampling module for installation process --- lib/auth_provider.dart | 31 +- lib/models/air_collection_data.dart | 163 +++--- lib/models/air_installation_data.dart | 177 +++++- .../manual/air_manual_collection_screen.dart | 22 +- .../air_manual_installation_screen.dart | 11 +- .../manual/widgets/air_manual_collection.dart | 110 +++- .../widgets/air_manual_installation.dart | 547 ++++++++++-------- .../widgets/in_situ_step_1_sampling_info.dart | 2 + lib/services/air_sampling_service.dart | 223 +++---- lib/services/api_service.dart | 94 +-- lib/services/base_api_service.dart | 10 +- lib/services/local_storage_service.dart | 109 +++- 12 files changed, 941 insertions(+), 558 deletions(-) diff --git a/lib/auth_provider.dart b/lib/auth_provider.dart index 3978735..c2c4c70 100644 --- a/lib/auth_provider.dart +++ b/lib/auth_provider.dart @@ -1,5 +1,3 @@ -// lib/auth_provider.dart - import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; @@ -39,6 +37,10 @@ class AuthProvider with ChangeNotifier { List>? _departments; List>? _companies; List>? _positions; + List>? _airClients; + List>? _airManualStations; + // --- ADDED FOR STATE LIST --- + List>? _states; // --- Getters for UI access --- List>? get allUsers => _allUsers; @@ -50,6 +52,10 @@ class AuthProvider with ChangeNotifier { List>? get departments => _departments; List>? get companies => _companies; List>? get positions => _positions; + List>? get airClients => _airClients; + List>? get airManualStations => _airManualStations; + // --- ADDED FOR STATE LIST --- + List>? get states => _states; // --- SharedPreferences Keys (made public for BaseApiService) --- static const String tokenKey = 'jwt_token'; @@ -133,6 +139,13 @@ class AuthProvider with ChangeNotifier { _departments = data['departments'] != null ? List>.from(data['departments']) : null; _companies = data['companies'] != null ? List>.from(data['companies']) : null; _positions = data['positions'] != null ? List>.from(data['positions']) : null; + _airClients = data['airClients'] != null ? List>.from(data['airClients']) : null; + _airManualStations = data['airManualStations'] != null ? List>.from(data['airManualStations']) : null; + + // --- ADDED FOR STATE LIST --- + // Note: `syncAllData` in ApiService must be updated to fetch 'states' + _states = data['states'] != null ? List>.from(data['states']) : null; + await setLastSyncTimestamp(DateTime.now()); } } @@ -149,6 +162,13 @@ class AuthProvider with ChangeNotifier { _departments = await _dbHelper.loadDepartments(); _companies = await _dbHelper.loadCompanies(); _positions = await _dbHelper.loadPositions(); + _airClients = await _dbHelper.loadAirClients(); + _airManualStations = await _dbHelper.loadAirManualStations(); + + // --- ADDED FOR STATE LIST --- + // Note: `loadStates()` must be added to your DatabaseHelper class + _states = await _dbHelper.loadStates(); + debugPrint("AuthProvider: All master data loaded from local DB cache."); } @@ -208,6 +228,11 @@ class AuthProvider with ChangeNotifier { _departments = null; _companies = null; _positions = null; + _airClients = null; + _airManualStations = null; + + // --- ADDED FOR STATE LIST --- + _states = null; final prefs = await SharedPreferences.getInstance(); await prefs.clear(); @@ -220,4 +245,4 @@ class AuthProvider with ChangeNotifier { Future> resetPassword(String email) { return _apiService.post('auth/forgot-password', {'email': email}); } -} +} \ No newline at end of file diff --git a/lib/models/air_collection_data.dart b/lib/models/air_collection_data.dart index 83c8539..563100b 100644 --- a/lib/models/air_collection_data.dart +++ b/lib/models/air_collection_data.dart @@ -1,100 +1,121 @@ // 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 + // Link to the original installation + String? installationRefID; + int? airManId; // The ID from the server database + + // Collection Info + String? collectionDate; + String? collectionTime; String? weather; - String? finalTemp; - String? powerFailureStatus; - String? remark; + double? temperature; + String? powerFailure; - // PM10 Data - String? pm10FlowRate; + // PM10 Readings + double? pm10Flowrate; + String? pm10FlowrateResult; String? pm10TotalTime; - String? pm10Pressure; - String? pm10Vstd; + String? pm10TotalTimeResult; + double? pm10Pressure; + String? pm10PressureResult; + double? pm10Vstd; - // PM2.5 Data - String? pm25FlowRate; + // PM2.5 Readings + double? pm25Flowrate; + String? pm25FlowrateResult; String? pm25TotalTime; - String? pm25Pressure; - String? pm25Vstd; + String? pm25TotalTimeResult; + double? pm25Pressure; + String? pm25PressureResult; + double? 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 + // General + String? remarks; + int? collectionUserId; String? status; AirCollectionData({ this.installationRefID, - this.initialTemp, + this.airManId, + this.collectionDate, + this.collectionTime, this.weather, - this.finalTemp, - this.powerFailureStatus, - this.remark, - this.pm10FlowRate, + this.temperature, + this.powerFailure, + this.pm10Flowrate, + this.pm10FlowrateResult, this.pm10TotalTime, + this.pm10TotalTimeResult, this.pm10Pressure, + this.pm10PressureResult, this.pm10Vstd, - this.pm25FlowRate, + this.pm25Flowrate, + this.pm25FlowrateResult, this.pm25TotalTime, + this.pm25TotalTimeResult, this.pm25Pressure, + this.pm25PressureResult, 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.remarks, + this.collectionUserId, this.status, }); - // Method to convert the data to a JSON-like Map - Map toJson() { + /// Creates a map for saving all data to local storage. + Map toMap() { 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, + 'air_man_id': airManId, + 'air_man_collection_date': collectionDate, + 'air_man_collection_time': collectionTime, + 'air_man_collection_weather': weather, + 'air_man_collection_temperature': temperature, + 'air_man_collection_power_failure': powerFailure, + 'air_man_collection_pm10_flowrate': pm10Flowrate, + 'air_man_collection_pm10_flowrate_result': pm10FlowrateResult, + 'air_man_collection_pm10_total_time': pm10TotalTime, + 'air_man_collection_total_time_result': pm10TotalTimeResult, + 'air_man_collection_pm10_pressure': pm10Pressure, + 'air_man_collection_pm10_pressure_result': pm10PressureResult, + 'air_man_collection_pm10_vstd': pm10Vstd, + 'air_man_collection_pm25_flowrate': pm25Flowrate, + 'air_man_collection_pm25_flowrate_result': pm25FlowrateResult, + 'air_man_collection_pm25_total_time': pm25TotalTime, + 'air_man_collection_pm25_total_time_result': pm25TotalTimeResult, + 'air_man_collection_pm25_pressure': pm25Pressure, + 'air_man_collection_pm25_pressure_result': pm25PressureResult, + 'air_man_collection_pm25_vstd': pm25Vstd, + 'air_man_collection_remarks': remarks, 'status': status, }; } + + /// Creates a JSON map with keys that match the backend API. + Map toJson() { + // This method is now designed to match the API structure perfectly. + return { + 'air_man_id': airManId, + 'air_man_collection_date': collectionDate, + 'air_man_collection_time': collectionTime, + 'air_man_collection_weather': weather, + 'air_man_collection_temperature': temperature, + 'air_man_collection_power_failure': powerFailure, + 'air_man_collection_pm10_flowrate': pm10Flowrate, + 'air_man_collection_pm10_flowrate_result': pm10FlowrateResult, + 'air_man_collection_pm10_total_time': pm10TotalTime, + 'air_man_collection_total_time_result': pm10TotalTimeResult, + 'air_man_collection_pm10_pressure': pm10Pressure, + 'air_man_collection_pm10_pressure_result': pm10PressureResult, + 'air_man_collection_pm10_vstd': pm10Vstd, + 'air_man_collection_pm25_flowrate': pm25Flowrate, + 'air_man_collection_pm25_flowrate_result': pm25FlowrateResult, + 'air_man_collection_pm25_total_time': pm25TotalTime, + 'air_man_collection_pm25_total_time_result': pm25TotalTimeResult, + 'air_man_collection_pm25_pressure': pm25Pressure, + 'air_man_collection_pm25_pressure_result': pm25PressureResult, + 'air_man_collection_pm25_vstd': pm25Vstd, + 'air_man_collection_remarks': remarks, + }; + } } diff --git a/lib/models/air_installation_data.dart b/lib/models/air_installation_data.dart index d9a210b..fc134e4 100644 --- a/lib/models/air_installation_data.dart +++ b/lib/models/air_installation_data.dart @@ -1,11 +1,13 @@ // lib/models/air_installation_data.dart - import 'dart:io'; class AirInstallationData { String? refID; + int? airManId; // The ID from the server database String? samplingDate; - String? clientID; + int? clientId; + String? installationDate; + String? installationTime; String? stateID; String? locationName; String? stationID; @@ -16,26 +18,40 @@ class AirInstallationData { String? pm10FilterId; String? pm25FilterId; String? remark; + int? installationUserId; - // For handling image files during the process - File? image1; - File? image2; - File? image3; - File? image4; + File? imageFront; + File? imageBack; + File? imageLeft; + File? imageRight; - // For storing local paths after saving - String? image1Path; - String? image2Path; - String? image3Path; - String? image4Path; + File? optionalImage1; + File? optionalImage2; + File? optionalImage3; + File? optionalImage4; + String? optionalRemark1; + String? optionalRemark2; + String? optionalRemark3; + String? optionalRemark4; + + String? imageFrontPath; + String? imageBackPath; + String? imageLeftPath; + String? imageRightPath; + String? optionalImage1Path; + String? optionalImage2Path; + String? optionalImage3Path; + String? optionalImage4Path; - // To track submission status (e.g., 'L1', 'S1') String? status; AirInstallationData({ this.refID, + this.airManId, this.samplingDate, - this.clientID, + this.clientId, + this.installationDate, + this.installationTime, this.stateID, this.locationName, this.stationID, @@ -46,23 +62,47 @@ class AirInstallationData { this.pm10FilterId, this.pm25FilterId, this.remark, - this.image1, - this.image2, - this.image3, - this.image4, - this.image1Path, - this.image2Path, - this.image3Path, - this.image4Path, + this.installationUserId, + this.imageFront, + this.imageBack, + this.imageLeft, + this.imageRight, + this.optionalImage1, + this.optionalImage2, + this.optionalImage3, + this.optionalImage4, + this.optionalRemark1, + this.optionalRemark2, + this.optionalRemark3, + this.optionalRemark4, + this.imageFrontPath, + this.imageBackPath, + this.imageLeftPath, + this.imageRightPath, + this.optionalImage1Path, + this.optionalImage2Path, + this.optionalImage3Path, + this.optionalImage4Path, this.status, }); - // Method to convert the data to a JSON-like Map for APIs or local DB - Map toJson() { + Map toMap() { + imageFrontPath = imageFront?.path; + imageBackPath = imageBack?.path; + imageLeftPath = imageLeft?.path; + imageRightPath = imageRight?.path; + optionalImage1Path = optionalImage1?.path; + optionalImage2Path = optionalImage2?.path; + optionalImage3Path = optionalImage3?.path; + optionalImage4Path = optionalImage4?.path; + return { 'refID': refID, + 'air_man_id': airManId, 'samplingDate': samplingDate, - 'clientID': clientID, + 'clientId': clientId, + 'installationDate': installationDate, + 'installationTime': installationTime, 'stateID': stateID, 'locationName': locationName, 'stationID': stationID, @@ -73,11 +113,90 @@ class AirInstallationData { 'pm10FilterId': pm10FilterId, 'pm25FilterId': pm25FilterId, 'remark': remark, - 'image1Path': image1Path, - 'image2Path': image2Path, - 'image3Path': image3Path, - 'image4Path': image4Path, + 'installationUserId': installationUserId, 'status': status, + 'imageFrontPath': imageFrontPath, + 'imageBackPath': imageBackPath, + 'imageLeftPath': imageLeftPath, + 'imageRightPath': imageRightPath, + 'optionalImage1Path': optionalImage1Path, + 'optionalImage2Path': optionalImage2Path, + 'optionalImage3Path': optionalImage3Path, + 'optionalImage4Path': optionalImage4Path, + 'optionalRemark1': optionalRemark1, + 'optionalRemark2': optionalRemark2, + 'optionalRemark3': optionalRemark3, + 'optionalRemark4': optionalRemark4, }; } + + factory AirInstallationData.fromJson(Map json) { + File? fileFromPath(String? path) => (path != null && path.isNotEmpty) ? File(path) : null; + + return AirInstallationData( + refID: json['refID'], + airManId: json['air_man_id'], + samplingDate: json['samplingDate'], + clientId: json['clientId'], + installationDate: json['installationDate'], + installationTime: json['installationTime'], + stateID: json['stateID'], + locationName: json['locationName'], + stationID: json['stationID'], + region: json['region'], + weather: json['weather'], + temp: json['temp'], + powerFailure: json['powerFailure'] ?? false, + pm10FilterId: json['pm10FilterId'], + pm25FilterId: json['pm25FilterId'], + remark: json['remark'], + installationUserId: json['installationUserId'], + status: json['status'], + imageFront: fileFromPath(json['imageFrontPath']), + imageBack: fileFromPath(json['imageBackPath']), + imageLeft: fileFromPath(json['imageLeftPath']), + imageRight: fileFromPath(json['imageRightPath']), + optionalImage1: fileFromPath(json['optionalImage1Path']), + optionalImage2: fileFromPath(json['optionalImage2Path']), + optionalImage3: fileFromPath(json['optionalImage3Path']), + optionalImage4: fileFromPath(json['optionalImage4Path']), + optionalRemark1: json['optionalRemark1'], + optionalRemark2: json['optionalRemark2'], + optionalRemark3: json['optionalRemark3'], + optionalRemark4: json['optionalRemark4'], + ); + } + + Map toJsonForApi() { + return { + 'air_man_station_code': stationID, + 'air_man_sampling_date': samplingDate, + 'air_man_client_id': clientId, + 'air_man_installation_date': installationDate, + 'air_man_installation_time': installationTime, + 'air_man_installation_weather': weather, + 'air_man_installation_temperature': temp, + 'air_man_installation_power_failure': powerFailure ? 'Yes' : 'No', + 'air_man_installation_pm10_filter_id': pm10FilterId, + 'air_man_installation_pm25_filter_id': pm25FilterId, + 'air_man_installation_remarks': remark, + 'air_man_installation_image_optional_01_remarks': optionalRemark1, + 'air_man_installation_image_optional_02_remarks': optionalRemark2, + 'air_man_installation_image_optional_03_remarks': optionalRemark3, + 'air_man_installation_image_optional_04_remarks': optionalRemark4, + }; + } + + Map getImagesForUpload() { + final Map files = {}; + if (imageFront != null) files['front'] = imageFront!; + if (imageBack != null) files['back'] = imageBack!; + if (imageLeft != null) files['left'] = imageLeft!; + if (imageRight != null) files['right'] = imageRight!; + if (optionalImage1 != null) files['optional_01'] = optionalImage1!; + if (optionalImage2 != null) files['optional_02'] = optionalImage2!; + if (optionalImage3 != null) files['optional_03'] = optionalImage3!; + if (optionalImage4 != null) files['optional_04'] = optionalImage4!; + return files; + } } diff --git a/lib/screens/air/manual/air_manual_collection_screen.dart b/lib/screens/air/manual/air_manual_collection_screen.dart index 5640baa..878be06 100644 --- a/lib/screens/air/manual/air_manual_collection_screen.dart +++ b/lib/screens/air/manual/air_manual_collection_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:dropdown_search/dropdown_search.dart'; +import '../../../auth_provider.dart'; import '../../../models/air_installation_data.dart'; import '../../../models/air_collection_data.dart'; import '../../../services/air_sampling_service.dart'; @@ -26,7 +27,6 @@ class _AirManualCollectionScreenState extends State { @override void initState() { super.initState(); - // Fetch the list of pending installations when the screen loads _pendingInstallationsFuture = Provider.of(context, listen: false) .getPendingInstallations(); @@ -41,10 +41,6 @@ class _AirManualCollectionScreenState extends State { } 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); @@ -66,6 +62,8 @@ class _AirManualCollectionScreenState extends State { @override Widget build(BuildContext context) { + final authProvider = Provider.of(context, listen: false); + return Scaffold( appBar: AppBar( title: const Text('Air Sampling Collection'), @@ -112,8 +110,16 @@ class _AirManualCollectionScreenState extends State { onChanged: (AirInstallationData? data) { setState(() { _selectedInstallation = data; - // Reset collection data when selection changes - _collectionData = AirCollectionData(); + // When an installation is selected, prepare the collection data object + if (data != null) { + _collectionData = AirCollectionData( + installationRefID: data.refID, + airManId: data.airManId, // Pass the server ID + collectionUserId: authProvider.profileData?['user_id'], + ); + } else { + _collectionData = AirCollectionData(); + } }); }, selectedItem: _selectedInstallation, @@ -123,7 +129,7 @@ class _AirManualCollectionScreenState extends State { Expanded( child: _selectedInstallation != null ? AirManualCollectionWidget( - key: ValueKey(_selectedInstallation!.refID), // Ensures widget rebuilds + key: ValueKey(_selectedInstallation!.refID), data: _collectionData, initialTemp: double.tryParse(_selectedInstallation!.temp ?? '0.0') ?? 0.0, onSubmit: _submitCollection, diff --git a/lib/screens/air/manual/air_manual_installation_screen.dart b/lib/screens/air/manual/air_manual_installation_screen.dart index 93e2224..e78dc4c 100644 --- a/lib/screens/air/manual/air_manual_installation_screen.dart +++ b/lib/screens/air/manual/air_manual_installation_screen.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import 'dart:math'; import 'package:intl/intl.dart'; +import '../../../auth_provider.dart'; import '../../../models/air_installation_data.dart'; import '../../../services/air_sampling_service.dart'; import 'widgets/air_manual_installation.dart'; // The form widget @@ -25,11 +26,17 @@ class _AirManualInstallationScreenState @override void initState() { super.initState(); + + // Access the provider to get the current user's ID. + // listen: false is used because we only need to read the value once in initState. + final authProvider = Provider.of(context, listen: false); + final userId = authProvider.profileData?['user_id']; + _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', + // Set the user ID from the provider. The client selection is now handled in the widget. + installationUserId: userId, ); } diff --git a/lib/screens/air/manual/widgets/air_manual_collection.dart b/lib/screens/air/manual/widgets/air_manual_collection.dart index 84f1abe..ad2656e 100644 --- a/lib/screens/air/manual/widgets/air_manual_collection.dart +++ b/lib/screens/air/manual/widgets/air_manual_collection.dart @@ -1,7 +1,8 @@ // 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 +import 'package:intl/intl.dart'; +import '../../../../models/air_collection_data.dart'; class AirManualCollectionWidget extends StatefulWidget { final AirCollectionData data; @@ -25,9 +26,10 @@ class AirManualCollectionWidget extends StatefulWidget { class _AirManualCollectionWidgetState extends State { final _formKey = GlobalKey(); - // Controllers for text fields + // General Controllers + final _collectionDateController = TextEditingController(); + final _collectionTimeController = TextEditingController(); final _finalTempController = TextEditingController(); - final _powerFailureController = TextEditingController(); final _remarkController = TextEditingController(); // PM10 Controllers @@ -51,7 +53,57 @@ class _AirManualCollectionWidgetState extends State { @override void initState() { super.initState(); - // Pre-fill controllers if needed + final now = DateTime.now(); + widget.data.collectionDate ??= DateFormat('yyyy-MM-dd').format(now); + widget.data.collectionTime ??= DateFormat('HH:mm').format(now); + + _collectionDateController.text = widget.data.collectionDate!; + _collectionTimeController.text = widget.data.collectionTime!; + } + + @override + void dispose() { + _collectionDateController.dispose(); + _collectionTimeController.dispose(); + _finalTempController.dispose(); + _remarkController.dispose(); + _pm10FlowRateController.dispose(); + _pm10FlowRateResultController.dispose(); + _pm10TimeController.dispose(); + _pm10TimeResultController.dispose(); + _pm10PressureController.dispose(); + _pm10PressureResultController.dispose(); + _pm10VstdController.dispose(); + _pm25FlowRateController.dispose(); + _pm25FlowRateResultController.dispose(); + _pm25TimeController.dispose(); + _pm25TimeResultController.dispose(); + _pm25PressureController.dispose(); + _pm25PressureResultController.dispose(); + _pm25VstdController.dispose(); + super.dispose(); + } + + Future _selectDate(BuildContext context, TextEditingController controller) async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2101), + ); + if (picked != null) { + setState(() => controller.text = DateFormat('yyyy-MM-dd').format(picked)); + } + } + + Future _selectTime(BuildContext context, TextEditingController controller) async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (picked != null) { + setState(() => controller.text = picked.format(context)); + } } void _calculateVstd(int type) { @@ -60,9 +112,7 @@ class _AirManualCollectionWidgetState extends State { 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 vstdCtrl = type == 1 ? _pm10VstdController : _pm25VstdController; final pressureResultCtrl = type == 1 ? _pm10PressureResultController : _pm25PressureResultController; if (pressureCtrl.text.isEmpty || _finalTempController.text.isEmpty || flowResultCtrl.text.isEmpty || timeResultCtrl.text.isEmpty) { @@ -86,10 +136,10 @@ class _AirManualCollectionWidgetState extends State { if (type == 1) { _pm10VstdController.text = v_std.toStringAsFixed(3); - widget.data.pm10Vstd = _pm10VstdController.text; + widget.data.pm10Vstd = v_std; } else { _pm25VstdController.text = v_std.toStringAsFixed(3); - widget.data.pm25Vstd = _pm25VstdController.text; + widget.data.pm25Vstd = v_std; } } catch (e) { @@ -115,16 +165,20 @@ class _AirManualCollectionWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Center( - child: Text('Step 2: Collection Data', + child: Text('Collection Data', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), ), const Divider(height: 30), // Collection specific fields + TextFormField(controller: _collectionDateController, readOnly: true, decoration: const InputDecoration(labelText: 'Collection Date', border: OutlineInputBorder(), suffixIcon: Icon(Icons.calendar_today)), onTap: () => _selectDate(context, _collectionDateController), onSaved: (v) => widget.data.collectionDate = v, validator: (v) => v!.isEmpty ? 'Date is required' : null), + const SizedBox(height: 16), + TextFormField(controller: _collectionTimeController, readOnly: true, decoration: const InputDecoration(labelText: 'Collection Time', border: OutlineInputBorder(), suffixIcon: Icon(Icons.access_time)), onTap: () => _selectTime(context, _collectionTimeController), onSaved: (v) => widget.data.collectionTime = v, validator: (v) => v!.isEmpty ? 'Time is required' : null), + 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(), + items: ['Clear', 'Cloudy', '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, @@ -133,41 +187,42 @@ class _AirManualCollectionWidgetState extends State { TextFormField( controller: _finalTempController, decoration: const InputDecoration(labelText: 'Final Temperature (°C)', border: OutlineInputBorder()), - keyboardType: TextInputType.number, + keyboardType: const TextInputType.numberWithOptions(decimal: true), validator: (v) => v!.isEmpty ? 'Required' : null, - onSaved: (v) => widget.data.finalTemp = v, + onSaved: (v) => widget.data.temperature = double.tryParse(v!), ), const SizedBox(height: 16), - TextFormField( - controller: _powerFailureController, + DropdownButtonFormField( decoration: const InputDecoration(labelText: 'Power Failure Status', border: OutlineInputBorder()), - validator: (v) => v!.isEmpty ? 'Required' : null, - onSaved: (v) => widget.data.powerFailureStatus = v, + value: widget.data.powerFailure, + items: ['Yes', 'No'].map((v) => DropdownMenuItem(value: v, child: Text(v))).toList(), + onChanged: (v) => setState(() => widget.data.powerFailure = v), + validator: (v) => v == null ? 'Required' : null, + onSaved: (v) => widget.data.powerFailure = 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), + _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 = double.tryParse(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 = double.tryParse(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), + _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 = double.tryParse(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 = double.tryParse(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, + maxLines: 3, + onSaved: (v) => widget.data.remarks = v, ), - // TODO: Add 6 Image Picker Widgets Here, relabeled as per the PDF - const SizedBox(height: 30), SizedBox( width: double.infinity, @@ -179,7 +234,7 @@ class _AirManualCollectionWidgetState extends State { ), child: widget.isLoading ? const CircularProgressIndicator(color: Colors.white) - : const Text('Submit All Data', style: TextStyle(color: Colors.white)), + : const Text('Submit Collection Data', style: TextStyle(color: Colors.white)), ), ), ], @@ -188,7 +243,6 @@ class _AirManualCollectionWidgetState extends State { ); } - // 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), diff --git a/lib/screens/air/manual/widgets/air_manual_installation.dart b/lib/screens/air/manual/widgets/air_manual_installation.dart index 494493e..056f250 100644 --- a/lib/screens/air/manual/widgets/air_manual_installation.dart +++ b/lib/screens/air/manual/widgets/air_manual_installation.dart @@ -5,27 +5,13 @@ import 'dart:io'; import 'package:image_picker/image_picker.dart'; import 'package:provider/provider.dart'; import 'package:dropdown_search/dropdown_search.dart'; +import 'package:intl/intl.dart'; +import '../../../../auth_provider.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; @@ -44,166 +30,277 @@ class AirManualInstallationWidget extends StatefulWidget { class _AirManualInstallationWidgetState extends State { final _formKey = GlobalKey(); - // Local state for this step - bool _isLocationVisible = false; - List _states = []; - localState? _selectedState; - List _locations = []; - localLocation? _selectedLocation; + bool _isDataInitialized = false; - // Controllers for text fields - final TextEditingController _regionController = TextEditingController(); + List> _allClients = []; + List> _allStations = []; + + String? _selectedRegion; + String? _selectedState; + Map? _selectedLocation; + Map? _selectedClient; + + List _statesForSelectedRegion = []; + List> _locationsForSelectedState = []; + + final TextEditingController _samplingDateController = TextEditingController(); + final TextEditingController _installationDateController = TextEditingController(); + final TextEditingController _installationTimeController = TextEditingController(); final TextEditingController _tempController = TextEditingController(); final TextEditingController _pm10Controller = TextEditingController(); final TextEditingController _pm25Controller = TextEditingController(); final TextEditingController _remarkController = TextEditingController(); + final TextEditingController _optionalRemark1Controller = TextEditingController(); + final TextEditingController _optionalRemark2Controller = TextEditingController(); + final TextEditingController _optionalRemark3Controller = TextEditingController(); + final TextEditingController _optionalRemark4Controller = TextEditingController(); + + + static const List _regions = ["NORTHERN", "CENTRAL", "SOUTHERN", "EAST COAST", "EAST MALAYSIA"]; + static const List _weatherOptions = ['Clear', 'Cloudy', 'Raining']; + + static const Map> _regionToStatesMap = { + "NORTHERN": ["Perlis", "Kedah", "Penang", "Perak"], + "CENTRAL": ["Selangor", "Kuala Lumpur", "Putrajaya", "Negeri Sembilan"], + "SOUTHERN": ["Malacca", "Johor"], + "EAST COAST": ["Kelantan", "Terengganu", "Pahang"], + "EAST MALAYSIA": ["Sabah", "Sarawak", "Labuan"], + }; + + @override void initState() { super.initState(); - _loadStates(); - // Pre-fill controllers if data already exists - _regionController.text = widget.data.region ?? ''; + _samplingDateController.text = widget.data.samplingDate ?? ''; + _installationDateController.text = widget.data.installationDate ?? ''; + _installationTimeController.text = widget.data.installationTime ?? ''; _tempController.text = widget.data.temp ?? ''; _pm10Controller.text = widget.data.pm10FilterId ?? ''; _pm25Controller.text = widget.data.pm25FilterId ?? ''; _remarkController.text = widget.data.remark ?? ''; + _optionalRemark1Controller.text = widget.data.optionalRemark1 ?? ''; + _optionalRemark2Controller.text = widget.data.optionalRemark2 ?? ''; + _optionalRemark3Controller.text = widget.data.optionalRemark3 ?? ''; + _optionalRemark4Controller.text = widget.data.optionalRemark4 ?? ''; } - 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; - }); - } + void _initializeData(AuthProvider authProvider) { + final currentUser = authProvider.profileData; + final clients = authProvider.airClients; + final stations = authProvider.airManualStations; - 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'), - ]; + if (currentUser != null) { + widget.data.installationUserId = currentUser['user_id']; } + if (clients != null && clients.isNotEmpty) { + _allClients = clients; + final defaultClient = _allClients.firstWhere( + (c) => c['client_name'] == 'Jabatan Alam Sekitar', + orElse: () => _allClients.first, + ); + _selectedClient = defaultClient; + widget.data.clientId = defaultClient['client_id']; + } + + if (stations != null) { + _allStations = stations; + } + } + + void _onRegionChanged(String? region) { + if (region == null) return; + final statesInRegion = _regionToStatesMap[region] ?? []; + + final availableStates = _allStations + .where((s) => statesInRegion.contains(s['state_name'])) + .map((s) => s['state_name'] as String) + .toSet() + .toList(); + availableStates.sort(); + setState(() { - _locations = data; - _isLocationVisible = true; + _selectedRegion = region; + _statesForSelectedRegion = availableStates; + _selectedState = null; + _locationsForSelectedState = []; _selectedLocation = null; + widget.data.region = region; }); } - void _onSubmitPressed() { - if (_formKey.currentState!.validate()) { - _formKey.currentState!.save(); - widget.onSubmit(); // Use the passed onSubmit function + void _onStateChanged(String? stateName) { + if (stateName == null) return; + final filteredLocations = _allStations.where((s) => s['state_name'] == stateName).toList(); + setState(() { + _selectedState = stateName; + _locationsForSelectedState = filteredLocations; + _selectedLocation = null; + widget.data.stateID = stateName; + }); + } + + Future _selectDate(BuildContext context, TextEditingController controller) async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2101), + ); + if (picked != null) { + setState(() { + controller.text = DateFormat('yyyy-MM-dd').format(picked); + }); } } - // --- 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; + Future _selectTime(BuildContext context, TextEditingController controller) async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (picked != null) { + setState(() { + final String hour = picked.hour.toString().padLeft(2, '0'); + final String minute = picked.minute.toString().padLeft(2, '0'); + controller.text = '$hour:$minute'; + }); } + } + + Future _pickImage(ImageSource source, String imageInfo) async { + // --- FIX: Prevent picking image if station is not selected --- + if (widget.data.stationID == null || widget.data.stationID!.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please select a station before attaching images.'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + final service = Provider.of(context, listen: false); + final stationCode = widget.data.stationID!; final File? imageFile = await service.pickAndProcessImage( source, stationCode: stationCode, - imageInfo: imageInfo, + imageInfo: imageInfo.toUpperCase(), ); 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; + switch (imageInfo) { + case 'front': widget.data.imageFront = imageFile; break; + case 'back': widget.data.imageBack = imageFile; break; + case 'left': widget.data.imageLeft = imageFile; break; + case 'right': widget.data.imageRight = imageFile; break; + case 'optional_01': widget.data.optionalImage1 = imageFile; break; + case 'optional_02': widget.data.optionalImage2 = imageFile; break; + case 'optional_03': widget.data.optionalImage3 = imageFile; break; + case 'optional_04': widget.data.optionalImage4 = imageFile; break; } }); } } - 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, + Widget _buildImagePicker(String title, String imageInfo, File? imageFile, {TextEditingController? remarkController}) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 8), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( children: [ - ElevatedButton.icon( - icon: const Icon(Icons.camera_alt), - label: const Text('Camera'), - onPressed: () => _pickImage(ImageSource.camera, imageNumber), + Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Container( + height: 150, + width: double.infinity, + decoration: BoxDecoration(border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(8)), + child: imageFile != null ? Image.file(imageFile, fit: BoxFit.cover, key: UniqueKey()) : const Center(child: Icon(Icons.image, size: 50, color: Colors.grey)), ), - ElevatedButton.icon( - icon: const Icon(Icons.photo_library), - label: const Text('Gallery'), - onPressed: () => _pickImage(ImageSource.gallery, imageNumber), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton.icon(icon: const Icon(Icons.photo_library), label: const Text('Gallery'), onPressed: () => _pickImage(ImageSource.gallery, imageInfo)), + ElevatedButton.icon(icon: const Icon(Icons.camera_alt), label: const Text('Camera'), onPressed: () => _pickImage(ImageSource.camera, imageInfo)), + ], ), + if (remarkController != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: TextFormField( + controller: remarkController, + decoration: InputDecoration(labelText: 'Remarks for $title', border: const OutlineInputBorder()), + ), + ), ], ), - ], + ), ); } + void _onSubmitPressed() { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + widget.data.optionalRemark1 = _optionalRemark1Controller.text; + widget.data.optionalRemark2 = _optionalRemark2Controller.text; + widget.data.optionalRemark3 = _optionalRemark3Controller.text; + widget.data.optionalRemark4 = _optionalRemark4Controller.text; + widget.onSubmit(); + } + } + + @override + void dispose() { + _samplingDateController.dispose(); + _installationDateController.dispose(); + _installationTimeController.dispose(); + _tempController.dispose(); + _pm10Controller.dispose(); + _pm25Controller.dispose(); + _remarkController.dispose(); + _optionalRemark1Controller.dispose(); + _optionalRemark2Controller.dispose(); + _optionalRemark3Controller.dispose(); + _optionalRemark4Controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final authProvider = Provider.of(context); + + final isProviderDataReady = authProvider.airClients != null && + authProvider.airManualStations != null; + + if (isProviderDataReady && !_isDataInitialized) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _initializeData(authProvider); + setState(() { + _isDataInitialized = true; + }); + } + }); + } + + if (!_isDataInitialized) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text("Loading master data..."), + ], + ), + ); + } + return SingleChildScrollView( padding: const EdgeInsets.all(16.0), child: Form( @@ -211,126 +308,102 @@ class _AirManualInstallationWidgetState extends State( - 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), + DropdownSearch>( + items: _allClients, + selectedItem: _selectedClient, + itemAsString: (c) => c['client_name'], + popupProps: const PopupProps.menu(showSearchBox: true, fit: FlexFit.loose), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Client", border: OutlineInputBorder())), + onChanged: (value) => setState(() => _selectedClient = value), + onSaved: (v) => widget.data.clientId = v?['client_id'], + validator: (v) => v == null ? 'Client is required' : null, + ), + const SizedBox(height: 16), + + TextFormField(controller: _samplingDateController, readOnly: true, decoration: const InputDecoration(labelText: 'Sampling Date (Initiated)', border: OutlineInputBorder(), filled: true, fillColor: Colors.black12)), + const SizedBox(height: 16), + + DropdownSearch( + items: _regions, + selectedItem: _selectedRegion, + popupProps: const PopupProps.menu(showSearchBox: true, fit: FlexFit.loose), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Region", border: OutlineInputBorder())), + onChanged: _onRegionChanged, + validator: (v) => v == null ? 'Region is required' : null, + ), + const SizedBox(height: 16), + + DropdownSearch( + items: _statesForSelectedRegion, + selectedItem: _selectedState, + enabled: _selectedRegion != null, + popupProps: const PopupProps.menu(showSearchBox: true, fit: FlexFit.loose), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "State", border: OutlineInputBorder())), + onChanged: _onStateChanged, + validator: (v) => _selectedRegion != null && v == null ? 'State is required' : null, + ), + const SizedBox(height: 16), + + DropdownSearch>( + items: _locationsForSelectedState, + selectedItem: _selectedLocation, + enabled: _selectedState != null, + itemAsString: (s) => "${s['station_code']} - ${s['station_name']}", + popupProps: const PopupProps.menu(showSearchBox: true, fit: FlexFit.loose), + dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Station ID / Location", border: OutlineInputBorder())), + onChanged: (value) => setState(() { + _selectedLocation = value; + // Directly update the data object here to ensure it's available for image naming + widget.data.stationID = value?['station_code']; + widget.data.locationName = value?['station_name']; + }), + onSaved: (v) { + widget.data.stationID = v?['station_code']; + widget.data.locationName = v?['station_name']; + }, + validator: (v) => _selectedState != null && v == null ? 'Location is required' : null, + ), + const SizedBox(height: 16), + + TextFormField(controller: _installationDateController, readOnly: true, decoration: const InputDecoration(labelText: 'Installation Date', border: OutlineInputBorder(), suffixIcon: Icon(Icons.calendar_today)), onTap: () => _selectDate(context, _installationDateController), onSaved: (v) => widget.data.installationDate = v, validator: (v) => v!.isEmpty ? 'Date is required' : null), + const SizedBox(height: 16), + TextFormField(controller: _installationTimeController, readOnly: true, decoration: const InputDecoration(labelText: 'Installation Time', border: OutlineInputBorder(), suffixIcon: Icon(Icons.access_time)), onTap: () => _selectTime(context, _installationTimeController), onSaved: (v) => widget.data.installationTime = v, validator: (v) => v!.isEmpty ? 'Time is required' : null), + const SizedBox(height: 16), + DropdownButtonFormField(decoration: const InputDecoration(labelText: 'Weather', border: OutlineInputBorder()), value: widget.data.weather, items: _weatherOptions.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: const TextInputType.numberWithOptions(decimal: true), 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()), maxLines: 3, onSaved: (v) => widget.data.remark = v), + const SizedBox(height: 16), + + const Text("Required Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + _buildImagePicker('Site Picture (Front)', 'front', widget.data.imageFront), + _buildImagePicker('Site Picture (Back)', 'back', widget.data.imageBack), + _buildImagePicker('Site Picture (Left)', 'left', widget.data.imageLeft), + _buildImagePicker('Site Picture (Right)', 'right', widget.data.imageRight), + + const SizedBox(height: 24), + const Text("Optional Photos", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + _buildImagePicker('Optional Photo 1', 'optional_01', widget.data.optionalImage1, remarkController: _optionalRemark1Controller), + _buildImagePicker('Optional Photo 2', 'optional_02', widget.data.optionalImage2, remarkController: _optionalRemark2Controller), + _buildImagePicker('Optional Photo 3', 'optional_03', widget.data.optionalImage3, remarkController: _optionalRemark3Controller), + _buildImagePicker('Optional Photo 4', 'optional_04', widget.data.optionalImage4, remarkController: _optionalRemark4Controller), + 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)), + style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16), backgroundColor: Theme.of(context).primaryColor), + child: widget.isLoading ? const CircularProgressIndicator(color: Colors.white) : const Text('Submit Installation', style: TextStyle(color: Colors.white)), ), ), ], diff --git a/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart b/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart index f37895d..7d3f662 100644 --- a/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart +++ b/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart @@ -1,3 +1,5 @@ +//lib\screens\marine\manual\widgets\in_situ_step_1_sampling_info.dart + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:dropdown_search/dropdown_search.dart'; diff --git a/lib/services/air_sampling_service.dart b/lib/services/air_sampling_service.dart index 9aff09e..80e2f98 100644 --- a/lib/services/air_sampling_service.dart +++ b/lib/services/air_sampling_service.dart @@ -1,76 +1,28 @@ -// lib/services/air_sampling_service.dart - import 'dart:async'; import 'dart:io'; -import 'package:flutter/material.dart'; +import 'package:flutter/foundation.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.', - }; - } -} - +import 'api_service.dart'; +import 'local_storage_service.dart'; /// A dedicated service to handle all business logic for the Air Manual Sampling feature. class AirSamplingService { - final AirApiService _apiService = AirApiService(); - // final LocalStorageService _localStorageService = LocalStorageService(); + final ApiService _apiService = ApiService(); + 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" + required String imageInfo, }) async { final picker = ImagePicker(); final XFile? photo = await picker.pickImage( @@ -81,13 +33,11 @@ class AirSamplingService { 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 + final textWidth = watermarkTimestamp.length * 12; img.fillRect(originalImage, x1: 5, y1: 5, @@ -97,108 +47,121 @@ class AirSamplingService { 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"; + "${stationCode}_${fileTimestamp}_INSTALL_${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. + /// Orchestrates a two-step submission process for air installation samples. 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); + // --- OFFLINE-FIRST HELPER --- + Future> saveLocally() async { + debugPrint("Saving installation locally..."); + data.status = 'L1'; // Mark as Locally Saved, Pending Submission + await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!); return { 'status': 'L1', - 'message': 'Installation data saved locally.', + 'message': 'No connection or server error. Installation data saved locally.', }; } + + // --- STEP 1: SUBMIT TEXT DATA --- + debugPrint("Step 1: Submitting installation text data..."); + final textDataResult = await _apiService.post('air/manual/installation', data.toJsonForApi()); + + if (textDataResult['success'] != true) { + debugPrint("Failed to submit text data. Reason: ${textDataResult['message']}"); + return await saveLocally(); + } + + final recordId = textDataResult['data']?['air_man_id']; + if (recordId == null) { + debugPrint("Text data submitted, but did not receive a record ID."); + return await saveLocally(); + } + debugPrint("Text data submitted successfully. Received record ID: $recordId"); + + // --- STEP 2: UPLOAD IMAGE FILES --- + final filesToUpload = data.getImagesForUpload(); + if (filesToUpload.isEmpty) { + debugPrint("No images to upload. Submission complete."); + data.status = 'S1'; // Server Pending (no images needed) + await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!); + return {'status': 'S1', 'message': 'Installation data submitted successfully.'}; + } + + debugPrint("Step 2: Uploading ${filesToUpload.length} images for record ID $recordId..."); + final imageUploadResult = await _apiService.air.uploadInstallationImages( + airManId: recordId.toString(), + files: filesToUpload, + ); + + if (imageUploadResult['success'] != true) { + debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}"); + return await saveLocally(); + } + + debugPrint("Images uploaded successfully."); + data.status = 'S2'; // Server Pending (images uploaded) + await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!); + return { + 'status': 'S2', + 'message': 'Installation data and images submitted successfully.', + }; } + /// 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, + final result = await _apiService.post( + 'air/manual/collection', + data.toJson() ); - return result; + + if (result['success'] == true) { + data.status = 'S3'; // Server Completed + // Also save the completed record locally + await _localStorageService.saveAirSamplingRecord(data.toMap(), data.installationRefID!); + return {'status': 'S3', 'message': 'Collection submitted successfully.'}; + } else { + throw Exception(result['message'] ?? 'Unknown server error'); + } } catch (e) { - print("API submission failed: $e. Saving collection locally."); - // --- Fallback to Local Storage --- - // TODO: Implement local DB save for collection data + debugPrint("API submission failed: $e. Saving collection locally."); data.status = 'L3'; // Mark as completed locally - // await _localStorageService.saveAirCollectionData(data); + + // CORRECTED: Use toMap() to ensure all data is saved locally on failure. + await _localStorageService.saveAirSamplingRecord(data.toMap(), data.installationRefID!); + return { 'status': 'L3', - 'message': 'Collection data saved locally.', + 'message': 'No connection. Collection data saved locally.', }; } } - /// Fetches installations that are pending collection. + /// Fetches installations that are pending collection from local storage. 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 + debugPrint("Fetching pending installations from local storage..."); - // 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', - ), - ]; + final logs = await _localStorageService.getAllAirSamplingLogs(); + + final pendingInstallations = logs + .where((log) { + final status = log['status']; + // CORRECTED: Include 'S2' to show records that have successfully uploaded images + // but are still pending collection. + return status == 'L1' || status == 'S1' || status == 'S2'; + }) + .map((log) => AirInstallationData.fromJson(log)) + .toList(); + + return pendingInstallations; } void dispose() { diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 9f13faf..bb6807d 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1,5 +1,3 @@ -// lib/services/api_service.dart - import 'dart:io'; import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -23,12 +21,14 @@ class ApiService { late final MarineApiService marine; late final RiverApiService river; + late final AirApiService air; static const String imageBaseUrl = 'https://dev14.pstw.com.my/'; ApiService() { marine = MarineApiService(_baseService); river = RiverApiService(_baseService); + air = AirApiService(_baseService); } // --- Core API Methods --- @@ -73,8 +73,9 @@ class ApiService { Future> getAllDepartments() => _baseService.get('departments'); Future> getAllCompanies() => _baseService.get('companies'); Future> getAllPositions() => _baseService.get('positions'); + Future> getAllStates() => _baseService.get('states'); + - // --- ADDED: Method to send a queued Telegram alert to your server --- Future> sendTelegramAlert({ required String chatId, required String message, @@ -84,7 +85,6 @@ class ApiService { 'message': message, }); } - // --- END --- Future downloadProfilePicture(String imageUrl, String localPath) async { try { @@ -109,7 +109,6 @@ class ApiService { ); } - /// --- A dedicated method to refresh only the profile --- Future> refreshProfile() async { debugPrint('ApiService: Refreshing profile data from server...'); final result = await getProfile(); @@ -136,6 +135,9 @@ class ApiService { getAllDepartments(), getAllCompanies(), getAllPositions(), + air.getManualStations(), + air.getClients(), + getAllStates(), ]); final Map syncedData = { @@ -149,6 +151,9 @@ class ApiService { 'departments': results[7]['success'] == true ? results[7]['data'] : null, 'companies': results[8]['success'] == true ? results[8]['data'] : null, 'positions': results[9]['success'] == true ? results[9]['data'] : null, + 'airManualStations': results[10]['success'] == true ? results[10]['data'] : null, + 'airClients': results[11]['success'] == true ? results[11]['data'] : null, + 'states': results[12]['success'] == true ? results[12]['data'] : null, }; if (syncedData['profile'] != null) await _dbHelper.saveProfile(syncedData['profile']); @@ -161,6 +166,9 @@ class ApiService { if (syncedData['departments'] != null) await _dbHelper.saveDepartments(List>.from(syncedData['departments'])); if (syncedData['companies'] != null) await _dbHelper.saveCompanies(List>.from(syncedData['companies'])); if (syncedData['positions'] != null) await _dbHelper.savePositions(List>.from(syncedData['positions'])); + if (syncedData['airManualStations'] != null) await _dbHelper.saveAirManualStations(List>.from(syncedData['airManualStations'])); + if (syncedData['airClients'] != null) await _dbHelper.saveAirClients(List>.from(syncedData['airClients'])); + if (syncedData['states'] != null) await _dbHelper.saveStates(List>.from(syncedData['states'])); debugPrint('ApiService: Sync complete. Data saved to local DB.'); return {'success': true, 'data': syncedData}; @@ -176,6 +184,27 @@ class ApiService { // Part 2: Feature-Specific API Services // ======================================================================= +class AirApiService { + final BaseApiService _baseService; + AirApiService(this._baseService); + + Future> getManualStations() => _baseService.get('air/manual-stations'); + Future> getClients() => _baseService.get('air/clients'); + + // NEW: Added dedicated method for uploading installation images + Future> uploadInstallationImages({ + required String airManId, + required Map files, + }) { + return _baseService.postMultipart( + endpoint: 'air/manual/installation-images', + fields: {'air_man_id': airManId}, + files: files, + ); + } +} + + class MarineApiService { final BaseApiService _baseService; MarineApiService(this._baseService); @@ -221,8 +250,7 @@ class RiverApiService { class DatabaseHelper { static Database? _database; static const String _dbName = 'app_data.db'; - // FIXED: Incremented database version to trigger onUpgrade and create the new table - static const int _dbVersion = 10; + static const int _dbVersion = 12; static const String _profileTable = 'user_profile'; static const String _usersTable = 'all_users'; @@ -234,8 +262,11 @@ class DatabaseHelper { static const String _departmentsTable = 'departments'; static const String _companiesTable = 'companies'; static const String _positionsTable = 'positions'; - // --- ADDED: Table name for the alert queue --- static const String _alertQueueTable = 'alert_queue'; + static const String _airManualStationsTable = 'air_manual_stations'; + static const String _airClientsTable = 'air_clients'; + static const String _statesTable = 'states'; + Future get database async { if (_database != null) return _database!; @@ -259,33 +290,19 @@ class DatabaseHelper { await db.execute('CREATE TABLE $_departmentsTable(department_id INTEGER PRIMARY KEY, department_json TEXT)'); await db.execute('CREATE TABLE $_companiesTable(company_id INTEGER PRIMARY KEY, company_json TEXT)'); await db.execute('CREATE TABLE $_positionsTable(position_id INTEGER PRIMARY KEY, position_json TEXT)'); - - // --- ADDED: Create the alert_queue table --- - await db.execute(''' - CREATE TABLE $_alertQueueTable ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - chat_id TEXT NOT NULL, - message TEXT NOT NULL, - created_at TEXT NOT NULL - ) - '''); + await db.execute('''CREATE TABLE $_alertQueueTable (id INTEGER PRIMARY KEY AUTOINCREMENT, chat_id TEXT NOT NULL, message TEXT NOT NULL, created_at TEXT NOT NULL)'''); + await db.execute('CREATE TABLE $_airManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); + await db.execute('CREATE TABLE $_airClientsTable(client_id INTEGER PRIMARY KEY, client_json TEXT)'); + await db.execute('CREATE TABLE $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)'); } Future _onUpgrade(Database db, int oldVersion, int newVersion) async { - if (oldVersion < 10) { - await db.execute('DROP TABLE IF EXISTS $_profileTable'); - await db.execute('DROP TABLE IF EXISTS $_usersTable'); - await db.execute('DROP TABLE IF EXISTS $_tarballStationsTable'); - await db.execute('DROP TABLE IF EXISTS $_manualStationsTable'); - await db.execute('DROP TABLE IF EXISTS $_riverManualStationsTable'); - await db.execute('DROP TABLE IF EXISTS $_riverTriennialStationsTable'); - await db.execute('DROP TABLE IF EXISTS $_tarballClassificationsTable'); - await db.execute('DROP TABLE IF EXISTS $_departmentsTable'); - await db.execute('DROP TABLE IF EXISTS $_companiesTable'); - await db.execute('DROP TABLE IF EXISTS $_positionsTable'); - // --- ADDED: Drop the alert_queue table during upgrade --- - await db.execute('DROP TABLE IF EXISTS $_alertQueueTable'); - await _onCreate(db, newVersion); + if (oldVersion < 11) { + await db.execute('CREATE TABLE IF NOT EXISTS $_airManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); + await db.execute('CREATE TABLE IF NOT EXISTS $_airClientsTable(client_id INTEGER PRIMARY KEY, client_json TEXT)'); + } + if (oldVersion < 12) { + await db.execute('CREATE TABLE IF NOT EXISTS $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)'); } } @@ -293,7 +310,7 @@ class DatabaseHelper { final db = await database; await db.delete(table); for (var item in data) { - await db.insert(table, {'${idKey}_id': item[idKey], '${idKey}_json': jsonEncode(item)}, conflictAlgorithm: ConflictAlgorithm.replace); + await db.insert(table, {'${idKey}_id': item['${idKey}_id'], '${idKey}_json': jsonEncode(item)}, conflictAlgorithm: ConflictAlgorithm.replace); } } @@ -343,4 +360,13 @@ class DatabaseHelper { Future savePositions(List> data) => _saveData(_positionsTable, 'position', data); Future>?> loadPositions() => _loadData(_positionsTable, 'position'); -} \ No newline at end of file + + Future saveAirManualStations(List> stations) => _saveData(_airManualStationsTable, 'station', stations); + Future>?> loadAirManualStations() => _loadData(_airManualStationsTable, 'station'); + + Future saveAirClients(List> clients) => _saveData(_airClientsTable, 'client', clients); + Future>?> loadAirClients() => _loadData(_airClientsTable, 'client'); + + Future saveStates(List> states) => _saveData(_statesTable, 'state', states); + Future>?> loadStates() => _loadData(_statesTable, 'state'); +} diff --git a/lib/services/base_api_service.dart b/lib/services/base_api_service.dart index beb2827..0181c24 100644 --- a/lib/services/base_api_service.dart +++ b/lib/services/base_api_service.dart @@ -72,12 +72,12 @@ class BaseApiService { request.headers.addAll(headers); debugPrint('Headers added to multipart request.'); - // CORRECTED: Send all text fields as a single JSON string under the key 'data'. - // This is a common pattern for APIs that handle mixed file/data uploads and - // helps prevent issues where servers fail to parse individual fields. + // --- CORRECTED --- + // This adds each field directly to the request body, which is the standard + // for multipart/form-data and matches what the PHP backend expects. if (fields.isNotEmpty) { - request.fields['data'] = jsonEncode(fields); - debugPrint('Fields added as a single JSON string under the key "data".'); + request.fields.addAll(fields); + debugPrint('Fields added directly to multipart request: $fields'); } // Add files diff --git a/lib/services/local_storage_service.dart b/lib/services/local_storage_service.dart index 4be885d..b6b183d 100644 --- a/lib/services/local_storage_service.dart +++ b/lib/services/local_storage_service.dart @@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:path/path.dart' as p; +import '../models/air_installation_data.dart'; import '../models/tarball_data.dart'; import '../models/in_situ_sampling_data.dart'; import '../models/river_in_situ_sampling_data.dart'; @@ -40,7 +41,100 @@ class LocalStorageService { } // ======================================================================= - // Part 2: Tarball Specific Methods + // --- UPDATED: Part 2: Air Manual Sampling Methods --- + // ======================================================================= + + Future _getAirManualBaseDir() async { + final mmsv4Dir = await _getPublicMMSV4Directory(); + if (mmsv4Dir == null) return null; + + final airDir = Directory(p.join(mmsv4Dir.path, 'air', 'air_manual_sampling')); + if (!await airDir.exists()) { + await airDir.create(recursive: true); + } + return airDir; + } + + /// Saves or updates an air sampling record to a local JSON file within a folder named by its refID. + /// CORRECTED: This now accepts a generic Map, which is what the service layer provides. + Future saveAirSamplingRecord(Map data, String refID) async { + final baseDir = await _getAirManualBaseDir(); + if (baseDir == null) { + debugPrint("Could not get public storage directory for Air Manual. Check permissions."); + return null; + } + + try { + final eventDir = Directory(p.join(baseDir.path, refID)); + if (!await eventDir.exists()) { + await eventDir.create(recursive: true); + } + + // Helper function to copy a file and return its new path + Future copyImageToLocal(File? imageFile) async { + if (imageFile == null) return null; + try { + final String fileName = p.basename(imageFile.path); + final File newFile = await imageFile.copy(p.join(eventDir.path, fileName)); + return newFile.path; + } catch (e) { + debugPrint("Error copying file ${imageFile.path}: $e"); + return null; + } + } + + // This logic is now more robust; it checks for File objects and copies them if they exist. + final AirInstallationData tempInstallationData = AirInstallationData.fromJson(data); + data['imageFrontPath'] = await copyImageToLocal(tempInstallationData.imageFront); + data['imageBackPath'] = await copyImageToLocal(tempInstallationData.imageBack); + data['imageLeftPath'] = await copyImageToLocal(tempInstallationData.imageLeft); + data['imageRightPath'] = await copyImageToLocal(tempInstallationData.imageRight); + data['optionalImage1Path'] = await copyImageToLocal(tempInstallationData.optionalImage1); + data['optionalImage2Path'] = await copyImageToLocal(tempInstallationData.optionalImage2); + data['optionalImage3Path'] = await copyImageToLocal(tempInstallationData.optionalImage3); + data['optionalImage4Path'] = await copyImageToLocal(tempInstallationData.optionalImage4); + + + final jsonFile = File(p.join(eventDir.path, 'data.json')); + await jsonFile.writeAsString(jsonEncode(data)); + debugPrint("Air sampling log and images saved to: ${eventDir.path}"); + + return eventDir.path; + + } catch (e) { + debugPrint("Error saving air sampling log to local storage: $e"); + return null; + } + } + + Future>> getAllAirSamplingLogs() async { + final baseDir = await _getAirManualBaseDir(); + if (baseDir == null || !await baseDir.exists()) return []; + + try { + final List> logs = []; + final entities = baseDir.listSync(); + + for (var entity in entities) { + if (entity is Directory) { + final jsonFile = File(p.join(entity.path, 'data.json')); + if (await jsonFile.exists()) { + final content = await jsonFile.readAsString(); + final data = jsonDecode(content) as Map; + data['logDirectory'] = entity.path; + logs.add(data); + } + } + } + return logs; + } catch (e) { + debugPrint("Error getting all air sampling logs: $e"); + return []; + } + } + + // ======================================================================= + // Part 3: Tarball Specific Methods // ======================================================================= Future _getTarballBaseDir() async { @@ -145,7 +239,7 @@ class LocalStorageService { // ======================================================================= - // Part 3: Marine In-Situ Specific Methods + // Part 4: Marine In-Situ Specific Methods // ======================================================================= Future _getInSituBaseDir() async { @@ -248,10 +342,9 @@ class LocalStorageService { } // ======================================================================= - // UPDATED: Part 4: River In-Situ Specific Methods + // UPDATED: Part 5: River In-Situ Specific Methods // ======================================================================= - /// Gets the base directory for storing river in-situ sampling data logs, organized by sampling type. Future _getRiverInSituBaseDir(String? samplingType) async { final mmsv4Dir = await _getPublicMMSV4Directory(); if (mmsv4Dir == null) return null; @@ -270,9 +363,7 @@ class LocalStorageService { return inSituDir; } - /// Saves a single river in-situ sampling record to a unique folder in public storage. Future saveRiverInSituSamplingData(RiverInSituSamplingData data) async { - // UPDATED: Pass the samplingType to get the correct subdirectory. final baseDir = await _getRiverInSituBaseDir(data.samplingType); if (baseDir == null) { debugPrint("Could not get public storage directory for River In-Situ. Check permissions."); @@ -315,7 +406,6 @@ class LocalStorageService { } } - /// Retrieves all saved river in-situ submission logs from all subfolders. Future>> getAllRiverInSituLogs() async { final mmsv4Dir = await _getPublicMMSV4Directory(); if (mmsv4Dir == null) return []; @@ -325,12 +415,10 @@ class LocalStorageService { try { final List> logs = []; - // List all subdirectories (e.g., 'Schedule', 'Triennial', 'Others') final typeSubfolders = topLevelDir.listSync(); for (var typeSubfolder in typeSubfolders) { if (typeSubfolder is Directory) { - // List all event directories inside the type subfolder final eventFolders = typeSubfolder.listSync(); for (var eventFolder in eventFolders) { if (eventFolder is Directory) { @@ -352,7 +440,6 @@ class LocalStorageService { } } - /// Updates an existing river in-situ log file with new submission status. Future updateRiverInSituLog(Map updatedLogData) async { final logDir = updatedLogData['logDirectory']; if (logDir == null) { @@ -371,4 +458,4 @@ class LocalStorageService { debugPrint("Error updating river in-situ log: $e"); } } -} \ No newline at end of file +}