stats: histogram and date filters; search: on this day filter
This commit is contained in:
parent
a3c354af0c
commit
358cf901ed
9 changed files with 316 additions and 53 deletions
|
@ -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
|
||||
|
|
|
@ -122,6 +122,7 @@
|
|||
"filterFavouriteLabel": "Favorite",
|
||||
"filterLocationEmptyLabel": "Unlocated",
|
||||
"filterTagEmptyLabel": "Untagged",
|
||||
"filterOnThisDayLabel": "On this day",
|
||||
"filterRatingUnratedLabel": "Unrated",
|
||||
"filterRatingRejectedLabel": "Rejected",
|
||||
"filterTypeAnimatedLabel": "Animated",
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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)),
|
||||
|
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue