From d01df51296753d54dab99fe4cb17fddde4e9eb56 Mon Sep 17 00:00:00 2001 From: Inex Code Date: Tue, 6 Aug 2024 22:04:34 +0300 Subject: [PATCH] feat(accessibility): Add screen reader descriptions for graphs --- assets/translations/en.json | 8 + assets/translations/ru.json | 1 + lib/ui/components/cards/filled_card.dart | 3 + .../pre_styled_buttons/flash_fab.dart | 1 + lib/ui/pages/server_details/charts/chart.dart | 148 ++++---- .../server_details/charts/cpu_chart.dart | 259 +++++++------ .../server_details/charts/disk_charts.dart | 271 +++++++------- .../server_details/charts/memory_chart.dart | 259 +++++++------ .../server_details/charts/network_charts.dart | 341 ++++++++++-------- 9 files changed, 730 insertions(+), 561 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index 0c4e4cd6..19c21838 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -136,16 +136,24 @@ "day": "Day", "hour": "Hour", "cpu_title": "CPU Usage", + "cpu_chart_screen_reader_explanation": "This chart shows the CPU usage in last {period}. The last value is at {lastValue}%. The average usage is at {averageUsage}%. The maximal usage is at {maxUsage}% at {maxUsageTime}.", "disk_title": "Disk Usage", + "disk_chart_screen_reader_explanation": { + "beginning": "This chart shows the disk usage in last {period}. ", + "disk": "For {disk} the consumption went from {beginningValue}% to {endValue}%. " + }, "network_title": "Network Usage", + "network_chart_screen_reader_explanation": "This chart shows the network usage in last {period}. The last value is at {lastValueIn} inbound and {lastValueOut} outbound. The average usage is at {averageUsageIn} inbound and {averageUsageOut} outbound. The maximal inbound usage is at {maxUsageIn} at {maxUsageTimeIn} and maximal outbound {maxUsageOut} at {maxUsageTimeOut} out.", "in": "In", "out": "Out", "memory": "Memory usage", + "memory_chart_screen_reader_explanation": "This chart shows the memory usage in last {period}. The last value is at {lastValue}%. The average usage is at {averageUsage}%. The maximal usage is at {maxUsage}% at {maxUsageTime}.", "view_usage_by_service": "View usage by service", "unsupported": "You can't view resource usage charts without the server provider token.", "system": "System", "ssh_users": "SSH users", "ram_usage": "Average usage: {average}. Maximum: {max}.", + "loading": "The chart is loading…", "failed_to_load_memory_metrics": "Couldn't load memory usage data. This might be a connection issue, or there is not enough data yet." }, "server": { diff --git a/assets/translations/ru.json b/assets/translations/ru.json index ca085cb2..a4787529 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -136,6 +136,7 @@ "day": "День", "hour": "Час", "cpu_title": "Использование процессора", + "cpu_chart_screen_reader_explanation": "Этот график показывает использование процессора за последний {period}. Последнее значение {lastValue}%. Среднее использование {averageUsage}%. Максимальное использование было {maxUsage}% в {maxUsageTime}.", "network_title": "Использование сети", "in": "Получено", "out": "Отправлено", diff --git a/lib/ui/components/cards/filled_card.dart b/lib/ui/components/cards/filled_card.dart index 497f3c77..5122b95e 100644 --- a/lib/ui/components/cards/filled_card.dart +++ b/lib/ui/components/cards/filled_card.dart @@ -7,6 +7,7 @@ class FilledCard extends StatelessWidget { this.tertiary = false, this.error = false, this.clipped = true, + this.mergeSemantics = true, super.key, }); @@ -15,6 +16,7 @@ class FilledCard extends StatelessWidget { final bool error; final bool clipped; final bool secondary; + final bool mergeSemantics; @override Widget build(final BuildContext context) => Card( elevation: 0.0, @@ -29,6 +31,7 @@ class FilledCard extends StatelessWidget { : tertiary ? Theme.of(context).colorScheme.tertiaryContainer : Theme.of(context).colorScheme.surfaceVariant, + semanticContainer: mergeSemantics, child: child, ); } diff --git a/lib/ui/components/pre_styled_buttons/flash_fab.dart b/lib/ui/components/pre_styled_buttons/flash_fab.dart index caffbfac..5378838e 100644 --- a/lib/ui/components/pre_styled_buttons/flash_fab.dart +++ b/lib/ui/components/pre_styled_buttons/flash_fab.dart @@ -75,6 +75,7 @@ class _BrandFabState extends State ); }, isExtended: widget.extended, + tooltip: 'jobs.title'.tr(), child: Row( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/ui/pages/server_details/charts/chart.dart b/lib/ui/pages/server_details/charts/chart.dart index ca2e23d8..aae96dab 100644 --- a/lib/ui/pages/server_details/charts/chart.dart +++ b/lib/ui/pages/server_details/charts/chart.dart @@ -60,6 +60,7 @@ class _Chart extends StatelessWidget { if (!(state is MetricsLoaded && state.memoryMetrics == null)) FilledCard( clipped: false, + mergeSemantics: false, child: Padding( padding: const EdgeInsets.all(16.0), child: Column( @@ -110,42 +111,44 @@ class _Chart extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Text( - 'resource_chart.network_title'.tr(), - style: - Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), + ExcludeSemantics( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + 'resource_chart.network_title'.tr(), + style: + Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), ), - ), - Flexible( - fit: FlexFit.loose, - child: Wrap( - spacing: 8.0, - runSpacing: 8.0, - alignment: WrapAlignment.end, - runAlignment: WrapAlignment.end, - children: [ - Legend( - color: Theme.of(context).colorScheme.primary, - text: 'resource_chart.in'.tr(), - ), - Legend( - color: Theme.of(context).colorScheme.tertiary, - text: 'resource_chart.out'.tr(), - ), - ], + Flexible( + fit: FlexFit.loose, + child: Wrap( + spacing: 8.0, + runSpacing: 8.0, + alignment: WrapAlignment.end, + runAlignment: WrapAlignment.end, + children: [ + Legend( + color: Theme.of(context).colorScheme.primary, + text: 'resource_chart.in'.tr(), + ), + Legend( + color: Theme.of(context).colorScheme.tertiary, + text: 'resource_chart.out'.tr(), + ), + ], + ), ), - ), - ], + ], + ), ), const SizedBox(height: 20), Stack( @@ -205,42 +208,44 @@ class _Chart extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Text( - 'resource_chart.disk_title'.tr(), - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - ), - Flexible( - fit: FlexFit.loose, - child: Wrap( - spacing: 8.0, - runSpacing: 8.0, - alignment: WrapAlignment.end, - runAlignment: WrapAlignment.end, - children: disksGraphData - .map( - (final disk) => Legend( - color: disk.color, - text: disk.volume.displayName, + ExcludeSemantics( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + 'resource_chart.disk_title'.tr(), + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), - ) - .toList(), + ), ), - ), - ], + Flexible( + fit: FlexFit.loose, + child: Wrap( + spacing: 8.0, + runSpacing: 8.0, + alignment: WrapAlignment.end, + runAlignment: WrapAlignment.end, + children: disksGraphData + .map( + (final disk) => Legend( + color: disk.color, + text: disk.volume.displayName, + ), + ) + .toList(), + ), + ), + ], + ), ), const SizedBox(height: 20), Stack( @@ -390,9 +395,12 @@ class _GraphLoadingCardContent extends StatelessWidget { const _GraphLoadingCardContent(); @override - Widget build(final BuildContext context) => const SizedBox( + Widget build(final BuildContext context) => SizedBox( height: 200, - child: Center(child: CircularProgressIndicator.adaptive()), + child: Semantics( + label: 'resource_chart.loading'.tr(), + child: const Center(child: CircularProgressIndicator.adaptive()), + ), ); } diff --git a/lib/ui/pages/server_details/charts/cpu_chart.dart b/lib/ui/pages/server_details/charts/cpu_chart.dart index 7281f53c..0b9de6f0 100644 --- a/lib/ui/pages/server_details/charts/cpu_chart.dart +++ b/lib/ui/pages/server_details/charts/cpu_chart.dart @@ -1,6 +1,6 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:selfprivacy/logic/common_enum/common_enum.dart'; import 'package:selfprivacy/logic/models/metrics.dart'; import 'package:selfprivacy/ui/pages/server_details/charts/bottom_title.dart'; @@ -29,130 +29,163 @@ class CpuChart extends StatelessWidget { return res; } + String screenReaderDescription(final BuildContext context) { + final lastData = data.last; + final lastValue = lastData.value; + + final averageUsage = + data.map((final e) => e.value).reduce((final a, final b) => a + b) / + data.length; + final maxUsage = data + .map((final e) => e.value) + .reduce((final a, final b) => a > b ? a : b); + final maxUsageTime = data.firstWhere((final e) => e.value == maxUsage).time; + + final label = 'resource_chart.cpu_chart_screen_reader_explanation'.tr( + namedArgs: { + 'period': 'resource_chart.${period.name}'.tr(), + 'lastValue': lastValue.toStringAsFixed(1), + 'averageUsage': averageUsage.toStringAsFixed(1), + 'maxUsage': maxUsage.toStringAsFixed(1), + 'maxUsageTime': DateFormat('HH:mm dd MMMM', context.locale.languageCode) + .format(maxUsageTime), + }, + ); + + return label; + } + @override - Widget build(final BuildContext context) => LineChart( - LineChartData( - lineTouchData: LineTouchData( - enabled: true, - touchTooltipData: LineTouchTooltipData( - getTooltipColor: (final LineBarSpot _) => - Theme.of(context).colorScheme.surface, - tooltipPadding: const EdgeInsets.all(8), - getTooltipItems: (final List touchedBarSpots) { - final List res = []; + Widget build(final BuildContext context) => Semantics( + label: screenReaderDescription(context), + child: LineChart( + LineChartData( + lineTouchData: LineTouchData( + enabled: true, + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (final LineBarSpot _) => + Theme.of(context).colorScheme.surface, + tooltipPadding: const EdgeInsets.all(8), + getTooltipItems: (final List touchedBarSpots) { + final List res = []; - for (final spot in touchedBarSpots) { - final value = spot.y; - final date = data[spot.x.toInt()].time; + for (final spot in touchedBarSpots) { + final value = spot.y; + final date = data[spot.x.toInt()].time; - res.add( - LineTooltipItem( - '${value.toStringAsFixed(2)}% at ${DateFormat('HH:mm dd.MM.yyyy').format(date)}', - TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), - ), - ); - } - - return res; - }, - ), - ), - lineBarsData: [ - LineChartBarData( - spots: getSpots(), - isCurved: false, - barWidth: 2, - color: Theme.of(context).colorScheme.primary, - dotData: const FlDotData( - show: false, - ), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - colors: [ - Theme.of(context).colorScheme.primary.withOpacity(0.5), - Theme.of(context).colorScheme.primary.withOpacity(0.0), - ], - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - ), - ), - ), - ], - minY: 0, - // Maximal value of data by 100 step - maxY: 100, - minX: 0, - titlesData: FlTitlesData( - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - interval: 40, - reservedSize: 30, - getTitlesWidget: (final value, final titleMeta) => Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - bottomTitle( - value.toInt(), - data, - period, - ), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + res.add( + LineTooltipItem( + '${value.toStringAsFixed(2)}% at ${DateFormat('HH:mm dd.MM.yyyy').format(date)}', + TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, ), + ), + ); + } + + return res; + }, + ), + ), + lineBarsData: [ + LineChartBarData( + spots: getSpots(), + isCurved: false, + barWidth: 2, + color: Theme.of(context).colorScheme.primary, + dotData: const FlDotData( + show: false, + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.primary.withOpacity(0.5), + Theme.of(context).colorScheme.primary.withOpacity(0.0), + ], + begin: Alignment.bottomCenter, + end: Alignment.topCenter, ), ), - showTitles: true, + ), + ], + minY: 0, + // Maximal value of data by 100 step + maxY: 100, + minX: 0, + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + interval: 40, + reservedSize: 30, + getTitlesWidget: (final value, final titleMeta) => Padding( + padding: const EdgeInsets.all(8.0), + child: ExcludeSemantics( + child: Text( + bottomTitle( + value.toInt(), + data, + period, + ), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + ), + showTitles: true, + ), + ), + leftTitles: const AxisTitles( + sideTitles: SideTitles( + showTitles: false, + ), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles( + showTitles: false, + ), ), ), - leftTitles: const AxisTitles( - sideTitles: SideTitles( - showTitles: false, - ), - ), - rightTitles: const AxisTitles( - sideTitles: SideTitles( - showTitles: false, - ), - ), - ), - gridData: FlGridData( - show: true, - drawVerticalLine: true, - horizontalInterval: 25, - verticalInterval: 40, - getDrawingHorizontalLine: (final value) => FlLine( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - strokeWidth: 1, - ), - getDrawingVerticalLine: (final value) => FlLine( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - strokeWidth: 1, - ), - ), - borderData: FlBorderData( - show: true, - border: Border( - bottom: BorderSide( + gridData: FlGridData( + show: true, + drawVerticalLine: true, + horizontalInterval: 25, + verticalInterval: 40, + getDrawingHorizontalLine: (final value) => FlLine( color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - width: 1, + strokeWidth: 1, ), - left: BorderSide( + getDrawingVerticalLine: (final value) => FlLine( color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - width: 1, + strokeWidth: 1, ), - right: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - width: 1, - ), - top: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - width: 1, + ), + borderData: FlBorderData( + show: true, + border: Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), + left: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), + right: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), + top: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), ), ), ), diff --git a/lib/ui/pages/server_details/charts/disk_charts.dart b/lib/ui/pages/server_details/charts/disk_charts.dart index 5d8ebc07..ad7cc2b8 100644 --- a/lib/ui/pages/server_details/charts/disk_charts.dart +++ b/lib/ui/pages/server_details/charts/disk_charts.dart @@ -32,6 +32,36 @@ class DiskChart extends StatelessWidget { return res; } + String screenReaderDescription(final BuildContext context) { + final buffer = StringBuffer(); + buffer.write( + 'resource_chart.disk_chart_screen_reader_explanation.beginning'.tr( + namedArgs: { + 'period': 'resource_chart.${period.name}'.tr(), + }, + ), + ); + + for (final disk in diskData) { + final lastData = disk.diskData.last; + final lastValue = lastData.value; + + final firstData = disk.diskData.first; + final firstValue = firstData.value; + + buffer.write( + 'resource_chart.disk_chart_screen_reader_explanation.disk'.tr( + namedArgs: { + 'disk': disk.volume.displayName, + 'beginningValue': firstValue.toStringAsFixed(1), + 'endValue': lastValue.toStringAsFixed(1), + }, + ), + ); + } + return buffer.toString(); + } + @override Widget build(final BuildContext context) { final diskDataMax = [ @@ -39,142 +69,137 @@ class DiskChart extends StatelessWidget { (final disk) => disk.diskData.map((final e) => e.value).toList(), ), ].expand((final x) => x).reduce(max); - return LineChart( - LineChartData( - lineTouchData: LineTouchData( - enabled: true, - touchTooltipData: LineTouchTooltipData( - getTooltipColor: (final LineBarSpot _) => - Theme.of(context).colorScheme.surface, - tooltipPadding: const EdgeInsets.all(8), - getTooltipItems: (final List touchedBarSpots) { - final List res = []; + return Semantics( + label: screenReaderDescription(context), + child: LineChart( + LineChartData( + lineTouchData: LineTouchData( + enabled: true, + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (final LineBarSpot _) => + Theme.of(context).colorScheme.surface, + tooltipPadding: const EdgeInsets.all(8), + getTooltipItems: (final List touchedBarSpots) { + final List res = []; - bool timeShown = false; - for (final spot in touchedBarSpots) { - final value = spot.y; - final date = diskData.first.diskData[spot.x.toInt()].time; + bool timeShown = false; + for (final spot in touchedBarSpots) { + final value = spot.y; + final date = diskData.first.diskData[spot.x.toInt()].time; - res.add( - LineTooltipItem( - '${timeShown ? '' : DateFormat('HH:mm dd.MM.yyyy').format(date)} ${diskData[spot.barIndex].volume.displayName} ${value.toInt()}%', - TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.bold, + res.add( + LineTooltipItem( + '${timeShown ? '' : DateFormat('HH:mm dd.MM.yyyy').format(date)} ${diskData[spot.barIndex].volume.displayName} ${value.toInt()}%', + TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ), + ); + + timeShown = true; + } + + return res; + }, + ), + ), + lineBarsData: diskData + .map( + (final disk) => LineChartBarData( + spots: getSpots(disk.diskData), + isCurved: false, + barWidth: 2, + color: disk.color, + dotData: const FlDotData( + show: false, + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + disk.color.withOpacity(0.5), + disk.color.withOpacity(0.0), + ], + begin: Alignment.bottomCenter, + end: Alignment.topCenter, ), ), - ); - - timeShown = true; - } - - return res; - }, - ), - ), - lineBarsData: diskData - .map( - (final disk) => LineChartBarData( - spots: getSpots(disk.diskData), - isCurved: false, - barWidth: 2, - color: disk.color, - dotData: const FlDotData( - show: false, ), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - colors: [ - disk.color.withOpacity(0.5), - disk.color.withOpacity(0.0), - ], - begin: Alignment.bottomCenter, - end: Alignment.topCenter, + ) + .toList(), + minY: 0, + maxY: 100, + minX: 0, + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + interval: 40, + reservedSize: 30, + getTitlesWidget: (final value, final titleMeta) => Padding( + padding: const EdgeInsets.all(8.0), + child: ExcludeSemantics( + child: Text( + bottomTitle( + value.toInt(), + diskData.first.diskData, + period, + ), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), ), ), + showTitles: true, ), - ) - .toList(), - minY: 0, - maxY: 100, - minX: 0, - titlesData: FlTitlesData( - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - interval: 40, - reservedSize: 30, - getTitlesWidget: (final value, final titleMeta) => Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - bottomTitle( - value.toInt(), - diskData.first.diskData, - period, - ), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), + ), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles( + showTitles: false, ), - showTitles: true, ), ), - leftTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), + gridData: FlGridData( + show: true, + drawVerticalLine: true, + verticalInterval: 40, + horizontalInterval: diskDataMax * 2 / 6.5, + getDrawingHorizontalLine: (final value) => FlLine( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + strokeWidth: 1, + ), + getDrawingVerticalLine: (final value) => FlLine( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + strokeWidth: 1, + ), ), - rightTitles: AxisTitles( - sideTitles: SideTitles( - reservedSize: 50, - getTitlesWidget: (final value, final titleMeta) => Padding( - padding: const EdgeInsets.only(left: 5), - child: Text( - '${value.toInt()}%', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), + borderData: FlBorderData( + show: true, + border: Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), + left: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), + right: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), + top: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, ), - interval: diskDataMax * 2 / 6.5, - showTitles: true, - ), - ), - ), - gridData: FlGridData( - show: true, - drawVerticalLine: true, - verticalInterval: 40, - horizontalInterval: diskDataMax * 2 / 6.5, - getDrawingHorizontalLine: (final value) => FlLine( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - strokeWidth: 1, - ), - getDrawingVerticalLine: (final value) => FlLine( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - strokeWidth: 1, - ), - ), - borderData: FlBorderData( - show: true, - border: Border( - bottom: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - width: 1, - ), - left: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - width: 1, - ), - right: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - width: 1, - ), - top: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - width: 1, ), ), ), diff --git a/lib/ui/pages/server_details/charts/memory_chart.dart b/lib/ui/pages/server_details/charts/memory_chart.dart index 6379f16b..fc146b10 100644 --- a/lib/ui/pages/server_details/charts/memory_chart.dart +++ b/lib/ui/pages/server_details/charts/memory_chart.dart @@ -1,6 +1,6 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:selfprivacy/logic/common_enum/common_enum.dart'; import 'package:selfprivacy/logic/models/metrics.dart'; import 'package:selfprivacy/ui/pages/server_details/charts/bottom_title.dart'; @@ -29,130 +29,163 @@ class MemoryChart extends StatelessWidget { return res; } + String screenReaderDescription(final BuildContext context) { + final lastData = data.last; + final lastValue = lastData.value; + + final averageUsage = + data.map((final e) => e.value).reduce((final a, final b) => a + b) / + data.length; + final maxUsage = data + .map((final e) => e.value) + .reduce((final a, final b) => a > b ? a : b); + final maxUsageTime = data.firstWhere((final e) => e.value == maxUsage).time; + + final label = 'resource_chart.memory_chart_screen_reader_explanation'.tr( + namedArgs: { + 'period': 'resource_chart.${period.name}'.tr(), + 'lastValue': lastValue.toStringAsFixed(1), + 'averageUsage': averageUsage.toStringAsFixed(1), + 'maxUsage': maxUsage.toStringAsFixed(1), + 'maxUsageTime': DateFormat('HH:mm dd MMMM', context.locale.languageCode) + .format(maxUsageTime), + }, + ); + + return label; + } + @override - Widget build(final BuildContext context) => LineChart( - LineChartData( - lineTouchData: LineTouchData( - enabled: true, - touchTooltipData: LineTouchTooltipData( - getTooltipColor: (final LineBarSpot _) => - Theme.of(context).colorScheme.surface, - tooltipPadding: const EdgeInsets.all(8), - getTooltipItems: (final List touchedBarSpots) { - final List res = []; + Widget build(final BuildContext context) => Semantics( + label: screenReaderDescription(context), + child: LineChart( + LineChartData( + lineTouchData: LineTouchData( + enabled: true, + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (final LineBarSpot _) => + Theme.of(context).colorScheme.surface, + tooltipPadding: const EdgeInsets.all(8), + getTooltipItems: (final List touchedBarSpots) { + final List res = []; - for (final spot in touchedBarSpots) { - final value = spot.y; - final date = data[spot.x.toInt()].time; + for (final spot in touchedBarSpots) { + final value = spot.y; + final date = data[spot.x.toInt()].time; - res.add( - LineTooltipItem( - '${value.toStringAsFixed(2)}% at ${DateFormat('HH:mm dd.MM.yyyy').format(date)}', - TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), - ), - ); - } - - return res; - }, - ), - ), - lineBarsData: [ - LineChartBarData( - spots: getSpots(), - isCurved: false, - barWidth: 2, - color: Theme.of(context).colorScheme.primary, - dotData: const FlDotData( - show: false, - ), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - colors: [ - Theme.of(context).colorScheme.primary.withOpacity(0.5), - Theme.of(context).colorScheme.primary.withOpacity(0.0), - ], - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - ), - ), - ), - ], - minY: 0, - // Maximal value of data by 100 step - maxY: 100, - minX: 0, - titlesData: FlTitlesData( - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - interval: 40, - reservedSize: 30, - getTitlesWidget: (final value, final titleMeta) => Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - bottomTitle( - value.toInt(), - data, - period, - ), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + res.add( + LineTooltipItem( + '${value.toStringAsFixed(2)}% at ${DateFormat('HH:mm dd.MM.yyyy').format(date)}', + TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, ), + ), + ); + } + + return res; + }, + ), + ), + lineBarsData: [ + LineChartBarData( + spots: getSpots(), + isCurved: false, + barWidth: 2, + color: Theme.of(context).colorScheme.primary, + dotData: const FlDotData( + show: false, + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.primary.withOpacity(0.5), + Theme.of(context).colorScheme.primary.withOpacity(0.0), + ], + begin: Alignment.bottomCenter, + end: Alignment.topCenter, ), ), - showTitles: true, + ), + ], + minY: 0, + // Maximal value of data by 100 step + maxY: 100, + minX: 0, + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + interval: 40, + reservedSize: 30, + getTitlesWidget: (final value, final titleMeta) => Padding( + padding: const EdgeInsets.all(8.0), + child: ExcludeSemantics( + child: Text( + bottomTitle( + value.toInt(), + data, + period, + ), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + ), + showTitles: true, + ), + ), + leftTitles: const AxisTitles( + sideTitles: SideTitles( + showTitles: false, + ), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles( + showTitles: false, + ), ), ), - leftTitles: const AxisTitles( - sideTitles: SideTitles( - showTitles: false, - ), - ), - rightTitles: const AxisTitles( - sideTitles: SideTitles( - showTitles: false, - ), - ), - ), - gridData: FlGridData( - show: true, - drawVerticalLine: true, - horizontalInterval: 25, - verticalInterval: 40, - getDrawingHorizontalLine: (final value) => FlLine( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - strokeWidth: 1, - ), - getDrawingVerticalLine: (final value) => FlLine( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - strokeWidth: 1, - ), - ), - borderData: FlBorderData( - show: true, - border: Border( - bottom: BorderSide( + gridData: FlGridData( + show: true, + drawVerticalLine: true, + horizontalInterval: 25, + verticalInterval: 40, + getDrawingHorizontalLine: (final value) => FlLine( color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - width: 1, + strokeWidth: 1, ), - left: BorderSide( + getDrawingVerticalLine: (final value) => FlLine( color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - width: 1, + strokeWidth: 1, ), - right: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - width: 1, - ), - top: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - width: 1, + ), + borderData: FlBorderData( + show: true, + border: Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), + left: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), + right: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), + top: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), ), ), ), diff --git a/lib/ui/pages/server_details/charts/network_charts.dart b/lib/ui/pages/server_details/charts/network_charts.dart index 1cb07b80..019f48da 100644 --- a/lib/ui/pages/server_details/charts/network_charts.dart +++ b/lib/ui/pages/server_details/charts/network_charts.dart @@ -32,169 +32,226 @@ class NetworkChart extends StatelessWidget { return res; } + String screenReaderDescription(final BuildContext context) { + final lastDataIn = listData[0].last; + final lastDataOut = listData[1].last; + final lastValueIn = lastDataIn.value; + final lastValueOut = lastDataOut.value; + + final averageUsageIn = listData[0] + .map((final e) => e.value) + .reduce((final a, final b) => a + b) / + listData[0].length; + final averageUsageOut = listData[1] + .map((final e) => e.value) + .reduce((final a, final b) => a + b) / + listData[1].length; + + final maxUsageIn = listData[0] + .map((final e) => e.value) + .reduce((final a, final b) => a > b ? a : b); + final maxUsageOut = listData[1] + .map((final e) => e.value) + .reduce((final a, final b) => a > b ? a : b); + + final maxUsageTimeIn = + listData[0].firstWhere((final e) => e.value == maxUsageIn).time; + final maxUsageTimeOut = + listData[1].firstWhere((final e) => e.value == maxUsageOut).time; + + final label = 'resource_chart.network_chart_screen_reader_explanation'.tr( + namedArgs: { + 'period': 'resource_chart.${period.name}'.tr(), + 'lastValueIn': DiskSize(byte: lastValueIn.toInt()).toString(), + 'lastValueOut': DiskSize(byte: lastValueOut.toInt()).toString(), + 'averageUsageIn': DiskSize(byte: averageUsageIn.toInt()).toString(), + 'averageUsageOut': DiskSize(byte: averageUsageOut.toInt()).toString(), + 'maxUsageIn': DiskSize(byte: maxUsageIn.toInt()).toString(), + 'maxUsageOut': DiskSize(byte: maxUsageOut.toInt()).toString(), + 'maxUsageTimeIn': + DateFormat('HH:mm dd MMMM', context.locale.languageCode) + .format(maxUsageTimeIn), + 'maxUsageTimeOut': + DateFormat('HH:mm dd MMMM', context.locale.languageCode) + .format(maxUsageTimeOut), + }, + ); + + return label; + } + @override Widget build(final BuildContext context) { final listDataMax = [ ...listData[0].map((final e) => e.value), ...listData[1].map((final e) => e.value), ].reduce(max); - return LineChart( - LineChartData( - lineTouchData: LineTouchData( - enabled: true, - touchTooltipData: LineTouchTooltipData( - getTooltipColor: (final LineBarSpot _) => - Theme.of(context).colorScheme.surface, - tooltipPadding: const EdgeInsets.all(8), - getTooltipItems: (final List touchedBarSpots) { - final List res = []; + return Semantics( + label: screenReaderDescription(context), + child: LineChart( + LineChartData( + lineTouchData: LineTouchData( + enabled: true, + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (final LineBarSpot _) => + Theme.of(context).colorScheme.surface, + tooltipPadding: const EdgeInsets.all(8), + getTooltipItems: (final List touchedBarSpots) { + final List res = []; - bool timeShown = false; + bool timeShown = false; - for (final spot in touchedBarSpots) { - final value = spot.y; - final date = listData[0][spot.x.toInt()].time; + for (final spot in touchedBarSpots) { + final value = spot.y; + final date = listData[0][spot.x.toInt()].time; - res.add( - LineTooltipItem( - '${timeShown ? '' : DateFormat('HH:mm dd.MM.yyyy').format(date)} ${spot.barIndex == 0 ? 'resource_chart.in'.tr() : 'resource_chart.out'.tr()} ${DiskSize(byte: value.toInt()).toString()}', - TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.bold, + res.add( + LineTooltipItem( + '${timeShown ? '' : DateFormat('HH:mm dd.MM.yyyy').format(date)} ${spot.barIndex == 0 ? 'resource_chart.in'.tr() : 'resource_chart.out'.tr()} ${DiskSize(byte: value.toInt()).toString()}', + TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ), + ); + + timeShown = true; + } + + return res; + }, + ), + ), + lineBarsData: [ + // IN + LineChartBarData( + spots: getSpots(listData[0]), + isCurved: false, + barWidth: 2, + color: Theme.of(context).colorScheme.primary, + dotData: const FlDotData( + show: false, + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.primary.withOpacity(0.5), + Theme.of(context).colorScheme.primary.withOpacity(0.0), + ], + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + ), + ), + ), + // OUT + LineChartBarData( + spots: getSpots(listData[1]), + isCurved: false, + barWidth: 2, + color: Theme.of(context).colorScheme.tertiary, + dotData: const FlDotData( + show: false, + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.tertiary.withOpacity(0.5), + Theme.of(context).colorScheme.tertiary.withOpacity(0.0), + ], + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + ), + ), + ), + ], + minY: 0, + maxY: listDataMax * 1.2, + minX: 0, + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + interval: 40, + reservedSize: 30, + getTitlesWidget: (final value, final titleMeta) => Padding( + padding: const EdgeInsets.all(8.0), + child: ExcludeSemantics( + child: Text( + bottomTitle( + value.toInt(), + listData[0], + period, + ), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), - ); - - timeShown = true; - } - - return res; - }, - ), - ), - lineBarsData: [ - // IN - LineChartBarData( - spots: getSpots(listData[0]), - isCurved: false, - barWidth: 2, - color: Theme.of(context).colorScheme.primary, - dotData: const FlDotData( - show: false, - ), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - colors: [ - Theme.of(context).colorScheme.primary.withOpacity(0.5), - Theme.of(context).colorScheme.primary.withOpacity(0.0), - ], - begin: Alignment.bottomCenter, - end: Alignment.topCenter, + ), + showTitles: true, ), ), - ), - // OUT - LineChartBarData( - spots: getSpots(listData[1]), - isCurved: false, - barWidth: 2, - color: Theme.of(context).colorScheme.tertiary, - dotData: const FlDotData( - show: false, + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), ), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - colors: [ - Theme.of(context).colorScheme.tertiary.withOpacity(0.5), - Theme.of(context).colorScheme.tertiary.withOpacity(0.0), - ], - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - ), - ), - ), - ], - minY: 0, - maxY: listDataMax * 1.2, - minX: 0, - titlesData: FlTitlesData( - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - interval: 40, - reservedSize: 30, - getTitlesWidget: (final value, final titleMeta) => Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - bottomTitle( - value.toInt(), - listData[0], - period, + rightTitles: AxisTitles( + sideTitles: SideTitles( + reservedSize: 50, + getTitlesWidget: (final value, final titleMeta) => Padding( + padding: const EdgeInsets.only(left: 5), + child: ExcludeSemantics( + child: Text( + DiskSize(byte: value.toInt()).toString(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), ), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), ), + interval: listDataMax * 2 / 6.5, + showTitles: true, ), - showTitles: true, ), ), - leftTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), + gridData: FlGridData( + show: true, + drawVerticalLine: true, + verticalInterval: 40, + horizontalInterval: listDataMax * 2 / 6.5, + getDrawingHorizontalLine: (final value) => FlLine( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + strokeWidth: 1, + ), + getDrawingVerticalLine: (final value) => FlLine( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + strokeWidth: 1, + ), ), - rightTitles: AxisTitles( - sideTitles: SideTitles( - reservedSize: 50, - getTitlesWidget: (final value, final titleMeta) => Padding( - padding: const EdgeInsets.only(left: 5), - child: Text( - DiskSize(byte: value.toInt()).toString(), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), + borderData: FlBorderData( + show: true, + border: Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), + left: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), + right: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), + top: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, ), - interval: listDataMax * 2 / 6.5, - showTitles: true, - ), - ), - ), - gridData: FlGridData( - show: true, - drawVerticalLine: true, - verticalInterval: 40, - horizontalInterval: listDataMax * 2 / 6.5, - getDrawingHorizontalLine: (final value) => FlLine( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - strokeWidth: 1, - ), - getDrawingVerticalLine: (final value) => FlLine( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - strokeWidth: 1, - ), - ), - borderData: FlBorderData( - show: true, - border: Border( - bottom: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - width: 1, - ), - left: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - width: 1, - ), - right: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - width: 1, - ), - top: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), - width: 1, ), ), ),