From d77a0ed8e9ec021817464c852b0da95365e9689a Mon Sep 17 00:00:00 2001 From: ALim Aidrus Date: Thu, 6 Nov 2025 20:48:33 +0800 Subject: [PATCH] separate settings screen into sub screen --- lib/main.dart | 27 +- lib/screens/settings.dart | 1127 ++--------------- .../settings/air_clients_settings.dart | 189 +++ .../api_ftp_configurations_settings.dart | 138 ++ .../settings/parameter_limits_settings.dart | 485 +++++++ .../settings/station_info_settings.dart | 372 ++++++ .../submission_preferences_settings.dart | 255 ++++ .../settings/telegram_alert_settings.dart | 114 ++ lib/services/user_preferences_service.dart | 71 +- 9 files changed, 1703 insertions(+), 1075 deletions(-) create mode 100644 lib/screens/settings/air_clients_settings.dart create mode 100644 lib/screens/settings/api_ftp_configurations_settings.dart create mode 100644 lib/screens/settings/parameter_limits_settings.dart create mode 100644 lib/screens/settings/station_info_settings.dart create mode 100644 lib/screens/settings/submission_preferences_settings.dart create mode 100644 lib/screens/settings/telegram_alert_settings.dart diff --git a/lib/main.dart b/lib/main.dart index 1327a0f..4cd0ab8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -36,6 +36,15 @@ import 'package:environment_monitoring_app/home_page.dart'; import 'package:environment_monitoring_app/screens/profile.dart'; import 'package:environment_monitoring_app/screens/settings.dart'; +// --- START: New Settings Screen Imports --- +import 'package:environment_monitoring_app/screens/settings/submission_preferences_settings.dart'; +import 'package:environment_monitoring_app/screens/settings/telegram_alert_settings.dart'; +import 'package:environment_monitoring_app/screens/settings/api_ftp_configurations_settings.dart'; +import 'package:environment_monitoring_app/screens/settings/parameter_limits_settings.dart'; +import 'package:environment_monitoring_app/screens/settings/air_clients_settings.dart'; +import 'package:environment_monitoring_app/screens/settings/station_info_settings.dart'; +// --- END: New Settings Screen Imports --- + // Department Home Pages import 'package:environment_monitoring_app/screens/air/air_home_page.dart'; import 'package:environment_monitoring_app/screens/river/river_home_page.dart'; @@ -227,6 +236,7 @@ class _RootAppState extends State { } // --- 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. @@ -350,6 +360,21 @@ class _RootAppState extends State { '/profile': (context) => const ProfileScreen(), '/settings': (context) => const SettingsScreen(), + // --- START: New Settings Routes (const removed) --- + '/settings/submission-prefs': (context) => + SubmissionPreferencesSettingsScreen(), + '/settings/telegram-alerts': (context) => + TelegramAlertSettingsScreen(), + '/settings/api-ftp-configs': (context) => + ApiFtpConfigurationsSettingsScreen(), + '/settings/parameter-limits': (context) => + ParameterLimitsSettingsScreen(), + '/settings/air-clients': (context) => + AirClientsSettingsScreen(), + '/settings/station-info': (context) => + StationInfoSettingsScreen(), + // --- END: New Settings Routes --- + // Department Home Pages '/air/home': (context) => const AirHomePage(), '/river/home': (context) => const RiverHomePage(), @@ -462,7 +487,7 @@ class _SessionAwareWrapperState extends State { // --- MODIFICATION END --- // Call initial check here if needed, or rely on RootApp's check. - // _checkAndShowDialogIfNeeded(_authProvider.isSessionExpired); + _checkAndShowDialogIfNeeded(_authProvider.isSessionExpired); } @override diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 7fefef4..ee52449 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -4,24 +4,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; import 'package:environment_monitoring_app/auth_provider.dart'; -import 'package:environment_monitoring_app/services/settings_service.dart'; -import 'package:environment_monitoring_app/services/user_preferences_service.dart'; -import 'package:environment_monitoring_app/services/api_service.dart'; // Import for DatabaseHelper access - -class _ModuleSettings { - bool isApiEnabled; - bool isFtpEnabled; - List> apiConfigs; - List> ftpConfigs; - - _ModuleSettings({ - this.isApiEnabled = true, - this.isFtpEnabled = true, - required this.apiConfigs, - required this.ftpConfigs, - }); -} - class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -31,186 +13,8 @@ class SettingsScreen extends StatefulWidget { } class _SettingsScreenState extends State { - final SettingsService _settingsService = SettingsService(); bool _isSyncingData = false; - final UserPreferencesService _preferencesService = UserPreferencesService(); - final DatabaseHelper _dbHelper = DatabaseHelper(); // Add instance for direct access - bool _isLoadingSettings = true; - bool _isSaving = false; - - 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'}, - ]; - - final TextEditingController _tarballSearchController = TextEditingController(); - String _tarballSearchQuery = ''; - final TextEditingController _manualSearchController = TextEditingController(); - String _manualSearchQuery = ''; - final TextEditingController _riverManualSearchController = TextEditingController(); - String _riverManualSearchQuery = ''; - final TextEditingController _riverTriennialSearchController = TextEditingController(); - String _riverTriennialSearchQuery = ''; - final TextEditingController _airStationSearchController = TextEditingController(); - String _airStationSearchQuery = ''; - final TextEditingController _airClientSearchController = TextEditingController(); - String _airClientSearchQuery = ''; - - final TextEditingController _npeRiverLimitsSearchController = TextEditingController(); - String _npeRiverLimitsSearchQuery = ''; - final TextEditingController _npeMarineLimitsSearchController = TextEditingController(); - String _npeMarineLimitsSearchQuery = ''; - final TextEditingController _airLimitsSearchController = TextEditingController(); - 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(); - _loadAllModuleSettings(); - _tarballSearchController.addListener(_onTarballSearchChanged); - _manualSearchController.addListener(_onManualSearchChanged); - _riverManualSearchController.addListener(_onRiverManualSearchChanged); - _riverTriennialSearchController.addListener(_onRiverTriennialSearchChanged); - _airStationSearchController.addListener(_onAirStationSearchChanged); - _airClientSearchController.addListener(_onAirClientSearchChanged); - - _npeRiverLimitsSearchController.addListener(() => setState(() => _npeRiverLimitsSearchQuery = _npeRiverLimitsSearchController.text)); - _npeMarineLimitsSearchController.addListener(() => setState(() => _npeMarineLimitsSearchQuery = _npeMarineLimitsSearchController.text)); - _airLimitsSearchController.addListener(() => setState(() => _airLimitsSearchQuery = _airLimitsSearchController.text)); - _riverLimitsSearchController.addListener(() => setState(() => _riverLimitsSearchQuery = _riverLimitsSearchController.text)); - - _marineLimitsSearchController.addListener(() { - setState(() { - _marineLimitsSearchQuery = _marineLimitsSearchController.text; - _marineLimitsCurrentPage = 1; // Reset to page 1 when search query changes - }); - }); - } - - @override - void dispose() { - _tarballSearchController.dispose(); - _manualSearchController.dispose(); - _riverManualSearchController.dispose(); - _riverTriennialSearchController.dispose(); - _airStationSearchController.dispose(); - _airClientSearchController.dispose(); - - _npeRiverLimitsSearchController.dispose(); - _npeMarineLimitsSearchController.dispose(); - _airLimitsSearchController.dispose(); - _riverLimitsSearchController.dispose(); - _marineLimitsSearchController.dispose(); - super.dispose(); - } - - void _onTarballSearchChanged() { - setState(() { - _tarballSearchQuery = _tarballSearchController.text; - }); - } - - void _onManualSearchChanged() { - setState(() { - _manualSearchQuery = _manualSearchController.text; - }); - } - - void _onRiverManualSearchChanged() { - setState(() { - _riverManualSearchQuery = _riverManualSearchController.text; - }); - } - - void _onRiverTriennialSearchChanged() { - setState(() { - _riverTriennialSearchQuery = _riverTriennialSearchController.text; - }); - } - - void _onAirStationSearchChanged() { - setState(() { - _airStationSearchQuery = _airStationSearchController.text; - }); - } - - void _onAirClientSearchChanged() { - setState(() { - _airClientSearchQuery = _airClientSearchController.text; - }); - } - - Future _loadAllModuleSettings() async { - setState(() => _isLoadingSettings = true); - for (var module in _configurableModules) { - final moduleKey = module['key']!; - - // This method now simply loads whatever preferences are saved in the database. - // The auto-saving of defaults is handled by AuthProvider upon login/app start. - final prefs = await _preferencesService.getModulePreference(moduleKey); - final apiConfigsWithPrefs = await _preferencesService.getAllApiConfigsWithModulePreferences(moduleKey); - final ftpConfigsWithPrefs = await _preferencesService.getAllFtpConfigsWithModulePreferences(moduleKey); - - _moduleSettings[moduleKey] = _ModuleSettings( - isApiEnabled: prefs?['is_api_enabled'] ?? true, // Fallback to true if null - isFtpEnabled: prefs?['is_ftp_enabled'] ?? true, // Fallback to true if null - apiConfigs: apiConfigsWithPrefs, - ftpConfigs: ftpConfigsWithPrefs, - ); - } - if (mounted) { - setState(() => _isLoadingSettings = false); - } - } - - Future _saveAllModuleSettings() async { - setState(() => _isSaving = true); - - try { - for (var module in _configurableModules) { - final moduleKey = module['key']!; - final settings = _moduleSettings[moduleKey]!; - - await _preferencesService.saveModulePreference( - moduleName: moduleKey, - isApiEnabled: settings.isApiEnabled, - isFtpEnabled: settings.isFtpEnabled, - ); - - await _preferencesService.saveApiLinksForModule(moduleKey, settings.apiConfigs); - await _preferencesService.saveFtpLinksForModule(moduleKey, settings.ftpConfigs); - } - _showSnackBar('Submission preferences saved successfully.', isError: false); - } catch (e) { - _showSnackBar('Failed to save settings: $e', isError: true); - } finally { - if (mounted) { - setState(() => _isSaving = false); - } - } - } - Future _manualDataSync() async { if (_isSyncingData) return; setState(() => _isSyncingData = true); @@ -232,22 +36,17 @@ class _SettingsScreenState extends State { // 2. Attempt the sync operation try { - // AuthProvider's syncAllData will internally handle token validation and attempt a silent re-login if necessary. await auth.syncAllData(forceRefresh: true); - await _loadAllModuleSettings(); // Reload settings on successful sync - + // No need to reload settings here, child screens will load on demand if (mounted) { _showSnackBar('Data synced successfully.', isError: false); } } catch (e) { // 3. Handle failures if (mounted) { - // If the sync failed, check if the session is now marked as expired. - // This indicates that the silent re-login attempt during the sync also failed. if (auth.isSessionExpired) { _showSessionExpiredDialog(); } else { - // A different error occurred (e.g., server down, network issue during sync) _showSnackBar('Data sync failed: ${e.toString()}', isError: true); } } @@ -275,7 +74,6 @@ class _SettingsScreenState extends State { final auth = Provider.of(context, listen: false); Navigator.pop(dialogContext); auth.logout(); - // Navigate to the root, which will then redirect to the login screen. Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false); }, child: const Text("Logout"), @@ -296,142 +94,53 @@ class _SettingsScreenState extends State { } } + Widget _buildSectionHeader(BuildContext context, String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(8.0, 0, 8.0, 16.0), + child: Text( + title, + style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + ), + ); + } + + Widget _buildNavTile(BuildContext context, { + required String title, + required String subtitle, + required IconData icon, + required String routeName, + }) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 6.0), + child: ListTile( + leading: Icon(icon, size: 32.0, color: Theme.of(context).colorScheme.primary), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(subtitle), + trailing: const Icon(Icons.arrow_forward_ios), + onTap: () { + Navigator.pushNamed(context, routeName); + }, + ), + ); + } + @override Widget build(BuildContext context) { final auth = Provider.of(context); final lastSync = auth.lastSyncTimestamp; - final appSettings = auth.appSettings; - final npeParameterLimits = auth.npeParameterLimits; - final marineParameterLimits = auth.marineParameterLimits; - final riverParameterLimits = auth.riverParameterLimits; - final apiConfigs = auth.apiConfigs; - final ftpConfigs = auth.ftpConfigs; - final airClients = auth.airClients; - final departments = auth.departments; - final allManualStations = auth.manualStations; - - final int? airDepartmentId = departments?.firstWhere((d) => d['department_name'] == 'Air', orElse: () => {})?['department_id']; - final int? riverDepartmentId = departments?.firstWhere((d) => d['department_name'] == 'River', orElse: () => {})?['department_id']; - final int? marineDepartmentId = departments?.firstWhere((d) => d['department_name'] == 'Marine', orElse: () => {})?['department_id']; - - final filteredNpeRiverLimits = npeParameterLimits?.where((limit) { - final isRiverNpe = riverDepartmentId != null && limit['department_id'] == riverDepartmentId; - if (!isRiverNpe) return false; - final paramName = limit['param_parameter_list']?.toLowerCase() ?? ''; - final query = _npeRiverLimitsSearchQuery.toLowerCase(); - return paramName.contains(query); - }).toList(); - - final filteredNpeMarineLimits = npeParameterLimits?.where((limit) { - final isMarineNpe = marineDepartmentId != null && limit['department_id'] == marineDepartmentId; - if (!isMarineNpe) return false; - final paramName = limit['param_parameter_list']?.toLowerCase() ?? ''; - final query = _npeMarineLimitsSearchQuery.toLowerCase(); - return paramName.contains(query); - }).toList(); - - final filteredAirLimits = npeParameterLimits?.where((limit) { - final isAirLimit = airDepartmentId != null && limit['department_id'] == airDepartmentId; - if (!isAirLimit) return false; - final paramName = limit['param_parameter_list']?.toLowerCase() ?? ''; - final query = _airLimitsSearchQuery.toLowerCase(); - return paramName.contains(query); - }).toList(); - - final filteredRiverLimits = riverParameterLimits?.where((limit) { - final paramName = limit['param_parameter_list']?.toLowerCase() ?? ''; - final query = _riverLimitsSearchQuery.toLowerCase(); - return paramName.contains(query); - }).toList(); - - final filteredMarineLimits = marineParameterLimits?.where((limit) { - final paramName = limit['param_parameter_list']?.toLowerCase() ?? ''; - final query = _marineLimitsSearchQuery.toLowerCase(); - if (paramName.contains(query)) return true; - - final stationId = limit['station_id']; - if (stationId != null && allManualStations != null) { - final station = allManualStations.firstWhere((s) => s['station_id'] == stationId, orElse: () => {}); - if (station.isNotEmpty) { - final stationName = station['man_station_name']?.toLowerCase() ?? ''; - final stationCode = station['man_station_code']?.toLowerCase() ?? ''; - if (stationName.contains(query) || stationCode.contains(query)) { - return true; - } - } - } - return false; - }).toList(); - - final filteredTarballStations = (auth.tarballStations?.where((station) { - final stationName = station['tbl_station_name']?.toLowerCase() ?? ''; - final stationCode = station['tbl_station_code']?.toLowerCase() ?? ''; - final query = _tarballSearchQuery.toLowerCase(); - return stationName.contains(query) || stationCode.contains(query); - }).toList())?.cast>(); - - final filteredManualStations = (auth.manualStations?.where((station) { - final stationName = station['man_station_name']?.toLowerCase() ?? ''; - final stationCode = station['man_station_code']?.toLowerCase() ?? ''; - final query = _manualSearchQuery.toLowerCase(); - return stationName.contains(query) || stationCode.contains(query); - }).toList())?.cast>(); - - final filteredRiverManualStations = (auth.riverManualStations?.where((station) { - final riverName = station['sampling_river']?.toLowerCase() ?? ''; - final stationCode = station['sampling_station_code']?.toLowerCase() ?? ''; - final basinName = station['sampling_basin']?.toLowerCase() ?? ''; - final query = _riverManualSearchQuery.toLowerCase(); - return riverName.contains(query) || stationCode.contains(query) || basinName.contains(query); - }).toList())?.cast>(); - - final filteredRiverTriennialStations = (auth.riverTriennialStations?.where((station) { - final riverName = station['triennial_river']?.toLowerCase() ?? ''; - final stationCode = station['triennial_station_code']?.toLowerCase() ?? ''; - final basinName = station['triennial_basin']?.toLowerCase() ?? ''; - final query = _riverTriennialSearchQuery.toLowerCase(); - return riverName.contains(query) || stationCode.contains(query) || basinName.contains(query); - }).toList())?.cast>(); - - final filteredAirStations = (auth.airManualStations?.where((station) { - final stationName = station['station_name']?.toLowerCase() ?? ''; - final stationCode = station['station_code']?.toLowerCase() ?? ''; - final query = _airStationSearchQuery.toLowerCase(); - return stationName.contains(query) || stationCode.contains(query); - }).toList())?.cast>(); - - final filteredAirClients = (auth.airClients?.where((client) { - final clientName = client['client_name']?.toLowerCase() ?? ''; - final clientId = client['client_id']?.toString().toLowerCase() ?? ''; - final query = _airClientSearchQuery.toLowerCase(); - return clientName.contains(query) || clientId.contains(query); - }).toList())?.cast>(); - return Scaffold( appBar: AppBar( title: const Text("Settings"), - actions: [ - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: _isSaving - ? const Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white))) - : IconButton( - icon: const Icon(Icons.save), - onPressed: _isLoadingSettings ? null : _saveAllModuleSettings, - tooltip: 'Save Submission Preferences', - ), - ) - ], ), body: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), + padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildSectionHeader(context, "Synchronization"), Card( - margin: EdgeInsets.zero, + margin: const EdgeInsets.symmetric(vertical: 6.0), child: Padding( padding: const EdgeInsets.all(16.0), child: Column( @@ -453,313 +162,60 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 32), - _buildSectionHeader(context, "Submission Preferences"), - _isLoadingSettings - ? const Center(child: Padding(padding: EdgeInsets.all(16.0), child: CircularProgressIndicator())) - : Card( - margin: EdgeInsets.zero, - child: ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: _configurableModules.length, - itemBuilder: (context, index) { - final module = _configurableModules[index]; - final settings = _moduleSettings[module['key']]; - if (settings == null) return const SizedBox.shrink(); - return _buildModulePreferenceTile(module['name']!, module['key']!, settings); - }, - ), - ), - const SizedBox(height: 32), + _buildSectionHeader(context, "App Management"), - Text("Telegram Alert Settings", style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)), - const SizedBox(height: 16), - Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - ExpansionTile( - title: const Text('Marine Alerts', style: TextStyle(fontWeight: FontWeight.bold)), - initiallyExpanded: false, - children: [ - _buildChatIdEntry('In-Situ', _settingsService.getInSituChatId(appSettings)), - _buildChatIdEntry('Tarball', _settingsService.getTarballChatId(appSettings)), - _buildChatIdEntry('Investigative', _settingsService.getMarineInvestigativeChatId(appSettings)), - ], - ), - ExpansionTile( - title: const Text('River Alerts', style: TextStyle(fontWeight: FontWeight.bold)), - initiallyExpanded: false, - children: [ - _buildChatIdEntry('In-Situ', _settingsService.getRiverInSituChatId(appSettings)), - _buildChatIdEntry('Triennial', _settingsService.getRiverTriennialChatId(appSettings)), - _buildChatIdEntry('Investigative', _settingsService.getRiverInvestigativeChatId(appSettings)), - ], - ), - ExpansionTile( - title: const Text('Air Alerts', style: TextStyle(fontWeight: FontWeight.bold)), - initiallyExpanded: false, - children: [ - _buildChatIdEntry('Manual', _settingsService.getAirManualChatId(appSettings)), - _buildChatIdEntry('Investigative', _settingsService.getAirInvestigativeChatId(appSettings)), - ], - ), - ], - ), - ), + _buildNavTile( + context, + title: "Submission Preferences", + subtitle: "Manage API & FTP submissions for each module.", + icon: Icons.send_to_mobile, + routeName: '/settings/submission-prefs', ), - const SizedBox(height: 32), - _buildSectionHeader(context, "Configurations"), - Card( - margin: EdgeInsets.zero, - child: Column( - children: [ - _buildExpansionTile( - title: 'NPE River Parameter Limits', - leadingIcon: Icons.science_outlined, - child: Column( - children: [ - _buildSearchBar( - controller: _npeRiverLimitsSearchController, - labelText: 'Search NPE River Limits', - hintText: 'Search by parameter name', - ), - _buildInfoList(filteredNpeRiverLimits, (item) => _buildParameterLimitEntry(item, departments: departments)), - ], - ), - ), - _buildExpansionTile( - title: 'NPE Marine Parameter Limits', - leadingIcon: Icons.science_outlined, - child: Column( - children: [ - _buildSearchBar( - controller: _npeMarineLimitsSearchController, - labelText: 'Search NPE Marine Limits', - hintText: 'Search by parameter name', - ), - _buildInfoList(filteredNpeMarineLimits, (item) => _buildParameterLimitEntry(item, departments: departments)), - ], - ), - ), - _buildExpansionTile( - title: 'Air Parameter Limits', - leadingIcon: Icons.air, - child: Column( - children: [ - _buildSearchBar( - controller: _airLimitsSearchController, - labelText: 'Search Air Limits', - hintText: 'Search by parameter name', - ), - _buildInfoList(filteredAirLimits, (item) => _buildParameterLimitEntry(item, departments: departments)), - ], - ), - ), - _buildExpansionTile( - title: 'River Parameter Limits', - leadingIcon: Icons.water, - child: Column( - children: [ - _buildSearchBar( - controller: _riverLimitsSearchController, - labelText: 'Search River Limits', - hintText: 'Search by parameter name', - ), - _buildInfoList(filteredRiverLimits, (item) => _buildParameterLimitEntry(item)), - ], - ), - ), - _buildExpansionTile( - title: 'Marine Parameter Limits', - leadingIcon: Icons.waves, - child: Column( - children: [ - _buildSearchBar( - controller: _marineLimitsSearchController, - labelText: 'Search Marine Limits', - hintText: 'Search by parameter or station', - ), - _buildPaginatedMarineLimitsList(filteredMarineLimits, allManualStations), - ], - ), - ), - _buildExpansionTile( - title: 'API Configurations', - leadingIcon: Icons.cloud, - child: _buildInfoList(apiConfigs, (item) => _buildKeyValueEntry(item, 'config_name', 'api_url')), - ), - _buildExpansionTile( - title: 'FTP Configurations', - leadingIcon: Icons.folder, - child: _buildInfoList(ftpConfigs, (item) => _buildFtpConfigEntry(item)), - ), - ], - ), + _buildNavTile( + context, + title: "Telegram Alert Settings", + subtitle: "View Telegram chat IDs for alerts.", + icon: Icons.telegram, + routeName: '/settings/telegram-alerts', ), - const SizedBox(height: 32), - _buildSectionHeader(context, "Air Clients"), - Card( - margin: EdgeInsets.zero, - child: Column( - children: [ - _buildExpansionTile( - title: 'Air Clients', - leadingIcon: Icons.air, - child: Column( - children: [ - _buildSearchBar( - controller: _airClientSearchController, - labelText: 'Search Air Clients', - hintText: 'Search by name or ID', - ), - const SizedBox(height: 16), - _buildClientList( - filteredAirClients, - 'No matching air clients found.', - 'No air clients available. Sync to download.', - (client) => _buildClientTile( - title: client['client_name'] ?? 'N/A', - subtitle: 'ID: ${client['client_id'] ?? 'N/A'}', - ), - height: 250, - ), - ], - ), - ), - ], - ), + _buildNavTile( + context, + title: "API/FTP Configurations", + subtitle: "View synced server configurations.", + icon: Icons.dns, + routeName: '/settings/api-ftp-configs', ), - const SizedBox(height: 32), - _buildSectionHeader(context, "Stations Info"), - Card( - margin: EdgeInsets.zero, - child: Column( - children: [ - _buildExpansionTile( - title: 'Marine Stations', - leadingIcon: Icons.waves, - child: Column( - children: [ - _buildSearchBar( - controller: _tarballSearchController, - labelText: 'Search Tarball Stations', - hintText: 'Search by name or code', - ), - const SizedBox(height: 16), - _buildStationList( - filteredTarballStations, - 'No matching tarball stations found.', - 'No tarball stations available. Sync to download.', - (station) => _buildStationTile( - title: station['tbl_station_name'] ?? 'N/A', - subtitle: 'Code: ${station['tbl_station_code'] ?? 'N/A'}', - type: 'Tarball' - ), - height: 250, - ), - const SizedBox(height: 16), - _buildSearchBar( - controller: _manualSearchController, - labelText: 'Search Manual Stations', - hintText: 'Search by name or code', - ), - const SizedBox(height: 16), - _buildStationList( - filteredManualStations, - 'No matching manual stations found.', - 'No manual stations available. Sync to download.', - (station) => _buildStationTile( - title: station['man_station_name'] ?? 'N/A', - subtitle: 'Code: ${station['man_station_code'] ?? 'N/A'}', - type: 'Manual' - ), - height: 250, - ), - ], - ), - ), - _buildExpansionTile( - title: 'River Stations', - leadingIcon: Icons.water, - child: Column( - children: [ - _buildSearchBar( - controller: _riverManualSearchController, - labelText: 'Search River Manual Stations', - hintText: 'Search by name, code, or basin', - ), - const SizedBox(height: 16), - _buildStationList( - filteredRiverManualStations, - 'No matching river manual stations found.', - 'No river manual stations available. Sync to download.', - (station) => _buildStationTile( - title: station['sampling_river'] ?? 'N/A', - subtitle: 'Code: ${station['sampling_station_code'] ?? 'N/A'}, Basin: ${station['sampling_basin'] ?? 'N/A'}', - type: 'River Manual' - ), - height: 250, - ), - const SizedBox(height: 16), - _buildSearchBar( - controller: _riverTriennialSearchController, - labelText: 'Search River Triennial Stations', - hintText: 'Search by name, code, or basin', - ), - const SizedBox(height: 16), - _buildStationList( - filteredRiverTriennialStations, - 'No matching river triennial stations found.', - 'No river triennial stations available. Sync to download.', - (station) => _buildStationTile( - title: station['triennial_river'] ?? 'N/A', - subtitle: 'Code: ${station['triennial_station_code'] ?? 'N/A'}, Basin: ${station['triennial_basin'] ?? 'N/A'}', - type: 'River Triennial' - ), - height: 250, - ), - ], - ), - ), - _buildExpansionTile( - title: 'Air Stations', - leadingIcon: Icons.air, - child: Column( - children: [ - _buildSearchBar( - controller: _airStationSearchController, - labelText: 'Search Air Stations', - hintText: 'Search by name or code', - ), - const SizedBox(height: 16), - _buildStationList( - filteredAirStations, - 'No matching air stations found.', - 'No air stations available. Sync to download.', - (station) => _buildStationTile( - title: station['station_name'] ?? 'N/A', - subtitle: 'Code: ${station['station_code'] ?? 'N/A'}', - type: 'Air' - ), - height: 250, - ), - ], - ), - ), - ], - ), + _buildNavTile( + context, + title: "Parameter Limits", + subtitle: "View parameter limits for all modules.", + icon: Icons.science_outlined, + routeName: '/settings/parameter-limits', ), - const SizedBox(height: 32), + _buildNavTile( + context, + title: "Air Clients", + subtitle: "View and search for synced Air clients.", + icon: Icons.air, + routeName: '/settings/air-clients', + ), + + _buildNavTile( + context, + title: "Station Info", + subtitle: "View and search all synced stations.", + icon: Icons.location_on, + routeName: '/settings/station-info', + ), + + const SizedBox(height: 32), _buildSectionHeader(context, "Other Information"), Card( - margin: EdgeInsets.zero, + margin: const EdgeInsets.symmetric(vertical: 6.0), child: Column( children: [ ListTile( @@ -782,435 +238,4 @@ class _SettingsScreenState extends State { ), ); } - - Widget _buildModulePreferenceTile(String title, String moduleKey, _ModuleSettings settings) { - return ExpansionTile( - title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), - children: [ - SwitchListTile( - title: const Text('Enable API Submission'), - value: settings.isApiEnabled, - onChanged: (value) => setState(() => settings.isApiEnabled = value), - ), - if (settings.isApiEnabled) - _buildDestinationList('API Destinations', settings.apiConfigs, 'api_config_id'), - - const Divider(), - - SwitchListTile( - title: const Text('Enable FTP Submission'), - value: settings.isFtpEnabled, - onChanged: (value) => setState(() => settings.isFtpEnabled = value), - ), - if (settings.isFtpEnabled) - _buildDestinationList('FTP Destinations', settings.ftpConfigs, 'ftp_config_id'), - ], - ); - } - - // --- START MODIFICATION: This widget is updated to show ftp_module --- - Widget _buildDestinationList(String title, List> configs, String idKey) { - if (configs.isEmpty) { - return const ListTile( - dense: true, - title: Center(child: Text('No destinations configured. Sync to fetch.')), - ); - } - return Padding( - padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 16.0, bottom: 8.0), - 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(subtitleText, style: const TextStyle(fontSize: 12)), - value: config['is_enabled'] ?? false, - onChanged: (bool? value) { - setState(() { - config['is_enabled'] = value ?? false; - }); - }, - dense: true, - ); - }).toList(), - ], - ), - ); - } - // --- END MODIFICATION --- - - Widget _buildSectionHeader(BuildContext context, String title) { - return Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: Text( - title, - style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), - ), - ); - } - - Widget _buildExpansionTile({ - required String title, - required IconData leadingIcon, - required Widget child, - }) { - return ExpansionTile( - leading: Icon(leadingIcon), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: child, - ), - ], - ); - } - - Widget _buildInfoList(List>? items, Widget Function(Map) itemBuilder) { - if (items == null || items.isEmpty) { - return const ListTile( - title: Text('No data available. Sync to download.'), - dense: true, - ); - } - return Column( - children: items.map((item) => itemBuilder(item)).toList(), - ); - } - - 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, - leading: const Icon(Icons.telegram, size: 20), - title: Text('$label Chat ID'), - subtitle: Text(value.isNotEmpty ? value : 'Not Set'), - dense: true, - ); - } - - Widget _buildKeyValueEntry(Map item, String key1, String key2) { - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 4.0), - title: Text(item[key1]?.toString() ?? 'N/A', style: const TextStyle(fontSize: 14)), - subtitle: Text(item[key2]?.toString() ?? 'N/A', style: const TextStyle(fontSize: 12)), - dense: true, - ); - } - - Widget _buildParameterLimitEntry( - Map item, { - List>? departments, - List>? stations, - }) { - final paramName = item['param_parameter_list']?.toString() ?? 'N/A'; - final upperLimit = item['param_upper_limit']?.toString() ?? 'N/A'; - final lowerLimit = item['param_lower_limit']?.toString() ?? 'N/A'; - String contextSubtitle = ''; - - if (item.containsKey('department_id') && item['department_id'] != null && departments != null) { - final deptId = item['department_id']; - final dept = departments.firstWhere((d) => d['department_id'] == deptId, orElse: () => {}); - if (dept.isNotEmpty) { - contextSubtitle = 'Dept: ${dept['department_name']}'; - } - } - - if (item.containsKey('station_id') && item['station_id'] != null && stations != null) { - final stationId = item['station_id']; - final station = stations.firstWhere((s) => s['station_id'] == stationId, orElse: () => {}); - if (station.isNotEmpty) { - final stationCode = station['man_station_code'] ?? 'N/A'; - final stationName = station['man_station_name'] ?? 'N/A'; - contextSubtitle = 'Station: $stationCode - $stationName'; - } - } - - String unit = ''; - if (paramName.toLowerCase() == 'ph') { - unit = 'pH units'; - } else if (paramName.toLowerCase() == 'temp') { - unit = '°C'; - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - flex: 2, - child: Text( - lowerLimit, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), - textAlign: TextAlign.start, - ), - ), - Expanded( - flex: 5, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.science_outlined, size: 16), - const SizedBox(width: 8), - Flexible( - child: Text( - paramName, - style: const TextStyle(fontSize: 14), - textAlign: TextAlign.center, - ), - ), - ], - ), - if (contextSubtitle.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 2.0), - child: Text( - contextSubtitle, - style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic, color: Colors.grey), - textAlign: TextAlign.center, - ), - ), - ], - ), - ), - Expanded( - flex: 2, - child: Text( - upperLimit, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), - textAlign: TextAlign.end, - ), - ), - ], - ), - ); - } - - Widget _buildFtpConfigEntry(Map item) { - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 4.0), - title: Text(item['config_name']?.toString() ?? 'N/A', style: const TextStyle(fontSize: 14)), - 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)), - Text('Port: ${item['ftp_port']?.toString() ?? 'N/A'}', style: const TextStyle(fontSize: 12)), - ], - ), - leading: const Icon(Icons.folder_shared_outlined), - dense: true, - ); - } - - Widget _buildSearchBar({ - required TextEditingController controller, - required String labelText, - required String hintText, - }) { - return Padding( - padding: const EdgeInsets.only(bottom: 8.0, top: 8.0), - child: TextField( - controller: controller, - decoration: InputDecoration( - labelText: labelText, - hintText: hintText, - prefixIcon: const Icon(Icons.search), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)), - suffixIcon: controller.text.isNotEmpty - ? IconButton(icon: const Icon(Icons.clear), onPressed: () => controller.clear()) - : null, - ), - style: const TextStyle(fontSize: 14), - ), - ); - } - - Widget _buildStationList( - List>? stations, - String noMatchText, - String noDataText, - Widget Function(Map) itemBuilder, - {double height = 250}) { - if (stations == null || stations.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - _isSyncingData ? 'Loading...' : (stations == null ? noDataText : noMatchText), - textAlign: TextAlign.center, - ), - ), - ); - } - - return SizedBox( - height: height, - child: ListView.builder( - itemCount: stations.length, - itemBuilder: (context, index) { - final station = stations[index]; - return itemBuilder(station); - }, - ), - ); - } - - Widget _buildStationTile({required String title, required String subtitle, required String type}) { - return ListTile( - title: Text(title, style: const TextStyle(fontSize: 14)), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(subtitle, style: const TextStyle(fontSize: 12)), - Text('Type: $type', style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)), - ], - ), - dense: true, - ); - } - - Widget _buildClientList( - List>? clients, - String noMatchText, - String noDataText, - Widget Function(Map) itemBuilder, - {double height = 250}) { - if (clients == null || clients.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - _isSyncingData ? 'Loading...' : (clients == null ? noDataText : noMatchText), - textAlign: TextAlign.center, - ), - ), - ); - } - - return SizedBox( - height: height, - child: ListView.builder( - itemCount: clients.length, - itemBuilder: (context, index) { - final client = clients[index]; - return itemBuilder(client); - }, - ), - ); - } - - Widget _buildClientTile({required String title, required String subtitle}) { - return ListTile( - title: Text(title, style: const TextStyle(fontSize: 14)), - subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)), - dense: true, - ); - } } \ No newline at end of file diff --git a/lib/screens/settings/air_clients_settings.dart b/lib/screens/settings/air_clients_settings.dart new file mode 100644 index 0000000..7c1640b --- /dev/null +++ b/lib/screens/settings/air_clients_settings.dart @@ -0,0 +1,189 @@ +// lib/screens/settings/air_clients_settings.dart + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:environment_monitoring_app/auth_provider.dart'; + +class AirClientsSettingsScreen extends StatefulWidget { + const AirClientsSettingsScreen({super.key}); + + @override + State createState() => + _AirClientsSettingsScreenState(); +} + +class _AirClientsSettingsScreenState extends State { + final TextEditingController _airClientSearchController = + TextEditingController(); + String _airClientSearchQuery = ''; + + @override + void initState() { + super.initState(); + _airClientSearchController.addListener(_onAirClientSearchChanged); + } + + @override + void dispose() { + _airClientSearchController.dispose(); + super.dispose(); + } + + void _onAirClientSearchChanged() { + setState(() { + _airClientSearchQuery = _airClientSearchController.text; + }); + } + + Widget _buildSectionHeader(BuildContext context, String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(8.0, 24.0, 8.0, 16.0), + child: Text( + title, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + ); + } + + Widget _buildExpansionTile({ + required String title, + required IconData leadingIcon, + required Widget child, + }) { + return ExpansionTile( + leading: Icon(leadingIcon), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + initiallyExpanded: true, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: child, + ), + ], + ); + } + + Widget _buildSearchBar({ + required TextEditingController controller, + required String labelText, + required String hintText, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0, top: 8.0), + child: TextField( + controller: controller, + decoration: InputDecoration( + labelText: labelText, + hintText: hintText, + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)), + suffixIcon: controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () => controller.clear()) + : null, + ), + style: const TextStyle(fontSize: 14), + ), + ); + } + + Widget _buildClientList( + List>? clients, + String noMatchText, + String noDataText, + Widget Function(Map) itemBuilder, + {double height = 250}) { + if (clients == null || clients.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + clients == null ? noDataText : noMatchText, + textAlign: TextAlign.center, + ), + ), + ); + } + + return SizedBox( + height: height, + child: ListView.builder( + itemCount: clients.length, + itemBuilder: (context, index) { + final client = clients[index]; + return itemBuilder(client); + }, + ), + ); + } + + Widget _buildClientTile({required String title, required String subtitle}) { + return ListTile( + title: Text(title, style: const TextStyle(fontSize: 14)), + subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)), + dense: true, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Air Clients"), + ), + body: Consumer( + builder: (context, auth, child) { + final filteredAirClients = (auth.airClients?.where((client) { + final clientName = client['client_name']?.toLowerCase() ?? ''; + final clientId = client['client_id']?.toString().toLowerCase() ?? ''; + final query = _airClientSearchQuery.toLowerCase(); + return clientName.contains(query) || clientId.contains(query); + }).toList()) + ?.cast>(); + + return ListView( + padding: const EdgeInsets.all(16.0), + children: [ + _buildSectionHeader(context, "Air Clients"), + Card( + margin: EdgeInsets.zero, + child: Column( + children: [ + _buildExpansionTile( + title: 'Air Clients', + leadingIcon: Icons.air, + child: Column( + children: [ + _buildSearchBar( + controller: _airClientSearchController, + labelText: 'Search Air Clients', + hintText: 'Search by name or ID', + ), + const SizedBox(height: 16), + _buildClientList( + filteredAirClients, + 'No matching air clients found.', + 'No air clients available. Sync to download.', + (client) => _buildClientTile( + title: client['client_name'] ?? 'N/A', + subtitle: 'ID: ${client['client_id'] ?? 'N/A'}', + ), + height: 400, // Increased height + ), + ], + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/settings/api_ftp_configurations_settings.dart b/lib/screens/settings/api_ftp_configurations_settings.dart new file mode 100644 index 0000000..c5d3347 --- /dev/null +++ b/lib/screens/settings/api_ftp_configurations_settings.dart @@ -0,0 +1,138 @@ +// lib/screens/settings/api_ftp_configurations_settings.dart + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:environment_monitoring_app/auth_provider.dart'; + +class ApiFtpConfigurationsSettingsScreen extends StatelessWidget { + const ApiFtpConfigurationsSettingsScreen({super.key}); + + Widget _buildSectionHeader(BuildContext context, String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(8.0, 24.0, 8.0, 16.0), + child: Text( + title, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + ); + } + + Widget _buildExpansionTile({ + required String title, + required IconData leadingIcon, + required Widget child, + bool initiallyExpanded = false, + }) { + return ExpansionTile( + leading: Icon(leadingIcon), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + initiallyExpanded: initiallyExpanded, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: child, + ), + ], + ); + } + + Widget _buildInfoList(List>? items, + Widget Function(Map) itemBuilder) { + if (items == null || items.isEmpty) { + return const ListTile( + title: Text('No data available. Sync to download.'), + dense: true, + ); + } + return Column( + children: items.map((item) => itemBuilder(item)).toList(), + ); + } + + Widget _buildKeyValueEntry( + Map item, String key1, String key2) { + return ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 24.0, vertical: 4.0), + title: + Text(item[key1]?.toString() ?? 'N/A', style: const TextStyle(fontSize: 14)), + subtitle: + Text(item[key2]?.toString() ?? 'N/A', style: const TextStyle(fontSize: 12)), + dense: true, + ); + } + + Widget _buildFtpConfigEntry(Map item) { + return ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 24.0, vertical: 4.0), + title: Text(item['config_name']?.toString() ?? 'N/A', + style: const TextStyle(fontSize: 14)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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)), + Text('Port: ${item['ftp_port']?.toString() ?? 'N/A'}', + style: const TextStyle(fontSize: 12)), + ], + ), + leading: const Icon(Icons.folder_shared_outlined), + dense: true, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("API/FTP Configurations"), + ), + body: Consumer( + builder: (context, auth, child) { + final apiConfigs = auth.apiConfigs; + final ftpConfigs = auth.ftpConfigs; + + return ListView( + padding: const EdgeInsets.all(16.0), + children: [ + _buildSectionHeader(context, "Server Configurations"), + Card( + margin: EdgeInsets.zero, + child: Column( + children: [ + _buildExpansionTile( + title: 'API Configurations', + leadingIcon: Icons.cloud, + initiallyExpanded: true, + child: _buildInfoList( + apiConfigs, + (item) => + _buildKeyValueEntry(item, 'config_name', 'api_url')), + ), + _buildExpansionTile( + title: 'FTP Configurations', + leadingIcon: Icons.folder, + initiallyExpanded: true, + child: _buildInfoList( + ftpConfigs, (item) => _buildFtpConfigEntry(item)), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/settings/parameter_limits_settings.dart b/lib/screens/settings/parameter_limits_settings.dart new file mode 100644 index 0000000..d18e3dc --- /dev/null +++ b/lib/screens/settings/parameter_limits_settings.dart @@ -0,0 +1,485 @@ +// lib/screens/settings/parameter_limits_settings.dart + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:environment_monitoring_app/auth_provider.dart'; + +class ParameterLimitsSettingsScreen extends StatefulWidget { + const ParameterLimitsSettingsScreen({super.key}); + + @override + State createState() => + _ParameterLimitsSettingsScreenState(); +} + +class _ParameterLimitsSettingsScreenState + extends State { + final TextEditingController _npeRiverLimitsSearchController = + TextEditingController(); + String _npeRiverLimitsSearchQuery = ''; + final TextEditingController _npeMarineLimitsSearchController = + TextEditingController(); + String _npeMarineLimitsSearchQuery = ''; + final TextEditingController _airLimitsSearchController = + TextEditingController(); + String _airLimitsSearchQuery = ''; + final TextEditingController _riverLimitsSearchController = + TextEditingController(); + String _riverLimitsSearchQuery = ''; + final TextEditingController _marineLimitsSearchController = + TextEditingController(); + String _marineLimitsSearchQuery = ''; + + int _marineLimitsCurrentPage = 1; + final int _marineLimitsItemsPerPage = 15; + + @override + void initState() { + super.initState(); + _npeRiverLimitsSearchController.addListener(() => setState( + () => _npeRiverLimitsSearchQuery = _npeRiverLimitsSearchController.text)); + _npeMarineLimitsSearchController.addListener(() => setState(() => + _npeMarineLimitsSearchQuery = _npeMarineLimitsSearchController.text)); + _airLimitsSearchController.addListener(() => + setState(() => _airLimitsSearchQuery = _airLimitsSearchController.text)); + _riverLimitsSearchController.addListener(() => setState( + () => _riverLimitsSearchQuery = _riverLimitsSearchController.text)); + _marineLimitsSearchController.addListener(() { + setState(() { + _marineLimitsSearchQuery = _marineLimitsSearchController.text; + _marineLimitsCurrentPage = 1; // Reset to page 1 + }); + }); + } + + @override + void dispose() { + _npeRiverLimitsSearchController.dispose(); + _npeMarineLimitsSearchController.dispose(); + _airLimitsSearchController.dispose(); + _riverLimitsSearchController.dispose(); + _marineLimitsSearchController.dispose(); + super.dispose(); + } + + Widget _buildSectionHeader(BuildContext context, String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(8.0, 24.0, 8.0, 16.0), + child: Text( + title, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + ); + } + + Widget _buildExpansionTile({ + required String title, + required IconData leadingIcon, + required Widget child, + }) { + return ExpansionTile( + leading: Icon(leadingIcon), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: child, + ), + ], + ); + } + + Widget _buildInfoList(List>? items, + Widget Function(Map) itemBuilder) { + if (items == null || items.isEmpty) { + return const ListTile( + title: Text('No data available. Sync to download.'), + dense: true, + ); + } + return Column( + children: items.map((item) => itemBuilder(item)).toList(), + ); + } + + Widget _buildPaginatedMarineLimitsList( + List>? filteredList, + List>? allStations, + ) { + if (filteredList == null || filteredList.isEmpty) { + 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, + ); + } + + final totalItems = filteredList.length; + final totalPages = (totalItems / _marineLimitsItemsPerPage).ceil(); + if (totalPages > 0 && _marineLimitsCurrentPage > totalPages) { + _marineLimitsCurrentPage = totalPages; + } + + final startIndex = + (_marineLimitsCurrentPage - 1) * _marineLimitsItemsPerPage; + final endIndex = (startIndex + _marineLimitsItemsPerPage > totalItems) + ? totalItems + : startIndex + _marineLimitsItemsPerPage; + + final paginatedItems = filteredList.sublist(startIndex, endIndex); + + return Column( + children: [ + Column( + children: paginatedItems + .map((item) => + _buildParameterLimitEntry(item, stations: allStations)) + .toList(), + ), + 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, + ), + 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, + ), + ], + ), + ] + ], + ); + } + + Widget _buildParameterLimitEntry( + Map item, { + List>? departments, + List>? stations, + }) { + final paramName = item['param_parameter_list']?.toString() ?? 'N/A'; + final upperLimit = item['param_upper_limit']?.toString() ?? 'N/A'; + final lowerLimit = item['param_lower_limit']?.toString() ?? 'N/A'; + String contextSubtitle = ''; + + if (item.containsKey('department_id') && + item['department_id'] != null && + departments != null) { + final deptId = item['department_id']; + final dept = + departments.firstWhere((d) => d['department_id'] == deptId, orElse: () => {}); + if (dept.isNotEmpty) { + contextSubtitle = 'Dept: ${dept['department_name']}'; + } + } + + if (item.containsKey('station_id') && + item['station_id'] != null && + stations != null) { + final stationId = item['station_id']; + final station = + stations.firstWhere((s) => s['station_id'] == stationId, orElse: () => {}); + if (station.isNotEmpty) { + final stationCode = station['man_station_code'] ?? 'N/A'; + final stationName = station['man_station_name'] ?? 'N/A'; + contextSubtitle = 'Station: $stationCode - $stationName'; + } + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + flex: 2, + child: Text( + lowerLimit, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + textAlign: TextAlign.start, + ), + ), + Expanded( + flex: 5, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.science_outlined, size: 16), + const SizedBox(width: 8), + Flexible( + child: Text( + paramName, + style: const TextStyle(fontSize: 14), + textAlign: TextAlign.center, + ), + ), + ], + ), + if (contextSubtitle.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2.0), + child: Text( + contextSubtitle, + style: const TextStyle( + fontSize: 12, + fontStyle: FontStyle.italic, + color: Colors.grey), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + Expanded( + flex: 2, + child: Text( + upperLimit, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + textAlign: TextAlign.end, + ), + ), + ], + ), + ); + } + + Widget _buildSearchBar({ + required TextEditingController controller, + required String labelText, + required String hintText, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0, top: 8.0), + child: TextField( + controller: controller, + decoration: InputDecoration( + labelText: labelText, + hintText: hintText, + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)), + suffixIcon: controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () => controller.clear()) + : null, + ), + style: const TextStyle(fontSize: 14), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Parameter Limits"), + ), + body: Consumer( + builder: (context, auth, child) { + final npeParameterLimits = auth.npeParameterLimits; + final marineParameterLimits = auth.marineParameterLimits; + final riverParameterLimits = auth.riverParameterLimits; + final departments = auth.departments; + final allManualStations = auth.manualStations; + + final int? airDepartmentId = departments + ?.firstWhere((d) => d['department_name'] == 'Air', + orElse: () => {}) + ?['department_id']; + final int? riverDepartmentId = departments + ?.firstWhere((d) => d['department_name'] == 'River', + orElse: () => {}) + ?['department_id']; + final int? marineDepartmentId = departments + ?.firstWhere((d) => d['department_name'] == 'Marine', + orElse: () => {}) + ?['department_id']; + + final filteredNpeRiverLimits = npeParameterLimits?.where((limit) { + final isRiverNpe = + riverDepartmentId != null && limit['department_id'] == riverDepartmentId; + if (!isRiverNpe) return false; + final paramName = limit['param_parameter_list']?.toLowerCase() ?? ''; + final query = _npeRiverLimitsSearchQuery.toLowerCase(); + return paramName.contains(query); + }).toList(); + + final filteredNpeMarineLimits = npeParameterLimits?.where((limit) { + final isMarineNpe = marineDepartmentId != null && + limit['department_id'] == marineDepartmentId; + if (!isMarineNpe) return false; + final paramName = limit['param_parameter_list']?.toLowerCase() ?? ''; + final query = _npeMarineLimitsSearchQuery.toLowerCase(); + return paramName.contains(query); + }).toList(); + + final filteredAirLimits = npeParameterLimits?.where((limit) { + final isAirLimit = + airDepartmentId != null && limit['department_id'] == airDepartmentId; + if (!isAirLimit) return false; + final paramName = limit['param_parameter_list']?.toLowerCase() ?? ''; + final query = _airLimitsSearchQuery.toLowerCase(); + return paramName.contains(query); + }).toList(); + + final filteredRiverLimits = riverParameterLimits?.where((limit) { + final paramName = limit['param_parameter_list']?.toLowerCase() ?? ''; + final query = _riverLimitsSearchQuery.toLowerCase(); + return paramName.contains(query); + }).toList(); + + final filteredMarineLimits = marineParameterLimits?.where((limit) { + final paramName = limit['param_parameter_list']?.toLowerCase() ?? ''; + final query = _marineLimitsSearchQuery.toLowerCase(); + if (paramName.contains(query)) return true; + + final stationId = limit['station_id']; + if (stationId != null && allManualStations != null) { + final station = allManualStations.firstWhere( + (s) => s['station_id'] == stationId, + orElse: () => {}); + if (station.isNotEmpty) { + final stationName = + station['man_station_name']?.toLowerCase() ?? ''; + final stationCode = + station['man_station_code']?.toLowerCase() ?? ''; + if (stationName.contains(query) || stationCode.contains(query)) { + return true; + } + } + } + return false; + }).toList(); + + return ListView( + padding: const EdgeInsets.all(16.0), + children: [ + _buildSectionHeader(context, "Parameter Limits"), + Card( + margin: EdgeInsets.zero, + child: Column( + children: [ + _buildExpansionTile( + title: 'NPE River Parameter Limits', + leadingIcon: Icons.science_outlined, + child: Column( + children: [ + _buildSearchBar( + controller: _npeRiverLimitsSearchController, + labelText: 'Search NPE River Limits', + hintText: 'Search by parameter name', + ), + _buildInfoList( + filteredNpeRiverLimits, + (item) => _buildParameterLimitEntry(item, + departments: departments)), + ], + ), + ), + _buildExpansionTile( + title: 'NPE Marine Parameter Limits', + leadingIcon: Icons.science_outlined, + child: Column( + children: [ + _buildSearchBar( + controller: _npeMarineLimitsSearchController, + labelText: 'Search NPE Marine Limits', + hintText: 'Search by parameter name', + ), + _buildInfoList( + filteredNpeMarineLimits, + (item) => _buildParameterLimitEntry(item, + departments: departments)), + ], + ), + ), + _buildExpansionTile( + title: 'Air Parameter Limits', + leadingIcon: Icons.air, + child: Column( + children: [ + _buildSearchBar( + controller: _airLimitsSearchController, + labelText: 'Search Air Limits', + hintText: 'Search by parameter name', + ), + _buildInfoList( + filteredAirLimits, + (item) => _buildParameterLimitEntry(item, + departments: departments)), + ], + ), + ), + _buildExpansionTile( + title: 'River Parameter Limits', + leadingIcon: Icons.water, + child: Column( + children: [ + _buildSearchBar( + controller: _riverLimitsSearchController, + labelText: 'Search River Limits', + hintText: 'Search by parameter name', + ), + _buildInfoList(filteredRiverLimits, + (item) => _buildParameterLimitEntry(item)), + ], + ), + ), + _buildExpansionTile( + title: 'Marine Parameter Limits', + leadingIcon: Icons.waves, + child: Column( + children: [ + _buildSearchBar( + controller: _marineLimitsSearchController, + labelText: 'Search Marine Limits', + hintText: 'Search by parameter or station', + ), + _buildPaginatedMarineLimitsList( + filteredMarineLimits, allManualStations), + ], + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/settings/station_info_settings.dart b/lib/screens/settings/station_info_settings.dart new file mode 100644 index 0000000..6972f39 --- /dev/null +++ b/lib/screens/settings/station_info_settings.dart @@ -0,0 +1,372 @@ +// lib/screens/settings/station_info_settings.dart + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:environment_monitoring_app/auth_provider.dart'; + +class StationInfoSettingsScreen extends StatefulWidget { + const StationInfoSettingsScreen({super.key}); + + @override + State createState() => + _StationInfoSettingsScreenState(); +} + +class _StationInfoSettingsScreenState extends State { + final TextEditingController _tarballSearchController = + TextEditingController(); + String _tarballSearchQuery = ''; + final TextEditingController _manualSearchController = TextEditingController(); + String _manualSearchQuery = ''; + final TextEditingController _riverManualSearchController = + TextEditingController(); + String _riverManualSearchQuery = ''; + final TextEditingController _riverTriennialSearchController = + TextEditingController(); + String _riverTriennialSearchQuery = ''; + final TextEditingController _airStationSearchController = + TextEditingController(); + String _airStationSearchQuery = ''; + + @override + void initState() { + super.initState(); + _tarballSearchController.addListener(_onTarballSearchChanged); + _manualSearchController.addListener(_onManualSearchChanged); + _riverManualSearchController.addListener(_onRiverManualSearchChanged); + _riverTriennialSearchController.addListener(_onRiverTriennialSearchChanged); + _airStationSearchController.addListener(_onAirStationSearchChanged); + } + + @override + void dispose() { + _tarballSearchController.dispose(); + _manualSearchController.dispose(); + _riverManualSearchController.dispose(); + _riverTriennialSearchController.dispose(); + _airStationSearchController.dispose(); + super.dispose(); + } + + void _onTarballSearchChanged() { + setState(() { + _tarballSearchQuery = _tarballSearchController.text; + }); + } + + void _onManualSearchChanged() { + setState(() { + _manualSearchQuery = _manualSearchController.text; + }); + } + + void _onRiverManualSearchChanged() { + setState(() { + _riverManualSearchQuery = _riverManualSearchController.text; + }); + } + + void _onRiverTriennialSearchChanged() { + setState(() { + _riverTriennialSearchQuery = _riverTriennialSearchController.text; + }); + } + + void _onAirStationSearchChanged() { + setState(() { + _airStationSearchQuery = _airStationSearchController.text; + }); + } + + Widget _buildSectionHeader(BuildContext context, String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(8.0, 24.0, 8.0, 16.0), + child: Text( + title, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + ); + } + + Widget _buildExpansionTile({ + required String title, + required IconData leadingIcon, + required Widget child, + }) { + return ExpansionTile( + leading: Icon(leadingIcon), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + initiallyExpanded: true, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: child, + ), + ], + ); + } + + Widget _buildSearchBar({ + required TextEditingController controller, + required String labelText, + required String hintText, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0, top: 8.0), + child: TextField( + controller: controller, + decoration: InputDecoration( + labelText: labelText, + hintText: hintText, + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)), + suffixIcon: controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () => controller.clear()) + : null, + ), + style: const TextStyle(fontSize: 14), + ), + ); + } + + Widget _buildStationList( + List>? stations, + String noMatchText, + String noDataText, + Widget Function(Map) itemBuilder, + {double height = 250}) { + if (stations == null || stations.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + stations == null ? noDataText : noMatchText, + textAlign: TextAlign.center, + ), + ), + ); + } + + return SizedBox( + height: height, + child: ListView.builder( + itemCount: stations.length, + itemBuilder: (context, index) { + final station = stations[index]; + return itemBuilder(station); + }, + ), + ); + } + + Widget _buildStationTile( + {required String title, + required String subtitle, + required String type}) { + return ListTile( + title: Text(title, style: const TextStyle(fontSize: 14)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(subtitle, style: const TextStyle(fontSize: 12)), + Text('Type: $type', + style: + const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)), + ], + ), + dense: true, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Station Info"), + ), + body: Consumer( + builder: (context, auth, child) { + final filteredTarballStations = (auth.tarballStations?.where((station) { + final stationName = station['tbl_station_name']?.toLowerCase() ?? ''; + final stationCode = station['tbl_station_code']?.toLowerCase() ?? ''; + final query = _tarballSearchQuery.toLowerCase(); + return stationName.contains(query) || stationCode.contains(query); + }).toList()) + ?.cast>(); + + final filteredManualStations = (auth.manualStations?.where((station) { + final stationName = station['man_station_name']?.toLowerCase() ?? ''; + final stationCode = station['man_station_code']?.toLowerCase() ?? ''; + final query = _manualSearchQuery.toLowerCase(); + return stationName.contains(query) || stationCode.contains(query); + }).toList()) + ?.cast>(); + + final filteredRiverManualStations = + (auth.riverManualStations?.where((station) { + final riverName = station['sampling_river']?.toLowerCase() ?? ''; + final stationCode = + station['sampling_station_code']?.toLowerCase() ?? ''; + final basinName = station['sampling_basin']?.toLowerCase() ?? ''; + final query = _riverManualSearchQuery.toLowerCase(); + return riverName.contains(query) || + stationCode.contains(query) || + basinName.contains(query); + }).toList()) + ?.cast>(); + + final filteredRiverTriennialStations = + (auth.riverTriennialStations?.where((station) { + final riverName = station['triennial_river']?.toLowerCase() ?? ''; + final stationCode = + station['triennial_station_code']?.toLowerCase() ?? ''; + final basinName = station['triennial_basin']?.toLowerCase() ?? ''; + final query = _riverTriennialSearchQuery.toLowerCase(); + return riverName.contains(query) || + stationCode.contains(query) || + basinName.contains(query); + }).toList()) + ?.cast>(); + + final filteredAirStations = (auth.airManualStations?.where((station) { + final stationName = station['station_name']?.toLowerCase() ?? ''; + final stationCode = station['station_code']?.toLowerCase() ?? ''; + final query = _airStationSearchQuery.toLowerCase(); + return stationName.contains(query) || stationCode.contains(query); + }).toList()) + ?.cast>(); + + return ListView( + padding: const EdgeInsets.all(16.0), + children: [ + _buildSectionHeader(context, "Stations Info"), + Card( + margin: EdgeInsets.zero, + child: Column( + children: [ + _buildExpansionTile( + title: 'Marine Stations', + leadingIcon: Icons.waves, + child: Column( + children: [ + _buildSearchBar( + controller: _tarballSearchController, + labelText: 'Search Tarball Stations', + hintText: 'Search by name or code', + ), + const SizedBox(height: 16), + _buildStationList( + filteredTarballStations, + 'No matching tarball stations found.', + 'No tarball stations available. Sync to download.', + (station) => _buildStationTile( + title: station['tbl_station_name'] ?? 'N/A', + subtitle: + 'Code: ${station['tbl_station_code'] ?? 'N/A'}', + type: 'Tarball'), + height: 250, + ), + const SizedBox(height: 16), + _buildSearchBar( + controller: _manualSearchController, + labelText: 'Search Manual Stations', + hintText: 'Search by name or code', + ), + const SizedBox(height: 16), + _buildStationList( + filteredManualStations, + 'No matching manual stations found.', + 'No manual stations available. Sync to download.', + (station) => _buildStationTile( + title: station['man_station_name'] ?? 'N/A', + subtitle: + 'Code: ${station['man_station_code'] ?? 'N/A'}', + type: 'Manual'), + height: 250, + ), + ], + ), + ), + _buildExpansionTile( + title: 'River Stations', + leadingIcon: Icons.water, + child: Column( + children: [ + _buildSearchBar( + controller: _riverManualSearchController, + labelText: 'Search River Manual Stations', + hintText: 'Search by name, code, or basin', + ), + const SizedBox(height: 16), + _buildStationList( + filteredRiverManualStations, + 'No matching river manual stations found.', + 'No river manual stations available. Sync to download.', + (station) => _buildStationTile( + title: station['sampling_river'] ?? 'N/A', + subtitle: + 'Code: ${station['sampling_station_code'] ?? 'N/A'}, Basin: ${station['sampling_basin'] ?? 'N/A'}', + type: 'River Manual'), + height: 250, + ), + const SizedBox(height: 16), + _buildSearchBar( + controller: _riverTriennialSearchController, + labelText: 'Search River Triennial Stations', + hintText: 'Search by name, code, or basin', + ), + const SizedBox(height: 16), + _buildStationList( + filteredRiverTriennialStations, + 'No matching river triennial stations found.', + 'No river triennial stations available. Sync to download.', + (station) => _buildStationTile( + title: station['triennial_river'] ?? 'N/A', + subtitle: + 'Code: ${station['triennial_station_code'] ?? 'N/A'}, Basin: ${station['triennial_basin'] ?? 'N/A'}', + type: 'River Triennial'), + height: 250, + ), + ], + ), + ), + _buildExpansionTile( + title: 'Air Stations', + leadingIcon: Icons.air, + child: Column( + children: [ + _buildSearchBar( + controller: _airStationSearchController, + labelText: 'Search Air Stations', + hintText: 'Search by name or code', + ), + const SizedBox(height: 16), + _buildStationList( + filteredAirStations, + 'No matching air stations found.', + 'No air stations available. Sync to download.', + (station) => _buildStationTile( + title: station['station_name'] ?? 'N/A', + subtitle: + 'Code: ${station['station_code'] ?? 'N/A'}', + type: 'Air'), + height: 250, + ), + ], + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/settings/submission_preferences_settings.dart b/lib/screens/settings/submission_preferences_settings.dart new file mode 100644 index 0000000..179a6a3 --- /dev/null +++ b/lib/screens/settings/submission_preferences_settings.dart @@ -0,0 +1,255 @@ +// lib/screens/settings/submission_preferences_settings.dart + +import 'package:flutter/material.dart'; +import 'package:environment_monitoring_app/services/user_preferences_service.dart'; + +class _ModuleSettings { + bool isApiEnabled; + bool isFtpEnabled; + List> apiConfigs; + List> ftpConfigs; + + _ModuleSettings({ + this.isApiEnabled = true, + this.isFtpEnabled = true, + required this.apiConfigs, + required this.ftpConfigs, + }); +} + +class SubmissionPreferencesSettingsScreen extends StatefulWidget { + const SubmissionPreferencesSettingsScreen({super.key}); + + @override + State createState() => + _SubmissionPreferencesSettingsScreenState(); +} + +class _SubmissionPreferencesSettingsScreenState + extends State { + final UserPreferencesService _preferencesService = UserPreferencesService(); + bool _isLoadingSettings = true; + bool _isSaving = false; + + final Map _moduleSettings = {}; + + 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'}, + ]; + + @override + void initState() { + super.initState(); + _loadAllModuleSettings(); + } + + Future _loadAllModuleSettings() async { + setState(() => _isLoadingSettings = true); + for (var module in _configurableModules) { + final moduleKey = module['key']!; + + final prefs = await _preferencesService.getModulePreference(moduleKey); + final apiConfigsWithPrefs = + await _preferencesService.getAllApiConfigsWithModulePreferences(moduleKey); + final ftpConfigsWithPrefs = + await _preferencesService.getAllFtpConfigsWithModulePreferences(moduleKey); + + _moduleSettings[moduleKey] = _ModuleSettings( + isApiEnabled: prefs?['is_api_enabled'] ?? true, // Fallback to true if null + isFtpEnabled: prefs?['is_ftp_enabled'] ?? true, // Fallback to true if null + apiConfigs: apiConfigsWithPrefs, + ftpConfigs: ftpConfigsWithPrefs, + ); + } + if (mounted) { + setState(() => _isLoadingSettings = false); + } + } + + Future _saveAllModuleSettings() async { + setState(() => _isSaving = true); + + try { + for (var module in _configurableModules) { + final moduleKey = module['key']!; + final settings = _moduleSettings[moduleKey]!; + + await _preferencesService.saveModulePreference( + moduleName: moduleKey, + isApiEnabled: settings.isApiEnabled, + isFtpEnabled: settings.isFtpEnabled, + ); + + await _preferencesService.saveApiLinksForModule( + moduleKey, settings.apiConfigs); + await _preferencesService.saveFtpLinksForModule( + moduleKey, settings.ftpConfigs); + } + _showSnackBar('Submission preferences saved successfully.', isError: false); + } catch (e) { + _showSnackBar('Failed to save settings: $e', isError: true); + } finally { + if (mounted) { + setState(() => _isSaving = false); + } + } + } + + void _showSnackBar(String message, {bool isError = false}) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: + isError ? Theme.of(context).colorScheme.error : Colors.green, + ), + ); + } + } + + Widget _buildSectionHeader(BuildContext context, String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(8.0, 24.0, 8.0, 16.0), + child: Text( + title, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Submission Preferences"), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: _isSaving + ? const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(color: Colors.white))) + : IconButton( + icon: const Icon(Icons.save), + onPressed: + _isLoadingSettings ? null : _saveAllModuleSettings, + tooltip: 'Save Submission Preferences', + ), + ) + ], + ), + body: _isLoadingSettings + ? const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator())) + : ListView( + padding: const EdgeInsets.all(16.0), + children: [ + _buildSectionHeader(context, "Module Settings"), + Card( + margin: EdgeInsets.zero, + child: ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _configurableModules.length, + itemBuilder: (context, index) { + final module = _configurableModules[index]; + final settings = _moduleSettings[module['key']]; + if (settings == null) return const SizedBox.shrink(); + return _buildModulePreferenceTile( + module['name']!, module['key']!, settings); + }, + ), + ), + ], + ), + ); + } + + Widget _buildModulePreferenceTile( + String title, String moduleKey, _ModuleSettings settings) { + return ExpansionTile( + title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + initiallyExpanded: false, // Start collapsed + children: [ + SwitchListTile( + title: const Text('Enable API Submission'), + value: settings.isApiEnabled, + onChanged: (value) => setState(() => settings.isApiEnabled = value), + ), + if (settings.isApiEnabled) + _buildDestinationList( + 'API Destinations', settings.apiConfigs, 'api_config_id'), + const Divider(), + SwitchListTile( + title: const Text('Enable FTP Submission'), + value: settings.isFtpEnabled, + onChanged: (value) => setState(() => settings.isFtpEnabled = value), + ), + if (settings.isFtpEnabled) + _buildDestinationList( + 'FTP Destinations', settings.ftpConfigs, 'ftp_config_id'), + ], + ); + } + + Widget _buildDestinationList( + String title, List> configs, String idKey) { + if (configs.isEmpty) { + return const ListTile( + dense: true, + title: + Center(child: Text('No destinations configured. Sync to fetch.')), + ); + } + return Padding( + padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16.0, bottom: 8.0), + child: Text(title, style: Theme.of(context).textTheme.titleMedium), + ), + ...configs.map((config) { + 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(subtitleText, style: const TextStyle(fontSize: 12)), + value: config['is_enabled'] ?? false, + onChanged: (bool? value) { + setState(() { + config['is_enabled'] = value ?? false; + }); + }, + dense: true, + ); + }).toList(), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/settings/telegram_alert_settings.dart b/lib/screens/settings/telegram_alert_settings.dart new file mode 100644 index 0000000..e47d565 --- /dev/null +++ b/lib/screens/settings/telegram_alert_settings.dart @@ -0,0 +1,114 @@ +// lib/screens/settings/telegram_alert_settings.dart + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:environment_monitoring_app/auth_provider.dart'; +import 'package:environment_monitoring_app/services/settings_service.dart'; + +class TelegramAlertSettingsScreen extends StatelessWidget { + // --- START MODIFICATION --- + // Removed 'const' from the constructor to fix the error. + TelegramAlertSettingsScreen({super.key}); + // --- END MODIFICATION --- + + // Helper service for parsing settings + final SettingsService _settingsService = SettingsService(); + + Widget _buildChatIdEntry(String label, String value) { + return ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.telegram, size: 20), + title: Text('$label Chat ID'), + subtitle: Text(value.isNotEmpty ? value : 'Not Set'), + dense: true, + ); + } + + Widget _buildSectionHeader(BuildContext context, String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(8.0, 24.0, 8.0, 16.0), + child: Text( + title, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Telegram Alert Settings"), + ), + body: Consumer( + builder: (context, auth, child) { + final appSettings = auth.appSettings; + return ListView( + padding: const EdgeInsets.all(16.0), + children: [ + _buildSectionHeader(context, "Telegram Alerts"), + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + ExpansionTile( + title: const Text('Marine Alerts', + style: TextStyle(fontWeight: FontWeight.bold)), + initiallyExpanded: true, + children: [ + _buildChatIdEntry('In-Situ', + _settingsService.getInSituChatId(appSettings)), + _buildChatIdEntry('Tarball', + _settingsService.getTarballChatId(appSettings)), + _buildChatIdEntry( + 'Investigative', + _settingsService + .getMarineInvestigativeChatId(appSettings)), + ], + ), + ExpansionTile( + title: const Text('River Alerts', + style: TextStyle(fontWeight: FontWeight.bold)), + initiallyExpanded: false, + children: [ + _buildChatIdEntry('In-Situ', + _settingsService.getRiverInSituChatId(appSettings)), + _buildChatIdEntry( + 'Triennial', + _settingsService + .getRiverTriennialChatId(appSettings)), + _buildChatIdEntry( + 'Investigative', + _settingsService + .getRiverInvestigativeChatId(appSettings)), + ], + ), + ExpansionTile( + title: const Text('Air Alerts', + style: TextStyle(fontWeight: FontWeight.bold)), + initiallyExpanded: false, + children: [ + _buildChatIdEntry('Manual', + _settingsService.getAirManualChatId(appSettings)), + _buildChatIdEntry( + 'Investigative', + _settingsService + .getAirInvestigativeChatId(appSettings)), + ], + ), + ], + ), + ), + ), + ], + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/services/user_preferences_service.dart b/lib/services/user_preferences_service.dart index 44bfceb..65a64af 100644 --- a/lib/services/user_preferences_service.dart +++ b/lib/services/user_preferences_service.dart @@ -58,13 +58,20 @@ class UserPreferencesService { }).toList(); // 3. Determine default FTP links - // --- START MODIFICATION: Simplified logic using ftp_module --- + // Handle mapping 'marine_in_situ' -> 'marine_manual' + String expectedFtpModuleKey = moduleKey; + if (moduleKey == 'marine_in_situ') { + expectedFtpModuleKey = 'marine_manual'; + } else if (moduleKey == 'river_in_situ') { + expectedFtpModuleKey = 'river_manual'; + } + final defaultFtpLinks = allFtpConfigs.map((config) { 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; + bool isEnabled = (configModule == expectedFtpModuleKey) && isActive; return {...config, 'is_enabled': isEnabled}; }).toList(); @@ -124,25 +131,28 @@ class UserPreferencesService { // 3. Merge the two lists. return allApiConfigs.map((config) { final configId = config['api_config_id']; - bool isEnabled; // Default to disabled + bool isEnabled; + // --- START MODIFICATION: Corrected Merge Logic --- + Map? matchingLink; try { - // Find if a link exists for this config ID in the user's saved preferences. - final matchingLink = savedLinks.firstWhere( + matchingLink = savedLinks.firstWhere( (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 (e.g., new config). - // Default to the 'is_active' flag from the server. - isEnabled = (config['is_active'] == 1 || config['is_active'] == true); - // --- END --- + matchingLink = null; // No match found } + if (matchingLink != null) { + // A preference exists. Use the saved value. + isEnabled = matchingLink['is_enabled'] as bool? ?? false; + } else { + // No preference saved for this config. Apply default logic. + // (This handles newly synced configs automatically) + isEnabled = (config['is_active'] == 1 || config['is_active'] == true); + } + // --- END MODIFICATION --- + // Return a new map containing the original config details plus the 'is_enabled' flag. return { ...config, @@ -159,25 +169,40 @@ class UserPreferencesService { final savedLinks = await _dbHelper.getAllFtpLinksForModule(moduleName); + // Handle mapping 'marine_in_situ' -> 'marine_manual' + String expectedFtpModuleKey = moduleName; + if (moduleName == 'marine_in_situ') { + expectedFtpModuleKey = 'marine_manual'; + } else if (moduleName == 'river_in_situ') { + expectedFtpModuleKey = 'river_manual'; + } + return allFtpConfigs.map((config) { final configId = config['ftp_config_id']; bool isEnabled; + + // --- START MODIFICATION: Corrected Merge Logic --- + Map? matchingLink; try { - final matchingLink = savedLinks.firstWhere( + 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) { - // --- 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. + matchingLink = null; // No match found + } + + if (matchingLink != null) { + // A preference exists. Use the saved value. + isEnabled = matchingLink['is_enabled'] as bool? ?? false; + } else { + // No preference saved for this config. Apply default logic. final String configModule = config['ftp_module'] ?? ''; final bool isActive = (config['is_active'] == 1 || config['is_active'] == true); - isEnabled = (configModule == moduleName) && isActive; - // --- END MODIFICATION --- + // Use the mapped key for comparison + isEnabled = (configModule == expectedFtpModuleKey) && isActive; } + // --- END MODIFICATION --- + return { ...config, 'is_enabled': isEnabled,