import 'dart:async'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.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/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/theme/styles.dart'; import 'package:aves/theme/themes.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/vault_aware.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/basic/scaffold.dart'; import 'package:aves/widgets/common/basic/tv_edge_focus.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart'; import 'package:aves/widgets/stats/date/histogram.dart'; import 'package:aves/widgets/stats/filter_table.dart'; import 'package:aves/widgets/stats/mime_donut.dart'; import 'package:aves/widgets/stats/percent_text.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:percent_indicator/linear_percent_indicator.dart'; import 'package:provider/provider.dart'; class StatsPage extends StatefulWidget { static const routeName = '/collection/stats'; final Set entries; final CollectionSource source; final CollectionLens? parentCollection; const StatsPage({ super.key, required this.entries, required this.source, this.parentCollection, }); @override State createState() => _StatsPageState(); } class _StatsPageState extends State with FeedbackMixin, VaultAwareMixin { final Map _entryCountPerCountry = {}, _entryCountPerState = {}, _entryCountPerPlace = {}; final Map _entryCountPerTag = {}, _entryCountPerAlbum = {}; final Map _entryCountPerRating = Map.fromEntries(List.generate(7, (i) => MapEntry(5 - i, 0))); late final ValueNotifier _isPageAnimatingNotifier; int _totalSizeBytes = 0; Set get entries => widget.entries; @override void initState() { super.initState(); _isPageAnimatingNotifier = ValueNotifier(true); Future.delayed(ADurations.pageTransitionAnimation * timeDilation).then((_) { if (!mounted) return; _isPageAnimatingNotifier.value = false; }); entries.forEach((entry) { _totalSizeBytes += entry.sizeBytes ?? 0; 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; } var state = address.stateName; if (state != null && state.isNotEmpty) { state += '${LocationFilter.locationSeparator}${address.stateCode}'; _entryCountPerState[state] = (_entryCountPerState[state] ?? 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 album = entry.directory; if (album != null) { _entryCountPerAlbum[album] = (_entryCountPerAlbum[album] ?? 0) + 1; } final rating = entry.rating; _entryCountPerRating[rating] = (_entryCountPerRating[rating] ?? 0) + 1; }); } @override void dispose() { _isPageAnimatingNotifier.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final useTvLayout = settings.useTvLayout; return ValueListenableBuilder( valueListenable: _isPageAnimatingNotifier, builder: (context, animating, child) { final l10n = context.l10n; Widget child = const SizedBox(); if (!animating) { final durations = context.watch(); if (entries.isEmpty) { child = EmptyContent( icon: AIcons.image, text: l10n.collectionEmptyImages, ); } else { 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: [ 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 showRatings = _entryCountPerRating.entries.any((kv) => kv.key != 0 && kv.value > 0); final source = widget.source; final sizeIndicator = Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(AIcons.size), const SizedBox(width: 16), Expanded( child: Text(formatFileSize(context.locale, _totalSizeBytes)), ), ], ), ); child = NotificationListener( onNotification: (notification) { _onFilterSelection(context, notification.filter); return true; }, 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: [ const TvEdgeFocus(), mimeDonuts, Histogram( entries: entries, animationDuration: chartAnimationDuration, onFilterSelection: (filter) => _onFilterSelection(context, filter), ), const SizedBox(height: 16), sizeIndicator, const SizedBox(height: 16), _LocationIndicator(entries: entries), ..._buildFilterSection(context, l10n.statsTopCountriesSectionTitle, _entryCountPerCountry, (v) => LocationFilter(LocationLevel.country, v)), ..._buildFilterSection(context, l10n.statsTopStatesSectionTitle, _entryCountPerState, (v) => LocationFilter(LocationLevel.state, v)), ..._buildFilterSection(context, l10n.statsTopPlacesSectionTitle, _entryCountPerPlace, (v) => LocationFilter(LocationLevel.place, v)), ..._buildFilterSection(context, l10n.statsTopTagsSectionTitle, _entryCountPerTag, TagFilter.new), ..._buildFilterSection(context, l10n.statsTopAlbumsSectionTitle, _entryCountPerAlbum, (v) => AlbumFilter(v, source.getAlbumDisplayName(context, v))), if (showRatings) ..._buildFilterSection(context, l10n.searchRatingSectionTitle, _entryCountPerRating, RatingFilter.new, sortByCount: false, maxRowCount: null), ], ), ), ), ); } } return AvesScaffold( appBar: AppBar( automaticallyImplyLeading: !useTvLayout, title: Text(l10n.statsPageTitle), ), body: GestureAreaProtectorStack( child: SafeArea( bottom: false, child: TooltipTheme( data: TooltipTheme.of(context).copyWith( preferBelow: false, ), child: child, ), ), ), ); }, ); } List _buildFilterSection( BuildContext context, String title, Map entryCountMap, CollectionFilter Function(T key) filterBuilder, { bool sortByCount = true, int? maxRowCount = 3, }) { if (entryCountMap.isEmpty) return []; final totalEntryCount = entries.length; final hasMore = maxRowCount != null && entryCountMap.length > maxRowCount; final onHeaderPressed = hasMore ? () => Navigator.maybeOf(context)?.push( MaterialPageRoute( settings: const RouteSettings(name: StatsTopPage.routeName), builder: (context) => StatsTopPage( title: title, tableBuilder: (context) => FilterTable( totalEntryCount: totalEntryCount, entryCountMap: entryCountMap, filterBuilder: filterBuilder, sortByCount: sortByCount, maxRowCount: null, onFilterSelection: (filter) => _onFilterSelection(context, filter), ), onFilterSelection: (filter) => _onFilterSelection(context, filter), ), ), ) : null; Widget header = Text( title, style: AStyles.knownTitleText, ); if (settings.useTvLayout) { header = Padding( padding: const EdgeInsets.all(16), child: Row( mainAxisSize: MainAxisSize.min, children: [ header, const SizedBox(width: 16), Icon(AIcons.next, color: hasMore ? null : Theme.of(context).disabledColor), ], ), ); header = Container( padding: const EdgeInsets.symmetric(vertical: 12), alignment: AlignmentDirectional.centerStart, // prevent ink response when tapping the header does nothing, // because otherwise Play Store reviewers think it is broken navigation child: onHeaderPressed != null ? InkWell( onTap: onHeaderPressed, borderRadius: const BorderRadius.all(Radius.circular(123)), child: header, ) : Focus(child: header), ); } else { header = Padding( padding: const EdgeInsets.all(16), child: Row( children: [ header, const Spacer(), IconButton( icon: const Icon(AIcons.next), onPressed: onHeaderPressed, tooltip: MaterialLocalizations.of(context).moreButtonTooltip, ), ], ), ); } return [ header, FilterTable( totalEntryCount: totalEntryCount, entryCountMap: entryCountMap, filterBuilder: filterBuilder, sortByCount: sortByCount, maxRowCount: maxRowCount, onFilterSelection: (filter) => _onFilterSelection(context, filter), ), ]; } Future _onFilterSelection(BuildContext context, CollectionFilter filter) async { if (!await unlockFilter(context, filter)) return; if (widget.parentCollection != null) { _applyToParentCollectionPage(context, filter); } else { _jumpToCollectionPage(context, filter); } } void _applyToParentCollectionPage(BuildContext context, CollectionFilter 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`. // Do not use `WidgetsBinding.instance.addPostFrameCallback`, // as it may not trigger if there is no subsequent build. Future.delayed(const Duration(milliseconds: 100), () => Navigator.maybeOf(context)?.popUntil((route) => route.settings.name == CollectionPage.routeName)); } void _jumpToCollectionPage(BuildContext context, CollectionFilter filter) { Navigator.maybeOf(context)?.pushAndRemoveUntil( MaterialPageRoute( settings: const RouteSettings(name: CollectionPage.routeName), builder: (context) => CollectionPage( source: widget.source, filters: {filter}, ), ), (route) => false, ); } } class StatsTopPage extends StatelessWidget { static const routeName = '/collection/stats/top'; final String title; final WidgetBuilder tableBuilder; final AFilterCallback onFilterSelection; const StatsTopPage({ super.key, required this.title, required this.tableBuilder, required this.onFilterSelection, }); @override Widget build(BuildContext context) { return AvesScaffold( appBar: AppBar( automaticallyImplyLeading: !settings.useTvLayout, title: Text(title), ), body: GestureAreaProtectorStack( child: SafeArea( bottom: false, child: Builder(builder: (context) { return NotificationListener( onNotification: (notification) { onFilterSelection(notification.filter); return true; }, child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 8) + EdgeInsets.only( bottom: context.select((mq) => mq.effectiveBottomPadding), ), child: tableBuilder(context), ), ); }), ), ), ); } } class _LocationIndicator extends StatelessWidget { final Set entries; const _LocationIndicator({required this.entries}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final catalogued = entries.where((entry) => entry.isCatalogued); final withGps = catalogued.where((entry) => entry.hasGps); final withGpsCount = withGps.length; final withGpsPercent = withGpsCount / entries.length; final textScaler = MediaQuery.textScalerOf(context); final lineHeight = textScaler.scale(16); final barRadius = Radius.circular(lineHeight / 2); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(AIcons.location), Expanded( child: Stack( // use a stack instead of `center` field, so that the widgets // are centered even when the center child has larger height alignment: Alignment.center, children: [ LinearPercentIndicator( percent: withGpsPercent, lineHeight: lineHeight, backgroundColor: Themes.secondLayerColor(context), progressColor: theme.colorScheme.primary, animation: context.select((v) => v.animate), isRTL: context.isRtl, barRadius: barRadius, padding: EdgeInsets.symmetric(horizontal: lineHeight), ), LinearPercentIndicatorText(percent: withGpsPercent), ], ), ), // end padding to match leading, so that inside label is aligned with outside label below const SizedBox(width: 24), ], ), const SizedBox(height: 8), Text( context.l10n.statsWithGps(withGpsCount), textAlign: TextAlign.center, ), ], ), ); } }