From 358cf901edede36257f5c19a29e63ae83095a588 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 20 Jun 2022 18:41:52 +0900 Subject: [PATCH] stats: histogram and date filters; search: on this day filter --- CHANGELOG.md | 5 + lib/l10n/app_en.arb | 1 + lib/model/filters/date.dart | 126 +++++++++------- lib/model/filters/filters.dart | 4 + lib/widgets/search/search_delegate.dart | 2 + lib/widgets/stats/histogram.dart | 184 ++++++++++++++++++++++++ lib/widgets/stats/stats_page.dart | 5 + test/model/filters_test.dart | 7 + untranslated.json | 35 +++++ 9 files changed, 316 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac8faa429..58f2bdb27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project will be documented in this file. +### Added + +- Search: `on this day` filter +- Stats: histogram and date filters + ## [Unreleased] ## [v1.6.9] - 2022-06-18 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5657ade65..cb9cc6b69 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -122,6 +122,7 @@ "filterFavouriteLabel": "Favorite", "filterLocationEmptyLabel": "Unlocated", "filterTagEmptyLabel": "Untagged", + "filterOnThisDayLabel": "On this day", "filterRatingUnratedLabel": "Unrated", "filterRatingRejectedLabel": "Rejected", "filterTypeAnimatedLabel": "Animated", diff --git a/lib/model/filters/date.dart b/lib/model/filters/date.dart index 17a94a947..42b61bcb1 100644 --- a/lib/model/filters/date.dart +++ b/lib/model/filters/date.dart @@ -1,91 +1,111 @@ -import 'package:aves/model/device.dart'; import 'package:aves/model/filters/filters.dart'; +import 'package:aves/theme/format.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/time_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; -class LocationFilter extends CoveredCollectionFilter { - static const type = 'location'; - static const locationSeparator = ';'; +class DateFilter extends CoveredCollectionFilter { + static const type = 'date'; - final LocationLevel level; - late final String _location; - late final String? _countryCode; + final DateLevel level; + late final DateTime? date; + late final DateTime _effectiveDate; late final EntryFilter _test; + static final onThisDay = DateFilter(DateLevel.md, null); + @override - List get props => [level, _location, _countryCode]; + List get props => [level, date]; - LocationFilter(this.level, String location) { - final split = location.split(locationSeparator); - _location = split.isNotEmpty ? split[0] : location; - _countryCode = split.length > 1 ? split[1] : null; - - if (_location.isEmpty) { - _test = (entry) => !entry.hasGps; - } else if (level == LocationLevel.country) { - _test = (entry) => entry.addressDetails?.countryCode == _countryCode; - } else if (level == LocationLevel.place) { - _test = (entry) => entry.addressDetails?.place == _location; + DateFilter(this.level, this.date) { + _effectiveDate = date ?? DateTime.now(); + switch (level) { + case DateLevel.y: + _test = (entry) => entry.bestDate?.isAtSameYearAs(_effectiveDate) ?? false; + break; + case DateLevel.ym: + _test = (entry) => entry.bestDate?.isAtSameMonthAs(_effectiveDate) ?? false; + break; + case DateLevel.ymd: + _test = (entry) => entry.bestDate?.isAtSameDayAs(_effectiveDate) ?? false; + break; + case DateLevel.md: + final month = _effectiveDate.month; + final day = _effectiveDate.day; + _test = (entry) { + final bestDate = entry.bestDate; + return bestDate != null && bestDate.month == month && bestDate.day == day; + }; + break; + case DateLevel.m: + final month = _effectiveDate.month; + _test = (entry) => entry.bestDate?.month == month; + break; + case DateLevel.d: + final day = _effectiveDate.day; + _test = (entry) => entry.bestDate?.day == day; + break; } } - LocationFilter.fromMap(Map json) - : this( - LocationLevel.values.firstWhereOrNull((v) => v.toString() == json['level']) ?? LocationLevel.place, - json['location'], - ); + factory DateFilter.fromMap(Map json) { + final dateString = json['date'] as String?; + return DateFilter( + DateLevel.values.firstWhereOrNull((v) => v.toString() == json['level']) ?? DateLevel.ymd, + dateString != null ? DateTime.tryParse(dateString) : null, + ); + } @override Map toMap() => { 'type': type, 'level': level.toString(), - 'location': _countryCode != null ? countryNameAndCode : _location, + 'date': date?.toIso8601String(), }; - String get countryNameAndCode => '$_location$locationSeparator$_countryCode'; - - String? get countryCode => _countryCode; - @override EntryFilter get test => _test; @override - String get universalLabel => _location; + String get universalLabel => _effectiveDate.toIso8601String(); @override - String getLabel(BuildContext context) => _location.isEmpty ? context.l10n.filterLocationEmptyLabel : _location; + String getLabel(BuildContext context) { + final l10n = context.l10n; + final locale = l10n.localeName; + switch (level) { + case DateLevel.y: + return DateFormat.y(locale).format(_effectiveDate); + case DateLevel.ym: + return DateFormat.yMMM(locale).format(_effectiveDate); + case DateLevel.ymd: + return formatDay(_effectiveDate, locale); + case DateLevel.md: + if (date != null) { + return DateFormat.MMMd(locale).format(_effectiveDate); + } else { + return l10n.filterOnThisDayLabel; + } + case DateLevel.m: + return DateFormat.MMMM(locale).format(_effectiveDate); + case DateLevel.d: + return DateFormat.d(locale).format(_effectiveDate); + } + } @override Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) { - if (_countryCode != null && device.canRenderFlagEmojis) { - final flag = countryCodeToFlag(_countryCode); - if (flag != null) { - return Text( - flag, - style: TextStyle(fontSize: size), - textScaleFactor: 1.0, - ); - } - } - return Icon(_location.isEmpty ? AIcons.locationUnlocated : AIcons.location, size: size); + return Icon(AIcons.date, size: size); } @override String get category => type; @override - String get key => '$type-$level-$_location'; - - // U+0041 Latin Capital letter A - // U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A - static const _countryCodeToFlagDiff = 0x1F1E6 - 0x0041; - - static String? countryCodeToFlag(String? code) { - if (code == null || code.length != 2) return null; - return String.fromCharCodes(code.toUpperCase().codeUnits.map((letter) => letter += _countryCodeToFlagDiff)); - } + String get key => '$type-$level-$date'; } -enum LocationLevel { place, country } +enum DateLevel { y, ym, ymd, md, m, d } diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index eccbfb3f7..813eede4d 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -4,6 +4,7 @@ import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/coordinate.dart'; +import 'package:aves/model/filters/date.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/mime.dart'; @@ -28,6 +29,7 @@ abstract class CollectionFilter extends Equatable implements Comparable entries; + final FilterCallback onFilterSelection; + + const Histogram({ + super.key, + required this.entries, + required this.onFilterSelection, + }); + + @override + State createState() => _HistogramState(); +} + +class _HistogramState extends State { + DateLevel _level = DateLevel.y; + final Map _entryCountPerDate = {}; + final ValueNotifier _selection = ValueNotifier(null); + + static const histogramHeight = 200.0; + + @override + void initState() { + super.initState(); + + final entries = widget.entries; + final firstDate = entries.firstWhereOrNull((entry) => entry.bestDate != null)?.bestDate; + final lastDate = entries.lastWhereOrNull((entry) => entry.bestDate != null)?.bestDate; + + if (lastDate != null && firstDate != null) { + final range = firstDate.difference(lastDate); + if (range > const Duration(days: 1)) { + if (range < const Duration(days: 30)) { + _level = DateLevel.ymd; + } else if (range < const Duration(days: 365)) { + _level = DateLevel.ym; + } + + final dates = entries.map((entry) => entry.bestDate).whereNotNull(); + late DateTime Function(DateTime) groupByKey; + switch (_level) { + case DateLevel.ymd: + groupByKey = (v) => DateTime(v.year, v.month, v.day); + break; + case DateLevel.ym: + groupByKey = (v) => DateTime(v.year, v.month); + break; + default: + groupByKey = (v) => DateTime(v.year); + break; + } + _entryCountPerDate.addAll(groupBy(dates, groupByKey).map((k, v) => MapEntry(k, v.length))); + } + } + } + + @override + Widget build(BuildContext context) { + if (_entryCountPerDate.isEmpty) return const SizedBox(); + + final theme = Theme.of(context); + + final seriesData = _entryCountPerDate.entries.map((kv) { + return EntryByDate(date: kv.key, entryCount: kv.value); + }).toList(); + + final series = [ + charts.Series( + id: 'histogram', + colorFn: (d, i) => charts.ColorUtil.fromDartColor(theme.colorScheme.secondary), + domainFn: (d, i) => d.date, + measureFn: (d, i) => d.entryCount, + data: seriesData, + ), + ]; + + final axisColor = charts.ColorUtil.fromDartColor(theme.colorScheme.onPrimary.withOpacity(.9)); + final measureLineColor = charts.ColorUtil.fromDartColor(theme.colorScheme.onPrimary.withOpacity(.1)); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + height: histogramHeight, + child: charts.TimeSeriesChart( + series, + domainAxis: charts.DateTimeAxisSpec( + renderSpec: charts.SmallTickRendererSpec( + labelStyle: charts.TextStyleSpec(color: axisColor), + lineStyle: charts.LineStyleSpec(color: axisColor), + ), + ), + primaryMeasureAxis: charts.NumericAxisSpec( + renderSpec: charts.GridlineRendererSpec( + labelStyle: charts.TextStyleSpec(color: axisColor), + lineStyle: charts.LineStyleSpec(color: measureLineColor), + ), + ), + defaultRenderer: charts.BarRendererConfig(), + defaultInteractions: false, + behaviors: [ + charts.SelectNearest(), + charts.DomainHighlighter(), + ], + selectionModels: [ + charts.SelectionModelConfig( + changedListener: (model) => _selection.value = model.selectedDatum.firstOrNull?.datum as EntryByDate?, + ) + ], + ), + ), + ValueListenableBuilder( + valueListenable: _selection, + builder: (context, selection, child) { + late Widget child; + if (selection == null) { + child = const SizedBox(); + } else { + final filter = DateFilter(_level, selection.date); + final count = selection.entryCount; + child = Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + AvesFilterChip( + filter: filter, + onTap: widget.onFilterSelection, + ), + const Spacer(), + Text( + '$count', + style: TextStyle( + color: theme.textTheme.caption!.color, + ), + textAlign: TextAlign.end, + ), + ], + ), + ); + } + + return AnimatedSwitcher( + duration: context.read().formTransition, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axisAlignment: -1, + child: child, + ), + ), + child: child, + ); + }, + ), + ], + ); + } +} + +@immutable +class EntryByDate extends Equatable { + final DateTime date; + final int entryCount; + + @override + List get props => [date, entryCount]; + + const EntryByDate({ + required this.date, + required this.entryCount, + }); +} diff --git a/lib/widgets/stats/stats_page.dart b/lib/widgets/stats/stats_page.dart index b6955616c..1f538c952 100644 --- a/lib/widgets/stats/stats_page.dart +++ b/lib/widgets/stats/stats_page.dart @@ -20,6 +20,7 @@ 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'; @@ -148,6 +149,10 @@ class StatsPage extends StatelessWidget { child = ListView( children: [ mimeDonuts, + Histogram( + entries: entries, + onFilterSelection: (filter) => _onFilterSelection(context, filter), + ), locationIndicator, ..._buildFilterSection(context, context.l10n.statsTopCountries, entryCountPerCountry, (v) => LocationFilter(LocationLevel.country, v)), ..._buildFilterSection(context, context.l10n.statsTopPlaces, entryCountPerPlace, (v) => LocationFilter(LocationLevel.place, v)), diff --git a/test/model/filters_test.dart b/test/model/filters_test.dart index b8eaf5ce5..d1107457a 100644 --- a/test/model/filters_test.dart +++ b/test/model/filters_test.dart @@ -1,5 +1,6 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/coordinate.dart'; +import 'package:aves/model/filters/date.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; @@ -36,6 +37,12 @@ void main() { final bounds = CoordinateFilter(LatLng(29.979167, 28.223615), LatLng(36.451000, 31.134167)); expect(bounds, jsonRoundTrip(bounds)); + final date = DateFilter(DateLevel.ym, DateTime(1969, 7)); + expect(date, jsonRoundTrip(date)); + + final onThisDay = DateFilter.onThisDay; + expect(onThisDay, jsonRoundTrip(onThisDay)); + const fav = FavouriteFilter.instance; expect(fav, jsonRoundTrip(fav)); diff --git a/untranslated.json b/untranslated.json index 32c3446a3..c1c46b185 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,7 +1,28 @@ { + "de": [ + "filterOnThisDayLabel" + ], + + "es": [ + "filterOnThisDayLabel" + ], + + "fr": [ + "filterOnThisDayLabel" + ], + + "id": [ + "filterOnThisDayLabel" + ], + + "it": [ + "filterOnThisDayLabel" + ], + "ja": [ "slideshowActionResume", "slideshowActionShowInCollection", + "filterOnThisDayLabel", "slideshowVideoPlaybackSkip", "slideshowVideoPlaybackMuted", "slideshowVideoPlaybackWithSound", @@ -28,9 +49,18 @@ "viewerSetWallpaperButtonLabel" ], + "ko": [ + "filterOnThisDayLabel" + ], + + "pt": [ + "filterOnThisDayLabel" + ], + "ru": [ "slideshowActionResume", "slideshowActionShowInCollection", + "filterOnThisDayLabel", "slideshowVideoPlaybackSkip", "slideshowVideoPlaybackMuted", "slideshowVideoPlaybackWithSound", @@ -60,6 +90,7 @@ "tr": [ "slideshowActionResume", "slideshowActionShowInCollection", + "filterOnThisDayLabel", "slideshowVideoPlaybackSkip", "slideshowVideoPlaybackMuted", "slideshowVideoPlaybackWithSound", @@ -82,5 +113,9 @@ "settingsSlideshowVideoPlaybackTile", "settingsSlideshowVideoPlaybackTitle", "viewerSetWallpaperButtonLabel" + ], + + "zh": [ + "filterOnThisDayLabel" ] }