reverse filters to filter out/in

This commit is contained in:
Thibault Deckers 2022-09-27 19:15:20 +02:00
parent 92ba7b9e9f
commit 9ba9ec302e
34 changed files with 463 additions and 244 deletions

View file

@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- mosaic layout - mosaic layout
- reverse filters to filter out/in
- Albums: group by content type - Albums: group by content type
- Stats: top albums - Stats: top albums
- Stats: open full top listings - Stats: open full top listings

View file

@ -69,6 +69,8 @@
"chipActionGoToAlbumPage": "Show in Albums", "chipActionGoToAlbumPage": "Show in Albums",
"chipActionGoToCountryPage": "Show in Countries", "chipActionGoToCountryPage": "Show in Countries",
"chipActionGoToTagPage": "Show in Tags", "chipActionGoToTagPage": "Show in Tags",
"chipActionFilterOut": "Filter out",
"chipActionFilterIn": "Filter in",
"chipActionHide": "Hide", "chipActionHide": "Hide",
"chipActionPin": "Pin to top", "chipActionPin": "Pin to top",
"chipActionUnpin": "Unpin from top", "chipActionUnpin": "Unpin from top",

View file

@ -41,6 +41,8 @@
"chipActionGoToAlbumPage": "Afficher dans Albums", "chipActionGoToAlbumPage": "Afficher dans Albums",
"chipActionGoToCountryPage": "Afficher dans Pays", "chipActionGoToCountryPage": "Afficher dans Pays",
"chipActionGoToTagPage": "Afficher dans Libellés", "chipActionGoToTagPage": "Afficher dans Libellés",
"chipActionFilterOut": "Exclure",
"chipActionFilterIn": "Inclure",
"chipActionHide": "Masquer", "chipActionHide": "Masquer",
"chipActionPin": "Épingler", "chipActionPin": "Épingler",
"chipActionUnpin": "Retirer", "chipActionUnpin": "Retirer",
@ -173,6 +175,9 @@
"wallpaperTargetLock": "Écran de verrouillage", "wallpaperTargetLock": "Écran de verrouillage",
"wallpaperTargetHomeLock": "Écrans accueil et verrouillage", "wallpaperTargetHomeLock": "Écrans accueil et verrouillage",
"widgetOpenPageHome": "Ouvrir la page daccueil",
"widgetOpenPageViewer": "Ouvrir la visionneuse",
"albumTierNew": "Nouveaux", "albumTierNew": "Nouveaux",
"albumTierPinned": "Épinglés", "albumTierPinned": "Épinglés",
"albumTierSpecial": "Standards", "albumTierSpecial": "Standards",
@ -403,9 +408,12 @@
"sortOrderSmallestFirst": "Moins larges dabord", "sortOrderSmallestFirst": "Moins larges dabord",
"albumGroupTier": "par importance", "albumGroupTier": "par importance",
"albumGroupType": "par type",
"albumGroupVolume": "par volume de stockage", "albumGroupVolume": "par volume de stockage",
"albumGroupNone": "ne pas grouper", "albumGroupNone": "ne pas grouper",
"albumMimeTypeMixed": "Mixte",
"albumPickPageTitleCopy": "Copie", "albumPickPageTitleCopy": "Copie",
"albumPickPageTitleExport": "Export", "albumPickPageTitleExport": "Export",
"albumPickPageTitleMove": "Déplacement", "albumPickPageTitleMove": "Déplacement",
@ -614,6 +622,7 @@
"settingsWidgetPageTitle": "Cadre photo", "settingsWidgetPageTitle": "Cadre photo",
"settingsWidgetShowOutline": "Contours", "settingsWidgetShowOutline": "Contours",
"settingsWidgetOpenPage": "Quand vous appuyez sur le widget",
"settingsCollectionTile": "Collection", "settingsCollectionTile": "Collection",
@ -622,6 +631,7 @@
"statsTopCountriesSectionTitle": "Top pays", "statsTopCountriesSectionTitle": "Top pays",
"statsTopPlacesSectionTitle": "Top lieux", "statsTopPlacesSectionTitle": "Top lieux",
"statsTopTagsSectionTitle": "Top libellés", "statsTopTagsSectionTitle": "Top libellés",
"statsTopAlbumsSectionTitle": "Top albums",
"viewerOpenPanoramaButtonLabel": "OUVRIR LE PANORAMA", "viewerOpenPanoramaButtonLabel": "OUVRIR LE PANORAMA",
"viewerSetWallpaperButtonLabel": "APPLIQUER", "viewerSetWallpaperButtonLabel": "APPLIQUER",

View file

@ -41,6 +41,8 @@
"chipActionGoToAlbumPage": "앨범 페이지에서 보기", "chipActionGoToAlbumPage": "앨범 페이지에서 보기",
"chipActionGoToCountryPage": "국가 페이지에서 보기", "chipActionGoToCountryPage": "국가 페이지에서 보기",
"chipActionGoToTagPage": "태그 페이지에서 보기", "chipActionGoToTagPage": "태그 페이지에서 보기",
"chipActionFilterOut": "제외하기",
"chipActionFilterIn": "포함시키기",
"chipActionHide": "숨기기", "chipActionHide": "숨기기",
"chipActionPin": "고정", "chipActionPin": "고정",
"chipActionUnpin": "고정 해제", "chipActionUnpin": "고정 해제",
@ -173,6 +175,9 @@
"wallpaperTargetLock": "잠금화면", "wallpaperTargetLock": "잠금화면",
"wallpaperTargetHomeLock": "홈 및 잠금화면", "wallpaperTargetHomeLock": "홈 및 잠금화면",
"widgetOpenPageHome": "홈 열기",
"widgetOpenPageViewer": "뷰어 열기",
"albumTierNew": "신규", "albumTierNew": "신규",
"albumTierPinned": "고정", "albumTierPinned": "고정",
"albumTierSpecial": "기본", "albumTierSpecial": "기본",
@ -403,9 +408,12 @@
"sortOrderSmallestFirst": "작은 파일순", "sortOrderSmallestFirst": "작은 파일순",
"albumGroupTier": "단계별로", "albumGroupTier": "단계별로",
"albumGroupType": "유형별로",
"albumGroupVolume": "저장공간별로", "albumGroupVolume": "저장공간별로",
"albumGroupNone": "묶음 없음", "albumGroupNone": "묶음 없음",
"albumMimeTypeMixed": "혼합",
"albumPickPageTitleCopy": "앨범으로 복사", "albumPickPageTitleCopy": "앨범으로 복사",
"albumPickPageTitleExport": "앨범으로 내보내기", "albumPickPageTitleExport": "앨범으로 내보내기",
"albumPickPageTitleMove": "앨범으로 이동", "albumPickPageTitleMove": "앨범으로 이동",
@ -614,6 +622,7 @@
"settingsWidgetPageTitle": "사진 액자", "settingsWidgetPageTitle": "사진 액자",
"settingsWidgetShowOutline": "윤곽", "settingsWidgetShowOutline": "윤곽",
"settingsWidgetOpenPage": "위젯을 탭하면",
"settingsCollectionTile": "미디어", "settingsCollectionTile": "미디어",
@ -622,6 +631,7 @@
"statsTopCountriesSectionTitle": "국가 랭킹", "statsTopCountriesSectionTitle": "국가 랭킹",
"statsTopPlacesSectionTitle": "장소 랭킹", "statsTopPlacesSectionTitle": "장소 랭킹",
"statsTopTagsSectionTitle": "태그 랭킹", "statsTopTagsSectionTitle": "태그 랭킹",
"statsTopAlbumsSectionTitle": "앨범 랭킹",
"viewerOpenPanoramaButtonLabel": "파노라마 열기", "viewerOpenPanoramaButtonLabel": "파노라마 열기",
"viewerSetWallpaperButtonLabel": "설정", "viewerSetWallpaperButtonLabel": "설정",

View file

