From 0f391cd3d5f60c1a41b60c4ab853ab77e1351c5b Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 4 Sep 2022 20:05:09 +0200 Subject: [PATCH] stats: animation review --- lib/theme/durations.dart | 3 + lib/widgets/stats/date/histogram.dart | 120 ++++++-- lib/widgets/stats/filter_table.dart | 5 +- lib/widgets/stats/mime_donut.dart | 183 ++++++++++++ lib/widgets/stats/stats_page.dart | 409 ++++++++++---------------- 5 files changed, 439 insertions(+), 281 deletions(-) create mode 100644 lib/widgets/stats/mime_donut.dart diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index cf0562ea5..50e81aab5 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -95,6 +95,7 @@ class DurationsData { // common animations final Duration expansionTileAnimation; final Duration formTransition; + final Duration chartTransition; final Duration iconAnimation; final Duration staggeredAnimation; final Duration staggeredAnimationPageTarget; @@ -110,6 +111,7 @@ class DurationsData { const DurationsData({ this.expansionTileAnimation = const Duration(milliseconds: 200), this.formTransition = const Duration(milliseconds: 200), + this.chartTransition = const Duration(milliseconds: 400), this.iconAnimation = const Duration(milliseconds: 300), this.staggeredAnimation = const Duration(milliseconds: 375), this.staggeredAnimationPageTarget = const Duration(milliseconds: 800), @@ -123,6 +125,7 @@ class DurationsData { // as of Flutter v2.5.1, `ExpansionPanelList` throws if animation duration is zero expansionTileAnimation: const Duration(microseconds: 1), formTransition: Duration.zero, + chartTransition: Duration.zero, iconAnimation: Duration.zero, staggeredAnimation: Duration.zero, staggeredAnimationPageTarget: Duration.zero, diff --git a/lib/widgets/stats/date/histogram.dart b/lib/widgets/stats/date/histogram.dart index eac425d64..032822536 100644 --- a/lib/widgets/stats/date/histogram.dart +++ b/lib/widgets/stats/date/histogram.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:aves/model/entry.dart'; @@ -9,17 +10,21 @@ import 'package:aves/widgets/stats/date/axis.dart'; import 'package:charts_flutter/flutter.dart' as charts; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; class Histogram extends StatefulWidget { final Set entries; + final Duration animationDuration; final FilterCallback onFilterSelection; const Histogram({ super.key, required this.entries, + required this.animationDuration, required this.onFilterSelection, }); @@ -27,16 +32,19 @@ class Histogram extends StatefulWidget { State createState() => _HistogramState(); } -class _HistogramState extends State { +class _HistogramState extends State with AutomaticKeepAliveClientMixin { DateLevel _level = DateLevel.y; DateTime? _firstDate, _lastDate; final Map _entryCountPerDate = {}; final ValueNotifier<_EntryByDate?> _selection = ValueNotifier(null); List<_EntryByDate>? _seriesData; - List<_EntryByDate>? _interpolatedData; + late Future?> _interpolatedDataLoader; + late Future _areaChartLoader; static const histogramHeight = 200.0; + Duration get animationDuration => widget.animationDuration; + @override void initState() { super.initState(); @@ -78,22 +86,33 @@ class _HistogramState extends State { }).toList(); // smooth curve - _computeInterpolatedData(); + _interpolatedDataLoader = compute<_DataInterpolationArg, List<_EntryByDate>?>( + _computeInterpolatedData, + _DataInterpolationArg( + firstDate: _firstDate, + lastDate: _lastDate, + level: _level, + entryCountPerDate: _entryCountPerDate, + )); + _areaChartLoader = _interpolatedDataLoader.then((_) => Future.delayed(animationDuration * timeDilation)); } } } } - void _computeInterpolatedData() { - final firstDate = _firstDate; - final lastDate = _lastDate; - if (firstDate == null || lastDate == null) return; + static List<_EntryByDate>? _computeInterpolatedData(_DataInterpolationArg arg) { + final firstDate = arg.firstDate; + final lastDate = arg.lastDate; + final level = arg.level; + final entryCountPerDate = arg.entryCountPerDate; + + if (firstDate == null || lastDate == null) return null; final xRange = lastDate.difference(firstDate); final xRangeInMillis = xRange.inMilliseconds; late int xCount; late DateTime Function(DateTime date) incrementDate; - switch (_level) { + switch (level) { case DateLevel.ymd: xCount = xRange.inDays; incrementDate = (date) => DateTime(date.year, date.month, date.day + 1); @@ -107,42 +126,69 @@ class _HistogramState extends State { incrementDate = (date) => DateTime(date.year + 1); break; } - final yMax = _entryCountPerDate.values.reduce(max).toDouble(); + final yMax = entryCountPerDate.values.reduce(max).toDouble(); final xInterval = yMax / xCount; final controlPoints = []; var date = firstDate; for (int i = 0; i <= xCount; i++) { - controlPoints.add(Offset(i * xInterval, (_entryCountPerDate[date] ?? 0).toDouble())); + controlPoints.add(Offset(i * xInterval, (entryCountPerDate[date] ?? 0).toDouble())); date = incrementDate(date); } final interpolatedPoints = controlPoints.length > 3 ? CatmullRomSpline(controlPoints).generateSamples().map((sample) => sample.value).toList() : controlPoints; - _interpolatedData = interpolatedPoints.map((p) { + final interpolatedData = interpolatedPoints.map((p) { final date = firstDate.add(Duration(milliseconds: p.dx * xRangeInMillis ~/ yMax)); final entryCount = p.dy.clamp(0, yMax); return _EntryByDate(date: date, entryCount: entryCount); }).toList(); + + return interpolatedData; } @override Widget build(BuildContext context) { - if (_seriesData == null || _interpolatedData == null) return const SizedBox(); + super.build(context); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8), - height: histogramHeight, - child: Stack( - children: [ - _buildChart(context, _interpolatedData!, isInterpolated: true, isArea: true), - _buildChart(context, _interpolatedData!, isInterpolated: true, isArea: false), - _buildChart(context, _seriesData!, isInterpolated: false, isArea: false), - ], - ), - ), - _buildSelectionRow(), - ], + if (_seriesData == null) return const SizedBox(); + + return FutureBuilder?>( + future: _interpolatedDataLoader, + builder: (context, snapshot) { + final interpolatedData = snapshot.data; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + height: histogramHeight, + child: AnimatedSwitcher( + duration: animationDuration, + child: interpolatedData != null + ? Stack( + children: [ + FutureBuilder( + future: _areaChartLoader, + builder: (context, snapshot) { + Widget child = const SizedBox(); + if (snapshot.connectionState == ConnectionState.done) { + child = _buildChart(context, interpolatedData, isInterpolated: true, isArea: true); + } + return AnimatedSwitcher( + duration: animationDuration, + child: child, + ); + }, + ), + _buildChart(context, interpolatedData, isInterpolated: true, isArea: false), + _buildChart(context, _seriesData!, isInterpolated: false, isArea: false), + ], + ) + : const SizedBox(), + ), + ), + _buildSelectionRow(), + ], + ); + }, ); } @@ -200,6 +246,8 @@ class _HistogramState extends State { Widget chart = charts.TimeSeriesChart( series, + animate: false, + animationDuration: animationDuration, domainAxis: domainAxis, primaryMeasureAxis: charts.NumericAxisSpec( renderSpec: charts.GridlineRendererSpec( @@ -308,6 +356,9 @@ class _HistogramState extends State { }, ); } + + @override + bool get wantKeepAlive => true; } @immutable @@ -330,3 +381,16 @@ class _CircleSymbolRenderer extends charts.CircleSymbolRenderer { @override charts.Color? getSolidFillColor(charts.Color? fillColor) => fillColor; } + +class _DataInterpolationArg { + final DateLevel level; + final DateTime? firstDate, lastDate; + final Map entryCountPerDate; + + const _DataInterpolationArg({ + required this.level, + required this.firstDate, + required this.lastDate, + required this.entryCountPerDate, + }); +} diff --git a/lib/widgets/stats/filter_table.dart b/lib/widgets/stats/filter_table.dart index bf764ad5a..62159b672 100644 --- a/lib/widgets/stats/filter_table.dart +++ b/lib/widgets/stats/filter_table.dart @@ -1,4 +1,5 @@ import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/utils/constants.dart'; @@ -7,6 +8,7 @@ import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:percent_indicator/linear_percent_indicator.dart'; +import 'package:provider/provider.dart'; class FilterTable extends StatelessWidget { final int totalEntryCount; @@ -35,6 +37,7 @@ class FilterTable extends StatelessWidget { final locale = context.l10n.localeName; final numberFormat = NumberFormat.decimalPattern(locale); final percentFormat = NumberFormat.percentPattern(); + final animate = context.select((v) => v.accessibilityAnimations.animate); final sortedEntries = entryCountMap.entries.toList(); if (sortByCount) { @@ -85,7 +88,7 @@ class FilterTable extends StatelessWidget { lineHeight: lineHeight, backgroundColor: theme.colorScheme.onPrimary.withOpacity(.1), progressColor: isMonochrome ? theme.colorScheme.secondary : color, - animation: true, + animation: animate, isRTL: isRtl, barRadius: barRadius, center: Text( diff --git a/lib/widgets/stats/mime_donut.dart b/lib/widgets/stats/mime_donut.dart new file mode 100644 index 000000000..63fc18299 --- /dev/null +++ b/lib/widgets/stats/mime_donut.dart @@ -0,0 +1,183 @@ +import 'dart:math'; + +import 'package:aves/model/filters/mime.dart'; +import 'package:aves/model/settings/enums/accessibility_animations.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/colors.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/mime_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +class MimeDonut extends StatefulWidget { + final IconData icon; + final Map byMimeTypes; + final Duration animationDuration; + final FilterCallback onFilterSelection; + + const MimeDonut({ + super.key, + required this.icon, + required this.byMimeTypes, + required this.animationDuration, + required this.onFilterSelection, + }); + + @override + State createState() => _MimeDonutState(); +} + +class _MimeDonutState extends State with AutomaticKeepAliveClientMixin { + Map get byMimeTypes => widget.byMimeTypes; + + static const mimeDonutMinWidth = 124.0; + + @override + Widget build(BuildContext context) { + super.build(context); + + if (byMimeTypes.isEmpty) return const SizedBox.shrink(); + + final l10n = context.l10n; + final locale = l10n.localeName; + final numberFormat = NumberFormat.decimalPattern(locale); + + final sum = byMimeTypes.values.fold(0, (prev, v) => prev + v); + + final colors = context.watch(); + final seriesData = byMimeTypes.entries.map((kv) { + final mimeType = kv.key; + final displayText = MimeUtils.displayType(mimeType); + return EntryByMimeDatum( + mimeType: mimeType, + displayText: displayText, + color: colors.fromString(displayText), + entryCount: kv.value, + ); + }).toList(); + seriesData.sort((d1, d2) { + final c = d2.entryCount.compareTo(d1.entryCount); + return c != 0 ? c : compareAsciiUpperCase(d1.displayText, d2.displayText); + }); + + final series = [ + charts.Series( + id: 'mime', + colorFn: (d, i) => charts.ColorUtil.fromDartColor(d.color), + domainFn: (d, i) => d.displayText, + measureFn: (d, i) => d.entryCount, + data: seriesData, + labelAccessorFn: (d, _) => '${d.displayText}: ${d.entryCount}', + ), + ]; + + return LayoutBuilder(builder: (context, constraints) { + final textScaleFactor = MediaQuery.textScaleFactorOf(context); + final minWidth = mimeDonutMinWidth * textScaleFactor; + final availableWidth = constraints.maxWidth; + final dim = max(minWidth, availableWidth / (availableWidth > 4 * minWidth ? 4 : (availableWidth > 2 * minWidth ? 2 : 1))); + + final donut = SizedBox( + width: dim, + height: dim, + child: Stack( + children: [ + charts.PieChart( + series, + animate: context.select((v) => v.accessibilityAnimations.animate), + animationDuration: widget.animationDuration, + defaultRenderer: charts.ArcRendererConfig( + arcWidth: 16, + ), + ), + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(widget.icon), + Text( + numberFormat.format(sum), + textAlign: TextAlign.center, + ), + ], + ), + ), + ], + ), + ); + final legend = SizedBox( + width: dim, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: seriesData + .map((d) => GestureDetector( + onTap: () => widget.onFilterSelection(MimeFilter(d.mimeType)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(AIcons.disc, color: d.color), + const SizedBox(width: 8), + Flexible( + child: Text( + d.displayText, + overflow: TextOverflow.fade, + softWrap: false, + maxLines: 1, + ), + ), + const SizedBox(width: 8), + Text( + numberFormat.format(d.entryCount), + style: TextStyle( + color: Theme.of(context).textTheme.caption!.color, + ), + ), + ], + ), + )) + .toList(), + ), + ); + final children = [ + donut, + legend, + ]; + return availableWidth > minWidth * 2 + ? Row( + mainAxisSize: MainAxisSize.min, + children: children, + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: children, + ); + }); + } + + @override + bool get wantKeepAlive => true; +} + +@immutable +class EntryByMimeDatum extends Equatable { + final String mimeType, displayText; + final Color color; + final int entryCount; + + @override + List get props => [mimeType, displayText, color, entryCount]; + + const EntryByMimeDatum({ + required this.mimeType, + required this.displayText, + required this.color, + required this.entryCount, + }); +} diff --git a/lib/widgets/stats/stats_page.dart b/lib/widgets/stats/stats_page.dart index c4bdeaf17..e1479b427 100644 --- a/lib/widgets/stats/stats_page.dart +++ b/lib/widgets/stats/stats_page.dart @@ -1,19 +1,17 @@ -import 'dart:math'; +import 'dart:async'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; -import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/theme/colors.dart'; +import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; -import 'package:aves/utils/mime_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -21,285 +19,209 @@ import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/stats/date/histogram.dart'; import 'package:aves/widgets/stats/filter_table.dart'; -import 'package:charts_flutter/flutter.dart' as charts; +import 'package:aves/widgets/stats/mime_donut.dart'; import 'package:collection/collection.dart'; -import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:intl/intl.dart'; import 'package:percent_indicator/linear_percent_indicator.dart'; import 'package:provider/provider.dart'; -class StatsPage extends StatelessWidget { +class StatsPage extends StatefulWidget { static const routeName = '/collection/stats'; + final Set entries; final CollectionSource source; final CollectionLens? parentCollection; - final Set entries; - final Map entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {}; - final Map entryCountPerRating = Map.fromEntries(List.generate(7, (i) => MapEntry(5 - i, 0))); - static const mimeDonutMinWidth = 124.0; - - StatsPage({ + const StatsPage({ super.key, required this.entries, required this.source, this.parentCollection, - }) { + }); + + @override + State createState() => _StatsPageState(); +} + +class _StatsPageState extends State { + final Map _entryCountPerCountry = {}, _entryCountPerPlace = {}, _entryCountPerTag = {}; + final Map _entryCountPerRating = Map.fromEntries(List.generate(7, (i) => MapEntry(5 - i, 0))); + late final ValueNotifier _isPageAnimatingNotifier; + + Set get entries => widget.entries; + + @override + void initState() { + super.initState(); + + _isPageAnimatingNotifier = ValueNotifier(true); + Future.delayed(Durations.pageTransitionAnimation * timeDilation).then((_) { + if (!mounted) return; + _isPageAnimatingNotifier.value = false; + }); + entries.forEach((entry) { if (entry.hasAddress) { final address = entry.addressDetails!; var country = address.countryName; if (country != null && country.isNotEmpty) { country += '${LocationFilter.locationSeparator}${address.countryCode}'; - entryCountPerCountry[country] = (entryCountPerCountry[country] ?? 0) + 1; + _entryCountPerCountry[country] = (_entryCountPerCountry[country] ?? 0) + 1; } final place = address.place; if (place != null && place.isNotEmpty) { - entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1; + _entryCountPerPlace[place] = (_entryCountPerPlace[place] ?? 0) + 1; } } entry.tags.forEach((tag) { - entryCountPerTag[tag] = (entryCountPerTag[tag] ?? 0) + 1; + _entryCountPerTag[tag] = (_entryCountPerTag[tag] ?? 0) + 1; }); final rating = entry.rating; - entryCountPerRating[rating] = (entryCountPerRating[rating] ?? 0) + 1; + _entryCountPerRating[rating] = (_entryCountPerRating[rating] ?? 0) + 1; }); } @override Widget build(BuildContext context) { - final l10n = context.l10n; - final locale = l10n.localeName; - final numberFormat = NumberFormat.decimalPattern(locale); - final percentFormat = NumberFormat.percentPattern(); + return ValueListenableBuilder( + valueListenable: _isPageAnimatingNotifier, + builder: (context, animating, child) { + final l10n = context.l10n; - Widget child; - if (entries.isEmpty) { - child = EmptyContent( - icon: AIcons.image, - text: l10n.collectionEmptyImages, - ); - } else { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - final animate = context.select((v) => v.accessibilityAnimations.animate); - final byMimeTypes = groupBy(entries, (entry) => entry.mimeType).map((k, v) => MapEntry(k, v.length)); - final imagesByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('image'))); - final videoByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('video'))); - final mimeDonuts = Provider.value( - value: isDark ? NeonOnDark() : PastelOnLight(), - child: Builder( - builder: (context) { - return Wrap( + Widget child = const SizedBox(); + + if (!animating) { + final durations = context.watch(); + final percentFormat = NumberFormat.percentPattern(); + + if (entries.isEmpty) { + child = EmptyContent( + icon: AIcons.image, + text: l10n.collectionEmptyImages, + ); + } else { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final chartAnimationDuration = context.read().chartTransition; + + final byMimeTypes = groupBy(entries, (entry) => entry.mimeType).map((k, v) => MapEntry(k, v.length)); + final imagesByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('image'))); + final videoByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('video'))); + final mimeDonuts = Wrap( alignment: WrapAlignment.center, crossAxisAlignment: WrapCrossAlignment.center, children: [ - _buildMimeDonut(context, AIcons.image, imagesByMimeTypes, animate, numberFormat), - _buildMimeDonut(context, AIcons.video, videoByMimeTypes, animate, numberFormat), + MimeDonut( + icon: AIcons.image, + byMimeTypes: imagesByMimeTypes, + animationDuration: chartAnimationDuration, + onFilterSelection: (filter) => _onFilterSelection(context, filter), + ), + MimeDonut( + icon: AIcons.video, + byMimeTypes: videoByMimeTypes, + animationDuration: chartAnimationDuration, + onFilterSelection: (filter) => _onFilterSelection(context, filter), + ), ], ); - }, - ), - ); - final catalogued = entries.where((entry) => entry.isCatalogued); - final withGps = catalogued.where((entry) => entry.hasGps); - final withGpsCount = withGps.length; - final withGpsPercent = withGpsCount / entries.length; - final textScaleFactor = MediaQuery.textScaleFactorOf(context); - final lineHeight = 16 * textScaleFactor; - final barRadius = Radius.circular(lineHeight / 2); - final locationIndicator = Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(AIcons.location), - Expanded( - child: LinearPercentIndicator( - percent: withGpsPercent, - lineHeight: lineHeight, - backgroundColor: theme.colorScheme.onPrimary.withOpacity(.1), - progressColor: theme.colorScheme.secondary, - animation: animate, - isRTL: context.isRtl, - barRadius: barRadius, - center: Text( - percentFormat.format(withGpsPercent), - style: TextStyle( - shadows: isDark ? Constants.embossShadows : null, - ), - ), - padding: EdgeInsets.symmetric(horizontal: lineHeight), - ), - ), - // end padding to match leading, so that inside label is aligned with outside label below - const SizedBox(width: 24), - ], - ), - const SizedBox(height: 8), - Text( - l10n.statsWithGps(withGpsCount), - textAlign: TextAlign.center, - ), - ], - ), - ); - final showRatings = entryCountPerRating.entries.any((kv) => kv.key != 0 && kv.value > 0); - child = ListView( - children: [ - mimeDonuts, - Histogram( - entries: entries, - onFilterSelection: (filter) => _onFilterSelection(context, filter), - ), - locationIndicator, - ..._buildFilterSection(context, l10n.statsTopCountriesSectionTitle, entryCountPerCountry, (v) => LocationFilter(LocationLevel.country, v)), - ..._buildFilterSection(context, l10n.statsTopPlacesSectionTitle, entryCountPerPlace, (v) => LocationFilter(LocationLevel.place, v)), - ..._buildFilterSection(context, l10n.statsTopTagsSectionTitle, entryCountPerTag, TagFilter.new), - if (showRatings) ..._buildFilterSection(context, l10n.searchRatingSectionTitle, entryCountPerRating, RatingFilter.new, sortByCount: false, maxRowCount: null), - ], - ); - } - return MediaQueryDataProvider( - child: Scaffold( - appBar: AppBar( - title: Text(l10n.statsPageTitle), - ), - body: GestureAreaProtectorStack( - child: SafeArea( - bottom: false, - child: child, - ), - ), - ), - ); - } - - Widget _buildMimeDonut( - BuildContext context, - IconData icon, - Map byMimeTypes, - bool animate, - NumberFormat numberFormat, - ) { - if (byMimeTypes.isEmpty) return const SizedBox.shrink(); - - final sum = byMimeTypes.values.fold(0, (prev, v) => prev + v); - - final colors = context.watch(); - final seriesData = byMimeTypes.entries.map((kv) { - final mimeType = kv.key; - final displayText = MimeUtils.displayType(mimeType); - return EntryByMimeDatum( - mimeType: mimeType, - displayText: displayText, - color: colors.fromString(displayText), - entryCount: kv.value, - ); - }).toList(); - seriesData.sort((d1, d2) { - final c = d2.entryCount.compareTo(d1.entryCount); - return c != 0 ? c : compareAsciiUpperCase(d1.displayText, d2.displayText); - }); - - final series = [ - charts.Series( - id: 'mime', - colorFn: (d, i) => charts.ColorUtil.fromDartColor(d.color), - domainFn: (d, i) => d.displayText, - measureFn: (d, i) => d.entryCount, - data: seriesData, - labelAccessorFn: (d, _) => '${d.displayText}: ${d.entryCount}', - ), - ]; - - return LayoutBuilder(builder: (context, constraints) { - final textScaleFactor = MediaQuery.textScaleFactorOf(context); - final minWidth = mimeDonutMinWidth * textScaleFactor; - final availableWidth = constraints.maxWidth; - final dim = max(minWidth, availableWidth / (availableWidth > 4 * minWidth ? 4 : (availableWidth > 2 * minWidth ? 2 : 1))); - - final donut = SizedBox( - width: dim, - height: dim, - child: Stack( - children: [ - charts.PieChart( - series, - animate: animate, - defaultRenderer: charts.ArcRendererConfig( - arcWidth: 16, - ), - ), - Center( + final catalogued = entries.where((entry) => entry.isCatalogued); + final withGps = catalogued.where((entry) => entry.hasGps); + final withGpsCount = withGps.length; + final withGpsPercent = withGpsCount / entries.length; + final textScaleFactor = MediaQuery.textScaleFactorOf(context); + final lineHeight = 16 * textScaleFactor; + final barRadius = Radius.circular(lineHeight / 2); + final locationIndicator = Padding( + padding: const EdgeInsets.all(16), child: Column( - mainAxisSize: MainAxisSize.min, children: [ - Icon(icon), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(AIcons.location), + Expanded( + child: LinearPercentIndicator( + percent: withGpsPercent, + lineHeight: lineHeight, + backgroundColor: theme.colorScheme.onPrimary.withOpacity(.1), + progressColor: theme.colorScheme.secondary, + animation: context.select((v) => v.accessibilityAnimations.animate), + isRTL: context.isRtl, + barRadius: barRadius, + center: Text( + percentFormat.format(withGpsPercent), + style: TextStyle( + shadows: isDark ? Constants.embossShadows : null, + ), + ), + padding: EdgeInsets.symmetric(horizontal: lineHeight), + ), + ), + // end padding to match leading, so that inside label is aligned with outside label below + const SizedBox(width: 24), + ], + ), + const SizedBox(height: 8), Text( - numberFormat.format(sum), + l10n.statsWithGps(withGpsCount), textAlign: TextAlign.center, ), ], ), - ), - ], - ), - ); - final legend = SizedBox( - width: dim, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: seriesData - .map((d) => GestureDetector( - onTap: () => _onFilterSelection(context, MimeFilter(d.mimeType)), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(AIcons.disc, color: d.color), - const SizedBox(width: 8), - Flexible( - child: Text( - d.displayText, - overflow: TextOverflow.fade, - softWrap: false, - maxLines: 1, - ), - ), - const SizedBox(width: 8), - Text( - numberFormat.format(d.entryCount), - style: TextStyle( - color: Theme.of(context).textTheme.caption!.color, - ), - ), - ], - ), - )) - .toList(), - ), - ); - final children = [ - donut, - legend, - ]; - return availableWidth > minWidth * 2 - ? Row( - mainAxisSize: MainAxisSize.min, - children: children, - ) - : Column( - mainAxisSize: MainAxisSize.min, - children: children, ); - }); + final showRatings = _entryCountPerRating.entries.any((kv) => kv.key != 0 && kv.value > 0); + child = AnimationLimiter( + child: ListView( + children: AnimationConfiguration.toStaggeredList( + duration: durations.staggeredAnimation, + delay: durations.staggeredAnimationDelay * timeDilation, + childAnimationBuilder: (child) => SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation( + child: child, + ), + ), + children: [ + mimeDonuts, + Histogram( + entries: entries, + animationDuration: chartAnimationDuration, + onFilterSelection: (filter) => _onFilterSelection(context, filter), + ), + locationIndicator, + ..._buildFilterSection(context, l10n.statsTopCountriesSectionTitle, _entryCountPerCountry, (v) => LocationFilter(LocationLevel.country, v)), + ..._buildFilterSection(context, l10n.statsTopPlacesSectionTitle, _entryCountPerPlace, (v) => LocationFilter(LocationLevel.place, v)), + ..._buildFilterSection(context, l10n.statsTopTagsSectionTitle, _entryCountPerTag, TagFilter.new), + if (showRatings) ..._buildFilterSection(context, l10n.searchRatingSectionTitle, _entryCountPerRating, RatingFilter.new, sortByCount: false, maxRowCount: null), + ], + ), + ), + ); + } + } + + return MediaQueryDataProvider( + child: Scaffold( + appBar: AppBar( + title: Text(l10n.statsPageTitle), + ), + body: GestureAreaProtectorStack( + child: SafeArea( + bottom: false, + child: child, + ), + ), + ), + ); + }, + ); } List _buildFilterSection( @@ -332,7 +254,7 @@ class StatsPage extends StatelessWidget { } void _onFilterSelection(BuildContext context, CollectionFilter filter) { - if (parentCollection != null) { + if (widget.parentCollection != null) { _applyToParentCollectionPage(context, filter); } else { _jumpToCollectionPage(context, filter); @@ -340,7 +262,7 @@ class StatsPage extends StatelessWidget { } void _applyToParentCollectionPage(BuildContext context, CollectionFilter filter) { - parentCollection!.addFilter(filter); + widget.parentCollection!.addFilter(filter); // We delay closing the current page after applying the filter selection // so that hero animation target is ready in the `FilterBar`, // even when the target is a child of an `AnimatedList`. @@ -355,7 +277,7 @@ class StatsPage extends StatelessWidget { MaterialPageRoute( settings: const RouteSettings(name: CollectionPage.routeName), builder: (context) => CollectionPage( - source: source, + source: widget.source, filters: {filter}, ), ), @@ -363,20 +285,3 @@ class StatsPage extends StatelessWidget { ); } } - -@immutable -class EntryByMimeDatum extends Equatable { - final String mimeType, displayText; - final Color color; - final int entryCount; - - @override - List get props => [mimeType, displayText, color, entryCount]; - - const EntryByMimeDatum({ - required this.mimeType, - required this.displayText, - required this.color, - required this.entryCount, - }); -}