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