From da1de869ed54fa37ba8ad549e9685b609ee0f158 Mon Sep 17 00:00:00 2001 From: ALim Aidrus Date: Thu, 6 Nov 2025 15:21:19 +0800 Subject: [PATCH] repair api n ftp settings to be automatically selected during installation --- lib/auth_provider.dart | 42 +++- lib/home_page.dart | 2 +- lib/main.dart | 42 ++++ lib/screens/login.dart | 29 ++- ...rine_inves_manual_step_3_data_capture.dart | 30 ++- .../marine/manual/marine_image_request.dart | 194 +++++++++++++++--- .../tarball_sampling_step3_summary.dart | 2 +- .../widgets/in_situ_step_3_data_capture.dart | 28 ++- ...ver_inves_in_situ_step_3_data_capture.dart | 31 ++- ..._manual_triennial_step_3_data_capture.dart | 27 ++- .../river_in_situ_step_3_data_capture.dart | 25 ++- lib/screens/settings.dart | 127 +++++++++++- lib/services/api_service.dart | 161 +++++++++------ lib/services/marine_api_service.dart | 78 +++++++ lib/services/user_preferences_service.dart | 65 +++--- 15 files changed, 731 insertions(+), 152 deletions(-) diff --git a/lib/auth_provider.dart b/lib/auth_provider.dart index abd13d7..3219c02 100644 --- a/lib/auth_provider.dart +++ b/lib/auth_provider.dart @@ -57,6 +57,9 @@ class AuthProvider with ChangeNotifier { List>? _tarballClassifications; List>? _riverManualStations; List>? _riverTriennialStations; + // --- ADDED: River Investigative Stations --- + List>? _riverInvestigativeStations; + // --- END ADDED --- List>? _departments; List>? _companies; List>? _positions; @@ -79,6 +82,9 @@ class AuthProvider with ChangeNotifier { List>? get tarballClassifications => _tarballClassifications; List>? get riverManualStations => _riverManualStations; List>? get riverTriennialStations => _riverTriennialStations; + // --- ADDED: Getter for River Investigative Stations --- + List>? get riverInvestigativeStations => _riverInvestigativeStations; + // --- END ADDED --- List>? get departments => _departments; List>? get companies => _companies; List>? get positions => _positions; @@ -141,17 +147,39 @@ class AuthProvider with ChangeNotifier { } } + // --- START MODIFIED: Load server config with is_active check --- // Load server config early - final activeApiConfig = await _serverConfigService.getActiveApiConfig(); + Map? activeApiConfig = await _serverConfigService.getActiveApiConfig(); if (activeApiConfig == null) { - debugPrint("AuthProvider: No active API config found. Setting default bootstrap URL."); + debugPrint("AuthProvider: No active config in SharedPreferences. Checking local DB for 'is_active' flag..."); + // Load configs directly from DB helper (since cache isn't loaded yet) + final allApiConfigs = await _dbHelper.loadApiConfigs(); + if (allApiConfigs != null && allApiConfigs.isNotEmpty) { + try { + // Find the first config marked as active. Note: 'is_active' might be 1 or true. + activeApiConfig = allApiConfigs.firstWhere( + (c) => c['is_active'] == 1 || c['is_active'] == true, + orElse: () => allApiConfigs.first, // Fallback to just the first config if none are active + ); + debugPrint("AuthProvider: Found active config in DB: ${activeApiConfig['config_name']}"); + await _serverConfigService.setActiveApiConfig(activeApiConfig); + } catch (e) { + debugPrint("AuthProvider: Error finding active config in DB. $e"); + } + } + } + + // If still no config (empty DB, first launch), set hardcoded default + if (activeApiConfig == null) { + debugPrint("AuthProvider: No active config found in DB. Setting default bootstrap URL."); final initialConfig = { 'api_config_id': 0, 'config_name': 'Default Server', - 'api_url': 'https://mms-apiv4.pstw.com.my/v1', // Use actual default if needed + 'api_url': 'https://mms-apiv4.pstw.com.my/v1', // This is your bootstrap URL }; await _serverConfigService.setActiveApiConfig(initialConfig); } + // --- END MODIFIED --- final lastSyncString = prefs.getString(lastSyncTimestampKey); if (lastSyncString != null) { @@ -499,6 +527,9 @@ class AuthProvider with ChangeNotifier { _tarballClassifications = await _dbHelper.loadTarballClassifications(); _riverManualStations = await _dbHelper.loadRiverManualStations(); _riverTriennialStations = await _dbHelper.loadRiverTriennialStations(); + // --- MODIFIED: Load River Investigative Stations --- + _riverInvestigativeStations = await _dbHelper.loadRiverInvestigativeStations(); + // --- END MODIFIED --- _departments = await _dbHelper.loadDepartments(); _companies = await _dbHelper.loadCompanies(); _positions = await _dbHelper.loadPositions(); @@ -528,7 +559,9 @@ class AuthProvider with ChangeNotifier { // Now proceed with post-login actions that *don't* belong in the helper debugPrint('AuthProvider: Login successful. Session and profile persisted.'); + // --- THIS IS THE LINE THAT FIXES THE TICKING --- await _userPreferencesService.applyAndSaveDefaultPreferencesIfNeeded(); + // --- // The main sync triggered by a direct user login await syncAllData(forceRefresh: true); // Notify listeners *after* sync is attempted (or throws) @@ -625,6 +658,9 @@ class AuthProvider with ChangeNotifier { _tarballClassifications = null; _riverManualStations = null; _riverTriennialStations = null; + // --- MODIFIED: Clear River Investigative Stations --- + _riverInvestigativeStations = null; + // --- END MODIFIED --- _departments = null; _companies = null; _positions = null; diff --git a/lib/home_page.dart b/lib/home_page.dart index 7da25d2..f9cf145 100644 --- a/lib/home_page.dart +++ b/lib/home_page.dart @@ -29,7 +29,7 @@ class _HomePageState extends State { }); }, ), - title: const Text("MMS Version 3.7.01"), + title: const Text("MMS Version 3.8.01"), actions: [ IconButton( icon: const Icon(Icons.person), diff --git a/lib/main.dart b/lib/main.dart index 77f5690..1327a0f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -205,13 +205,28 @@ class RootApp extends StatefulWidget { } class _RootAppState extends State { + // --- START: MODIFICATION FOR HOURLY SYNC --- + Timer? _configSyncTimer; + // --- END: MODIFICATION --- + @override void initState() { super.initState(); _initializeConnectivityListener(); _performInitialSessionCheck(); + // --- START: MODIFICATION FOR HOURLY SYNC --- + _initializePeriodicSync(); // Start the hourly sync timer + // --- END: MODIFICATION --- } + // --- START: MODIFICATION FOR HOURLY SYNC --- + @override + void dispose() { + _configSyncTimer?.cancel(); // Cancel the timer when the app closes + super.dispose(); + } + // --- END: MODIFICATION --- + /// Initial check when app loads to see if we need to transition from offline to online. void _performInitialSessionCheck() async { // Wait a moment for providers to be fully available. @@ -259,6 +274,33 @@ class _RootAppState extends State { }); } + // --- START: MODIFICATION FOR HOURLY SYNC --- + /// Initializes a recurring timer to sync data periodically. + void _initializePeriodicSync() { + // Start a timer for 1 hour (as requested). You can change this duration. + _configSyncTimer = Timer.periodic(const Duration(hours: 1), (timer) { + debugPrint("[Main] Periodic 1-hour sync triggered."); + + // Use 'context.read' for a safe, one-time read inside a timer + if (mounted) { + final authProvider = context.read(); + + // Only sync if the user is logged in and the session isn't expired + if (authProvider.isLoggedIn && !authProvider.isSessionExpired) { + debugPrint("[Main] User is logged in. Starting periodic data sync..."); + + // Run syncAllData but don't block. Catch errors to prevent the timer from stopping. + authProvider.syncAllData().catchError((e) { + debugPrint("[Main] Error during periodic 1-hour sync: $e"); + }); + } else { + debugPrint("[Main] Skipping periodic sync: User not logged in or session expired."); + } + } + }); + } + // --- END: MODIFICATION --- + @override Widget build(BuildContext context) { return Consumer( diff --git a/lib/screens/login.dart b/lib/screens/login.dart index c919485..ac2d900 100644 --- a/lib/screens/login.dart +++ b/lib/screens/login.dart @@ -147,13 +147,32 @@ class _LoginScreenState extends State { ), ), const SizedBox(height: 32), - Center( - child: Image.asset( - 'assets/icon_3_512x512.png', - height: 120, - width: 120, + + // --- MODIFICATION START --- + // Replaced the original `Center(child: Image.asset(...))` + // with this `Container` to make the icon circular and add a shadow. + Container( + width: 130, + height: 130, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, // Background color in case icon has transparency + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), // Shadow color + spreadRadius: 2, // How far the shadow spreads + blurRadius: 8, // The blurriness of the shadow + offset: const Offset(0, 4), // Position of shadow (x, y) + ), + ], + image: const DecorationImage( + image: AssetImage('assets/icon_3_512x512.png'), + fit: BoxFit.cover, // Ensures the image fills the circle + ), ), ), + // --- MODIFICATION END --- + const SizedBox(height: 48), TextFormField( controller: _emailController, diff --git a/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_3_data_capture.dart b/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_3_data_capture.dart index 9a3f960..f9778c6 100644 --- a/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_3_data_capture.dart +++ b/lib/screens/marine/investigative/manual_sampling/marine_inves_manual_step_3_data_capture.dart @@ -95,12 +95,29 @@ class _MarineInvesManualStep3DataCaptureState extends State(); + // Use the member variable, not context, for lifecycle safety + final service = _samplingService; if (type == 'bluetooth') { service.disconnectFromBluetooth(); } else { @@ -296,12 +315,16 @@ class _MarineInvesManualStep3DataCaptureState extends State(); + // Use the member variable, not context, for lifecycle safety + final service = _samplingService; if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { _disconnect('bluetooth'); } @@ -309,6 +332,7 @@ class _MarineInvesManualStep3DataCaptureState extends State readings) { const defaultValue = -999.0; diff --git a/lib/screens/marine/manual/marine_image_request.dart b/lib/screens/marine/manual/marine_image_request.dart index 45c59af..176c3b9 100644 --- a/lib/screens/marine/manual/marine_image_request.dart +++ b/lib/screens/marine/manual/marine_image_request.dart @@ -7,6 +7,37 @@ import 'package:image_picker/image_picker.dart'; import '../../../auth_provider.dart'; import '../../../services/api_service.dart'; +// --- FIX: Import the new dedicated service --- +import '../../../services/marine_api_service.dart'; + +// --- FIX: Define image key lists --- +const List _inSituImageKeys = [ + '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', +]; + + +// --- START: MODIFIED TO FIX 0 IMAGE URLS --- +// These keys are now an exact match for the keys in your server log +const List _tarballImageKeys = [ + 'left_side_coastal_view', + 'right_side_coastal_view', + 'drawing_vertical_lines', + 'drawing_horizontal_line', + 'optional_photo_01', + 'optional_photo_02', + 'optional_photo_03', + 'optional_photo_04', +]; +// --- END: MODIFIED TO FIX 0 IMAGE URLS --- + class MarineImageRequestScreen extends StatefulWidget { const MarineImageRequestScreen({super.key}); @@ -19,8 +50,9 @@ class _MarineImageRequestScreenState extends State { final _formKey = GlobalKey(); final _dateController = TextEditingController(); - String? _selectedSamplingType = 'All Manual Sampling'; - final List _samplingTypes = ['All Manual Sampling', 'In-Situ Sampling', 'Tarball Sampling']; + // --- FIX: Default to a specific type, not 'All' --- + String? _selectedSamplingType = 'In-Situ Sampling'; + final List _samplingTypes = ['In-Situ Sampling', 'Tarball Sampling', 'All Manual Sampling']; String? _selectedStateName; String? _selectedCategoryName; @@ -38,7 +70,12 @@ class _MarineImageRequestScreenState extends State { @override void initState() { super.initState(); - _initializeStationFilters(); + // --- FIX: Use addPostFrameCallback to ensure provider is ready --- + WidgetsBinding.instance.addPostFrameCallback((_) { + if(mounted) { + _initializeStationFilters(); + } + }); } @override @@ -47,14 +84,85 @@ class _MarineImageRequestScreenState extends State { super.dispose(); } + // --- FIX: New function to get the correct station list from AuthProvider --- + List> _getStationsForType(AuthProvider auth) { + switch (_selectedSamplingType) { + case 'In-Situ Sampling': + return auth.manualStations ?? []; + case 'Tarball Sampling': + return auth.tarballStations ?? []; + case 'All Manual Sampling': + // 'All' is complex. Defaulting to manual stations as a fallback. + return auth.manualStations ?? []; + default: + return []; + } + } + + // --- FIX: New function to get the correct station ID key --- + String _getStationIdKey() { + switch (_selectedSamplingType) { + case 'In-Situ Sampling': + return 'station_id'; // from manualStations + case 'Tarball Sampling': + return 'station_id'; // from tarballStations (assuming 'station_id') + default: + return 'station_id'; + } + } + + // --- FIX: New function to get the correct station code key --- + String _getStationCodeKey() { + switch (_selectedSamplingType) { + case 'In-Situ Sampling': + return 'man_station_code'; + case 'Tarball Sampling': + return 'tbl_station_code'; // Please verify this key + default: + return 'man_station_code'; + } + } + + // --- FIX: New function to get the correct station name key --- + String _getStationNameKey() { + switch (_selectedSamplingType) { + case 'In-Situ Sampling': + return 'man_station_name'; + case 'Tarball Sampling': + return 'tbl_station_name'; // Please verify this key + default: + return 'man_station_name'; + } + } + + // --- FIX: Function modified to be dynamic --- void _initializeStationFilters() { final auth = Provider.of(context, listen: false); - final allStations = auth.manualStations ?? []; + // --- FIX: Use helper to get dynamic station list --- + final allStations = _getStationsForType(auth); + if (allStations.isNotEmpty) { + // Assuming 'state_name' exists on all station types final states = allStations.map((s) => s['state_name'] as String?).whereType().toSet().toList(); states.sort(); setState(() { _statesList = states; + // --- FIX: Reset dependent fields on change --- + _selectedStateName = null; + _selectedCategoryName = null; + _selectedStation = null; + _categoriesForState = []; + _stationsForCategory = []; + }); + } else { + // --- FIX: Handle empty list --- + setState(() { + _statesList = []; + _selectedStateName = null; + _selectedCategoryName = null; + _selectedStation = null; + _categoriesForState = []; + _stationsForCategory = []; }); } } @@ -102,7 +210,10 @@ class _MarineImageRequestScreenState extends State { return; } - final stationId = _selectedStation!['station_id']; + // --- FIX: Get the correct station ID key --- + final stationIdKey = _getStationIdKey(); + final stationId = _selectedStation![stationIdKey]; + if (stationId == null) { debugPrint("[Image Request] ERROR: Station ID is null."); if (mounted) { @@ -121,33 +232,47 @@ class _MarineImageRequestScreenState extends State { try { debugPrint("[Image Request] All checks passed. Calling API with Station ID: $stationId, Type: $_selectedSamplingType"); + + // This call is correct, as apiService.marine holds the MarineApiService final result = await apiService.marine.getManualSamplingImages( - stationId: stationId, + stationId: stationId as int, // Ensure it's passed as int samplingDate: _selectedDate!, - samplingType: _selectedSamplingType!, + samplingType: _selectedSamplingType!, // This is now used by the API service ); if (mounted && result['success'] == true) { + // --- FIX: The data key from your new log is just 'data' --- final List> records = List>.from(result['data'] ?? []); final List 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', - ]; + // --- FIX: Determine which set of keys to use --- + List imageKeys; + switch (_selectedSamplingType) { + case 'In-Situ Sampling': + imageKeys = _inSituImageKeys; + break; + case 'Tarball Sampling': + imageKeys = _tarballImageKeys; // Now uses the corrected list + break; + case 'All Manual Sampling': + default: + // Default to In-Situ keys as a fallback + imageKeys = _inSituImageKeys; + break; + } + // If 'All Manual Sampling' is selected, you could merge keys: + // imageKeys = [..._inSituImageKeys, ..._tarballImageKeys]; 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; + + // --- FIX: Make URL construction robust --- + final fullUrl = imagePathFromServer.startsWith('http') + ? imagePathFromServer + : ApiService.imageBaseUrl + imagePathFromServer; + fetchedUrls.add(fullUrl); debugPrint("[Image Request] Found and constructed URL: $fullUrl"); } @@ -155,7 +280,8 @@ class _MarineImageRequestScreenState extends State { } setState(() { - _imageUrls = fetchedUrls; + // --- FIX: Use toSet() to remove potential duplicates --- + _imageUrls = fetchedUrls.toSet().toList(); }); debugPrint("[Image Request] Successfully processed and constructed ${_imageUrls.length} image URLs."); } else if (mounted) { @@ -261,8 +387,9 @@ class _MarineImageRequestScreenState extends State { 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'; + // --- FIX: Use dynamic keys --- + final stationCode = _selectedStation?[_getStationCodeKey()] ?? 'N/A'; + final stationName = _selectedStation?[_getStationNameKey()] ?? 'N/A'; final fullStationIdentifier = '$stationCode - $stationName'; final result = await apiService.marine.sendImageRequestEmail( @@ -318,7 +445,11 @@ class _MarineImageRequestScreenState extends State { DropdownButtonFormField( value: _selectedSamplingType, items: _samplingTypes.map((type) => DropdownMenuItem(value: type, child: Text(type))).toList(), - onChanged: (value) => setState(() => _selectedSamplingType = value), + onChanged: (value) => setState(() { + _selectedSamplingType = value; + // --- FIX: Re-initialize filters when type changes --- + _initializeStationFilters(); + }), decoration: const InputDecoration(labelText: 'Sampling Type *', border: OutlineInputBorder()), validator: (value) => value == null ? 'Please select a type' : null, ), @@ -334,7 +465,8 @@ class _MarineImageRequestScreenState extends State { _selectedCategoryName = null; _selectedStation = null; final auth = Provider.of(context, listen: false); - final allStations = auth.manualStations ?? []; + // --- FIX: Use helper to get dynamic station list --- + final allStations = _getStationsForType(auth); final categories = state != null ? allStations.where((s) => s['state_name'] == state).map((s) => s['category_name'] as String?).whereType().toSet().toList() : []; categories.sort(); _categoriesForState = categories; @@ -355,8 +487,11 @@ class _MarineImageRequestScreenState extends State { _selectedCategoryName = category; _selectedStation = null; final auth = Provider.of(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'] ?? ''))) : []; + // --- FIX: Use helper to get dynamic station list --- + final allStations = _getStationsForType(auth); + // --- FIX: Use dynamic key for sorting --- + final stationCodeKey = _getStationCodeKey(); + _stationsForCategory = category != null ? (allStations.where((s) => s['state_name'] == _selectedStateName && s['category_name'] == category).toList()..sort((a, b) => (a[stationCodeKey] ?? '').compareTo(b[stationCodeKey] ?? ''))) : []; }); }, validator: (val) => _selectedStateName != null && val == null ? "Category is required" : null, @@ -366,7 +501,12 @@ class _MarineImageRequestScreenState extends State { items: _stationsForCategory, selectedItem: _selectedStation, enabled: _selectedCategoryName != null, - itemAsString: (station) => "${station['man_station_code']} - ${station['man_station_name']}", + // --- FIX: Use dynamic keys for display string --- + itemAsString: (station) { + final code = station[_getStationCodeKey()] ?? 'N/A'; + final name = station[_getStationNameKey()] ?? 'N/A'; + return "$code - $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), diff --git a/lib/screens/marine/manual/tarball_sampling_step3_summary.dart b/lib/screens/marine/manual/tarball_sampling_step3_summary.dart index f554df1..6dd14c5 100644 --- a/lib/screens/marine/manual/tarball_sampling_step3_summary.dart +++ b/lib/screens/marine/manual/tarball_sampling_step3_summary.dart @@ -302,7 +302,7 @@ class _TarballSamplingStep3SummaryState extends State with Wi super.dispose(); } + // --- START: MODIFIED LIFECYCLE METHOD --- @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { if (mounted) { - setState(() {}); + // Use the member variable, not context + final service = _samplingService; + final btConnecting = service.bluetoothConnectionState.value == BluetoothConnectionState.connecting; + final serialConnecting = service.serialConnectionState.value == SerialConnectionState.connecting; + + // If the widget's local state is loading OR the service's state is stuck connecting + if (_isLoading || btConnecting || serialConnecting) { + // Force-call disconnect to reset both the service's state + // and the local _isLoading flag (inside _disconnect). + _disconnectFromAll(); + } else { + // If not stuck, just a normal refresh + setState(() {}); + } } } } + // --- END: MODIFIED LIFECYCLE METHOD --- void _initializeControllers() { widget.data.dataCaptureDate = widget.data.samplingDate; @@ -298,8 +313,10 @@ class _InSituStep3DataCaptureState extends State with Wi }); } + // --- START: MODIFIED DISCONNECT METHOD --- void _disconnect(String type) { - final service = context.read(); + // Use the member variable, not context, for lifecycle safety + final service = _samplingService; if (type == 'bluetooth') { service.disconnectFromBluetooth(); } else { @@ -312,12 +329,16 @@ class _InSituStep3DataCaptureState extends State with Wi setState(() { _isAutoReading = false; _isLockedOut = false; // --- MODIFICATION: Reset lockout state --- + _isLoading = false; // <-- CRITICAL: Also reset the loading flag }); } } + // --- END: MODIFIED DISCONNECT METHOD --- + // --- START: MODIFIED DISCONNECT_ALL METHOD --- void _disconnectFromAll() { - final service = context.read(); + // Use the member variable, not context, for lifecycle safety + final service = _samplingService; if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { _disconnect('bluetooth'); } @@ -325,6 +346,7 @@ class _InSituStep3DataCaptureState extends State with Wi _disconnect('serial'); } } + // --- END: MODIFIED DISCONNECT_ALL METHOD --- void _updateTextFields(Map readings) { const defaultValue = -999.0; diff --git a/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_3_data_capture.dart b/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_3_data_capture.dart index ae57370..c95eb16 100644 --- a/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_3_data_capture.dart +++ b/lib/screens/river/investigative/manual_sampling/river_inves_in_situ_step_3_data_capture.dart @@ -114,7 +114,21 @@ class _RiverInvesStep3DataCaptureState extends State void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { if (mounted) { - setState(() {}); // Refresh UI, e.g., to re-check connection state + // --- START MODIFICATION --- + final service = _samplingService; + final btConnecting = service.bluetoothConnectionState.value == BluetoothConnectionState.connecting; + final serialConnecting = service.serialConnectionState.value == SerialConnectionState.connecting; + + // If the widget's local state is loading OR the service's state is stuck connecting + if (_isLoading || btConnecting || serialConnecting) { + // Force-call disconnect to reset both the service's state + // and the local _isLoading flag (inside _disconnect). + _disconnectFromAll(); + } else { + // If not stuck, just a normal refresh + setState(() {}); + } + // --- END MODIFICATION --- } } } @@ -393,10 +407,13 @@ class _RiverInvesStep3DataCaptureState extends State void _disconnect(String type) { // Logic copied from RiverInSituStep3DataCaptureState._disconnect // Uses the correct _samplingService instance + // --- START MODIFICATION --- + final service = _samplingService; // NEW: Use the member variable + // --- END MODIFICATION --- if (type == 'bluetooth') { - _samplingService.disconnectFromBluetooth(); + service.disconnectFromBluetooth(); } else { - _samplingService.disconnectFromSerial(); + service.disconnectFromSerial(); } _dataSubscription?.cancel(); _dataSubscription = null; @@ -405,16 +422,20 @@ class _RiverInvesStep3DataCaptureState extends State setState(() { _isAutoReading = false; _isLockedOut = false; // Reset lockout state + _isLoading = false; // --- NEW: Also reset the loading flag --- }); } } void _disconnectFromAll() { // Logic copied from RiverInSituStep3DataCaptureState._disconnectFromAll - if (_samplingService.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { + // --- START MODIFICATION --- + final service = _samplingService; // NEW: Use the member variable + // --- END MODIFICATION --- + if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { _disconnect('bluetooth'); } - if (_samplingService.serialConnectionState.value != SerialConnectionState.disconnected) { + if (service.serialConnectionState.value != SerialConnectionState.disconnected) { _disconnect('serial'); } } diff --git a/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_3_data_capture.dart b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_3_data_capture.dart index 62a13c6..d262614 100644 --- a/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_3_data_capture.dart +++ b/lib/screens/river/manual/triennial/widgets/river_manual_triennial_step_3_data_capture.dart @@ -120,7 +120,21 @@ class _RiverManualTriennialStep3DataCaptureState extends State(); + // --- START MODIFICATION --- + final service = _samplingService; // NEW: Use the member variable + // --- END MODIFICATION --- if (type == 'bluetooth') { service.disconnectFromBluetooth(); } else { @@ -360,12 +376,15 @@ class _RiverManualTriennialStep3DataCaptureState extends State(); + // --- START MODIFICATION --- + final service = _samplingService; // NEW: Use the member variable + // --- END MODIFICATION --- if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { _disconnect('bluetooth'); } @@ -519,6 +538,8 @@ class _RiverManualTriennialStep3DataCaptureState extends State(); + // --- START MODIFICATION --- + final service = _samplingService; // NEW: Use the member variable + // --- END MODIFICATION --- if (type == 'bluetooth') { service.disconnectFromBluetooth(); } else { @@ -360,12 +376,15 @@ class _RiverInSituStep3DataCaptureState extends State(); + // --- START MODIFICATION --- + final service = _samplingService; // NEW: Use the member variable + // --- END MODIFICATION --- if (service.bluetoothConnectionState.value != BluetoothConnectionState.disconnected) { _disconnect('bluetooth'); } diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 296d9d1..7fefef4 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -41,10 +41,14 @@ class _SettingsScreenState extends State { final Map _moduleSettings = {}; + // This list seems correct based on your file final List> _configurableModules = [ {'key': 'marine_tarball', 'name': 'Marine Tarball'}, {'key': 'marine_in_situ', 'name': 'Marine In-Situ'}, + {'key': 'marine_investigative', 'name': 'Marine Investigative'}, {'key': 'river_in_situ', 'name': 'River In-Situ'}, + {'key': 'river_triennial', 'name': 'River Triennial'}, + {'key': 'river_investigative', 'name': 'River Investigative'}, {'key': 'air_installation', 'name': 'Air Installation'}, {'key': 'air_collection', 'name': 'Air Collection'}, ]; @@ -70,9 +74,16 @@ class _SettingsScreenState extends State { String _airLimitsSearchQuery = ''; final TextEditingController _riverLimitsSearchController = TextEditingController(); String _riverLimitsSearchQuery = ''; + // --- MODIFICATION: Added missing controller --- final TextEditingController _marineLimitsSearchController = TextEditingController(); + // --- END MODIFICATION --- String _marineLimitsSearchQuery = ''; + // --- START: PAGINATION STATE FOR MARINE LIMITS --- + int _marineLimitsCurrentPage = 1; + final int _marineLimitsItemsPerPage = 15; + // --- END: PAGINATION STATE FOR MARINE LIMITS --- + @override void initState() { super.initState(); @@ -88,7 +99,13 @@ class _SettingsScreenState extends State { _npeMarineLimitsSearchController.addListener(() => setState(() => _npeMarineLimitsSearchQuery = _npeMarineLimitsSearchController.text)); _airLimitsSearchController.addListener(() => setState(() => _airLimitsSearchQuery = _airLimitsSearchController.text)); _riverLimitsSearchController.addListener(() => setState(() => _riverLimitsSearchQuery = _riverLimitsSearchController.text)); - _marineLimitsSearchController.addListener(() => setState(() => _marineLimitsSearchQuery = _marineLimitsSearchController.text)); + + _marineLimitsSearchController.addListener(() { + setState(() { + _marineLimitsSearchQuery = _marineLimitsSearchController.text; + _marineLimitsCurrentPage = 1; // Reset to page 1 when search query changes + }); + }); } @override @@ -566,7 +583,7 @@ class _SettingsScreenState extends State { labelText: 'Search Marine Limits', hintText: 'Search by parameter or station', ), - _buildInfoList(filteredMarineLimits, (item) => _buildParameterLimitEntry(item, stations: allManualStations)), + _buildPaginatedMarineLimitsList(filteredMarineLimits, allManualStations), ], ), ), @@ -748,7 +765,7 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.info_outline), title: const Text('App Version'), - subtitle: const Text('MMS Version 3.7.01'), + subtitle: const Text('MMS Version 3.8.01'), dense: true, ), ListTile( @@ -791,6 +808,7 @@ class _SettingsScreenState extends State { ); } + // --- START MODIFICATION: This widget is updated to show ftp_module --- Widget _buildDestinationList(String title, List> configs, String idKey) { if (configs.isEmpty) { return const ListTile( @@ -808,9 +826,19 @@ class _SettingsScreenState extends State { child: Text(title, style: Theme.of(context).textTheme.titleMedium), ), ...configs.map((config) { + // Check if this is an FTP config by looking for the 'ftp_module' key + bool isFtp = config.containsKey('ftp_module'); + String subtitleText; + + if (isFtp) { + subtitleText = 'Module: ${config['ftp_module'] ?? 'N/A'} | Host: ${config['ftp_host'] ?? 'N/A'}'; + } else { + subtitleText = config['api_url'] ?? 'No URL'; + } + return CheckboxListTile( title: Text(config['config_name'] ?? 'Unnamed'), - subtitle: Text(config['api_url'] ?? config['ftp_host'] ?? 'No URL/Host'), + subtitle: Text(subtitleText, style: const TextStyle(fontSize: 12)), value: config['is_enabled'] ?? false, onChanged: (bool? value) { setState(() { @@ -824,6 +852,7 @@ class _SettingsScreenState extends State { ), ); } + // --- END MODIFICATION --- Widget _buildSectionHeader(BuildContext context, String title) { return Padding( @@ -864,6 +893,91 @@ class _SettingsScreenState extends State { ); } + Widget _buildPaginatedMarineLimitsList( + List>? filteredList, + List>? allStations, + ) { + // 1. Handle null or empty list after filtering + if (filteredList == null || filteredList.isEmpty) { + // Check if the original list (pre-filter) was also empty + final originalList = context.read().marineParameterLimits; + String message = 'No matching parameter limits found.'; + if (originalList == null || originalList.isEmpty) { + message = 'No data available. Sync to download.'; + } + return ListTile( + title: Text(message, textAlign: TextAlign.center), + dense: true, + ); + } + + // 2. Pagination Calculations + final totalItems = filteredList.length; + final totalPages = (totalItems / _marineLimitsItemsPerPage).ceil(); + if (totalPages > 0 && _marineLimitsCurrentPage > totalPages) { + _marineLimitsCurrentPage = totalPages; + } + + + // 3. Get items for the current page + final startIndex = (_marineLimitsCurrentPage - 1) * _marineLimitsItemsPerPage; + final endIndex = (startIndex + _marineLimitsItemsPerPage > totalItems) + ? totalItems + : startIndex + _marineLimitsItemsPerPage; + + final paginatedItems = filteredList.sublist(startIndex, endIndex); + + // 4. Build the UI + return Column( + children: [ + // 4.1. The list of items for the current page + Column( + children: paginatedItems + .map((item) => _buildParameterLimitEntry(item, stations: allStations)) + .toList(), + ), + + // 4.2. The pagination controls (only show if more than one page) + if (totalPages > 1) ...[ + const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios), + iconSize: 18.0, + tooltip: 'Previous Page', + onPressed: _marineLimitsCurrentPage > 1 + ? () { + setState(() { + _marineLimitsCurrentPage--; + }); + } + : null, // Disable button if on first page + ), + Text( + 'Page $_marineLimitsCurrentPage of $totalPages', + style: Theme.of(context).textTheme.bodySmall, + ), + IconButton( + icon: const Icon(Icons.arrow_forward_ios), + iconSize: 18.0, + tooltip: 'Next Page', + onPressed: _marineLimitsCurrentPage < totalPages + ? () { + setState(() { + _marineLimitsCurrentPage++; + }); + } + : null, // Disable button if on last page + ), + ], + ), + ] + ], + ); + } + Widget _buildChatIdEntry(String label, String value) { return ListTile( contentPadding: EdgeInsets.zero, @@ -905,11 +1019,9 @@ class _SettingsScreenState extends State { final stationId = item['station_id']; final station = stations.firstWhere((s) => s['station_id'] == stationId, orElse: () => {}); if (station.isNotEmpty) { - // --- START: MODIFICATION --- final stationCode = station['man_station_code'] ?? 'N/A'; final stationName = station['man_station_name'] ?? 'N/A'; contextSubtitle = 'Station: $stationCode - $stationName'; - // --- END: MODIFICATION --- } } @@ -983,6 +1095,9 @@ class _SettingsScreenState extends State { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // --- MODIFICATION: Added ftp_module display --- + Text('Module: ${item['ftp_module']?.toString() ?? 'N/A'}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), + // --- Text('Host: ${item['ftp_host']?.toString() ?? 'N/A'}', style: const TextStyle(fontSize: 12)), Text('User: ${item['ftp_user']?.toString() ?? 'N/A'}', style: const TextStyle(fontSize: 12)), Text('Pass: ${item['ftp_pass']?.toString() ?? 'N/A'}', style: const TextStyle(fontSize: 12)), diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index a2c8149..8419b3a 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -226,6 +226,16 @@ class ApiService { await dbHelper.deleteRiverTriennialStations(id); } }, + // --- ADDED: River Investigative Stations Sync --- + 'riverInvestigativeStations': { + // IMPORTANT: Make sure this endpoint matches your server's route + 'endpoint': 'river/investigative-stations', + 'handler': (d, id) async { + await dbHelper.upsertRiverInvestigativeStations(d); + await dbHelper.deleteRiverInvestigativeStations(id); + } + }, + // --- END ADDED --- 'departments': { 'endpoint': 'departments', 'handler': (d, id) async { @@ -485,15 +495,79 @@ class AirApiService { } } +// ======================================================================= +// --- START OF MODIFIED SECTION --- +// The entire MarineApiService class is replaced with the corrected version. +// ======================================================================= class MarineApiService { final BaseApiService _baseService; - final TelegramService _telegramService; // Still needed if _handleAlerts were here + final TelegramService _telegramService; final ServerConfigService _serverConfigService; - final DatabaseHelper _dbHelper; // Still needed for parameter limit lookups if alerts were here + final DatabaseHelper _dbHelper; // Kept to match constructor MarineApiService(this._baseService, this._telegramService, this._serverConfigService, this._dbHelper); - // --- KEPT METHODS --- + // --- KEPT METHODS (Unchanged) --- + Future> getTarballStations() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'marine/tarball/stations'); + } + + Future> getManualStations() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'marine/manual/stations'); + } + + Future> getTarballClassifications() async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + return _baseService.get(baseUrl, 'marine/tarball/classifications'); + } + + // --- REPLACED/FIXED METHOD --- + Future> getManualSamplingImages({ + required int stationId, + required DateTime samplingDate, + required String samplingType, // This parameter is NOW USED + }) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate); + + String endpoint; + // Determine the correct endpoint based on the sampling type + switch (samplingType) { + case 'In-Situ Sampling': + endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr'; + break; + case 'Tarball Sampling': + // **IMPORTANT**: Please verify this is the correct endpoint for tarball records + endpoint = 'marine/tarball/records-by-station?station_id=$stationId&date=$dateStr'; + break; + case 'All Manual Sampling': + default: + // 'All' is complex. Defaulting to 'manual' (in-situ) as a fallback. + endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr'; + break; + } + + // This new debug print will help you confirm the fix is working + debugPrint("MarineApiService: Calling API endpoint: $endpoint"); + + final response = await _baseService.get(baseUrl, endpoint); + + // Adjusting response parsing based on observed structure + if (response['success'] == true && response['data'] is Map && response['data']['data'] is List) { + return { + 'success': true, + 'data': response['data']['data'], // Return the inner 'data' list + 'message': response['message'], + }; + } + // Return original response if structure doesn't match + return response; + } + + + // --- ADDED METHOD --- Future> sendImageRequestEmail({ required String recipientEmail, required List imageUrls, @@ -511,65 +585,13 @@ class MarineApiService { return _baseService.postMultipart( baseUrl: baseUrl, - endpoint: 'marine/images/send-email', + endpoint: 'marine/images/send-email', // **IMPORTANT**: Verify this endpoint fields: fields, files: {}, ); } - Future> getManualSamplingImages({ - required int stationId, - required DateTime samplingDate, - required String samplingType, // This parameter seems unused in the current endpoint - }) async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate); - - // Endpoint seems specific to 'manual' records, might need adjustment if other types needed - 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); - - // Adjusting response parsing based on observed structure - if (response['success'] == true && response['data'] is Map && response['data']['data'] is List) { - return { - 'success': true, - 'data': response['data']['data'], // Return the inner 'data' list - 'message': response['message'], - }; - } - // Return original response if structure doesn't match - return response; - } - - - Future> getTarballStations() async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.get(baseUrl, 'marine/tarball/stations'); - } - - Future> getManualStations() async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.get(baseUrl, 'marine/manual/stations'); - } - - Future> getTarballClassifications() async { - final baseUrl = await _serverConfigService.getActiveApiUrl(); - return _baseService.get(baseUrl, 'marine/tarball/classifications'); - } - - // --- REMOVED METHODS (Logic moved to feature services) --- - // - submitInSituSample - // - _handleInSituSuccessAlert - // - _generateInSituAlertMessage - // - _getOutOfBoundsAlertSection (InSitu version) - // - submitTarballSample - // - _handleTarballSuccessAlert - // - _generateTarballAlertMessage - - // --- KEPT METHODS (Simple POSTs called by specific services) --- + // --- KEPT METHODS (Unchanged) --- Future> submitPreDepartureChecklist(MarineManualPreDepartureChecklistData data) async { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.post(baseUrl, 'marine/checklist', data.toApiFormData()); @@ -590,6 +612,9 @@ class MarineApiService { return _baseService.get(baseUrl, 'marine/maintenance/previous'); } } +// ======================================================================= +// --- END OF MODIFIED SECTION --- +// ======================================================================= class RiverApiService { final BaseApiService _baseService; @@ -665,7 +690,9 @@ class RiverApiService { class DatabaseHelper { static Database? _database; static const String _dbName = 'app_data.db'; - static const int _dbVersion = 23; // Keep version updated if schema changes + // --- MODIFIED: Incremented DB version --- + static const int _dbVersion = 24; // Keep version updated if schema changes + // --- END MODIFIED --- // compute-related static variables/methods REMOVED @@ -675,6 +702,9 @@ class DatabaseHelper { static const String _manualStationsTable = 'marine_manual_stations'; static const String _riverManualStationsTable = 'river_manual_stations'; static const String _riverTriennialStationsTable = 'river_triennial_stations'; + // --- ADDED: River Investigative Stations Table Name --- + static const String _riverInvestigativeStationsTable = 'river_investigative_stations'; + // --- END ADDED --- static const String _tarballClassificationsTable = 'marine_tarball_classifications'; static const String _departmentsTable = 'departments'; static const String _companiesTable = 'companies'; @@ -726,6 +756,9 @@ class DatabaseHelper { await db.execute('CREATE TABLE $_manualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); await db.execute('CREATE TABLE $_riverManualStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); await db.execute('CREATE TABLE $_riverTriennialStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); + // --- ADDED: River Investigative Stations Table Create --- + await db.execute('CREATE TABLE $_riverInvestigativeStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); + // --- END ADDED --- await db.execute('CREATE TABLE $_tarballClassificationsTable(classification_id INTEGER PRIMARY KEY, classification_json TEXT)'); await db.execute('CREATE TABLE $_departmentsTable(department_id INTEGER PRIMARY KEY, department_json TEXT)'); await db.execute('CREATE TABLE $_companiesTable(company_id INTEGER PRIMARY KEY, company_json TEXT)'); @@ -894,6 +927,11 @@ class DatabaseHelper { debugPrint("Upgrade warning: Failed to drop old parameter limits table (may not exist): $e"); } } + // --- ADDED: Upgrade step for new table --- + if (oldVersion < 24) { + await db.execute('CREATE TABLE IF NOT EXISTS $_riverInvestigativeStationsTable(station_id INTEGER PRIMARY KEY, station_json TEXT)'); + } + // --- END ADDED --- } // --- Data Handling Methods --- @@ -1074,6 +1112,13 @@ class DatabaseHelper { Future deleteRiverTriennialStations(List ids) => _deleteData(_riverTriennialStationsTable, 'station_id', ids); Future>?> loadRiverTriennialStations() => _loadData(_riverTriennialStationsTable, 'station'); + // --- ADDED: River Investigative Stations DB Methods --- + Future upsertRiverInvestigativeStations(List> data) => + _upsertData(_riverInvestigativeStationsTable, 'station_id', data, 'station'); + Future deleteRiverInvestigativeStations(List ids) => _deleteData(_riverInvestigativeStationsTable, 'station_id', ids); + Future>?> loadRiverInvestigativeStations() => _loadData(_riverInvestigativeStationsTable, 'station'); + // --- END ADDED --- + Future upsertTarballClassifications(List> data) => _upsertData(_tarballClassificationsTable, 'classification_id', data, 'classification'); Future deleteTarballClassifications(List ids) => _deleteData(_tarballClassificationsTable, 'classification_id', ids); diff --git a/lib/services/marine_api_service.dart b/lib/services/marine_api_service.dart index 55cb10e..f48dfbb 100644 --- a/lib/services/marine_api_service.dart +++ b/lib/services/marine_api_service.dart @@ -1,8 +1,15 @@ // lib/services/marine_api_service.dart +import 'dart:convert'; // Added: Necessary for jsonEncode +import 'package:flutter/foundation.dart'; // Added: Necessary for debugPrint +import 'package:intl/intl.dart'; // Added: Necessary for DateFormat + import 'package:environment_monitoring_app/services/base_api_service.dart'; import 'package:environment_monitoring_app/services/telegram_service.dart'; import 'package:environment_monitoring_app/services/server_config_service.dart'; +// Added: Necessary for ApiService.imageBaseUrl, assuming it's defined there. +// If not, you may need to adjust this. +import 'package:environment_monitoring_app/services/api_service.dart'; class MarineApiService { final BaseApiService _baseService; @@ -25,4 +32,75 @@ class MarineApiService { final baseUrl = await _serverConfigService.getActiveApiUrl(); return _baseService.get(baseUrl, 'marine/tarball/classifications'); } + + // --- ADDED: Method to fetch images (Fixes the issue) --- + /// Fetches image records for either In-Situ or Tarball sampling. + Future> getManualSamplingImages({ + required int stationId, + required DateTime samplingDate, + required String samplingType, // This parameter is now used + }) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate); + + String endpoint; + // Determine the correct endpoint based on the sampling type + switch (samplingType) { + case 'In-Situ Sampling': + endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr'; + break; + case 'Tarball Sampling': + // **IMPORTANT**: Please verify this is the correct endpoint for tarball records + endpoint = 'marine/tarball/records-by-station?station_id=$stationId&date=$dateStr'; + break; + case 'All Manual Sampling': + default: + // 'All' is complex. Defaulting to 'manual' (in-situ) as a fallback. + endpoint = 'marine/manual/records-by-station?station_id=$stationId&date=$dateStr'; + break; + } + + debugPrint("MarineApiService: Calling API endpoint: $endpoint"); + + final response = await _baseService.get(baseUrl, endpoint); + + // This parsing logic assumes the server nests the list inside {'data': {'data': [...]}} + // Adjust if your API response is different + if (response['success'] == true && response['data'] is Map && response['data']['data'] is List) { + return { + 'success': true, + 'data': response['data']['data'], // Return the inner 'data' list + 'message': response['message'], + }; + } + + // Return original response if structure doesn't match + return response; + } + + // --- ADDED: Method to send email request (Fixes the issue) --- + /// Sends the selected image URLs to the server for emailing. + Future> sendImageRequestEmail({ + required String recipientEmail, + required List imageUrls, + required String stationName, + required String samplingDate, + }) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + + final Map fields = { + 'recipientEmail': recipientEmail, + 'imageUrls': jsonEncode(imageUrls), // Encode list as JSON string + 'stationName': stationName, + 'samplingDate': samplingDate, + }; + + // Use postMultipart (even with no files) as it's common for this kind of endpoint + return _baseService.postMultipart( + baseUrl: baseUrl, + endpoint: 'marine/images/send-email', // **IMPORTANT**: Verify this endpoint + fields: fields, + files: {}, // No files being uploaded, just data + ); + } } \ No newline at end of file diff --git a/lib/services/user_preferences_service.dart b/lib/services/user_preferences_service.dart index fad2f53..44bfceb 100644 --- a/lib/services/user_preferences_service.dart +++ b/lib/services/user_preferences_service.dart @@ -11,10 +11,14 @@ class UserPreferencesService { static const _defaultPrefsSavedKey = 'default_preferences_saved'; // Moved from settings.dart for central access + // This list now includes all your modules final List> _configurableModules = [ {'key': 'marine_tarball', 'name': 'Marine Tarball'}, {'key': 'marine_in_situ', 'name': 'Marine In-Situ'}, + {'key': 'marine_investigative', 'name': 'Marine Investigative'}, {'key': 'river_in_situ', 'name': 'River In-Situ'}, + {'key': 'river_triennial', 'name': 'River Triennial'}, + {'key': 'river_investigative', 'name': 'River Investigative'}, {'key': 'air_installation', 'name': 'Air Installation'}, {'key': 'air_collection', 'name': 'Air Collection'}, ]; @@ -47,43 +51,24 @@ class UserPreferencesService { ); // 2. Determine default API links + // This is correct: Tick any API server marked as 'is_active' by default. final defaultApiLinks = allApiConfigs.map((config) { - bool isEnabled = config['config_name'] == 'PSTW_HQ'; + bool isEnabled = (config['is_active'] == 1 || config['is_active'] == true); return {...config, 'is_enabled': isEnabled}; }).toList(); // 3. Determine default FTP links + // --- START MODIFICATION: Simplified logic using ftp_module --- final defaultFtpLinks = allFtpConfigs.map((config) { - bool isEnabled = false; // Disable all by default - switch (moduleKey) { - case 'marine_tarball': - if (config['config_name'] == 'pstw_marine_tarball' || config['config_name'] == 'tes_marine_tarball') { - isEnabled = true; - } - break; - case 'marine_in_situ': - if (config['config_name'] == 'pstw_marine_manual' || config['config_name'] == 'tes_marine_manual') { - isEnabled = true; - } - break; - case 'river_in_situ': - if (config['config_name'] == 'pstw_river_manual' || config['config_name'] == 'tes_river_manual') { - isEnabled = true; - } - break; - case 'air_collection': - if (config['config_name'] == 'pstw_air_collect' || config['config_name'] == 'tes_air_collect') { - isEnabled = true; - } - break; - case 'air_installation': - if (config['config_name'] == 'pstw_air_install' || config['config_name'] == 'tes_air_install') { - isEnabled = true; - } - break; - } + final String configModule = config['ftp_module'] ?? ''; + final bool isActive = (config['is_active'] == 1 || config['is_active'] == true); + + // Enable if the config's module matches the current moduleKey AND it's active + bool isEnabled = (configModule == moduleKey) && isActive; + return {...config, 'is_enabled': isEnabled}; }).toList(); + // --- END MODIFICATION --- // 4. Save the default links to the database. await saveApiLinksForModule(moduleKey, defaultApiLinks); @@ -139,7 +124,7 @@ class UserPreferencesService { // 3. Merge the two lists. return allApiConfigs.map((config) { final configId = config['api_config_id']; - bool isEnabled = false; // Default to disabled + bool isEnabled; // Default to disabled try { // Find if a link exists for this config ID in the user's saved preferences. @@ -147,11 +132,15 @@ class UserPreferencesService { (link) => link['api_config_id'] == configId, // If no link is found, 'orElse' is not triggered, it throws. ); + // A link was found, use the user's saved preference isEnabled = matchingLink['is_enabled'] as bool? ?? false; } catch (e) { + // --- THIS IS THE FIX for API post-sync --- // A 'firstWhere' with no match throws an error. We catch it here. - // This means no link was saved for this config, so it remains disabled. - isEnabled = false; + // This means no link was saved (e.g., new config). + // Default to the 'is_active' flag from the server. + isEnabled = (config['is_active'] == 1 || config['is_active'] == true); + // --- END --- } // Return a new map containing the original config details plus the 'is_enabled' flag. @@ -172,14 +161,22 @@ class UserPreferencesService { return allFtpConfigs.map((config) { final configId = config['ftp_config_id']; - bool isEnabled = false; + bool isEnabled; try { final matchingLink = savedLinks.firstWhere( (link) => link['ftp_config_id'] == configId, ); + // A link was found, use the user's saved preference isEnabled = matchingLink['is_enabled'] as bool? ?? false; } catch (e) { - isEnabled = false; + // --- START MODIFICATION: Use ftp_module for defaults --- + // No matching link was found (e.g., new FTP config synced). + // Default to 'enabled' if its module matches the one we're checking + // and the config is marked 'is_active' from the server. + final String configModule = config['ftp_module'] ?? ''; + final bool isActive = (config['is_active'] == 1 || config['is_active'] == true); + isEnabled = (configModule == moduleName) && isActive; + // --- END MODIFICATION --- } return { ...config,