repair separate department parameter limit for river and marine
This commit is contained in:
parent
18c2bf3ec0
commit
fc14740b01
@ -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/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/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/overview.dart' as marineContinuousOverview;
|
||||
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/report': (context) => marineManualReport.MarineManualReport(),
|
||||
//'/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/info': (context) => const MarineContinuousInfoCentreDocument(),
|
||||
|
||||
@ -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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
493
lib/screens/marine/manual/marine_image_request.dart
Normal file
493
lib/screens/marine/manual/marine_image_request.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -262,6 +262,12 @@ class _InSituStep1SamplingInfoState extends State<InSituStep1SamplingInfo> {
|
||||
_stationLatController.text = widget.data.stationLatitude ?? '';
|
||||
_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
|
||||
_calculateDistance();
|
||||
});
|
||||
@ -459,6 +465,17 @@ class _InSituStep1SamplingInfoState extends State<InSituStep1SamplingInfo> {
|
||||
widget.data.stationLongitude = station?['man_longitude']?.toString();
|
||||
_stationLatController.text = widget.data.stationLatitude ?? '';
|
||||
_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();
|
||||
}),
|
||||
validator: (val) => widget.data.selectedCategoryName != null && val == null ? "Station is required" : null,
|
||||
|
||||
@ -315,11 +315,7 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
|
||||
|
||||
final currentReadings = _captureReadingsToMap();
|
||||
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 ?? [];
|
||||
// --- END: MODIFICATION ---
|
||||
final outOfBoundsParams = _validateParameters(currentReadings, marineLimits);
|
||||
|
||||
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) {
|
||||
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) {
|
||||
if (value == null) return null;
|
||||
@ -359,10 +368,24 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
|
||||
final limitName = _parameterKeyToLimitName[key];
|
||||
if (limitName == null) return;
|
||||
|
||||
final limitData = limits.firstWhere(
|
||||
(l) => l['param_parameter_list'] == limitName,
|
||||
orElse: () => {},
|
||||
);
|
||||
debugPrint("Checking parameter: '$limitName' (key: '$key')");
|
||||
|
||||
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) {
|
||||
final lowerLimit = _parseLimitValue(limitData['param_lower_limit']);
|
||||
@ -379,6 +402,9 @@ class _InSituStep3DataCaptureState extends State<InSituStep3DataCapture> with Wi
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint("--- Parameter Validation End ---");
|
||||
|
||||
return invalidParams;
|
||||
}
|
||||
|
||||
|
||||
@ -39,12 +39,9 @@ class InSituStep4Summary extends StatelessWidget {
|
||||
/// Re-validates the final parameters against the defined limits.
|
||||
Set<String> _getOutOfBoundsKeys(BuildContext context) {
|
||||
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 ?? [];
|
||||
// --- END MODIFICATION ---
|
||||
final Set<String> invalidKeys = {};
|
||||
final int? stationId = data.selectedStation?['station_id'];
|
||||
|
||||
final readings = {
|
||||
'oxygenConcentration': data.oxygenConcentration, 'oxygenSaturation': data.oxygenSaturation,
|
||||
@ -66,7 +63,16 @@ class InSituStep4Summary extends StatelessWidget {
|
||||
final limitName = _parameterKeyToLimitName[key];
|
||||
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) {
|
||||
final lowerLimit = parseLimitValue(limitData['param_lower_limit']);
|
||||
|
||||
@ -37,7 +37,7 @@ class MarineHomePage extends StatelessWidget {
|
||||
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.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'),
|
||||
],
|
||||
),
|
||||
|
||||
@ -34,10 +34,11 @@ class ApiService {
|
||||
static const String imageBaseUrl = 'https://mms-apiv4.pstw.com.my/';
|
||||
|
||||
ApiService({required TelegramService telegramService}) {
|
||||
marine = MarineApiService(_baseService, telegramService, _serverConfigService);
|
||||
river = RiverApiService(_baseService, telegramService, _serverConfigService);
|
||||
marine = MarineApiService(_baseService, telegramService, _serverConfigService, dbHelper);
|
||||
river = RiverApiService(_baseService, telegramService, _serverConfigService, dbHelper);
|
||||
air = AirApiService(_baseService, telegramService, _serverConfigService);
|
||||
}
|
||||
// --- END: FIX FOR CONSTRUCTOR ERROR ---
|
||||
|
||||
// --- Core API Methods ---
|
||||
|
||||
@ -470,8 +471,63 @@ class MarineApiService {
|
||||
final BaseApiService _baseService;
|
||||
final TelegramService _telegramService;
|
||||
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 {
|
||||
final baseUrl = await _serverConfigService.getActiveApiUrl();
|
||||
@ -567,7 +623,7 @@ class MarineApiService {
|
||||
Future<void> _handleInSituSuccessAlert(InSituSamplingData data,
|
||||
List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
|
||||
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);
|
||||
if (!wasSent) {
|
||||
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({
|
||||
required Map<String, String> formData,
|
||||
required Map<String, File?> imageFiles,
|
||||
@ -666,8 +827,9 @@ class RiverApiService {
|
||||
final BaseApiService _baseService;
|
||||
final TelegramService _telegramService;
|
||||
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 {
|
||||
final baseUrl = await _serverConfigService.getActiveApiUrl();
|
||||
@ -749,37 +911,7 @@ class RiverApiService {
|
||||
Future<void> _handleInSituSuccessAlert(
|
||||
Map<String, String> formData, List<Map<String, dynamic>>? appSettings, {required bool isDataOnly}) async {
|
||||
try {
|
||||
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('🔔 *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 String message = await _generateInSituAlertMessage(formData, isDataOnly: isDataOnly);
|
||||
final bool wasSent = await _telegramService.sendAlertImmediately('river_in_situ', message, appSettings);
|
||||
if (!wasSent) {
|
||||
await _telegramService.queueMessage('river_in_situ', message, appSettings);
|
||||
@ -788,6 +920,114 @@ class RiverApiService {
|
||||
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 {
|
||||
static Database? _database;
|
||||
static const String _dbName = 'app_data.db';
|
||||
// --- START: INCREMENTED DB VERSION ---
|
||||
static const int _dbVersion = 23;
|
||||
// --- END: INCREMENTED DB VERSION ---
|
||||
|
||||
static const String _profileTable = 'user_profile';
|
||||
static const String _usersTable = 'all_users';
|
||||
static const String _tarballStationsTable = 'marine_tarball_stations';
|
||||
@ -817,11 +1054,9 @@ class DatabaseHelper {
|
||||
static const String _statesTable = 'states';
|
||||
static const String _appSettingsTable = 'app_settings';
|
||||
static const String _parameterLimitsTable = 'manual_parameter_limits';
|
||||
// --- START: ADDED NEW TABLE CONSTANTS ---
|
||||
static const String _npeParameterLimitsTable = 'npe_parameter_limits';
|
||||
static const String _marineParameterLimitsTable = 'marine_parameter_limits';
|
||||
static const String _riverParameterLimitsTable = 'river_parameter_limits';
|
||||
// --- END: ADDED NEW TABLE CONSTANTS ---
|
||||
static const String _apiConfigsTable = 'api_configurations';
|
||||
static const String _ftpConfigsTable = 'ftp_configurations';
|
||||
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 $_appSettingsTable(setting_id INTEGER PRIMARY KEY, setting_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 $_marineParameterLimitsTable(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 $_ftpConfigsTable(ftp_config_id INTEGER PRIMARY KEY, config_json TEXT)');
|
||||
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");
|
||||
}
|
||||
}
|
||||
// --- START: ADDED UPGRADE LOGIC FOR NEW TABLES ---
|
||||
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 $_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)');
|
||||
}
|
||||
// --- 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 {
|
||||
if (data.isEmpty) return;
|
||||
final db = await database;
|
||||
@ -1040,7 +1270,6 @@ class DatabaseHelper {
|
||||
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 {
|
||||
if (ids.isEmpty) return;
|
||||
final db = await database;
|
||||
@ -1075,9 +1304,6 @@ class DatabaseHelper {
|
||||
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 {
|
||||
final db = await database;
|
||||
final List<Map<String, dynamic>> maps = await db.query(
|
||||
@ -1097,8 +1323,6 @@ class DatabaseHelper {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Inserts or replaces a user's profile and credentials.
|
||||
/// This ensures the record exists when caching credentials during login.
|
||||
Future<void> upsertUserWithCredentials({
|
||||
required Map<String, dynamic> profile,
|
||||
required String passwordHash,
|
||||
@ -1117,7 +1341,6 @@ class DatabaseHelper {
|
||||
debugPrint("Upserted user credentials for ${profile['email']}");
|
||||
}
|
||||
|
||||
/// Retrieves the stored password hash for a user by email.
|
||||
Future<String?> getUserPasswordHashByEmail(String email) async {
|
||||
final db = await database;
|
||||
final List<Map<String, dynamic>> result = await db.query(
|
||||
@ -1132,18 +1355,14 @@ class DatabaseHelper {
|
||||
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 {
|
||||
if (data.isEmpty) return;
|
||||
final db = await database;
|
||||
for (var item in data) {
|
||||
final updateData = {
|
||||
//'email': item['email'],
|
||||
'user_json': jsonEncode(item),
|
||||
};
|
||||
|
||||
// Try to update existing record first, preserving other columns like password_hash
|
||||
int count = await db.update(
|
||||
_usersTable,
|
||||
updateData,
|
||||
@ -1151,7 +1370,6 @@ class DatabaseHelper {
|
||||
whereArgs: [item['user_id']],
|
||||
);
|
||||
|
||||
// If no record was updated (count == 0), insert a new record.
|
||||
if (count == 0) {
|
||||
await db.insert(
|
||||
_usersTable,
|
||||
@ -1159,7 +1377,6 @@ class DatabaseHelper {
|
||||
'user_id': item['user_id'],
|
||||
'email': item['email'],
|
||||
'user_json': jsonEncode(item),
|
||||
// password_hash will be null for a new user until they log in on this device.
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||
);
|
||||
@ -1167,7 +1384,6 @@ class DatabaseHelper {
|
||||
}
|
||||
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<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<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> deleteNpeParameterLimits(List<dynamic> ids) => _deleteData(_npeParameterLimitsTable, 'param_autoid', ids);
|
||||
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> deleteRiverParameterLimits(List<dynamic> ids) => _deleteData(_riverParameterLimitsTable, 'param_autoid', ids);
|
||||
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> deleteApiConfigs(List<dynamic> ids) => _deleteData(_apiConfigsTable, 'api_config_id', ids);
|
||||
|
||||
@ -405,8 +405,8 @@ class MarineInSituSamplingService {
|
||||
|
||||
return {
|
||||
'statuses': <Map<String, dynamic>>[
|
||||
...(ftpDataResult['statuses'] as List),
|
||||
...(ftpImageResult['statuses'] as List),
|
||||
...(ftpDataResult['statuses'] as List<dynamic>? ?? []),
|
||||
...(ftpImageResult['statuses'] as List<dynamic>? ?? []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user