357 lines
13 KiB
Dart
357 lines
13 KiB
Dart
import 'dart:async';
|
|
import 'dart:collection';
|
|
|
|
import 'package:aves/model/entry/entry.dart';
|
|
import 'package:aves/model/entry/extensions/multipage.dart';
|
|
import 'package:aves/model/entry/sort.dart';
|
|
import 'package:aves/model/favourites.dart';
|
|
import 'package:aves/model/filters/album.dart';
|
|
import 'package:aves/model/filters/favourite.dart';
|
|
import 'package:aves/model/filters/filters.dart';
|
|
import 'package:aves/model/filters/location.dart';
|
|
import 'package:aves/model/filters/query.dart';
|
|
import 'package:aves/model/filters/rating.dart';
|
|
import 'package:aves/model/filters/trash.dart';
|
|
import 'package:aves/model/settings/settings.dart';
|
|
import 'package:aves/model/source/collection_source.dart';
|
|
import 'package:aves/model/source/events.dart';
|
|
import 'package:aves/model/source/location/location.dart';
|
|
import 'package:aves/model/source/section_keys.dart';
|
|
import 'package:aves/model/source/tag.dart';
|
|
import 'package:aves/utils/collection_utils.dart';
|
|
import 'package:aves_model/aves_model.dart';
|
|
import 'package:aves_utils/aves_utils.dart';
|
|
import 'package:collection/collection.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
class CollectionLens with ChangeNotifier {
|
|
final CollectionSource source;
|
|
final Set<CollectionFilter> filters;
|
|
List<String> burstPatterns;
|
|
EntryGroupFactor sectionFactor;
|
|
EntrySortFactor sortFactor;
|
|
bool sortReverse;
|
|
final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortSectionChangeNotifier = AChangeNotifier();
|
|
final List<StreamSubscription> _subscriptions = [];
|
|
int? id;
|
|
bool listenToSource, groupBursts, fixedSort;
|
|
List<AvesEntry>? fixedSelection;
|
|
|
|
final Set<AvesEntry> _syntheticEntries = {};
|
|
List<AvesEntry> _filteredSortedEntries = [];
|
|
|
|
Map<SectionKey, List<AvesEntry>> sections = Map.unmodifiable({});
|
|
|
|
CollectionLens({
|
|
required this.source,
|
|
Set<CollectionFilter?>? filters,
|
|
this.id,
|
|
this.listenToSource = true,
|
|
this.groupBursts = true,
|
|
this.fixedSort = false,
|
|
this.fixedSelection,
|
|
}) : filters = (filters ?? {}).whereNotNull().toSet(),
|
|
burstPatterns = settings.collectionBurstPatterns,
|
|
sectionFactor = settings.collectionSectionFactor,
|
|
sortFactor = settings.collectionSortFactor,
|
|
sortReverse = settings.collectionSortReverse {
|
|
id ??= hashCode;
|
|
if (listenToSource) {
|
|
final sourceEvents = source.eventBus;
|
|
_subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => _onEntryAdded(e.entries)));
|
|
_subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => _onEntryRemoved(e.entries)));
|
|
_subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) {
|
|
switch (e.type) {
|
|
case MoveType.copy:
|
|
case MoveType.export:
|
|
// refreshing new items is already handled via `EntryAddedEvent`s
|
|
break;
|
|
case MoveType.move:
|
|
case MoveType.fromBin:
|
|
refresh();
|
|
case MoveType.toBin:
|
|
_onEntryRemoved(e.entries);
|
|
}
|
|
}));
|
|
_subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => refresh()));
|
|
_subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => refresh()));
|
|
_subscriptions.add(sourceEvents.on<CatalogMetadataChangedEvent>().listen((e) => refresh()));
|
|
_subscriptions.add(sourceEvents.on<AddressMetadataChangedEvent>().listen((e) {
|
|
if (this.filters.any((filter) => filter is LocationFilter)) {
|
|
refresh();
|
|
}
|
|
}));
|
|
favourites.addListener(_onFavouritesChanged);
|
|
}
|
|
_subscriptions.add(settings.updateStream
|
|
.where((event) => [
|
|
SettingKeys.collectionBurstPatternsKey,
|
|
SettingKeys.collectionSortFactorKey,
|
|
SettingKeys.collectionGroupFactorKey,
|
|
SettingKeys.collectionSortReverseKey,
|
|
].contains(event.key))
|
|
.listen((_) => _onSettingsChanged()));
|
|
refresh();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_subscriptions
|
|
..forEach((sub) => sub.cancel())
|
|
..clear();
|
|
favourites.removeListener(_onFavouritesChanged);
|
|
filterChangeNotifier.dispose();
|
|
sortSectionChangeNotifier.dispose();
|
|
_disposeSyntheticEntries();
|
|
super.dispose();
|
|
}
|
|
|
|
CollectionLens copyWith({
|
|
CollectionSource? source,
|
|
Set<CollectionFilter>? filters,
|
|
bool? listenToSource,
|
|
List<AvesEntry>? fixedSelection,
|
|
}) =>
|
|
CollectionLens(
|
|
source: source ?? this.source,
|
|
filters: filters ?? this.filters,
|
|
id: id,
|
|
listenToSource: listenToSource ?? this.listenToSource,
|
|
fixedSelection: fixedSelection ?? this.fixedSelection,
|
|
);
|
|
|
|
void _disposeSyntheticEntries() {
|
|
_syntheticEntries.forEach((v) => v.dispose());
|
|
_syntheticEntries.clear();
|
|
}
|
|
|
|
bool get isEmpty => _filteredSortedEntries.isEmpty;
|
|
|
|
int get entryCount => _filteredSortedEntries.length;
|
|
|
|
// sorted as displayed to the user, i.e. sorted then sectioned, not an absolute order on all entries
|
|
List<AvesEntry>? _sortedEntries;
|
|
|
|
List<AvesEntry> get sortedEntries {
|
|
_sortedEntries ??= List.of(sections.entries.expand((kv) => kv.value));
|
|
return _sortedEntries!;
|
|
}
|
|
|
|
bool get showHeaders {
|
|
bool showAlbumHeaders() => !filters.any((v) => v is AlbumFilter && !v.reversed);
|
|
|
|
switch (sortFactor) {
|
|
case EntrySortFactor.date:
|
|
switch (sectionFactor) {
|
|
case EntryGroupFactor.none:
|
|
return false;
|
|
case EntryGroupFactor.album:
|
|
return showAlbumHeaders();
|
|
case EntryGroupFactor.month:
|
|
return true;
|
|
case EntryGroupFactor.day:
|
|
return true;
|
|
}
|
|
case EntrySortFactor.name:
|
|
return showAlbumHeaders();
|
|
case EntrySortFactor.rating:
|
|
return !filters.any((f) => f is RatingFilter);
|
|
case EntrySortFactor.size:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
void addFilter(CollectionFilter filter) {
|
|
if (filters.contains(filter)) return;
|
|
filters.removeWhere((other) => !filter.isCompatible(other));
|
|
filters.add(filter);
|
|
_onFilterChanged();
|
|
}
|
|
|
|
void removeFilter(CollectionFilter filter) {
|
|
if (!filters.contains(filter)) return;
|
|
filters.remove(filter);
|
|
_onFilterChanged();
|
|
}
|
|
|
|
void setLiveQuery(String query) {
|
|
filters.removeWhere((v) => v is QueryFilter && v.live);
|
|
if (query.isNotEmpty) {
|
|
filters.add(QueryFilter(query, live: true));
|
|
}
|
|
_onFilterChanged();
|
|
}
|
|
|
|
void _onFilterChanged() {
|
|
refresh();
|
|
filterChangeNotifier.notifyListeners();
|
|
}
|
|
|
|
void _applyFilters() {
|
|
final entries = fixedSelection ?? (filters.contains(TrashFilter.instance) ? source.trashedEntries : source.visibleEntries);
|
|
_disposeSyntheticEntries();
|
|
_filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry))));
|
|
|
|
if (groupBursts) {
|
|
_groupBursts();
|
|
}
|
|
}
|
|
|
|
void _groupBursts() {
|
|
final byBurstKey = groupBy<AvesEntry, String?>(_filteredSortedEntries, (entry) => entry.getBurstKey(burstPatterns)).whereNotNullKey();
|
|
byBurstKey.forEach((burstKey, entries) {
|
|
if (entries.length > 1) {
|
|
entries.sort(AvesEntrySort.compareByName);
|
|
final mainEntry = entries.first;
|
|
final burstEntry = mainEntry.copyWith(burstEntries: entries);
|
|
_syntheticEntries.add(burstEntry);
|
|
|
|
entries.skip(1).toList().forEach((subEntry) {
|
|
_filteredSortedEntries.remove(subEntry);
|
|
});
|
|
final index = _filteredSortedEntries.indexOf(mainEntry);
|
|
_filteredSortedEntries.removeAt(index);
|
|
_filteredSortedEntries.insert(index, burstEntry);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _applySort() {
|
|
if (fixedSort) return;
|
|
|
|
switch (sortFactor) {
|
|
case EntrySortFactor.date:
|
|
_filteredSortedEntries.sort(AvesEntrySort.compareByDate);
|
|
case EntrySortFactor.name:
|
|
_filteredSortedEntries.sort(AvesEntrySort.compareByName);
|
|
case EntrySortFactor.rating:
|
|
_filteredSortedEntries.sort(AvesEntrySort.compareByRating);
|
|
case EntrySortFactor.size:
|
|
_filteredSortedEntries.sort(AvesEntrySort.compareBySize);
|
|
}
|
|
if (sortReverse) {
|
|
_filteredSortedEntries = _filteredSortedEntries.reversed.toList();
|
|
}
|
|
}
|
|
|
|
void _applySection() {
|
|
if (fixedSort) {
|
|
sections = Map.fromEntries([
|
|
MapEntry(const SectionKey(), _filteredSortedEntries),
|
|
]);
|
|
} else {
|
|
switch (sortFactor) {
|
|
case EntrySortFactor.date:
|
|
switch (sectionFactor) {
|
|
case EntryGroupFactor.album:
|
|
sections = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
|
case EntryGroupFactor.month:
|
|
sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredSortedEntries, (entry) => EntryDateSectionKey(entry.monthTaken));
|
|
case EntryGroupFactor.day:
|
|
sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredSortedEntries, (entry) => EntryDateSectionKey(entry.dayTaken));
|
|
case EntryGroupFactor.none:
|
|
sections = Map.fromEntries([
|
|
MapEntry(const SectionKey(), _filteredSortedEntries),
|
|
]);
|
|
}
|
|
case EntrySortFactor.name:
|
|
final byAlbum = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
|
final int Function(EntryAlbumSectionKey, EntryAlbumSectionKey) compare = sortReverse ? (a, b) => source.compareAlbumsByName(b.directory, a.directory) : (a, b) => source.compareAlbumsByName(a.directory, b.directory);
|
|
sections = SplayTreeMap<EntryAlbumSectionKey, List<AvesEntry>>.of(byAlbum, compare);
|
|
case EntrySortFactor.rating:
|
|
sections = groupBy<AvesEntry, EntryRatingSectionKey>(_filteredSortedEntries, (entry) => EntryRatingSectionKey(entry.rating));
|
|
case EntrySortFactor.size:
|
|
sections = Map.fromEntries([
|
|
MapEntry(const SectionKey(), _filteredSortedEntries),
|
|
]);
|
|
}
|
|
}
|
|
sections = Map.unmodifiable(sections);
|
|
_sortedEntries = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
// metadata change should also trigger a full refresh
|
|
// as dates impact sorting and sectioning
|
|
void refresh() {
|
|
_applyFilters();
|
|
_applySort();
|
|
_applySection();
|
|
}
|
|
|
|
void _onFavouritesChanged() {
|
|
if (filters.any((filter) => filter is FavouriteFilter)) {
|
|
refresh();
|
|
}
|
|
}
|
|
|
|
void _onSettingsChanged() {
|
|
final newBurstPatterns = settings.collectionBurstPatterns;
|
|
final newSortFactor = settings.collectionSortFactor;
|
|
final newSectionFactor = settings.collectionSectionFactor;
|
|
final newSortReverse = settings.collectionSortReverse;
|
|
|
|
final needFilter = burstPatterns != newBurstPatterns;
|
|
final needSort = needFilter || sortFactor != newSortFactor || sortReverse != newSortReverse;
|
|
final needSection = needSort || sectionFactor != newSectionFactor;
|
|
|
|
if (needFilter) {
|
|
burstPatterns = newBurstPatterns;
|
|
_applyFilters();
|
|
}
|
|
if (needSort) {
|
|
sortFactor = newSortFactor;
|
|
sortReverse = newSortReverse;
|
|
_applySort();
|
|
}
|
|
if (needSection) {
|
|
sectionFactor = newSectionFactor;
|
|
_applySection();
|
|
}
|
|
|
|
if (needFilter) {
|
|
filterChangeNotifier.notifyListeners();
|
|
}
|
|
if (needSort || needSection) {
|
|
sortSectionChangeNotifier.notifyListeners();
|
|
}
|
|
}
|
|
|
|
void _onEntryAdded(Set<AvesEntry>? entries) {
|
|
refresh();
|
|
}
|
|
|
|
void _onEntryRemoved(Set<AvesEntry> entries) {
|
|
if (groupBursts) {
|
|
// find impacted burst groups
|
|
final obsoleteBurstEntries = <AvesEntry>{};
|
|
final burstKeys = entries.map((entry) => entry.getBurstKey(burstPatterns)).whereNotNull().toSet();
|
|
if (burstKeys.isNotEmpty) {
|
|
_filteredSortedEntries.where((entry) => entry.isBurst && burstKeys.contains(entry.getBurstKey(burstPatterns))).forEach((mainEntry) {
|
|
final subEntries = mainEntry.burstEntries!;
|
|
// remove the deleted sub-entries
|
|
subEntries.removeWhere(entries.contains);
|
|
if (subEntries.isEmpty) {
|
|
// remove the burst entry itself
|
|
obsoleteBurstEntries.add(mainEntry);
|
|
}
|
|
// TODO TLAD [burst] recreate the burst main entry if the first sub-entry got deleted
|
|
});
|
|
entries.addAll(obsoleteBurstEntries);
|
|
}
|
|
}
|
|
|
|
// we should remove obsolete entries and sections
|
|
// but do not apply sort/section
|
|
// as section order change would surprise the user while browsing
|
|
fixedSelection?.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)));
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
String toString() => '$runtimeType#${shortHash(this)}{id=$id, source=$source, filters=$filters, entryCount=$entryCount}';
|
|
}
|