From ea3d79afbeb49f90fd505219c53b80b7c65339cd Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 9 Feb 2021 13:38:53 +0900 Subject: [PATCH] #13 hidden filters --- lib/model/actions/chip_actions.dart | 5 + lib/model/entry.dart | 10 +- lib/model/settings/settings.dart | 5 + lib/model/source/album.dart | 35 +++++-- lib/model/source/collection_lens.dart | 30 +++--- lib/model/source/collection_source.dart | 98 ++++++++++++++----- lib/model/source/location.dart | 10 +- lib/model/source/media_store_source.dart | 14 +-- lib/model/source/tag.dart | 10 +- lib/theme/icons.dart | 1 + lib/utils/android_file_utils.dart | 7 +- lib/widgets/collection/app_bar.dart | 4 + .../collection/entry_set_action_delegate.dart | 6 +- lib/widgets/debug/app_debug_page.dart | 9 +- lib/widgets/filter_grids/albums_page.dart | 1 + .../common/chip_action_delegate.dart | 25 +++-- .../filter_grids/common/filter_nav_page.dart | 2 +- .../filter_grids/common/section_keys.dart | 4 +- lib/widgets/filter_grids/countries_page.dart | 3 +- lib/widgets/filter_grids/tags_page.dart | 3 +- lib/widgets/search/expandable_filter_row.dart | 12 +-- lib/widgets/settings/hidden_filters.dart | 67 +++++++++++++ lib/widgets/settings/settings_page.dart | 2 + lib/widgets/stats/stats.dart | 2 +- lib/widgets/viewer/entry_action_delegate.dart | 2 +- 25 files changed, 260 insertions(+), 107 deletions(-) create mode 100644 lib/widgets/settings/hidden_filters.dart diff --git a/lib/model/actions/chip_actions.dart b/lib/model/actions/chip_actions.dart index b2bfb1689..7a25b78ca 100644 --- a/lib/model/actions/chip_actions.dart +++ b/lib/model/actions/chip_actions.dart @@ -10,6 +10,7 @@ enum ChipSetAction { enum ChipAction { delete, + hide, pin, unpin, rename, @@ -20,6 +21,8 @@ extension ExtraChipAction on ChipAction { switch (this) { case ChipAction.delete: return 'Delete'; + case ChipAction.hide: + return 'Hide'; case ChipAction.pin: return 'Pin to top'; case ChipAction.unpin: @@ -34,6 +37,8 @@ extension ExtraChipAction on ChipAction { switch (this) { case ChipAction.delete: return AIcons.delete; + case ChipAction.hide: + return AIcons.hide; case ChipAction.pin: case ChipAction.unpin: return AIcons.pin; diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 92417998e..de75b6219 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -172,14 +172,8 @@ class AvesEntry { addressChangeNotifier.dispose(); } - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is AvesEntry && other.uri == uri && other.pageId == pageId && other._dateModifiedSecs == _dateModifiedSecs; - } - - @override - int get hashCode => hashValues(uri, pageId, _dateModifiedSecs); + // do not implement [Object.==] and [Object.hashCode] using mutable attributes (e.g. `uri`) + // so that we can reliably use instances in a `Set`, which requires consistent hash codes over time @override String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path, pageId=$pageId}'; diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index fcd9a5823..a2f439dae 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -43,6 +43,7 @@ class Settings extends ChangeNotifier { static const countrySortFactorKey = 'country_sort_factor'; static const tagSortFactorKey = 'tag_sort_factor'; static const pinnedFiltersKey = 'pinned_filters'; + static const hiddenFiltersKey = 'hidden_filters'; // viewer static const showOverlayMinimapKey = 'show_overlay_minimap'; @@ -167,6 +168,10 @@ class Settings extends ChangeNotifier { set pinnedFilters(Set newValue) => setAndNotify(pinnedFiltersKey, newValue.map((filter) => filter.toJson()).toList()); + Set get hiddenFilters => (_prefs.getStringList(hiddenFiltersKey) ?? []).map(CollectionFilter.fromJson).toSet(); + + set hiddenFilters(Set newValue) => setAndNotify(hiddenFiltersKey, newValue.map((filter) => filter.toJson()).toList()); + // viewer bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, false); diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index 004517b7b..1c358d6f3 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -34,9 +34,18 @@ mixin AlbumMixin on SourceBase { final uniqueName = parts.skip(parts.length - partCount).join(separator); final volume = androidFileUtils.getStorageVolume(album); - final volumeRoot = volume?.path ?? ''; - final albumRelativePath = album.substring(volumeRoot.length); - if (uniqueName.length < albumRelativePath.length || volume == null) { + if (volume == null) { + return uniqueName; + } + + final volumeRootLength = volume.path.length; + if (album.length < volumeRootLength) { + // `album` is at the root, without trailing '/' + return uniqueName; + } + + final albumRelativePath = album.substring(volumeRootLength); + if (uniqueName.length < albumRelativePath.length) { return uniqueName; } else if (volume.isPrimary) { return albumRelativePath; @@ -67,9 +76,17 @@ mixin AlbumMixin on SourceBase { ))); } - void addDirectory(Iterable albums) { - _directories.addAll(albums); - _notifyAlbumChange(); + void updateDirectories() { + final visibleDirectories = visibleEntries.map((entry) => entry.directory).toSet(); + addDirectories(visibleDirectories); + cleanEmptyAlbums(); + } + + void addDirectories(Set albums) { + if (!_directories.containsAll(albums)) { + _directories.addAll(albums); + _notifyAlbumChange(); + } } void cleanEmptyAlbums([Set albums]) { @@ -85,7 +102,7 @@ mixin AlbumMixin on SourceBase { } } - bool _isEmptyAlbum(String album) => !rawEntries.any((entry) => entry.directory == album); + bool _isEmptyAlbum(String album) => !visibleEntries.any((entry) => entry.directory == album); // filter summary @@ -105,11 +122,11 @@ mixin AlbumMixin on SourceBase { } int albumEntryCount(AlbumFilter filter) { - return _filterEntryCountMap.putIfAbsent(filter.album, () => rawEntries.where((entry) => filter.filter(entry)).length); + return _filterEntryCountMap.putIfAbsent(filter.album, () => visibleEntries.where((entry) => filter.filter(entry)).length); } AvesEntry albumRecentEntry(AlbumFilter filter) { - return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry))); + return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry), orElse: () => null)); } } diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 455619fa0..d20d9554e 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -25,7 +25,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel int id; bool listenToSource; - List _filteredEntries; + List _filteredSortedEntries; List _subscriptions = []; Map> sections = Map.unmodifiable({}); @@ -64,9 +64,9 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel super.dispose(); } - bool get isEmpty => _filteredEntries.isEmpty; + bool get isEmpty => _filteredSortedEntries.isEmpty; - int get entryCount => _filteredEntries.length; + int get entryCount => _filteredSortedEntries.length; // sorted as displayed to the user, i.e. sorted then grouped, not an absolute order on all entries List _sortedEntries; @@ -122,20 +122,20 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel } void _applyFilters() { - final rawEntries = source.rawEntries; - _filteredEntries = List.of(filters.isEmpty ? rawEntries : rawEntries.where((entry) => filters.fold(true, (prev, filter) => prev && filter.filter(entry)))); + final entries = source.visibleEntries; + _filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.fold(true, (prev, filter) => prev && filter.filter(entry)))); } void _applySort() { switch (sortFactor) { case EntrySortFactor.date: - _filteredEntries.sort(AvesEntry.compareByDate); + _filteredSortedEntries.sort(AvesEntry.compareByDate); break; case EntrySortFactor.size: - _filteredEntries.sort(AvesEntry.compareBySize); + _filteredSortedEntries.sort(AvesEntry.compareBySize); break; case EntrySortFactor.name: - _filteredEntries.sort(AvesEntry.compareByName); + _filteredSortedEntries.sort(AvesEntry.compareByName); break; } } @@ -145,28 +145,28 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel case EntrySortFactor.date: switch (groupFactor) { case EntryGroupFactor.album: - sections = groupBy(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory)); + sections = groupBy(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory)); break; case EntryGroupFactor.month: - sections = groupBy(_filteredEntries, (entry) => EntryDateSectionKey(entry.monthTaken)); + sections = groupBy(_filteredSortedEntries, (entry) => EntryDateSectionKey(entry.monthTaken)); break; case EntryGroupFactor.day: - sections = groupBy(_filteredEntries, (entry) => EntryDateSectionKey(entry.dayTaken)); + sections = groupBy(_filteredSortedEntries, (entry) => EntryDateSectionKey(entry.dayTaken)); break; case EntryGroupFactor.none: sections = Map.fromEntries([ - MapEntry(null, _filteredEntries), + MapEntry(null, _filteredSortedEntries), ]); break; } break; case EntrySortFactor.size: sections = Map.fromEntries([ - MapEntry(null, _filteredEntries), + MapEntry(null, _filteredSortedEntries), ]); break; case EntrySortFactor.name: - final byAlbum = groupBy(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory)); + final byAlbum = groupBy(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory)); sections = SplayTreeMap>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory, b.directory)); break; } @@ -191,7 +191,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel // we should remove obsolete entries and sections // but do not apply sort/group // as section order change would surprise the user while browsing - _filteredEntries.removeWhere(entries.contains); + _filteredSortedEntries.removeWhere(entries.contains); _sortedEntries?.removeWhere(entries.contains); sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains)); sections = Map.unmodifiable(Map.fromEntries(sections.entries.where((kv) => kv.value.isNotEmpty))); diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 6743e0c6e..5f8280dc4 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -8,6 +8,7 @@ import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata_db.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/tag.dart'; @@ -16,13 +17,9 @@ import 'package:event_bus/event_bus.dart'; import 'package:flutter/foundation.dart'; mixin SourceBase { - final List _rawEntries = []; + EventBus get eventBus; - List get rawEntries => List.unmodifiable(_rawEntries); - - final EventBus _eventBus = EventBus(); - - EventBus get eventBus => _eventBus; + Set get visibleEntries; List get sortedEntriesByDate; @@ -34,11 +31,30 @@ mixin SourceBase { } abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin { + final EventBus _eventBus = EventBus(); + + @override + EventBus get eventBus => _eventBus; + + final Set _rawEntries = {}; + + // TODO TLAD use `Set.unmodifiable()` when possible + Set get allEntries => Set.of(_rawEntries); + + Set _visibleEntries; + + @override + Set get visibleEntries { + // TODO TLAD use `Set.unmodifiable()` when possible + _visibleEntries ??= Set.of(_applyHiddenFilters(_rawEntries)); + return _visibleEntries; + } + List _sortedEntriesByDate; @override List get sortedEntriesByDate { - _sortedEntriesByDate ??= List.of(_rawEntries)..sort(AvesEntry.compareByDate); + _sortedEntriesByDate ??= List.unmodifiable(visibleEntries.toList()..sort(AvesEntry.compareByDate)); return _sortedEntriesByDate; } @@ -52,10 +68,23 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${_savedDates.length} entries'); } - void addAll(Set entries) { + Iterable _applyHiddenFilters(Iterable entries) { + final hiddenFilters = settings.hiddenFilters; + return hiddenFilters.isEmpty ? entries : entries.where((entry) => !hiddenFilters.any((filter) => filter.filter(entry))); + } + + void _invalidate([Set entries]) { + _visibleEntries = null; + _sortedEntriesByDate = null; + invalidateAlbumFilterSummary(entries: entries); + invalidateCountryFilterSummary(entries); + invalidateTagFilterSummary(entries); + } + + void addEntries(Set entries) { if (entries.isEmpty) return; if (_rawEntries.isNotEmpty) { - final newContentIds = entries.map((entry) => entry.contentId).toList(); + final newContentIds = entries.map((entry) => entry.contentId).toSet(); _rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId)); } entries.forEach((entry) { @@ -63,28 +92,32 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM entry.catalogDateMillis = _savedDates.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null)?.dateMillis; }); _rawEntries.addAll(entries); - addDirectory(_rawEntries.map((entry) => entry.directory)); - _invalidateFilterSummaries(entries); + _invalidate(entries); + + addDirectories(_applyHiddenFilters(entries).map((entry) => entry.directory).toSet()); eventBus.fire(EntryAddedEvent(entries)); } - void removeEntries(Set entries) { - if (entries.isEmpty) return; + void removeEntries(Set uris) { + if (uris.isEmpty) return; + final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet(); entries.forEach((entry) => entry.removeFromFavourites()); - _rawEntries.removeWhere(entries.contains); + _rawEntries.removeAll(entries); + _invalidate(entries); + cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet()); updateLocations(); updateTags(); - _invalidateFilterSummaries(entries); eventBus.fire(EntryRemovedEvent(entries)); } void clearEntries() { _rawEntries.clear(); - cleanEmptyAlbums(); + _invalidate(); + + updateDirectories(); updateLocations(); updateTags(); - _invalidateFilterSummaries(); } Future _moveEntry(AvesEntry entry, Map newFields, bool isFavourite) async { @@ -154,13 +187,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM } if (copy) { - addAll(movedEntries); + addEntries(movedEntries); } else { cleanEmptyAlbums(fromAlbums); - addDirectory({destinationAlbum}); + addDirectories({destinationAlbum}); } invalidateAlbumFilterSummary(directories: fromAlbums); - _invalidateFilterSummaries(movedEntries); + _invalidate(movedEntries); eventBus.fire(EntryMovedEvent(movedEntries)); } @@ -174,13 +207,6 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM // filter summary - void _invalidateFilterSummaries([Set entries]) { - _sortedEntriesByDate = null; - invalidateAlbumFilterSummary(entries: entries); - invalidateCountryFilterSummary(entries); - invalidateTagFilterSummary(entries); - } - int count(CollectionFilter filter) { if (filter is AlbumFilter) return albumEntryCount(filter); if (filter is LocationFilter) return countryEntryCount(filter); @@ -194,6 +220,24 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM if (filter is TagFilter) return tagRecentEntry(filter); return null; } + + void changeFilterVisibility(CollectionFilter filter, bool visible) { + final hiddenFilters = settings.hiddenFilters; + if (visible) { + hiddenFilters.remove(filter); + } else { + hiddenFilters.add(filter); + settings.searchHistory = settings.searchHistory..remove(filter); + } + settings.hiddenFilters = hiddenFilters; + + _invalidate(); + // it is possible for entries hidden by a filter type, to have an impact on other types + // e.g. given a sole entry for country C and tag T, hiding T should make C disappear too + updateDirectories(); + updateLocations(); + updateTags(); + } } enum SourceState { loading, cataloguing, locating, ready } diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index 8c81cfca1..616e55463 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -19,7 +19,7 @@ mixin LocationMixin on SourceBase { Future loadAddresses() async { final stopwatch = Stopwatch()..start(); final saved = await metadataDb.loadAddresses(); - rawEntries.forEach((entry) { + visibleEntries.forEach((entry) { final contentId = entry.contentId; entry.addressDetails = saved.firstWhere((address) => address.contentId == contentId, orElse: () => null); }); @@ -31,7 +31,7 @@ mixin LocationMixin on SourceBase { if (!(await availability.canGeolocate)) return; // final stopwatch = Stopwatch()..start(); - final byLocated = groupBy(rawEntries.where((entry) => entry.hasGps), (entry) => entry.isLocated); + final byLocated = groupBy(visibleEntries.where((entry) => entry.hasGps), (entry) => entry.isLocated); final todo = byLocated[false] ?? []; if (todo.isEmpty) return; @@ -91,7 +91,7 @@ mixin LocationMixin on SourceBase { } void updateLocations() { - final locations = rawEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails).toList(); + final locations = visibleEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails).toList(); sortedPlaces = List.unmodifiable(locations.map((address) => address.place).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase)); // the same country code could be found with different country names @@ -121,11 +121,11 @@ mixin LocationMixin on SourceBase { } int countryEntryCount(LocationFilter filter) { - return _filterEntryCountMap.putIfAbsent(filter.countryCode, () => rawEntries.where((entry) => filter.filter(entry)).length); + return _filterEntryCountMap.putIfAbsent(filter.countryCode, () => visibleEntries.where((entry) => filter.filter(entry)).length); } AvesEntry countryRecentEntry(LocationFilter filter) { - return _filterRecentEntryMap.putIfAbsent(filter.countryCode, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry))); + return _filterRecentEntryMap.putIfAbsent(filter.countryCode, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry), orElse: () => null)); } } diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index ff8dfe584..e3b69463e 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -54,7 +54,7 @@ class MediaStoreSource extends CollectionSource { oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId)); // show known entries - addAll(oldEntries); + addEntries(oldEntries); await loadCatalogMetadata(); // 600ms for 5500 entries await loadAddresses(); // 200ms for 3000 entries debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}'); @@ -69,7 +69,7 @@ class MediaStoreSource extends CollectionSource { final allNewEntries = {}, pendingNewEntries = {}; void addPendingEntries() { allNewEntries.addAll(pendingNewEntries); - addAll(pendingNewEntries); + addEntries(pendingNewEntries); pendingNewEntries.clear(); } @@ -89,7 +89,7 @@ class MediaStoreSource extends CollectionSource { invalidateAlbumFilterSummary(entries: allNewEntries); final analytics = FirebaseAnalytics(); - unawaited(analytics.setUserProperty(name: 'local_item_count', value: (ceilBy(rawEntries.length, 3)).toString())); + unawaited(analytics.setUserProperty(name: 'local_item_count', value: (ceilBy(allEntries.length, 3)).toString())); unawaited(analytics.setUserProperty(name: 'album_count', value: (ceilBy(rawAlbums.length, 1)).toString())); stateNotifier.value = SourceState.cataloguing; @@ -124,9 +124,9 @@ class MediaStoreSource extends CollectionSource { // clean up obsolete entries final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(uriByContentId.keys.toList())).toSet(); + final obsoleteUris = obsoleteContentIds.map((contentId) => uriByContentId[contentId]).toSet(); + removeEntries(obsoleteUris); obsoleteContentIds.forEach(uriByContentId.remove); - final obsoleteEntries = rawEntries.where((e) => obsoleteContentIds.contains(e.contentId)).toSet(); - removeEntries(obsoleteEntries); // fetch new entries final newEntries = {}; @@ -135,7 +135,7 @@ class MediaStoreSource extends CollectionSource { final uri = kv.value; final sourceEntry = await ImageFileService.getEntry(uri, null); if (sourceEntry != null) { - final existingEntry = rawEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null); + final existingEntry = allEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null); if (existingEntry == null || sourceEntry.dateModifiedSecs > existingEntry.dateModifiedSecs) { final volume = androidFileUtils.getStorageVolume(sourceEntry.path); if (volume != null) { @@ -149,7 +149,7 @@ class MediaStoreSource extends CollectionSource { } if (newEntries.isNotEmpty) { - addAll(newEntries); + addEntries(newEntries); await metadataDb.saveEntries(newEntries); invalidateAlbumFilterSummary(entries: newEntries); diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 9ce9d8cf0..781c5f2ed 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -14,7 +14,7 @@ mixin TagMixin on SourceBase { Future loadCatalogMetadata() async { final stopwatch = Stopwatch()..start(); final saved = await metadataDb.loadMetadataEntries(); - rawEntries.forEach((entry) { + visibleEntries.forEach((entry) { final contentId = entry.contentId; entry.catalogMetadata = saved.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null); }); @@ -24,7 +24,7 @@ mixin TagMixin on SourceBase { Future catalogEntries() async { // final stopwatch = Stopwatch()..start(); - final todo = rawEntries.where((entry) => !entry.isCatalogued).toList(); + final todo = visibleEntries.where((entry) => !entry.isCatalogued).toList(); if (todo.isEmpty) return; var progressDone = 0; @@ -55,7 +55,7 @@ mixin TagMixin on SourceBase { } void updateTags() { - final tags = rawEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase); + final tags = visibleEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase); sortedTags = List.unmodifiable(tags); invalidateTagFilterSummary(); @@ -79,11 +79,11 @@ mixin TagMixin on SourceBase { } int tagEntryCount(TagFilter filter) { - return _filterEntryCountMap.putIfAbsent(filter.tag, () => rawEntries.where((entry) => filter.filter(entry)).length); + return _filterEntryCountMap.putIfAbsent(filter.tag, () => visibleEntries.where((entry) => filter.filter(entry)).length); } AvesEntry tagRecentEntry(TagFilter filter) { - return _filterRecentEntryMap.putIfAbsent(filter.tag, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry))); + return _filterRecentEntryMap.putIfAbsent(filter.tag, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry), orElse: () => null)); } } diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 2ff083f0e..2e7745856 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -37,6 +37,7 @@ class AIcons { static const IconData favouriteActive = Icons.favorite; static const IconData goUp = Icons.arrow_upward_outlined; static const IconData group = Icons.group_work_outlined; + static const IconData hide = Icons.visibility_off_outlined; static const IconData info = Icons.info_outlined; static const IconData layers = Icons.layers_outlined; static const IconData openOutside = Icons.open_in_new_outlined; diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index 949e3424a..12a71dd47 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -42,7 +42,12 @@ class AndroidFileUtils { bool isDownloadPath(String path) => path == downloadPath; - StorageVolume getStorageVolume(String path) => storageVolumes.firstWhere((v) => path.startsWith(v.path), orElse: () => null); + StorageVolume getStorageVolume(String path) { + final volume = storageVolumes.firstWhere((v) => path.startsWith(v.path), orElse: () => null); + // storage volume path includes trailing '/', but argument path may or may not, + // which is an issue when the path is at the root + return volume != null || path.endsWith('/') ? volume : getStorageVolume('$path/'); + } bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false; diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 091f38a24..8db5ed80a 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -194,6 +194,7 @@ class _CollectionAppBarState extends State with SingleTickerPr return PopupMenuButton( key: Key('appbar-menu-button'), itemBuilder: (context) { + final isNotEmpty = !collection.isEmpty; final hasSelection = collection.selection.isNotEmpty; return [ PopupMenuItem( @@ -216,10 +217,12 @@ class _CollectionAppBarState extends State with SingleTickerPr if (AvesApp.mode == AppMode.main) PopupMenuItem( value: CollectionAction.select, + enabled: isNotEmpty, child: MenuRow(text: 'Select', icon: AIcons.select), ), PopupMenuItem( value: CollectionAction.stats, + enabled: isNotEmpty, child: MenuRow(text: 'Stats', icon: AIcons.stats), ), if (AvesApp.mode == AppMode.main && canAddShortcuts) @@ -248,6 +251,7 @@ class _CollectionAppBarState extends State with SingleTickerPr PopupMenuDivider(), PopupMenuItem( value: CollectionAction.selectAll, + enabled: collection.selection.length < collection.entryCount, child: MenuRow(text: 'Select all'), ), PopupMenuItem( diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index fc4441c79..7bfaffe0a 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -146,15 +146,13 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware opStream: ImageFileService.delete(selection), itemCount: selectionCount, onDone: (processed) { - final deletedUris = processed.where((e) => e.success).map((e) => e.uri).toList(); + final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); final deletedCount = deletedUris.length; if (deletedCount < selectionCount) { final count = selectionCount - deletedCount; showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}'); } - if (deletedCount > 0) { - source.removeEntries(selection.where((e) => deletedUris.contains(e.uri)).toSet()); - } + source.removeEntries(deletedUris); collection.clearSelection(); collection.browse(); }, diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index b600f0380..a21969462 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -27,7 +27,9 @@ class AppDebugPage extends StatefulWidget { } class _AppDebugPageState extends State { - List get entries => widget.source.rawEntries; + CollectionSource get source => widget.source; + + Set get visibleEntries => source.visibleEntries; static OverlayEntry _taskQueueOverlayEntry; @@ -59,7 +61,7 @@ class _AppDebugPageState extends State { } Widget _buildGeneralTabView() { - final catalogued = entries.where((entry) => entry.isCatalogued); + final catalogued = visibleEntries.where((entry) => entry.isCatalogued); final withGps = catalogued.where((entry) => entry.hasGps); final located = withGps.where((entry) => entry.isLocated); return AvesExpansionTile( @@ -98,7 +100,8 @@ class _AppDebugPageState extends State { padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), child: InfoRowGroup( { - 'Entries': '${entries.length}', + 'All entries': '${source.allEntries.length}', + 'Visible entries': '${visibleEntries.length}', 'Catalogued': '${catalogued.length}', 'With GPS': '${withGps.length}', 'With address': '${located.length}', diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index 90b8750c4..a484a158e 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -44,6 +44,7 @@ class AlbumListPage extends StatelessWidget { settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, ChipAction.rename, ChipAction.delete, + ChipAction.hide, ], filterSections: getAlbumEntries(source), emptyBuilder: () => EmptyContent( diff --git a/lib/widgets/filter_grids/common/chip_action_delegate.dart b/lib/widgets/filter_grids/common/chip_action_delegate.dart index d8eb689df..b2fa1c7a1 100644 --- a/lib/widgets/filter_grids/common/chip_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_action_delegate.dart @@ -16,6 +16,12 @@ import 'package:intl/intl.dart'; import 'package:path/path.dart' as path; class ChipActionDelegate { + final CollectionSource source; + + ChipActionDelegate({ + @required this.source, + }); + void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) { switch (action) { case ChipAction.pin: @@ -24,6 +30,9 @@ class ChipActionDelegate { case ChipAction.unpin: settings.pinnedFilters = settings.pinnedFilters..remove(filter); break; + case ChipAction.hide: + source.changeFilterVisibility(filter, false); + break; default: break; } @@ -31,11 +40,9 @@ class ChipActionDelegate { } class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { - final CollectionSource source; - AlbumChipActionDelegate({ - @required this.source, - }); + @required CollectionSource source, + }) : super(source: source); @override void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) { @@ -53,7 +60,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per } Future _showDeleteDialog(BuildContext context, AlbumFilter filter) async { - final selection = source.rawEntries.where(filter.filter).toSet(); + final selection = source.visibleEntries.where(filter.filter).toSet(); final count = selection.length; final confirmed = await showDialog( @@ -85,15 +92,13 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per opStream: ImageFileService.delete(selection), itemCount: selectionCount, onDone: (processed) { - final deletedUris = processed.where((e) => e.success).map((e) => e.uri).toList(); + final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); final deletedCount = deletedUris.length; if (deletedCount < selectionCount) { final count = selectionCount - deletedCount; showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}'); } - if (deletedCount > 0) { - source.removeEntries(selection.where((e) => deletedUris.contains(e.uri)).toSet()); - } + source.removeEntries(deletedUris); }, ); } @@ -108,7 +113,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per if (!await checkStoragePermissionForAlbums(context, {album})) return; - final todoEntries = source.rawEntries.where(filter.filter).toSet(); + final todoEntries = source.visibleEntries.where(filter.filter).toSet(); final destinationAlbum = path.join(path.dirname(album), newName); if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return; diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index d67f41a8b..448b7f62f 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -151,7 +151,7 @@ class FilterNavigationPage extends StatelessWidget { } static int compareFiltersByDate(FilterGridItem a, FilterGridItem b) { - final c = b.entry.bestDate?.compareTo(a.entry.bestDate) ?? -1; + final c = (b.entry?.bestDate ?? DateTime.fromMillisecondsSinceEpoch(0)).compareTo(a.entry?.bestDate ?? DateTime.fromMillisecondsSinceEpoch(0)); return c != 0 ? c : a.filter.compareTo(b.filter); } diff --git a/lib/widgets/filter_grids/common/section_keys.dart b/lib/widgets/filter_grids/common/section_keys.dart index 9828bf51b..1f672758f 100644 --- a/lib/widgets/filter_grids/common/section_keys.dart +++ b/lib/widgets/filter_grids/common/section_keys.dart @@ -75,8 +75,8 @@ extension ExtraAlbumImportance on AlbumImportance { class StorageVolumeSectionKey extends ChipSectionKey { final StorageVolume volume; - StorageVolumeSectionKey(this.volume) : super(title: volume.description); + StorageVolumeSectionKey(this.volume) : super(title: volume?.description ?? 'Unknown'); @override - Widget get leading => volume.isRemovable ? Icon(AIcons.removableStorage) : null; + Widget get leading => (volume?.isRemovable ?? false) ? Icon(AIcons.removableStorage) : null; } diff --git a/lib/widgets/filter_grids/countries_page.dart b/lib/widgets/filter_grids/countries_page.dart index f348480c4..eceecf5c8 100644 --- a/lib/widgets/filter_grids/countries_page.dart +++ b/lib/widgets/filter_grids/countries_page.dart @@ -34,9 +34,10 @@ class CountryListPage extends StatelessWidget { source: source, title: 'Countries', chipSetActionDelegate: CountryChipSetActionDelegate(source: source), - chipActionDelegate: ChipActionDelegate(), + chipActionDelegate: ChipActionDelegate(source: source), chipActionsBuilder: (filter) => [ settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, + ChipAction.hide, ], filterSections: _getCountryEntries(), emptyBuilder: () => EmptyContent( diff --git a/lib/widgets/filter_grids/tags_page.dart b/lib/widgets/filter_grids/tags_page.dart index 5743cec5d..c31525c3e 100644 --- a/lib/widgets/filter_grids/tags_page.dart +++ b/lib/widgets/filter_grids/tags_page.dart @@ -34,9 +34,10 @@ class TagListPage extends StatelessWidget { source: source, title: 'Tags', chipSetActionDelegate: TagChipSetActionDelegate(source: source), - chipActionDelegate: ChipActionDelegate(), + chipActionDelegate: ChipActionDelegate(source: source), chipActionsBuilder: (filter) => [ settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, + ChipAction.hide, ], filterSections: _getTagEntries(), emptyBuilder: () => EmptyContent( diff --git a/lib/widgets/search/expandable_filter_row.dart b/lib/widgets/search/expandable_filter_row.dart index a838d3901..c5501f33f 100644 --- a/lib/widgets/search/expandable_filter_row.dart +++ b/lib/widgets/search/expandable_filter_row.dart @@ -15,7 +15,7 @@ class ExpandableFilterRow extends StatelessWidget { const ExpandableFilterRow({ this.title, @required this.filters, - this.expandedNotifier, + @required this.expandedNotifier, this.heroTypeBuilder, @required this.onTap, }); @@ -29,7 +29,7 @@ class ExpandableFilterRow extends StatelessWidget { final hasTitle = title != null && title.isNotEmpty; - final isExpanded = hasTitle && expandedNotifier?.value == title; + final isExpanded = hasTitle && expandedNotifier.value == title; Widget titleRow; if (hasTitle) { @@ -52,7 +52,7 @@ class ExpandableFilterRow extends StatelessWidget { ); } - final filtersList = filters.toList(); + final filterList = filters.toList(); final wrap = Container( key: ValueKey('wrap$title'), padding: EdgeInsets.symmetric(horizontal: horizontalPadding), @@ -62,7 +62,7 @@ class ExpandableFilterRow extends StatelessWidget { child: Wrap( spacing: horizontalPadding, runSpacing: verticalPadding, - children: filtersList.map(_buildFilterChip).toList(), + children: filterList.map(_buildFilterChip).toList(), ), ); final list = Container( @@ -76,10 +76,10 @@ class ExpandableFilterRow extends StatelessWidget { physics: BouncingScrollPhysics(), padding: EdgeInsets.symmetric(horizontal: horizontalPadding), itemBuilder: (context, index) { - return index < filtersList.length ? _buildFilterChip(filtersList[index]) : null; + return index < filterList.length ? _buildFilterChip(filterList[index]) : null; }, separatorBuilder: (context, index) => SizedBox(width: 8), - itemCount: filtersList.length, + itemCount: filterList.length, ), ); final filterChips = isExpanded ? wrap : list; diff --git a/lib/widgets/settings/hidden_filters.dart b/lib/widgets/settings/hidden_filters.dart new file mode 100644 index 000000000..ccbde9d95 --- /dev/null +++ b/lib/widgets/settings/hidden_filters.dart @@ -0,0 +1,67 @@ +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class HiddenFilters extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Selector>( + selector: (context, s) => s.hiddenFilters, + builder: (context, hiddenFilters, child) { + return ListTile( + title: hiddenFilters.isEmpty ? Text('There are no hidden filters') : Text('Hidden filters'), + trailing: hiddenFilters.isEmpty + ? null + : OutlinedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: HiddenFilterPage.routeName), + builder: (context) => HiddenFilterPage(), + ), + ); + }, + child: Text('Edit'.toUpperCase()), + ), + ); + }); + } +} + +class HiddenFilterPage extends StatelessWidget { + static const routeName = '/settings/hidden'; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Hidden Filters'), + ), + body: SafeArea( + child: Padding( + padding: EdgeInsets.all(8), + child: Consumer( + builder: (context, settings, child) { + final filterList = settings.hiddenFilters.toList()..sort(); + return Wrap( + spacing: 8, + runSpacing: 8, + children: filterList + .map((filter) => AvesFilterChip( + filter: filter, + removable: true, + onTap: (filter) => context.read().changeFilterVisibility(filter, true), + )) + .toList(), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index e82cab105..5da66e45e 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -9,6 +9,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/settings/access_grants.dart'; import 'package:aves/widgets/settings/entry_background.dart'; +import 'package:aves/widgets/settings/hidden_filters.dart'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:provider/provider.dart'; @@ -241,6 +242,7 @@ class _SettingsPageState extends State { onChanged: (v) => settings.isCrashlyticsEnabled = v, title: Text('Allow anonymous analytics and crash reporting'), ), + HiddenFilters(), Padding( padding: EdgeInsets.only(top: 8, bottom: 16), child: GrantedDirectories(), diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats.dart index a11c82b21..92132e5b6 100644 --- a/lib/widgets/stats/stats.dart +++ b/lib/widgets/stats/stats.dart @@ -29,7 +29,7 @@ class StatsPage extends StatelessWidget { final CollectionLens parentCollection; final Map entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {}; - List get entries => parentCollection?.sortedEntries ?? source.rawEntries; + Set get entries => parentCollection?.sortedEntries?.toSet() ?? source.visibleEntries; static const mimeDonutMinWidth = 124.0; diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index d53ea166b..c85c8cc86 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -141,7 +141,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix showFeedback(context, 'Failed'); } else { if (hasCollection) { - collection.source.removeEntries({entry}); + collection.source.removeEntries({entry.uri}); } EntryDeletedNotification(entry).dispatch(context); }