repair separate department parameter limit for river and marine

This commit is contained in:
ALim Aidrus 2025-10-04 00:36:36 +08:00
parent 18c2bf3ec0
commit fc14740b01
9 changed files with 837 additions and 141 deletions

View File

@ -70,7 +70,7 @@ import 'package:environment_monitoring_app/screens/marine/manual/pre_sampling.da
import 'package:environment_monitoring_app/screens/marine/manual/in_situ_sampling.dart' as marineManualInSituSampling; import 'package:environment_monitoring_app/screens/marine/manual/in_situ_sampling.dart' as marineManualInSituSampling;
import 'package:environment_monitoring_app/screens/marine/manual/report.dart' as marineManualReport; import 'package:environment_monitoring_app/screens/marine/manual/report.dart' as marineManualReport;
import 'package:environment_monitoring_app/screens/marine/manual/data_status_log.dart' as marineManualDataStatusLog; import 'package:environment_monitoring_app/screens/marine/manual/data_status_log.dart' as marineManualDataStatusLog;
import 'package:environment_monitoring_app/screens/marine/manual/image_request.dart' as marineManualImageRequest; import 'package:environment_monitoring_app/screens/marine/manual/marine_image_request.dart' as marineManualImageRequest;
import 'package:environment_monitoring_app/screens/marine/continuous/marine_continuous_info_centre_document.dart'; import 'package:environment_monitoring_app/screens/marine/continuous/marine_continuous_info_centre_document.dart';
import 'package:environment_monitoring_app/screens/marine/continuous/overview.dart' as marineContinuousOverview; import 'package:environment_monitoring_app/screens/marine/continuous/overview.dart' as marineContinuousOverview;
import 'package:environment_monitoring_app/screens/marine/continuous/entry.dart' as marineContinuousEntry; import 'package:environment_monitoring_app/screens/marine/continuous/entry.dart' as marineContinuousEntry;
@ -281,7 +281,8 @@ class _RootAppState extends State<RootApp> {
'/marine/manual/tarball': (context) => const TarballSamplingStep1(), '/marine/manual/tarball': (context) => const TarballSamplingStep1(),
'/marine/manual/report': (context) => marineManualReport.MarineManualReport(), '/marine/manual/report': (context) => marineManualReport.MarineManualReport(),
//'/marine/manual/data-log': (context) => marineManualDataStatusLog.MarineManualDataStatusLog(), // This is handled in onGenerateRoute //'/marine/manual/data-log': (context) => marineManualDataStatusLog.MarineManualDataStatusLog(), // This is handled in onGenerateRoute
'/marine/manual/image-request': (context) => marineManualImageRequest.MarineManualImageRequest(), '/marine/manual/image-request': (context) => const marineManualImageRequest.MarineImageRequestScreen(),
// Marine Continuous // Marine Continuous
'/marine/continuous/info': (context) => const MarineContinuousInfoCentreDocument(), '/marine/continuous/info': (context) => const MarineContinuousInfoCentreDocument(),

View File

@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io'; // Add this line at the top of these files
class MarineManualImageRequest extends StatefulWidget {
@override
State<MarineManualImageRequest> createState() => _MarineManualImageRequestState();
}
class _MarineManualImageRequestState extends State<MarineManualImageRequest> {
XFile? _image;
final picker = ImagePicker();
final _descriptionController = TextEditingController();
Future<void> _pickImage() async {
final pickedFile = await picker.pickImage(source: ImageSource.camera);
setState(() => _image = pickedFile);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Marine Manual Image Request")),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
ElevatedButton.icon(
icon: Icon(Icons.camera_alt),
label: Text("Capture Image"),
onPressed: _pickImage,
),
SizedBox(height: 16),
if (_image != null)
Image.file(
File(_image!.path),
height: 200,
),
SizedBox(height: 16),
TextField(
controller: _descriptionController,
decoration: InputDecoration(labelText: "Description"),
maxLines: 3,
),
SizedBox(height: 24),
ElevatedButton(
onPressed: () {
// Submit logic here
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Image request submitted")),
);
},
child: Text("Submit Request"),
),
],
),
),
);
}
}

View File

