feat(accessibility): Add screen reader descriptions for graphs

This commit is contained in:
Inex Code 2024-08-06 22:04:34 +03:00
parent 100ad5c367
commit d01df51296
9 changed files with 730 additions and 561 deletions

View file

@ -136,16 +136,24 @@
"day": "Day", "day": "Day",
"hour": "Hour", "hour": "Hour",
"cpu_title": "CPU Usage", "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_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_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", "in": "In",
"out": "Out", "out": "Out",
"memory": "Memory usage", "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", "view_usage_by_service": "View usage by service",
"unsupported": "You can't view resource usage charts without the server provider token.", "unsupported": "You can't view resource usage charts without the server provider token.",
"system": "System", "system": "System",
"ssh_users": "SSH users", "ssh_users": "SSH users",
"ram_usage": "Average usage: {average}. Maximum: {max}.", "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." "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": { "server": {

View file

@ -136,6 +136,7 @@
"day": "День", "day": "День",
"hour": "Час", "hour": "Час",
"cpu_title": "Использование процессора", "cpu_title": "Использование процессора",
"cpu_chart_screen_reader_explanation": "Этот график показывает использование процессора за последний {period}. Последнее значение {lastValue}%. Среднее использование {averageUsage}%. Максимальное использование было {maxUsage}% в {maxUsageTime}.",
"network_title": "Использование сети", "network_title": "Использование сети",
"in": "Получено", "in": "Получено",
"out": "Отправлено", "out": "Отправлено",

View file

@ -7,6 +7,7 @@ class FilledCard extends StatelessWidget {
this.tertiary = false, this.tertiary = false,
this.error = false, this.error = false,
this.clipped = true, this.clipped = true,
this.mergeSemantics = true,
super.key, super.key,
}); });
@ -15,6 +16,7 @@ class FilledCard extends StatelessWidget {
final bool error; final bool error;
final bool clipped; final bool clipped;
final bool secondary; final bool secondary;
final bool mergeSemantics;
@override @override
Widget build(final BuildContext context) => Card( Widget build(final BuildContext context) => Card(
elevation: 0.0, elevation: 0.0,
@ -29,6 +31,7 @@ class FilledCard extends StatelessWidget {
: tertiary : tertiary
? Theme.of(context).colorScheme.tertiaryContainer ? Theme.of(context).colorScheme.tertiaryContainer
: Theme.of(context).colorScheme.surfaceVariant, : Theme.of(context).colorScheme.surfaceVariant,
semanticContainer: mergeSemantics,
child: child, child: child,
); );
} }

View file

@ -75,6 +75,7 @@ class _BrandFabState extends State<BrandFab>
); );
}, },
isExtended: widget.extended, isExtended: widget.extended,
tooltip: 'jobs.title'.tr(),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,

View file

