fix marine tarball telegram alert

This commit is contained in:
ALim Aidrus 2025-08-17 22:01:37 +08:00
parent e3b58bf74e
commit 475e645d25
3 changed files with 140 additions and 62 deletions

View File

@ -1,3 +1,4 @@
//import 'dart' as dart;
import 'dart:io'; import 'dart:io';
/// This class holds all the data collected across the multi-step tarball sampling form. /// This class holds all the data collected across the multi-step tarball sampling form.
@ -97,7 +98,7 @@ class TarballSamplingData {
'optional_photo_remark_02': optionalRemark2 ?? '', 'optional_photo_remark_02': optionalRemark2 ?? '',
'optional_photo_remark_03': optionalRemark3 ?? '', 'optional_photo_remark_03': optionalRemark3 ?? '',
'optional_photo_remark_04': optionalRemark4 ?? '', 'optional_photo_remark_04': optionalRemark4 ?? '',
'distance_difference_remarks': distanceDifferenceRemarks ?? '', 'distance_remarks': distanceDifferenceRemarks ?? '',
// Human-readable names for the Telegram alert // Human-readable names for the Telegram alert
'tbl_station_name': selectedStation?['tbl_station_name']?.toString() ?? '', 'tbl_station_name': selectedStation?['tbl_station_name']?.toString() ?? '',

View File

@ -17,6 +17,7 @@ class TarballSamplingStep1 extends StatefulWidget {
class _TarballSamplingStep1State extends State<TarballSamplingStep1> { class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
// MODIFIED: A single data object is managed for the entire form.
final _data = TarballSamplingData(); final _data = TarballSamplingData();
bool _isLoading = false; bool _isLoading = false;
@ -29,11 +30,10 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
final TextEditingController _currentLatController = TextEditingController(); final TextEditingController _currentLatController = TextEditingController();
final TextEditingController _currentLonController = TextEditingController(); final TextEditingController _currentLonController = TextEditingController();
// --- State for Dropdowns and Location --- // --- State for Dropdowns ---
List<String> _statesList = []; List<String> _statesList = [];
List<String> _categoriesForState = []; List<String> _categoriesForState = [];
List<Map<String, dynamic>> _stationsForCategory = []; List<Map<String, dynamic>> _stationsForCategory = [];
double? _distanceDifference;
@override @override
void initState() { void initState() {
@ -43,7 +43,6 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
@override @override
void dispose() { void dispose() {
// Dispose all controllers to prevent memory leaks
_firstSamplerController.dispose(); _firstSamplerController.dispose();
_dateController.dispose(); _dateController.dispose();
_timeController.dispose(); _timeController.dispose();
@ -56,10 +55,6 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
void _initializeForm() { void _initializeForm() {
final auth = Provider.of<AuthProvider>(context, listen: false); final auth = Provider.of<AuthProvider>(context, listen: false);
// Set initial values for the data model and controllers
// This relies on the AuthProvider having been populated with data,
// which works offline if the data was fetched and cached previously.
_data.firstSampler = auth.profileData?['first_name'] ?? 'Current User'; _data.firstSampler = auth.profileData?['first_name'] ?? 'Current User';
_data.firstSamplerUserId = auth.profileData?['user_id']; _data.firstSamplerUserId = auth.profileData?['user_id'];
_firstSamplerController.text = _data.firstSampler!; _firstSamplerController.text = _data.firstSampler!;
@ -70,8 +65,6 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
_dateController.text = _data.samplingDate!; _dateController.text = _data.samplingDate!;
_timeController.text = _data.samplingTime!; _timeController.text = _data.samplingTime!;
// Populate the initial list of unique states from all available stations.
// This also relies on cached data in AuthProvider for offline use.
final allStations = auth.tarballStations ?? []; final allStations = auth.tarballStations ?? [];
if (allStations.isNotEmpty) { if (allStations.isNotEmpty) {
final states = allStations.map((s) => s['state_name'] as String?).whereType<String>().toSet().toList(); final states = allStations.map((s) => s['state_name'] as String?).whereType<String>().toSet().toList();
@ -80,12 +73,10 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
} }
} }
/// Fetches the device's location with an offline-first approach.
Future<void> _getCurrentLocation() async { Future<void> _getCurrentLocation() async {
bool serviceEnabled; bool serviceEnabled;
LocationPermission permission; LocationPermission permission;
// Check if location services are enabled.
serviceEnabled = await Geolocator.isLocationServiceEnabled(); serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) { if (!serviceEnabled) {
_showSnackBar('Location services are disabled. Please enable them.'); _showSnackBar('Location services are disabled. Please enable them.');
@ -109,12 +100,7 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
// --- OFFLINE-FIRST LOGIC ---
// 1. Try to get the last known position. This is fast and works offline.
Position? position = await Geolocator.getLastKnownPosition(); Position? position = await Geolocator.getLastKnownPosition();
// 2. If no last known position, get the current position using GPS.
// This can work offline but may take longer.
position ??= await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); position ??= await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
if (mounted) { if (mounted) {
@ -144,21 +130,91 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
double distanceInMeters = Geolocator.distanceBetween(lat1, lon1, lat2, lon2); double distanceInMeters = Geolocator.distanceBetween(lat1, lon1, lat2, lon2);
setState(() { setState(() {
_distanceDifference = distanceInMeters / 1000; _data.distanceDifference = distanceInMeters / 1000; // Convert to km
_data.distanceDifference = _distanceDifference;
}); });
} }
} }
// --- MODIFIED: This function now validates distance and shows a dialog if needed ---
void _goToNextStep() { void _goToNextStep() {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
_formKey.currentState!.save(); _formKey.currentState!.save();
final distanceInMeters = (_data.distanceDifference ?? 0) * 1000;
if (distanceInMeters > 700) {
_showDistanceRemarkDialog();
} else {
_data.distanceDifferenceRemarks = null; // Clear old remarks if within range
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute(builder: (context) => TarballSamplingStep2(data: _data)), MaterialPageRoute(builder: (context) => TarballSamplingStep2(data: _data)),
); );
} }
} }
}
// --- NEW: This function displays the mandatory remarks dialog ---
Future<void> _showDistanceRemarkDialog() async {
final remarkController = TextEditingController(text: _data.distanceDifferenceRemarks);
final dialogFormKey = GlobalKey<FormState>();
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Distance Warning'),
content: SingleChildScrollView(
child: Form(
key: dialogFormKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Your current location is more than 700m away from the station.'),
const SizedBox(height: 16),
TextFormField(
controller: remarkController,
decoration: const InputDecoration(
labelText: 'Remarks *',
hintText: 'Please provide a reason...',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Remarks are required to continue.';
}
return null;
},
maxLines: 3,
),
],
),
),
),
actions: <Widget>[
TextButton(
child: const Text('Cancel'),
onPressed: () => Navigator.of(context).pop(),
),
FilledButton(
child: const Text('Confirm'),
onPressed: () {
if (dialogFormKey.currentState!.validate()) {
_data.distanceDifferenceRemarks = remarkController.text;
Navigator.of(context).pop();
Navigator.push(
context,
MaterialPageRoute(builder: (context) => TarballSamplingStep2(data: _data)),
);
}
},
),
],
);
},
);
}
void _showSnackBar(String message) { void _showSnackBar(String message) {
if (mounted) { if (mounted) {
@ -168,14 +224,10 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// For offline functionality, all data used in the dropdowns (tarballStations, allUsers)
// must be fetched and cached in the AuthProvider when the app is online.
final auth = Provider.of<AuthProvider>(context, listen: false); final auth = Provider.of<AuthProvider>(context, listen: false);
final allStations = auth.tarballStations ?? []; final allStations = auth.tarballStations ?? [];
final currentUser = auth.profileData;
final allUsers = auth.allUsers ?? []; final allUsers = auth.allUsers ?? [];
final secondSamplersList = allUsers.where((user) => user['user_id'] != currentUser?['user_id']).toList(); final secondSamplersList = allUsers.where((user) => user['user_id'] != auth.profileData?['user_id']).toList();
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text("Tarball Sampling (1/3)")), appBar: AppBar(title: const Text("Tarball Sampling (1/3)")),
@ -188,12 +240,10 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
const SizedBox(height: 24), const SizedBox(height: 24),
TextFormField(controller: _firstSamplerController, readOnly: true, decoration: const InputDecoration(labelText: '1st Sampler')), TextFormField(controller: _firstSamplerController, readOnly: true, decoration: const InputDecoration(labelText: '1st Sampler')),
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownSearch<Map<String, dynamic>>( DropdownSearch<Map<String, dynamic>>(
// --- CORRECTED: Added a ValueKey to prevent the "Duplicate GlobalKey" error ---
// This ensures the widget rebuilds cleanly when its item list changes.
key: ValueKey(allUsers.length), key: ValueKey(allUsers.length),
items: secondSamplersList, items: secondSamplersList,
selectedItem: _data.secondSampler, // Bind to the data model
itemAsString: (sampler) => "${sampler['first_name']} ${sampler['last_name']}", itemAsString: (sampler) => "${sampler['first_name']} ${sampler['last_name']}",
onChanged: (sampler) => setState(() => _data.secondSampler = sampler), onChanged: (sampler) => setState(() => _data.secondSampler = sampler),
popupProps: const PopupProps.menu( popupProps: const PopupProps.menu(
@ -204,7 +254,6 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
dropdownSearchDecoration: InputDecoration(labelText: '2nd Sampler (Optional)'), dropdownSearchDecoration: InputDecoration(labelText: '2nd Sampler (Optional)'),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
children: [ children: [
@ -216,6 +265,7 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownSearch<String>( DropdownSearch<String>(
items: _statesList, items: _statesList,
selectedItem: _data.selectedStateName, // Bind to the data model
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))), popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *")), dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *")),
onChanged: (state) { onChanged: (state) {
@ -225,7 +275,7 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
_data.selectedStation = null; _data.selectedStation = null;
_stationLatController.clear(); _stationLatController.clear();
_stationLonController.clear(); _stationLonController.clear();
_distanceDifference = null; _data.distanceDifference = null;
if (state != null) { if (state != null) {
_categoriesForState = allStations.where((s) => s['state_name'] == state).map((s) => s['category_name'] as String?).whereType<String>().toSet().toList(); _categoriesForState = allStations.where((s) => s['state_name'] == state).map((s) => s['category_name'] as String?).whereType<String>().toSet().toList();
@ -241,6 +291,7 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownSearch<String>( DropdownSearch<String>(
items: _categoriesForState, items: _categoriesForState,
selectedItem: _data.selectedCategoryName, // Bind to the data model
enabled: _data.selectedStateName != null, enabled: _data.selectedStateName != null,
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Category..."))), popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Category..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Category *")), dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Category *")),
@ -250,7 +301,7 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
_data.selectedStation = null; _data.selectedStation = null;
_stationLatController.clear(); _stationLatController.clear();
_stationLonController.clear(); _stationLonController.clear();
_distanceDifference = null; _data.distanceDifference = null;
if (category != null) { if (category != null) {
_stationsForCategory = allStations.where((s) => s['state_name'] == _data.selectedStateName && s['category_name'] == category).toList(); _stationsForCategory = allStations.where((s) => s['state_name'] == _data.selectedStateName && s['category_name'] == category).toList();
@ -264,6 +315,7 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownSearch<Map<String, dynamic>>( DropdownSearch<Map<String, dynamic>>(
items: _stationsForCategory, items: _stationsForCategory,
selectedItem: _data.selectedStation, // Bind to the data model
enabled: _data.selectedCategoryName != null, enabled: _data.selectedCategoryName != null,
itemAsString: (station) => "${station['tbl_station_code']} - ${station['tbl_station_name']}", itemAsString: (station) => "${station['tbl_station_code']} - ${station['tbl_station_name']}",
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Station..."))), popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Station..."))),
@ -289,14 +341,32 @@ class _TarballSamplingStep1State extends State<TarballSamplingStep1> {
TextFormField(controller: _currentLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Latitude')), TextFormField(controller: _currentLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Latitude')),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField(controller: _currentLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Longitude')), TextFormField(controller: _currentLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Longitude')),
if (_distanceDifference != null) if (_data.distanceDifference != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
child: Text('Distance from Station: ${_distanceDifference!.toStringAsFixed(2)} km', // --- MODIFIED: This UI now better reflects the warning/ok status ---
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: ((_data.distanceDifference ?? 0) * 1000) > 700 ? Colors.red.withOpacity(0.1) : Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: ((_data.distanceDifference ?? 0) * 1000) > 700 ? Colors.red : Colors.green),
),
child: RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: Theme.of(context).textTheme.bodyLarge,
children: <TextSpan>[
const TextSpan(text: 'Distance from Station: '),
TextSpan(
text: '${(_data.distanceDifference! * 1000).toStringAsFixed(0)} meters',
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: _distanceDifference! > 1.0 ? Colors.red : Colors.green, color: ((_data.distanceDifference ?? 0) * 1000) > 700 ? Colors.red : Colors.green),
) ),
],
),
),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@ -114,9 +114,11 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
_buildDetailRow("Current Longitude:", widget.data.currentLongitude), _buildDetailRow("Current Longitude:", widget.data.currentLongitude),
_buildDetailRow("Distance Difference:", _buildDetailRow("Distance Difference:",
widget.data.distanceDifference != null widget.data.distanceDifference != null
? "${widget.data.distanceDifference!.toStringAsFixed(2)} km" ? "${(widget.data.distanceDifference! * 1000).toStringAsFixed(0)} meters"
: "N/A" : "N/A"
), ),
// NECESSARY CHANGE: Add this line to display the remarks.
_buildDetailRow("Distance Remarks:", widget.data.distanceDifferenceRemarks),
], ],
), ),
@ -199,7 +201,11 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
} }
Widget _buildDetailRow(String label, String? value) { Widget _buildDetailRow(String label, String? value) {
return Padding( // This function now correctly handles null or empty remarks by displaying 'N/A'
final bool isValueAvailable = value != null && value.isNotEmpty;
return Visibility(
visible: isValueAvailable, // Only show the row if there is a value to display
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0), padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -215,12 +221,13 @@ class _TarballSamplingStep3SummaryState extends State<TarballSamplingStep3Summar
Expanded( Expanded(
flex: 3, flex: 3,
child: Text( child: Text(
value != null && value.isNotEmpty ? value : 'N/A', value ?? 'N/A', // Display the value or 'N/A' if null
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
), ),
], ],
), ),
),
); );
} }