@ -0,0 +1,493 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:dropdown_search/dropdown_search.dart';
import 'package:intl/intl.dart';
import 'package:image_picker/image_picker.dart';
import '../../../auth_provider.dart';
import '../../../services/api_service.dart';
class MarineImageRequestScreen extends StatefulWidget {
const MarineImageRequestScreen({super.key});
@override
State<MarineImageRequestScreen> createState() => _MarineImageRequestScreenState();
}
class _MarineImageRequestScreenState extends State<MarineImageRequestScreen> {
final _formKey = GlobalKey<FormState>();
final _dateController = TextEditingController();
String? _selectedSamplingType = 'All Manual Sampling';
final List<String> _samplingTypes = ['All Manual Sampling', 'In-Situ Sampling', 'Tarball Sampling'];
String? _selectedStateName;
String? _selectedCategoryName;
Map<String, dynamic>? _selectedStation;
DateTime? _selectedDate;
List<String> _statesList = [];
List<String> _categoriesForState = [];
List<Map<String, dynamic>> _stationsForCategory = [];
bool _isLoading = false;
List<String> _imageUrls = [];
final Set<String> _selectedImageUrls = {};
@override
void initState() {
super.initState();
_initializeStationFilters();
}
@override
void dispose() {
_dateController.dispose();
super.dispose();
}
void _initializeStationFilters() {
final auth = Provider.of<AuthProvider>(context, listen: false);
final allStations = auth.manualStations ?? [];
if (allStations.isNotEmpty) {
final states = allStations.map((s) => s['state_name'] as String?).whereType<String>().toSet().toList();
states.sort();
setState(() {
_statesList = states;
});
}
}
Future<void> _selectDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now(),
);
if (picked != null && picked != _selectedDate) {
setState(() {
_selectedDate = picked;
_dateController.text = DateFormat('yyyy-MM-dd').format(_selectedDate!);
});
}
}
Future<void> _searchImages() async {
if (_formKey.currentState!.validate()) {
setState(() {
_isLoading = true;
_imageUrls = [];
_selectedImageUrls.clear();
});
debugPrint("[Image Request] Search button pressed. Starting validation.");
debugPrint("[Image Request] Selected Station: ${_selectedStation}");
debugPrint("[Image Request] Date: ${_selectedDate}");
debugPrint("[Image Request] Sampling Type: $_selectedSamplingType");
if (_selectedStation == null || _selectedDate == null || _selectedSamplingType == null) {
debugPrint("[Image Request] ERROR: Station, date, or sampling type is missing. Aborting search.");
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Error: Station, date, or sampling type is missing.'),
backgroundColor: Colors.red,
),
);
setState(() => _isLoading = false);
}
return;
}
final stationId = _selectedStation!['station_id'];
if (stationId == null) {
debugPrint("[Image Request] ERROR: Station ID is null.");
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Error: Invalid station data.'),
backgroundColor: Colors.red,
),
);
setState(() => _isLoading = false);
}
return;
}
final apiService = Provider.of<ApiService>(context, listen: false);
try {
debugPrint("[Image Request] All checks passed. Calling API with Station ID: $stationId, Type: $_selectedSamplingType");
final result = await apiService.marine.getManualSamplingImages(
stationId: stationId,
samplingDate: _selectedDate!,
samplingType: _selectedSamplingType!,
);
if (mounted && result['success'] == true) {
final List<Map<String, dynamic>> records = List<Map<String, dynamic>>.from(result['data'] ?? []);
final List<String> fetchedUrls = [];
const imageKeys = [
'man_left_side_land_view_path',
'man_right_side_land_view_path',
'man_filling_water_into_sample_bottle_path',
'man_seawater_in_clear_glass_bottle_path',
'man_examine_preservative_ph_paper_path',
'man_optional_photo_01_path',
'man_optional_photo_02_path',
'man_optional_photo_03_path',
'man_optional_photo_04_path',
];
for (final record in records) {
for (final key in imageKeys) {
if (record[key] != null && (record[key] as String).isNotEmpty) {
final String imagePathFromServer = record[key];
final fullUrl = ApiService.imageBaseUrl + imagePathFromServer;
fetchedUrls.add(fullUrl);
debugPrint("[Image Request] Found and constructed URL: $fullUrl");
}
}
}
setState(() {
_imageUrls = fetchedUrls;
});
debugPrint("[Image Request] Successfully processed and constructed ${_imageUrls.length} image URLs.");
} else if (mounted) {
debugPrint("[Image Request] API call failed. Message: ${result['message']}");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result['message'] ?? 'Failed to fetch images.')),
);
}
} catch (e) {
debugPrint("[Image Request] An exception occurred during API call: $e");
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('An error occurred: $e')),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
} else {
debugPrint("[Image Request] Form validation failed.");
}
}
Future<void> _showEmailDialog() async {
final emailController = TextEditingController();
final dialogFormKey = GlobalKey<FormState>();
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return StatefulBuilder(
builder: (context, setDialogState) {
bool isSending = false;
return AlertDialog(
title: const Text('Send Images via Email'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isSending)
const Padding(
padding: EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(width: 24),
Text("Sending..."),
],
),
)
else
Form(
key: dialogFormKey,
child: TextFormField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Recipient Email Address',
hintText: 'user@example.com',
),
validator: (value) {
if (value == null || value.isEmpty || !RegExp(r'\S+@\S+\.\S+').hasMatch(value)) {
return 'Please enter a valid email address.';
}
return null;
},
),
),
],
),
actions: <Widget>[
TextButton(
onPressed: isSending ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: isSending ? null : () async {
if (dialogFormKey.currentState!.validate()) {
setDialogState(() => isSending = true);
await _sendEmailRequestToServer(emailController.text);
if (mounted) Navigator.of(context).pop();
}
},
child: const Text('Send'),
),
],
);
},
);
},
);
}
Future<void> _sendEmailRequestToServer(String toEmail) async {
final apiService = Provider.of<ApiService>(context, listen: false);
try {
debugPrint("[Image Request] Sending email request to server for recipient: $toEmail");
final stationCode = _selectedStation?['man_station_code'] ?? 'N/A';
final stationName = _selectedStation?['man_station_name'] ?? 'N/A';
final fullStationIdentifier = '$stationCode - $stationName';
final result = await apiService.marine.sendImageRequestEmail(
recipientEmail: toEmail,
imageUrls: _selectedImageUrls.toList(),
stationName: fullStationIdentifier,
samplingDate: _dateController.text,
);
if(mounted) {
if (result['success'] == true) {
debugPrint("[Image Request] Server responded with success for email request.");
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Success! Email is being sent by the server.'),
backgroundColor: Colors.green,
),
);
} else {
debugPrint("[Image Request] Server responded with failure for email request. Message: ${result['message']}");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: ${result['message']}'),
backgroundColor: Colors.red,
),
);
}
}
} catch (e) {
debugPrint("[Image Request] An exception occurred while sending email request: $e");
if(mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('An error occurred: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Marine Image Request")),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24.0),
children: [
Text("Image Search Filters", style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 24),
DropdownButtonFormField<String>(
value: _selectedSamplingType,
items: _samplingTypes.map((type) => DropdownMenuItem(value: type, child: Text(type))).toList(),
onChanged: (value) => setState(() => _selectedSamplingType = value),
decoration: const InputDecoration(labelText: 'Sampling Type *', border: OutlineInputBorder()),
validator: (value) => value == null ? 'Please select a type' : null,
),
const SizedBox(height: 16),
DropdownSearch<String>(
items: _statesList,
selectedItem: _selectedStateName,
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *", border: OutlineInputBorder())),
onChanged: (state) {
setState(() {
_selectedStateName = state;
_selectedCategoryName = null;
_selectedStation = null;
final auth = Provider.of<AuthProvider>(context, listen: false);
final allStations = auth.manualStations ?? [];
final categories = state != null ? allStations.where((s) => s['state_name'] == state).map((s) => s['category_name'] as String?).whereType<String>().toSet().toList() : <String>[];
categories.sort();
_categoriesForState = categories;
_stationsForCategory = [];
});
},
validator: (val) => val == null ? "State is required" : null,
),
const SizedBox(height: 16),
DropdownSearch<String>(
items: _categoriesForState,
selectedItem: _selectedCategoryName,
enabled: _selectedStateName != null,
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Category..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Category *", border: OutlineInputBorder())),
onChanged: (category) {
setState(() {
_selectedCategoryName = category;
_selectedStation = null;
final auth = Provider.of<AuthProvider>(context, listen: false);
final allStations = auth.manualStations ?? [];
_stationsForCategory = category != null ? (allStations.where((s) => s['state_name'] == _selectedStateName && s['category_name'] == category).toList()..sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? ''))) : [];
});
},
validator: (val) => _selectedStateName != null && val == null ? "Category is required" : null,
),
const SizedBox(height: 16),
DropdownSearch<Map<String, dynamic>>(
items: _stationsForCategory,
selectedItem: _selectedStation,
enabled: _selectedCategoryName != null,
itemAsString: (station) => "${station['man_station_code']} - ${station['man_station_name']}",
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Station..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Station *", border: OutlineInputBorder())),
onChanged: (station) => setState(() => _selectedStation = station),
validator: (val) => _selectedCategoryName != null && val == null ? "Station is required" : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _dateController,
readOnly: true,
decoration: InputDecoration(
labelText: 'Select Date *',
hintText: 'Tap to pick a date',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.calendar_today),
onPressed: _selectDate,
),
),
onTap: _selectDate,
validator: (val) => val == null || val.isEmpty ? "Date is required" : null,
),
const SizedBox(height: 32),
ElevatedButton.icon(
icon: const Icon(Icons.search),
label: const Text('Search Images'),
onPressed: _isLoading ? null : _searchImages,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 24),
const Divider(thickness: 1),
const SizedBox(height: 16),
Text("Results", style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 16),
_buildResults(),
if (_selectedImageUrls.isNotEmpty) ...[
const SizedBox(height: 24),
ElevatedButton.icon(
icon: const Icon(Icons.email_outlined),
label: Text('Send (${_selectedImageUrls.length}) Selected Image(s)'),
onPressed: _showEmailDialog,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.secondary,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
],
],
),
),
);
}
Widget _buildResults() {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_imageUrls.isEmpty) {
return const Center(
child: Text(
'No images found. Please adjust your filters and search again.',
textAlign: TextAlign.center,
),
);
}
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.0,
),
itemCount: _imageUrls.length,
itemBuilder: (context, index) {
final imageUrl = _imageUrls[index];
final isSelected = _selectedImageUrls.contains(imageUrl);
return GestureDetector(
onTap: () {
setState(() {
if (isSelected) {
_selectedImageUrls.remove(imageUrl);
} else {
_selectedImageUrls.add(imageUrl);
}
});
},
child: Card(
clipBehavior: Clip.antiAlias,
elevation: 2.0,
child: GridTile(
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
imageUrl,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
},
errorBuilder: (context, error, stackTrace) {
return const Icon(Icons.broken_image, color: Colors.grey, size: 40);
},
),
if (isSelected)
Container(
color: Colors.black.withOpacity(0.6),
child: const Icon(Icons.check_circle, color: Colors.white, size: 40),
),
],
),
),
),
);
},
);
}
}

