stats: histogram and date filters; search: on this day filter

This commit is contained in:
Thibault Deckers 2022-06-20 18:41:52 +09:00
parent a3c354af0c
commit 358cf901ed
9 changed files with 316 additions and 53 deletions

View file

@ -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
## <a id="unreleased"></a>[Unreleased]
## <a id="v1.6.9"></a>[v1.6.9] - 2022-06-18

View file

@ -122,6 +122,7 @@
"filterFavouriteLabel": "Favorite",
"filterLocationEmptyLabel": "Unlocated",
"filterTagEmptyLabel": "Untagged",
"filterOnThisDayLabel": "On this day",
"filterRatingUnratedLabel": "Unrated",
"filterRatingRejectedLabel": "Rejected",
"filterTypeAnimatedLabel": "Animated",

View file

@ -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<Object?> get props => [level, _location, _countryCode];
List<Object?> 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<String, dynamic> json)
: this(
LocationLevel.values.firstWhereOrNull((v) => v.toString() == json['level']) ?? LocationLevel.place,
json['location'],
factory DateFilter.fromMap(Map<String, dynamic> 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<String, dynamic> 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 }

View file

@ -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<Collecti
MimeFilter.type,
AlbumFilter.type,
TypeFilter.type,
DateFilter.type,
LocationFilter.type,
CoordinateFilter.type,
FavouriteFilter.type,
@ -52,6 +54,8 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
return AlbumFilter.fromMap(jsonMap);
case CoordinateFilter.type:
return CoordinateFilter.fromMap(jsonMap);
case DateFilter.type:
return DateFilter.fromMap(jsonMap);
case FavouriteFilter.type:
return FavouriteFilter.instance;
case LocationFilter.type:

View file

@ -1,4 +1,5 @@
import 'package:aves/model/filters/album.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';
@ -41,6 +42,7 @@ class CollectionSearchDelegate extends AvesSearchDelegate {
TypeFilter.geotiff,
TypeFilter.raw,
MimeFilter(MimeTypes.svg),
DateFilter.onThisDay,
];
CollectionSearchDelegate({

View file

@ -0,0 +1,184 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/date.dart';
import 'package:aves/theme/durations.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:provider/provider.dart';
class Histogram extends StatefulWidget {
final Set<AvesEntry> entries;
final FilterCallback onFilterSelection;
const Histogram({
super.key,
required this.entries,
required this.onFilterSelection,
});
@override
State<Histogram> createState() => _HistogramState();
}
class _HistogramState extends State<Histogram> {
DateLevel _level = DateLevel.y;
final Map<DateTime, int> _entryCountPerDate = {};
final ValueNotifier<EntryByDate?> _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<DateTime, DateTime>(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<EntryByDate, DateTime>(
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<DateTime>(),
defaultInteractions: false,
behaviors: [
charts.SelectNearest(),
charts.DomainHighlighter(),
],
selectionModels: [
charts.SelectionModelConfig(
changedListener: (model) => _selection.value = model.selectedDatum.firstOrNull?.datum as EntryByDate?,
)
],
),
),
ValueListenableBuilder<EntryByDate?>(
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<DurationsData>().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<Object?> get props => [date, entryCount];
const EntryByDate({
required this.date,
required this.entryCount,
});
}

View file

@ -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<String>(context, context.l10n.statsTopCountries, entryCountPerCountry, (v) => LocationFilter(LocationLevel.country, v)),
..._buildFilterSection<String>(context, context.l10n.statsTopPlaces, entryCountPerPlace, (v) => LocationFilter(LocationLevel.place, v)),

View file

@ -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));

View file

@ -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"
]
}