add in air manual sampling module for installation process
This commit is contained in:
parent
d60e6cd60f
commit
e5af5a3f03
@ -1,5 +1,3 @@
|
||||
// lib/auth_provider.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
@ -39,6 +37,10 @@ class AuthProvider with ChangeNotifier {
|
||||
List<Map<String, dynamic>>? _departments;
|
||||
List<Map<String, dynamic>>? _companies;
|
||||
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 ---
|
||||
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 companies => _companies;
|
||||
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) ---
|
||||
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;
|
||||
_companies = data['companies'] != null ? List<Map<String, dynamic>>.from(data['companies']) : 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());
|
||||
}
|
||||
}
|
||||
@ -149,6 +162,13 @@ class AuthProvider with ChangeNotifier {
|
||||
_departments = await _dbHelper.loadDepartments();
|
||||
_companies = await _dbHelper.loadCompanies();
|
||||
_positions = await _dbHelper.loadPositions();
|
||||
_airClients = await _dbHelper.loadAirClients();
|
||||
_airManualStations = await _dbHelper.loadAirManualStations();
|
||||
|
||||
// --- ADDED FOR STATE LIST ---
|
||||
// Note: `loadStates()` must be added to your DatabaseHelper class
|
||||
_states = await _dbHelper.loadStates();
|
||||
|
||||
debugPrint("AuthProvider: All master data loaded from local DB cache.");
|
||||
}
|
||||
|
||||
@ -208,6 +228,11 @@ class AuthProvider with ChangeNotifier {
|
||||
_departments = null;
|
||||
_companies = null;
|
||||
_positions = null;
|
||||
_airClients = null;
|
||||
_airManualStations = null;
|
||||
|
||||
// --- ADDED FOR STATE LIST ---
|
||||
_states = null;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.clear();
|
||||
@ -220,4 +245,4 @@ class AuthProvider with ChangeNotifier {
|
||||
Future<Map<String, dynamic>> resetPassword(String email) {
|
||||
return _apiService.post('auth/forgot-password', {'email': email});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,100 +1,121 @@
|
||||
// lib/models/air_collection_data.dart
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
class AirCollectionData {
|
||||
String? installationRefID; // Foreign key to link with AirInstallationData
|
||||
String? initialTemp; // Needed for VSTD calculation
|
||||
// Link to the original installation
|
||||
String? installationRefID;
|
||||
int? airManId; // The ID from the server database
|
||||
|
||||
// Collection Info
|
||||
String? collectionDate;
|
||||
String? collectionTime;
|
||||
String? weather;
|
||||
String? finalTemp;
|
||||
String? powerFailureStatus;
|
||||
String? remark;
|
||||
double? temperature;
|
||||
String? powerFailure;
|
||||
|
||||
// PM10 Data
|
||||
String? pm10FlowRate;
|
||||
// PM10 Readings
|
||||
double? pm10Flowrate;
|
||||
String? pm10FlowrateResult;
|
||||
String? pm10TotalTime;
|
||||
String? pm10Pressure;
|
||||
String? pm10Vstd;
|
||||
String? pm10TotalTimeResult;
|
||||
double? pm10Pressure;
|
||||
String? pm10PressureResult;
|
||||
double? pm10Vstd;
|
||||
|
||||
// PM2.5 Data
|
||||
String? pm25FlowRate;
|
||||
// PM2.5 Readings
|
||||
double? pm25Flowrate;
|
||||
String? pm25FlowrateResult;
|
||||
String? pm25TotalTime;
|
||||
String? pm25Pressure;
|
||||
String? pm25Vstd;
|
||||
String? pm25TotalTimeResult;
|
||||
double? pm25Pressure;
|
||||
String? pm25PressureResult;
|
||||
double? pm25Vstd;
|
||||
|
||||
// Image files (6 as per the flowchart)
|
||||
File? imageSiteLeft;
|
||||
File? imageSiteRight;
|
||||
File? imageSiteFront;
|
||||
File? imageSiteBack;
|
||||
File? imageChart;
|
||||
File? imageFilterPaper;
|
||||
|
||||
// Local paths after saving
|
||||
String? imageSiteLeftPath;
|
||||
String? imageSiteRightPath;
|
||||
String? imageSiteFrontPath;
|
||||
String? imageSiteBackPath;
|
||||
String? imageChartPath;
|
||||
String? imageFilterPaperPath;
|
||||
|
||||
// Submission status
|
||||
// General
|
||||
String? remarks;
|
||||
int? collectionUserId;
|
||||
String? status;
|
||||
|
||||
AirCollectionData({
|
||||
this.installationRefID,
|
||||
this.initialTemp,
|
||||
this.airManId,
|
||||
this.collectionDate,
|
||||
this.collectionTime,
|
||||
this.weather,
|
||||
this.finalTemp,
|
||||
this.powerFailureStatus,
|
||||
this.remark,
|
||||
this.pm10FlowRate,
|
||||
this.temperature,
|
||||
this.powerFailure,
|
||||
this.pm10Flowrate,
|
||||
this.pm10FlowrateResult,
|
||||
this.pm10TotalTime,
|
||||
this.pm10TotalTimeResult,
|
||||
this.pm10Pressure,
|
||||
this.pm10PressureResult,
|
||||
this.pm10Vstd,
|
||||
this.pm25FlowRate,
|
||||
this.pm25Flowrate,
|
||||
this.pm25FlowrateResult,
|
||||
this.pm25TotalTime,
|
||||
this.pm25TotalTimeResult,
|
||||
this.pm25Pressure,
|
||||
this.pm25PressureResult,
|
||||
this.pm25Vstd,
|
||||
this.imageSiteLeft,
|
||||
this.imageSiteRight,
|
||||
this.imageSiteFront,
|
||||
this.imageSiteBack,
|
||||
this.imageChart,
|
||||
this.imageFilterPaper,
|
||||
this.imageSiteLeftPath,
|
||||
this.imageSiteRightPath,
|
||||
this.imageSiteFrontPath,
|
||||
this.imageSiteBackPath,
|
||||
this.imageChartPath,
|
||||
this.imageFilterPaperPath,
|
||||
this.remarks,
|
||||
this.collectionUserId,
|
||||
this.status,
|
||||
});
|
||||
|
||||
// Method to convert the data to a JSON-like Map
|
||||
Map<String, dynamic> toJson() {
|
||||
/// Creates a map for saving all data to local storage.
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'installationRefID': installationRefID,
|
||||
'initialTemp': initialTemp,
|
||||
'weather': weather,
|
||||
'finalTemp': finalTemp,
|
||||
'powerFailureStatus': powerFailureStatus,
|
||||
'remark': remark,
|
||||
'pm10FlowRate': pm10FlowRate,
|
||||
'pm10TotalTime': pm10TotalTime,
|
||||
'pm10Pressure': pm10Pressure,
|
||||
'pm10Vstd': pm10Vstd,
|
||||
'pm25FlowRate': pm25FlowRate,
|
||||
'pm25TotalTime': pm25TotalTime,
|
||||
'pm25Pressure': pm25Pressure,
|
||||
'pm25Vstd': pm25Vstd,
|
||||
'imageSiteLeftPath': imageSiteLeftPath,
|
||||
'imageSiteRightPath': imageSiteRightPath,
|
||||
'imageSiteFrontPath': imageSiteFrontPath,
|
||||
'imageSiteBackPath': imageSiteBackPath,
|
||||
'imageChartPath': imageChartPath,
|
||||
'imageFilterPaperPath': imageFilterPaperPath,
|
||||
'air_man_id': airManId,
|
||||
'air_man_collection_date': collectionDate,
|
||||
'air_man_collection_time': collectionTime,
|
||||
'air_man_collection_weather': weather,
|
||||
'air_man_collection_temperature': temperature,
|
||||
'air_man_collection_power_failure': powerFailure,
|
||||
'air_man_collection_pm10_flowrate': pm10Flowrate,
|
||||
'air_man_collection_pm10_flowrate_result': pm10FlowrateResult,
|
||||
'air_man_collection_pm10_total_time': pm10TotalTime,
|
||||
'air_man_collection_total_time_result': pm10TotalTimeResult,
|
||||
'air_man_collection_pm10_pressure': pm10Pressure,
|
||||
'air_man_collection_pm10_pressure_result': pm10PressureResult,
|
||||
'air_man_collection_pm10_vstd': pm10Vstd,
|
||||
'air_man_collection_pm25_flowrate': pm25Flowrate,
|
||||
'air_man_collection_pm25_flowrate_result': pm25FlowrateResult,
|
||||
'air_man_collection_pm25_total_time': pm25TotalTime,
|
||||
'air_man_collection_pm25_total_time_result': pm25TotalTimeResult,
|
||||
'air_man_collection_pm25_pressure': pm25Pressure,
|
||||
'air_man_collection_pm25_pressure_result': pm25PressureResult,
|
||||
'air_man_collection_pm25_vstd': pm25Vstd,
|
||||
'air_man_collection_remarks': remarks,
|
||||
'status': status,
|
||||
};
|
||||
}
|
||||
|
||||
/// Creates a JSON map with keys that match the backend API.
|
||||
Map<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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
// lib/models/air_installation_data.dart
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
class AirInstallationData {
|
||||
String? refID;
|
||||
int? airManId; // The ID from the server database
|
||||
String? samplingDate;
|
||||
String? clientID;
|
||||
int? clientId;
|
||||
String? installationDate;
|
||||
String? installationTime;
|
||||
String? stateID;
|
||||
String? locationName;
|
||||
String? stationID;
|
||||
@ -16,26 +18,40 @@ class AirInstallationData {
|
||||
String? pm10FilterId;
|
||||
String? pm25FilterId;
|
||||
String? remark;
|
||||
int? installationUserId;
|
||||
|
||||
// For handling image files during the process
|
||||
File? image1;
|
||||
File? image2;
|
||||
File? image3;
|
||||
File? image4;
|
||||
File? imageFront;
|
||||
File? imageBack;
|
||||
File? imageLeft;
|
||||
File? imageRight;
|
||||
|
||||
// For storing local paths after saving
|
||||
String? image1Path;
|
||||
String? image2Path;
|
||||
String? image3Path;
|
||||
String? image4Path;
|
||||
File? optionalImage1;
|
||||
File? optionalImage2;
|
||||
File? optionalImage3;
|
||||
File? optionalImage4;
|
||||
String? optionalRemark1;
|
||||
String? optionalRemark2;
|
||||
String? optionalRemark3;
|
||||
String? optionalRemark4;
|
||||
|
||||
String? imageFrontPath;
|
||||
String? imageBackPath;
|
||||
String? imageLeftPath;
|
||||
String? imageRightPath;
|
||||
String? optionalImage1Path;
|
||||
String? optionalImage2Path;
|
||||
String? optionalImage3Path;
|
||||
String? optionalImage4Path;
|
||||
|
||||
// To track submission status (e.g., 'L1', 'S1')
|
||||
String? status;
|
||||
|
||||
AirInstallationData({
|
||||
this.refID,
|
||||
this.airManId,
|
||||
this.samplingDate,
|
||||
this.clientID,
|
||||
this.clientId,
|
||||
this.installationDate,
|
||||
this.installationTime,
|
||||
this.stateID,
|
||||
this.locationName,
|
||||
this.stationID,
|
||||
@ -46,23 +62,47 @@ class AirInstallationData {
|
||||
this.pm10FilterId,
|
||||
this.pm25FilterId,
|
||||
this.remark,
|
||||
this.image1,
|
||||
this.image2,
|
||||
this.image3,
|
||||
this.image4,
|
||||
this.image1Path,
|
||||
this.image2Path,
|
||||
this.image3Path,
|
||||
this.image4Path,
|
||||
this.installationUserId,
|
||||
this.imageFront,
|
||||
this.imageBack,
|
||||
this.imageLeft,
|
||||
this.imageRight,
|
||||
this.optionalImage1,
|
||||
this.optionalImage2,
|
||||
this.optionalImage3,
|
||||
this.optionalImage4,
|
||||
this.optionalRemark1,
|
||||
this.optionalRemark2,
|
||||
this.optionalRemark3,
|
||||
this.optionalRemark4,
|
||||
this.imageFrontPath,
|
||||
this.imageBackPath,
|
||||
this.imageLeftPath,
|
||||
this.imageRightPath,
|
||||
this.optionalImage1Path,
|
||||
this.optionalImage2Path,
|
||||
this.optionalImage3Path,
|
||||
this.optionalImage4Path,
|
||||
this.status,
|
||||
});
|
||||
|
||||
// Method to convert the data to a JSON-like Map for APIs or local DB
|
||||
Map<String, dynamic> toJson() {
|
||||
Map<String, dynamic> toMap() {
|
||||
imageFrontPath = imageFront?.path;
|
||||
imageBackPath = imageBack?.path;
|
||||
imageLeftPath = imageLeft?.path;
|
||||
imageRightPath = imageRight?.path;
|
||||
optionalImage1Path = optionalImage1?.path;
|
||||
optionalImage2Path = optionalImage2?.path;
|
||||
optionalImage3Path = optionalImage3?.path;
|
||||
optionalImage4Path = optionalImage4?.path;
|
||||
|
||||
return {
|
||||
'refID': refID,
|
||||
'air_man_id': airManId,
|
||||
'samplingDate': samplingDate,
|
||||
'clientID': clientID,
|
||||
'clientId': clientId,
|
||||
'installationDate': installationDate,
|
||||
'installationTime': installationTime,
|
||||
'stateID': stateID,
|
||||
'locationName': locationName,
|
||||
'stationID': stationID,
|
||||
@ -73,11 +113,90 @@ class AirInstallationData {
|
||||
'pm10FilterId': pm10FilterId,
|
||||
'pm25FilterId': pm25FilterId,
|
||||
'remark': remark,
|
||||
'image1Path': image1Path,
|
||||
'image2Path': image2Path,
|
||||
'image3Path': image3Path,
|
||||
'image4Path': image4Path,
|
||||
'installationUserId': installationUserId,
|
||||
'status': status,
|
||||
'imageFrontPath': imageFrontPath,
|
||||
'imageBackPath': imageBackPath,
|
||||
'imageLeftPath': imageLeftPath,
|
||||
'imageRightPath': imageRightPath,
|
||||
'optionalImage1Path': optionalImage1Path,
|
||||
'optionalImage2Path': optionalImage2Path,
|
||||
'optionalImage3Path': optionalImage3Path,
|
||||
'optionalImage4Path': optionalImage4Path,
|
||||
'optionalRemark1': optionalRemark1,
|
||||
'optionalRemark2': optionalRemark2,
|
||||
'optionalRemark3': optionalRemark3,
|
||||
'optionalRemark4': optionalRemark4,
|
||||
};
|
||||
}
|
||||
|
||||
factory AirInstallationData.fromJson(Map<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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:dropdown_search/dropdown_search.dart';
|
||||
|
||||
import '../../../auth_provider.dart';
|
||||
import '../../../models/air_installation_data.dart';
|
||||
import '../../../models/air_collection_data.dart';
|
||||
import '../../../services/air_sampling_service.dart';
|
||||
@ -26,7 +27,6 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Fetch the list of pending installations when the screen loads
|
||||
_pendingInstallationsFuture =
|
||||
Provider.of<AirSamplingService>(context, listen: false)
|
||||
.getPendingInstallations();
|
||||
@ -41,10 +41,6 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
|
||||
}
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
// Link the collection data to the selected installation
|
||||
_collectionData.installationRefID = _selectedInstallation!.refID;
|
||||
_collectionData.initialTemp = _selectedInstallation!.temp;
|
||||
|
||||
final service = Provider.of<AirSamplingService>(context, listen: false);
|
||||
final result = await service.submitCollection(_collectionData);
|
||||
|
||||
@ -66,6 +62,8 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Air Sampling Collection'),
|
||||
@ -112,8 +110,16 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
|
||||
onChanged: (AirInstallationData? data) {
|
||||
setState(() {
|
||||
_selectedInstallation = data;
|
||||
// Reset collection data when selection changes
|
||||
_collectionData = AirCollectionData();
|
||||
// When an installation is selected, prepare the collection data object
|
||||
if (data != null) {
|
||||
_collectionData = AirCollectionData(
|
||||
installationRefID: data.refID,
|
||||
airManId: data.airManId, // Pass the server ID
|
||||
collectionUserId: authProvider.profileData?['user_id'],
|
||||
);
|
||||
} else {
|
||||
_collectionData = AirCollectionData();
|
||||
}
|
||||
});
|
||||
},
|
||||
selectedItem: _selectedInstallation,
|
||||
@ -123,7 +129,7 @@ class _AirManualCollectionScreenState extends State<AirManualCollectionScreen> {
|
||||
Expanded(
|
||||
child: _selectedInstallation != null
|
||||
? AirManualCollectionWidget(
|
||||
key: ValueKey(_selectedInstallation!.refID), // Ensures widget rebuilds
|
||||
key: ValueKey(_selectedInstallation!.refID),
|
||||
data: _collectionData,
|
||||
initialTemp: double.tryParse(_selectedInstallation!.temp ?? '0.0') ?? 0.0,
|
||||
onSubmit: _submitCollection,
|
||||
|
||||
@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
||||
import 'dart:math';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../auth_provider.dart';
|
||||
import '../../../models/air_installation_data.dart';
|
||||
import '../../../services/air_sampling_service.dart';
|
||||
import 'widgets/air_manual_installation.dart'; // The form widget
|
||||
@ -25,11 +26,17 @@ class _AirManualInstallationScreenState
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Access the provider to get the current user's ID.
|
||||
// listen: false is used because we only need to read the value once in initState.
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final userId = authProvider.profileData?['user_id'];
|
||||
|
||||
_installationData = AirInstallationData(
|
||||
refID: _generateRandomId(10), // Generate a unique ID for this installation
|
||||
samplingDate: DateFormat('yyyy-MM-dd').format(DateTime.now()),
|
||||
// TODO: Get clientID from an auth provider
|
||||
clientID: 'FlutterUser',
|
||||
// Set the user ID from the provider. The client selection is now handled in the widget.
|
||||
installationUserId: userId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
// lib/screens/air/manual/widgets/air_manual_collection.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../models/air_collection_data.dart'; // Import the actual data model
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../../models/air_collection_data.dart';
|
||||
|
||||
class AirManualCollectionWidget extends StatefulWidget {
|
||||
final AirCollectionData data;
|
||||
@ -25,9 +26,10 @@ class AirManualCollectionWidget extends StatefulWidget {
|
||||
class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Controllers for text fields
|
||||
// General Controllers
|
||||
final _collectionDateController = TextEditingController();
|
||||
final _collectionTimeController = TextEditingController();
|
||||
final _finalTempController = TextEditingController();
|
||||
final _powerFailureController = TextEditingController();
|
||||
final _remarkController = TextEditingController();
|
||||
|
||||
// PM10 Controllers
|
||||
@ -51,7 +53,57 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Pre-fill controllers if needed
|
||||
final now = DateTime.now();
|
||||
widget.data.collectionDate ??= DateFormat('yyyy-MM-dd').format(now);
|
||||
widget.data.collectionTime ??= DateFormat('HH:mm').format(now);
|
||||
|
||||
_collectionDateController.text = widget.data.collectionDate!;
|
||||
_collectionTimeController.text = widget.data.collectionTime!;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_collectionDateController.dispose();
|
||||
_collectionTimeController.dispose();
|
||||
_finalTempController.dispose();
|
||||
_remarkController.dispose();
|
||||
_pm10FlowRateController.dispose();
|
||||
_pm10FlowRateResultController.dispose();
|
||||
_pm10TimeController.dispose();
|
||||
_pm10TimeResultController.dispose();
|
||||
_pm10PressureController.dispose();
|
||||
_pm10PressureResultController.dispose();
|
||||
_pm10VstdController.dispose();
|
||||
_pm25FlowRateController.dispose();
|
||||
_pm25FlowRateResultController.dispose();
|
||||
_pm25TimeController.dispose();
|
||||
_pm25TimeResultController.dispose();
|
||||
_pm25PressureController.dispose();
|
||||
_pm25PressureResultController.dispose();
|
||||
_pm25VstdController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<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) {
|
||||
@ -60,9 +112,7 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
||||
final pressureCtrl = type == 1 ? _pm10PressureController : _pm25PressureController;
|
||||
final flowResultCtrl = type == 1 ? _pm10FlowRateResultController : _pm25FlowRateResultController;
|
||||
final timeResultCtrl = type == 1 ? _pm10TimeResultController : _pm25TimeResultController;
|
||||
// --- BUG FIX START ---
|
||||
final vstdCtrl = type == 1 ? _pm10VstdController : _pm25VstdController; // Correctly assign PM2.5 controller
|
||||
// --- BUG FIX END ---
|
||||
final vstdCtrl = type == 1 ? _pm10VstdController : _pm25VstdController;
|
||||
final pressureResultCtrl = type == 1 ? _pm10PressureResultController : _pm25PressureResultController;
|
||||
|
||||
if (pressureCtrl.text.isEmpty || _finalTempController.text.isEmpty || flowResultCtrl.text.isEmpty || timeResultCtrl.text.isEmpty) {
|
||||
@ -86,10 +136,10 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
||||
|
||||
if (type == 1) {
|
||||
_pm10VstdController.text = v_std.toStringAsFixed(3);
|
||||
widget.data.pm10Vstd = _pm10VstdController.text;
|
||||
widget.data.pm10Vstd = v_std;
|
||||
} else {
|
||||
_pm25VstdController.text = v_std.toStringAsFixed(3);
|
||||
widget.data.pm25Vstd = _pm25VstdController.text;
|
||||
widget.data.pm25Vstd = v_std;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
@ -115,16 +165,20 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Center(
|
||||
child: Text('Step 2: Collection Data',
|
||||
child: Text('Collection Data',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const Divider(height: 30),
|
||||
|
||||
// Collection specific fields
|
||||
TextFormField(controller: _collectionDateController, readOnly: true, decoration: const InputDecoration(labelText: 'Collection Date', border: OutlineInputBorder(), suffixIcon: Icon(Icons.calendar_today)), onTap: () => _selectDate(context, _collectionDateController), onSaved: (v) => widget.data.collectionDate = v, validator: (v) => v!.isEmpty ? 'Date is required' : null),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(controller: _collectionTimeController, readOnly: true, decoration: const InputDecoration(labelText: 'Collection Time', border: OutlineInputBorder(), suffixIcon: Icon(Icons.access_time)), onTap: () => _selectTime(context, _collectionTimeController), onSaved: (v) => widget.data.collectionTime = v, validator: (v) => v!.isEmpty ? 'Time is required' : null),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(labelText: 'Weather', border: OutlineInputBorder()),
|
||||
value: widget.data.weather,
|
||||
items: ['Clear', 'Raining'].map((v) => DropdownMenuItem(value: v, child: Text(v))).toList(),
|
||||
items: ['Clear', 'Cloudy', 'Raining'].map((v) => DropdownMenuItem(value: v, child: Text(v))).toList(),
|
||||
onChanged: (v) => setState(() => widget.data.weather = v),
|
||||
validator: (v) => v == null ? 'Required' : null,
|
||||
onSaved: (v) => widget.data.weather = v,
|
||||
@ -133,41 +187,42 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
||||
TextFormField(
|
||||
controller: _finalTempController,
|
||||
decoration: const InputDecoration(labelText: 'Final Temperature (°C)', border: OutlineInputBorder()),
|
||||
keyboardType: TextInputType.number,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) => v!.isEmpty ? 'Required' : null,
|
||||
onSaved: (v) => widget.data.finalTemp = v,
|
||||
onSaved: (v) => widget.data.temperature = double.tryParse(v!),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _powerFailureController,
|
||||
DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(labelText: 'Power Failure Status', border: OutlineInputBorder()),
|
||||
validator: (v) => v!.isEmpty ? 'Required' : null,
|
||||
onSaved: (v) => widget.data.powerFailureStatus = v,
|
||||
value: widget.data.powerFailure,
|
||||
items: ['Yes', 'No'].map((v) => DropdownMenuItem(value: v, child: Text(v))).toList(),
|
||||
onChanged: (v) => setState(() => widget.data.powerFailure = v),
|
||||
validator: (v) => v == null ? 'Required' : null,
|
||||
onSaved: (v) => widget.data.powerFailure = v,
|
||||
),
|
||||
|
||||
// PM10 Section
|
||||
const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Text("PM10 Data", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16))),
|
||||
_buildResultRow(_pm10FlowRateController, "Flow Rate (cfm)", _pm10FlowRateResultController, "Result (m³/min)", (v) => setState(() => _pm10FlowRateResultController.text = (double.tryParse(v) ?? 0 * 0.0283).toStringAsFixed(4)), (v) => widget.data.pm10FlowRate = v),
|
||||
_buildResultRow(_pm10TimeController, "Total Time (hours)", _pm10TimeResultController, "Result (mins)", (v) => setState(() => _pm10TimeResultController.text = (double.tryParse(v) ?? 0 * 60).toStringAsFixed(2)), (v) => widget.data.pm10TotalTime = v),
|
||||
_buildResultRow(_pm10PressureController, "Pressure (hPa)", _pm10PressureResultController, "Result (inHg)", (v) => _calculateVstd(1), (v) => widget.data.pm10Pressure = v),
|
||||
_buildResultRow(_pm10FlowRateController, "Flow Rate (cfm)", _pm10FlowRateResultController, "Result (m³/min)", (v) => setState(() => _pm10FlowRateResultController.text = ((double.tryParse(v) ?? 0) * 0.0283).toStringAsFixed(4)), (v) => widget.data.pm10Flowrate = double.tryParse(v!)),
|
||||
_buildResultRow(_pm10TimeController, "Total Time (hours)", _pm10TimeResultController, "Result (mins)", (v) => setState(() => _pm10TimeResultController.text = ((double.tryParse(v) ?? 0) * 60).toStringAsFixed(2)), (v) => widget.data.pm10TotalTime = v),
|
||||
_buildResultRow(_pm10PressureController, "Pressure (hPa)", _pm10PressureResultController, "Result (inHg)", (v) => _calculateVstd(1), (v) => widget.data.pm10Pressure = double.tryParse(v!)),
|
||||
TextFormField(controller: _pm10VstdController, readOnly: true, decoration: const InputDecoration(labelText: 'VSTD PM10 (m³)', border: OutlineInputBorder(), filled: true, fillColor: Colors.black12)),
|
||||
|
||||
// PM2.5 Section
|
||||
const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Text("PM2.5 Data", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16))),
|
||||
_buildResultRow(_pm25FlowRateController, "Flow Rate (cfm)", _pm25FlowRateResultController, "Result (m³/min)", (v) => setState(() => _pm25FlowRateResultController.text = (double.tryParse(v) ?? 0 * 0.0283).toStringAsFixed(4)), (v) => widget.data.pm25FlowRate = v),
|
||||
_buildResultRow(_pm25TimeController, "Total Time (hours)", _pm25TimeResultController, "Result (mins)", (v) => setState(() => _pm25TimeResultController.text = (double.tryParse(v) ?? 0 * 60).toStringAsFixed(2)), (v) => widget.data.pm25TotalTime = v),
|
||||
_buildResultRow(_pm25PressureController, "Pressure (hPa)", _pm25PressureResultController, "Result (inHg)", (v) => _calculateVstd(2), (v) => widget.data.pm25Pressure = v),
|
||||
_buildResultRow(_pm25FlowRateController, "Flow Rate (cfm)", _pm25FlowRateResultController, "Result (m³/min)", (v) => setState(() => _pm25FlowRateResultController.text = ((double.tryParse(v) ?? 0) * 0.0283).toStringAsFixed(4)), (v) => widget.data.pm25Flowrate = double.tryParse(v!)),
|
||||
_buildResultRow(_pm25TimeController, "Total Time (hours)", _pm25TimeResultController, "Result (mins)", (v) => setState(() => _pm25TimeResultController.text = ((double.tryParse(v) ?? 0) * 60).toStringAsFixed(2)), (v) => widget.data.pm25TotalTime = v),
|
||||
_buildResultRow(_pm25PressureController, "Pressure (hPa)", _pm25PressureResultController, "Result (inHg)", (v) => _calculateVstd(2), (v) => widget.data.pm25Pressure = double.tryParse(v!)),
|
||||
TextFormField(controller: _pm25VstdController, readOnly: true, decoration: const InputDecoration(labelText: 'VSTD PM2.5 (m³)', border: OutlineInputBorder(), filled: true, fillColor: Colors.black12)),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _remarkController,
|
||||
decoration: const InputDecoration(labelText: 'Remark (Optional)', border: OutlineInputBorder()),
|
||||
onSaved: (v) => widget.data.remark = v,
|
||||
maxLines: 3,
|
||||
onSaved: (v) => widget.data.remarks = v,
|
||||
),
|
||||
|
||||
// TODO: Add 6 Image Picker Widgets Here, relabeled as per the PDF
|
||||
|
||||
const SizedBox(height: 30),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@ -179,7 +234,7 @@ class _AirManualCollectionWidgetState extends State<AirManualCollectionWidget> {
|
||||
),
|
||||
child: widget.isLoading
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text('Submit All Data', style: TextStyle(color: Colors.white)),
|
||||
: const Text('Submit Collection Data', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -188,7 +243,6 @@ class _AirManualCollectionWidgetState extends State<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) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
|
||||
@ -5,27 +5,13 @@ import 'dart:io';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:dropdown_search/dropdown_search.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../../auth_provider.dart';
|
||||
import '../../../../models/air_installation_data.dart';
|
||||
import '../../../../services/air_sampling_service.dart';
|
||||
|
||||
// --- Placeholder classes to replace old dependencies ---
|
||||
class localState {
|
||||
final String stateID;
|
||||
final String stateName;
|
||||
localState({required this.stateID, required this.stateName});
|
||||
}
|
||||
|
||||
class localLocation {
|
||||
final String stationID;
|
||||
final String locationName;
|
||||
localLocation({required this.stationID, required this.locationName});
|
||||
}
|
||||
// ---------------------------------------------------------
|
||||
|
||||
|
||||
class AirManualInstallationWidget extends StatefulWidget {
|
||||
final AirInstallationData data;
|
||||
// --- UPDATED: Parameters now match the collection widget ---
|
||||
final Future<void> Function() onSubmit;
|
||||
final bool isLoading;
|
||||
|
||||
@ -44,166 +30,277 @@ class AirManualInstallationWidget extends StatefulWidget {
|
||||
class _AirManualInstallationWidgetState extends State<AirManualInstallationWidget> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Local state for this step
|
||||
bool _isLocationVisible = false;
|
||||
List<localState> _states = [];
|
||||
localState? _selectedState;
|
||||
List<localLocation> _locations = [];
|
||||
localLocation? _selectedLocation;
|
||||
bool _isDataInitialized = false;
|
||||
|
||||
// Controllers for text fields
|
||||
final TextEditingController _regionController = TextEditingController();
|
||||
List<Map<String, dynamic>> _allClients = [];
|
||||
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 _pm10Controller = TextEditingController();
|
||||
final TextEditingController _pm25Controller = TextEditingController();
|
||||
final TextEditingController _remarkController = TextEditingController();
|
||||
|
||||
final TextEditingController _optionalRemark1Controller = TextEditingController();
|
||||
final TextEditingController _optionalRemark2Controller = TextEditingController();
|
||||
final TextEditingController _optionalRemark3Controller = TextEditingController();
|
||||
final TextEditingController _optionalRemark4Controller = TextEditingController();
|
||||
|
||||
|
||||
static const List<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
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStates();
|
||||
// Pre-fill controllers if data already exists
|
||||
_regionController.text = widget.data.region ?? '';
|
||||
_samplingDateController.text = widget.data.samplingDate ?? '';
|
||||
_installationDateController.text = widget.data.installationDate ?? '';
|
||||
_installationTimeController.text = widget.data.installationTime ?? '';
|
||||
_tempController.text = widget.data.temp ?? '';
|
||||
_pm10Controller.text = widget.data.pm10FilterId ?? '';
|
||||
_pm25Controller.text = widget.data.pm25FilterId ?? '';
|
||||
_remarkController.text = widget.data.remark ?? '';
|
||||
_optionalRemark1Controller.text = widget.data.optionalRemark1 ?? '';
|
||||
_optionalRemark2Controller.text = widget.data.optionalRemark2 ?? '';
|
||||
_optionalRemark3Controller.text = widget.data.optionalRemark3 ?? '';
|
||||
_optionalRemark4Controller.text = widget.data.optionalRemark4 ?? '';
|
||||
}
|
||||
|
||||
Future<void> _loadStates() async {
|
||||
// This now uses placeholder data.
|
||||
final data = [
|
||||
localState(stateID: 'SGR', stateName: 'Selangor'),
|
||||
localState(stateID: 'JHR', stateName: 'Johor'),
|
||||
localState(stateID: 'KDH', stateName: 'Kedah'),
|
||||
localState(stateID: 'PRK', stateName: 'Perak'),
|
||||
];
|
||||
setState(() {
|
||||
_states = data;
|
||||
});
|
||||
}
|
||||
void _initializeData(AuthProvider authProvider) {
|
||||
final currentUser = authProvider.profileData;
|
||||
final clients = authProvider.airClients;
|
||||
final stations = authProvider.airManualStations;
|
||||
|
||||
Future<void> _loadLocations(String? stateID) async {
|
||||
if (stateID == null) return;
|
||||
|
||||
// This now uses placeholder data.
|
||||
List<localLocation> data = [];
|
||||
if (stateID == 'SGR') {
|
||||
data = [
|
||||
localLocation(stationID: 'SGR01', locationName: 'Shah Alam'),
|
||||
localLocation(stationID: 'SGR02', locationName: 'Klang'),
|
||||
localLocation(stationID: 'SGR03', locationName: 'Petaling Jaya'),
|
||||
];
|
||||
} else if (stateID == 'JHR') {
|
||||
data = [
|
||||
localLocation(stationID: 'JHR01', locationName: 'Johor Bahru'),
|
||||
localLocation(stationID: 'JHR02', locationName: 'Muar'),
|
||||
];
|
||||
if (currentUser != null) {
|
||||
widget.data.installationUserId = currentUser['user_id'];
|
||||
}
|
||||
|
||||
if (clients != null && clients.isNotEmpty) {
|
||||
_allClients = clients;
|
||||
final defaultClient = _allClients.firstWhere(
|
||||
(c) => c['client_name'] == 'Jabatan Alam Sekitar',
|
||||
orElse: () => _allClients.first,
|
||||
);
|
||||
_selectedClient = defaultClient;
|
||||
widget.data.clientId = defaultClient['client_id'];
|
||||
}
|
||||
|
||||
if (stations != null) {
|
||||
_allStations = stations;
|
||||
}
|
||||
}
|
||||
|
||||
void _onRegionChanged(String? region) {
|
||||
if (region == null) return;
|
||||
final statesInRegion = _regionToStatesMap[region] ?? [];
|
||||
|
||||
final availableStates = _allStations
|
||||
.where((s) => statesInRegion.contains(s['state_name']))
|
||||
.map((s) => s['state_name'] as String)
|
||||
.toSet()
|
||||
.toList();
|
||||
availableStates.sort();
|
||||
|
||||
setState(() {
|
||||
_locations = data;
|
||||
_isLocationVisible = true;
|
||||
_selectedRegion = region;
|
||||
_statesForSelectedRegion = availableStates;
|
||||
_selectedState = null;
|
||||
_locationsForSelectedState = [];
|
||||
_selectedLocation = null;
|
||||
widget.data.region = region;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSubmitPressed() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
_formKey.currentState!.save();
|
||||
widget.onSubmit(); // Use the passed onSubmit function
|
||||
void _onStateChanged(String? stateName) {
|
||||
if (stateName == null) return;
|
||||
final filteredLocations = _allStations.where((s) => s['state_name'] == stateName).toList();
|
||||
setState(() {
|
||||
_selectedState = stateName;
|
||||
_locationsForSelectedState = filteredLocations;
|
||||
_selectedLocation = null;
|
||||
widget.data.stateID = stateName;
|
||||
});
|
||||
}
|
||||
|
||||
Future<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> _pickImage(ImageSource source, int imageNumber) async {
|
||||
final service = Provider.of<AirSamplingService>(context, listen: false);
|
||||
final stationCode = widget.data.stationID ?? 'UNKNOWN';
|
||||
|
||||
String imageInfo;
|
||||
switch (imageNumber) {
|
||||
case 1: imageInfo = 'INSTALLATION_LEFT'; break;
|
||||
case 2: imageInfo = 'INSTALLATION_RIGHT'; break;
|
||||
case 3: imageInfo = 'INSTALLATION_FRONT'; break;
|
||||
case 4: imageInfo = 'INSTALLATION_BACK'; break;
|
||||
default: return;
|
||||
Future<void> _selectTime(BuildContext context, TextEditingController controller) async {
|
||||
final TimeOfDay? picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.now(),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
final String hour = picked.hour.toString().padLeft(2, '0');
|
||||
final String minute = picked.minute.toString().padLeft(2, '0');
|
||||
controller.text = '$hour:$minute';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<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(
|
||||
source,
|
||||
stationCode: stationCode,
|
||||
imageInfo: imageInfo,
|
||||
imageInfo: imageInfo.toUpperCase(),
|
||||
);
|
||||
|
||||
if (imageFile != null) {
|
||||
setState(() {
|
||||
switch (imageNumber) {
|
||||
case 1: widget.data.image1 = imageFile; break;
|
||||
case 2: widget.data.image2 = imageFile; break;
|
||||
case 3: widget.data.image3 = imageFile; break;
|
||||
case 4: widget.data.image4 = imageFile; break;
|
||||
switch (imageInfo) {
|
||||
case 'front': widget.data.imageFront = imageFile; break;
|
||||
case 'back': widget.data.imageBack = imageFile; break;
|
||||
case 'left': widget.data.imageLeft = imageFile; break;
|
||||
case 'right': widget.data.imageRight = imageFile; break;
|
||||
case 'optional_01': widget.data.optionalImage1 = imageFile; break;
|
||||
case 'optional_02': widget.data.optionalImage2 = imageFile; break;
|
||||
case 'optional_03': widget.data.optionalImage3 = imageFile; break;
|
||||
case 'optional_04': widget.data.optionalImage4 = imageFile; break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildImagePicker(String title, File? imageFile, int imageNumber) {
|
||||
// ... (This helper widget remains the same)
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Divider(height: 20),
|
||||
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
if (imageFile != null)
|
||||
Stack(
|
||||
alignment: Alignment.topRight,
|
||||
children: [
|
||||
Image.file(imageFile, fit: BoxFit.cover, width: double.infinity, height: 200),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.white, size: 30),
|
||||
style: IconButton.styleFrom(backgroundColor: Colors.red.withOpacity(0.7)),
|
||||
onPressed: () => setState(() {
|
||||
switch (imageNumber) {
|
||||
case 1: widget.data.image1 = null; break;
|
||||
case 2: widget.data.image2 = null; break;
|
||||
case 3: widget.data.image3 = null; break;
|
||||
case 4: widget.data.image4 = null; break;
|
||||
}
|
||||
}),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Container(
|
||||
height: 150,
|
||||
width: double.infinity,
|
||||
color: Colors.grey[200],
|
||||
child: const Center(child: Text('No Image Selected')),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
Widget _buildImagePicker(String title, String imageInfo, File? imageFile, {TextEditingController? remarkController}) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.camera_alt),
|
||||
label: const Text('Camera'),
|
||||
onPressed: () => _pickImage(ImageSource.camera, imageNumber),
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 150,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(8)),
|
||||
child: imageFile != null ? Image.file(imageFile, fit: BoxFit.cover, key: UniqueKey()) : const Center(child: Icon(Icons.image, size: 50, color: Colors.grey)),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.photo_library),
|
||||
label: const Text('Gallery'),
|
||||
onPressed: () => _pickImage(ImageSource.gallery, imageNumber),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
ElevatedButton.icon(icon: const Icon(Icons.photo_library), label: const Text('Gallery'), onPressed: () => _pickImage(ImageSource.gallery, imageInfo)),
|
||||
ElevatedButton.icon(icon: const Icon(Icons.camera_alt), label: const Text('Camera'), onPressed: () => _pickImage(ImageSource.camera, imageInfo)),
|
||||
],
|
||||
),
|
||||
if (remarkController != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: TextFormField(
|
||||
controller: remarkController,
|
||||
decoration: InputDecoration(labelText: 'Remarks for $title', border: const OutlineInputBorder()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onSubmitPressed() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
_formKey.currentState!.save();
|
||||
widget.data.optionalRemark1 = _optionalRemark1Controller.text;
|
||||
widget.data.optionalRemark2 = _optionalRemark2Controller.text;
|
||||
widget.data.optionalRemark3 = _optionalRemark3Controller.text;
|
||||
widget.data.optionalRemark4 = _optionalRemark4Controller.text;
|
||||
widget.onSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_samplingDateController.dispose();
|
||||
_installationDateController.dispose();
|
||||
_installationTimeController.dispose();
|
||||
_tempController.dispose();
|
||||
_pm10Controller.dispose();
|
||||
_pm25Controller.dispose();
|
||||
_remarkController.dispose();
|
||||
_optionalRemark1Controller.dispose();
|
||||
_optionalRemark2Controller.dispose();
|
||||
_optionalRemark3Controller.dispose();
|
||||
_optionalRemark4Controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authProvider = Provider.of<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(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Form(
|
||||
@ -211,126 +308,102 @@ class _AirManualInstallationWidgetState extends State<AirManualInstallationWidge
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ... (All form fields remain the same)
|
||||
const Center(
|
||||
child: Text('Installation Details',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const Center(child: Text('Installation Details', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))),
|
||||
const Divider(height: 30),
|
||||
DropdownSearch<localState>(
|
||||
items: _states,
|
||||
selectedItem: _selectedState,
|
||||
itemAsString: (localState s) => s.stateName,
|
||||
popupProps: const PopupProps.menu(showSearchBox: true),
|
||||
dropdownDecoratorProps: const DropDownDecoratorProps(
|
||||
dropdownSearchDecoration: InputDecoration(
|
||||
labelText: "Select State",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
onChanged: (localState? value) {
|
||||
setState(() {
|
||||
_selectedState = value;
|
||||
widget.data.stateID = value?.stateID;
|
||||
_loadLocations(value?.stateID);
|
||||
});
|
||||
},
|
||||
validator: (v) => v == null ? 'State is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_isLocationVisible)
|
||||
DropdownSearch<localLocation>(
|
||||
items: _locations,
|
||||
selectedItem: _selectedLocation,
|
||||
itemAsString: (localLocation l) => "${l.stationID} - ${l.locationName}",
|
||||
// --- FIXED: Removed 'const' from PopupProps.menu ---
|
||||
popupProps: PopupProps.menu(showSearchBox: true,
|
||||
emptyBuilder: (context, searchEntry) => const Center(child: Text("No locations found for this state")),
|
||||
),
|
||||
dropdownDecoratorProps: const DropDownDecoratorProps(
|
||||
dropdownSearchDecoration: InputDecoration(
|
||||
labelText: "Select Location",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
onChanged: (localLocation? value) {
|
||||
setState(() {
|
||||
_selectedLocation = value;
|
||||
widget.data.stationID = value?.stationID;
|
||||
widget.data.locationName = value?.locationName;
|
||||
});
|
||||
},
|
||||
validator: (v) => v == null ? 'Location is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _regionController,
|
||||
decoration: const InputDecoration(labelText: 'Region', border: OutlineInputBorder()),
|
||||
validator: (v) => v!.isEmpty ? 'Region is required' : null,
|
||||
onSaved: (v) => widget.data.region = v,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(labelText: 'Weather', border: OutlineInputBorder()),
|
||||
value: widget.data.weather,
|
||||
items: ['Clear', 'Raining'].map((v) => DropdownMenuItem(value: v, child: Text(v))).toList(),
|
||||
onChanged: (v) => setState(() => widget.data.weather = v),
|
||||
onSaved: (v) => widget.data.weather = v,
|
||||
validator: (v) => v == null ? 'Weather is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _tempController,
|
||||
decoration: const InputDecoration(labelText: 'Temperature (°C)', border: OutlineInputBorder()),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) => v!.isEmpty ? 'Temperature is required' : null,
|
||||
onSaved: (v) => widget.data.temp = v,
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text("Power Failure?"),
|
||||
value: widget.data.powerFailure,
|
||||
onChanged: (bool value) => setState(() => widget.data.powerFailure = value),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _pm10Controller,
|
||||
enabled: !widget.data.powerFailure,
|
||||
decoration: const InputDecoration(labelText: 'PM10 Filter ID', border: OutlineInputBorder()),
|
||||
validator: (v) => !widget.data.powerFailure && v!.isEmpty ? 'Required' : null,
|
||||
onSaved: (v) => widget.data.pm10FilterId = v,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _pm25Controller,
|
||||
enabled: !widget.data.powerFailure,
|
||||
decoration: const InputDecoration(labelText: 'PM2.5 Filter ID', border: OutlineInputBorder()),
|
||||
validator: (v) => !widget.data.powerFailure && v!.isEmpty ? 'Required' : null,
|
||||
onSaved: (v) => widget.data.pm25FilterId = v,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _remarkController,
|
||||
decoration: const InputDecoration(labelText: 'Remark (Optional)', border: OutlineInputBorder()),
|
||||
onSaved: (v) => widget.data.remark = v,
|
||||
),
|
||||
|
||||
_buildImagePicker('Site Picture (Left)', widget.data.image1, 1),
|
||||
_buildImagePicker('Site Picture (Right)', widget.data.image2, 2),
|
||||
_buildImagePicker('Site Picture (Front)', widget.data.image3, 3),
|
||||
_buildImagePicker('Site Picture (Back)', widget.data.image4, 4),
|
||||
DropdownSearch<Map<String, dynamic>>(
|
||||
items: _allClients,
|
||||
selectedItem: _selectedClient,
|
||||
itemAsString: (c) => c['client_name'],
|
||||
popupProps: const PopupProps.menu(showSearchBox: true, fit: FlexFit.loose),
|
||||
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Client", border: OutlineInputBorder())),
|
||||
onChanged: (value) => setState(() => _selectedClient = value),
|
||||
onSaved: (v) => widget.data.clientId = v?['client_id'],
|
||||
validator: (v) => v == null ? 'Client is required' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(controller: _samplingDateController, readOnly: true, decoration: const InputDecoration(labelText: 'Sampling Date (Initiated)', border: OutlineInputBorder(), filled: true, fillColor: Colors.black12)),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
DropdownSearch<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),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
// --- UPDATED: Use isLoading and call _onSubmitPressed ---
|
||||
onPressed: widget.isLoading ? null : _onSubmitPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
child: widget.isLoading
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text('Submit Installation', style: TextStyle(color: Colors.white)),
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16), backgroundColor: Theme.of(context).primaryColor),
|
||||
child: widget.isLoading ? const CircularProgressIndicator(color: Colors.white) : const Text('Submit Installation', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
//lib\screens\marine\manual\widgets\in_situ_step_1_sampling_info.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:dropdown_search/dropdown_search.dart';
|
||||
|
||||
@ -1,76 +1,28 @@
|
||||
// lib/services/air_sampling_service.dart
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
// Import your actual data models
|
||||
import '../models/air_installation_data.dart';
|
||||
import '../models/air_collection_data.dart';
|
||||
|
||||
// Import a local storage service (you would create this)
|
||||
// import 'local_storage_service.dart';
|
||||
|
||||
// A placeholder for your actual API service
|
||||
class AirApiService {
|
||||
Future<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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
import 'api_service.dart';
|
||||
import 'local_storage_service.dart';
|
||||
|
||||
/// A dedicated service to handle all business logic for the Air Manual Sampling feature.
|
||||
class AirSamplingService {
|
||||
final AirApiService _apiService = AirApiService();
|
||||
// final LocalStorageService _localStorageService = LocalStorageService();
|
||||
final ApiService _apiService = ApiService();
|
||||
final LocalStorageService _localStorageService = LocalStorageService();
|
||||
|
||||
/// Picks an image from the specified source, adds a timestamp watermark,
|
||||
/// and saves it to a temporary directory with a standardized name.
|
||||
Future<File?> pickAndProcessImage(
|
||||
ImageSource source, {
|
||||
required String stationCode,
|
||||
required String imageInfo, // e.g., "INSTALLATION_LEFT", "COLLECTION_CHART"
|
||||
required String imageInfo,
|
||||
}) async {
|
||||
final picker = ImagePicker();
|
||||
final XFile? photo = await picker.pickImage(
|
||||
@ -81,13 +33,11 @@ class AirSamplingService {
|
||||
img.Image? originalImage = img.decodeImage(bytes);
|
||||
if (originalImage == null) return null;
|
||||
|
||||
// Prepare watermark text
|
||||
final String watermarkTimestamp =
|
||||
DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
|
||||
final font = img.arial24;
|
||||
|
||||
// Add a white background for better text visibility
|
||||
final textWidth = watermarkTimestamp.length * 12; // Estimate width
|
||||
final textWidth = watermarkTimestamp.length * 12;
|
||||
img.fillRect(originalImage,
|
||||
x1: 5,
|
||||
y1: 5,
|
||||
@ -97,108 +47,121 @@ class AirSamplingService {
|
||||
img.drawString(originalImage, watermarkTimestamp,
|
||||
font: font, x: 10, y: 10, color: img.ColorRgb8(0, 0, 0));
|
||||
|
||||
// Create a standardized file name
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final fileTimestamp = watermarkTimestamp.replaceAll(':', '-').replaceAll(' ', '_');
|
||||
final newFileName =
|
||||
"${stationCode}_${fileTimestamp}_${imageInfo.replaceAll(' ', '')}.jpg";
|
||||
"${stationCode}_${fileTimestamp}_INSTALL_${imageInfo.replaceAll(' ', '')}.jpg";
|
||||
final filePath = path.join(tempDir.path, newFileName);
|
||||
|
||||
// Save the processed image and return the file
|
||||
return File(filePath)..writeAsBytesSync(img.encodeJpg(originalImage));
|
||||
}
|
||||
|
||||
/// Submits only the installation data.
|
||||
/// Orchestrates a two-step submission process for air installation samples.
|
||||
Future<Map<String, dynamic>> submitInstallation(AirInstallationData data) async {
|
||||
try {
|
||||
// Prepare image files for upload
|
||||
final List<File> images = [];
|
||||
if (data.image1 != null) images.add(data.image1!);
|
||||
if (data.image2 != null) images.add(data.image2!);
|
||||
if (data.image3 != null) images.add(data.image3!);
|
||||
if (data.image4 != null) images.add(data.image4!);
|
||||
|
||||
// In a real app, you would check connectivity here before attempting the API call
|
||||
final result = await _apiService.submitInstallation(
|
||||
installationJson: data.toJson(),
|
||||
imageFiles: images,
|
||||
);
|
||||
return result;
|
||||
} catch (e) {
|
||||
print("API submission failed: $e. Saving installation locally.");
|
||||
// --- Fallback to Local Storage ---
|
||||
// TODO: Implement local DB save for installation data
|
||||
data.status = 'L1'; // Mark as saved locally, pending collection
|
||||
// await _localStorageService.saveAirInstallationData(data);
|
||||
// --- OFFLINE-FIRST HELPER ---
|
||||
Future<Map<String, dynamic>> saveLocally() async {
|
||||
debugPrint("Saving installation locally...");
|
||||
data.status = 'L1'; // Mark as Locally Saved, Pending Submission
|
||||
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
|
||||
return {
|
||||
'status': 'L1',
|
||||
'message': 'Installation data saved locally.',
|
||||
'message': 'No connection or server error. Installation data saved locally.',
|
||||
};
|
||||
}
|
||||
|
||||
// --- STEP 1: SUBMIT TEXT DATA ---
|
||||
debugPrint("Step 1: Submitting installation text data...");
|
||||
final textDataResult = await _apiService.post('air/manual/installation', data.toJsonForApi());
|
||||
|
||||
if (textDataResult['success'] != true) {
|
||||
debugPrint("Failed to submit text data. Reason: ${textDataResult['message']}");
|
||||
return await saveLocally();
|
||||
}
|
||||
|
||||
final recordId = textDataResult['data']?['air_man_id'];
|
||||
if (recordId == null) {
|
||||
debugPrint("Text data submitted, but did not receive a record ID.");
|
||||
return await saveLocally();
|
||||
}
|
||||
debugPrint("Text data submitted successfully. Received record ID: $recordId");
|
||||
|
||||
// --- STEP 2: UPLOAD IMAGE FILES ---
|
||||
final filesToUpload = data.getImagesForUpload();
|
||||
if (filesToUpload.isEmpty) {
|
||||
debugPrint("No images to upload. Submission complete.");
|
||||
data.status = 'S1'; // Server Pending (no images needed)
|
||||
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
|
||||
return {'status': 'S1', 'message': 'Installation data submitted successfully.'};
|
||||
}
|
||||
|
||||
debugPrint("Step 2: Uploading ${filesToUpload.length} images for record ID $recordId...");
|
||||
final imageUploadResult = await _apiService.air.uploadInstallationImages(
|
||||
airManId: recordId.toString(),
|
||||
files: filesToUpload,
|
||||
);
|
||||
|
||||
if (imageUploadResult['success'] != true) {
|
||||
debugPrint("Image upload failed. Reason: ${imageUploadResult['message']}");
|
||||
return await saveLocally();
|
||||
}
|
||||
|
||||
debugPrint("Images uploaded successfully.");
|
||||
data.status = 'S2'; // Server Pending (images uploaded)
|
||||
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.refID!);
|
||||
return {
|
||||
'status': 'S2',
|
||||
'message': 'Installation data and images submitted successfully.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// Submits only the collection data, linked to a previous installation.
|
||||
Future<Map<String, dynamic>> submitCollection(AirCollectionData data) async {
|
||||
try {
|
||||
// Prepare image files for upload
|
||||
final List<File> images = [];
|
||||
if (data.imageSiteLeft != null) images.add(data.imageSiteLeft!);
|
||||
if (data.imageSiteRight != null) images.add(data.imageSiteRight!);
|
||||
// ... add all other collection images
|
||||
|
||||
// In a real app, you would check connectivity here
|
||||
final result = await _apiService.submitCollection(
|
||||
collectionJson: data.toJson(),
|
||||
imageFiles: images,
|
||||
final result = await _apiService.post(
|
||||
'air/manual/collection',
|
||||
data.toJson()
|
||||
);
|
||||
return result;
|
||||
|
||||
if (result['success'] == true) {
|
||||
data.status = 'S3'; // Server Completed
|
||||
// Also save the completed record locally
|
||||
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.installationRefID!);
|
||||
return {'status': 'S3', 'message': 'Collection submitted successfully.'};
|
||||
} else {
|
||||
throw Exception(result['message'] ?? 'Unknown server error');
|
||||
}
|
||||
} catch (e) {
|
||||
print("API submission failed: $e. Saving collection locally.");
|
||||
// --- Fallback to Local Storage ---
|
||||
// TODO: Implement local DB save for collection data
|
||||
debugPrint("API submission failed: $e. Saving collection locally.");
|
||||
data.status = 'L3'; // Mark as completed locally
|
||||
// await _localStorageService.saveAirCollectionData(data);
|
||||
|
||||
// CORRECTED: Use toMap() to ensure all data is saved locally on failure.
|
||||
await _localStorageService.saveAirSamplingRecord(data.toMap(), data.installationRefID!);
|
||||
|
||||
return {
|
||||
'status': 'L3',
|
||||
'message': 'Collection data saved locally.',
|
||||
'message': 'No connection. Collection data saved locally.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches installations that are pending collection.
|
||||
/// Fetches installations that are pending collection from local storage.
|
||||
Future<List<AirInstallationData>> getPendingInstallations() async {
|
||||
// In a real app, this would query your local database or a server endpoint
|
||||
// for records with status 'L1' (local, pending) or 'S1' (server, pending).
|
||||
print("Fetching pending installations...");
|
||||
await Future.delayed(const Duration(milliseconds: 500)); // Simulate network/DB delay
|
||||
debugPrint("Fetching pending installations from local storage...");
|
||||
|
||||
// Return placeholder data for demonstration
|
||||
return [
|
||||
AirInstallationData(
|
||||
refID: 'ABC1234567',
|
||||
stationID: 'SGR01',
|
||||
locationName: 'Shah Alam',
|
||||
samplingDate: '2025-08-10',
|
||||
temp: '28.5',
|
||||
status: 'L1', // Example of a locally saved installation
|
||||
),
|
||||
AirInstallationData(
|
||||
refID: 'DEF8901234',
|
||||
stationID: 'JHR02',
|
||||
locationName: 'Muar',
|
||||
samplingDate: '2025-08-09',
|
||||
temp: '29.1',
|
||||
status: 'S1', // Example of a server-saved installation
|
||||
),
|
||||
AirInstallationData(
|
||||
refID: 'GHI5678901',
|
||||
stationID: 'PRK01',
|
||||
locationName: 'Ipoh',
|
||||
samplingDate: '2025-08-11',
|
||||
temp: '27.9',
|
||||
status: 'S1',
|
||||
),
|
||||
];
|
||||
final logs = await _localStorageService.getAllAirSamplingLogs();
|
||||
|
||||
final pendingInstallations = logs
|
||||
.where((log) {
|
||||
final status = log['status'];
|
||||
// CORRECTED: Include 'S2' to show records that have successfully uploaded images
|
||||
// but are still pending collection.
|
||||
return status == 'L1' || status == 'S1' || status == 'S2';
|
||||
})
|
||||
.map((log) => AirInstallationData.fromJson(log))
|
||||
.toList();
|
||||
|
||||
return pendingInstallations;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
// lib/services/api_service.dart
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@ -23,12 +21,14 @@ class ApiService {
|
||||
|
||||
late final MarineApiService marine;
|
||||
late final RiverApiService river;
|
||||
late final AirApiService air;
|
||||
|
||||
static const String imageBaseUrl = 'https://dev14.pstw.com.my/';
|
||||
|
||||
ApiService() {
|
||||
marine = MarineApiService(_baseService);
|
||||
river = RiverApiService(_baseService);
|
||||
air = AirApiService(_baseService);
|
||||
}
|
||||
|
||||
// --- Core API Methods ---
|
||||
@ -73,8 +73,9 @@ class ApiService {
|
||||
Future<Map<String, dynamic>> getAllDepartments() => _baseService.get('departments');
|
||||
Future<Map<String, dynamic>> getAllCompanies() => _baseService.get('companies');
|
||||
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({
|
||||
required String chatId,
|
||||
required String message,
|
||||
@ -84,7 +85,6 @@ class ApiService {
|
||||
'message': message,
|
||||
});
|
||||
}
|
||||
// --- END ---
|
||||
|
||||
Future<File?> downloadProfilePicture(String imageUrl, String localPath) async {
|
||||
try {
|
||||
@ -109,7 +109,6 @@ class ApiService {
|
||||
);
|
||||
}
|
||||
|
||||
/// --- A dedicated method to refresh only the profile ---
|
||||
Future<Map<String, dynamic>> refreshProfile() async {
|
||||
debugPrint('ApiService: Refreshing profile data from server...');
|
||||
final result = await getProfile();
|
||||
@ -136,6 +135,9 @@ class ApiService {
|
||||
getAllDepartments(),
|
||||
getAllCompanies(),
|
||||
getAllPositions(),
|
||||
air.getManualStations(),
|
||||
air.getClients(),
|
||||
getAllStates(),
|
||||
]);
|
||||
|
||||
final Map<String, dynamic> syncedData = {
|
||||
@ -149,6 +151,9 @@ class ApiService {
|
||||
'departments': results[7]['success'] == true ? results[7]['data'] : null,
|
||||
'companies': results[8]['success'] == true ? results[8]['data'] : null,
|
||||
'positions': results[9]['success'] == true ? results[9]['data'] : null,
|
||||
'airManualStations': results[10]['success'] == true ? results[10]['data'] : null,
|
||||
'airClients': results[11]['success'] == true ? results[11]['data'] : null,
|
||||
'states': results[12]['success'] == true ? results[12]['data'] : null,
|
||||
};
|
||||
|
||||
if (syncedData['profile'] != null) await _dbHelper.saveProfile(syncedData['profile']);
|
||||
@ -161,6 +166,9 @@ class ApiService {
|
||||
if (syncedData['departments'] != null) await _dbHelper.saveDepartments(List<Map<String, dynamic>>.from(syncedData['departments']));
|
||||
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['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.');
|
||||
return {'success': true, 'data': syncedData};
|
||||
@ -176,6 +184,27 @@ class ApiService {
|
||||
// Part 2: Feature-Specific API Services
|
||||
// =======================================================================
|
||||
|
||||
class AirApiService {
|
||||
final BaseApiService _baseService;
|
||||
AirApiService(this._baseService);
|
||||
|
||||
Future<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 {
|
||||
final BaseApiService _baseService;
|
||||
MarineApiService(this._baseService);
|
||||
@ -221,8 +250,7 @@ class RiverApiService {
|
||||
class DatabaseHelper {
|
||||
static Database? _database;
|
||||
static const String _dbName = 'app_data.db';
|
||||
// FIXED: Incremented database version to trigger onUpgrade and create the new table
|
||||
static const int _dbVersion = 10;
|
||||
static const int _dbVersion = 12;
|
||||
|
||||
static const String _profileTable = 'user_profile';
|
||||
static const String _usersTable = 'all_users';
|
||||
@ -234,8 +262,11 @@ class DatabaseHelper {
|
||||
static const String _departmentsTable = 'departments';
|
||||
static const String _companiesTable = 'companies';
|
||||
static const String _positionsTable = 'positions';
|
||||
// --- ADDED: Table name for the alert queue ---
|
||||
static const String _alertQueueTable = 'alert_queue';
|
||||
static const String _airManualStationsTable = 'air_manual_stations';
|
||||
static const String _airClientsTable = 'air_clients';
|
||||
static const String _statesTable = 'states';
|
||||
|
||||
|
||||
Future<Database> get database async {
|
||||
if (_database != null) return _database!;
|
||||
@ -259,33 +290,19 @@ class DatabaseHelper {
|
||||
await db.execute('CREATE TABLE $_departmentsTable(department_id INTEGER PRIMARY KEY, department_json TEXT)');
|
||||
await db.execute('CREATE TABLE $_companiesTable(company_id INTEGER PRIMARY KEY, company_json TEXT)');
|
||||
await db.execute('CREATE TABLE $_positionsTable(position_id INTEGER PRIMARY KEY, position_json TEXT)');
|
||||
|
||||
// --- ADDED: Create the alert_queue table ---
|
||||
await db.execute('''
|
||||
CREATE TABLE $_alertQueueTable (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute('''CREATE TABLE $_alertQueueTable (id INTEGER PRIMARY KEY AUTOINCREMENT, chat_id TEXT NOT NULL, message TEXT NOT NULL, created_at TEXT NOT NULL)''');
|
||||
await db.execute('CREATE TABLE $_airManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)');
|
||||
await db.execute('CREATE TABLE $_airClientsTable(client_id INTEGER PRIMARY KEY, client_json TEXT)');
|
||||
await db.execute('CREATE TABLE $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)');
|
||||
}
|
||||
|
||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||
if (oldVersion < 10) {
|
||||
await db.execute('DROP TABLE IF EXISTS $_profileTable');
|
||||
await db.execute('DROP TABLE IF EXISTS $_usersTable');
|
||||
await db.execute('DROP TABLE IF EXISTS $_tarballStationsTable');
|
||||
await db.execute('DROP TABLE IF EXISTS $_manualStationsTable');
|
||||
await db.execute('DROP TABLE IF EXISTS $_riverManualStationsTable');
|
||||
await db.execute('DROP TABLE IF EXISTS $_riverTriennialStationsTable');
|
||||
await db.execute('DROP TABLE IF EXISTS $_tarballClassificationsTable');
|
||||
await db.execute('DROP TABLE IF EXISTS $_departmentsTable');
|
||||
await db.execute('DROP TABLE IF EXISTS $_companiesTable');
|
||||
await db.execute('DROP TABLE IF EXISTS $_positionsTable');
|
||||
// --- ADDED: Drop the alert_queue table during upgrade ---
|
||||
await db.execute('DROP TABLE IF EXISTS $_alertQueueTable');
|
||||
await _onCreate(db, newVersion);
|
||||
if (oldVersion < 11) {
|
||||
await db.execute('CREATE TABLE IF NOT EXISTS $_airManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)');
|
||||
await db.execute('CREATE TABLE IF NOT EXISTS $_airClientsTable(client_id INTEGER PRIMARY KEY, client_json TEXT)');
|
||||
}
|
||||
if (oldVersion < 12) {
|
||||
await db.execute('CREATE TABLE IF NOT EXISTS $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)');
|
||||
}
|
||||
}
|
||||
|
||||
@ -293,7 +310,7 @@ class DatabaseHelper {
|
||||
final db = await database;
|
||||
await db.delete(table);
|
||||
for (var item in data) {
|
||||
await db.insert(table, {'${idKey}_id': item[idKey], '${idKey}_json': jsonEncode(item)}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
await db.insert(table, {'${idKey}_id': item['${idKey}_id'], '${idKey}_json': jsonEncode(item)}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
}
|
||||
|
||||
@ -343,4 +360,13 @@ class DatabaseHelper {
|
||||
|
||||
Future<void> savePositions(List<Map<String, dynamic>> data) => _saveData(_positionsTable, 'position', data);
|
||||
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');
|
||||
}
|
||||
|
||||
@ -72,12 +72,12 @@ class BaseApiService {
|
||||
request.headers.addAll(headers);
|
||||
debugPrint('Headers added to multipart request.');
|
||||
|
||||
// CORRECTED: Send all text fields as a single JSON string under the key 'data'.
|
||||
// This is a common pattern for APIs that handle mixed file/data uploads and
|
||||
// helps prevent issues where servers fail to parse individual fields.
|
||||
// --- CORRECTED ---
|
||||
// This adds each field directly to the request body, which is the standard
|
||||
// for multipart/form-data and matches what the PHP backend expects.
|
||||
if (fields.isNotEmpty) {
|
||||
request.fields['data'] = jsonEncode(fields);
|
||||
debugPrint('Fields added as a single JSON string under the key "data".');
|
||||
request.fields.addAll(fields);
|
||||
debugPrint('Fields added directly to multipart request: $fields');
|
||||
}
|
||||
|
||||
// Add files
|
||||
|
||||
@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../models/air_installation_data.dart';
|
||||
import '../models/tarball_data.dart';
|
||||
import '../models/in_situ_sampling_data.dart';
|
||||
import '../models/river_in_situ_sampling_data.dart';
|
||||
@ -40,7 +41,100 @@ class LocalStorageService {
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Part 2: Tarball Specific Methods
|
||||
// --- UPDATED: Part 2: Air Manual Sampling Methods ---
|
||||
// =======================================================================
|
||||
|
||||
Future<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 {
|
||||
@ -145,7 +239,7 @@ class LocalStorageService {
|
||||
|
||||
|
||||
// =======================================================================
|
||||
// Part 3: Marine In-Situ Specific Methods
|
||||
// Part 4: Marine In-Situ Specific Methods
|
||||
// =======================================================================
|
||||
|
||||
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 {
|
||||
final mmsv4Dir = await _getPublicMMSV4Directory();
|
||||
if (mmsv4Dir == null) return null;
|
||||
@ -270,9 +363,7 @@ class LocalStorageService {
|
||||
return inSituDir;
|
||||
}
|
||||
|
||||
/// Saves a single river in-situ sampling record to a unique folder in public storage.
|
||||
Future<String?> saveRiverInSituSamplingData(RiverInSituSamplingData data) async {
|
||||
// UPDATED: Pass the samplingType to get the correct subdirectory.
|
||||
final baseDir = await _getRiverInSituBaseDir(data.samplingType);
|
||||
if (baseDir == null) {
|
||||
debugPrint("Could not get public storage directory for River In-Situ. Check permissions.");
|
||||
@ -315,7 +406,6 @@ class LocalStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieves all saved river in-situ submission logs from all subfolders.
|
||||
Future<List<Map<String, dynamic>>> getAllRiverInSituLogs() async {
|
||||
final mmsv4Dir = await _getPublicMMSV4Directory();
|
||||
if (mmsv4Dir == null) return [];
|
||||
@ -325,12 +415,10 @@ class LocalStorageService {
|
||||
|
||||
try {
|
||||
final List<Map<String, dynamic>> logs = [];
|
||||
// List all subdirectories (e.g., 'Schedule', 'Triennial', 'Others')
|
||||
final typeSubfolders = topLevelDir.listSync();
|
||||
|
||||
for (var typeSubfolder in typeSubfolders) {
|
||||
if (typeSubfolder is Directory) {
|
||||
// List all event directories inside the type subfolder
|
||||
final eventFolders = typeSubfolder.listSync();
|
||||
for (var eventFolder in eventFolders) {
|
||||
if (eventFolder is Directory) {
|
||||
@ -352,7 +440,6 @@ class LocalStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates an existing river in-situ log file with new submission status.
|
||||
Future<void> updateRiverInSituLog(Map<String, dynamic> updatedLogData) async {
|
||||
final logDir = updatedLogData['logDirectory'];
|
||||
if (logDir == null) {
|
||||
@ -371,4 +458,4 @@ class LocalStorageService {
|
||||
debugPrint("Error updating river in-situ log: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user