View File

@ -262,6 +262,12 @@ class _InSituStep1SamplingInfoState extends State<InSituStep1SamplingInfo> {
_stationLatController.text = widget.data.stationLatitude ?? ''; _stationLatController.text = widget.data.stationLatitude ?? '';
_stationLonController.text = widget.data.stationLongitude ?? ''; _stationLonController.text = widget.data.stationLongitude ?? '';
// --- START DEBUG ---
debugPrint("--- Nearby Station Selected in Step 1 ---");
debugPrint("Selected Station Data: $station");
debugPrint("Selected Station ID (man_station_id): ${station['man_station_id']}");
// --- END DEBUG ---
// Recalculate distance // Recalculate distance
_calculateDistance(); _calculateDistance();
}); });
@ -459,6 +465,17 @@ class _InSituStep1SamplingInfoState extends State<InSituStep1SamplingInfo> {
widget.data.stationLongitude = station?['man_longitude']?.toString(); widget.data.stationLongitude = station?['man_longitude']?.toString();
_stationLatController.text = widget.data.stationLatitude ?? ''; _stationLatController.text = widget.data.stationLatitude ?? '';
_stationLonController.text = widget.data.stationLongitude ?? ''; _stationLonController.text = widget.data.stationLongitude ?? '';
// --- START DEBUG ---
if (station != null) {
debugPrint("--- Station Selected in Step 1 ---");
debugPrint("Selected Station Data: $station");
debugPrint("Selected Station ID (man_station_id): ${station['man_station_id']}");
} else {
debugPrint("--- Station Deselected in Step 1 ---");
}
// --- END DEBUG ---
_calculateDistance(); _calculateDistance();
}), }),
validator: (val) => widget.data.selectedCategoryName != null && val == null ? "Station is required" : null, validator: (val) => widget.data.selectedCategoryName != null && val == null ? "Station is required" : null,

View File

@ -315,11 +315,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
final currentReadings = _captureReadingsToMap(); final currentReadings = _captureReadingsToMap();
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
// --- START: MODIFICATION ---
// The `parameterLimits` getter was removed from AuthProvider.
// This now correctly uses the new `marineParameterLimits` getter.
final marineLimits = authProvider.marineParameterLimits ?? []; final marineLimits = authProvider.marineParameterLimits ?? [];
// --- END: MODIFICATION ---
final outOfBoundsParams = _validateParameters(currentReadings, marineLimits); final outOfBoundsParams = _validateParameters(currentReadings, marineLimits);
setState(() { setState(() {
@ -345,6 +341,19 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
List<Map<String, dynamic>> _validateParameters(Map<String, double> readings, List<Map<String, dynamic>> limits) { List<Map<String, dynamic>> _validateParameters(Map<String, double> readings, List<Map<String, dynamic>> limits) {
final List<Map<String, dynamic>> invalidParams = []; final List<Map<String, dynamic>> invalidParams = [];
final int? stationId = widget.data.selectedStation?['station_id'];
debugPrint("--- Parameter Validation Start ---");
debugPrint("Selected Station ID: $stationId");
debugPrint("Total Marine Limits Loaded: ${limits.length}");
// --- START DEBUG: Add type inspection ---
if (limits.isNotEmpty && stationId != null) {
debugPrint("Inspecting the first loaded limit record: ${limits.first}");
debugPrint("Type of Selected Station ID ($stationId): ${stationId.runtimeType}");
debugPrint("Type of man_station_id in first limit record: ${limits.first['man_station_id']?.runtimeType}");
}
// --- END DEBUG ---
double? _parseLimitValue(dynamic value) { double? _parseLimitValue(dynamic value) {
if (value == null) return null; if (value == null) return null;
@ -359,10 +368,24 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
final limitName = _parameterKeyToLimitName[key]; final limitName = _parameterKeyToLimitName[key];
if (limitName == null) return; if (limitName == null) return;
final limitData = limits.firstWhere( debugPrint("Checking parameter: '$limitName' (key: '$key')");
(l) => l['param_parameter_list'] == limitName,
orElse: () => {}, Map<String, dynamic> limitData = {};
);
if (stationId != null) {
// --- START FIX: Use type-safe comparison ---
limitData = limits.firstWhere(
(l) => l['param_parameter_list'] == limitName && l['station_id']?.toString() == stationId.toString(),
orElse: () => {},
);
// --- END FIX ---
}
if (limitData.isNotEmpty) {
debugPrint(" > Found station-specific limit for Station ID $stationId: $limitData");
} else {
debugPrint(" > No station-specific limit found for Station ID $stationId. Skipping check for this parameter.");
}
if (limitData.isNotEmpty) { if (limitData.isNotEmpty) {
final lowerLimit = _parseLimitValue(limitData['param_lower_limit']); final lowerLimit = _parseLimitValue(limitData['param_lower_limit']);
@ -379,6 +402,9 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
} }
} }
}); });
debugPrint("--- Parameter Validation End ---");
return invalidParams; return invalidParams;
} }

