diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 723c08a..bdd9f99 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -749,5 +749,14 @@ "noBlockingScheduleThisDevice": "There's no blocking schedule for this device.", "selectTimezone": "Select a timezone", "selectClientsFiltersInfo": "Select the clients you want to display. If no clients are selected, all will be displayed.", - "noDataThisSection": "There's no data for this section." + "noDataThisSection": "There's no data for this section.", + "statisticsSettings": "Statistics settings", + "statisticsSettingsDescription": "Configure data collection for statistics", + "loadingStatisticsSettings": "Loading statistics settings...", + "statisticsSettingsLoadError": "An error occured when loading statistics settings.", + "customTimeInHours": "Custom time (in hours)", + "invalidTime": "Invalid time", + "removeDomain": "Remove domain", + "addDomain": "Add domain", + "notLess1Hour": "Time cannot be less than 1 hour" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 837d9d7..d9b2f9a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -749,5 +749,14 @@ "noBlockingScheduleThisDevice": "No hay programación de bloqueo para este dispositivo.", "selectTimezone": "Selecciona una zona horaria", "selectClientsFiltersInfo": "Selecciona los clientes que quieres mostrar. Si no hay clientes seleccionados, se mostrarán todos.", - "noDataThisSection": "No hay datos para esta sección." + "noDataThisSection": "No hay datos para esta sección.", + "statisticsSettings": "Ajustes de estadísticas", + "statisticsSettingsDescription": "Configura la recolección de datos para estadísticas", + "loadingStatisticsSettings": "Cargando ajustes de estadísticas...", + "statisticsSettingsLoadError": "Ocurrió un error al cargar los ajustes de estadísticas.", + "customTimeInHours": "Tiempo personalizado (en horas)", + "invalidTime": "Tiempo no válido", + "removeDomain": "Eliminar dominio", + "addDomain": "Añadir dominio", + "notLess1Hour": "El tiempo no puede ser inferior a 1 hora" } \ No newline at end of file diff --git a/lib/models/statistics_config.dart b/lib/models/statistics_config.dart new file mode 100644 index 0000000..f504708 --- /dev/null +++ b/lib/models/statistics_config.dart @@ -0,0 +1,23 @@ +class StatisticsConfig { + final List? ignored; + final int? interval; + final bool? enabled; + + StatisticsConfig({ + this.ignored, + this.interval, + this.enabled, + }); + + factory StatisticsConfig.fromJson(Map json) => StatisticsConfig( + ignored: json["ignored"] == null ? [] : List.from(json["ignored"]!.map((x) => x)), + interval: json["interval"], + enabled: json["enabled"], + ); + + Map toJson() => { + "ignored": ignored == null ? [] : List.from(ignored!.map((x) => x)), + "interval": interval, + "enabled": enabled, + }; +} diff --git a/lib/screens/settings/app_logs/app_log_details_modal.dart b/lib/screens/settings/app_logs/app_log_details_modal.dart deleted file mode 100644 index 26d07f2..0000000 --- a/lib/screens/settings/app_logs/app_log_details_modal.dart +++ /dev/null @@ -1,194 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - -import 'package:adguard_home_manager/models/app_log.dart'; - -class AppLogDetailsModal extends StatefulWidget { - final AppLog log; - - const AppLogDetailsModal({ - Key? key, - required this.log - }) : super(key: key); - - @override - State createState() => _AppLogDetailsModalState(); -} - -class _AppLogDetailsModalState extends State { - String valueToShow = 'message'; - - String generateBody() { - switch (valueToShow) { - case 'message': - return widget.log.message; - - case 'statusCode': - return widget.log.statusCode != null - ? widget.log.statusCode.toString() - : "[NO STAUS CODE]"; - - case 'body': - return widget.log.resBody != null - ? widget.log.resBody.toString() - : "[NO RESPONSE BODY]"; - - default: - return ''; - } - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Column( - children: [ - Icon( - Icons.description_rounded, - size: 24, - color: Theme.of(context).listTileTheme.iconColor, - ), - const SizedBox(height: 16), - Text( - AppLocalizations.of(context)!.logDetails, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 24, - color: Theme.of(context).colorScheme.onSurface - ), - ) - ], - ), - scrollable: true, - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(height: 20), - Row( - children: [ - Material( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(15), - bottomLeft: Radius.circular(15) - ), - color: Colors.transparent, - child: InkWell( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(15), - bottomLeft: Radius.circular(15) - ), - onTap: () => setState(() => valueToShow = 'message'), - child: AnimatedContainer( - padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 10), - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(15), - bottomLeft: Radius.circular(15) - ), - border: Border.all( - color: Theme.of(context).colorScheme.primary - ), - color: valueToShow == 'message' - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.primary.withOpacity(0.05) - ), - child: Text( - "Message", - style: TextStyle( - color: valueToShow == 'message' - ? Colors.white - : null - ), - ), - ), - ), - ), - Material( - color: Colors.transparent, - child: InkWell( - onTap: () => setState(() => valueToShow = 'statusCode'), - child: AnimatedContainer( - padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 10), - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - decoration: BoxDecoration( - border: Border( - top: BorderSide( - color: Theme.of(context).colorScheme.primary - ), - bottom: BorderSide( - color: Theme.of(context).colorScheme.primary - ), - ), - color: valueToShow == 'statusCode' - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.primary.withOpacity(0.05) - ), - child: Text( - "Status code", - style: TextStyle( - color: valueToShow == 'statusCode' - ? Colors.white - : null - ), - ), - ), - ), - ), - Material( - borderRadius: const BorderRadius.only( - topRight: Radius.circular(15), - bottomRight: Radius.circular(15) - ), - color: Colors.transparent, - child: InkWell( - borderRadius: const BorderRadius.only( - topRight: Radius.circular(15), - bottomRight: Radius.circular(15) - ), - onTap: () => setState(() => valueToShow = 'body'), - child: AnimatedContainer( - padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 10), - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - topRight: Radius.circular(15), - bottomRight: Radius.circular(15) - ), - border: Border.all( - color: Theme.of(context).colorScheme.primary - ), - color: valueToShow == 'body' - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.primary.withOpacity(0.05) - ), - child: Text( - "Body", - style: TextStyle( - color: valueToShow == 'body' - ? Colors.white - : null - ), - ), - ), - ), - ), - ], - ), - const SizedBox(height: 20), - Text(generateBody()) - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text("Close") - ) - ], - ); - } -} \ No newline at end of file diff --git a/lib/screens/settings/app_logs/app_logs.dart b/lib/screens/settings/app_logs/app_logs.dart deleted file mode 100644 index 19a8502..0000000 --- a/lib/screens/settings/app_logs/app_logs.dart +++ /dev/null @@ -1,81 +0,0 @@ -// ignore_for_file: use_build_context_synchronously - -import 'dart:convert'; - -import 'package:adguard_home_manager/functions/copy_clipboard.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - -import 'package:adguard_home_manager/screens/settings/app_logs/app_log_details_modal.dart'; - -import 'package:adguard_home_manager/providers/app_config_provider.dart'; - -class AppLogs extends StatelessWidget { - const AppLogs({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final appConfigProvider = Provider.of(context); - - return Scaffold( - appBar: AppBar( - title: Text(AppLocalizations.of(context)!.logs), - actions: [ - IconButton( - onPressed: appConfigProvider.logs.isNotEmpty - ? () => copyToClipboard( - value: jsonEncode(appConfigProvider.logs.map((log) => log.toMap()).toList()), - successMessage: AppLocalizations.of(context)!.logsCopiedClipboard - ) - : null, - icon: const Icon(Icons.share), - tooltip: AppLocalizations.of(context)!.copyLogsClipboard, - ), - const SizedBox(width: 10), - ], - ), - body: appConfigProvider.logs.isNotEmpty - ? ListView.builder( - padding: const EdgeInsets.only(top: 0), - itemCount: appConfigProvider.logs.length, - itemBuilder: (context, index) => ListTile( - title: Text( - appConfigProvider.logs[index].message, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.normal, - color: Theme.of(context).colorScheme.onSurface - ), - ), - subtitle: Text( - appConfigProvider.logs[index].dateTime.toString(), - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.normal, - color: Theme.of(context).listTileTheme.textColor - ), - ), - trailing: Text(appConfigProvider.logs[index].type), - onTap: () => { - showDialog( - context: context, - builder: (context) => AppLogDetailsModal( - log: appConfigProvider.logs[index] - ) - ) - }, - ) - ) - : Center( - child: Text( - AppLocalizations.of(context)!.noSavedLogs, - style: TextStyle( - fontSize: 24, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ) - ); - } -} \ No newline at end of file diff --git a/lib/screens/settings/logs_settings/config_widgets.dart b/lib/screens/settings/logs_settings/config_widgets.dart index 4dbaee7..5896a2b 100644 --- a/lib/screens/settings/logs_settings/config_widgets.dart +++ b/lib/screens/settings/logs_settings/config_widgets.dart @@ -4,6 +4,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:adguard_home_manager/screens/settings/logs_settings/logs_settings.dart'; import 'package:adguard_home_manager/widgets/section_label.dart'; +import 'package:adguard_home_manager/widgets/master_switch.dart'; import 'package:adguard_home_manager/widgets/custom_checkbox_list_tile.dart'; class LogsConfigOptions extends StatelessWidget { @@ -70,48 +71,21 @@ class LogsConfigOptions extends StatelessWidget { return ListView( children: [ const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Material( - color: Theme.of(context).colorScheme.primary.withOpacity(0.1), - borderRadius: BorderRadius.circular(28), - child: InkWell( - onTap: () => updateGeneralSwitch(!generalSwitch), - borderRadius: BorderRadius.circular(28), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 8 - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - AppLocalizations.of(context)!.enableLog, - style: const TextStyle( - fontSize: 18, - ), - ), - Switch( - value: generalSwitch, - onChanged: updateGeneralSwitch, - ) - ], - ), - ), - ), - ), + MasterSwitch( + label: AppLocalizations.of(context)!.enableLog, + value: generalSwitch, + onChange: updateGeneralSwitch ), const SizedBox(height: 16), CustomCheckboxListTile( value: anonymizeClientIp, onChanged: (_) => updateAnonymizeClientIp(!anonymizeClientIp), title: AppLocalizations.of(context)!.anonymizeClientIp, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), ), const SizedBox(height: 16), Padding( - padding: const EdgeInsets.symmetric(horizontal: 22), + padding: const EdgeInsets.symmetric(horizontal: 16), child: DropdownButtonFormField( items: retentionItems.asMap().entries.map((item) => DropdownMenuItem( value: item.value, @@ -137,10 +111,10 @@ class LogsConfigOptions extends StatelessWidget { children: [ SectionLabel( label: AppLocalizations.of(context)!.ignoredDomains, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 0), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0), ), Padding( - padding: const EdgeInsets.only(right: 10), + padding: const EdgeInsets.only(right: 6), child: IconButton( onPressed: () => updateIgnoredDomainsControllers([ ...ignoredDomainsControllers, @@ -158,7 +132,7 @@ class LogsConfigOptions extends StatelessWidget { ), if (ignoredDomainsControllers.isNotEmpty) ...ignoredDomainsControllers.map((controller) => Padding( padding: const EdgeInsets.only( - top: 12, bottom: 12, left: 24, right: 10 + top: 12, bottom: 12, left: 16, right: 6 ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -210,69 +184,4 @@ class LogsConfigOptions extends StatelessWidget { ], ); } -} - -class ConfigLogsLoading extends StatelessWidget { - const ConfigLogsLoading({super.key}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(24), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 30), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text( - AppLocalizations.of(context)!.loadingLogsSettings, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 22, - color: Theme.of(context).colorScheme.onSurfaceVariant - ), - ), - ) - ], - ), - ), - ); - } -} - -class ConfigLogsError extends StatelessWidget { - const ConfigLogsError({super.key}); - - @override - Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon( - Icons.error, - color: Colors.red, - size: 50, - ), - const SizedBox(height: 30), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text( - AppLocalizations.of(context)!.logSettingsNotLoaded, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 22, - color: Theme.of(context).colorScheme.onSurfaceVariant - ), - ), - ) - ], - ), - ); - } } \ No newline at end of file diff --git a/lib/screens/settings/logs_settings/logs_settings.dart b/lib/screens/settings/logs_settings/logs_settings.dart index 8c651e0..cd723c7 100644 --- a/lib/screens/settings/logs_settings/logs_settings.dart +++ b/lib/screens/settings/logs_settings/logs_settings.dart @@ -4,6 +4,7 @@ import 'package:uuid/uuid.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:adguard_home_manager/screens/settings/logs_settings/config_widgets.dart'; +import 'package:adguard_home_manager/widgets/load_status_widgets.dart'; import 'package:adguard_home_manager/models/querylog_config.dart'; import 'package:adguard_home_manager/classes/process_modal.dart'; @@ -182,7 +183,7 @@ class _LogsSettingsState extends State { builder: (context) { switch (loadStatus) { case LoadStatus.loading: - return const ConfigLogsLoading(); + return LoadingData(text: AppLocalizations.of(context)!.loadingLogsSettings); case LoadStatus.loaded: return LogsConfigOptions( @@ -200,7 +201,7 @@ class _LogsSettingsState extends State { ); case LoadStatus.error: - return const ConfigLogsError(); + return ErrorLoadData(text: AppLocalizations.of(context)!.logSettingsNotLoaded,); default: return const SizedBox(); diff --git a/lib/screens/settings/settings.dart b/lib/screens/settings/settings.dart index 0e5256d..c9b7151 100644 --- a/lib/screens/settings/settings.dart +++ b/lib/screens/settings/settings.dart @@ -12,6 +12,7 @@ import 'package:adguard_home_manager/screens/settings/logs_settings/logs_setting import 'package:adguard_home_manager/screens/settings/access_settings/access_settings.dart'; import 'package:adguard_home_manager/screens/settings/customization/customization.dart'; import 'package:adguard_home_manager/screens/settings/dhcp/dhcp.dart'; +import 'package:adguard_home_manager/screens/settings/statistics_settings/statistics_settings.dart'; import 'package:adguard_home_manager/screens/settings/safe_search_settings.dart'; import 'package:adguard_home_manager/screens/settings/update_server/update.dart'; import 'package:adguard_home_manager/screens/settings/dns/dns.dart'; @@ -148,11 +149,19 @@ class _SettingsWidgetState extends State<_SettingsWidget> { screenToNavigate: const LogsSettings(), twoColumns: widget.twoColumns, ), + _SettingsTile( + icon: Icons.analytics_rounded, + title: AppLocalizations.of(context)!.statisticsSettings, + subtitle: AppLocalizations.of(context)!.statisticsSettingsDescription, + thisItem: 2, + screenToNavigate: const StatisticsSettings(), + twoColumns: widget.twoColumns, + ), _SettingsTile( icon: Icons.lock_rounded, title: AppLocalizations.of(context)!.accessSettings, subtitle: AppLocalizations.of(context)!.accessSettingsDescription, - thisItem: 2, + thisItem: 3, screenToNavigate: const AccessSettings(), twoColumns: widget.twoColumns, ), @@ -160,7 +169,7 @@ class _SettingsWidgetState extends State<_SettingsWidget> { icon: Icons.install_desktop_rounded, title: AppLocalizations.of(context)!.dhcpSettings, subtitle: AppLocalizations.of(context)!.dhcpSettingsDescription, - thisItem: 3, + thisItem: 4, screenToNavigate: const DhcpScreen(), twoColumns: widget.twoColumns, ), @@ -168,7 +177,7 @@ class _SettingsWidgetState extends State<_SettingsWidget> { icon: Icons.dns_rounded, title: AppLocalizations.of(context)!.dnsSettings, subtitle: AppLocalizations.of(context)!.dnsSettingsDescription, - thisItem: 4, + thisItem: 5, screenToNavigate: DnsSettings( splitView: widget.twoColumns, ), @@ -178,7 +187,7 @@ class _SettingsWidgetState extends State<_SettingsWidget> { icon: Icons.security_rounded, title: AppLocalizations.of(context)!.encryptionSettings, subtitle: AppLocalizations.of(context)!.encryptionSettingsDescription, - thisItem: 5, + thisItem: 6, screenToNavigate: const EncryptionSettings(), twoColumns: widget.twoColumns, ), @@ -186,7 +195,7 @@ class _SettingsWidgetState extends State<_SettingsWidget> { icon: Icons.route_rounded, title: AppLocalizations.of(context)!.dnsRewrites, subtitle: AppLocalizations.of(context)!.dnsRewritesDescription, - thisItem: 6, + thisItem: 7, screenToNavigate: const DnsRewritesScreen(), twoColumns: widget.twoColumns, ), @@ -206,7 +215,7 @@ class _SettingsWidgetState extends State<_SettingsWidget> { ), ) : null, - thisItem: 7, + thisItem: 8, screenToNavigate: const UpdateScreen(), twoColumns: widget.twoColumns, ), @@ -214,7 +223,7 @@ class _SettingsWidgetState extends State<_SettingsWidget> { icon: Icons.info_rounded, title: AppLocalizations.of(context)!.serverInformation, subtitle: AppLocalizations.of(context)!.serverInformationDescription, - thisItem: 8, + thisItem: 9, screenToNavigate: const ServerInformation(), twoColumns: widget.twoColumns, ), @@ -224,7 +233,7 @@ class _SettingsWidgetState extends State<_SettingsWidget> { icon: Icons.palette_rounded, title: AppLocalizations.of(context)!.customization, subtitle: AppLocalizations.of(context)!.customizationDescription, - thisItem: 9, + thisItem: 10, screenToNavigate: const Customization(), twoColumns: widget.twoColumns, ), @@ -236,7 +245,7 @@ class _SettingsWidgetState extends State<_SettingsWidget> { ? "${AppLocalizations.of(context)!.connectedTo} ${serversProvider.selectedServer!.name}" : "${AppLocalizations.of(context)!.selectedServer} ${serversProvider.selectedServer!.name}" : AppLocalizations.of(context)!.noServerSelected, - thisItem: 10, + thisItem: 11, screenToNavigate: const Servers(), twoColumns: widget.twoColumns, ), @@ -244,7 +253,7 @@ class _SettingsWidgetState extends State<_SettingsWidget> { icon: Icons.settings, title: AppLocalizations.of(context)!.generalSettings, subtitle: AppLocalizations.of(context)!.generalSettingsDescription, - thisItem: 11, + thisItem: 12, screenToNavigate: GeneralSettings(splitView: widget.twoColumns), twoColumns: widget.twoColumns, ), @@ -252,7 +261,7 @@ class _SettingsWidgetState extends State<_SettingsWidget> { icon: Icons.build_outlined, title: AppLocalizations.of(context)!.advancedSettings, subtitle: AppLocalizations.of(context)!.advancedSetupDescription, - thisItem: 12, + thisItem: 13, screenToNavigate: const AdvancedSettings(), twoColumns: widget.twoColumns, ), diff --git a/lib/screens/settings/statistics_settings/statistics_settings.dart b/lib/screens/settings/statistics_settings/statistics_settings.dart new file mode 100644 index 0000000..f514e78 --- /dev/null +++ b/lib/screens/settings/statistics_settings/statistics_settings.dart @@ -0,0 +1,338 @@ +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import 'package:adguard_home_manager/screens/settings/logs_settings/logs_settings.dart'; +import 'package:adguard_home_manager/widgets/load_status_widgets.dart'; +import 'package:adguard_home_manager/widgets/section_label.dart'; +import 'package:adguard_home_manager/widgets/master_switch.dart'; + +import 'package:adguard_home_manager/classes/process_modal.dart'; +import 'package:adguard_home_manager/functions/snackbar.dart'; +import 'package:adguard_home_manager/providers/app_config_provider.dart'; +import 'package:adguard_home_manager/providers/servers_provider.dart'; +import 'package:adguard_home_manager/models/statistics_config.dart'; +import 'package:adguard_home_manager/constants/enums.dart'; + +class StatisticsSettings extends StatefulWidget { + const StatisticsSettings({super.key}); + + @override + State createState() => _StatisticsSettingsState(); +} + +class _StatisticsSettingsState extends State { + final Uuid uuid = const Uuid(); + LoadStatus _loadStatus = LoadStatus.loading; + bool _generalSwitch = false; + final List _retentionItems = [ + "custom", + "86400000", + "604800000", + "2592000000", + "7776000000" + ]; + final _customTimeController = TextEditingController(); + String? _customTimeError; + String? _retentionTime; + List _ignoredDomainsControllers = []; + + void loadData() async { + final serversProvider = Provider.of(context, listen: false); + + final result = await serversProvider.apiClient2!.getStatisticsConfig(); + + if (!mounted) return; + if (result.successful == true) { + final data = result.content as StatisticsConfig; + setState(() { + _generalSwitch = data.enabled ?? false; + if (_retentionItems.contains(data.interval.toString())) { + _retentionTime = data.interval.toString(); + } + else if (data.interval != null) { + _retentionTime = "custom"; + _customTimeController.text = Duration(milliseconds: data.interval!).inHours.toString(); + } + if (data.ignored != null) { + _ignoredDomainsControllers = data.ignored!.map((e) => DomainListItemController( + id: uuid.v4(), + controller: TextEditingController(text: e), + error: false + )).toList(); + } + _loadStatus = LoadStatus.loaded; + }); + } + else { + setState(() => _loadStatus = LoadStatus.error); + } + } + + @override + void initState() { + loadData(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + final serversProvider = Provider.of(context); + final appConfigProvider = Provider.of(context); + + final List dropdownItemTranslation = [ + AppLocalizations.of(context)!.custom, + AppLocalizations.of(context)!.hours24, + AppLocalizations.of(context)!.days7, + AppLocalizations.of(context)!.days30, + AppLocalizations.of(context)!.days90, + ]; + + + void validateDomain(String value, String id) { + final domainRegex = RegExp(r'^([a-z0-9|-]+\.)*[a-z0-9|-]+\.[a-z]+$'); + bool error = false; + if (domainRegex.hasMatch(value)) { + error = false; + } + else { + error = true; + } + setState(() { + _ignoredDomainsControllers = _ignoredDomainsControllers.map((entry) { + if (entry.id != id) return entry; + return DomainListItemController( + id: id, + controller: entry.controller, + error: error + ); + }).toList(); + }); + } + + void validateCustomTime(String v) { + try { + final regex = RegExp(r'^\d+$'); + final parsed = int.parse(v); + if (!regex.hasMatch(v)) { + setState(() => _customTimeError = AppLocalizations.of(context)!.invalidTime); + } + else if (parsed < 1) { + setState(() => _customTimeError = AppLocalizations.of(context)!.notLess1Hour); + } + else { + setState(() => _customTimeError = null); + } + } catch (_) { + setState(() => _customTimeError = AppLocalizations.of(context)!.invalidTime); + } + } + + void updateConfig() async { + ProcessModal processModal = ProcessModal(); + processModal.open(AppLocalizations.of(context)!.updatingSettings); + + final result = await serversProvider.apiClient2!.updateStatisticsSettings( + body: { + "enabled": _generalSwitch, + "interval": _retentionTime == "custom" + ? Duration(hours: int.parse(_customTimeController.text)).inMilliseconds + : int.parse(_retentionTime!), + "ignored": _ignoredDomainsControllers.map((e) => e.controller.text).toList() + } + ); + + processModal.close(); + + if (!mounted) return; + + if (result.successful == true) { + showSnacbkar( + appConfigProvider: appConfigProvider, + label: AppLocalizations.of(context)!.logsConfigUpdated, + color: Colors.green + ); + } + else { + showSnacbkar( + appConfigProvider: appConfigProvider, + label: AppLocalizations.of(context)!.logsConfigNotUpdated, + color: Colors.red + ); + } + } + + final validValues = _ignoredDomainsControllers.where( + (d) => d.controller.text == "" || d.error == true + ).isEmpty && + (_retentionTime != "custom" || + (_retentionTime == "custom" && _customTimeController.text != "" && _customTimeError == null)); + + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.statisticsSettings), + actions: [ + IconButton( + onPressed: validValues ? () => updateConfig() : null, + icon: const Icon(Icons.save_rounded), + tooltip: AppLocalizations.of(context)!.save, + ), + const SizedBox(width: 8) + ], + ), + body: Builder( + builder: (context) { + switch (_loadStatus) { + case LoadStatus.loading: + return LoadingData(text: AppLocalizations.of(context)!.loadingStatisticsSettings); + + case LoadStatus.loaded: + return ListView( + children: [ + const SizedBox(height: 8), + MasterSwitch( + label: AppLocalizations.of(context)!.enableLog, + value: _generalSwitch, + onChange: (v) => setState(() => _generalSwitch = v) + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: DropdownButtonFormField( + items: _retentionItems.asMap().entries.map((item) => DropdownMenuItem( + value: item.value, + child: Text(dropdownItemTranslation[item.key]), + )).toList(), + value: _retentionTime, + onChanged: (value) => setState(() { + if (value != null && value != "custom") { + _customTimeError = null; + _customTimeController.text = ""; + }; + _retentionTime = value; + }), + decoration: InputDecoration( + border: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(10) + ) + ), + label: Text(AppLocalizations.of(context)!.retentionTime) + ), + borderRadius: BorderRadius.circular(20), + ), + ), + if (_retentionTime == "custom") Padding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 24 + ), + child: TextFormField( + controller: _customTimeController, + onChanged: validateCustomTime, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.schedule_rounded), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(10) + ) + ), + labelText: AppLocalizations.of(context)!.customTimeInHours, + errorText: _customTimeError + ), + keyboardType: TextInputType.number, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SectionLabel( + label: AppLocalizations.of(context)!.ignoredDomains, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0), + ), + Padding( + padding: const EdgeInsets.only(right: 6), + child: IconButton( + onPressed: () => setState(() => _ignoredDomainsControllers = [ + ..._ignoredDomainsControllers, + DomainListItemController( + id: uuid.v4(), + controller: TextEditingController(), + error: false + ), + ]), + icon: const Icon(Icons.add), + tooltip: AppLocalizations.of(context)!.addDomain, + ), + ) + ], + ), + ), + if (_ignoredDomainsControllers.isNotEmpty) ..._ignoredDomainsControllers.map((controller) => Padding( + padding: const EdgeInsets.only( + top: 12, bottom: 12, left: 16, right: 6 + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: TextFormField( + controller: controller.controller, + onChanged: (v) => validateDomain(v, controller.id), + decoration: InputDecoration( + prefixIcon: const Icon(Icons.link_rounded), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(10) + ) + ), + labelText: AppLocalizations.of(context)!.domain, + errorText: controller.error + ? AppLocalizations.of(context)!.invalidDomain + : null + ), + ), + ), + const SizedBox(width: 12), + Padding( + padding: controller.error + ? const EdgeInsets.only(bottom: 24) + : const EdgeInsets.all(0), + child: IconButton( + onPressed: () => setState(() => _ignoredDomainsControllers = _ignoredDomainsControllers.where((e) => e.id != controller.id).toList()), + icon: const Icon(Icons.remove_circle_outline_outlined), + tooltip: AppLocalizations.of(context)!.removeDomain, + ), + ) + ], + ), + )), + if (_ignoredDomainsControllers.isEmpty) Container( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + AppLocalizations.of(context)!.noIgnoredDomainsAdded, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ); + + case LoadStatus.error: + return ErrorLoadData(text: AppLocalizations.of(context)!.statisticsSettingsLoadError); + + default: + return const SizedBox(); + } + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart index 0abb737..4f8ba90 100644 --- a/lib/services/api_client.dart +++ b/lib/services/api_client.dart @@ -4,6 +4,7 @@ import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:adguard_home_manager/models/blocked_services.dart'; import 'package:adguard_home_manager/models/querylog_config.dart'; +import 'package:adguard_home_manager/models/statistics_config.dart'; import 'package:adguard_home_manager/models/dns_info.dart'; import 'package:adguard_home_manager/models/encryption.dart'; import 'package:adguard_home_manager/models/dhcp.dart'; @@ -881,4 +882,38 @@ class ApiClientV2 { content: result.body != null ? jsonDecode(result.body!) : null ); } + + Future getStatisticsConfig() async { + final result = await HttpRequestClient.get(urlPath: '/stats/config', server: server); + if (result.successful) { + try { + return ApiResponse( + successful: true, + content: StatisticsConfig.fromJson(jsonDecode(result.body!)) + ); + } catch (e, stackTrace) { + Sentry.captureException( + e, + stackTrace: stackTrace, + hint: Hint.withMap({ "statusCode": result.statusCode.toString() }) + ); + return const ApiResponse(successful: false); + } + } + else { + return const ApiResponse(successful: false); + } + } + + Future updateStatisticsSettings({ + required Map body + }) async { + final result = await HttpRequestClient.put( + urlPath: '/stats/config/update', + server: server, + body: body + ); + print(result.body); + return ApiResponse(successful: result.successful); + } } \ No newline at end of file diff --git a/lib/widgets/custom_settings_tile.dart b/lib/widgets/custom_settings_tile.dart index 554f13b..824b710 100644 --- a/lib/widgets/custom_settings_tile.dart +++ b/lib/widgets/custom_settings_tile.dart @@ -12,7 +12,7 @@ class CustomSettingsTile extends StatelessWidget { final int? selectedItem; const CustomSettingsTile({ - Key? key, + super.key, required this.title, this.subtitle, this.subtitleWidget, @@ -22,7 +22,7 @@ class CustomSettingsTile extends StatelessWidget { this.padding, required this.thisItem, required this.selectedItem, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/widgets/load_status_widgets.dart b/lib/widgets/load_status_widgets.dart new file mode 100644 index 0000000..2b1e699 --- /dev/null +++ b/lib/widgets/load_status_widgets.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +class LoadingData extends StatelessWidget { + final String text; + + const LoadingData({ + super.key, + required this.text, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 30), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + text, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 22, + color: Theme.of(context).colorScheme.onSurfaceVariant + ), + ), + ) + ], + ), + ), + ); + } +} + +class ErrorLoadData extends StatelessWidget { + final String text; + + const ErrorLoadData({ + super.key, + required this.text + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.error, + color: Colors.red, + size: 50, + ), + const SizedBox(height: 30), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + text, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 22, + color: Theme.of(context).colorScheme.onSurfaceVariant + ), + ), + ) + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/master_switch.dart b/lib/widgets/master_switch.dart new file mode 100644 index 0000000..323de68 --- /dev/null +++ b/lib/widgets/master_switch.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +class MasterSwitch extends StatelessWidget { + final String label; + final bool value; + final void Function(bool) onChange; + final EdgeInsets? margin; + + const MasterSwitch({ + super.key, + required this.label, + required this.value, + required this.onChange, + this.margin + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: margin ?? const EdgeInsets.symmetric(horizontal: 16), + child: Material( + color: Theme.of(context).colorScheme.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(28), + child: InkWell( + onTap: () => onChange(!value), + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8 + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 18, + ), + ), + Switch( + value: value, + onChanged: onChange, + ) + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file