import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/location.dart'; import 'package:aves/model/entry/extensions/props.dart'; import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/rating.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/ref/locales.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/format.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/view/src/metadata/exportable_fields.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/identity/empty.dart'; import 'package:aves/widgets/dialogs/export_collection_stats_page.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:aves/widgets/stats/top_page.dart'; import 'package:aves/widgets/viewer/controls/notifications.dart'; import 'package:aves_model/aves_model.dart'; import 'package:collection/collection.dart'; import 'package:csv/csv.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 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; int _totalDurationMillis = 0; Set get entries => widget.entries; @override void initState() { super.initState(); _isPageAnimatingNotifier = ValueNotifier(true); Future.delayed(ADurations.pageTransitionLoose * timeDilation).then((_) { if (!mounted) return; _isPageAnimatingNotifier.value = false; }); entries.forEach((entry) { _totalSizeBytes += entry.sizeBytes ?? 0; _totalDurationMillis += entry.durationMillis ?? 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; 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), _buildTotalSizeIndicator(context), if (_totalDurationMillis > 0) ...[ const SizedBox(height: 16), _buildTotalDurationIndicator(), ], 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) => StoredAlbumFilter(v, source.getStoredAlbumDisplayName(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), actions: [ IconButton( icon: Icon(AIcons.fileExport), onPressed: () => _export(context), tooltip: context.l10n.settingsActionExport, ), ], ), body: GestureAreaProtectorStack( child: SafeArea( bottom: false, child: TooltipTheme( data: TooltipTheme.of(context).copyWith( preferBelow: false, ), child: child, ), ), ), ); }, ); } Widget _buildTotalSizeIndicator(BuildContext context) { return 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)), ), ], ), ); } Widget _buildTotalDurationIndicator() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(AIcons.videoPlay), const SizedBox(width: 16), Expanded( child: Text(formatFriendlyDuration(Duration(milliseconds: _totalDurationMillis))), ), ], ), ); } 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, totalEntryCount: totalEntryCount, entryCountMap: entryCountMap, filterBuilder: filterBuilder, sortByCount: sortByCount, 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!.addFilters({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, ); } Future _export(BuildContext context) async { final sample = entries.first; final locale = context.locale; String previewer(ExportableEntryField field) => _exportEntryField(field, sample, locale)?.toString() ?? ''; final options = await Navigator.maybeOf(context)?.push<(String, Set, ExportTarget)>( MaterialPageRoute( settings: const RouteSettings(name: ExportCollectionStatsPage.routeName), builder: (context) => ExportCollectionStatsPage(previewer: previewer), ), ); if (options == null) return; final (mimeType, fieldSet, target) = options; final index = ExportableEntryField.values.indexOf; final fieldList = fieldSet.sorted((a, b) => index(a).compareTo(index(b))); String body = ''; switch (mimeType) { case MimeTypes.csv: body = _exportToCsv(fieldList, context); case MimeTypes.json: body = _exportToJson(fieldList); } final bool? success; switch (target) { case ExportTarget.clipboard: success = await appService.copyToClipboard(text: body); case ExportTarget.file: success = await storageService.createFile( 'aves-stats-${DateFormat('yyyyMMdd_HHmmss', asciiLocale).format(DateTime.now())}${MimeTypes.extensionFor(mimeType)}', mimeType, Uint8List.fromList(utf8.encode(body)), ); } if (success != null) { if (success) { showFeedback(context, FeedbackType.info, context.l10n.genericSuccessFeedback); } else { showFeedback(context, FeedbackType.warn, context.l10n.genericFailureFeedback); } } } static Object? _exportEntryField(ExportableEntryField field, AvesEntry entry, String locale) { switch (field) { case ExportableEntryField.uri: return entry.uri; case ExportableEntryField.path: return entry.path; case ExportableEntryField.title: return entry.bestTitle; case ExportableEntryField.date: return entry.bestDate?.toIso8601String(); case ExportableEntryField.size: return entry.sizeBytes; case ExportableEntryField.resolution: return entry.getResolutionText(locale); case ExportableEntryField.width: return entry.displaySize.width.toInt(); case ExportableEntryField.height: return entry.displaySize.height.toInt(); case ExportableEntryField.duration: final durationMillis = entry.durationMillis ?? 0; return durationMillis > 0 ? durationMillis : null; case ExportableEntryField.coordinates: final latLng = entry.latLng; return latLng != null ? '${latLng.latitude},${latLng.longitude}' : null; case ExportableEntryField.address: final shortAddress = entry.shortAddress; return shortAddress.isNotEmpty ? shortAddress : null; case ExportableEntryField.tags: return entry.tags.join(';'); } } String _exportToCsv(List fields, BuildContext context) { final locale = context.locale; final headers = fields.map((v) => v.getText(context)).toList(); List toCsvValues(AvesEntry entry) => fields.map((field) { return _exportEntryField(field, entry, locale)?.toString() ?? ''; }).toList(); return const ListToCsvConverter().convert( [headers, ...entries.map(toCsvValues)], ); } String _exportToJson(List fields) { final locale = context.locale; Map toJsonMap(AvesEntry entry) => Map.fromEntries( fields.map((field) => MapEntry(field.name, _exportEntryField(field, entry, locale))), ); return jsonEncode(entries.map(toJsonMap).toList()); } } 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, ), ], ), ); } }