environment_monitoring_app/lib/screens/settings.dart

967 lines
38 KiB
Dart

// lib/screens/settings.dart
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';
// START CHANGE: Import the new UserPreferencesService to manage submission settings
import 'package:environment_monitoring_app/services/user_preferences_service.dart';
// END CHANGE
// START CHANGE: A helper class to manage the state of each module's settings in the UI
class _ModuleSettings {
bool isApiEnabled;
bool isFtpEnabled;
List<Map<String, dynamic>> apiConfigs;
List<Map<String, dynamic>> ftpConfigs;
_ModuleSettings({
this.isApiEnabled = true,
this.isFtpEnabled = true,
required this.apiConfigs,
required this.ftpConfigs,
});
}
// END CHANGE
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final SettingsService _settingsService = SettingsService();
bool _isSyncingData = false;
// START CHANGE: New state variables for managing submission preferences UI
final UserPreferencesService _preferencesService = UserPreferencesService();
bool _isLoadingSettings = true;
bool _isSaving = false;
// This map holds the live state of the settings UI for each module
final Map<String, _ModuleSettings> _moduleSettings = {};
// This list defines which modules will appear in the new settings section
final List<Map<String, String>> _configurableModules = [
{'key': 'marine_tarball', 'name': 'Marine Tarball'},
{'key': 'marine_in_situ', 'name': 'Marine In-Situ'},
{'key': 'river_in_situ', 'name': 'River In-Situ'},
{'key': 'air_installation', 'name': 'Air Installation'},
{'key': 'air_collection', 'name': 'Air Collection'},
];
// END CHANGE
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 = '';
@override
void initState() {
super.initState();
_loadAllModuleSettings(); // Load the new submission preferences on init
_tarballSearchController.addListener(_onTarballSearchChanged);
_manualSearchController.addListener(_onManualSearchChanged);
_riverManualSearchController.addListener(_onRiverManualSearchChanged);
_riverTriennialSearchController.addListener(_onRiverTriennialSearchChanged);
_airStationSearchController.addListener(_onAirStationSearchChanged);
_airClientSearchController.addListener(_onAirClientSearchChanged);
}
@override
void dispose() {
_tarballSearchController.dispose();
_manualSearchController.dispose();
_riverManualSearchController.dispose();
_riverTriennialSearchController.dispose();
_airStationSearchController.dispose();
_airClientSearchController.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;
});
}
// START CHANGE: New methods for loading and saving the submission preferences
Future<void> _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);
// START MODIFICATION: Apply default settings for submission preferences
// This logic checks if the main toggle for a submission type is on but no
// specific destination is checked. If so, it applies a default selection.
// This ensures a default configuration without overriding saved user choices.
// Check if any API config is already enabled from preferences.
final bool isAnyApiConfigEnabled = apiConfigsWithPrefs.any((c) => c['is_enabled'] == true);
// If the main API toggle is on but no specific API is selected, apply the default.
if (prefs['is_api_enabled'] == true && !isAnyApiConfigEnabled) {
final pstwHqApi = apiConfigsWithPrefs.firstWhere((c) => c['config_name'] == 'pstw_hq', orElse: () => {});
if (pstwHqApi.isNotEmpty) {
pstwHqApi['is_enabled'] = true;
}
}
// Check if any FTP config is already enabled from preferences.
final bool isAnyFtpConfigEnabled = ftpConfigsWithPrefs.any((c) => c['is_enabled'] == true);
// If the main FTP toggle is on but no specific FTP is selected, apply the defaults for the module.
if (prefs['is_ftp_enabled'] == true && !isAnyFtpConfigEnabled) {
switch (moduleKey) {
case 'marine_tarball':
for (var config in ftpConfigsWithPrefs) {
if (config['config_name'] == 'pstw_marine_tarball' || config['config_name'] == 'tes_marine_tarball') {
config['is_enabled'] = true;
}
}
break;
case 'marine_in_situ':
for (var config in ftpConfigsWithPrefs) {
if (config['config_name'] == 'pstw_marine_manual' || config['config_name'] == 'tes_marine_manual') {
config['is_enabled'] = true;
}
}
break;
case 'river_in_situ':
for (var config in ftpConfigsWithPrefs) {
if (config['config_name'] == 'pstw_river_manual' || config['config_name'] == 'tes_river_manual') {
config['is_enabled'] = true;
}
}
break;
case 'air_collection':
for (var config in ftpConfigsWithPrefs) {
if (config['config_name'] == 'pstw_air_collect' || config['config_name'] == 'tes_air_collect') {
config['is_enabled'] = true;
}
}
break;
case 'air_installation':
for (var config in ftpConfigsWithPrefs) {
if (config['config_name'] == 'pstw_air_install' || config['config_name'] == 'tes_air_install') {
config['is_enabled'] = true;
}
}
break;
}
}
// END MODIFICATION
_moduleSettings[moduleKey] = _ModuleSettings(
isApiEnabled: prefs['is_api_enabled'],
isFtpEnabled: prefs['is_ftp_enabled'],
apiConfigs: apiConfigsWithPrefs,
ftpConfigs: ftpConfigsWithPrefs,
);
}
if (mounted) {
setState(() => _isLoadingSettings = false);
}
}
Future<void> _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);
}
}
}
// END CHANGE
Future<void> _manualDataSync() async {
if (_isSyncingData) return;
setState(() => _isSyncingData = true);
final auth = Provider.of<AuthProvider>(context, listen: false);
try {
await auth.syncAllData(forceRefresh: true);
// MODIFIED: After syncing, also reload module settings to reflect any new server configurations.
await _loadAllModuleSettings();
if (mounted) {
_showSnackBar('Data synced successfully.', isError: false);
}
} catch (e) {
if (mounted) {
_showSnackBar('Data sync failed. Please check your connection.', isError: true);
}
} finally {
if (mounted) {
setState(() => _isSyncingData = 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,
),
);
}
}
@override
Widget build(BuildContext context) {
final auth = Provider.of<AuthProvider>(context);
final lastSync = auth.lastSyncTimestamp;
// Get the synced data from the provider.
final appSettings = auth.appSettings;
final parameterLimits = auth.parameterLimits;
final apiConfigs = auth.apiConfigs;
final ftpConfigs = auth.ftpConfigs;
final airClients = auth.airClients;
final departments = auth.departments;
// Find Department IDs
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'];
// Filter Parameter Limits by Department ID
final filteredAirLimits = parameterLimits?.where((limit) => limit['department_id'] == airDepartmentId).toList();
final filteredRiverLimits = parameterLimits?.where((limit) => limit['department_id'] == riverDepartmentId).toList();
final filteredMarineLimits = parameterLimits?.where((limit) => limit['department_id'] == marineDepartmentId).toList();
// Filter Marine Stations
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<Map<String, dynamic>>();
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<Map<String, dynamic>>();
// Filter River Stations
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<Map<String, dynamic>>();
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<Map<String, dynamic>>();
// Filter Air Stations
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<Map<String, dynamic>>();
// Filter Air Clients
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<Map<String, dynamic>>();
return Scaffold(
appBar: AppBar(
title: const Text("Settings"),
// START CHANGE: Add a save button to the AppBar
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',
),
)
],
// END CHANGE
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader(context, "Synchronization"),
Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text("Last Data Sync:", style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(lastSync != null ? DateFormat('yyyy-MM-dd HH:mm:ss').format(lastSync.toLocal()) : 'Never', style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _isSyncingData ? null : _manualDataSync,
icon: _isSyncingData ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Icon(Icons.cloud_sync),
label: Text(_isSyncingData ? 'Syncing Data...' : 'Sync App Data'),
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 50)),
),
],
),
),
),
const SizedBox(height: 32),
// START CHANGE: Insert the new Submission Preferences section
_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),
// END CHANGE
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)),
],
),
],
),
),
),
const SizedBox(height: 32),
_buildSectionHeader(context, "Configurations"),
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
_buildExpansionTile(
title: 'Air Parameter Limits',
leadingIcon: Icons.poll,
child: _buildInfoList(filteredAirLimits, (item) => _buildParameterLimitEntry(item)),
),
_buildExpansionTile(
title: 'River Parameter Limits',
leadingIcon: Icons.poll,
child: _buildInfoList(filteredRiverLimits, (item) => _buildParameterLimitEntry(item)),
),
_buildExpansionTile(
title: 'Marine Parameter Limits',
leadingIcon: Icons.poll,
child: _buildInfoList(filteredMarineLimits, (item) => _buildParameterLimitEntry(item)),
),
_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)),
),
],
),
),
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,
),
],
),
),
],
),
),
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,
),
],
),
),
],
),
),
const SizedBox(height: 32),
_buildSectionHeader(context, "Other Information"),
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('App Version'),
subtitle: const Text('MMS V4 1.2.07'),
dense: true,
),
ListTile(
leading: const Icon(Icons.privacy_tip_outlined),
title: const Text('Privacy Policy'),
onTap: () {},
dense: true,
),
],
),
),
],
),
),
);
}
// START CHANGE: New helper widgets for the preferences UI
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'),
],
);
}
Widget _buildDestinationList(String title, List<Map<String, dynamic>> 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) {
return CheckboxListTile(
title: Text(config['config_name'] ?? 'Unnamed'),
subtitle: Text(config['api_url'] ?? config['ftp_host'] ?? 'No URL/Host'),
value: config['is_enabled'] ?? false,
onChanged: (bool? value) {
setState(() {
config['is_enabled'] = value ?? false;
});
},
dense: true,
);
}).toList(),
],
),
);
}
// END CHANGE
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: [
child,
],
);
}
Widget _buildInfoList(List<Map<String, dynamic>>? items, Widget Function(Map<String, dynamic>) 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 _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<String, dynamic> 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<String, dynamic> item) {
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 unit = '';
// Hardcoded units as they are not available in the provided data
if (paramName.toLowerCase() == 'ph') {
unit = 'pH units';
} else if (paramName.toLowerCase() == 'temp') {
unit = '°C';
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.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),
Text(
paramName,
style: const TextStyle(fontSize: 14),
),
],
),
Text(
unit,
style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic, color: Colors.grey),
),
],
),
),
Expanded(
flex: 2,
child: Text(
upperLimit,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
textAlign: TextAlign.end,
),
),
],
),
);
}
Widget _buildFtpConfigEntry(Map<String, dynamic> 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('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 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<Map<String, dynamic>>? stations,
String noMatchText,
String noDataText,
Widget Function(Map<String, dynamic>) 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<Map<String, dynamic>>? clients,
String noMatchText,
String noDataText,
Widget Function(Map<String, dynamic>) 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,
);
}
}