add in air manual sampling module for installation process

This commit is contained in:
ALim Aidrus 2025-08-12 21:12:10 +08:00
parent d60e6cd60f
commit e5af5a3f03
12 changed files with 941 additions and 558 deletions

View File

@ -1,5 +1,3 @@
// lib/auth_provider.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
@ -39,6 +37,10 @@ class AuthProvider with ChangeNotifier {
List<Map<String, dynamic>>? _departments; List<Map<String, dynamic>>? _departments;
List<Map<String, dynamic>>? _companies; List<Map<String, dynamic>>? _companies;
List<Map<String, dynamic>>? _positions; List<Map<String, dynamic>>? _positions;
List<Map<String, dynamic>>? _airClients;
List<Map<String, dynamic>>? _airManualStations;
// --- ADDED FOR STATE LIST ---
List<Map<String, dynamic>>? _states;
// --- Getters for UI access --- // --- Getters for UI access ---
List<Map<String, dynamic>>? get allUsers => _allUsers; List<Map<String, dynamic>>? get allUsers => _allUsers;
@ -50,6 +52,10 @@ class AuthProvider with ChangeNotifier {
List<Map<String, dynamic>>? get departments => _departments; List<Map<String, dynamic>>? get departments => _departments;
List<Map<String, dynamic>>? get companies => _companies; List<Map<String, dynamic>>? get companies => _companies;
List<Map<String, dynamic>>? get positions => _positions; List<Map<String, dynamic>>? get positions => _positions;
List<Map<String, dynamic>>? get airClients => _airClients;
List<Map<String, dynamic>>? get airManualStations => _airManualStations;
// --- ADDED FOR STATE LIST ---
List<Map<String, dynamic>>? get states => _states;
// --- SharedPreferences Keys (made public for BaseApiService) --- // --- SharedPreferences Keys (made public for BaseApiService) ---
static const String tokenKey = 'jwt_token'; static const String tokenKey = 'jwt_token';
@ -133,6 +139,13 @@ class AuthProvider with ChangeNotifier {
_departments = data['departments'] != null ? List<Map<String, dynamic>>.from(data['departments']) : null; _departments = data['departments'] != null ? List<Map<String, dynamic>>.from(data['departments']) : null;
_companies = data['companies'] != null ? List<Map<String, dynamic>>.from(data['companies']) : null; _companies = data['companies'] != null ? List<Map<String, dynamic>>.from(data['companies']) : null;
_positions = data['positions'] != null ? List<Map<String, dynamic>>.from(data['positions']) : null; _positions = data['positions'] != null ? List<Map<String, dynamic>>.from(data['positions']) : null;
_airClients = data['airClients'] != null ? List<Map<String, dynamic>>.from(data['airClients']) : null;
_airManualStations = data['airManualStations'] != null ? List<Map<String, dynamic>>.from(data['airManualStations']) : null;
// --- ADDED FOR STATE LIST ---
// Note: `syncAllData` in ApiService must be updated to fetch 'states'
_states = data['states'] != null ? List<Map<String, dynamic>>.from(data['states']) : null;
await setLastSyncTimestamp(DateTime.now()); await setLastSyncTimestamp(DateTime.now());
} }
} }
@ -149,6 +162,13 @@ class AuthProvider with ChangeNotifier {
_departments = await _dbHelper.loadDepartments(); _departments = await _dbHelper.loadDepartments();
_companies = await _dbHelper.loadCompanies(); _companies = await _dbHelper.loadCompanies();
_positions = await _dbHelper.loadPositions(); _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."); debugPrint("AuthProvider: All master data loaded from local DB cache.");
} }
@ -208,6 +228,11 @@ class AuthProvider with ChangeNotifier {
_departments = null; _departments = null;
_companies = null; _companies = null;
_positions = null; _positions = null;
_airClients = null;
_airManualStations = null;
// --- ADDED FOR STATE LIST ---
_states = null;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.clear(); await prefs.clear();
@ -220,4 +245,4 @@ class AuthProvider with ChangeNotifier {
Future<Map<String, dynamic>> resetPassword(String email) { Future<Map<String, dynamic>> resetPassword(String email) {
return _apiService.post('auth/forgot-password', {'email': email}); return _apiService.post('auth/forgot-password', {'email': email});
} }
} }

View File