View File

@ -39,12 +39,9 @@ class InSituStep4Summary extends StatelessWidget {
/// Re-validates the final parameters against the defined limits. /// Re-validates the final parameters against the defined limits.
Set<String> _getOutOfBoundsKeys(BuildContext context) { Set<String> _getOutOfBoundsKeys(BuildContext context) {
final authProvider = Provider.of<AuthProvider>(context, listen: false); final authProvider = Provider.of<AuthProvider>(context, listen: false);
// --- START MODIFICATION ---
// The `parameterLimits` getter was removed from AuthProvider.
// This now correctly uses the new `marineParameterLimits` getter.
final marineLimits = authProvider.marineParameterLimits ?? []; final marineLimits = authProvider.marineParameterLimits ?? [];
// --- END MODIFICATION ---
final Set<String> invalidKeys = {}; final Set<String> invalidKeys = {};
final int? stationId = data.selectedStation?['station_id'];
final readings = { final readings = {
'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation, 'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation,
@ -66,7 +63,16 @@ class InSituStep4Summary extends StatelessWidget {
final limitName = _parameterKeyToLimitName[key]; final limitName = _parameterKeyToLimitName[key];
if (limitName == null) return; if (limitName == null) return;
final limitData = marineLimits.firstWhere((l) => l['param_parameter_list'] == limitName, orElse: () => {}); // START MODIFICATION: Only check for station-specific limits
Map<String, dynamic> limitData = {};
if (stationId != null) {
limitData = marineLimits.firstWhere(
(l) => l['param_parameter_list'] == limitName && l['station_id'] == stationId,
orElse: () => {},
);
}
// END MODIFICATION
if (limitData.isNotEmpty) { if (limitData.isNotEmpty) {
final lowerLimit = parseLimitValue(limitData['param_lower_limit']); final lowerLimit = parseLimitValue(limitData['param_lower_limit']);

View File

@ -37,7 +37,7 @@ class MarineHomePage extends StatelessWidget {
SidebarItem(icon: Icons.waves, label: "Tarball Sampling", route: '/marine/manual/tarball'), SidebarItem(icon: Icons.waves, label: "Tarball Sampling", route: '/marine/manual/tarball'),
SidebarItem(icon: Icons.article, label: "Data Log", route: '/marine/manual/data-log'), SidebarItem(icon: Icons.article, label: "Data Log", route: '/marine/manual/data-log'),
//SidebarItem(icon: Icons.image, label: "Image Request", route: '/marine/manual/image-request'), SidebarItem(icon: Icons.image, label: "Image Request", route: '/marine/manual/image-request'),
//SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/manual/report'), //SidebarItem(icon: Icons.receipt_long, label: "Report", route: '/marine/manual/report'),
], ],
), ),

View File

@ -34,10 +34,11 @@ class ApiService {
static const String imageBaseUrl = 'https://mms-apiv4.pstw.com.my/'; static const String imageBaseUrl = 'https://mms-apiv4.pstw.com.my/';
ApiService({required TelegramService telegramService}) { ApiService({required TelegramService telegramService}) {
marine = MarineApiService(_baseService, telegramService, _serverConfigService); marine = MarineApiService(_baseService, telegramService, _serverConfigService, dbHelper);
river = RiverApiService(_baseService, telegramService, _serverConfigService); river = RiverApiService(_baseService, telegramService, _serverConfigService, dbHelper);
air = AirApiService(_baseService, telegramService, _serverConfigService); air = AirApiService(_baseService, telegramService, _serverConfigService);
} }
// --- END: FIX FOR CONSTRUCTOR ERROR ---
// --- Core API Methods --- // --- Core API Methods ---
@ -470,8 +471,63 @@ class MarineApiService {
final BaseApiService _baseService; final BaseApiService _baseService;
final TelegramService _telegramService; final TelegramService _telegramService;
final ServerConfigService _serverConfigService; final ServerConfigService _serverConfigService;
final DatabaseHelper _dbHelper;
MarineApiService(this._baseService, this._telegramService, this._serverConfigService); MarineApiService(this._baseService, this._telegramService, this._serverConfigService, this._dbHelper);
Future<Map<String, dynamic>> sendImageRequestEmail({
required String recipientEmail,
required List<String> imageUrls,
required String stationName,
required String samplingDate,
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
final Map<String, String> fields = {
'recipientEmail': recipientEmail,
'imageUrls': jsonEncode(imageUrls),
'stationName': stationName,
'samplingDate': samplingDate,
};
return _baseService.postMultipart(
baseUrl: baseUrl,
endpoint: 'marine/images/send-email',
fields: fields,
files: {},
);
}
// --- START: FIX - Replaced mock with a real API call ---
Future<Map<String, dynamic>> getManualSamplingImages({
required int stationId,
required DateTime samplingDate,
required String samplingType,
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate);
final String endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr';
debugPrint("ApiService: Calling real API endpoint: $endpoint");
final response = await _baseService.get(baseUrl, endpoint);
// The backend now returns a root 'data' key which the base service handles.
// However, the PHP controller wraps the results again in a 'data' key inside the main data object.
// We need to extract this nested list.
if (response['success'] == true && response['data'] is Map && response['data']['data'] is List) {
return {
'success': true,
'data': response['data']['data'],
'message': response['message'],
};
}
// Return the original response if the structure isn't as expected, or if it's an error.
return response;
}
// --- END: FIX ---
Future<Map<String, dynamic>> getTarballStations() async { Future<Map<String, dynamic>> getTarballStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
@ -567,7 +623,7 @@ class MarineApiService {
Future<void> _handleInSituSuccessAlert(InSituSamplingData data, Future<void> _handleInSituSuccessAlert(InSituSamplingData data,
List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async { List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try { try {
final message = data.generateTelegramAlertMessage(isDataOnly: isDataOnly); final message = await _generateInSituAlertMessage(data, isDataOnly: isDataOnly);
final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings); final bool wasSent = await _telegramService.sendAlertImmediately('marine_in_situ', message, appSettings);
if (!wasSent) { if (!wasSent) {
await _telegramService.queueMessage('marine_in_situ', message, appSettings); await _telegramService.queueMessage('marine_in_situ', message, appSettings);
@ -577,6 +633,111 @@ class MarineApiService {
} }
} }
Future<String> _generateInSituAlertMessage(InSituSamplingData data, {required bool isDataOnly}) async {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = data.selectedStation?['man_station_name'] ?? 'N/A';
final stationCode = data.selectedStation?['man_station_code'] ?? 'N/A';
final distanceKm = data.distanceDifferenceInKm ?? 0;
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
final distanceRemarks = data.distanceDifferenceRemarks ?? 'N/A';
final buffer = StringBuffer()
..writeln('✅ *Marine In-Situ Sample ${submissionType} Submitted:*')
..writeln()
..writeln('*Station Name & Code:* $stationName ($stationCode)')
..writeln('*Date of Submitted:* ${data.samplingDate}')
..writeln('*Submitted by User:* ${data.firstSamplerName}')
..writeln('*Sonde ID:* ${data.sondeId ?? 'N/A'}')
..writeln('*Status of Submission:* Successful');
if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) {
buffer
..writeln()
..writeln('🔔 *Distance Alert:*')
..writeln('*Distance from station:* $distanceMeters meters');
if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
buffer.writeln('*Remarks for distance:* $distanceRemarks');
}
}
final outOfBoundsAlert = await _getOutOfBoundsAlertSection(data);
if (outOfBoundsAlert.isNotEmpty) {
buffer.write(outOfBoundsAlert);
}
return buffer.toString();
}
Future<String> _getOutOfBoundsAlertSection(InSituSamplingData data) async {
const Map<String, String> _parameterKeyToLimitName = {
'oxygenConcentration': 'Oxygen Conc', 'oxygenSaturation': 'Oxygen Sat', 'ph': 'pH',
'salinity': 'Salinity', 'electricalConductivity': 'Conductivity', 'temperature': 'Temperature',
'tds': 'TDS', 'turbidity': 'Turbidity', 'tss': 'TSS', 'batteryVoltage': 'Battery',
};
final allLimits = await _dbHelper.loadMarineParameterLimits() ?? [];
if (allLimits.isEmpty) return "";
final int? stationId = data.selectedStation?['station_id'];
final readings = {
'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation,
'ph': data.ph, 'salinity': data.salinity, 'electricalConductivity': data.electricalConductivity,
'temperature': data.temperature, 'tds': data.tds, 'turbidity': data.turbidity,
'tss': data.tss, 'batteryVoltage': data.batteryVoltage,
};
final List<String> outOfBoundsMessages = [];
double? parseLimitValue(dynamic value) {
if (value == null) return null;
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
readings.forEach((key, value) {
if (value == null || value == -999.0) return;
final limitName = _parameterKeyToLimitName[key];
if (limitName == null) return;
// START MODIFICATION: Only check for station-specific limits
Map<String, dynamic> limitData = {};
if (stationId != null) {
limitData = allLimits.firstWhere(
(l) => l['param_parameter_list'] == limitName && l['station_id'] == stationId,
orElse: () => {},
);
}
// END MODIFICATION
if (limitData.isNotEmpty) {
final lowerLimit = parseLimitValue(limitData['param_lower_limit']);
final upperLimit = parseLimitValue(limitData['param_upper_limit']);
if ((lowerLimit != null && value < lowerLimit) || (upperLimit != null && value > upperLimit)) {
final valueStr = value.toStringAsFixed(5);
final lowerStr = lowerLimit?.toStringAsFixed(5) ?? 'N/A';
final upperStr = upperLimit?.toStringAsFixed(5) ?? 'N/A';
outOfBoundsMessages.add('- *$limitName*: `$valueStr` (Limit: `$lowerStr` - `$upperStr`)');
}
}
});
if (outOfBoundsMessages.isEmpty) {
return "";
}
final buffer = StringBuffer()
..writeln()
..writeln('⚠️ *Parameter Limit Alert:*')
..writeln('The following parameters were outside their defined limits:');
buffer.writeAll(outOfBoundsMessages, '\n');
return buffer.toString();
}
Future<Map<String, dynamic>> submitTarballSample({ Future<Map<String, dynamic>> submitTarballSample({
required Map<String, String> formData, required Map<String, String> formData,
required Map<String, File?> imageFiles, required Map<String, File?> imageFiles,
@ -666,8 +827,9 @@ class RiverApiService {
final BaseApiService _baseService; final BaseApiService _baseService;
final TelegramService _telegramService; final TelegramService _telegramService;
final ServerConfigService _serverConfigService; final ServerConfigService _serverConfigService;
final DatabaseHelper _dbHelper;
RiverApiService(this._baseService, this._telegramService, this._serverConfigService); RiverApiService(this._baseService, this._telegramService, this._serverConfigService, this._dbHelper);
Future<Map<String, dynamic>> getManualStations() async { Future<Map<String, dynamic>> getManualStations() async {
final baseUrl = await _serverConfigService.getActiveApiUrl(); final baseUrl = await _serverConfigService.getActiveApiUrl();
@ -749,37 +911,7 @@ class RiverApiService {
Future<void> _handleInSituSuccessAlert( Future<void> _handleInSituSuccessAlert(
Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async { Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
try { try {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)"; final String message = await _generateInSituAlertMessage(formData, isDataOnly: isDataOnly);
final stationName = formData['r_man_station_name'] ?? 'N/A';
final stationCode = formData['r_man_station_code'] ?? 'N/A';
final submissionDate = formData['r_man_date'] ?? DateFormat('yyyy-MM-dd').format(DateTime.now());
final submitter = formData['first_sampler_name'] ?? 'N/A';
final sondeID = formData['r_man_sondeID'] ?? 'N/A';
final distanceKm = double.tryParse(formData['r_man_distance_difference'] ?? '0') ?? 0;
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
final distanceRemarks = formData['r_man_distance_difference_remarks'] ?? 'N/A';
final buffer = StringBuffer()
..writeln('✅ *River In-Situ Sample ${submissionType} Submitted:*')
..writeln()
..writeln('*Station Name & Code:* $stationName ($stationCode)')
..writeln('*Date of Submitted:* $submissionDate')
..writeln('*Submitted by User:* $submitter')
..writeln('*Sonde ID:* $sondeID')
..writeln('*Status of Submission:* Successful');
if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) {
buffer
..writeln()
..writeln('🔔 *Alert:*')
..writeln('*Distance from station:* $distanceMeters meters');
if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
buffer.writeln('*Remarks for distance:* $distanceRemarks');
}
}
final String message = buffer.toString();
final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings); final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings);
if (!wasSent) { if (!wasSent) {
await _telegramService.queueMessage('river_in_situ', message, appSettings); await _telegramService.queueMessage('river_in_situ', message, appSettings);
@ -788,6 +920,114 @@ class RiverApiService {
debugPrint("Failed to handle River Telegram alert: $e"); debugPrint("Failed to handle River Telegram alert: $e");
} }
} }
Future<String> _generateInSituAlertMessage(Map<String, String> formData, {required bool isDataOnly}) async {
final submissionType = isDataOnly ? "(Data Only)" : "(Data & Images)";
final stationName = formData['r_man_station_name'] ?? 'N/A';
final stationCode = formData['r_man_station_code'] ?? 'N/A';
final submissionDate = formData['r_man_date'] ?? DateFormat('yyyy-MM-dd').format(DateTime.now());
final submitter = formData['first_sampler_name'] ?? 'N/A';
final sondeID = formData['r_man_sondeID'] ?? 'N/A';
final distanceKm = double.tryParse(formData['r_man_distance_difference'] ?? '0') ?? 0;
final distanceMeters = (distanceKm * 1000).toStringAsFixed(0);
final distanceRemarks = formData['r_man_distance_difference_remarks'] ?? 'N/A';
final buffer = StringBuffer()
..writeln('✅ *River In-Situ Sample ${submissionType} Submitted:*')
..writeln()
..writeln('*Station Name & Code:* $stationName ($stationCode)')
..writeln('*Date of Submitted:* $submissionDate')
..writeln('*Submitted by User:* $submitter')
..writeln('*Sonde ID:* $sondeID')
..writeln('*Status of Submission:* Successful');
if (distanceKm > 0 || (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A')) {
buffer
..writeln()
..writeln('🔔 *Distance Alert:*')
..writeln('*Distance from station:* $distanceMeters meters');
if (distanceRemarks.isNotEmpty && distanceRemarks != 'N/A') {
buffer.writeln('*Remarks for distance:* $distanceRemarks');
}
}
final outOfBoundsAlert = await _getOutOfBoundsAlertSection(formData);
if (outOfBoundsAlert.isNotEmpty) {
buffer.write(outOfBoundsAlert);
}
return buffer.toString();
}
Future<String> _getOutOfBoundsAlertSection(Map<String, String> formData) async {
const Map<String, String> _formKeyToLimitName = {
'r_man_ph': 'pH',
'r_man_temperature': 'Temperature',
'r_man_dissolved_oxygen': 'Dissolved Oxygen',
'r_man_conductivity': 'Conductivity',
'r_man_salinity': 'Salinity',
'r_man_turbidity': 'Turbidity',
'r_man_tds': 'TDS',
'r_man_sonde_battery': 'Sonde Battery',
};
final allLimits = await _dbHelper.loadRiverParameterLimits() ?? [];
if (allLimits.isEmpty) return "";
final int? stationId = int.tryParse(formData['r_man_station_id'] ?? '');
final List<String> outOfBoundsMessages = [];
double? parseLimitValue(dynamic value) {
if (value == null) return null;
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
formData.forEach((key, valueStr) {
final double? value = double.tryParse(valueStr);
if (value == null || value == -999.0) return;
final limitName = _formKeyToLimitName[key];
if (limitName == null) return;
Map<String, dynamic> limitData = {};
if (stationId != null) {
limitData = allLimits.firstWhere(
(l) => l['param_parameter_list'] == limitName && l['r_man_station_id'] == stationId,
orElse: () => {},
);
}
if (limitData.isEmpty) {
limitData = allLimits.firstWhere(
(l) => l['param_parameter_list'] == limitName && l['r_man_station_id'] == null,
orElse: () => {},
);
}
if (limitData.isNotEmpty) {
final lowerLimit = parseLimitValue(limitData['param_lower_limit']);
final upperLimit = parseLimitValue(limitData['param_upper_limit']);
if ((lowerLimit != null && value < lowerLimit) || (upperLimit != null && value > upperLimit)) {
final valueFmt = value.toStringAsFixed(5);
final lowerFmt = lowerLimit?.toStringAsFixed(5) ?? 'N/A';
final upperFmt = upperLimit?.toStringAsFixed(5) ?? 'N/A';
outOfBoundsMessages.add('- *$limitName*: `$valueFmt` (Limit: `$lowerFmt` - `$upperFmt`)');
}
}
});
if (outOfBoundsMessages.isEmpty) return "";
final buffer = StringBuffer()
..writeln()
..writeln('⚠️ *Parameter Limit Alert:*')
..writeln('The following parameters were outside their defined limits:');
buffer.writeAll(outOfBoundsMessages, '\n');
return buffer.toString();
}
} }
// ======================================================================= // =======================================================================
@ -797,10 +1037,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';
// --- START: INCREMENTED DB VERSION ---
static const int _dbVersion = 23; static const int _dbVersion = 23;
// --- END: INCREMENTED DB VERSION ---
static const String _profileTable = 'user_profile'; static const String _profileTable = 'user_profile';
static const String _usersTable = 'all_users'; static const String _usersTable = 'all_users';
static const String _tarballStationsTable = 'marine_tarball_stations'; static const String _tarballStationsTable = 'marine_tarball_stations';
@ -817,11 +1054,9 @@ class DatabaseHelper {
static const String _statesTable = 'states'; static const String _statesTable = 'states';
static const String _appSettingsTable = 'app_settings'; static const String _appSettingsTable = 'app_settings';
static const String _parameterLimitsTable = 'manual_parameter_limits'; static const String _parameterLimitsTable = 'manual_parameter_limits';
// --- START: ADDED NEW TABLE CONSTANTS ---
static const String _npeParameterLimitsTable = 'npe_parameter_limits'; static const String _npeParameterLimitsTable = 'npe_parameter_limits';
static const String _marineParameterLimitsTable = 'marine_parameter_limits'; static const String _marineParameterLimitsTable = 'marine_parameter_limits';
static const String _riverParameterLimitsTable = 'river_parameter_limits'; static const String _riverParameterLimitsTable = 'river_parameter_limits';
// --- END: ADDED NEW TABLE CONSTANTS ---
static const String _apiConfigsTable = 'api_configurations'; static const String _apiConfigsTable = 'api_configurations';
static const String _ftpConfigsTable = 'ftp_configurations'; static const String _ftpConfigsTable = 'ftp_configurations';
static const String _retryQueueTable = 'retry_queue'; static const String _retryQueueTable = 'retry_queue';
@ -867,11 +1102,9 @@ class DatabaseHelper {
await db.execute('CREATE TABLE $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)'); await db.execute('CREATE TABLE $_statesTable(state_id INTEGER PRIMARY KEY, state_json TEXT)');
await db.execute('CREATE TABLE $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)'); await db.execute('CREATE TABLE $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_json TEXT)');
await db.execute('CREATE TABLE $_parameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); await db.execute('CREATE TABLE $_parameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)');
// --- START: ADDED CREATE TABLE FOR NEW LIMITS ---
await db.execute('CREATE TABLE $_npeParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); await db.execute('CREATE TABLE $_npeParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)');
await db.execute('CREATE TABLE $_marineParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); await db.execute('CREATE TABLE $_marineParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)');
await db.execute('CREATE TABLE $_riverParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); await db.execute('CREATE TABLE $_riverParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)');
// --- END: ADDED CREATE TABLE FOR NEW LIMITS ---
await db.execute('CREATE TABLE $_apiConfigsTable(api_config_id INTEGER PRIMARY KEY, config_json TEXT)'); await db.execute('CREATE TABLE $_apiConfigsTable(api_config_id INTEGER PRIMARY KEY, config_json TEXT)');
await db.execute('CREATE TABLE $_ftpConfigsTable(ftp_config_id INTEGER PRIMARY KEY, config_json TEXT)'); await db.execute('CREATE TABLE $_ftpConfigsTable(ftp_config_id INTEGER PRIMARY KEY, config_json TEXT)');
await db.execute(''' await db.execute('''
@ -1015,16 +1248,13 @@ class DatabaseHelper {
debugPrint("Upgrade warning: Failed to add password_hash column to users table (may already exist): $e"); debugPrint("Upgrade warning: Failed to add password_hash column to users table (may already exist): $e");
} }
} }
// --- START: ADDED UPGRADE LOGIC FOR NEW TABLES ---
if (oldVersion < 23) { if (oldVersion < 23) {
await db.execute('CREATE TABLE IF NOT EXISTS $_npeParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); await db.execute('CREATE TABLE IF NOT EXISTS $_npeParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)');
await db.execute('CREATE TABLE IF NOT EXISTS $_marineParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); await db.execute('CREATE TABLE IF NOT EXISTS $_marineParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)');
await db.execute('CREATE TABLE IF NOT EXISTS $_riverParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)'); await db.execute('CREATE TABLE IF NOT EXISTS $_riverParameterLimitsTable(param_autoid INTEGER PRIMARY KEY, limit_json TEXT)');
} }
// --- END: ADDED UPGRADE LOGIC FOR NEW TABLES ---
} }
/// Performs an "upsert": inserts new records or replaces existing ones.
Future<void> _upsertData(String table, String idKeyName, List<Map<String, dynamic>> data, String jsonKeyName) async { Future<void> _upsertData(String table, String idKeyName, List<Map<String, dynamic>> data, String jsonKeyName) async {
if (data.isEmpty) return; if (data.isEmpty) return;
final db = await database; final db = await database;
@ -1040,7 +1270,6 @@ class DatabaseHelper {
debugPrint("Upserted ${data.length} items into $table"); debugPrint("Upserted ${data.length} items into $table");
} }
/// Deletes a list of records from a table by their primary keys.
Future<void> _deleteData(String table, String idKeyName, List<dynamic> ids) async { Future<void> _deleteData(String table, String idKeyName, List<dynamic> ids) async {
if (ids.isEmpty) return; if (ids.isEmpty) return;
final db = await database; final db = await database;
@ -1075,9 +1304,6 @@ class DatabaseHelper {
return null; return null;
} }
// --- START: Offline Authentication and User Upsert Methods ---
/// Retrieves a user's profile JSON from the users table by email.
Future<Map<String, dynamic>?> loadProfileByEmail(String email) async { Future<Map<String, dynamic>?> loadProfileByEmail(String email) async {
final db = await database; final db = await database;
final List<Map<String, dynamic>> maps = await db.query( final List<Map<String, dynamic>> maps = await db.query(
@ -1097,8 +1323,6 @@ class DatabaseHelper {
return null; return null;
} }
/// Inserts or replaces a user's profile and credentials.
/// This ensures the record exists when caching credentials during login.
Future<void> upsertUserWithCredentials({ Future<void> upsertUserWithCredentials({
required Map<String, dynamic> profile, required Map<String, dynamic> profile,
required String passwordHash, required String passwordHash,
@ -1117,7 +1341,6 @@ class DatabaseHelper {
debugPrint("Upserted user credentials for ${profile['email']}"); debugPrint("Upserted user credentials for ${profile['email']}");
} }
/// Retrieves the stored password hash for a user by email.
Future<String?> getUserPasswordHashByEmail(String email) async { Future<String?> getUserPasswordHashByEmail(String email) async {
final db = await database; final db = await database;
final List<Map<String, dynamic>> result = await db.query( final List<Map<String, dynamic>> result = await db.query(
@ -1132,18 +1355,14 @@ class DatabaseHelper {
return null; return null;
} }
// --- START: Custom upsert method for user sync to prevent hash overwrite ---
/// Upserts user data from sync without overwriting the local password hash.
Future<void> upsertUsers(List<Map<String, dynamic>> data) async { Future<void> upsertUsers(List<Map<String, dynamic>> data) async {
if (data.isEmpty) return; if (data.isEmpty) return;
final db = await database; final db = await database;
for (var item in data) { for (var item in data) {
final updateData = { final updateData = {
//'email': item['email'],
'user_json': jsonEncode(item), 'user_json': jsonEncode(item),
}; };
// Try to update existing record first, preserving other columns like password_hash
int count = await db.update( int count = await db.update(
_usersTable, _usersTable,
updateData, updateData,
@ -1151,7 +1370,6 @@ class DatabaseHelper {
whereArgs: [item['user_id']], whereArgs: [item['user_id']],
); );
// If no record was updated (count == 0), insert a new record.
if (count == 0) { if (count == 0) {
await db.insert( await db.insert(
_usersTable, _usersTable,
@ -1159,7 +1377,6 @@ class DatabaseHelper {
'user_id': item['user_id'], 'user_id': item['user_id'],
'email': item['email'], 'email': item['email'],
'user_json': jsonEncode(item), 'user_json': jsonEncode(item),
// password_hash will be null for a new user until they log in on this device.
}, },
conflictAlgorithm: ConflictAlgorithm.ignore, conflictAlgorithm: ConflictAlgorithm.ignore,
); );
@ -1167,7 +1384,6 @@ class DatabaseHelper {
} }
debugPrint("Upserted ${data.length} user items in custom upsert method."); debugPrint("Upserted ${data.length} user items in custom upsert method.");
} }
// --- END: Custom upsert method ---
Future<void> deleteUsers(List<dynamic> ids) => _deleteData(_usersTable, 'user_id', ids); Future<void> deleteUsers(List<dynamic> ids) => _deleteData(_usersTable, 'user_id', ids);
Future<List<Map<String, dynamic>>?> loadUsers() => _loadData(_usersTable, 'user'); Future<List<Map<String, dynamic>>?> loadUsers() => _loadData(_usersTable, 'user');
@ -1234,7 +1450,6 @@ class DatabaseHelper {
Future<void> deleteParameterLimits(List<dynamic> ids) => _deleteData(_parameterLimitsTable, 'param_autoid', ids); Future<void> deleteParameterLimits(List<dynamic> ids) => _deleteData(_parameterLimitsTable, 'param_autoid', ids);
Future<List<Map<String, dynamic>>?> loadParameterLimits() => _loadData(_parameterLimitsTable, 'limit'); Future<List<Map<String, dynamic>>?> loadParameterLimits() => _loadData(_parameterLimitsTable, 'limit');
// --- START: ADDED NEW DB METHODS FOR PARAMETER LIMITS ---
Future<void> upsertNpeParameterLimits(List<Map<String, dynamic>> data) => _upsertData(_npeParameterLimitsTable, 'param_autoid', data, 'limit'); Future<void> upsertNpeParameterLimits(List<Map<String, dynamic>> data) => _upsertData(_npeParameterLimitsTable, 'param_autoid', data, 'limit');
Future<void> deleteNpeParameterLimits(List<dynamic> ids) => _deleteData(_npeParameterLimitsTable, 'param_autoid', ids); Future<void> deleteNpeParameterLimits(List<dynamic> ids) => _deleteData(_npeParameterLimitsTable, 'param_autoid', ids);
Future<List<Map<String, dynamic>>?> loadNpeParameterLimits() => _loadData(_npeParameterLimitsTable, 'limit'); Future<List<Map<String, dynamic>>?> loadNpeParameterLimits() => _loadData(_npeParameterLimitsTable, 'limit');
@ -1246,7 +1461,6 @@ class DatabaseHelper {
Future<void> upsertRiverParameterLimits(List<Map<String, dynamic>> data) => _upsertData(_riverParameterLimitsTable, 'param_autoid', data, 'limit'); Future<void> upsertRiverParameterLimits(List<Map<String, dynamic>> data) => _upsertData(_riverParameterLimitsTable, 'param_autoid', data, 'limit');
Future<void> deleteRiverParameterLimits(List<dynamic> ids) => _deleteData(_riverParameterLimitsTable, 'param_autoid', ids); Future<void> deleteRiverParameterLimits(List<dynamic> ids) => _deleteData(_riverParameterLimitsTable, 'param_autoid', ids);
Future<List<Map<String, dynamic>>?> loadRiverParameterLimits() => _loadData(_riverParameterLimitsTable, 'limit'); Future<List<Map<String, dynamic>>?> loadRiverParameterLimits() => _loadData(_riverParameterLimitsTable, 'limit');
// --- END: ADDED NEW DB METHODS FOR PARAMETER LIMITS ---
Future<void> upsertApiConfigs(List<Map<String, dynamic>> data) => _upsertData(_apiConfigsTable, 'api_config_id', data, 'config'); Future<void> upsertApiConfigs(List<Map<String, dynamic>> data) => _upsertData(_apiConfigsTable, 'api_config_id', data, 'config');
Future<void> deleteApiConfigs(List<dynamic> ids) => _deleteData(_apiConfigsTable, 'api_config_id', ids); Future<void> deleteApiConfigs(List<dynamic> ids) => _deleteData(_apiConfigsTable, 'api_config_id', ids);

View File

@ -405,8 +405,8 @@ class MarineInSituSamplingService {
return { return {
'statuses': <Map<String, dynamic>>[ 'statuses': <Map<String, dynamic>>[
...(ftpDataResult['statuses'] as List), ...(ftpDataResult['statuses'] as List<dynamic>? ?? []),
...(ftpImageResult['statuses'] as List), ...(ftpImageResult['statuses'] as List<dynamic>? ?? []),
], ],
}; };
} }