import 'dart:math'; 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/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'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/stats/filter_table.dart'; import 'package:aves/widgets/stats/histogram.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:percent_indicator/linear_percent_indicator.dart'; import 'package:provider/provider.dart'; class StatsPage extends StatelessWidget { static const routeName = '/collection/stats'; 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({ super.key, required this.entries, required this.source, this.parentCollection, }) { 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; } final place = address.place; if (place != null && place.isNotEmpty) { entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1; } } entry.tags.forEach((tag) { entryCountPerTag[tag] = (entryCountPerTag[tag] ?? 0) + 1; }); final rating = entry.rating; 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(); 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( alignment: WrapAlignment.center, crossAxisAlignment: WrapCrossAlignment.center, children: [ _buildMimeDonut(context, AIcons.image, imagesByMimeTypes, animate, numberFormat), _buildMimeDonut(context, AIcons.video, videoByMimeTypes, animate, numberFormat), ], ); }, ), ); 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( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(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: () => _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, ); }); } List _buildFilterSection( BuildContext context, String title, Map entryCountMap, CollectionFilter Function(T key) filterBuilder, { bool sortByCount = true, int? maxRowCount = 5, }) { if (entryCountMap.isEmpty) return []; return [ Padding( padding: const EdgeInsets.all(16), child: Text( title, style: Constants.knownTitleTextStyle, ), ), FilterTable( totalEntryCount: entries.length, entryCountMap: entryCountMap, filterBuilder: filterBuilder, sortByCount: sortByCount, maxRowCount: maxRowCount, onFilterSelection: (filter) => _onFilterSelection(context, filter), ), ]; } void _onFilterSelection(BuildContext context, CollectionFilter filter) { if (parentCollection != null) { _applyToParentCollectionPage(context, filter); } else { _jumpToCollectionPage(context, filter); } } void _applyToParentCollectionPage(BuildContext context, CollectionFilter filter) { 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`. // Do not use `WidgetsBinding.instance.addPostFrameCallback`, // as it may not trigger if there is no subsequent build. Future.delayed(const Duration(milliseconds: 100), () => Navigator.pop(context)); } void _jumpToCollectionPage(BuildContext context, CollectionFilter filter) { Navigator.pushAndRemoveUntil( context, MaterialPageRoute( settings: const RouteSettings(name: CollectionPage.routeName), builder: (context) => CollectionPage( source: source, filters: {filter}, ), ), (route) => false, ); } } @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, }); }