@ -1,100 +1,121 @@
// lib/models/air_collection_data.dart // lib/models/air_collection_data.dart
import 'dart:io';
class AirCollectionData { class AirCollectionData {
String? installationRefID; // Foreign key to link with AirInstallationData // Link to the original installation
String? initialTemp; // Needed for VSTD calculation String? installationRefID;
int? airManId; // The ID from the server database
// Collection Info
String? collectionDate;
String? collectionTime;
String? weather; String? weather;
String? finalTemp; double? temperature;
String? powerFailureStatus; String? powerFailure;
String? remark;
// PM10 Data // PM10 Readings
String? pm10FlowRate; double? pm10Flowrate;
String? pm10FlowrateResult;
String? pm10TotalTime; String? pm10TotalTime;
String? pm10Pressure; String? pm10TotalTimeResult;
String? pm10Vstd; double? pm10Pressure;
String? pm10PressureResult;
double? pm10Vstd;
// PM2.5 Data // PM2.5 Readings
String? pm25FlowRate; double? pm25Flowrate;
String? pm25FlowrateResult;
String? pm25TotalTime; String? pm25TotalTime;
String? pm25Pressure; String? pm25TotalTimeResult;
String? pm25Vstd; double? pm25Pressure;
String? pm25PressureResult;
double? pm25Vstd;
// Image files (6 as per the flowchart) // General
File? imageSiteLeft; String? remarks;
File? imageSiteRight; int? collectionUserId;
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; String? status;
AirCollectionData({ AirCollectionData({
this.installationRefID, this.installationRefID,
this.initialTemp, this.airManId,
this.collectionDate,
this.collectionTime,
this.weather, this.weather,
this.finalTemp, this.temperature,
this.powerFailureStatus, this.powerFailure,
this.remark, this.pm10Flowrate,
this.pm10FlowRate, this.pm10FlowrateResult,
this.pm10TotalTime, this.pm10TotalTime,
this.pm10TotalTimeResult,
this.pm10Pressure, this.pm10Pressure,
this.pm10PressureResult,
this.pm10Vstd, this.pm10Vstd,
this.pm25FlowRate, this.pm25Flowrate,
this.pm25FlowrateResult,
this.pm25TotalTime, this.pm25TotalTime,
this.pm25TotalTimeResult,
this.pm25Pressure, this.pm25Pressure,
this.pm25PressureResult,
this.pm25Vstd, this.pm25Vstd,
this.imageSiteLeft, this.remarks,
this.imageSiteRight, this.collectionUserId,
this.imageSiteFront,
this.imageSiteBack,
this.imageChart,
this.imageFilterPaper,
this.imageSiteLeftPath,
this.imageSiteRightPath,
this.imageSiteFrontPath,
this.imageSiteBackPath,
this.imageChartPath,
this.imageFilterPaperPath,
this.status, this.status,
}); });
// Method to convert the data to a JSON-like Map /// Creates a map for saving all data to local storage.
Map<String, dynamic> toJson() { Map<String, dynamic> toMap() {
return { return {
'installationRefID': installationRefID, 'installationRefID': installationRefID,
'initialTemp': initialTemp, 'air_man_id': airManId,
'weather': weather, 'air_man_collection_date': collectionDate,
'finalTemp': finalTemp, 'air_man_collection_time': collectionTime,
'powerFailureStatus': powerFailureStatus, 'air_man_collection_weather': weather,
'remark': remark, 'air_man_collection_temperature': temperature,
'pm10FlowRate': pm10FlowRate, 'air_man_collection_power_failure': powerFailure,
'pm10TotalTime': pm10TotalTime, 'air_man_collection_pm10_flowrate': pm10Flowrate,
'pm10Pressure': pm10Pressure, 'air_man_collection_pm10_flowrate_result': pm10FlowrateResult,
'pm10Vstd': pm10Vstd, 'air_man_collection_pm10_total_time': pm10TotalTime,
'pm25FlowRate': pm25FlowRate, 'air_man_collection_total_time_result': pm10TotalTimeResult,
'pm25TotalTime': pm25TotalTime, 'air_man_collection_pm10_pressure': pm10Pressure,
'pm25Pressure': pm25Pressure, 'air_man_collection_pm10_pressure_result': pm10PressureResult,
'pm25Vstd': pm25Vstd, 'air_man_collection_pm10_vstd': pm10Vstd,
'imageSiteLeftPath': imageSiteLeftPath, 'air_man_collection_pm25_flowrate': pm25Flowrate,
'imageSiteRightPath': imageSiteRightPath, 'air_man_collection_pm25_flowrate_result': pm25FlowrateResult,
'imageSiteFrontPath': imageSiteFrontPath, 'air_man_collection_pm25_total_time': pm25TotalTime,
'imageSiteBackPath': imageSiteBackPath, 'air_man_collection_pm25_total_time_result': pm25TotalTimeResult,
'imageChartPath': imageChartPath, 'air_man_collection_pm25_pressure': pm25Pressure,
'imageFilterPaperPath': imageFilterPaperPath, 'air_man_collection_pm25_pressure_result': pm25PressureResult,
'air_man_collection_pm25_vstd': pm25Vstd,
'air_man_collection_remarks': remarks,
'status': status, 'status': status,
}; };
} }
/// Creates a JSON map with keys that match the backend API.
Map<String, dynamic> 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,
};
}
} }

View File

