1101 lines
44 KiB
Dart
1101 lines
44 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';
|
|
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<Map<String, dynamic>> apiConfigs;
|
|
List<Map<String, dynamic>> ftpConfigs;
|
|
|
|
_ModuleSettings({
|
|
this.isApiEnabled = true,
|
|
this.isFtpEnabled = true,
|
|
required this.apiConfigs,
|
|
required this.ftpConfigs,
|
|
});
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
final UserPreferencesService _preferencesService = UserPreferencesService();
|
|
final DatabaseHelper _dbHelper = DatabaseHelper(); // Add instance for direct access
|
|
bool _isLoadingSettings = true;
|
|
bool _isSaving = false;
|
|
|
|
final Map<String, _ModuleSettings> _moduleSettings = {};
|
|
|
|
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'},
|
|
];
|
|
|
|
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 = '';
|
|
final TextEditingController _marineLimitsSearchController = TextEditingController();
|
|
String _marineLimitsSearchQuery = '';
|
|
|
|
@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));
|
|
}
|
|
|
|
@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<void> _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<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);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _manualDataSync() async {
|
|
if (_isSyncingData) return;
|
|
setState(() => _isSyncingData = true);
|
|
|
|
final auth = Provider.of<AuthProvider>(context, listen: false);
|
|
|
|
// 1. Pre-sync checks
|
|
if (!await auth.isConnected()) {
|
|
_showSnackBar('Sync failed: No internet connection.', isError: true);
|
|
setState(() => _isSyncingData = false);
|
|
return;
|
|
}
|
|
|
|
if (auth.isSessionExpired) {
|
|
_showSessionExpiredDialog();
|
|
setState(() => _isSyncingData = false);
|
|
return;
|
|
}
|
|
|
|
// 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
|
|
|
|
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);
|
|
}
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _isSyncingData = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _showSessionExpiredDialog() {
|
|
if (!mounted) return;
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text("Session Expired"),
|
|
content: const Text("Your session has expired and automatic re-login failed. Please log out and log in again to sync data."),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: const Text("OK"),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
final auth = Provider.of<AuthProvider>(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"),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
|
|
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<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>>();
|
|
|
|
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>>();
|
|
|
|
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>>();
|
|
|
|
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"),
|
|
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),
|
|
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),
|
|
|
|
_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),
|
|
|
|
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: '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',
|
|
),
|
|
_buildInfoList(filteredMarineLimits, (item) => _buildParameterLimitEntry(item, stations: 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)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
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 Version 3.5.01'),
|
|
dense: true,
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.privacy_tip_outlined),
|
|
title: const Text('Privacy Policy'),
|
|
onTap: () {},
|
|
dense: true,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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<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, {
|
|
List<Map<String, dynamic>>? departments,
|
|
List<Map<String, dynamic>>? 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) {
|
|
// --- START: MODIFICATION ---
|
|
final stationCode = station['man_station_code'] ?? 'N/A';
|
|
final stationName = station['man_station_name'] ?? 'N/A';
|
|
contextSubtitle = 'Station: $stationCode - $stationName';
|
|
// --- END: MODIFICATION ---
|
|
}
|
|
}
|
|
|
|
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<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 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<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,
|
|
);
|
|
}
|
|
} |