@ -60,6 +60,7 @@ class _Chart extends StatelessWidget {
if (!(state is MetricsLoaded && state.memoryMetrics == null)) if (!(state is MetricsLoaded && state.memoryMetrics == null))
FilledCard( FilledCard(
clipped: false, clipped: false,
mergeSemantics: false,
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
@ -110,42 +111,44 @@ class _Chart extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( ExcludeSemantics(
crossAxisAlignment: CrossAxisAlignment.center, child: Row(
mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.max,
children: [ mainAxisAlignment: MainAxisAlignment.spaceBetween,
Flexible( children: [
child: Text( Flexible(
'resource_chart.network_title'.tr(), child: Text(
style: 'resource_chart.network_title'.tr(),
Theme.of(context).textTheme.titleMedium?.copyWith( style:
color: Theme.of(context) Theme.of(context).textTheme.titleMedium?.copyWith(
.colorScheme color: Theme.of(context)
.onSurfaceVariant, .colorScheme
), .onSurfaceVariant,
),
),
), ),
), Flexible(
Flexible( fit: FlexFit.loose,
fit: FlexFit.loose, child: Wrap(
child: Wrap( spacing: 8.0,
spacing: 8.0, runSpacing: 8.0,
runSpacing: 8.0, alignment: WrapAlignment.end,
alignment: WrapAlignment.end, runAlignment: WrapAlignment.end,
runAlignment: WrapAlignment.end, children: [
children: [ Legend(
Legend( color: Theme.of(context).colorScheme.primary,
color: Theme.of(context).colorScheme.primary, text: 'resource_chart.in'.tr(),
text: 'resource_chart.in'.tr(), ),
), Legend(
Legend( color: Theme.of(context).colorScheme.tertiary,
color: Theme.of(context).colorScheme.tertiary, text: 'resource_chart.out'.tr(),
text: 'resource_chart.out'.tr(), ),
), ],
], ),
), ),
), ],
], ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Stack( Stack(
@ -205,42 +208,44 @@ class _Chart extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( ExcludeSemantics(
crossAxisAlignment: CrossAxisAlignment.center, child: Row(
mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.max,
children: [ mainAxisAlignment: MainAxisAlignment.spaceBetween,
Flexible( children: [
child: Text( Flexible(
'resource_chart.disk_title'.tr(), child: Text(
style: Theme.of(context) 'resource_chart.disk_title'.tr(),
.textTheme style: Theme.of(context)
.titleMedium .textTheme
?.copyWith( .titleMedium
color: Theme.of(context) ?.copyWith(
.colorScheme color: Theme.of(context)
.onSurfaceVariant, .colorScheme
), .onSurfaceVariant,
),
),
Flexible(
fit: FlexFit.loose,
child: Wrap(
spacing: 8.0,
runSpacing: 8.0,
alignment: WrapAlignment.end,
runAlignment: WrapAlignment.end,
children: disksGraphData
.map<Widget>(
(final disk) => Legend(
color: disk.color,
text: disk.volume.displayName,
), ),
) ),
.toList(),
), ),
), Flexible(
], fit: FlexFit.loose,
child: Wrap(
spacing: 8.0,
runSpacing: 8.0,
alignment: WrapAlignment.end,
runAlignment: WrapAlignment.end,
children: disksGraphData
.map<Widget>(
(final disk) => Legend(
color: disk.color,
text: disk.volume.displayName,
),
)
.toList(),
),
),
],
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Stack( Stack(
@ -390,9 +395,12 @@ class _GraphLoadingCardContent extends StatelessWidget {
const _GraphLoadingCardContent(); const _GraphLoadingCardContent();
@override @override
Widget build(final BuildContext context) => const SizedBox( Widget build(final BuildContext context) => SizedBox(
height: 200, height: 200,
child: Center(child: CircularProgressIndicator.adaptive()), child: Semantics(
label: 'resource_chart.loading'.tr(),
child: const Center(child: CircularProgressIndicator.adaptive()),
),
); );
} }

View file

@ -1,6 +1,6 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:selfprivacy/logic/common_enum/common_enum.dart'; import 'package:selfprivacy/logic/common_enum/common_enum.dart';
import 'package:selfprivacy/logic/models/metrics.dart'; import 'package:selfprivacy/logic/models/metrics.dart';
import 'package:selfprivacy/ui/pages/server_details/charts/bottom_title.dart'; import 'package:selfprivacy/ui/pages/server_details/charts/bottom_title.dart';
@ -29,130 +29,163 @@ class CpuChart extends StatelessWidget {
return res; 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 @override
Widget build(final BuildContext context) => LineChart( Widget build(final BuildContext context) => Semantics(
LineChartData( label: screenReaderDescription(context),
lineTouchData: LineTouchData( child: LineChart(
enabled: true, LineChartData(
touchTooltipData: LineTouchTooltipData( lineTouchData: LineTouchData(
getTooltipColor: (final LineBarSpot _) => enabled: true,
Theme.of(context).colorScheme.surface, touchTooltipData: LineTouchTooltipData(
tooltipPadding: const EdgeInsets.all(8), getTooltipColor: (final LineBarSpot _) =>
getTooltipItems: (final List<LineBarSpot> touchedBarSpots) { Theme.of(context).colorScheme.surface,
final List<LineTooltipItem> res = []; tooltipPadding: const EdgeInsets.all(8),
getTooltipItems: (final List<LineBarSpot> touchedBarSpots) {
final List<LineTooltipItem> res = [];
for (final spot in touchedBarSpots) { for (final spot in touchedBarSpots) {
final value = spot.y; final value = spot.y;
final date = data[spot.x.toInt()].time; final date = data[spot.x.toInt()].time;
res.add( res.add(
LineTooltipItem( LineTooltipItem(
'${value.toStringAsFixed(2)}% at ${DateFormat('HH:mm dd.MM.yyyy').format(date)}', '${value.toStringAsFixed(2)}% at ${DateFormat('HH:mm dd.MM.yyyy').format(date)}',
TextStyle( TextStyle(
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold, 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,
), ),
),
);
}
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( gridData: FlGridData(
sideTitles: SideTitles( show: true,
showTitles: false, drawVerticalLine: true,
), horizontalInterval: 25,
), verticalInterval: 40,
rightTitles: const AxisTitles( getDrawingHorizontalLine: (final value) => FlLine(
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(
color: Theme.of(context).colorScheme.outline.withOpacity(0.3), 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), color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
width: 1, strokeWidth: 1,
), ),
right: BorderSide( ),
color: Theme.of(context).colorScheme.outline.withOpacity(0.3), borderData: FlBorderData(
width: 1, show: true,
), border: Border(
top: BorderSide( bottom: BorderSide(
color: Theme.of(context).colorScheme.outline.withOpacity(0.3), color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
width: 1, 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,
),
), ),
), ),
), ),

View file

@ -32,6 +32,36 @@ class DiskChart extends StatelessWidget {
return res; 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 @override
Widget build(final BuildContext context) { Widget build(final BuildContext context) {
final diskDataMax = [ final diskDataMax = [
@ -39,142 +69,137 @@ class DiskChart extends StatelessWidget {
(final disk) => disk.diskData.map((final e) => e.value).toList(), (final disk) => disk.diskData.map((final e) => e.value).toList(),
), ),
].expand((final x) => x).reduce(max); ].expand((final x) => x).reduce(max);
return LineChart( return Semantics(
LineChartData( label: screenReaderDescription(context),
lineTouchData: LineTouchData( child: LineChart(
enabled: true, LineChartData(
touchTooltipData: LineTouchTooltipData( lineTouchData: LineTouchData(
getTooltipColor: (final LineBarSpot _) => enabled: true,
Theme.of(context).colorScheme.surface, touchTooltipData: LineTouchTooltipData(
tooltipPadding: const EdgeInsets.all(8), getTooltipColor: (final LineBarSpot _) =>
getTooltipItems: (final List<LineBarSpot> touchedBarSpots) { Theme.of(context).colorScheme.surface,
final List<LineTooltipItem> res = []; tooltipPadding: const EdgeInsets.all(8),
getTooltipItems: (final List<LineBarSpot> touchedBarSpots) {
final List<LineTooltipItem> res = [];
bool timeShown = false; bool timeShown = false;
for (final spot in touchedBarSpots) { for (final spot in touchedBarSpots) {
final value = spot.y; final value = spot.y;
final date = diskData.first.diskData[spot.x.toInt()].time; final date = diskData.first.diskData[spot.x.toInt()].time;
res.add( res.add(
LineTooltipItem( LineTooltipItem(
'${timeShown ? '' : DateFormat('HH:mm dd.MM.yyyy').format(date)} ${diskData[spot.barIndex].volume.displayName} ${value.toInt()}%', '${timeShown ? '' : DateFormat('HH:mm dd.MM.yyyy').format(date)} ${diskData[spot.barIndex].volume.displayName} ${value.toInt()}%',
TextStyle( TextStyle(
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
),
),
);
timeShown = true;
}
return res;
},
),
),
lineBarsData: diskData
.map<LineChartBarData>(
(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<LineChartBarData>(
(final disk) => LineChartBarData(
spots: getSpots(disk.diskData),
isCurved: false,
barWidth: 2,
color: disk.color,
dotData: const FlDotData(
show: false,
), ),
belowBarData: BarAreaData( )
show: true, .toList(),
gradient: LinearGradient( minY: 0,
colors: [ maxY: 100,
disk.color.withOpacity(0.5), minX: 0,
disk.color.withOpacity(0.0), titlesData: FlTitlesData(
], topTitles: const AxisTitles(
begin: Alignment.bottomCenter, sideTitles: SideTitles(showTitles: false),
end: Alignment.topCenter, ),
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(), leftTitles: const AxisTitles(
minY: 0, sideTitles: SideTitles(showTitles: false),
maxY: 100, ),
minX: 0, rightTitles: const AxisTitles(
titlesData: FlTitlesData( sideTitles: SideTitles(
topTitles: const AxisTitles( showTitles: false,
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,
),
),
), ),
showTitles: true,
), ),
), ),
leftTitles: const AxisTitles( gridData: FlGridData(
sideTitles: SideTitles(showTitles: false), 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( borderData: FlBorderData(
sideTitles: SideTitles( show: true,
reservedSize: 50, border: Border(
getTitlesWidget: (final value, final titleMeta) => Padding( bottom: BorderSide(
padding: const EdgeInsets.only(left: 5), color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
child: Text( width: 1,
'${value.toInt()}%', ),
style: Theme.of(context).textTheme.labelSmall?.copyWith( left: BorderSide(
color: Theme.of(context).colorScheme.onSurfaceVariant, 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,
), ),
), ),
), ),

View file

@ -1,6 +1,6 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:selfprivacy/logic/common_enum/common_enum.dart'; import 'package:selfprivacy/logic/common_enum/common_enum.dart';
import 'package:selfprivacy/logic/models/metrics.dart'; import 'package:selfprivacy/logic/models/metrics.dart';
import 'package:selfprivacy/ui/pages/server_details/charts/bottom_title.dart'; import 'package:selfprivacy/ui/pages/server_details/charts/bottom_title.dart';
@ -29,130 +29,163 @@ class MemoryChart extends StatelessWidget {
return res; 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 @override
Widget build(final BuildContext context) => LineChart( Widget build(final BuildContext context) => Semantics(
LineChartData( label: screenReaderDescription(context),
lineTouchData: LineTouchData( child: LineChart(
enabled: true, LineChartData(
touchTooltipData: LineTouchTooltipData( lineTouchData: LineTouchData(
getTooltipColor: (final LineBarSpot _) => enabled: true,
Theme.of(context).colorScheme.surface, touchTooltipData: LineTouchTooltipData(
tooltipPadding: const EdgeInsets.all(8), getTooltipColor: (final LineBarSpot _) =>
getTooltipItems: (final List<LineBarSpot> touchedBarSpots) { Theme.of(context).colorScheme.surface,
final List<LineTooltipItem> res = []; tooltipPadding: const EdgeInsets.all(8),
getTooltipItems: (final List<LineBarSpot> touchedBarSpots) {
final List<LineTooltipItem> res = [];
for (final spot in touchedBarSpots) { for (final spot in touchedBarSpots) {
final value = spot.y; final value = spot.y;
final date = data[spot.x.toInt()].time; final date = data[spot.x.toInt()].time;
res.add( res.add(
LineTooltipItem( LineTooltipItem(
'${value.toStringAsFixed(2)}% at ${DateFormat('HH:mm dd.MM.yyyy').format(date)}', '${value.toStringAsFixed(2)}% at ${DateFormat('HH:mm dd.MM.yyyy').format(date)}',
TextStyle( TextStyle(
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold, 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,
), ),
),
);
}
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( gridData: FlGridData(
sideTitles: SideTitles( show: true,
showTitles: false, drawVerticalLine: true,
), horizontalInterval: 25,
), verticalInterval: 40,
rightTitles: const AxisTitles( getDrawingHorizontalLine: (final value) => FlLine(
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(
color: Theme.of(context).colorScheme.outline.withOpacity(0.3), 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), color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
width: 1, strokeWidth: 1,
), ),
right: BorderSide( ),
color: Theme.of(context).colorScheme.outline.withOpacity(0.3), borderData: FlBorderData(
width: 1, show: true,
), border: Border(
top: BorderSide( bottom: BorderSide(
color: Theme.of(context).colorScheme.outline.withOpacity(0.3), color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
width: 1, 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,
),
), ),
), ),
), ),

View file

@ -32,169 +32,226 @@ class NetworkChart extends StatelessWidget {
return res; 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 @override
Widget build(final BuildContext context) { Widget build(final BuildContext context) {
final listDataMax = [ final listDataMax = [
...listData[0].map((final e) => e.value), ...listData[0].map((final e) => e.value),
...listData[1].map((final e) => e.value), ...listData[1].map((final e) => e.value),
].reduce(max); ].reduce(max);
return LineChart( return Semantics(
LineChartData( label: screenReaderDescription(context),
lineTouchData: LineTouchData( child: LineChart(
enabled: true, LineChartData(
touchTooltipData: LineTouchTooltipData( lineTouchData: LineTouchData(
getTooltipColor: (final LineBarSpot _) => enabled: true,
Theme.of(context).colorScheme.surface, touchTooltipData: LineTouchTooltipData(
tooltipPadding: const EdgeInsets.all(8), getTooltipColor: (final LineBarSpot _) =>
getTooltipItems: (final List<LineBarSpot> touchedBarSpots) { Theme.of(context).colorScheme.surface,
final List<LineTooltipItem> res = []; tooltipPadding: const EdgeInsets.all(8),
getTooltipItems: (final List<LineBarSpot> touchedBarSpots) {
final List<LineTooltipItem> res = [];
bool timeShown = false; bool timeShown = false;
for (final spot in touchedBarSpots) { for (final spot in touchedBarSpots) {
final value = spot.y; final value = spot.y;
final date = listData[0][spot.x.toInt()].time; final date = listData[0][spot.x.toInt()].time;
res.add( res.add(
LineTooltipItem( 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()}', '${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( TextStyle(
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold, 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,
),
), ),
), ),
); ),
showTitles: true,
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,
), ),
), ),
), leftTitles: const AxisTitles(
// OUT sideTitles: SideTitles(showTitles: false),
LineChartBarData(
spots: getSpots(listData[1]),
isCurved: false,
barWidth: 2,
color: Theme.of(context).colorScheme.tertiary,
dotData: const FlDotData(
show: false,
), ),
belowBarData: BarAreaData( rightTitles: AxisTitles(
show: true, sideTitles: SideTitles(
gradient: LinearGradient( reservedSize: 50,
colors: [ getTitlesWidget: (final value, final titleMeta) => Padding(
Theme.of(context).colorScheme.tertiary.withOpacity(0.5), padding: const EdgeInsets.only(left: 5),
Theme.of(context).colorScheme.tertiary.withOpacity(0.0), child: ExcludeSemantics(
], child: Text(
begin: Alignment.bottomCenter, DiskSize(byte: value.toInt()).toString(),
end: Alignment.topCenter, style: Theme.of(context).textTheme.labelSmall?.copyWith(
), color:
), Theme.of(context).colorScheme.onSurfaceVariant,
), ),
], ),
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,
), ),
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( gridData: FlGridData(
sideTitles: SideTitles(showTitles: false), 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( borderData: FlBorderData(
sideTitles: SideTitles( show: true,
reservedSize: 50, border: Border(
getTitlesWidget: (final value, final titleMeta) => Padding( bottom: BorderSide(
padding: const EdgeInsets.only(left: 5), color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
child: Text( width: 1,
DiskSize(byte: value.toInt()).toString(), ),
style: Theme.of(context).textTheme.labelSmall?.copyWith( left: BorderSide(
color: Theme.of(context).colorScheme.onSurfaceVariant, 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,
), ),
), ),
), ),