#13 hidden filters
This commit is contained in:
parent
c5ee55adb0
commit
ea3d79afbe
25 changed files with 260 additions and 107 deletions
|
@ -10,6 +10,7 @@ enum ChipSetAction {
|
||||||
|
|
||||||
enum ChipAction {
|
enum ChipAction {
|
||||||
delete,
|
delete,
|
||||||
|
hide,
|
||||||
pin,
|
pin,
|
||||||
unpin,
|
unpin,
|
||||||
rename,
|
rename,
|
||||||
|
@ -20,6 +21,8 @@ extension ExtraChipAction on ChipAction {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
case ChipAction.delete:
|
case ChipAction.delete:
|
||||||
return 'Delete';
|
return 'Delete';
|
||||||
|
case ChipAction.hide:
|
||||||
|
return 'Hide';
|
||||||
case ChipAction.pin:
|
case ChipAction.pin:
|
||||||
return 'Pin to top';
|
return 'Pin to top';
|
||||||
case ChipAction.unpin:
|
case ChipAction.unpin:
|
||||||
|
@ -34,6 +37,8 @@ extension ExtraChipAction on ChipAction {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
case ChipAction.delete:
|
case ChipAction.delete:
|
||||||
return AIcons.delete;
|
return AIcons.delete;
|
||||||
|
case ChipAction.hide:
|
||||||
|
return AIcons.hide;
|
||||||
case ChipAction.pin:
|
case ChipAction.pin:
|
||||||
case ChipAction.unpin:
|
case ChipAction.unpin:
|
||||||
return AIcons.pin;
|
return AIcons.pin;
|
||||||
|
|
|
@ -172,14 +172,8 @@ class AvesEntry {
|
||||||
addressChangeNotifier.dispose();
|
addressChangeNotifier.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
// do not implement [Object.==] and [Object.hashCode] using mutable attributes (e.g. `uri`)
|
||||||
bool operator ==(Object other) {
|
// so that we can reliably use instances in a `Set`, which requires consistent hash codes over time
|
||||||
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);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path, pageId=$pageId}';
|
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path, pageId=$pageId}';
|
||||||
|
|
|
@ -43,6 +43,7 @@ class Settings extends ChangeNotifier {
|
||||||
static const countrySortFactorKey = 'country_sort_factor';
|
static const countrySortFactorKey = 'country_sort_factor';
|
||||||
static const tagSortFactorKey = 'tag_sort_factor';
|
static const tagSortFactorKey = 'tag_sort_factor';
|
||||||
static const pinnedFiltersKey = 'pinned_filters';
|
static const pinnedFiltersKey = 'pinned_filters';
|
||||||
|
static const hiddenFiltersKey = 'hidden_filters';
|
||||||
|
|
||||||
// viewer
|
// viewer
|
||||||
static const showOverlayMinimapKey = 'show_overlay_minimap';
|
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 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
|
// viewer
|
||||||
|
|
||||||
bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, false);
|
bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, false);
|
||||||
|
|
|
@ -34,9 +34,18 @@ mixin AlbumMixin on SourceBase {
|
||||||
final uniqueName = parts.skip(parts.length - partCount).join(separator);
|
final uniqueName = parts.skip(parts.length - partCount).join(separator);
|
||||||
|
|
||||||
final volume = androidFileUtils.getStorageVolume(album);
|
final volume = androidFileUtils.getStorageVolume(album);
|
||||||
final volumeRoot = volume?.path ?? '';
|
if (volume == null) {
|
||||||
final albumRelativePath = album.substring(volumeRoot.length);
|
return uniqueName;
|
||||||
if (uniqueName.length < albumRelativePath.length || volume == null) {
|
}
|
||||||
|
|
||||||
|
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;
|
return uniqueName;
|
||||||
} else if (volume.isPrimary) {
|
} else if (volume.isPrimary) {
|
||||||
return albumRelativePath;
|
return albumRelativePath;
|
||||||
|
@ -67,9 +76,17 @@ mixin AlbumMixin on SourceBase {
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
void addDirectory(Iterable<String> albums) {
|
void updateDirectories() {
|
||||||
_directories.addAll(albums);
|
final visibleDirectories = visibleEntries.map((entry) => entry.directory).toSet();
|
||||||
_notifyAlbumChange();
|
addDirectories(visibleDirectories);
|
||||||
|
cleanEmptyAlbums();
|
||||||
|
}
|
||||||
|
|
||||||
|
void addDirectories(Set<String> albums) {
|
||||||
|
if (!_directories.containsAll(albums)) {
|
||||||
|
_directories.addAll(albums);
|
||||||
|
_notifyAlbumChange();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void cleanEmptyAlbums([Set<String> albums]) {
|
void cleanEmptyAlbums([Set<String> 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
|
// filter summary
|
||||||
|
|
||||||
|
@ -105,11 +122,11 @@ mixin AlbumMixin on SourceBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
int albumEntryCount(AlbumFilter filter) {
|
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) {
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
int id;
|
int id;
|
||||||
bool listenToSource;
|
bool listenToSource;
|
||||||
|
|
||||||
List<AvesEntry> _filteredEntries;
|
List<AvesEntry> _filteredSortedEntries;
|
||||||
List<StreamSubscription> _subscriptions = [];
|
List<StreamSubscription> _subscriptions = [];
|
||||||
|
|
||||||
Map<SectionKey, List<AvesEntry>> sections = Map.unmodifiable({});
|
Map<SectionKey, List<AvesEntry>> sections = Map.unmodifiable({});
|
||||||
|
@ -64,9 +64,9 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
super.dispose();
|
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
|
// sorted as displayed to the user, i.e. sorted then grouped, not an absolute order on all entries
|
||||||
List<AvesEntry> _sortedEntries;
|
List<AvesEntry> _sortedEntries;
|
||||||
|
@ -122,20 +122,20 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyFilters() {
|
void _applyFilters() {
|
||||||
final rawEntries = source.rawEntries;
|
final entries = source.visibleEntries;
|
||||||
_filteredEntries = List.of(filters.isEmpty ? rawEntries : rawEntries.where((entry) => filters.fold(true, (prev, filter) => prev && filter.filter(entry))));
|
_filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.fold(true, (prev, filter) => prev && filter.filter(entry))));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applySort() {
|
void _applySort() {
|
||||||
switch (sortFactor) {
|
switch (sortFactor) {
|
||||||
case EntrySortFactor.date:
|
case EntrySortFactor.date:
|
||||||
_filteredEntries.sort(AvesEntry.compareByDate);
|
_filteredSortedEntries.sort(AvesEntry.compareByDate);
|
||||||
break;
|
break;
|
||||||
case EntrySortFactor.size:
|
case EntrySortFactor.size:
|
||||||
_filteredEntries.sort(AvesEntry.compareBySize);
|
_filteredSortedEntries.sort(AvesEntry.compareBySize);
|
||||||
break;
|
break;
|
||||||
case EntrySortFactor.name:
|
case EntrySortFactor.name:
|
||||||
_filteredEntries.sort(AvesEntry.compareByName);
|
_filteredSortedEntries.sort(AvesEntry.compareByName);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -145,28 +145,28 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
case EntrySortFactor.date:
|
case EntrySortFactor.date:
|
||||||
switch (groupFactor) {
|
switch (groupFactor) {
|
||||||
case EntryGroupFactor.album:
|
case EntryGroupFactor.album:
|
||||||
sections = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
sections = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
||||||
break;
|
break;
|
||||||
case EntryGroupFactor.month:
|
case EntryGroupFactor.month:
|
||||||
sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.monthTaken));
|
sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredSortedEntries, (entry) => EntryDateSectionKey(entry.monthTaken));
|
||||||
break;
|
break;
|
||||||
case EntryGroupFactor.day:
|
case EntryGroupFactor.day:
|
||||||
sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.dayTaken));
|
sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredSortedEntries, (entry) => EntryDateSectionKey(entry.dayTaken));
|
||||||
break;
|
break;
|
||||||
case EntryGroupFactor.none:
|
case EntryGroupFactor.none:
|
||||||
sections = Map.fromEntries([
|
sections = Map.fromEntries([
|
||||||
MapEntry(null, _filteredEntries),
|
MapEntry(null, _filteredSortedEntries),
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case EntrySortFactor.size:
|
case EntrySortFactor.size:
|
||||||
sections = Map.fromEntries([
|
sections = Map.fromEntries([
|
||||||
MapEntry(null, _filteredEntries),
|
MapEntry(null, _filteredSortedEntries),
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
case EntrySortFactor.name:
|
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));
|
sections = SplayTreeMap<EntryAlbumSectionKey, List<AvesEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory, b.directory));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -191,7 +191,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
// we should remove obsolete entries and sections
|
// we should remove obsolete entries and sections
|
||||||
// but do not apply sort/group
|
// but do not apply sort/group
|
||||||
// as section order change would surprise the user while browsing
|
// as section order change would surprise the user while browsing
|
||||||
_filteredEntries.removeWhere(entries.contains);
|
_filteredSortedEntries.removeWhere(entries.contains);
|
||||||
_sortedEntries?.removeWhere(entries.contains);
|
_sortedEntries?.removeWhere(entries.contains);
|
||||||
sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains));
|
sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains));
|
||||||
sections = Map.unmodifiable(Map.fromEntries(sections.entries.where((kv) => kv.value.isNotEmpty)));
|
sections = Map.unmodifiable(Map.fromEntries(sections.entries.where((kv) => kv.value.isNotEmpty)));
|
||||||
|
|
|
@ -8,6 +8,7 @@ import 'package:aves/model/filters/location.dart';
|
||||||
import 'package:aves/model/filters/tag.dart';
|
import 'package:aves/model/filters/tag.dart';
|
||||||
import 'package:aves/model/metadata.dart';
|
import 'package:aves/model/metadata.dart';
|
||||||
import 'package:aves/model/metadata_db.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/album.dart';
|
||||||
import 'package:aves/model/source/location.dart';
|
import 'package:aves/model/source/location.dart';
|
||||||
import 'package:aves/model/source/tag.dart';
|
import 'package:aves/model/source/tag.dart';
|
||||||
|
@ -16,13 +17,9 @@ import 'package:event_bus/event_bus.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
mixin SourceBase {
|
mixin SourceBase {
|
||||||
final List<AvesEntry> _rawEntries = [];
|
EventBus get eventBus;
|
||||||
|
|
||||||
List<AvesEntry> get rawEntries => List.unmodifiable(_rawEntries);
|
Set<AvesEntry> get visibleEntries;
|
||||||
|
|
||||||
final EventBus _eventBus = EventBus();
|
|
||||||
|
|
||||||
EventBus get eventBus => _eventBus;
|
|
||||||
|
|
||||||
List<AvesEntry> get sortedEntriesByDate;
|
List<AvesEntry> get sortedEntriesByDate;
|
||||||
|
|
||||||
|
@ -34,11 +31,30 @@ mixin SourceBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
|
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;
|
List<AvesEntry> _sortedEntriesByDate;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<AvesEntry> get sortedEntriesByDate {
|
List<AvesEntry> get sortedEntriesByDate {
|
||||||
_sortedEntriesByDate ??= List.of(_rawEntries)..sort(AvesEntry.compareByDate);
|
_sortedEntriesByDate ??= List.unmodifiable(visibleEntries.toList()..sort(AvesEntry.compareByDate));
|
||||||
return _sortedEntriesByDate;
|
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');
|
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 (entries.isEmpty) return;
|
||||||
if (_rawEntries.isNotEmpty) {
|
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));
|
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
|
||||||
}
|
}
|
||||||
entries.forEach((entry) {
|
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;
|
entry.catalogDateMillis = _savedDates.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null)?.dateMillis;
|
||||||
});
|
});
|
||||||
_rawEntries.addAll(entries);
|
_rawEntries.addAll(entries);
|
||||||
addDirectory(_rawEntries.map((entry) => entry.directory));
|
_invalidate(entries);
|
||||||
_invalidateFilterSummaries(entries);
|
|
||||||
|
addDirectories(_applyHiddenFilters(entries).map((entry) => entry.directory).toSet());
|
||||||
eventBus.fire(EntryAddedEvent(entries));
|
eventBus.fire(EntryAddedEvent(entries));
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeEntries(Set<AvesEntry> entries) {
|
void removeEntries(Set<String> uris) {
|
||||||
if (entries.isEmpty) return;
|
if (uris.isEmpty) return;
|
||||||
|
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
|
||||||
entries.forEach((entry) => entry.removeFromFavourites());
|
entries.forEach((entry) => entry.removeFromFavourites());
|
||||||
_rawEntries.removeWhere(entries.contains);
|
_rawEntries.removeAll(entries);
|
||||||
|
_invalidate(entries);
|
||||||
|
|
||||||
cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet());
|
cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet());
|
||||||
updateLocations();
|
updateLocations();
|
||||||
updateTags();
|
updateTags();
|
||||||
_invalidateFilterSummaries(entries);
|
|
||||||
eventBus.fire(EntryRemovedEvent(entries));
|
eventBus.fire(EntryRemovedEvent(entries));
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearEntries() {
|
void clearEntries() {
|
||||||
_rawEntries.clear();
|
_rawEntries.clear();
|
||||||
cleanEmptyAlbums();
|
_invalidate();
|
||||||
|
|
||||||
|
updateDirectories();
|
||||||
updateLocations();
|
updateLocations();
|
||||||
updateTags();
|
updateTags();
|
||||||
_invalidateFilterSummaries();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _moveEntry(AvesEntry entry, Map newFields, bool isFavourite) async {
|
Future<void> _moveEntry(AvesEntry entry, Map newFields, bool isFavourite) async {
|
||||||
|
@ -154,13 +187,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
}
|
}
|
||||||
|
|
||||||
if (copy) {
|
if (copy) {
|
||||||
addAll(movedEntries);
|
addEntries(movedEntries);
|
||||||
} else {
|
} else {
|
||||||
cleanEmptyAlbums(fromAlbums);
|
cleanEmptyAlbums(fromAlbums);
|
||||||
addDirectory({destinationAlbum});
|
addDirectories({destinationAlbum});
|
||||||
}
|
}
|
||||||
invalidateAlbumFilterSummary(directories: fromAlbums);
|
invalidateAlbumFilterSummary(directories: fromAlbums);
|
||||||
_invalidateFilterSummaries(movedEntries);
|
_invalidate(movedEntries);
|
||||||
eventBus.fire(EntryMovedEvent(movedEntries));
|
eventBus.fire(EntryMovedEvent(movedEntries));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,13 +207,6 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
|
|
||||||
// filter summary
|
// filter summary
|
||||||
|
|
||||||
void _invalidateFilterSummaries([Set<AvesEntry> entries]) {
|
|
||||||
_sortedEntriesByDate = null;
|
|
||||||
invalidateAlbumFilterSummary(entries: entries);
|
|
||||||
invalidateCountryFilterSummary(entries);
|
|
||||||
invalidateTagFilterSummary(entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
int count(CollectionFilter filter) {
|
int count(CollectionFilter filter) {
|
||||||
if (filter is AlbumFilter) return albumEntryCount(filter);
|
if (filter is AlbumFilter) return albumEntryCount(filter);
|
||||||
if (filter is LocationFilter) return countryEntryCount(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);
|
if (filter is TagFilter) return tagRecentEntry(filter);
|
||||||
return null;
|
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 }
|
enum SourceState { loading, cataloguing, locating, ready }
|
||||||
|
|
|
@ -19,7 +19,7 @@ mixin LocationMixin on SourceBase {
|
||||||
Future<void> loadAddresses() async {
|
Future<void> loadAddresses() async {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
final saved = await metadataDb.loadAddresses();
|
final saved = await metadataDb.loadAddresses();
|
||||||
rawEntries.forEach((entry) {
|
visibleEntries.forEach((entry) {
|
||||||
final contentId = entry.contentId;
|
final contentId = entry.contentId;
|
||||||
entry.addressDetails = saved.firstWhere((address) => address.contentId == contentId, orElse: () => null);
|
entry.addressDetails = saved.firstWhere((address) => address.contentId == contentId, orElse: () => null);
|
||||||
});
|
});
|
||||||
|
@ -31,7 +31,7 @@ mixin LocationMixin on SourceBase {
|
||||||
if (!(await availability.canGeolocate)) return;
|
if (!(await availability.canGeolocate)) return;
|
||||||
|
|
||||||
// final stopwatch = Stopwatch()..start();
|
// 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] ?? [];
|
final todo = byLocated[false] ?? [];
|
||||||
if (todo.isEmpty) return;
|
if (todo.isEmpty) return;
|
||||||
|
|
||||||
|
@ -91,7 +91,7 @@ mixin LocationMixin on SourceBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateLocations() {
|
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));
|
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
|
// the same country code could be found with different country names
|
||||||
|
@ -121,11 +121,11 @@ mixin LocationMixin on SourceBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
int countryEntryCount(LocationFilter filter) {
|
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) {
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -54,7 +54,7 @@ class MediaStoreSource extends CollectionSource {
|
||||||
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
|
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
|
||||||
|
|
||||||
// show known entries
|
// show known entries
|
||||||
addAll(oldEntries);
|
addEntries(oldEntries);
|
||||||
await loadCatalogMetadata(); // 600ms for 5500 entries
|
await loadCatalogMetadata(); // 600ms for 5500 entries
|
||||||
await loadAddresses(); // 200ms for 3000 entries
|
await loadAddresses(); // 200ms for 3000 entries
|
||||||
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
|
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
|
||||||
|
@ -69,7 +69,7 @@ class MediaStoreSource extends CollectionSource {
|
||||||
final allNewEntries = <AvesEntry>{}, pendingNewEntries = <AvesEntry>{};
|
final allNewEntries = <AvesEntry>{}, pendingNewEntries = <AvesEntry>{};
|
||||||
void addPendingEntries() {
|
void addPendingEntries() {
|
||||||
allNewEntries.addAll(pendingNewEntries);
|
allNewEntries.addAll(pendingNewEntries);
|
||||||
addAll(pendingNewEntries);
|
addEntries(pendingNewEntries);
|
||||||
pendingNewEntries.clear();
|
pendingNewEntries.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -89,7 +89,7 @@ class MediaStoreSource extends CollectionSource {
|
||||||
invalidateAlbumFilterSummary(entries: allNewEntries);
|
invalidateAlbumFilterSummary(entries: allNewEntries);
|
||||||
|
|
||||||
final analytics = FirebaseAnalytics();
|
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()));
|
unawaited(analytics.setUserProperty(name: 'album_count', value: (ceilBy(rawAlbums.length, 1)).toString()));
|
||||||
|
|
||||||
stateNotifier.value = SourceState.cataloguing;
|
stateNotifier.value = SourceState.cataloguing;
|
||||||
|
@ -124,9 +124,9 @@ class MediaStoreSource extends CollectionSource {
|
||||||
|
|
||||||
// clean up obsolete entries
|
// clean up obsolete entries
|
||||||
final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(uriByContentId.keys.toList())).toSet();
|
final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(uriByContentId.keys.toList())).toSet();
|
||||||
|
final obsoleteUris = obsoleteContentIds.map((contentId) => uriByContentId[contentId]).toSet();
|
||||||
|
removeEntries(obsoleteUris);
|
||||||
obsoleteContentIds.forEach(uriByContentId.remove);
|
obsoleteContentIds.forEach(uriByContentId.remove);
|
||||||
final obsoleteEntries = rawEntries.where((e) => obsoleteContentIds.contains(e.contentId)).toSet();
|
|
||||||
removeEntries(obsoleteEntries);
|
|
||||||
|
|
||||||
// fetch new entries
|
// fetch new entries
|
||||||
final newEntries = <AvesEntry>{};
|
final newEntries = <AvesEntry>{};
|
||||||
|
@ -135,7 +135,7 @@ class MediaStoreSource extends CollectionSource {
|
||||||
final uri = kv.value;
|
final uri = kv.value;
|
||||||
final sourceEntry = await ImageFileService.getEntry(uri, null);
|
final sourceEntry = await ImageFileService.getEntry(uri, null);
|
||||||
if (sourceEntry != 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) {
|
if (existingEntry == null || sourceEntry.dateModifiedSecs > existingEntry.dateModifiedSecs) {
|
||||||
final volume = androidFileUtils.getStorageVolume(sourceEntry.path);
|
final volume = androidFileUtils.getStorageVolume(sourceEntry.path);
|
||||||
if (volume != null) {
|
if (volume != null) {
|
||||||
|
@ -149,7 +149,7 @@ class MediaStoreSource extends CollectionSource {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newEntries.isNotEmpty) {
|
if (newEntries.isNotEmpty) {
|
||||||
addAll(newEntries);
|
addEntries(newEntries);
|
||||||
await metadataDb.saveEntries(newEntries);
|
await metadataDb.saveEntries(newEntries);
|
||||||
invalidateAlbumFilterSummary(entries: newEntries);
|
invalidateAlbumFilterSummary(entries: newEntries);
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ mixin TagMixin on SourceBase {
|
||||||
Future<void> loadCatalogMetadata() async {
|
Future<void> loadCatalogMetadata() async {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
final saved = await metadataDb.loadMetadataEntries();
|
final saved = await metadataDb.loadMetadataEntries();
|
||||||
rawEntries.forEach((entry) {
|
visibleEntries.forEach((entry) {
|
||||||
final contentId = entry.contentId;
|
final contentId = entry.contentId;
|
||||||
entry.catalogMetadata = saved.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null);
|
entry.catalogMetadata = saved.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null);
|
||||||
});
|
});
|
||||||
|
@ -24,7 +24,7 @@ mixin TagMixin on SourceBase {
|
||||||
|
|
||||||
Future<void> catalogEntries() async {
|
Future<void> catalogEntries() async {
|
||||||
// final stopwatch = Stopwatch()..start();
|
// final stopwatch = Stopwatch()..start();
|
||||||
final todo = rawEntries.where((entry) => !entry.isCatalogued).toList();
|
final todo = visibleEntries.where((entry) => !entry.isCatalogued).toList();
|
||||||
if (todo.isEmpty) return;
|
if (todo.isEmpty) return;
|
||||||
|
|
||||||
var progressDone = 0;
|
var progressDone = 0;
|
||||||
|
@ -55,7 +55,7 @@ mixin TagMixin on SourceBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateTags() {
|
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);
|
sortedTags = List.unmodifiable(tags);
|
||||||
|
|
||||||
invalidateTagFilterSummary();
|
invalidateTagFilterSummary();
|
||||||
|
@ -79,11 +79,11 @@ mixin TagMixin on SourceBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
int tagEntryCount(TagFilter filter) {
|
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) {
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -37,6 +37,7 @@ class AIcons {
|
||||||
static const IconData favouriteActive = Icons.favorite;
|
static const IconData favouriteActive = Icons.favorite;
|
||||||
static const IconData goUp = Icons.arrow_upward_outlined;
|
static const IconData goUp = Icons.arrow_upward_outlined;
|
||||||
static const IconData group = Icons.group_work_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 info = Icons.info_outlined;
|
||||||
static const IconData layers = Icons.layers_outlined;
|
static const IconData layers = Icons.layers_outlined;
|
||||||
static const IconData openOutside = Icons.open_in_new_outlined;
|
static const IconData openOutside = Icons.open_in_new_outlined;
|
||||||
|
|
|
@ -42,7 +42,12 @@ class AndroidFileUtils {
|
||||||
|
|
||||||
bool isDownloadPath(String path) => path == downloadPath;
|
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;
|
bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false;
|
||||||
|
|
||||||
|
|
|
@ -194,6 +194,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
return PopupMenuButton<CollectionAction>(
|
return PopupMenuButton<CollectionAction>(
|
||||||
key: Key('appbar-menu-button'),
|
key: Key('appbar-menu-button'),
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
|
final isNotEmpty = !collection.isEmpty;
|
||||||
final hasSelection = collection.selection.isNotEmpty;
|
final hasSelection = collection.selection.isNotEmpty;
|
||||||
return [
|
return [
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
|
@ -216,10 +217,12 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
if (AvesApp.mode == AppMode.main)
|
if (AvesApp.mode == AppMode.main)
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: CollectionAction.select,
|
value: CollectionAction.select,
|
||||||
|
enabled: isNotEmpty,
|
||||||
child: MenuRow(text: 'Select', icon: AIcons.select),
|
child: MenuRow(text: 'Select', icon: AIcons.select),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: CollectionAction.stats,
|
value: CollectionAction.stats,
|
||||||
|
enabled: isNotEmpty,
|
||||||
child: MenuRow(text: 'Stats', icon: AIcons.stats),
|
child: MenuRow(text: 'Stats', icon: AIcons.stats),
|
||||||
),
|
),
|
||||||
if (AvesApp.mode == AppMode.main && canAddShortcuts)
|
if (AvesApp.mode == AppMode.main && canAddShortcuts)
|
||||||
|
@ -248,6 +251,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
PopupMenuDivider(),
|
PopupMenuDivider(),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: CollectionAction.selectAll,
|
value: CollectionAction.selectAll,
|
||||||
|
enabled: collection.selection.length < collection.entryCount,
|
||||||
child: MenuRow(text: 'Select all'),
|
child: MenuRow(text: 'Select all'),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
|
|
|
@ -146,15 +146,13 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
opStream: ImageFileService.delete(selection),
|
opStream: ImageFileService.delete(selection),
|
||||||
itemCount: selectionCount,
|
itemCount: selectionCount,
|
||||||
onDone: (processed) {
|
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;
|
final deletedCount = deletedUris.length;
|
||||||
if (deletedCount < selectionCount) {
|
if (deletedCount < selectionCount) {
|
||||||
final count = selectionCount - deletedCount;
|
final count = selectionCount - deletedCount;
|
||||||
showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}');
|
showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}');
|
||||||
}
|
}
|
||||||
if (deletedCount > 0) {
|
source.removeEntries(deletedUris);
|
||||||
source.removeEntries(selection.where((e) => deletedUris.contains(e.uri)).toSet());
|
|
||||||
}
|
|
||||||
collection.clearSelection();
|
collection.clearSelection();
|
||||||
collection.browse();
|
collection.browse();
|
||||||
},
|
},
|
||||||
|
|
|
@ -27,7 +27,9 @@ class AppDebugPage extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppDebugPageState extends State<AppDebugPage> {
|
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;
|
static OverlayEntry _taskQueueOverlayEntry;
|
||||||
|
|
||||||
|
@ -59,7 +61,7 @@ class _AppDebugPageState extends State<AppDebugPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildGeneralTabView() {
|
Widget _buildGeneralTabView() {
|
||||||
final catalogued = entries.where((entry) => entry.isCatalogued);
|
final catalogued = visibleEntries.where((entry) => entry.isCatalogued);
|
||||||
final withGps = catalogued.where((entry) => entry.hasGps);
|
final withGps = catalogued.where((entry) => entry.hasGps);
|
||||||
final located = withGps.where((entry) => entry.isLocated);
|
final located = withGps.where((entry) => entry.isLocated);
|
||||||
return AvesExpansionTile(
|
return AvesExpansionTile(
|
||||||
|
@ -98,7 +100,8 @@ class _AppDebugPageState extends State<AppDebugPage> {
|
||||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
child: InfoRowGroup(
|
child: InfoRowGroup(
|
||||||
{
|
{
|
||||||
'Entries': '${entries.length}',
|
'All entries': '${source.allEntries.length}',
|
||||||
|
'Visible entries': '${visibleEntries.length}',
|
||||||
'Catalogued': '${catalogued.length}',
|
'Catalogued': '${catalogued.length}',
|
||||||
'With GPS': '${withGps.length}',
|
'With GPS': '${withGps.length}',
|
||||||
'With address': '${located.length}',
|
'With address': '${located.length}',
|
||||||
|
|
|
@ -44,6 +44,7 @@ class AlbumListPage extends StatelessWidget {
|
||||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||||
ChipAction.rename,
|
ChipAction.rename,
|
||||||
ChipAction.delete,
|
ChipAction.delete,
|
||||||
|
ChipAction.hide,
|
||||||
],
|
],
|
||||||
filterSections: getAlbumEntries(source),
|
filterSections: getAlbumEntries(source),
|
||||||
emptyBuilder: () => EmptyContent(
|
emptyBuilder: () => EmptyContent(
|
||||||
|
|
|
@ -16,6 +16,12 @@ import 'package:intl/intl.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
class ChipActionDelegate {
|
class ChipActionDelegate {
|
||||||
|
final CollectionSource source;
|
||||||
|
|
||||||
|
ChipActionDelegate({
|
||||||
|
@required this.source,
|
||||||
|
});
|
||||||
|
|
||||||
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
|
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case ChipAction.pin:
|
case ChipAction.pin:
|
||||||
|
@ -24,6 +30,9 @@ class ChipActionDelegate {
|
||||||
case ChipAction.unpin:
|
case ChipAction.unpin:
|
||||||
settings.pinnedFilters = settings.pinnedFilters..remove(filter);
|
settings.pinnedFilters = settings.pinnedFilters..remove(filter);
|
||||||
break;
|
break;
|
||||||
|
case ChipAction.hide:
|
||||||
|
source.changeFilterVisibility(filter, false);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -31,11 +40,9 @@ class ChipActionDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
final CollectionSource source;
|
|
||||||
|
|
||||||
AlbumChipActionDelegate({
|
AlbumChipActionDelegate({
|
||||||
@required this.source,
|
@required CollectionSource source,
|
||||||
});
|
}) : super(source: source);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
|
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 {
|
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 count = selection.length;
|
||||||
|
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
|
@ -85,15 +92,13 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
|
||||||
opStream: ImageFileService.delete(selection),
|
opStream: ImageFileService.delete(selection),
|
||||||
itemCount: selectionCount,
|
itemCount: selectionCount,
|
||||||
onDone: (processed) {
|
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;
|
final deletedCount = deletedUris.length;
|
||||||
if (deletedCount < selectionCount) {
|
if (deletedCount < selectionCount) {
|
||||||
final count = selectionCount - deletedCount;
|
final count = selectionCount - deletedCount;
|
||||||
showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}');
|
showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}');
|
||||||
}
|
}
|
||||||
if (deletedCount > 0) {
|
source.removeEntries(deletedUris);
|
||||||
source.removeEntries(selection.where((e) => deletedUris.contains(e.uri)).toSet());
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -108,7 +113,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
|
||||||
|
|
||||||
if (!await checkStoragePermissionForAlbums(context, {album})) return;
|
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);
|
final destinationAlbum = path.join(path.dirname(album), newName);
|
||||||
|
|
||||||
if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return;
|
if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return;
|
||||||
|
|
|
@ -151,7 +151,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
static int compareFiltersByDate(FilterGridItem<CollectionFilter> a, FilterGridItem<CollectionFilter> b) {
|
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);
|
return c != 0 ? c : a.filter.compareTo(b.filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -75,8 +75,8 @@ extension ExtraAlbumImportance on AlbumImportance {
|
||||||
class StorageVolumeSectionKey extends ChipSectionKey {
|
class StorageVolumeSectionKey extends ChipSectionKey {
|
||||||
final StorageVolume volume;
|
final StorageVolume volume;
|
||||||
|
|
||||||
StorageVolumeSectionKey(this.volume) : super(title: volume.description);
|
StorageVolumeSectionKey(this.volume) : super(title: volume?.description ?? 'Unknown');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget get leading => volume.isRemovable ? Icon(AIcons.removableStorage) : null;
|
Widget get leading => (volume?.isRemovable ?? false) ? Icon(AIcons.removableStorage) : null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,9 +34,10 @@ class CountryListPage extends StatelessWidget {
|
||||||
source: source,
|
source: source,
|
||||||
title: 'Countries',
|
title: 'Countries',
|
||||||
chipSetActionDelegate: CountryChipSetActionDelegate(source: source),
|
chipSetActionDelegate: CountryChipSetActionDelegate(source: source),
|
||||||
chipActionDelegate: ChipActionDelegate(),
|
chipActionDelegate: ChipActionDelegate(source: source),
|
||||||
chipActionsBuilder: (filter) => [
|
chipActionsBuilder: (filter) => [
|
||||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||||
|
ChipAction.hide,
|
||||||
],
|
],
|
||||||
filterSections: _getCountryEntries(),
|
filterSections: _getCountryEntries(),
|
||||||
emptyBuilder: () => EmptyContent(
|
emptyBuilder: () => EmptyContent(
|
||||||
|
|
|
@ -34,9 +34,10 @@ class TagListPage extends StatelessWidget {
|
||||||
source: source,
|
source: source,
|
||||||
title: 'Tags',
|
title: 'Tags',
|
||||||
chipSetActionDelegate: TagChipSetActionDelegate(source: source),
|
chipSetActionDelegate: TagChipSetActionDelegate(source: source),
|
||||||
chipActionDelegate: ChipActionDelegate(),
|
chipActionDelegate: ChipActionDelegate(source: source),
|
||||||
chipActionsBuilder: (filter) => [
|
chipActionsBuilder: (filter) => [
|
||||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||||
|
ChipAction.hide,
|
||||||
],
|
],
|
||||||
filterSections: _getTagEntries(),
|
filterSections: _getTagEntries(),
|
||||||
emptyBuilder: () => EmptyContent(
|
emptyBuilder: () => EmptyContent(
|
||||||
|
|
|
@ -15,7 +15,7 @@ class ExpandableFilterRow extends StatelessWidget {
|
||||||
const ExpandableFilterRow({
|
const ExpandableFilterRow({
|
||||||
this.title,
|
this.title,
|
||||||
@required this.filters,
|
@required this.filters,
|
||||||
this.expandedNotifier,
|
@required this.expandedNotifier,
|
||||||
this.heroTypeBuilder,
|
this.heroTypeBuilder,
|
||||||
@required this.onTap,
|
@required this.onTap,
|
||||||
});
|
});
|
||||||
|
@ -29,7 +29,7 @@ class ExpandableFilterRow extends StatelessWidget {
|
||||||
|
|
||||||
final hasTitle = title != null && title.isNotEmpty;
|
final hasTitle = title != null && title.isNotEmpty;
|
||||||
|
|
||||||
final isExpanded = hasTitle && expandedNotifier?.value == title;
|
final isExpanded = hasTitle && expandedNotifier.value == title;
|
||||||
|
|
||||||
Widget titleRow;
|
Widget titleRow;
|
||||||
if (hasTitle) {
|
if (hasTitle) {
|
||||||
|
@ -52,7 +52,7 @@ class ExpandableFilterRow extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final filtersList = filters.toList();
|
final filterList = filters.toList();
|
||||||
final wrap = Container(
|
final wrap = Container(
|
||||||
key: ValueKey('wrap$title'),
|
key: ValueKey('wrap$title'),
|
||||||
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
|
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
|
||||||
|
@ -62,7 +62,7 @@ class ExpandableFilterRow extends StatelessWidget {
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
spacing: horizontalPadding,
|
spacing: horizontalPadding,
|
||||||
runSpacing: verticalPadding,
|
runSpacing: verticalPadding,
|
||||||
children: filtersList.map(_buildFilterChip).toList(),
|
children: filterList.map(_buildFilterChip).toList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final list = Container(
|
final list = Container(
|
||||||
|
@ -76,10 +76,10 @@ class ExpandableFilterRow extends StatelessWidget {
|
||||||
physics: BouncingScrollPhysics(),
|
physics: BouncingScrollPhysics(),
|
||||||
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
|
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
|
||||||
itemBuilder: (context, index) {
|
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),
|
separatorBuilder: (context, index) => SizedBox(width: 8),
|
||||||
itemCount: filtersList.length,
|
itemCount: filterList.length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final filterChips = isExpanded ? wrap : list;
|
final filterChips = isExpanded ? wrap : list;
|
||||||
|
|
67
lib/widgets/settings/hidden_filters.dart
Normal file
67
lib/widgets/settings/hidden_filters.dart
Normal 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(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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/dialogs/aves_selection_dialog.dart';
|
||||||
import 'package:aves/widgets/settings/access_grants.dart';
|
import 'package:aves/widgets/settings/access_grants.dart';
|
||||||
import 'package:aves/widgets/settings/entry_background.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/material.dart';
|
||||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
@ -241,6 +242,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
onChanged: (v) => settings.isCrashlyticsEnabled = v,
|
onChanged: (v) => settings.isCrashlyticsEnabled = v,
|
||||||
title: Text('Allow anonymous analytics and crash reporting'),
|
title: Text('Allow anonymous analytics and crash reporting'),
|
||||||
),
|
),
|
||||||
|
HiddenFilters(),
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(top: 8, bottom: 16),
|
padding: EdgeInsets.only(top: 8, bottom: 16),
|
||||||
child: GrantedDirectories(),
|
child: GrantedDirectories(),
|
||||||
|
|
|
@ -29,7 +29,7 @@ class StatsPage extends StatelessWidget {
|
||||||
final CollectionLens parentCollection;
|
final CollectionLens parentCollection;
|
||||||
final Map<String, int> entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {};
|
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;
|
static const mimeDonutMinWidth = 124.0;
|
||||||
|
|
||||||
|
|
|
@ -141,7 +141,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
showFeedback(context, 'Failed');
|
showFeedback(context, 'Failed');
|
||||||
} else {
|
} else {
|
||||||
if (hasCollection) {
|
if (hasCollection) {
|
||||||
collection.source.removeEntries({entry});
|
collection.source.removeEntries({entry.uri});
|
||||||
}
|
}
|
||||||
EntryDeletedNotification(entry).dispatch(context);
|
EntryDeletedNotification(entry).dispatch(context);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue