From 161628a72a8aaa2b12cdf051417de941ea8d5567 Mon Sep 17 00:00:00 2001 From: Juan Gilsanz Polo Date: Sat, 21 Oct 2023 21:06:38 +0200 Subject: [PATCH] Added ring chart home top items --- lib/l10n/app_en.arb | 11 +- lib/l10n/app_es.arb | 11 +- lib/screens/home/home.dart | 229 ++++++++------- lib/screens/home/top_items.dart | 451 ++++++++++++++++++++++-------- lib/widgets/custom_pie_chart.dart | 32 +++ pubspec.lock | 8 + pubspec.yaml | 1 + 7 files changed, 512 insertions(+), 231 deletions(-) create mode 100644 lib/widgets/custom_pie_chart.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f54e491..b9c7b1e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -57,10 +57,10 @@ "serverStatusNotRefreshed": "Server status could not be refreshed", "loadingStatus": "Loading status...", "errorLoadServerStatus": "Server status could not be loaded", - "topQueriedDomains": "Top queried domains", + "topQueriedDomains": "Queried domains", "viewMore": "View more", - "topClients": "Top clients", - "topBlockedDomains": "Top blocked domains", + "topClients": "Cients", + "topBlockedDomains": "Blocked domains", "appSettings": "App settings", "theme": "Theme", "light": "Light", @@ -662,5 +662,8 @@ "topItemsOrderDescription": "Order the home screen top items lists", "topItemsReorderInfo": "Hold and swipe an item to reorder it.", "discardChanges": "Discard changes", - "discardChangesDescription": "Are you sure you want to discard the changes?" + "discardChangesDescription": "Are you sure you want to discard the changes?", + "others": "Others", + "showChart": "Show chart", + "hideChart": "Hide chart" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index d0c7a15..fe13bcd 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -57,10 +57,10 @@ "serverStatusNotRefreshed": "No se ha podido actualizar el estado del servidor", "loadingStatus": "Cargando estado...", "errorLoadServerStatus": "Error al cargar el estado", - "topQueriedDomains": "Top dominios solicitados", + "topQueriedDomains": "Dominios solicitados", "viewMore": "Ver más", - "topClients": "Top clientes recurrentes", - "topBlockedDomains": "Top dominios bloqueados", + "topClients": "Clientes recurrentes", + "topBlockedDomains": "Dominios bloqueados", "appSettings": "Ajustes de la app", "theme": "Tema", "light": "Claro", @@ -662,5 +662,8 @@ "topItemsOrderDescription": "Ordena las listas de top de elementos en la pantalla de inicio", "topItemsReorderInfo": "Mantén presionado y desliza un elemento para reordenarlo.", "discardChanges": "Descartar cambios", - "discardChangesDescription": "¿Estás seguro de que deseas descartar los cambios realizados?" + "discardChangesDescription": "¿Estás seguro de que deseas descartar los cambios realizados?", + "others": "Otros", + "showChart": "Mostrar gráfico", + "hideChart": "Ocultar gráfico" } \ No newline at end of file diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart index 01d251d..59fb0bf 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -175,110 +175,66 @@ class _HomeState extends State { child: CombinedHomeChart(), ), - if (width <= 700) ...appConfigProvider.homeTopItemsOrder.asMap().entries.map((item) { - Widget list() { - switch (item.value) { - case HomeTopItems.queriedDomains: - return TopItems( - label: AppLocalizations.of(context)!.topQueriedDomains, - data: statusProvider.serverStatus!.stats.topQueriedDomains, - type: 'topQueriedDomains', - ); - - case HomeTopItems.blockedDomains: - return TopItems( - label: AppLocalizations.of(context)!.topBlockedDomains, - data: statusProvider.serverStatus!.stats.topBlockedDomains, - type: 'topBlockedDomains', - ); - - case HomeTopItems.recurrentClients: - return TopItems( - label: AppLocalizations.of(context)!.topClients, - data: statusProvider.serverStatus!.stats.topClients, - type: 'topClients', - clients: true, - ); - - default: - return const SizedBox(); - } - } - - return Column( - children: [ - list(), - if (item.key < appConfigProvider.homeTopItemsOrder.length - 1) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Divider( - thickness: 1, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.2), - ), - ), - const SizedBox(height: 16), - ] - ], - ); - }), - if (width > 700) Column( - children: [ - Wrap( - alignment: WrapAlignment.center, - children: appConfigProvider.homeTopItemsOrder.map((item) { - switch (item) { - case HomeTopItems.queriedDomains: - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 500 - ), - child: TopItems( - label: AppLocalizations.of(context)!.topQueriedDomains, - data: statusProvider.serverStatus!.stats.topQueriedDomains, - type: 'topQueriedDomains', - ), - ), - ); + TopItemsLists(order: appConfigProvider.homeTopItemsOrder), - case HomeTopItems.blockedDomains: - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 500 - ), - child: TopItems( - label: AppLocalizations.of(context)!.topBlockedDomains, - data: statusProvider.serverStatus!.stats.topBlockedDomains, - type: 'topBlockedDomains', - ), - ), - ); + // if (width > 700) Column( + // children: [ + // Wrap( + // alignment: WrapAlignment.center, + // children: appConfigProvider.homeTopItemsOrder.map((item) { + // switch (item) { + // case HomeTopItems.queriedDomains: + // return Padding( + // padding: const EdgeInsets.only(bottom: 16), + // child: ConstrainedBox( + // constraints: const BoxConstraints( + // maxWidth: 500 + // ), + // child: TopItems( + // label: AppLocalizations.of(context)!.topQueriedDomains, + // data: statusProvider.serverStatus!.stats.topQueriedDomains, + // type: 'topQueriedDomains', + // ), + // ), + // ); - case HomeTopItems.recurrentClients: - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 500 - ), - child: TopItems( - label: AppLocalizations.of(context)!.topClients, - data: statusProvider.serverStatus!.stats.topClients, - type: 'topClients', - ), - ), - ); + // case HomeTopItems.blockedDomains: + // return Padding( + // padding: const EdgeInsets.only(bottom: 16), + // child: ConstrainedBox( + // constraints: const BoxConstraints( + // maxWidth: 500 + // ), + // child: TopItems( + // label: AppLocalizations.of(context)!.topBlockedDomains, + // data: statusProvider.serverStatus!.stats.topBlockedDomains, + // type: 'topBlockedDomains', + // ), + // ), + // ); + + // case HomeTopItems.recurrentClients: + // return Padding( + // padding: const EdgeInsets.only(bottom: 16), + // child: ConstrainedBox( + // constraints: const BoxConstraints( + // maxWidth: 500 + // ), + // child: TopItems( + // label: AppLocalizations.of(context)!.topClients, + // data: statusProvider.serverStatus!.stats.topClients, + // type: 'topClients', + // ), + // ), + // ); - default: - return const SizedBox(); - } - }).toList(), - ), - ], - ) + // default: + // return const SizedBox(); + // } + // }).toList(), + // ), + // ], + // ) ]; } @@ -348,4 +304,75 @@ class _HomeState extends State { ), ); } +} + +class TopItemsLists extends StatelessWidget { + final List order; + + const TopItemsLists({ + Key? key, + required this.order, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final statusProvider = Provider.of(context); + + List bottom = [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Divider( + thickness: 1, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.2), + ), + ), + const SizedBox(height: 16), + ]; + + return Column( + children: order.asMap().entries.map((item) { + switch (item.value) { + case HomeTopItems.queriedDomains: + return Column( + children: [ + TopItems( + label: AppLocalizations.of(context)!.topQueriedDomains, + data: statusProvider.serverStatus!.stats.topQueriedDomains, + type: 'topQueriedDomains', + ), + if (item.key < order.length - 1) ...bottom + ], + ); + + case HomeTopItems.blockedDomains: + return Column( + children: [ + TopItems( + label: AppLocalizations.of(context)!.topBlockedDomains, + data: statusProvider.serverStatus!.stats.topBlockedDomains, + type: 'topBlockedDomains', + ), + if (item.key < order.length - 1) ...bottom + ], + ); + + case HomeTopItems.recurrentClients: + return Column( + children: [ + TopItems( + label: AppLocalizations.of(context)!.topClients, + data: statusProvider.serverStatus!.stats.topClients, + type: 'topClients', + clients: true, + ), + if (item.key < order.length - 1) ...bottom + ], + ); + + default: + return const SizedBox(); + } + }).toList(), + ); + } } \ No newline at end of file diff --git a/lib/screens/home/top_items.dart b/lib/screens/home/top_items.dart index c43ba3a..9cacd68 100644 --- a/lib/screens/home/top_items.dart +++ b/lib/screens/home/top_items.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:adguard_home_manager/widgets/custom_pie_chart.dart'; import 'package:adguard_home_manager/widgets/domain_options.dart'; import 'package:adguard_home_manager/screens/top_items/top_items_modal.dart'; import 'package:adguard_home_manager/screens/top_items/top_items.dart'; @@ -15,7 +16,7 @@ import 'package:adguard_home_manager/providers/status_provider.dart'; import 'package:adguard_home_manager/providers/logs_provider.dart'; import 'package:adguard_home_manager/providers/app_config_provider.dart'; -class TopItems extends StatelessWidget { +class TopItems extends StatefulWidget { final String type; final String label; final List> data; @@ -29,104 +30,30 @@ class TopItems extends StatelessWidget { this.clients }) : super(key: key); + @override + State createState() => _TopItemsState(); +} + +class _TopItemsState extends State { + bool _showChart = true; + + final colors = [ + Colors.red, + Colors.green, + Colors.blue, + Colors.orange, + Colors.teal, + Colors.grey + ]; + @override Widget build(BuildContext context) { final statusProvider = Provider.of(context); - final appConfigProvider = Provider.of(context); - final logsProvider = Provider.of(context); final width = MediaQuery.of(context).size.width; - Widget rowItem(Map item) { - String? name; - if (clients != null && clients == true) { - try { - name = statusProvider.serverStatus!.clients.firstWhere((c) => c.ids.contains(item.keys.toList()[0])).name; - } catch (e) { - // ---- // - } - } - - return Material( - color: Colors.transparent, - child: DomainOptions( - item: item.keys.toList()[0], - isClient: type == 'topClients', - isBlocked: type == 'topBlockedDomains', - onTap: () { - if (type == 'topQueriedDomains' || type == 'topBlockedDomains') { - logsProvider.setSearchText(item.keys.toList()[0]); - logsProvider.setSelectedClients(null); - logsProvider.setAppliedFilters( - AppliedFiters( - selectedResultStatus: 'all', - searchText: item.keys.toList()[0], - clients: null - ) - ); - appConfigProvider.setSelectedScreen(2); - } - else if (type == 'topClients') { - logsProvider.setSearchText(null); - logsProvider.setSelectedClients([item.keys.toList()[0]]); - logsProvider.setAppliedFilters( - AppliedFiters( - selectedResultStatus: 'all', - searchText: null, - clients: [item.keys.toList()[0]] - ) - ); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 8 - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.keys.toList()[0], - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurface - ), - ), - if (name != null) ...[ - const SizedBox(height: 5), - Text( - name, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 14, - color: Theme.of(context).colorScheme.onSurfaceVariant - ), - ), - ] - ], - ), - ), - Text( - item.values.toList()[0].toString(), - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface - ), - ) - ], - ), - ), - ), - ); - } - List> generateData() { - switch (type) { + switch (widget.type) { case 'topQueriedDomains': return statusProvider.serverStatus!.stats.topQueriedDomains; @@ -141,37 +68,123 @@ class TopItems extends StatelessWidget { } } + Map chartData() { + Map values = {}; + widget.data.sublist(0, widget.data.length > 5 ? 5 : widget.data.length).forEach((element) { + values = { + ...values, + element.keys.first: element.values.first.toDouble() + }; + }); + if (widget.data.length > 5) { + final int rest = List.from( + widget.data.sublist(5, widget.data.length).map((e) => e.values.first.toInt()) + ).reduce((a, b) => a + b); + values = { + ...values, + AppLocalizations.of(context)!.others: rest.toDouble() + }; + } + return values; + } + + final List itemsList = widget.data.sublist( + 0, + widget.data.length > 5 ? 5 : widget.data.length + ).asMap().entries.map((e) => RowItem( + clients: widget.clients ?? false, + domain: e.value.keys.toList()[0], + number: e.value.values.toList()[0].toString(), + type: widget.type, + chartColor: _showChart ? colors[e.key] : null, + )).toList(); + + final Widget noItems = Padding( + padding: const EdgeInsets.only( + bottom: 20, + top: 10 + ), + child: Text( + AppLocalizations.of(context)!.noItems, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + + final Widget chart = CustomPieChart( + data: chartData(), + colors: colors + ); + return SizedBox( child: Column( children: [ - Text( - label, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurface + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: width <= 700 + ? MainAxisAlignment.spaceBetween + : MainAxisAlignment.center, + children: [ + Text( + widget.label, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface + ), + ), + if (width <= 700) TextButton( + onPressed: () => setState(() => _showChart = !_showChart), + child: Text( + _showChart + ? AppLocalizations.of(context)!.hideChart + : AppLocalizations.of(context)!.showChart + ) + ) + ], ), ), - const SizedBox(height: 20), - if (data.isEmpty) Padding( - padding: const EdgeInsets.only( - bottom: 20, - top: 10 - ), - child: Text( - AppLocalizations.of(context)!.noItems, - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + + const SizedBox(height: 24), + + if (widget.data.isEmpty) noItems, + if (widget.data.isNotEmpty && width > 700) SizedBox( + height: 240, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + flex: 1, + child: Padding( + padding: const EdgeInsets.all(16), + child: chart, + ) + ), + Expanded( + flex: 2, + child: Column( + children: [ + ...itemsList, + OthersRowItem(items: widget.data) + ] + ), + ) + ], ), ), - if (data.isNotEmpty) rowItem(data[0]), - if (data.length >= 2) rowItem(data[1]), - if (data.length >= 3) rowItem(data[2]), - if (data.length >= 4) rowItem(data[3]), - if (data.length >= 5) rowItem(data[4]), - if (data.length > 5) ...[ + if (widget.data.isNotEmpty && width <= 700) ...[ + if (_showChart) ...[ + chart, + const SizedBox(height: 16), + ], + ...itemsList, + if (_showChart) OthersRowItem(items: widget.data) + ], + + if (widget.data.length > 5) ...[ const SizedBox(height: 20), Padding( padding: const EdgeInsets.only(right: 20), @@ -185,9 +198,9 @@ class TopItems extends StatelessWidget { context: context, barrierDismissible: false, builder: (context) => TopItemsModal( - type: type, - title: label, - isClient: clients, + type: widget.type, + title: widget.label, + isClient: widget.clients, data: generateData(), ) ) @@ -196,9 +209,9 @@ class TopItems extends StatelessWidget { Navigator.of(context).push( MaterialPageRoute( builder: (context) => TopItemsScreen( - type: type, - title: label, - isClient: clients, + type: widget.type, + title: widget.label, + isClient: widget.clients, data: generateData(), ) ) @@ -226,4 +239,198 @@ class TopItems extends StatelessWidget { ), ); } +} + +class RowItem extends StatelessWidget { + final String type; + final Color? chartColor; + final String domain; + final String number; + final bool clients; + + const RowItem({ + Key? key, + required this.type, + this.chartColor, + required this.domain, + required this.number, + required this.clients + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final statusProvider = Provider.of(context); + final appConfigProvider = Provider.of(context); + final logsProvider = Provider.of(context); + + String? name; + if (clients == true) { + try { + name = statusProvider.serverStatus!.clients.firstWhere((c) => c.ids.contains(domain)).name; + } catch (e) { + // ---- // + } + } + + return Material( + color: Colors.transparent, + child: DomainOptions( + item: domain, + isClient: type == 'topClients', + isBlocked: type == 'topBlockedDomains', + onTap: () { + if (type == 'topQueriedDomains' || type == 'topBlockedDomains') { + logsProvider.setSearchText(domain); + logsProvider.setSelectedClients(null); + logsProvider.setAppliedFilters( + AppliedFiters( + selectedResultStatus: 'all', + searchText: domain, + clients: null + ) + ); + appConfigProvider.setSelectedScreen(2); + } + else if (type == 'topClients') { + logsProvider.setSearchText(null); + logsProvider.setSelectedClients([domain]); + logsProvider.setAppliedFilters( + AppliedFiters( + selectedResultStatus: 'all', + searchText: null, + clients: [domain] + ) + ); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8 + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Row( + children: [ + if (chartColor != null) Container( + margin: const EdgeInsets.only(right: 16), + width: 12, + height: 12, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + color: chartColor + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + domain, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onSurface + ), + ), + if (name != null) ...[ + const SizedBox(height: 5), + Text( + name, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant + ), + ), + ] + ], + ), + ), + ], + ), + ), + const SizedBox(width: 16), + Text( + number, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface + ), + ) + ], + ), + ), + ), + ); + } +} + +class OthersRowItem extends StatelessWidget { + final List> items; + + const OthersRowItem({ + Key? key, + required this.items + }) : super(key: key); + + @override + Widget build(BuildContext context) { + + if (items.length <= 5) { + return const SizedBox(); + } + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8 + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Row( + children: [ + Container( + margin: const EdgeInsets.only(right: 16), + width: 12, + height: 12, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + color: Colors.grey + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.others, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onSurface + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(width: 16), + Text( + List.from( + items.sublist(5, items.length).map((e) => e.values.first.toInt()) + ).reduce((a, b) => a + b).toString(), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface + ), + ) + ], + ), + ); + } } \ No newline at end of file diff --git a/lib/widgets/custom_pie_chart.dart b/lib/widgets/custom_pie_chart.dart new file mode 100644 index 0000000..2968568 --- /dev/null +++ b/lib/widgets/custom_pie_chart.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:pie_chart/pie_chart.dart'; + +class CustomPieChart extends StatelessWidget { + final Map data; + final List colors; + + const CustomPieChart({ + Key? key, + required this.data, + required this.colors, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return PieChart( + dataMap: data, + animationDuration: const Duration(milliseconds: 800), + chartRadius: MediaQuery.of(context).size.width / 3, + colorList: colors, + initialAngleInDegree: 270, + chartType: ChartType.ring, + ringStrokeWidth: 12, + legendOptions: const LegendOptions( + showLegends: false + ), + chartValuesOptions: const ChartValuesOptions( + showChartValues: false, + ), + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index d0842e2..c647bf5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -478,6 +478,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.4.0" + pie_chart: + dependency: "direct main" + description: + name: pie_chart + sha256: "5dba6d0eb4718e8ed00a9079361cd8947c3f84ac5a5d76f05a27f4ec5e27589e" + url: "https://pub.dev" + source: hosted + version: "5.3.2" plugin_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d18eb72..2445ec4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -73,6 +73,7 @@ dependencies: sentry_flutter: ^7.9.0 flutter_dotenv: ^5.0.2 flutter_reorderable_list: ^1.3.1 + pie_chart: ^5.3.2 dev_dependencies: flutter_test: