From 31b64fc2032c3ac030f2d1f075946066bc7d04ea Mon Sep 17 00:00:00 2001 From: ALim Aidrus Date: Thu, 2 Oct 2025 10:51:06 +0800 Subject: [PATCH] repair on the register screen --- android/app/src/main/AndroidManifest.xml | 4 +- lib/auth_provider.dart | 21 ++ .../widgets/in_situ_step_1_sampling_info.dart | 190 +++++++++++++++--- .../widgets/in_situ_step_2_site_info.dart | 106 +++++----- lib/screens/register.dart | 34 ++++ .../river_in_situ_step_1_sampling_info.dart | 153 ++++++++++++-- .../river_in_situ_step_2_site_info.dart | 2 +- .../river_in_situ_step_3_data_capture.dart | 66 ++++-- .../widgets/river_in_situ_step_5_summary.dart | 110 ++++++++-- lib/screens/settings.dart | 2 +- lib/services/api_service.dart | 60 ++++++ 11 files changed, 620 insertions(+), 128 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f507a7f..01e4a58 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,9 +27,9 @@ - + diff --git a/lib/auth_provider.dart b/lib/auth_provider.dart index 1c0247b..0c68437 100644 --- a/lib/auth_provider.dart +++ b/lib/auth_provider.dart @@ -267,6 +267,27 @@ class AuthProvider with ChangeNotifier { } } + // --- START: NEW METHOD FOR REGISTRATION SCREEN --- + Future syncRegistrationData() async { + final connectivityResult = await Connectivity().checkConnectivity(); + if (connectivityResult.contains(ConnectivityResult.none)) { + debugPrint("AuthProvider: Device is OFFLINE. Skipping registration data sync."); + return; + } + + debugPrint("AuthProvider: Fetching data for registration screen..."); + final result = await _apiService.syncRegistrationData(); + + if (result['success']) { + await _loadDataFromCache(); // Reload data from DB into the provider + notifyListeners(); // Notify the UI to rebuild + debugPrint("AuthProvider: Registration data loaded and UI notified."); + } else { + debugPrint("AuthProvider: Registration data sync failed."); + } + } + // --- END: NEW METHOD FOR REGISTRATION SCREEN --- + Future refreshProfile() async { final connectivityResult = await Connectivity().checkConnectivity(); if (connectivityResult.contains(ConnectivityResult.none)) { diff --git a/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart b/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart index bfd4089..c8ca92f 100644 --- a/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart +++ b/lib/screens/marine/manual/widgets/in_situ_step_1_sampling_info.dart @@ -8,9 +8,7 @@ import 'package:simple_barcode_scanner/simple_barcode_scanner.dart'; import '../../../../auth_provider.dart'; import '../../../../models/in_situ_sampling_data.dart'; -// START CHANGE: Import the new, correct service file import '../../../../services/marine_in_situ_sampling_service.dart'; -// END CHANGE class InSituStep1SamplingInfo extends StatefulWidget { final InSituSamplingData data; @@ -90,6 +88,10 @@ class _InSituStep1SamplingInfoState extends State { _dateController.text = widget.data.samplingDate!; _timeController.text = widget.data.samplingTime!; + if (widget.data.samplingType == null) { + widget.data.samplingType = 'Schedule'; + } + final allStations = auth.manualStations ?? []; if (allStations.isNotEmpty) { final states = allStations.map((s) => s['state_name'] as String?).whereType().toSet().toList(); @@ -110,7 +112,8 @@ class _InSituStep1SamplingInfoState extends State { .where((s) => s['state_name'] == widget.data.selectedStateName && s['category_name'] == widget.data.selectedCategoryName) - .toList(); + .toList() + ..sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? '')); } setState(() { @@ -121,9 +124,7 @@ class _InSituStep1SamplingInfoState extends State { Future _getCurrentLocation() async { setState(() => _isLoadingLocation = true); - // START CHANGE: Use the correct, new service type from Provider final service = Provider.of(context, listen: false); - // END CHANGE try { final position = await service.getCurrentLocation(); @@ -154,9 +155,7 @@ class _InSituStep1SamplingInfoState extends State { final lon2Str = widget.data.currentLongitude; if (lat1Str != null && lon1Str != null && lat2Str != null && lon2Str != null) { - // START CHANGE: Use the correct, new service type from Provider final service = Provider.of(context, listen: false); - // END CHANGE final lat1 = double.tryParse(lat1Str); final lon1 = double.tryParse(lon1Str); final lat2 = double.tryParse(lat2Str); @@ -184,31 +183,113 @@ class _InSituStep1SamplingInfoState extends State { } } - /// Validates the form and distance, then proceeds to the next step. + // --- START: New function to find and show nearby stations --- + Future _findAndShowNearbyStations() async { + if (widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) { + await _getCurrentLocation(); + if (!mounted || widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) { + return; + } + } + + final service = Provider.of(context, listen: false); + final auth = Provider.of(context, listen: false); + + final currentLat = double.parse(widget.data.currentLatitude!); + final currentLon = double.parse(widget.data.currentLongitude!); + final allStations = auth.manualStations ?? []; + final List> nearbyStations = []; + + for (var station in allStations) { + final stationLat = station['man_latitude']; + final stationLon = station['man_longitude']; + + if (stationLat is num && stationLon is num) { + final distance = service.calculateDistance(currentLat, currentLon, stationLat.toDouble(), stationLon.toDouble()); + if (distance <= 5.0) { // 5km radius + nearbyStations.add({'station': station, 'distance': distance}); + } + } + } + + nearbyStations.sort((a, b) => a['distance'].compareTo(b['distance'])); + + if (!mounted) return; + + final selectedStation = await showDialog>( + context: context, + builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations), + ); + + if (selectedStation != null) { + _updateFormWithSelectedStation(selectedStation); + } + } + // --- END: New function --- + + // --- START: New helper to update form after selection --- + void _updateFormWithSelectedStation(Map station) { + final allStations = Provider.of(context, listen: false).manualStations ?? []; + setState(() { + // Update State + widget.data.selectedStateName = station['state_name']; + + // Update Category List based on new State + final categories = allStations + .where((s) => s['state_name'] == widget.data.selectedStateName) + .map((s) => s['category_name'] as String?) + .whereType() + .toSet() + .toList(); + categories.sort(); + _categoriesForState = categories; + + // Update Category + widget.data.selectedCategoryName = station['category_name']; + + // Update Station List based on new State and Category + _stationsForCategory = allStations + .where((s) => + s['state_name'] == widget.data.selectedStateName && + s['category_name'] == widget.data.selectedCategoryName) + .toList() + ..sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? '')); + + // Update Selected Station and its coordinates + widget.data.selectedStation = station; + widget.data.stationLatitude = station['man_latitude']?.toString(); + widget.data.stationLongitude = station['man_longitude']?.toString(); + _stationLatController.text = widget.data.stationLatitude ?? ''; + _stationLonController.text = widget.data.stationLongitude ?? ''; + + // Recalculate distance + _calculateDistance(); + }); + } + // --- END: New helper --- + void _goToNextStep() { if (_formKey.currentState!.validate()) { _formKey.currentState!.save(); final distanceInMeters = (widget.data.distanceDifferenceInKm ?? 0) * 1000; - if (distanceInMeters > 700) { + if (distanceInMeters > 50) { _showDistanceRemarkDialog(); } else { - // If distance is okay, clear any previous remarks and proceed. widget.data.distanceDifferenceRemarks = null; widget.onNext(); } } } - /// Shows a dialog to force the user to enter remarks for large distance differences. Future _showDistanceRemarkDialog() async { final remarkController = TextEditingController(text: widget.data.distanceDifferenceRemarks); final dialogFormKey = GlobalKey(); return showDialog( context: context, - barrierDismissible: false, // User must interact with the dialog + barrierDismissible: false, builder: (BuildContext context) { return AlertDialog( title: const Text('Distance Warning'), @@ -219,7 +300,7 @@ class _InSituStep1SamplingInfoState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Your current location is more than 700m away from the station.'), + const Text('Your current location is more than 50m away from the station.'), const SizedBox(height: 16), TextFormField( controller: remarkController, @@ -255,7 +336,7 @@ class _InSituStep1SamplingInfoState extends State { widget.data.distanceDifferenceRemarks = remarkController.text; }); Navigator.of(context).pop(); - widget.onNext(); // Proceed to next step + widget.onNext(); } }, ), @@ -270,14 +351,14 @@ class _InSituStep1SamplingInfoState extends State { final auth = Provider.of(context, listen: false); final allStations = auth.manualStations ?? []; final allUsers = auth.allUsers ?? []; - final secondSamplersList = allUsers.where((user) => user['user_id'] != auth.profileData?['user_id']).toList(); + final secondSamplersList = allUsers.where((user) => user['user_id'] != auth.profileData?['user_id']).toList() + ..sort((a, b) => (a['first_name'] ?? '').compareTo(b['first_name'] ?? '')); return Form( key: _formKey, child: ListView( padding: const EdgeInsets.all(24.0), children: [ - // Sampling Information section... Text("Sampling Information", style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 24), TextFormField(controller: _firstSamplerController, readOnly: true, decoration: const InputDecoration(labelText: '1st Sampler')), @@ -308,7 +389,6 @@ class _InSituStep1SamplingInfoState extends State { ), const SizedBox(height: 24), - // Station Selection section... Text("Station Selection", style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 16), DropdownSearch( @@ -355,7 +435,12 @@ class _InSituStep1SamplingInfoState extends State { _stationLatController.clear(); _stationLonController.clear(); widget.data.distanceDifferenceInKm = null; - _stationsForCategory = category != null ? allStations.where((s) => s['state_name'] == widget.data.selectedStateName && s['category_name'] == category).toList() : []; + _stationsForCategory = category != null + ? (allStations + .where((s) => s['state_name'] == widget.data.selectedStateName && s['category_name'] == category) + .toList() + ..sort((a, b) => (a['man_station_code'] ?? '').compareTo(b['man_station_code'] ?? ''))) + : []; }); }, validator: (val) => widget.data.selectedStateName != null && val == null ? "Category is required" : null, @@ -382,9 +467,20 @@ class _InSituStep1SamplingInfoState extends State { TextFormField(controller: _stationLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Latitude')), const SizedBox(height: 16), TextFormField(controller: _stationLonController, readOnly: true, decoration: const InputDecoration(labelText: 'Station Longitude')), - const SizedBox(height: 24), - // Location Verification section... + // --- START: Added Nearby Station Button --- + const SizedBox(height: 16), + ElevatedButton.icon( + icon: const Icon(Icons.explore_outlined), + label: const Text("NEARBY STATION"), + onPressed: _isLoadingLocation ? null : _findAndShowNearbyStations, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + // --- END: Added Nearby Station Button --- + + const SizedBox(height: 24), Text("Location Verification", style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 16), TextFormField(controller: _currentLatController, readOnly: true, decoration: const InputDecoration(labelText: 'Current Latitude')), @@ -396,9 +492,9 @@ class _InSituStep1SamplingInfoState extends State { child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 700 ? Colors.red.withOpacity(0.1) : Colors.green.withOpacity(0.1), + color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red.withOpacity(0.1) : Colors.green.withOpacity(0.1), borderRadius: BorderRadius.circular(8), - border: Border.all(color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 700 ? Colors.red : Colors.green), + border: Border.all(color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red : Colors.green), ), child: RichText( textAlign: TextAlign.center, @@ -410,7 +506,7 @@ class _InSituStep1SamplingInfoState extends State { text: '${(widget.data.distanceDifferenceInKm! * 1000).toStringAsFixed(0)} meters', style: TextStyle( fontWeight: FontWeight.bold, - color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 700 ? Colors.red : Colors.green + color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red : Colors.green ), ), ], @@ -449,4 +545,50 @@ class _InSituStep1SamplingInfoState extends State { ), ); } -} \ No newline at end of file +} + +// --- START: New Dialog Widget for Nearby Stations --- +class _NearbyStationsDialog extends StatelessWidget { + final List> nearbyStations; + + const _NearbyStationsDialog({required this.nearbyStations}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Nearby Stations (within 5km)'), + content: SizedBox( + width: double.maxFinite, + child: nearbyStations.isEmpty + ? const Center(child: Text('No stations found.')) + : ListView.builder( + shrinkWrap: true, + itemCount: nearbyStations.length, + itemBuilder: (context, index) { + final item = nearbyStations[index]; + final station = item['station'] as Map; + final distanceInMeters = (item['distance'] as double) * 1000; + + return Card( + child: ListTile( + title: Text("${station['man_station_code'] ?? 'N/A'}"), + subtitle: Text("${station['man_station_name'] ?? 'N/A'}"), + trailing: Text("${distanceInMeters.toStringAsFixed(0)} m"), + onTap: () { + Navigator.of(context).pop(station); + }, + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ], + ); + } +} +// --- END: New Dialog Widget --- \ No newline at end of file diff --git a/lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart b/lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart index af1e0f4..2190ce5 100644 --- a/lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart +++ b/lib/screens/marine/manual/widgets/in_situ_step_2_site_info.dart @@ -6,9 +6,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:provider/provider.dart'; import '../../../../models/in_situ_sampling_data.dart'; -// START CHANGE: Import the new, correct service file import '../../../../services/marine_in_situ_sampling_service.dart'; -// END CHANGE /// The second step of the In-Situ Sampling form. /// Gathers on-site conditions (weather, tide) and handles all photo attachments. @@ -30,13 +28,10 @@ class _InSituStep2SiteInfoState extends State { final _formKey = GlobalKey(); bool _isPickingImage = false; - // --- UI Controllers for remarks --- + // --- START MODIFICATION: Removed optional remark controllers --- late final TextEditingController _eventRemarksController; late final TextEditingController _labRemarksController; - late final TextEditingController _optionalRemark1Controller; - late final TextEditingController _optionalRemark2Controller; - late final TextEditingController _optionalRemark3Controller; - late final TextEditingController _optionalRemark4Controller; + // --- END MODIFICATION --- final List _weatherOptions = ['Clear', 'Rainy', 'Cloudy']; @@ -48,20 +43,16 @@ class _InSituStep2SiteInfoState extends State { super.initState(); _eventRemarksController = TextEditingController(text: widget.data.eventRemarks); _labRemarksController = TextEditingController(text: widget.data.labRemarks); - _optionalRemark1Controller = TextEditingController(text: widget.data.optionalRemark1); - _optionalRemark2Controller = TextEditingController(text: widget.data.optionalRemark2); - _optionalRemark3Controller = TextEditingController(text: widget.data.optionalRemark3); - _optionalRemark4Controller = TextEditingController(text: widget.data.optionalRemark4); + // --- START MODIFICATION: Removed initialization for optional remark controllers --- + // --- END MODIFICATION --- } @override void dispose() { _eventRemarksController.dispose(); _labRemarksController.dispose(); - _optionalRemark1Controller.dispose(); - _optionalRemark2Controller.dispose(); - _optionalRemark3Controller.dispose(); - _optionalRemark4Controller.dispose(); + // --- START MODIFICATION: Removed disposal of optional remark controllers --- + // --- END MODIFICATION --- super.dispose(); } @@ -70,9 +61,7 @@ class _InSituStep2SiteInfoState extends State { if (_isPickingImage) return; setState(() => _isPickingImage = true); - // START CHANGE: Use the correct service type from Provider final service = Provider.of(context, listen: false); - // END CHANGE final file = await service.pickAndProcessImage(source, data: widget.data, imageInfo: imageInfo, isRequired: isRequired); @@ -89,26 +78,25 @@ class _InSituStep2SiteInfoState extends State { /// Validates the form and all required images before proceeding. void _goToNextStep() { + // --- START MODIFICATION: Updated validation logic --- + if (widget.data.leftLandViewImage == null || + widget.data.rightLandViewImage == null || + widget.data.waterFillingImage == null || + widget.data.seawaterColorImage == null) { + _showSnackBar('Please attach all 4 required photos before proceeding.', isError: true); + return; + } + + // Form validation now handles the conditional requirement for Event Remarks if (!_formKey.currentState!.validate()) { return; } - if (widget.data.leftLandViewImage == null || - widget.data.rightLandViewImage == null || - widget.data.waterFillingImage == null || - widget.data.seawaterColorImage == null || - widget.data.phPaperImage == null) { - _showSnackBar('Please attach all 5 required photos before proceeding.', isError: true); - return; - } - _formKey.currentState!.save(); - // --- FIXED: Correctly save remarks text to the data model's remark properties --- - widget.data.optionalRemark1 = _optionalRemark1Controller.text; - widget.data.optionalRemark2 = _optionalRemark2Controller.text; - widget.data.optionalRemark3 = _optionalRemark3Controller.text; - widget.data.optionalRemark4 = _optionalRemark4Controller.text; + + // Removed saving of optional remarks as they are no longer present widget.onNext(); + // --- END MODIFICATION --- } void _showSnackBar(String message, {bool isError = false}) { @@ -122,6 +110,14 @@ class _InSituStep2SiteInfoState extends State { @override Widget build(BuildContext context) { + // --- START MODIFICATION: Logic to determine if Event Remarks are required --- + final bool areAdditionalPhotosAttached = widget.data.phPaperImage != null || + widget.data.optionalImage1 != null || + widget.data.optionalImage2 != null || + widget.data.optionalImage3 != null || + widget.data.optionalImage4 != null; + // --- END MODIFICATION --- + return Form( key: _formKey, child: ListView( @@ -163,27 +159,39 @@ class _InSituStep2SiteInfoState extends State { _buildImagePicker('Right Side Land View', 'RIGHT_LAND_VIEW', widget.data.rightLandViewImage, (file) => widget.data.rightLandViewImage = file, isRequired: true), _buildImagePicker('Filling Water into Sample Bottle', 'WATER_FILLING', widget.data.waterFillingImage, (file) => widget.data.waterFillingImage = file, isRequired: true), _buildImagePicker('Seawater in Clear Glass Bottle', 'SEAWATER_COLOR', widget.data.seawaterColorImage, (file) => widget.data.seawaterColorImage = file, isRequired: true), - _buildImagePicker('Examine Preservative (pH paper)', 'PH_PAPER', widget.data.phPaperImage, (file) => widget.data.phPaperImage = file, isRequired: true), const SizedBox(height: 24), - // --- Section: Optional Photos --- - Text("Optional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge), + // --- START MODIFICATION: Section for additional photos and conditional remarks --- + Text("Additional Photos & Remarks", style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), - _buildImagePicker('Optional Photo 1', 'OPTIONAL_1', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, remarkController: _optionalRemark1Controller, isRequired: false), - _buildImagePicker('Optional Photo 2', 'OPTIONAL_2', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, remarkController: _optionalRemark2Controller, isRequired: false), - _buildImagePicker('Optional Photo 3', 'OPTIONAL_3', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, remarkController: _optionalRemark3Controller, isRequired: false), - _buildImagePicker('Optional Photo 4', 'OPTIONAL_4', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, remarkController: _optionalRemark4Controller, isRequired: false), + // pH Paper photo is now the first optional photo + _buildImagePicker('Examine Preservative (pH paper)', 'PH_PAPER', widget.data.phPaperImage, (file) => widget.data.phPaperImage = file, isRequired: false), + // Other optional photos no longer have remark fields + _buildImagePicker('Optional Photo 1', 'OPTIONAL_1', widget.data.optionalImage1, (file) => widget.data.optionalImage1 = file, isRequired: false), + _buildImagePicker('Optional Photo 2', 'OPTIONAL_2', widget.data.optionalImage2, (file) => widget.data.optionalImage2 = file, isRequired: false), + _buildImagePicker('Optional Photo 3', 'OPTIONAL_3', widget.data.optionalImage3, (file) => widget.data.optionalImage3 = file, isRequired: false), + _buildImagePicker('Optional Photo 4', 'OPTIONAL_4', widget.data.optionalImage4, (file) => widget.data.optionalImage4 = file, isRequired: false), const SizedBox(height: 24), - // --- Section: Remarks --- Text("Remarks", style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 16), + // Event Remarks field is now conditionally required TextFormField( controller: _eventRemarksController, - decoration: const InputDecoration(labelText: 'Event Remarks (Optional)', hintText: 'e.g., unusual smells, colors, etc.'), + decoration: InputDecoration( + labelText: areAdditionalPhotosAttached ? 'Event Remarks *' : 'Event Remarks (Optional)', + hintText: 'e.g., unusual smells, colors, etc.' + ), onSaved: (value) => widget.data.eventRemarks = value, + validator: (value) { + if (areAdditionalPhotosAttached && (value == null || value.trim().isEmpty)) { + return 'Event Remarks are required when attaching additional photos.'; + } + return null; + }, maxLines: 3, ), + // --- END MODIFICATION --- const SizedBox(height: 16), TextFormField( controller: _labRemarksController, @@ -203,7 +211,9 @@ class _InSituStep2SiteInfoState extends State { } /// A reusable widget for picking and displaying an image, matching the tarball design. - Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {TextEditingController? remarkController, bool isRequired = false}) { + // --- START MODIFICATION: Removed remarkController parameter --- + Widget _buildImagePicker(String title, String imageInfo, File? imageFile, Function(File?) setImageCallback, {bool isRequired = false}) { + // --- END MODIFICATION --- return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Column( @@ -235,18 +245,8 @@ class _InSituStep2SiteInfoState extends State { ElevatedButton.icon(onPressed: _isPickingImage ? null : () => _setImage(setImageCallback, ImageSource.gallery, imageInfo, isRequired: isRequired), icon: const Icon(Icons.photo_library), label: const Text("Gallery")), ], ), - if (remarkController != null) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: TextFormField( - controller: remarkController, - decoration: InputDecoration( - labelText: 'Remarks for $title', - hintText: 'Add an optional remark...', - border: const OutlineInputBorder(), - ), - ), - ), + // --- START MODIFICATION: Removed remark text field --- + // --- END MODIFICATION --- ], ), ); diff --git a/lib/screens/register.dart b/lib/screens/register.dart index 06ffac4..88c9389 100644 --- a/lib/screens/register.dart +++ b/lib/screens/register.dart @@ -1,3 +1,5 @@ +// lib/screens/register.dart + import 'package:flutter/material.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:provider/provider.dart'; @@ -33,6 +35,20 @@ class _RegisterScreenState extends State { int? _selectedCompanyId; int? _selectedPositionId; + @override + void initState() { + super.initState(); + // Use addPostFrameCallback to safely access the Provider after the first frame. + WidgetsBinding.instance.addPostFrameCallback((_) { + final auth = Provider.of(context, listen: false); + // Check if data is already loaded to avoid unnecessary API calls. + if (auth.departments == null || auth.departments!.isEmpty) { + // Call the new, specific function that works without a login token. + auth.syncRegistrationData(); + } + }); + } + @override void dispose() { _usernameController.dispose(); @@ -120,6 +136,24 @@ class _RegisterScreenState extends State { final companies = auth.companies ?? []; final positions = auth.positions ?? []; + // If the lists are empty, it means data is likely still loading. + // Show a loading indicator. + if (departments.isEmpty && companies.isEmpty && positions.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text("Loading registration options..."), + ], + ), + ), + ); + } + return Form( key: _formKey, child: Column( diff --git a/lib/screens/river/manual/widgets/river_in_situ_step_1_sampling_info.dart b/lib/screens/river/manual/widgets/river_in_situ_step_1_sampling_info.dart index 60abec7..ddf4513 100644 --- a/lib/screens/river/manual/widgets/river_in_situ_step_1_sampling_info.dart +++ b/lib/screens/river/manual/widgets/river_in_situ_step_1_sampling_info.dart @@ -36,12 +36,10 @@ class _RiverInSituStep1SamplingInfoState extends State _statesList = []; List> _stationsForState = []; final List _samplingTypes = ['Schedule', 'Triennial']; - // REMOVED: Weather options list. @override void initState() { @@ -60,7 +58,6 @@ class _RiverInSituStep1SamplingInfoState extends State s['state_name'] as String?).whereType().toSet().toList(); @@ -99,7 +99,10 @@ class _RiverInSituStep1SamplingInfoState extends State s['state_name'] == widget.data.selectedStateName) - .toList(); + .toList() + // --- START MODIFICATION: Sort stations on initial load --- + ..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? '')); + // --- END MODIFICATION --- } setState(() { @@ -169,13 +172,75 @@ class _RiverInSituStep1SamplingInfoState extends State _findAndShowNearbyStations() async { + if (widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) { + await _getCurrentLocation(); + if (!mounted || widget.data.currentLatitude == null || widget.data.currentLatitude!.isEmpty) { + return; + } + } + + final service = Provider.of(context, listen: false); + final auth = Provider.of(context, listen: false); + + final currentLat = double.parse(widget.data.currentLatitude!); + final currentLon = double.parse(widget.data.currentLongitude!); + final allStations = auth.riverManualStations ?? []; + final List> nearbyStations = []; + + for (var station in allStations) { + final stationLat = station['sampling_lat']; + final stationLon = station['sampling_long']; + + if (stationLat is num && stationLon is num) { + final distance = service.calculateDistance(currentLat, currentLon, stationLat.toDouble(), stationLon.toDouble()); + if (distance <= 3.0) { + nearbyStations.add({'station': station, 'distance': distance}); + } + } + } + + nearbyStations.sort((a, b) => a['distance'].compareTo(b['distance'])); + + if (!mounted) return; + + final selectedStation = await showDialog>( + context: context, + builder: (context) => _NearbyStationsDialog(nearbyStations: nearbyStations), + ); + + if (selectedStation != null) { + _updateFormWithSelectedStation(selectedStation); + } + } + + void _updateFormWithSelectedStation(Map station) { + final allStations = Provider.of(context, listen: false).riverManualStations ?? []; + setState(() { + widget.data.selectedStateName = station['state_name']; + _stationsForState = allStations + .where((s) => s['state_name'] == widget.data.selectedStateName) + .toList() + ..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? '')); + + widget.data.selectedStation = station; + widget.data.stationLatitude = station['sampling_lat']?.toString(); + widget.data.stationLongitude = station['sampling_long']?.toString(); + _stationLatController.text = widget.data.stationLatitude ?? ''; + _stationLonController.text = widget.data.stationLongitude ?? ''; + + _calculateDistance(); + }); + } + + void _goToNextStep() { if (_formKey.currentState!.validate()) { _formKey.currentState!.save(); final distanceInMeters = (widget.data.distanceDifferenceInKm ?? 0) * 1000; - if (distanceInMeters > 700) { + if (distanceInMeters > 50) { _showDistanceRemarkDialog(); } else { widget.data.distanceDifferenceRemarks = null; @@ -201,7 +266,7 @@ class _RiverInSituStep1SamplingInfoState extends State(context, listen: false); final allStations = auth.riverManualStations ?? []; final allUsers = auth.allUsers ?? []; - final secondSamplersList = allUsers.where((user) => user['user_id'] != auth.profileData?['user_id']).toList(); + + // --- START MODIFICATION: Sort 2nd sampler list alphabetically --- + final secondSamplersList = allUsers.where((user) => user['user_id'] != auth.profileData?['user_id']).toList() + ..sort((a, b) => (a['first_name'] ?? '').compareTo(b['first_name'] ?? '')); + // --- END MODIFICATION --- return Form( key: _formKey, @@ -318,9 +387,12 @@ class _RiverInSituStep1SamplingInfoState extends State s['state_name'] == state) .toList() + // --- START MODIFICATION: Sort stations when state changes --- + ..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? ''))) + // --- END MODIFICATION --- : []; }); }, @@ -355,6 +427,17 @@ class _RiverInSituStep1SamplingInfoState extends State 700 ? Colors.red.withOpacity(0.1) : Colors.green.withOpacity(0.1), + color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red.withOpacity(0.1) : Colors.green.withOpacity(0.1), borderRadius: BorderRadius.circular(8), - border: Border.all(color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 700 ? Colors.red : Colors.green), + border: Border.all(color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red : Colors.green), ), child: RichText( textAlign: TextAlign.center, @@ -382,7 +465,7 @@ class _RiverInSituStep1SamplingInfoState extends State 700 ? Colors.red : Colors.green + color: ((widget.data.distanceDifferenceInKm ?? 0) * 1000) > 50 ? Colors.red : Colors.green ), ), ], @@ -398,8 +481,6 @@ class _RiverInSituStep1SamplingInfoState extends State> nearbyStations; + + const _NearbyStationsDialog({required this.nearbyStations}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Nearby Stations (within 3km)'), + content: SizedBox( + width: double.maxFinite, + child: nearbyStations.isEmpty + ? const Center(child: Text('No stations found.')) + : ListView.builder( + shrinkWrap: true, + itemCount: nearbyStations.length, + itemBuilder: (context, index) { + final item = nearbyStations[index]; + final station = item['station'] as Map; + final distanceInMeters = (item['distance'] as double) * 1000; + + return Card( + child: ListTile( + title: Text("${station['sampling_station_code'] ?? 'N/A'}"), + subtitle: Text("${station['sampling_river'] ?? 'N/A'}"), + trailing: Text("${distanceInMeters.toStringAsFixed(0)} m"), + onTap: () { + Navigator.of(context).pop(station); + }, + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ], + ); + } } \ No newline at end of file diff --git a/lib/screens/river/manual/widgets/river_in_situ_step_2_site_info.dart b/lib/screens/river/manual/widgets/river_in_situ_step_2_site_info.dart index 46c6d7c..8451539 100644 --- a/lib/screens/river/manual/widgets/river_in_situ_step_2_site_info.dart +++ b/lib/screens/river/manual/widgets/river_in_situ_step_2_site_info.dart @@ -28,7 +28,7 @@ class _RiverInSituStep2SiteInfoState extends State { late final TextEditingController _eventRemarksController; late final TextEditingController _labRemarksController; - final List _weatherOptions = ['Clear', 'Rainy', 'Cloudy']; + final List _weatherOptions = ['Cloudy', 'Drizzle', 'Rainy', 'Sunny', 'Windy']; @override void initState() { diff --git a/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart b/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart index 5e05e82..3f53f81 100644 --- a/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart +++ b/lib/screens/river/manual/widgets/river_in_situ_step_3_data_capture.dart @@ -35,8 +35,13 @@ class _RiverInSituStep3DataCaptureState extends State? _previousReadingsForComparison; + Set _outOfBoundsKeys = {}; final Map _parameterKeyToLimitName = const { 'oxygenConcentration': 'Oxygen Conc', @@ -48,6 +53,7 @@ class _RiverInSituStep3DataCaptureState extends State(context, listen: false); + // --- END FIX --- _initializeControllers(); _initializeFlowrateControllers(); WidgetsBinding.instance.addObserver(this); @@ -87,6 +96,16 @@ class _RiverInSituStep3DataCaptureState extends State limit['department_id'] == 3).toList(); final outOfBoundsParams = _validateParameters(currentReadings, riverLimits); + setState(() { + _outOfBoundsKeys = outOfBoundsParams.map((p) => _parameters.firstWhere((param) => param['label'] == p['label'])['key'] as String).toSet(); + }); + if (outOfBoundsParams.isNotEmpty) { _showParameterLimitDialog(outOfBoundsParams, currentReadings); } else { @@ -445,11 +466,12 @@ class _RiverInSituStep3DataCaptureState extends State TableRow( children: [ Padding(padding: const EdgeInsets.all(6.0), child: Text(p['label'])), - Padding(padding: const EdgeInsets.all(6.0), child: Text('${p['lower_limit']?.toStringAsFixed(1) ?? 'N/A'} - ${p['upper_limit']?.toStringAsFixed(1) ?? 'N/A'}')), + Padding(padding: const EdgeInsets.all(6.0), child: Text('${p['lower_limit']?.toStringAsFixed(5) ?? 'N/A'} - ${p['upper_limit']?.toStringAsFixed(5) ?? 'N/A'}')), Padding( padding: const EdgeInsets.all(6.0), child: Text( - p['value'].toStringAsFixed(2), + p['value'].toStringAsFixed(5), style: const TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold), ), ), @@ -772,7 +806,7 @@ class _RiverInSituStep3DataCaptureState extends State _parameterKeyToLimitName = { + 'oxygenConcentration': 'Oxygen Conc', + 'oxygenSaturation': 'Oxygen Sat', + 'ph': 'pH', + 'salinity': 'Salinity', + 'electricalConductivity': 'Conductivity', + 'temperature': 'Temperature', + 'tds': 'TDS', + 'turbidity': 'Turbidity', + 'ammonia': 'Ammonia', + 'batteryVoltage': 'Battery', + }; + + /// Re-validates the final parameters against the defined limits. + Set _getOutOfBoundsKeys(BuildContext context) { + final authProvider = Provider.of(context, listen: false); + // Filter for River department (id: 3) + final riverLimits = (authProvider.parameterLimits ?? []).where((limit) => limit['department_id'] == 3).toList(); + final Set invalidKeys = {}; + + 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, + 'ammonia': data.ammonia, 'batteryVoltage': data.batteryVoltage, + }; + + 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; + + final limitData = riverLimits.firstWhere((l) => l['param_parameter_list'] == limitName, 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)) { + invalidKeys.add(key); + } + } + }); + + return invalidKeys; + } + // --- END: MODIFICATION FOR HIGHLIGHTING --- + @override Widget build(BuildContext context) { + // --- START: MODIFICATION FOR HIGHLIGHTING --- + // Get the set of out-of-bounds keys before building the list. + final outOfBoundsKeys = _getOutOfBoundsKeys(context); + // --- END: MODIFICATION FOR HIGHLIGHTING --- + return ListView( padding: const EdgeInsets.all(16.0), children: [ @@ -92,17 +159,18 @@ class RiverInSituStep5Summary extends StatelessWidget { _buildDetailRow("Sonde ID:", data.sondeId), _buildDetailRow("Capture Time:", "${data.dataCaptureDate} ${data.dataCaptureTime}"), const Divider(height: 20), - _buildParameterListItem(context, icon: Icons.air, label: "Oxygen Conc.", unit: "mg/L", value: data.oxygenConcentration?.toStringAsFixed(2)), - _buildParameterListItem(context, icon: Icons.percent, label: "Oxygen Sat.", unit: "%", value: data.oxygenSaturation?.toStringAsFixed(2)), - _buildParameterListItem(context, icon: Icons.science_outlined, label: "pH", unit: "", value: data.ph?.toStringAsFixed(2)), - _buildParameterListItem(context, icon: Icons.waves, label: "Salinity", unit: "ppt", value: data.salinity?.toStringAsFixed(2)), - _buildParameterListItem(context, icon: Icons.flash_on, label: "Conductivity", unit: "µS/cm", value: data.electricalConductivity?.toStringAsFixed(0)), - _buildParameterListItem(context, icon: Icons.thermostat, label: "Temperature", unit: "°C", value: data.temperature?.toStringAsFixed(2)), - _buildParameterListItem(context, icon: Icons.grain, label: "TDS", unit: "mg/L", value: data.tds?.toStringAsFixed(2)), - _buildParameterListItem(context, icon: Icons.opacity, label: "Turbidity", unit: "NTU", value: data.turbidity?.toStringAsFixed(2)), - _buildParameterListItem(context, icon: Icons.science, label: "Ammonia", unit: "mg/L", value: data.ammonia?.toStringAsFixed(2)), // MODIFIED: Replaced TSS with Ammonia - _buildParameterListItem(context, icon: Icons.battery_charging_full, label: "Battery", unit: "V", value: data.batteryVoltage?.toStringAsFixed(2)), - + // --- START: MODIFICATION FOR 5 DECIMALS & HIGHLIGHTING --- + _buildParameterListItem(context, icon: Icons.air, label: "Oxygen Conc.", unit: "mg/L", value: data.oxygenConcentration, isOutOfBounds: outOfBoundsKeys.contains('oxygenConcentration')), + _buildParameterListItem(context, icon: Icons.percent, label: "Oxygen Sat.", unit: "%", value: data.oxygenSaturation, isOutOfBounds: outOfBoundsKeys.contains('oxygenSaturation')), + _buildParameterListItem(context, icon: Icons.science_outlined, label: "pH", unit: "", value: data.ph, isOutOfBounds: outOfBoundsKeys.contains('ph')), + _buildParameterListItem(context, icon: Icons.waves, label: "Salinity", unit: "ppt", value: data.salinity, isOutOfBounds: outOfBoundsKeys.contains('salinity')), + _buildParameterListItem(context, icon: Icons.flash_on, label: "Conductivity", unit: "µS/cm", value: data.electricalConductivity, isOutOfBounds: outOfBoundsKeys.contains('electricalConductivity')), + _buildParameterListItem(context, icon: Icons.thermostat, label: "Temperature", unit: "°C", value: data.temperature, isOutOfBounds: outOfBoundsKeys.contains('temperature')), + _buildParameterListItem(context, icon: Icons.grain, label: "TDS", unit: "mg/L", value: data.tds, isOutOfBounds: outOfBoundsKeys.contains('tds')), + _buildParameterListItem(context, icon: Icons.opacity, label: "Turbidity", unit: "NTU", value: data.turbidity, isOutOfBounds: outOfBoundsKeys.contains('turbidity')), + _buildParameterListItem(context, icon: Icons.science, label: "Ammonia", unit: "mg/L", value: data.ammonia, isOutOfBounds: outOfBoundsKeys.contains('ammonia')), + _buildParameterListItem(context, icon: Icons.battery_charging_full, label: "Battery", unit: "V", value: data.batteryVoltage, isOutOfBounds: outOfBoundsKeys.contains('batteryVoltage')), + // --- END: MODIFICATION --- const Divider(height: 20), _buildFlowrateSummary(context), ], @@ -176,9 +244,17 @@ class RiverInSituStep5Summary extends StatelessWidget { ); } - Widget _buildParameterListItem(BuildContext context, {required IconData icon, required String label, required String unit, required String? value}) { - final bool isMissing = value == null || value.contains('-999'); - final String displayValue = isMissing ? 'N/A' : '$value ${unit}'.trim(); + // --- START: MODIFICATION FOR 5 DECIMALS & HIGHLIGHTING --- + Widget _buildParameterListItem(BuildContext context, {required IconData icon, required String label, required String unit, required double? value, bool isOutOfBounds = false}) { + final bool isMissing = value == null || value == -999.0; + // Format the value to 5 decimal places if it's a valid number. + final String displayValue = isMissing ? 'N/A' : '${value.toStringAsFixed(5)} ${unit}'.trim(); + + // Determine the color for the value based on theme and status. + final Color? defaultTextColor = Theme.of(context).textTheme.bodyLarge?.color; + final Color valueColor = isOutOfBounds + ? Colors.red + : (isMissing ? Colors.grey : defaultTextColor ?? Colors.black); return ListTile( dense: true, @@ -188,12 +264,13 @@ class RiverInSituStep5Summary extends StatelessWidget { trailing: Text( displayValue, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: isMissing ? Colors.grey : null, - fontWeight: isMissing ? null : FontWeight.bold, + color: valueColor, + fontWeight: isOutOfBounds ? FontWeight.bold : null, ), ), ); } + // --- END: MODIFICATION --- Widget _buildImageCard(String title, File? image, {String? remark}) { final bool hasRemark = remark != null && remark.isNotEmpty; @@ -229,7 +306,6 @@ class RiverInSituStep5Summary extends StatelessWidget { ); } - // FIX: Reorganized the widget for a cleaner and more beautiful presentation. Widget _buildFlowrateSummary(BuildContext context) { final method = data.flowrateMethod ?? 'N/A'; diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 3f792fa..f6d3125 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -650,7 +650,7 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.info_outline), title: const Text('App Version'), - subtitle: const Text('1.2.03'), + subtitle: const Text('MMS V4 1.2.07'), dense: true, ), ListTile( diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 080d9ed..c8318fb 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -330,6 +330,66 @@ class ApiService { return {'success': false, 'message': 'Data sync failed: $e'}; } } + + // --- START: NEW METHOD FOR REGISTRATION SCREEN --- + /// Fetches only the public master data required for the registration screen. + Future> syncRegistrationData() async { + debugPrint('ApiService: Starting registration data sync...'); + try { + // Define only the tasks needed for registration + final syncTasks = { + 'departments': { + 'endpoint': 'departments', + 'handler': (d, id) async { + await dbHelper.upsertDepartments(d); + await dbHelper.deleteDepartments(id); + } + }, + 'companies': { + 'endpoint': 'companies', + 'handler': (d, id) async { + await dbHelper.upsertCompanies(d); + await dbHelper.deleteCompanies(id); + } + }, + 'positions': { + 'endpoint': 'positions', + 'handler': (d, id) async { + await dbHelper.upsertPositions(d); + await dbHelper.deletePositions(id); + } + }, + }; + + // Fetch all deltas in parallel, always a full fetch (since = null) + final fetchFutures = syncTasks.map((key, value) => + MapEntry(key, _fetchDelta(value['endpoint'] as String, null))); + final results = await Future.wait(fetchFutures.values); + final resultData = Map.fromIterables(fetchFutures.keys, results); + + // Process and save all changes + for (var entry in resultData.entries) { + final key = entry.key; + final result = entry.value; + + if (result['success'] == true && result['data'] != null) { + final updated = List>.from(result['data']['updated'] ?? []); + final deleted = List.from(result['data']['deleted'] ?? []); + await (syncTasks[key]!['handler'] as Function)(updated, deleted); + } else { + debugPrint('ApiService: Failed to sync $key. Message: ${result['message']}'); + } + } + + debugPrint('ApiService: Registration data sync complete.'); + return {'success': true, 'message': 'Registration data sync successful.'}; + } catch (e) { + debugPrint('ApiService: Registration data sync failed: $e'); + return {'success': false, 'message': 'Registration data sync failed: $e'}; + } + } +// --- END: NEW METHOD FOR REGISTRATION SCREEN --- + } // =======================================================================