@ -6,6 +6,7 @@ enum ChipAction {
goToAlbumPage, goToAlbumPage,
goToCountryPage, goToCountryPage,
goToTagPage, goToTagPage,
reverse,
hide, hide,
} }
@ -18,6 +19,9 @@ extension ExtraChipAction on ChipAction {
return context.l10n.chipActionGoToCountryPage; return context.l10n.chipActionGoToCountryPage;
case ChipAction.goToTagPage: case ChipAction.goToTagPage:
return context.l10n.chipActionGoToTagPage; return context.l10n.chipActionGoToTagPage;
case ChipAction.reverse:
// different data depending on state
return context.l10n.chipActionFilterOut;
case ChipAction.hide: case ChipAction.hide:
return context.l10n.chipActionHide; return context.l10n.chipActionHide;
} }
@ -33,6 +37,8 @@ extension ExtraChipAction on ChipAction {
return AIcons.location; return AIcons.location;
case ChipAction.goToTagPage: case ChipAction.goToTagPage:
return AIcons.tag; return AIcons.tag;
case ChipAction.reverse:
return AIcons.reverse;
case ChipAction.hide: case ChipAction.hide:
return AIcons.hide; return AIcons.hide;
} }

View file

@ -14,16 +14,20 @@ class AlbumFilter extends CoveredCollectionFilter {
final String album; final String album;
final String? displayName; final String? displayName;
late final EntryFilter _test;
@override @override
List<Object?> get props => [album]; List<Object?> get props => [album, reversed];
const AlbumFilter(this.album, this.displayName); AlbumFilter(this.album, this.displayName, {super.reversed = false}) {
_test = (entry) => entry.directory == album;
}
factory AlbumFilter.fromMap(Map<String, dynamic> json) { factory AlbumFilter.fromMap(Map<String, dynamic> json) {
return AlbumFilter( return AlbumFilter(
json['album'], json['album'],
json['uniqueName'], json['uniqueName'],
reversed: json['reversed'] ?? false,
); );
} }
@ -32,10 +36,14 @@ class AlbumFilter extends CoveredCollectionFilter {
'type': type, 'type': type,
'album': album, 'album': album,
'uniqueName': displayName, 'uniqueName': displayName,
'reversed': reversed,
}; };
@override @override
EntryFilter get test => (entry) => entry.directory == album; EntryFilter get positiveTest => _test;
@override
bool get exclusiveProp => true;
@override @override
String get universalLabel => displayName ?? pContext.split(album).last; String get universalLabel => displayName ?? pContext.split(album).last;

View file

