#13 hidden filters

This commit is contained in:
Thibault Deckers 2021-02-09 13:38:53 +09:00
parent c5ee55adb0
commit ea3d79afbe
25 changed files with 260 additions and 107 deletions

View file

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

View file

@ -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}';

View file

@ -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<CollectionFilter> newValue) => setAndNotify(pinnedFiltersKey, newValue.map((filter) => filter.toJson()).toList());
Set<CollectionFilter> get hiddenFilters => (_prefs.getStringList(hiddenFiltersKey) ?? []).map(CollectionFilter.fromJson).toSet();
set hiddenFilters(Set<CollectionFilter> newValue) => setAndNotify(hiddenFiltersKey, newValue.map((filter) => filter.toJson()).toList());
// viewer
bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, false);

View file

@ -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,10 +76,18 @@ mixin AlbumMixin on SourceBase {
)));
}
void addDirectory(Iterable<String> albums) {
void updateDirectories() {
final visibleDirectories = visibleEntries.map((entry) => entry.directory).toSet();
addDirectories(visibleDirectories);
cleanEmptyAlbums();
}
void addDirectories(Set<String> albums) {
if (!_directories.containsAll(albums)) {
_directories.addAll(albums);
_notifyAlbumChange();
}
}
void cleanEmptyAlbums([Set<String> albums]) {
final emptyAlbums = (albums ?? _directories).where(_isEmptyAlbum).toSet();
@ -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));
}
}

View file

@ -25,7 +25,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
int id;
bool listenToSource;
List<AvesEntry> _filteredEntries;
List<AvesEntry> _filteredSortedEntries;
List<StreamSubscription> _subscriptions = [];
Map<SectionKey, List<AvesEntry>> 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<AvesEntry> _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<AvesEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
sections = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory));
break;
case EntryGroupFactor.month:
sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.monthTaken));
sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredSortedEntries, (entry) => EntryDateSectionKey(entry.monthTaken));
break;
case EntryGroupFactor.day:
sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.dayTaken));
sections = groupBy<AvesEntry, EntryDateSectionKey>(_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<AvesEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
final byAlbum = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory));
sections = SplayTreeMap<EntryAlbumSectionKey, List<AvesEntry>>.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)));

View file

@ -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<AvesEntry> _rawEntries = [];
EventBus get eventBus;
List<AvesEntry> get rawEntries => List.unmodifiable(_rawEntries);
final EventBus _eventBus = EventBus();
EventBus get eventBus => _eventBus;
Set<AvesEntry> get visibleEntries;
List<AvesEntry> 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<AvesEntry> _rawEntries = {};
// TODO TLAD use `Set.unmodifiable()` when possible
Set<AvesEntry> get allEntries => Set.of(_rawEntries);
Set<AvesEntry> _visibleEntries;
@override
Set<AvesEntry> get visibleEntries {
// TODO TLAD use `Set.unmodifiable()` when possible
_visibleEntries ??= Set.of(_applyHiddenFilters(_rawEntries));
return _visibleEntries;
}
List<AvesEntry> _sortedEntriesByDate;
@override
List<AvesEntry> 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<AvesEntry> entries) {
Iterable<AvesEntry> _applyHiddenFilters(Iterable<AvesEntry> entries) {
final hiddenFilters = settings.hiddenFilters;
return hiddenFilters.isEmpty ? entries : entries.where((entry) => !hiddenFilters.any((filter) => filter.filter(entry)));
}
void _invalidate([Set<AvesEntry> entries]) {
_visibleEntries = null;
_sortedEntriesByDate = null;
invalidateAlbumFilterSummary(entries: entries);
invalidateCountryFilterSummary(entries);
invalidateTagFilterSummary(entries);
}
void addEntries(Set<AvesEntry> 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<AvesEntry> entries) {
if (entries.isEmpty) return;
void removeEntries(Set<String> 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<void> _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<AvesEntry> 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 }

View file

@ -19,7 +19,7 @@ mixin LocationMixin on SourceBase {
Future<void> 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<AvesEntry, bool>(rawEntries.where((entry) => entry.hasGps), (entry) => entry.isLocated);
final byLocated = groupBy<AvesEntry, bool>(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<String>.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));
}
}

View file

@ -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 = <AvesEntry>{}, pendingNewEntries = <AvesEntry>{};
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 = <AvesEntry>{};
@ -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);

View file

@ -14,7 +14,7 @@ mixin TagMixin on SourceBase {
Future<void> 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<void> 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));
}
}

View file

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

View file

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

View file

@ -194,6 +194,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
return PopupMenuButton<CollectionAction>(
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<CollectionAppBar> 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<CollectionAppBar> with SingleTickerPr
PopupMenuDivider(),
PopupMenuItem(
value: CollectionAction.selectAll,
enabled: collection.selection.length < collection.entryCount,
child: MenuRow(text: 'Select all'),
),
PopupMenuItem(

View file

@ -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();
},

View file

@ -27,7 +27,9 @@ class AppDebugPage extends StatefulWidget {
}
class _AppDebugPageState extends State<AppDebugPage> {
List<AvesEntry> get entries => widget.source.rawEntries;
CollectionSource get source => widget.source;
Set<AvesEntry> get visibleEntries => source.visibleEntries;
static OverlayEntry _taskQueueOverlayEntry;
@ -59,7 +61,7 @@ class _AppDebugPageState extends State<AppDebugPage> {
}
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<AppDebugPage> {
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}',

View file

@ -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(

View file

@ -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<void> _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<bool>(
@ -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;

View file

@ -151,7 +151,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
}
static int compareFiltersByDate(FilterGridItem<CollectionFilter> a, FilterGridItem<CollectionFilter> 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);
}

View file

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

View file

@ -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(

View file

@ -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(

View file

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

View file

@ -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<Settings, Set<CollectionFilter>>(
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<Settings>(
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<CollectionSource>().changeFilterVisibility(filter, true),
))
.toList(),
);
},
),
),
),
);
}
}

View file

@ -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<SettingsPage> {
onChanged: (v) => settings.isCrashlyticsEnabled = v,
title: Text('Allow anonymous analytics and crash reporting'),
),
HiddenFilters(),
Padding(
padding: EdgeInsets.only(top: 8, bottom: 16),
child: GrantedDirectories(),

View file

@ -29,7 +29,7 @@ class StatsPage extends StatelessWidget {
final CollectionLens parentCollection;
final Map<String, int> entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {};
List<AvesEntry> get entries => parentCollection?.sortedEntries ?? source.rawEntries;
Set<AvesEntry> get entries => parentCollection?.sortedEntries?.toSet() ?? source.visibleEntries;
static const mimeDonutMinWidth = 124.0;

View file

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