@ -1,11 +1,13 @@
// lib/models/air_installation_data.dart // lib/models/air_installation_data.dart
import 'dart:io'; import 'dart:io';
class AirInstallationData { class AirInstallationData {
String? refID; String? refID;
int? airManId; // The ID from the server database
String? samplingDate; String? samplingDate;
String? clientID; int? clientId;
String? installationDate;
String? installationTime;
String? stateID; String? stateID;
String? locationName; String? locationName;
String? stationID; String? stationID;
@ -16,26 +18,40 @@ class AirInstallationData {
String? pm10FilterId; String? pm10FilterId;
String? pm25FilterId; String? pm25FilterId;
String? remark; String? remark;
int? installationUserId;
// For handling image files during the process File? imageFront;
File? image1; File? imageBack;
File? image2; File? imageLeft;
File? image3; File? imageRight;
File? image4;
// For storing local paths after saving File? optionalImage1;
String? image1Path; File? optionalImage2;
String? image2Path; File? optionalImage3;
String? image3Path; File? optionalImage4;
String? image4Path; 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; String? status;
AirInstallationData({ AirInstallationData({
this.refID, this.refID,
this.airManId,
this.samplingDate, this.samplingDate,
this.clientID, this.clientId,
this.installationDate,
this.installationTime,
this.stateID, this.stateID,
this.locationName, this.locationName,
this.stationID, this.stationID,
@ -46,23 +62,47 @@ class AirInstallationData {
this.pm10FilterId, this.pm10FilterId,
this.pm25FilterId, this.pm25FilterId,
this.remark, this.remark,
this.image1, this.installationUserId,
this.image2, this.imageFront,
this.image3, this.imageBack,
this.image4, this.imageLeft,
this.image1Path, this.imageRight,
this.image2Path, this.optionalImage1,
this.image3Path, this.optionalImage2,
this.image4Path, 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, this.status,
}); });
// Method to convert the data to a JSON-like Map for APIs or local DB Map<String, dynamic> toMap() {
Map<String, dynamic> toJson() { 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 { return {
'refID': refID, 'refID': refID,
'air_man_id': airManId,
'samplingDate': samplingDate, 'samplingDate': samplingDate,
'clientID': clientID, 'clientId': clientId,
'installationDate': installationDate,
'installationTime': installationTime,
'stateID': stateID, 'stateID': stateID,
'locationName': locationName, 'locationName': locationName,
'stationID': stationID, 'stationID': stationID,
@ -73,11 +113,90 @@ class AirInstallationData {
'pm10FilterId': pm10FilterId, 'pm10FilterId': pm10FilterId,
'pm25FilterId': pm25FilterId, 'pm25FilterId': pm25FilterId,
'remark': remark, 'remark': remark,
'image1Path': image1Path, 'installationUserId': installationUserId,
'image2Path': image2Path,
'image3Path': image3Path,
'image4Path': image4Path,
'status': status, '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<String, dynamic> 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<String, dynamic> 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<String, File> getImagesForUpload() {
final Map<String, File> 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;
}
} }

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:dropdown_search/dropdown_search.dart'; import 'package:dropdown_search/dropdown_search.dart';
import '../../../auth_provider.dart';
import '../../../models/air_installation_data.dart'; import '../../../models/air_installation_data.dart';
import '../../../models/air_collection_data.dart'; import '../../../models/air_collection_data.dart';
import '../../../services/air_sampling_service.dart'; import '../../../services/air_sampling_service.dart';
@ -26,7 +27,6 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Fetch the list of pending installations when the screen loads
_pendingInstallationsFuture = _pendingInstallationsFuture =
Provider.of<AirSamplingService>(context, listen: false) Provider.of<AirSamplingService>(context, listen: false)
.getPendingInstallations(); .getPendingInstallations();
@ -41,10 +41,6 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
} }
setState(() => _isLoading = true); 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 service = Provider.of<AirSamplingService>(context, listen: false);
final result = await service.submitCollection(_collectionData); final result = await service.submitCollection(_collectionData);
@ -66,6 +62,8 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Air Sampling Collection'), title: const Text('Air Sampling Collection'),
@ -112,8 +110,16 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
onChanged: (AirInstallationData? data) { onChanged: (AirInstallationData? data) {
setState(() { setState(() {
_selectedInstallation = data; _selectedInstallation = data;
// Reset collection data when selection changes // When an installation is selected, prepare the collection data object
_collectionData = AirCollectionData(); 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, selectedItem: _selectedInstallation,
@ -123,7 +129,7 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
Expanded( Expanded(
child: _selectedInstallation != null child: _selectedInstallation != null
? AirManualCollectionWidget( ? AirManualCollectionWidget(
key: ValueKey(_selectedInstallation!.refID), // Ensures widget rebuilds key: ValueKey(_selectedInstallation!.refID),
data: _collectionData, data: _collectionData,
initialTemp: double.tryParse(_selectedInstallation!.temp ?? '0.0') ?? 0.0, initialTemp: double.tryParse(_selectedInstallation!.temp ?? '0.0') ?? 0.0,
onSubmit: _submitCollection, onSubmit: _submitCollection,

View File

@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import 'dart:math'; import 'dart:math';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../auth_provider.dart';
import '../../../models/air_installation_data.dart'; import '../../../models/air_installation_data.dart';
import '../../../services/air_sampling_service.dart'; import '../../../services/air_sampling_service.dart';
import 'widgets/air_manual_installation.dart'; // The form widget import 'widgets/air_manual_installation.dart'; // The form widget
@ -25,11 +26,17 @@ class _AirManualInstallationScreenState
@override @override
void initState() { void initState() {
super.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<AuthProvider>(context, listen: false);
final userId = authProvider.profileData?['user_id'];
_installationData = AirInstallationData( _installationData = AirInstallationData(
refID: _generateRandomId(10), // Generate a unique ID for this installation refID: _generateRandomId(10), // Generate a unique ID for this installation
samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()), samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()),
// TODO: Get clientID from an auth provider // Set the user ID from the provider. The client selection is now handled in the widget.
clientID: 'FlutterUser', installationUserId: userId,
); );
} }

View File

@ -1,7 +1,8 @@
// lib/screens/air/manual/widgets/air_manual_collection.dart // lib/screens/air/manual/widgets/air_manual_collection.dart
import 'package:flutter/material.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 { class AirManualCollectionWidget extends StatefulWidget {
final AirCollectionData data; final AirCollectionData data;
@ -25,9 +26,10 @@ class AirManualCollectionWidget extends StatefulWidget {
class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> { class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
// Controllers for text fields // General Controllers
final _collectionDateController = TextEditingController();
final _collectionTimeController = TextEditingController();
final _finalTempController = TextEditingController(); final _finalTempController = TextEditingController();
final _powerFailureController = TextEditingController();
final _remarkController = TextEditingController(); final _remarkController = TextEditingController();
// PM10 Controllers // PM10 Controllers
@ -51,7 +53,57 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
@override @override
void initState() { void initState() {
super.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<void> _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<void> _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) { void _calculateVstd(int type) {
@ -60,9 +112,7 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
final pressureCtrl = type == 1 ? _pm10PressureController : _pm25PressureController; final pressureCtrl = type == 1 ? _pm10PressureController : _pm25PressureController;
final flowResultCtrl = type == 1 ? _pm10FlowRateResultController : _pm25FlowRateResultController; final flowResultCtrl = type == 1 ? _pm10FlowRateResultController : _pm25FlowRateResultController;
final timeResultCtrl = type == 1 ? _pm10TimeResultController : _pm25TimeResultController; final timeResultCtrl = type == 1 ? _pm10TimeResultController : _pm25TimeResultController;
// --- BUG FIX START --- final vstdCtrl = type == 1 ? _pm10VstdController : _pm25VstdController;
final vstdCtrl = type == 1 ? _pm10VstdController : _pm25VstdController; // Correctly assign PM2.5 controller
// --- BUG FIX END ---
final pressureResultCtrl = type == 1 ? _pm10PressureResultController : _pm25PressureResultController; final pressureResultCtrl = type == 1 ? _pm10PressureResultController : _pm25PressureResultController;
if (pressureCtrl.text.isEmpty || _finalTempController.text.isEmpty || flowResultCtrl.text.isEmpty || timeResultCtrl.text.isEmpty) { if (pressureCtrl.text.isEmpty || _finalTempController.text.isEmpty || flowResultCtrl.text.isEmpty || timeResultCtrl.text.isEmpty) {
@ -86,10 +136,10 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
if (type == 1) { if (type == 1) {
_pm10VstdController.text = v_std.toStringAsFixed(3); _pm10VstdController.text = v_std.toStringAsFixed(3);
widget.data.pm10Vstd = _pm10VstdController.text; widget.data.pm10Vstd = v_std;
} else { } else {
_pm25VstdController.text = v_std.toStringAsFixed(3); _pm25VstdController.text = v_std.toStringAsFixed(3);
widget.data.pm25Vstd = _pm25VstdController.text; widget.data.pm25Vstd = v_std;
} }
} catch (e) { } catch (e) {
@ -115,16 +165,20 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Center( const Center(
child: Text('Step 2: Collection Data', child: Text('Collection Data',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
), ),
const Divider(height: 30), const Divider(height: 30),
// Collection specific fields // 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<String>( DropdownButtonFormField<String>(
decoration: const InputDecoration(labelText: 'Weather', border: OutlineInputBorder()), decoration: const InputDecoration(labelText: 'Weather', border: OutlineInputBorder()),
value: widget.data.weather, 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), onChanged: (v) => setState(() => widget.data.weather = v),
validator: (v) => v == null ? 'Required' : null, validator: (v) => v == null ? 'Required' : null,
onSaved: (v) => widget.data.weather = v, onSaved: (v) => widget.data.weather = v,
@ -133,41 +187,42 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
TextFormField( TextFormField(
controller: _finalTempController, controller: _finalTempController,
decoration: const InputDecoration(labelText: 'Final Temperature (°C)', border: OutlineInputBorder()), decoration: const InputDecoration(labelText: 'Final Temperature (°C)', border: OutlineInputBorder()),
keyboardType: TextInputType.number, keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => v!.isEmpty ? 'Required' : null, validator: (v) => v!.isEmpty ? 'Required' : null,
onSaved: (v) => widget.data.finalTemp = v, onSaved: (v) => widget.data.temperature = double.tryParse(v!),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( DropdownButtonFormField<String>(
controller: _powerFailureController,
decoration: const InputDecoration(labelText: 'Power Failure Status', border: OutlineInputBorder()), decoration: const InputDecoration(labelText: 'Power Failure Status', border: OutlineInputBorder()),
validator: (v) => v!.isEmpty ? 'Required' : null, value: widget.data.powerFailure,
onSaved: (v) => widget.data.powerFailureStatus = v, 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 // PM10 Section
const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Text("PM10 Data", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16))), 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(_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(_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(_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)), TextFormField(controller: _pm10VstdController, readOnly: true, decoration: const InputDecoration(labelText: 'VSTD PM10 (m³)', border: OutlineInputBorder(), filled: true, fillColor: Colors.black12)),
// PM2.5 Section // PM2.5 Section
const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Text("PM2.5 Data", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16))), 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(_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(_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(_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)), TextFormField(controller: _pm25VstdController, readOnly: true, decoration: const InputDecoration(labelText: 'VSTD PM2.5 (m³)', border: OutlineInputBorder(), filled: true, fillColor: Colors.black12)),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _remarkController, controller: _remarkController,
decoration: const InputDecoration(labelText: 'Remark (Optional)', border: OutlineInputBorder()), 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), const SizedBox(height: 30),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@ -179,7 +234,7 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
), ),
child: widget.isLoading child: widget.isLoading
? const CircularProgressIndicator(color: Colors.white) ? 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<AirManualCollectionWidget> {
); );
} }
// 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) { Widget _buildResultRow(TextEditingController inputCtrl, String inputLabel, TextEditingController resultCtrl, String resultLabel, Function(String) onChanged, Function(String?) onSaved) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@ -5,27 +5,13 @@ import 'dart:io';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:dropdown_search/dropdown_search.dart'; import 'package:dropdown_search/dropdown_search.dart';
import 'package:intl/intl.dart';
import '../../../../auth_provider.dart';
import '../../../../models/air_installation_data.dart'; import '../../../../models/air_installation_data.dart';
import '../../../../services/air_sampling_service.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 { class AirManualInstallationWidget extends StatefulWidget {
final AirInstallationData data; final AirInstallationData data;
// --- UPDATED: Parameters now match the collection widget ---
final Future<void> Function() onSubmit; final Future<void> Function() onSubmit;
final bool isLoading; final bool isLoading;
@ -44,166 +30,277 @@ class AirManualInstallationWidget extends StatefulWidget {
class _AirManualInstallationWidgetState extends State<AirManualInstallationWidget> { class _AirManualInstallationWidgetState extends State<AirManualInstallationWidget> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
// Local state for this step bool _isDataInitialized = false;
bool _isLocationVisible = false;
List<localState> _states = [];
localState? _selectedState;
List<localLocation> _locations = [];
localLocation? _selectedLocation;
// Controllers for text fields List<Map<String, dynamic>> _allClients = [];
final TextEditingController _regionController = TextEditingController(); List<Map<String, dynamic>> _allStations = [];
String? _selectedRegion;
String? _selectedState;
Map<String, dynamic>? _selectedLocation;
Map<String, dynamic>? _selectedClient;
List<String> _statesForSelectedRegion = [];
List<Map<String, dynamic>> _locationsForSelectedState = [];
final TextEditingController _samplingDateController = TextEditingController();
final TextEditingController _installationDateController = TextEditingController();
final TextEditingController _installationTimeController = TextEditingController();
final TextEditingController _tempController = TextEditingController(); final TextEditingController _tempController = TextEditingController();
final TextEditingController _pm10Controller = TextEditingController(); final TextEditingController _pm10Controller = TextEditingController();
final TextEditingController _pm25Controller = TextEditingController(); final TextEditingController _pm25Controller = TextEditingController();
final TextEditingController _remarkController = TextEditingController(); final TextEditingController _remarkController = TextEditingController();
final TextEditingController _optionalRemark1Controller = TextEditingController();
final TextEditingController _optionalRemark2Controller = TextEditingController();
final TextEditingController _optionalRemark3Controller = TextEditingController();
final TextEditingController _optionalRemark4Controller = TextEditingController();
static const List<String> _regions = ["NORTHERN", "CENTRAL", "SOUTHERN", "EAST COAST", "EAST MALAYSIA"];
static const List<String> _weatherOptions = ['Clear', 'Cloudy', 'Raining'];
static const Map<String, List<String>> _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 @override
void initState() { void initState() {
super.initState(); super.initState();
_loadStates(); _samplingDateController.text = widget.data.samplingDate ?? '';
// Pre-fill controllers if data already exists _installationDateController.text = widget.data.installationDate ?? '';
_regionController.text = widget.data.region ?? ''; _installationTimeController.text = widget.data.installationTime ?? '';
_tempController.text = widget.data.temp ?? ''; _tempController.text = widget.data.temp ?? '';
_pm10Controller.text = widget.data.pm10FilterId ?? ''; _pm10Controller.text = widget.data.pm10FilterId ?? '';
_pm25Controller.text = widget.data.pm25FilterId ?? ''; _pm25Controller.text = widget.data.pm25FilterId ?? '';
_remarkController.text = widget.data.remark ?? ''; _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<void> _loadStates() async { void _initializeData(AuthProvider authProvider) {
// This now uses placeholder data. final currentUser = authProvider.profileData;
final data = [ final clients = authProvider.airClients;
localState(stateID: 'SGR', stateName: 'Selangor'), final stations = authProvider.airManualStations;
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 (currentUser != null) {
if (stateID == null) return; widget.data.installationUserId = currentUser['user_id'];
// 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'),
];
} }
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(() { setState(() {
_locations = data; _selectedRegion = region;
_isLocationVisible = true; _statesForSelectedRegion = availableStates;
_selectedState = null;
_locationsForSelectedState = [];
_selectedLocation = null; _selectedLocation = null;
widget.data.region = region;
}); });
} }
void _onSubmitPressed() { void _onStateChanged(String? stateName) {
if (_formKey.currentState!.validate()) { if (stateName == null) return;
_formKey.currentState!.save(); final filteredLocations = _allStations.where((s) => s['state_name'] == stateName).toList();
widget.onSubmit(); // Use the passed onSubmit function setState(() {
_selectedState = stateName;
_locationsForSelectedState = filteredLocations;
_selectedLocation = null;
widget.data.stateID = stateName;
});
}
Future<void> _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<void> _selectTime(BuildContext context, TextEditingController controller) async {
Future<void> _pickImage(ImageSource source, int imageNumber) async { final TimeOfDay? picked = await showTimePicker(
final service = Provider.of<AirSamplingService>(context, listen: false); context: context,
final stationCode = widget.data.stationID ?? 'UNKNOWN'; initialTime: TimeOfDay.now(),
);
String imageInfo; if (picked != null) {
switch (imageNumber) { setState(() {
case 1: imageInfo = 'INSTALLATION_LEFT'; break; final String hour = picked.hour.toString().padLeft(2, '0');
case 2: imageInfo = 'INSTALLATION_RIGHT'; break; final String minute = picked.minute.toString().padLeft(2, '0');
case 3: imageInfo = 'INSTALLATION_FRONT'; break; controller.text = '$hour:$minute';
case 4: imageInfo = 'INSTALLATION_BACK'; break; });
default: return;
} }
}
Future<void> _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<AirSamplingService>(context, listen: false);
final stationCode = widget.data.stationID!;
final File? imageFile = await service.pickAndProcessImage( final File? imageFile = await service.pickAndProcessImage(
source, source,
stationCode: stationCode, stationCode: stationCode,
imageInfo: imageInfo, imageInfo: imageInfo.toUpperCase(),
); );
if (imageFile != null) { if (imageFile != null) {
setState(() { setState(() {
switch (imageNumber) { switch (imageInfo) {
case 1: widget.data.image1 = imageFile; break; case 'front': widget.data.imageFront = imageFile; break;
case 2: widget.data.image2 = imageFile; break; case 'back': widget.data.imageBack = imageFile; break;
case 3: widget.data.image3 = imageFile; break; case 'left': widget.data.imageLeft = imageFile; break;
case 4: widget.data.image4 = 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) { Widget _buildImagePicker(String title, String imageInfo, File? imageFile, {TextEditingController? remarkController}) {
// ... (This helper widget remains the same) return Card(
return Column( margin: const EdgeInsets.symmetric(vertical: 8),
crossAxisAlignment: CrossAxisAlignment.start, child: Padding(
children: [ padding: const EdgeInsets.all(8.0),
const Divider(height: 20), child: Column(
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: [ children: [
ElevatedButton.icon( Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
icon: const Icon(Icons.camera_alt), const SizedBox(height: 8),
label: const Text('Camera'), Container(
onPressed: () => _pickImage(ImageSource.camera, imageNumber), 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( Row(
icon: const Icon(Icons.photo_library), mainAxisAlignment: MainAxisAlignment.spaceEvenly,
label: const Text('Gallery'), children: [
onPressed: () => _pickImage(ImageSource.gallery, imageNumber), 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final authProvider = Provider.of<AuthProvider>(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( return SingleChildScrollView(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Form( child: Form(
@ -211,126 +308,102 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// ... (All form fields remain the same) const Center(child: Text('Installation Details', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))),
const Center(
child: Text('Installation Details',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
),
const Divider(height: 30), 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), DropdownSearch<Map<String, dynamic>>(
_buildImagePicker('Site Picture (Right)', widget.data.image2, 2), items: _allClients,
_buildImagePicker('Site Picture (Front)', widget.data.image3, 3), selectedItem: _selectedClient,
_buildImagePicker('Site Picture (Back)', widget.data.image4, 4), 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<String>(
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<String>(
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<Map<String, dynamic>>(
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<String>(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), const SizedBox(height: 30),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
// --- UPDATED: Use isLoading and call _onSubmitPressed ---
onPressed: widget.isLoading ? null : _onSubmitPressed, onPressed: widget.isLoading ? null : _onSubmitPressed,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16), backgroundColor: Theme.of(context).primaryColor),
padding: const EdgeInsets.symmetric(vertical: 16), child: widget.isLoading ? const CircularProgressIndicator(color: Colors.white) : const Text('Submit Installation', style: TextStyle(color: Colors.white)),
backgroundColor: Colors.blue,
),
child: widget.isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text('Submit Installation', style: TextStyle(color: Colors.white)),
), ),
), ),
], ],

View File

@ -1,3 +1,5 @@
//lib\screens\marine\manual\widgets\in_situ_step_1_sampling_info.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:dropdown_search/dropdown_search.dart'; import 'package:dropdown_search/dropdown_search.dart';

View File

@ -1,76 +1,28 @@
// lib/services/air_sampling_service.dart
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
// Import your actual data models
import '../models/air_installation_data.dart'; import '../models/air_installation_data.dart';
import '../models/air_collection_data.dart'; import '../models/air_collection_data.dart';
import 'api_service.dart';
// Import a local storage service (you would create this) import 'local_storage_service.dart';
// 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. /// A dedicated service to handle all business logic for the Air Manual Sampling feature.
class AirSamplingService { class AirSamplingService {
final AirApiService _apiService = AirApiService(); final ApiService _apiService = ApiService();
// final LocalStorageService _localStorageService = LocalStorageService(); final LocalStorageService _localStorageService = LocalStorageService();
/// Picks an image from the specified source, adds a timestamp watermark, /// Picks an image from the specified source, adds a timestamp watermark,
/// and saves it to a temporary directory with a standardized name. /// and saves it to a temporary directory with a standardized name.
Future<File?> pickAndProcessImage( Future<File?> pickAndProcessImage(
ImageSource source, { ImageSource source, {
required String stationCode, required String stationCode,
required String imageInfo, // e.g., "INSTALLATION_LEFT", "COLLECTION_CHART" required String imageInfo,
}) async { }) async {
final picker = ImagePicker(); final picker = ImagePicker();
final XFile? photo = await picker.pickImage( final XFile? photo = await picker.pickImage(
@ -81,13 +33,11 @@ class AirSamplingService {
img.Image? originalImage = img.decodeImage(bytes); img.Image? originalImage = img.decodeImage(bytes);
if (originalImage == null) return null; if (originalImage == null) return null;
// Prepare watermark text
final String watermarkTimestamp = final String watermarkTimestamp =
DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()); DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
final font = img.arial24; final font = img.arial24;
// Add a white background for better text visibility final textWidth = watermarkTimestamp.length * 12;
final textWidth = watermarkTimestamp.length * 12; // Estimate width
img.fillRect(originalImage, img.fillRect(originalImage,
x1: 5, x1: 5,
y1: 5, y1: 5,
@ -97,108 +47,121 @@ class AirSamplingService {
img.drawString(originalImage, watermarkTimestamp, img.drawString(originalImage, watermarkTimestamp,
font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0)); font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0));
// Create a standardized file name
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final fileTimestamp = watermarkTimestamp.replaceAll(':', '-').replaceAll(' ', '_'); final fileTimestamp = watermarkTimestamp.replaceAll(':', '-').replaceAll(' ', '_');
final newFileName = final newFileName =
"${stationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg"; "${stationCode}_${fileTimestamp}_INSTALL_${imageInfo.replaceAll(' ', '')}.jpg";
final filePath = path.join(tempDir.path, newFileName); final filePath = path.join(tempDir.path, newFileName);
// Save the processed image and return the file
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage)); return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
} }
/// Submits only the installation data. /// Orchestrates a two-step submission process for air installation samples.
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data) async { Future<Map<String, dynamic>> submitInstallation(AirInstallationData data) async {
try { // --- OFFLINE-FIRST HELPER ---
// Prepare image files for upload Future<Map<String, dynamic>> saveLocally() async {
final List<File> images = []; debugPrint("Saving installation locally...");
if (data.image1 != null) images.add(data.image1!); data.status = 'L1'; // Mark as Locally Saved, Pending Submission
if (data.image2 != null) images.add(data.image2!); await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
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 { return {
'status': 'L1', '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. /// Submits only the collection data, linked to a previous installation.
Future<Map<String, dynamic>> submitCollection(AirCollectionData data) async { Future<Map<String, dynamic>> submitCollection(AirCollectionData data) async {
try { try {
// Prepare image files for upload final result = await _apiService.post(
final List<File> images = []; 'air/manual/collection',
if (data.imageSiteLeft != null) images.add(data.imageSiteLeft!); data.toJson()
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;
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) { } catch (e) {
print("API submission failed: $e. Saving collection locally."); debugPrint("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 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 { return {
'status': 'L3', '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<List<AirInstallationData>> getPendingInstallations() async { Future<List<AirInstallationData>> getPendingInstallations() async {
// In a real app, this would query your local database or a server endpoint debugPrint("Fetching pending installations from local storage...");
// 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 final logs = await _localStorageService.getAllAirSamplingLogs();
return [
AirInstallationData( final pendingInstallations = logs
refID: 'ABC1234567', .where((log) {
stationID: 'SGR01', final status = log['status'];
locationName: 'Shah Alam', // CORRECTED: Include 'S2' to show records that have successfully uploaded images
samplingDate: '2025-08-10', // but are still pending collection.
temp: '28.5', return status == 'L1' || status == 'S1' || status == 'S2';
status: 'L1', // Example of a locally saved installation })
), .map((log) => AirInstallationData.fromJson(log))
AirInstallationData( .toList();
refID: 'DEF8901234',
stationID: 'JHR02', return pendingInstallations;
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() { void dispose() {

View File

@ -1,5 +1,3 @@
// lib/services/api_service.dart
import 'dart:io'; import 'dart:io';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -23,12 +21,14 @@ class ApiService {
late final MarineApiService marine; late final MarineApiService marine;
late final RiverApiService river; late final RiverApiService river;
late final AirApiService air;
static const String imageBaseUrl = 'https://dev14.pstw.com.my/'; static const String imageBaseUrl = 'https://dev14.pstw.com.my/';
ApiService() { ApiService() {
marine = MarineApiService(_baseService); marine = MarineApiService(_baseService);
river = RiverApiService(_baseService); river = RiverApiService(_baseService);
air = AirApiService(_baseService);
} }
// --- Core API Methods --- // --- Core API Methods ---
@ -73,8 +73,9 @@ class ApiService {
Future<Map<String, dynamic>> getAllDepartments() => _baseService.get('departments'); Future<Map<String, dynamic>> getAllDepartments() => _baseService.get('departments');
Future<Map<String, dynamic>> getAllCompanies() => _baseService.get('companies'); Future<Map<String, dynamic>> getAllCompanies() => _baseService.get('companies');
Future<Map<String, dynamic>> getAllPositions() => _baseService.get('positions'); Future<Map<String, dynamic>> getAllPositions() => _baseService.get('positions');
Future<Map<String, dynamic>> getAllStates() => _baseService.get('states');
// --- ADDED: Method to send a queued Telegram alert to your server ---
Future<Map<String, dynamic>> sendTelegramAlert({ Future<Map<String, dynamic>> sendTelegramAlert({
required String chatId, required String chatId,
required String message, required String message,
@ -84,7 +85,6 @@ class ApiService {
'message': message, 'message': message,
}); });
} }
// --- END ---
Future<File?> downloadProfilePicture(String imageUrl, String localPath) async { Future<File?> downloadProfilePicture(String imageUrl, String localPath) async {
try { try {
@ -109,7 +109,6 @@ class ApiService {
); );
} }
/// --- A dedicated method to refresh only the profile ---
Future<Map<String, dynamic>> refreshProfile() async { Future<Map<String, dynamic>> refreshProfile() async {
debugPrint('ApiService: Refreshing profile data from server...'); debugPrint('ApiService: Refreshing profile data from server...');
final result = await getProfile(); final result = await getProfile();
@ -136,6 +135,9 @@ class ApiService {
getAllDepartments(), getAllDepartments(),
getAllCompanies(), getAllCompanies(),
getAllPositions(), getAllPositions(),
air.getManualStations(),
air.getClients(),
getAllStates(),
]); ]);
final Map<String, dynamic> syncedData = { final Map<String, dynamic> syncedData = {
@ -149,6 +151,9 @@ class ApiService {
'departments': results[7]['success'] == true ? results[7]['data'] : null, 'departments': results[7]['success'] == true ? results[7]['data'] : null,
'companies': results[8]['success'] == true ? results[8]['data'] : null, 'companies': results[8]['success'] == true ? results[8]['data'] : null,
'positions': results[9]['success'] == true ? results[9]['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']); if (syncedData['profile'] != null) await _dbHelper.saveProfile(syncedData['profile']);
@ -161,6 +166,9 @@ class ApiService {
if (syncedData['departments'] != null) await _dbHelper.saveDepartments(List<Map<String, dynamic>>.from(syncedData['departments'])); if (syncedData['departments'] != null) await _dbHelper.saveDepartments(List<Map<String, dynamic>>.from(syncedData['departments']));
if (syncedData['companies'] != null) await _dbHelper.saveCompanies(List<Map<String, dynamic>>.from(syncedData['companies'])); if (syncedData['companies'] != null) await _dbHelper.saveCompanies(List<Map<String, dynamic>>.from(syncedData['companies']));
if (syncedData['positions'] != null) await _dbHelper.savePositions(List<Map<String, dynamic>>.from(syncedData['positions'])); if (syncedData['positions'] != null) await _dbHelper.savePositions(List<Map<String, dynamic>>.from(syncedData['positions']));
if (syncedData['airManualStations'] != null) await _dbHelper.saveAirManualStations(List<Map<String, dynamic>>.from(syncedData['airManualStations']));
if (syncedData['airClients'] != null) await _dbHelper.saveAirClients(List<Map<String, dynamic>>.from(syncedData['airClients']));
if (syncedData['states'] != null) await _dbHelper.saveStates(List<Map<String, dynamic>>.from(syncedData['states']));
debugPrint('ApiService: Sync complete. Data saved to local DB.'); debugPrint('ApiService: Sync complete. Data saved to local DB.');
return {'success': true, 'data': syncedData}; return {'success': true, 'data': syncedData};
@ -176,6 +184,27 @@ class ApiService {
// Part 2: Feature-Specific API Services // Part 2: Feature-Specific API Services
// ======================================================================= // =======================================================================
class AirApiService {
final BaseApiService _baseService;
AirApiService(this._baseService);
Future<Map<String, dynamic>> getManualStations() => _baseService.get('air/manual-stations');
Future<Map<String, dynamic>> getClients() => _baseService.get('air/clients');
// NEW: Added dedicated method for uploading installation images
Future<Map<String, dynamic>> uploadInstallationImages({
required String airManId,
required Map<String, File> files,
}) {
return _baseService.postMultipart(
endpoint: 'air/manual/installation-images',
fields: {'air_man_id': airManId},
files: files,
);
}
}
class MarineApiService { class MarineApiService {
final BaseApiService _baseService; final BaseApiService _baseService;
MarineApiService(this._baseService); MarineApiService(this._baseService);
@ -221,8 +250,7 @@ class RiverApiService {
class DatabaseHelper { class DatabaseHelper {
static Database? _database; static Database? _database;
static const String _dbName = 'app_data.db'; static const String _dbName = 'app_data.db';
// FIXED: Incremented database version to trigger onUpgrade and create the new table static const int _dbVersion = 12;
static const int _dbVersion = 10;
static const String _profileTable = 'user_profile'; static const String _profileTable = 'user_profile';
static const String _usersTable = 'all_users'; static const String _usersTable = 'all_users';
@ -234,8 +262,11 @@ class DatabaseHelper {
static const String _departmentsTable = 'departments'; static const String _departmentsTable = 'departments';
static const String _companiesTable = 'companies'; static const String _companiesTable = 'companies';
static const String _positionsTable = 'positions'; static const String _positionsTable = 'positions';
// --- ADDED: Table name for the alert queue ---
static const String _alertQueueTable = '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<Database> get database async { Future<Database> get database async {
if (_database != null) return _database!; 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 $_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 $_companiesTable(company_id INTEGER PRIMARY KEY, company_json TEXT)');
await db.execute('CREATE TABLE $_positionsTable(position_id INTEGER PRIMARY KEY, position_json TEXT)'); await db.execute('CREATE TABLE $_positionsTable(position_id INTEGER PRIMARY KEY, position_json TEXT)');
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)''');
// --- ADDED: Create the alert_queue table --- await db.execute('CREATE TABLE $_airManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)');
await db.execute(''' await db.execute('CREATE TABLE $_airClientsTable(client_id INTEGER PRIMARY KEY, client_json TEXT)');
CREATE TABLE $_alertQueueTable ( await db.execute('CREATE TABLE $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)');
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id TEXT NOT NULL,
message TEXT NOT NULL,
created_at TEXT NOT NULL
)
''');
} }
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async { Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 10) { if (oldVersion < 11) {
await db.execute('DROP TABLE IF EXISTS $_profileTable'); await db.execute('CREATE TABLE IF NOT EXISTS $_airManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)');
await db.execute('DROP TABLE IF EXISTS $_usersTable'); await db.execute('CREATE TABLE IF NOT EXISTS $_airClientsTable(client_id INTEGER PRIMARY KEY, client_json TEXT)');
await db.execute('DROP TABLE IF EXISTS $_tarballStationsTable'); }
await db.execute('DROP TABLE IF EXISTS $_manualStationsTable'); if (oldVersion < 12) {
await db.execute('DROP TABLE IF EXISTS $_riverManualStationsTable'); await db.execute('CREATE TABLE IF NOT EXISTS $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)');
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);
} }
} }
@ -293,7 +310,7 @@ class DatabaseHelper {
final db = await database; final db = await database;
await db.delete(table); await db.delete(table);
for (var item in data) { 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<void> savePositions(List<Map<String, dynamic>> data) => _saveData(_positionsTable, 'position', data); Future<void> savePositions(List<Map<String, dynamic>> data) => _saveData(_positionsTable, 'position', data);
Future<List<Map<String, dynamic>>?> loadPositions() => _loadData(_positionsTable, 'position'); Future<List<Map<String, dynamic>>?> loadPositions() => _loadData(_positionsTable, 'position');
}
Future<void> saveAirManualStations(List<Map<String, dynamic>> stations) => _saveData(_airManualStationsTable, 'station', stations);
Future<List<Map<String, dynamic>>?> loadAirManualStations() => _loadData(_airManualStationsTable, 'station');
Future<void> saveAirClients(List<Map<String, dynamic>> clients) => _saveData(_airClientsTable, 'client', clients);
Future<List<Map<String, dynamic>>?> loadAirClients() => _loadData(_airClientsTable, 'client');
Future<void> saveStates(List<Map<String, dynamic>> states) => _saveData(_statesTable, 'state', states);
Future<List<Map<String, dynamic>>?> loadStates() => _loadData(_statesTable, 'state');
}

View File

@ -72,12 +72,12 @@ class BaseApiService {
request.headers.addAll(headers); request.headers.addAll(headers);
debugPrint('Headers added to multipart request.'); debugPrint('Headers added to multipart request.');
// CORRECTED: Send all text fields as a single JSON string under the key 'data'. // --- CORRECTED ---
// This is a common pattern for APIs that handle mixed file/data uploads and // This adds each field directly to the request body, which is the standard
// helps prevent issues where servers fail to parse individual fields. // for multipart/form-data and matches what the PHP backend expects.
if (fields.isNotEmpty) { if (fields.isNotEmpty) {
request.fields['data'] = jsonEncode(fields); request.fields.addAll(fields);
debugPrint('Fields added as a single JSON string under the key "data".'); debugPrint('Fields added directly to multipart request: $fields');
} }
// Add files // Add files

View File

@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../models/air_installation_data.dart';
import '../models/tarball_data.dart'; import '../models/tarball_data.dart';
import '../models/in_situ_sampling_data.dart'; import '../models/in_situ_sampling_data.dart';
import '../models/river_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<Directory?> _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<String?> saveAirSamplingRecord(Map<String, dynamic> 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<String?> 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<List<Map<String, dynamic>>> getAllAirSamplingLogs() async {
final baseDir = await _getAirManualBaseDir();
if (baseDir == null || !await baseDir.exists()) return [];
try {
final List<Map<String, dynamic>> 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<String, dynamic>;
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<Directory?> _getTarballBaseDir() async { Future<Directory?> _getTarballBaseDir() async {
@ -145,7 +239,7 @@ class LocalStorageService {
// ======================================================================= // =======================================================================
// Part 3: Marine In-Situ Specific Methods // Part 4: Marine In-Situ Specific Methods
// ======================================================================= // =======================================================================
Future<Directory?> _getInSituBaseDir() async { Future<Directory?> _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<Directory?> _getRiverInSituBaseDir(String? samplingType) async { Future<Directory?> _getRiverInSituBaseDir(String? samplingType) async {
final mmsv4Dir = await _getPublicMMSV4Directory(); final mmsv4Dir = await _getPublicMMSV4Directory();
if (mmsv4Dir == null) return null; if (mmsv4Dir == null) return null;
@ -270,9 +363,7 @@ class LocalStorageService {
return inSituDir; return inSituDir;
} }
/// Saves a single river in-situ sampling record to a unique folder in public storage.
Future<String?> saveRiverInSituSamplingData(RiverInSituSamplingData data) async { Future<String?> saveRiverInSituSamplingData(RiverInSituSamplingData data) async {
// UPDATED: Pass the samplingType to get the correct subdirectory.
final baseDir = await _getRiverInSituBaseDir(data.samplingType); final baseDir = await _getRiverInSituBaseDir(data.samplingType);
if (baseDir == null) { if (baseDir == null) {
debugPrint("Could not get public storage directory for River In-Situ. Check permissions."); 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<List<Map<String, dynamic>>> getAllRiverInSituLogs() async { Future<List<Map<String, dynamic>>> getAllRiverInSituLogs() async {
final mmsv4Dir = await _getPublicMMSV4Directory(); final mmsv4Dir = await _getPublicMMSV4Directory();
if (mmsv4Dir == null) return []; if (mmsv4Dir == null) return [];
@ -325,12 +415,10 @@ class LocalStorageService {
try { try {
final List<Map<String, dynamic>> logs = []; final List<Map<String, dynamic>> logs = [];
// List all subdirectories (e.g., 'Schedule', 'Triennial', 'Others')
final typeSubfolders = topLevelDir.listSync(); final typeSubfolders = topLevelDir.listSync();
for (var typeSubfolder in typeSubfolders) { for (var typeSubfolder in typeSubfolders) {
if (typeSubfolder is Directory) { if (typeSubfolder is Directory) {
// List all event directories inside the type subfolder
final eventFolders = typeSubfolder.listSync(); final eventFolders = typeSubfolder.listSync();
for (var eventFolder in eventFolders) { for (var eventFolder in eventFolders) {
if (eventFolder is Directory) { if (eventFolder is Directory) {
@ -352,7 +440,6 @@ class LocalStorageService {
} }
} }
/// Updates an existing river in-situ log file with new submission status.
Future<void> updateRiverInSituLog(Map<String, dynamic> updatedLogData) async { Future<void> updateRiverInSituLog(Map<String, dynamic> updatedLogData) async {
final logDir = updatedLogData['logDirectory']; final logDir = updatedLogData['logDirectory'];
if (logDir == null) { if (logDir == null) {
@ -371,4 +458,4 @@ class LocalStorageService {
debugPrint("Error updating river in-situ log: $e"); debugPrint("Error updating river in-situ log: $e");
} }
} }
} }