@ -17,16 +17,20 @@ class CoordinateFilter extends CollectionFilter {
final LatLng sw; final LatLng sw;
final LatLng ne; final LatLng ne;
final bool minuteSecondPadding; final bool minuteSecondPadding;
late final EntryFilter _test;
@override @override
List<Object?> get props => [sw, ne]; List<Object?> get props => [sw, ne, reversed];
const CoordinateFilter(this.sw, this.ne, {this.minuteSecondPadding = false}); CoordinateFilter(this.sw, this.ne, {this.minuteSecondPadding = false, super.reversed = false}) {
_test = (entry) => GeoUtils.contains(sw, ne, entry.latLng);
}
factory CoordinateFilter.fromMap(Map<String, dynamic> json) { factory CoordinateFilter.fromMap(Map<String, dynamic> json) {
return CoordinateFilter( return CoordinateFilter(
LatLng.fromJson(json['sw']), LatLng.fromJson(json['sw']),
LatLng.fromJson(json['ne']), LatLng.fromJson(json['ne']),
reversed: json['reversed'] ?? false,
); );
} }
@ -35,10 +39,11 @@ class CoordinateFilter extends CollectionFilter {
'type': type, 'type': type,
'sw': sw.toJson(), 'sw': sw.toJson(),
'ne': ne.toJson(), 'ne': ne.toJson(),
'reversed': reversed,
}; };
@override @override
EntryFilter get test => (entry) => GeoUtils.contains(sw, ne, entry.latLng); EntryFilter get positiveTest => _test;
String _formatBounds(AppLocalizations l10n, CoordinateFormat format) { String _formatBounds(AppLocalizations l10n, CoordinateFormat format) {
String s(LatLng latLng) => format.format( String s(LatLng latLng) => format.format(
@ -50,6 +55,9 @@ class CoordinateFilter extends CollectionFilter {
return '${s(ne)}\n${s(sw)}'; return '${s(ne)}\n${s(sw)}';
} }
@override
bool get exclusiveProp => false;
@override @override
String get universalLabel => _formatBounds(lookupAppLocalizations(AppLocalizations.supportedLocales.first), CoordinateFormat.decimal); String get universalLabel => _formatBounds(lookupAppLocalizations(AppLocalizations.supportedLocales.first), CoordinateFormat.decimal);

View file

@ -18,9 +18,9 @@ class DateFilter extends CollectionFilter {
static final onThisDay = DateFilter(DateLevel.md, null); static final onThisDay = DateFilter(DateLevel.md, null);
@override @override
List<Object?> get props => [level, date]; List<Object?> get props => [level, date, reversed];
DateFilter(this.level, this.date) { DateFilter(this.level, this.date, {super.reversed = false}) {
_effectiveDate = date ?? DateTime.now(); _effectiveDate = date ?? DateTime.now();
switch (level) { switch (level) {
case DateLevel.y: case DateLevel.y:
@ -56,6 +56,7 @@ class DateFilter extends CollectionFilter {
return DateFilter( return DateFilter(
DateLevel.values.firstWhereOrNull((v) => v.toString() == json['level']) ?? DateLevel.ymd, DateLevel.values.firstWhereOrNull((v) => v.toString() == json['level']) ?? DateLevel.ymd,
dateString != null ? DateTime.tryParse(dateString) : null, dateString != null ? DateTime.tryParse(dateString) : null,
reversed: json['reversed'] ?? false,
); );
} }
@ -64,15 +65,20 @@ class DateFilter extends CollectionFilter {
'type': type, 'type': type,
'level': level.toString(), 'level': level.toString(),
'date': date?.toIso8601String(), 'date': date?.toIso8601String(),
'reversed': reversed,
}; };
@override @override
EntryFilter get test => _test; EntryFilter get positiveTest => _test;
@override
bool get exclusiveProp => true;
@override @override
bool isCompatible(CollectionFilter other) { bool isCompatible(CollectionFilter other) {
if (other is DateFilter) { if (other is DateFilter) {
return isCompatibleLevel(level, other.level); if (reversed != other.reversed && this == other.reverse()) return false;
return reversed || other.reversed || isCompatibleLevel(level, other.level);
} else { } else {
return true; return true;
} }

View file

@ -1,3 +1,4 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/colors.dart'; import 'package:aves/theme/colors.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
@ -9,20 +10,32 @@ import 'package:provider/provider.dart';
class FavouriteFilter extends CollectionFilter { class FavouriteFilter extends CollectionFilter {
static const type = 'favourite'; static const type = 'favourite';
static bool _test(AvesEntry entry) => entry.isFavourite;
static const instance = FavouriteFilter._private(); static const instance = FavouriteFilter._private();
static const instanceReversed = FavouriteFilter._private(reversed: true);
@override @override
List<Object?> get props => []; List<Object?> get props => [reversed];
const FavouriteFilter._private(); const FavouriteFilter._private({super.reversed = false});
factory FavouriteFilter.fromMap(Map<String, dynamic> json) {
final reversed = json['reversed'] ?? false;
return reversed ? instanceReversed : instance;
}
@override @override
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'type': type, 'type': type,
'reversed': reversed,
}; };
@override @override
EntryFilter get test => (entry) => entry.isFavourite; EntryFilter get positiveTest => _test;
@override
bool get exclusiveProp => false;
@override @override
String get universalLabel => type; String get universalLabel => type;

View file

@ -42,9 +42,44 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
PathFilter.type, PathFilter.type,
]; ];
final bool not; final bool reversed;
const CollectionFilter({this.not = false}); const CollectionFilter({required this.reversed});
static CollectionFilter? _fromMap(Map<String, dynamic> jsonMap) {
final type = jsonMap['type'];
switch (type) {
case AlbumFilter.type:
return AlbumFilter.fromMap(jsonMap);
case CoordinateFilter.type:
return CoordinateFilter.fromMap(jsonMap);
case DateFilter.type:
return DateFilter.fromMap(jsonMap);
case FavouriteFilter.type:
return FavouriteFilter.fromMap(jsonMap);
case LocationFilter.type:
return LocationFilter.fromMap(jsonMap);
case MimeFilter.type:
return MimeFilter.fromMap(jsonMap);
case MissingFilter.type:
return MissingFilter.fromMap(jsonMap);
case PathFilter.type:
return PathFilter.fromMap(jsonMap);
case QueryFilter.type:
return QueryFilter.fromMap(jsonMap);
case RatingFilter.type:
return RatingFilter.fromMap(jsonMap);
case RecentlyAddedFilter.type:
return RecentlyAddedFilter.fromMap(jsonMap);
case TagFilter.type:
return TagFilter.fromMap(jsonMap);
case TypeFilter.type:
return TypeFilter.fromMap(jsonMap);
case TrashFilter.type:
return TrashFilter.fromMap(jsonMap);
}
return null;
}
static CollectionFilter? fromJson(String jsonString) { static CollectionFilter? fromJson(String jsonString) {
if (jsonString.isEmpty) return null; if (jsonString.isEmpty) return null;
@ -52,37 +87,7 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
try { try {
final jsonMap = jsonDecode(jsonString); final jsonMap = jsonDecode(jsonString);
if (jsonMap is Map<String, dynamic>) { if (jsonMap is Map<String, dynamic>) {
final type = jsonMap['type']; return _fromMap(jsonMap);
switch (type) {
case AlbumFilter.type:
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:
return LocationFilter.fromMap(jsonMap);
case MimeFilter.type:
return MimeFilter.fromMap(jsonMap);
case MissingFilter.type:
return MissingFilter.fromMap(jsonMap);
case PathFilter.type:
return PathFilter.fromMap(jsonMap);
case QueryFilter.type:
return QueryFilter.fromMap(jsonMap);
case RatingFilter.type:
return RatingFilter.fromMap(jsonMap);
case RecentlyAddedFilter.type:
return RecentlyAddedFilter.instance;
case TagFilter.type:
return TagFilter.fromMap(jsonMap);
case TypeFilter.type:
return TypeFilter.fromMap(jsonMap);
case TrashFilter.type:
return TrashFilter.instance;
}
} }
} catch (error, stack) { } catch (error, stack) {
debugPrint('failed to parse filter from json=$jsonString error=$error\n$stack'); debugPrint('failed to parse filter from json=$jsonString error=$error\n$stack');
@ -95,9 +100,21 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
String toJson() => jsonEncode(toMap()); String toJson() => jsonEncode(toMap());
EntryFilter get test; EntryFilter get positiveTest;
bool isCompatible(CollectionFilter other) => category != other.category; EntryFilter get test => reversed ? (v) => !positiveTest(v) : positiveTest;
CollectionFilter reverse() => _fromMap(toMap()..['reversed'] = !reversed)!;
bool get exclusiveProp;
bool isCompatible(CollectionFilter other) {
if (category != other.category) return true;
if (!reversed && !other.reversed) return !exclusiveProp;
if (reversed && other.reversed) return true;
if (this == other.reverse()) return false;
return true;
}
String get universalLabel; String get universalLabel;
@ -129,7 +146,7 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
@immutable @immutable
abstract class CoveredCollectionFilter extends CollectionFilter { abstract class CoveredCollectionFilter extends CollectionFilter {
const CoveredCollectionFilter({bool not = false}) : super(not: not); const CoveredCollectionFilter({required super.reversed});
@override @override
Future<Color> color(BuildContext context) { Future<Color> color(BuildContext context) {

View file

@ -15,9 +15,9 @@ class LocationFilter extends CoveredCollectionFilter {
late final EntryFilter _test; late final EntryFilter _test;
@override @override
List<Object?> get props => [level, _location, _countryCode]; List<Object?> get props => [level, _location, _countryCode, reversed];
LocationFilter(this.level, String location) { LocationFilter(this.level, String location, {super.reversed = false}) {
final split = location.split(locationSeparator); final split = location.split(locationSeparator);
_location = split.isNotEmpty ? split[0] : location; _location = split.isNotEmpty ? split[0] : location;
_countryCode = split.length > 1 ? split[1] : null; _countryCode = split.length > 1 ? split[1] : null;
@ -35,6 +35,7 @@ class LocationFilter extends CoveredCollectionFilter {
return LocationFilter( return LocationFilter(
LocationLevel.values.firstWhereOrNull((v) => v.toString() == json['level']) ?? LocationLevel.place, LocationLevel.values.firstWhereOrNull((v) => v.toString() == json['level']) ?? LocationLevel.place,
json['location'], json['location'],
reversed: json['reversed'] ?? false,
); );
} }
@ -43,6 +44,7 @@ class LocationFilter extends CoveredCollectionFilter {
'type': type, 'type': type,
'level': level.toString(), 'level': level.toString(),
'location': _countryCode != null ? countryNameAndCode : _location, 'location': _countryCode != null ? countryNameAndCode : _location,
'reversed': reversed,
}; };
String get countryNameAndCode => '$_location$locationSeparator$_countryCode'; String get countryNameAndCode => '$_location$locationSeparator$_countryCode';
@ -50,7 +52,10 @@ class LocationFilter extends CoveredCollectionFilter {
String? get countryCode => _countryCode; String? get countryCode => _countryCode;
@override @override
EntryFilter get test => _test; EntryFilter get positiveTest => _test;
@override
bool get exclusiveProp => true;
@override @override
String get universalLabel => _location; String get universalLabel => _location;

View file

@ -14,17 +14,17 @@ class MimeFilter extends CollectionFilter {
static const type = 'mime'; static const type = 'mime';
final String mime; final String mime;
late final EntryFilter _test;
late final String _label; late final String _label;
late final IconData _icon; late final IconData _icon;
late final EntryFilter _test;
static final image = MimeFilter(MimeTypes.anyImage); static final image = MimeFilter(MimeTypes.anyImage);
static final video = MimeFilter(MimeTypes.anyVideo); static final video = MimeFilter(MimeTypes.anyVideo);
@override @override
List<Object?> get props => [mime]; List<Object?> get props => [mime, reversed];
MimeFilter(this.mime) { MimeFilter(this.mime, {super.reversed = false}) {
IconData? icon; IconData? icon;
var lowMime = mime.toLowerCase(); var lowMime = mime.toLowerCase();
if (lowMime.endsWith('/*')) { if (lowMime.endsWith('/*')) {
@ -46,6 +46,7 @@ class MimeFilter extends CollectionFilter {
factory MimeFilter.fromMap(Map<String, dynamic> json) { factory MimeFilter.fromMap(Map<String, dynamic> json) {
return MimeFilter( return MimeFilter(
json['mime'], json['mime'],
reversed: json['reversed'] ?? false,
); );
} }
@ -53,10 +54,14 @@ class MimeFilter extends CollectionFilter {
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'type': type, 'type': type,
'mime': mime, 'mime': mime,
'reversed': reversed,
}; };
@override @override
EntryFilter get test => _test; EntryFilter get positiveTest => _test;
@override
bool get exclusiveProp => true;
@override @override
String get universalLabel => _label; String get universalLabel => _label;

View file

@ -10,16 +10,16 @@ class MissingFilter extends CollectionFilter {
static const _title = 'title'; static const _title = 'title';
final String metadataType; final String metadataType;
late final EntryFilter _test;
late final IconData _icon; late final IconData _icon;
late final EntryFilter _test;
static final date = MissingFilter._private(_date); static final date = MissingFilter._private(_date);
static final title = MissingFilter._private(_title); static final title = MissingFilter._private(_title);
@override @override
List<Object?> get props => [metadataType]; List<Object?> get props => [metadataType, reversed];
MissingFilter._private(this.metadataType) { MissingFilter._private(this.metadataType, {super.reversed = false}) {
switch (metadataType) { switch (metadataType) {
case _date: case _date:
_test = (entry) => (entry.catalogMetadata?.dateMillis ?? 0) == 0; _test = (entry) => (entry.catalogMetadata?.dateMillis ?? 0) == 0;
@ -35,6 +35,7 @@ class MissingFilter extends CollectionFilter {
factory MissingFilter.fromMap(Map<String, dynamic> json) { factory MissingFilter.fromMap(Map<String, dynamic> json) {
return MissingFilter._private( return MissingFilter._private(
json['metadataType'], json['metadataType'],
reversed: json['reversed'] ?? false,
); );
} }
@ -42,10 +43,14 @@ class MissingFilter extends CollectionFilter {
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'type': type, 'type': type,
'metadataType': metadataType, 'metadataType': metadataType,
'reversed': reversed,
}; };
@override @override
EntryFilter get test => _test; EntryFilter get positiveTest => _test;
@override
bool get exclusiveProp => false;
@override @override
String get universalLabel => metadataType; String get universalLabel => metadataType;

View file

@ -10,14 +10,24 @@ class PathFilter extends CollectionFilter {
// without trailing separator // without trailing separator
final String _rootAlbum; final String _rootAlbum;
@override late final EntryFilter _test;
List<Object?> get props => [path];
PathFilter(this.path) : _rootAlbum = path.substring(0, path.length - 1); @override
List<Object?> get props => [path, reversed];
PathFilter(this.path, {super.reversed = false}) : _rootAlbum = path.substring(0, path.length - 1) {
_test = (entry) {
final dir = entry.directory;
if (dir == null) return false;
// avoid string building in most cases
return dir.startsWith(_rootAlbum) && '$dir${pContext.separator}'.startsWith(path);
};
}
factory PathFilter.fromMap(Map<String, dynamic> json) { factory PathFilter.fromMap(Map<String, dynamic> json) {
return PathFilter( return PathFilter(
json['path'], json['path'],
reversed: json['reversed'] ?? false,
); );
} }
@ -25,15 +35,14 @@ class PathFilter extends CollectionFilter {
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'type': type, 'type': type,
'path': path, 'path': path,
'reversed': reversed,
}; };
@override @override
EntryFilter get test => (entry) { EntryFilter get positiveTest => _test;
final dir = entry.directory;
if (dir == null) return false; @override
// avoid string building in most cases bool get exclusiveProp => true;
return dir.startsWith(_rootAlbum) && '$dir${pContext.separator}'.startsWith(path);
};
@override @override
String get universalLabel => path; String get universalLabel => path;

View file

@ -18,7 +18,7 @@ class QueryFilter extends CollectionFilter {
late final EntryFilter _test; late final EntryFilter _test;
@override @override
List<Object?> get props => [query, live]; List<Object?> get props => [query, live, reversed];
static final _fieldPattern = RegExp(r'(.+)([=<>])(.+)'); static final _fieldPattern = RegExp(r'(.+)([=<>])(.+)');
static final _fileSizePattern = RegExp(r'(\d+)([KMG])?'); static final _fileSizePattern = RegExp(r'(\d+)([KMG])?');
@ -33,7 +33,7 @@ class QueryFilter extends CollectionFilter {
static const opLower = '<'; static const opLower = '<';
static const opGreater = '>'; static const opGreater = '>';
QueryFilter(this.query, {this.colorful = true, this.live = false}) { QueryFilter(this.query, {this.colorful = true, this.live = false, super.reversed = false}) {
var upQuery = query.toUpperCase(); var upQuery = query.toUpperCase();
final test = fieldTest(upQuery); final test = fieldTest(upQuery);
@ -62,6 +62,7 @@ class QueryFilter extends CollectionFilter {
factory QueryFilter.fromMap(Map<String, dynamic> json) { factory QueryFilter.fromMap(Map<String, dynamic> json) {
return QueryFilter( return QueryFilter(
json['query'], json['query'],
reversed: json['reversed'] ?? false,
); );
} }
@ -69,13 +70,14 @@ class QueryFilter extends CollectionFilter {
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'type': type, 'type': type,
'query': query, 'query': query,
'reversed': reversed,
}; };
@override @override
EntryFilter get test => _test; EntryFilter get positiveTest => _test;
@override @override
bool isCompatible(CollectionFilter other) => true; bool get exclusiveProp => false;
@override @override
String get universalLabel => query; String get universalLabel => query;

View file

@ -7,15 +7,19 @@ class RatingFilter extends CollectionFilter {
static const type = 'rating'; static const type = 'rating';
final int rating; final int rating;
late final EntryFilter _test;
@override @override
List<Object?> get props => [rating]; List<Object?> get props => [rating, reversed];
const RatingFilter(this.rating); RatingFilter(this.rating, {super.reversed = false}) {
_test = (entry) => entry.rating == rating;
}
factory RatingFilter.fromMap(Map<String, dynamic> json) { factory RatingFilter.fromMap(Map<String, dynamic> json) {
return RatingFilter( return RatingFilter(
json['rating'] ?? 0, json['rating'] ?? 0,
reversed: json['reversed'] ?? false,
); );
} }
@ -23,10 +27,14 @@ class RatingFilter extends CollectionFilter {
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'type': type, 'type': type,
'rating': rating, 'rating': rating,
'reversed': reversed,
}; };
@override @override
EntryFilter get test => (entry) => entry.rating == rating; EntryFilter get positiveTest => _test;
@override
bool get exclusiveProp => true;
@override @override
String get universalLabel => '$rating'; String get universalLabel => '$rating';

View file

@ -6,30 +6,43 @@ import 'package:flutter/material.dart';
class RecentlyAddedFilter extends CollectionFilter { class RecentlyAddedFilter extends CollectionFilter {
static const type = 'recently_added'; static const type = 'recently_added';
static late EntryFilter _test;
static final instance = RecentlyAddedFilter._private(); static final instance = RecentlyAddedFilter._private();
static final instanceReversed = RecentlyAddedFilter._private(reversed: true);
static late int nowSecs; static late int nowSecs;
static void updateNow() { static void updateNow() {
nowSecs = DateTime.now().millisecondsSinceEpoch ~/ 1000; nowSecs = DateTime.now().millisecondsSinceEpoch ~/ 1000;
_test = (entry) => (nowSecs - (entry.dateAddedSecs ?? 0)) < _dayInSecs;
} }
static const _dayInSecs = 24 * 60 * 60; static const _dayInSecs = 24 * 60 * 60;
@override @override
List<Object?> get props => []; List<Object?> get props => [reversed];
RecentlyAddedFilter._private() { RecentlyAddedFilter._private({super.reversed = false}) {
updateNow(); updateNow();
} }
factory RecentlyAddedFilter.fromMap(Map<String, dynamic> json) {
final reversed = json['reversed'] ?? false;
return reversed ? instanceReversed : instance;
}
@override @override
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'type': type, 'type': type,
'reversed': reversed,
}; };
@override @override
EntryFilter get test => (entry) => (nowSecs - (entry.dateAddedSecs ?? 0)) < _dayInSecs; EntryFilter get positiveTest => _test;
@override
bool get exclusiveProp => false;
@override @override
String get universalLabel => type; String get universalLabel => type;

View file

@ -10,20 +10,20 @@ class TagFilter extends CoveredCollectionFilter {
late final EntryFilter _test; late final EntryFilter _test;
@override @override
List<Object?> get props => [tag]; List<Object?> get props => [tag, reversed];
TagFilter(this.tag, {bool not = false}) : super(not: not) { TagFilter(this.tag, {super.reversed = false}) {
if (tag.isEmpty) { if (tag.isEmpty) {
_test = not ? (entry) => entry.tags.isNotEmpty : (entry) => entry.tags.isEmpty; _test = (entry) => entry.tags.isEmpty;
} else { } else {
_test = not ? (entry) => !entry.tags.contains(tag) : (entry) => entry.tags.contains(tag); _test = (entry) => entry.tags.contains(tag);
} }
} }
factory TagFilter.fromMap(Map<String, dynamic> json) { factory TagFilter.fromMap(Map<String, dynamic> json) {
return TagFilter( return TagFilter(
json['tag'], json['tag'],
not: json['not'] ?? false, reversed: json['reversed'] ?? false,
); );
} }
@ -31,14 +31,14 @@ class TagFilter extends CoveredCollectionFilter {
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'type': type, 'type': type,
'tag': tag, 'tag': tag,
'not': not, 'reversed': reversed,
}; };
@override @override
EntryFilter get test => _test; EntryFilter get positiveTest => _test;
@override @override
bool isCompatible(CollectionFilter other) => true; bool get exclusiveProp => false;
@override @override
String get universalLabel => tag; String get universalLabel => tag;

View file

@ -1,3 +1,4 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
@ -6,20 +7,32 @@ import 'package:flutter/material.dart';
class TrashFilter extends CollectionFilter { class TrashFilter extends CollectionFilter {
static const type = 'trash'; static const type = 'trash';
static bool _test(AvesEntry entry) => entry.trashed;
static const instance = TrashFilter._private(); static const instance = TrashFilter._private();
static const instanceReversed = TrashFilter._private(reversed: true);
@override @override
List<Object?> get props => []; List<Object?> get props => [reversed];
const TrashFilter._private(); const TrashFilter._private({super.reversed = false});
factory TrashFilter.fromMap(Map<String, dynamic> json) {
final reversed = json['reversed'] ?? false;
return reversed ? instanceReversed : instance;
}
@override @override
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'type': type, 'type': type,
'reversed': reversed,
}; };
@override @override
EntryFilter get test => (entry) => entry.trashed; EntryFilter get positiveTest => _test;
@override
bool get exclusiveProp => false;
@override @override
String get universalLabel => type; String get universalLabel => type;

View file

@ -17,8 +17,8 @@ class TypeFilter extends CollectionFilter {
static const _sphericalVideo = 'spherical_video'; // subset of videos static const _sphericalVideo = 'spherical_video'; // subset of videos
final String itemType; final String itemType;
late final EntryFilter _test;
late final IconData _icon; late final IconData _icon;
late final EntryFilter _test;
static final animated = TypeFilter._private(_animated); static final animated = TypeFilter._private(_animated);
static final geotiff = TypeFilter._private(_geotiff); static final geotiff = TypeFilter._private(_geotiff);
@ -28,9 +28,9 @@ class TypeFilter extends CollectionFilter {
static final sphericalVideo = TypeFilter._private(_sphericalVideo); static final sphericalVideo = TypeFilter._private(_sphericalVideo);
@override @override
List<Object?> get props => [itemType]; List<Object?> get props => [itemType, reversed];
TypeFilter._private(this.itemType) { TypeFilter._private(this.itemType, {super.reversed = false}) {
switch (itemType) { switch (itemType) {
case _animated: case _animated:
_test = (entry) => entry.isAnimated; _test = (entry) => entry.isAnimated;
@ -62,6 +62,7 @@ class TypeFilter extends CollectionFilter {
factory TypeFilter.fromMap(Map<String, dynamic> json) { factory TypeFilter.fromMap(Map<String, dynamic> json) {
return TypeFilter._private( return TypeFilter._private(
json['itemType'], json['itemType'],
reversed: json['reversed'] ?? false,
); );
} }
@ -69,10 +70,14 @@ class TypeFilter extends CollectionFilter {
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'type': type, 'type': type,
'itemType': itemType, 'itemType': itemType,
'reversed': reversed,
}; };
@override @override
EntryFilter get test => _test; EntryFilter get positiveTest => _test;
@override
bool get exclusiveProp => false;
@override @override
String get universalLabel => itemType; String get universalLabel => itemType;

View file

@ -128,7 +128,7 @@ class CollectionLens with ChangeNotifier {
} }
bool get showHeaders { bool get showHeaders {
bool showAlbumHeaders() => !filters.any((f) => f is AlbumFilter); bool showAlbumHeaders() => !filters.any((v) => v is AlbumFilter && !v.reversed);
switch (sortFactor) { switch (sortFactor) {
case EntrySortFactor.date: case EntrySortFactor.date:

View file

@ -105,6 +105,7 @@ class AIcons {
static const IconData print = Icons.print_outlined; static const IconData print = Icons.print_outlined;
static const IconData refresh = Icons.refresh_outlined; static const IconData refresh = Icons.refresh_outlined;
static const IconData replay10 = Icons.replay_10_outlined; static const IconData replay10 = Icons.replay_10_outlined;
static const IconData reverse = Icons.invert_colors_outlined;
static const IconData skip10 = Icons.forward_10_outlined; static const IconData skip10 = Icons.forward_10_outlined;
static const IconData reset = Icons.restart_alt_outlined; static const IconData reset = Icons.restart_alt_outlined;
static const IconData restore = Icons.restore_outlined; static const IconData restore = Icons.restore_outlined;

View file

@ -29,6 +29,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_app_bar.dart'; import 'package:aves/widgets/common/identity/aves_app_bar.dart';
import 'package:aves/widgets/common/search/route.dart'; import 'package:aves/widgets/common/search/route.dart';
import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; import 'package:aves/widgets/dialogs/tile_view_dialog.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart';
import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/search/search_delegate.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
@ -168,10 +169,16 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
bottom: Column( bottom: Column(
children: [ children: [
if (showFilterBar) if (showFilterBar)
FilterBar( NotificationListener<ReverseFilterNotification>(
filters: visibleFilters, onNotification: (notification) {
removable: removableFilters, collection.addFilter(notification.reversedFilter);
onTap: removableFilters ? collection.removeFilter : null, return true;
},
child: FilterBar(
filters: visibleFilters,
removable: removableFilters,
onTap: removableFilters ? collection.removeFilter : null,
),
), ),
if (queryEnabled) if (queryEnabled)
EntryQueryBar( EntryQueryBar(

View file

@ -64,7 +64,7 @@ class _FilterBarState extends State<FilterBar> {
), ),
); );
} }
: (context, animation) => _buildChip(filter), : (context, animation) => const SizedBox(),
duration: animate ? Durations.filterBarRemovalAnimation : Duration.zero, duration: animate ? Durations.filterBarRemovalAnimation : Duration.zero,
); );
}); });

View file

@ -15,6 +15,7 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/filter_bar.dart'; import 'package:aves/widgets/collection/filter_bar.dart';
import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -96,6 +97,7 @@ class AvesFilterChip extends StatefulWidget {
if (filter is AlbumFilter) ChipAction.goToAlbumPage, if (filter is AlbumFilter) ChipAction.goToAlbumPage,
if ((filter is LocationFilter && filter.level == LocationLevel.country)) ChipAction.goToCountryPage, if ((filter is LocationFilter && filter.level == LocationLevel.country)) ChipAction.goToCountryPage,
if (filter is TagFilter) ChipAction.goToTagPage, if (filter is TagFilter) ChipAction.goToTagPage,
ChipAction.reverse,
ChipAction.hide, ChipAction.hide,
]; ];
@ -113,12 +115,20 @@ class AvesFilterChip extends StatefulWidget {
child: Text(filter.getLabel(context)), child: Text(filter.getLabel(context)),
), ),
const PopupMenuDivider(), const PopupMenuDivider(),
...actions.map((action) => PopupMenuItem( ...actions.map((action) {
value: action, late String text;
child: MenuIconTheme( if (action == ChipAction.reverse) {
child: MenuRow(text: action.getText(context), icon: action.getIcon()), text = filter.reversed ? context.l10n.chipActionFilterIn : context.l10n.chipActionFilterOut;
), } else {
)), text = action.getText(context);
}
return PopupMenuItem(
value: action,
child: MenuIconTheme(
child: MenuRow(text: text, icon: action.getIcon()),
),
);
}),
], ],
); );
if (selectedAction != null) { if (selectedAction != null) {
@ -229,7 +239,8 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
filter.getLabel(context), filter.getLabel(context),
style: TextStyle( style: TextStyle(
fontSize: AvesFilterChip.fontSize, fontSize: AvesFilterChip.fontSize,
decoration: filter.not ? TextDecoration.lineThrough : null, decoration: filter.reversed ? TextDecoration.lineThrough : null,
decorationThickness: 2,
), ),
softWrap: false, softWrap: false,
overflow: TextOverflow.fade, overflow: TextOverflow.fade,

View file

@ -173,7 +173,7 @@ class _AppDebugPageState extends State<AppDebugPage> {
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
settings.changeFilterVisibility(settings.hiddenFilters, true); settings.changeFilterVisibility(settings.hiddenFilters, true);
settings.changeFilterVisibility({ settings.changeFilterVisibility({
TagFilter('aves-thumbnail', not: true), TagFilter('aves-thumbnail', reversed: true),
}, false); }, false);
await favourites.clear(); await favourites.clear();
await favourites.add(source.visibleEntries); await favourites.add(source.visibleEntries);

View file

@ -13,9 +13,6 @@ import 'package:provider/provider.dart';
class ChipActionDelegate { class ChipActionDelegate {
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) { void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
switch (action) { switch (action) {
case ChipAction.hide:
_hide(context, filter);
break;
case ChipAction.goToAlbumPage: case ChipAction.goToAlbumPage:
_goTo(context, filter, AlbumListPage.routeName, (context) => const AlbumListPage()); _goTo(context, filter, AlbumListPage.routeName, (context) => const AlbumListPage());
break; break;
@ -25,11 +22,34 @@ class ChipActionDelegate {
case ChipAction.goToTagPage: case ChipAction.goToTagPage:
_goTo(context, filter, TagListPage.routeName, (context) => const TagListPage()); _goTo(context, filter, TagListPage.routeName, (context) => const TagListPage());
break; break;
case ChipAction.reverse:
ReverseFilterNotification(filter).dispatch(context);
break;
case ChipAction.hide:
_hide(context, filter);
break;
default: default:
break; break;
} }
} }
void _goTo(
BuildContext context,
CollectionFilter filter,
String routeName,
WidgetBuilder pageBuilder,
) {
context.read<HighlightInfo>().set(filter);
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
settings: RouteSettings(name: routeName),
builder: pageBuilder,
),
(route) => false,
);
}
Future<void> _hide(BuildContext context, CollectionFilter filter) async { Future<void> _hide(BuildContext context, CollectionFilter filter) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
@ -53,21 +73,11 @@ class ChipActionDelegate {
settings.changeFilterVisibility({filter}, false); settings.changeFilterVisibility({filter}, false);
} }
}
void _goTo(
BuildContext context, @immutable
CollectionFilter filter, class ReverseFilterNotification extends Notification {
String routeName, final CollectionFilter reversedFilter;
WidgetBuilder pageBuilder,
) { ReverseFilterNotification(CollectionFilter filter) : reversedFilter = filter.reverse();
context.read<HighlightInfo>().set(filter);
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
settings: RouteSettings(name: routeName),
builder: pageBuilder,
),
(route) => false,
);
}
} }

View file

@ -21,6 +21,7 @@ import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
import 'package:aves/widgets/common/providers/map_theme_provider.dart'; import 'package:aves/widgets/common/providers/map_theme_provider.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/thumbnail/scroller.dart'; import 'package:aves/widgets/common/thumbnail/scroller.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart';
import 'package:aves/widgets/map/map_info_row.dart'; import 'package:aves/widgets/map/map_info_row.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart';
import 'package:aves/widgets/viewer/notifications.dart'; import 'package:aves/widgets/viewer/notifications.dart';
@ -164,9 +165,15 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return NotificationListener<FilterSelectedNotification>( return NotificationListener(
onNotification: (notification) { onNotification: (notification) {
_goToCollection(notification.filter); if (notification is FilterSelectedNotification) {
_goToCollection(notification.filter);
} else if (notification is ReverseFilterNotification) {
_goToCollection(notification.reversedFilter);
} else {
return false;
}
return true; return true;
}, },
child: Selector<Settings, EntryMapStyle>( child: Selector<Settings, EntryMapStyle>(

View file

@ -22,6 +22,7 @@ import 'package:aves/widgets/common/expandable_filter_row.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/common/search/delegate.dart'; import 'package:aves/widgets/common/search/delegate.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -65,52 +66,60 @@ class CollectionSearchDelegate extends AvesSearchDelegate {
final upQuery = query.trim().toUpperCase(); final upQuery = query.trim().toUpperCase();
bool containQuery(String s) => s.toUpperCase().contains(upQuery); bool containQuery(String s) => s.toUpperCase().contains(upQuery);
return SafeArea( return SafeArea(
child: ValueListenableBuilder<String?>( child: NotificationListener<ReverseFilterNotification>(
onNotification: (notification) {
_select(context, notification.reversedFilter);
return true;
},
child: ValueListenableBuilder<String?>(
valueListenable: _expandedSectionNotifier, valueListenable: _expandedSectionNotifier,
builder: (context, expandedSection, child) { builder: (context, expandedSection, child) {
final queryFilter = _buildQueryFilter(false); final queryFilter = _buildQueryFilter(false);
return Selector<Settings, Set<CollectionFilter>>( return Selector<Settings, Set<CollectionFilter>>(
selector: (context, s) => s.hiddenFilters, selector: (context, s) => s.hiddenFilters,
builder: (context, hiddenFilters, child) { builder: (context, hiddenFilters, child) {
bool notHidden(CollectionFilter filter) => !hiddenFilters.contains(filter); bool notHidden(CollectionFilter filter) => !hiddenFilters.contains(filter);
final visibleTypeFilters = typeFilters.where(notHidden).toList(); final visibleTypeFilters = typeFilters.where(notHidden).toList();
if (hiddenFilters.contains(MimeFilter.video)) { if (hiddenFilters.contains(MimeFilter.video)) {
[MimeFilter.image, TypeFilter.sphericalVideo].forEach(visibleTypeFilters.remove); [MimeFilter.image, TypeFilter.sphericalVideo].forEach(visibleTypeFilters.remove);
} }
final history = settings.searchHistory.where(notHidden).toList(); final history = settings.searchHistory.where(notHidden).toList();
return ListView( return ListView(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
children: [ children: [
_buildFilterRow(
context: context,
filters: [
queryFilter,
...visibleTypeFilters,
].whereNotNull().where((f) => containQuery(f.getLabel(context))).toList(),
// usually perform hero animation only on tapped chips,
// but we also need to animate the query chip when it is selected by submitting the search query
heroTypeBuilder: (filter) => filter == queryFilter ? HeroType.always : HeroType.onTap,
),
if (upQuery.isEmpty && history.isNotEmpty)
_buildFilterRow( _buildFilterRow(
context: context, context: context,
filters: [ title: context.l10n.searchRecentSectionTitle,
queryFilter, filters: history,
...visibleTypeFilters,
].whereNotNull().where((f) => containQuery(f.getLabel(context))).toList(),
// usually perform hero animation only on tapped chips,
// but we also need to animate the query chip when it is selected by submitting the search query
heroTypeBuilder: (filter) => filter == queryFilter ? HeroType.always : HeroType.onTap,
), ),
if (upQuery.isEmpty && history.isNotEmpty) _buildDateFilters(context, containQuery),
_buildFilterRow( _buildAlbumFilters(containQuery),
context: context, _buildCountryFilters(containQuery),
title: context.l10n.searchRecentSectionTitle, _buildPlaceFilters(containQuery),
filters: history, _buildTagFilters(containQuery),
), _buildRatingFilters(context, containQuery),
_buildDateFilters(context, containQuery), _buildMetadataFilters(context, containQuery),
_buildAlbumFilters(containQuery), ],
_buildCountryFilters(containQuery), );
_buildPlaceFilters(containQuery), },
_buildTagFilters(containQuery), );
_buildRatingFilters(context, containQuery), },
_buildMetadataFilters(context, containQuery), ),
], ),
);
});
}),
); );
} }
@ -219,7 +228,7 @@ class CollectionSearchDelegate extends AvesSearchDelegate {
MissingFilter.date, MissingFilter.date,
LocationFilter(LocationLevel.place, ''), LocationFilter(LocationLevel.place, ''),
TagFilter(''), TagFilter(''),
const RatingFilter(0), RatingFilter(0),
MissingFilter.title, MissingFilter.title,
].where((f) => containQuery(f.getLabel(context))).toList(), ].where((f) => containQuery(f.getLabel(context))).toList(),
); );

View file

@ -17,8 +17,10 @@ import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.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/common/identity/empty.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.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/date/histogram.dart';
import 'package:aves/widgets/stats/filter_table.dart'; import 'package:aves/widgets/stats/filter_table.dart';
import 'package:aves/widgets/stats/mime_donut.dart'; import 'package:aves/widgets/stats/mime_donut.dart';
@ -185,31 +187,37 @@ class _StatsPageState extends State<StatsPage> {
); );
final showRatings = _entryCountPerRating.entries.any((kv) => kv.key != 0 && kv.value > 0); final showRatings = _entryCountPerRating.entries.any((kv) => kv.key != 0 && kv.value > 0);
final source = widget.source; final source = widget.source;
child = AnimationLimiter( child = NotificationListener<ReverseFilterNotification>(
child: ListView( onNotification: (notification) {
children: AnimationConfiguration.toStaggeredList( _onFilterSelection(context, notification.reversedFilter);
duration: durations.staggeredAnimation, return true;
delay: durations.staggeredAnimationDelay * timeDilation, },
childAnimationBuilder: (child) => SlideAnimation( child: AnimationLimiter(
verticalOffset: 50.0, child: ListView(
child: FadeInAnimation( children: AnimationConfiguration.toStaggeredList(
child: child, duration: durations.staggeredAnimation,
delay: durations.staggeredAnimationDelay * timeDilation,
childAnimationBuilder: (child) => SlideAnimation(
verticalOffset: 50.0,
child: FadeInAnimation(
child: child,
),
), ),
children: [
mimeDonuts,
Histogram(
entries: entries,
animationDuration: chartAnimationDuration,
onFilterSelection: (filter) => _onFilterSelection(context, filter),
),
locationIndicator,
..._buildFilterSection<String>(context, l10n.statsTopCountriesSectionTitle, _entryCountPerCountry, (v) => LocationFilter(LocationLevel.country, v)),
..._buildFilterSection<String>(context, l10n.statsTopPlacesSectionTitle, _entryCountPerPlace, (v) => LocationFilter(LocationLevel.place, v)),
..._buildFilterSection<String>(context, l10n.statsTopTagsSectionTitle, _entryCountPerTag, TagFilter.new),
..._buildFilterSection<String>(context, l10n.statsTopAlbumsSectionTitle, _entryCountPerAlbum, (v) => AlbumFilter(v, source.getAlbumDisplayName(context, v))),
if (showRatings) ..._buildFilterSection<int>(context, l10n.searchRatingSectionTitle, _entryCountPerRating, RatingFilter.new, sortByCount: false, maxRowCount: null),
],
), ),
children: [
mimeDonuts,
Histogram(
entries: entries,
animationDuration: chartAnimationDuration,
onFilterSelection: (filter) => _onFilterSelection(context, filter),
),
locationIndicator,
..._buildFilterSection<String>(context, l10n.statsTopCountriesSectionTitle, _entryCountPerCountry, (v) => LocationFilter(LocationLevel.country, v)),
..._buildFilterSection<String>(context, l10n.statsTopPlacesSectionTitle, _entryCountPerPlace, (v) => LocationFilter(LocationLevel.place, v)),
..._buildFilterSection<String>(context, l10n.statsTopTagsSectionTitle, _entryCountPerTag, TagFilter.new),
..._buildFilterSection<String>(context, l10n.statsTopAlbumsSectionTitle, _entryCountPerAlbum, (v) => AlbumFilter(v, source.getAlbumDisplayName(context, v))),
if (showRatings) ..._buildFilterSection<int>(context, l10n.searchRatingSectionTitle, _entryCountPerRating, RatingFilter.new, sortByCount: false, maxRowCount: null),
],
), ),
), ),
); );
@ -277,6 +285,7 @@ class _StatsPageState extends State<StatsPage> {
maxRowCount: null, maxRowCount: null,
onFilterSelection: (filter) => _onFilterSelection(context, filter), onFilterSelection: (filter) => _onFilterSelection(context, filter),
), ),
onFilterSelection: (filter) => _onFilterSelection(context, filter),
), ),
), ),
) )
@ -312,7 +321,7 @@ class _StatsPageState extends State<StatsPage> {
// even when the target is a child of an `AnimatedList`. // even when the target is a child of an `AnimatedList`.
// Do not use `WidgetsBinding.instance.addPostFrameCallback`, // Do not use `WidgetsBinding.instance.addPostFrameCallback`,
// as it may not trigger if there is no subsequent build. // as it may not trigger if there is no subsequent build.
Future.delayed(const Duration(milliseconds: 100), () => Navigator.pop(context)); Future.delayed(const Duration(milliseconds: 100), () => Navigator.popUntil(context, (route) => route.settings.name == CollectionPage.routeName));
} }
void _jumpToCollectionPage(BuildContext context, CollectionFilter filter) { void _jumpToCollectionPage(BuildContext context, CollectionFilter filter) {
@ -335,11 +344,13 @@ class StatsTopPage extends StatelessWidget {
final String title; final String title;
final WidgetBuilder tableBuilder; final WidgetBuilder tableBuilder;
final FilterCallback onFilterSelection;
const StatsTopPage({ const StatsTopPage({
super.key, super.key,
required this.title, required this.title,
required this.tableBuilder, required this.tableBuilder,
required this.onFilterSelection,
}); });
@override @override
@ -353,12 +364,18 @@ class StatsTopPage extends StatelessWidget {
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
child: Builder(builder: (context) { child: Builder(builder: (context) {
return SingleChildScrollView( return NotificationListener<ReverseFilterNotification>(
padding: const EdgeInsets.symmetric(vertical: 8) + onNotification: (notification) {
EdgeInsets.only( onFilterSelection(notification.reversedFilter);
bottom: context.select<MediaQueryData, double>((mq) => mq.effectiveBottomPadding), return true;
), },
child: tableBuilder(context), child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 8) +
EdgeInsets.only(
bottom: context.select<MediaQueryData, double>((mq) => mq.effectiveBottomPadding),
),
child: tableBuilder(context),
),
); );
}), }),
), ),

View file

@ -8,6 +8,7 @@ import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart';
import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart';
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart'; import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
import 'package:aves/widgets/viewer/info/basic_section.dart'; import 'package:aves/widgets/viewer/info/basic_section.dart';
@ -230,25 +231,31 @@ class _InfoPageContentState extends State<_InfoPageContent> {
metadataNotifier: _metadataNotifier, metadataNotifier: _metadataNotifier,
); );
return CustomScrollView( return NotificationListener<ReverseFilterNotification>(
controller: widget.scrollController, onNotification: (notification) {
slivers: [ _onFilter(notification.reversedFilter);
InfoAppBar( return true;
entry: entry, },
actionDelegate: _actionDelegate, child: CustomScrollView(
metadataNotifier: _metadataNotifier, controller: widget.scrollController,
onBackPressed: widget.goToViewer, slivers: [
), InfoAppBar(
SliverPadding( entry: entry,
padding: horizontalPadding + const EdgeInsets.only(top: 8), actionDelegate: _actionDelegate,
sliver: basicAndLocationSliver, metadataNotifier: _metadataNotifier,
), onBackPressed: widget.goToViewer,
SliverPadding( ),
padding: horizontalPadding + const EdgeInsets.only(bottom: 8), SliverPadding(
sliver: metadataSliver, padding: horizontalPadding + const EdgeInsets.only(top: 8),
), sliver: basicAndLocationSliver,
const BottomPaddingSliver(), ),
], SliverPadding(
padding: horizontalPadding + const EdgeInsets.only(bottom: 8),
sliver: metadataSliver,
),
const BottomPaddingSliver(),
],
),
); );
} }

View file

@ -113,7 +113,7 @@ void main() {
}); });
test('album/country/tag hidden on launch when their items are hidden by entry prop', () async { test('album/country/tag hidden on launch when their items are hidden by entry prop', () async {
settings.hiddenFilters = {const AlbumFilter(testAlbum, 'whatever')}; settings.hiddenFilters = {AlbumFilter(testAlbum, 'whatever')};
final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1'); final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1');
(mediaStoreService as FakeMediaStoreService).entries = { (mediaStoreService as FakeMediaStoreService).entries = {
@ -191,7 +191,7 @@ void main() {
expect(source.rawAlbums.length, 1); expect(source.rawAlbums.length, 1);
expect(covers.count, 0); expect(covers.count, 0);
const albumFilter = AlbumFilter(testAlbum, 'whatever'); final albumFilter = AlbumFilter(testAlbum, 'whatever');
expect(albumFilter.test(image1), true); expect(albumFilter.test(image1), true);
expect(covers.count, 0); expect(covers.count, 0);
expect(covers.of(albumFilter), null); expect(covers.of(albumFilter), null);
@ -213,7 +213,7 @@ void main() {
final source = await _initSource(); final source = await _initSource();
await image1.toggleFavourite(); await image1.toggleFavourite();
const albumFilter = AlbumFilter(testAlbum, 'whatever'); final albumFilter = AlbumFilter(testAlbum, 'whatever');
await covers.set(filter: albumFilter, entryId: image1.id, packageName: null, color: null); await covers.set(filter: albumFilter, entryId: image1.id, packageName: null, color: null);
await source.updateAfterRename( await source.updateAfterRename(
todoEntries: {image1}, todoEntries: {image1},
@ -257,8 +257,8 @@ void main() {
expect(source.rawAlbums.contains(sourceAlbum), true); expect(source.rawAlbums.contains(sourceAlbum), true);
expect(source.rawAlbums.contains(destinationAlbum), false); expect(source.rawAlbums.contains(destinationAlbum), false);
const sourceAlbumFilter = AlbumFilter(sourceAlbum, 'whatever'); final sourceAlbumFilter = AlbumFilter(sourceAlbum, 'whatever');
const destinationAlbumFilter = AlbumFilter(destinationAlbum, 'whatever'); final destinationAlbumFilter = AlbumFilter(destinationAlbum, 'whatever');
expect(sourceAlbumFilter.test(image1), true); expect(sourceAlbumFilter.test(image1), true);
expect(destinationAlbumFilter.test(image1), false); expect(destinationAlbumFilter.test(image1), false);
@ -308,7 +308,7 @@ void main() {
final source = await _initSource(); final source = await _initSource();
expect(source.rawAlbums.length, 1); expect(source.rawAlbums.length, 1);
const sourceAlbumFilter = AlbumFilter(sourceAlbum, 'whatever'); final sourceAlbumFilter = AlbumFilter(sourceAlbum, 'whatever');
await covers.set(filter: sourceAlbumFilter, entryId: image1.id, packageName: null, color: null); await covers.set(filter: sourceAlbumFilter, entryId: image1.id, packageName: null, color: null);
await source.updateAfterMove( await source.updateAfterMove(
@ -333,14 +333,14 @@ void main() {
final source = await _initSource(); final source = await _initSource();
await image1.toggleFavourite(); await image1.toggleFavourite();
var albumFilter = const AlbumFilter(sourceAlbum, 'whatever'); var albumFilter = AlbumFilter(sourceAlbum, 'whatever');
await covers.set(filter: albumFilter, entryId: image1.id, packageName: null, color: null); await covers.set(filter: albumFilter, entryId: image1.id, packageName: null, color: null);
await source.renameAlbum(sourceAlbum, destinationAlbum, { await source.renameAlbum(sourceAlbum, destinationAlbum, {
image1 image1
}, { }, {
FakeMediaStoreService.moveOpEventForMove(image1, sourceAlbum, destinationAlbum), FakeMediaStoreService.moveOpEventForMove(image1, sourceAlbum, destinationAlbum),
}); });
albumFilter = const AlbumFilter(destinationAlbum, 'whatever'); albumFilter = AlbumFilter(destinationAlbum, 'whatever');
expect(favourites.count, 1); expect(favourites.count, 1);
expect(image1.isFavourite, true); expect(image1.isFavourite, true);

View file

@ -33,7 +33,7 @@ void main() {
test('Filter serialization', () { test('Filter serialization', () {
CollectionFilter? jsonRoundTrip(filter) => CollectionFilter.fromJson(filter.toJson()); CollectionFilter? jsonRoundTrip(filter) => CollectionFilter.fromJson(filter.toJson());
const album = AlbumFilter('path/to/album', 'album'); final album = AlbumFilter('path/to/album', 'album');
expect(album, jsonRoundTrip(album)); expect(album, jsonRoundTrip(album));
final bounds = CoordinateFilter(LatLng(29.979167, 28.223615), LatLng(36.451000, 31.134167)); final bounds = CoordinateFilter(LatLng(29.979167, 28.223615), LatLng(36.451000, 31.134167));
@ -63,7 +63,7 @@ void main() {
final query = QueryFilter('some query'); final query = QueryFilter('some query');
expect(query, jsonRoundTrip(query)); expect(query, jsonRoundTrip(query));
const rating = RatingFilter(3); final rating = RatingFilter(3);
expect(rating, jsonRoundTrip(rating)); expect(rating, jsonRoundTrip(rating));
final recent = RecentlyAddedFilter.instance; final recent = RecentlyAddedFilter.instance;

View file

@ -1,5 +1,7 @@
{ {
"de": [ "de": [
"chipActionFilterOut",
"chipActionFilterIn",
"viewerTransitionNone", "viewerTransitionNone",
"widgetOpenPageHome", "widgetOpenPageHome",
"widgetOpenPageViewer", "widgetOpenPageViewer",
@ -11,6 +13,8 @@
], ],
"el": [ "el": [
"chipActionFilterOut",
"chipActionFilterIn",
"viewerTransitionNone", "viewerTransitionNone",
"widgetOpenPageHome", "widgetOpenPageHome",
"widgetOpenPageViewer", "widgetOpenPageViewer",
@ -22,6 +26,8 @@
], ],
"es": [ "es": [
"chipActionFilterOut",
"chipActionFilterIn",
"entryInfoActionEditTitleDescription", "entryInfoActionEditTitleDescription",
"filterNoDateLabel", "filterNoDateLabel",
"filterNoTitleLabel", "filterNoTitleLabel",
@ -48,16 +54,9 @@
"viewerInfoLabelDescription" "viewerInfoLabelDescription"
], ],
"fr": [
"widgetOpenPageHome",
"widgetOpenPageViewer",
"albumGroupType",
"albumMimeTypeMixed",
"settingsWidgetOpenPage",
"statsTopAlbumsSectionTitle"
],
"id": [ "id": [
"chipActionFilterOut",
"chipActionFilterIn",
"viewerTransitionNone", "viewerTransitionNone",
"widgetOpenPageHome", "widgetOpenPageHome",
"widgetOpenPageViewer", "widgetOpenPageViewer",
@ -69,6 +68,8 @@
], ],
"it": [ "it": [
"chipActionFilterOut",
"chipActionFilterIn",
"viewerTransitionNone", "viewerTransitionNone",
"widgetOpenPageHome", "widgetOpenPageHome",
"widgetOpenPageViewer", "widgetOpenPageViewer",
@ -80,6 +81,8 @@
], ],
"ja": [ "ja": [
"chipActionFilterOut",
"chipActionFilterIn",
"entryInfoActionEditTitleDescription", "entryInfoActionEditTitleDescription",
"filterNoDateLabel", "filterNoDateLabel",
"filterNoTitleLabel", "filterNoTitleLabel",
@ -107,16 +110,9 @@
"viewerInfoLabelDescription" "viewerInfoLabelDescription"
], ],
"ko": [
"widgetOpenPageHome",
"widgetOpenPageViewer",
"albumGroupType",
"albumMimeTypeMixed",
"settingsWidgetOpenPage",
"statsTopAlbumsSectionTitle"
],
"nl": [ "nl": [
"chipActionFilterOut",
"chipActionFilterIn",
"viewerTransitionNone", "viewerTransitionNone",
"widgetOpenPageHome", "widgetOpenPageHome",
"widgetOpenPageViewer", "widgetOpenPageViewer",
@ -128,6 +124,8 @@
], ],
"pt": [ "pt": [
"chipActionFilterOut",
"chipActionFilterIn",
"viewerTransitionNone", "viewerTransitionNone",
"widgetOpenPageHome", "widgetOpenPageHome",
"widgetOpenPageViewer", "widgetOpenPageViewer",
@ -139,6 +137,8 @@
], ],
"ru": [ "ru": [
"chipActionFilterOut",
"chipActionFilterIn",
"viewerTransitionNone", "viewerTransitionNone",
"widgetOpenPageHome", "widgetOpenPageHome",
"widgetOpenPageViewer", "widgetOpenPageViewer",
@ -150,6 +150,8 @@
], ],
"tr": [ "tr": [
"chipActionFilterOut",
"chipActionFilterIn",
"slideshowActionResume", "slideshowActionResume",
"slideshowActionShowInCollection", "slideshowActionShowInCollection",
"entryInfoActionEditTitleDescription", "entryInfoActionEditTitleDescription",
@ -206,6 +208,8 @@
], ],
"zh": [ "zh": [
"chipActionFilterOut",
"chipActionFilterIn",
"viewerTransitionNone", "viewerTransitionNone",
"widgetOpenPageHome", "widgetOpenPageHome",
"widgetOpenPageViewer", "widgetOpenPageViewer",