various collection model fixes

This commit is contained in:
Thibault Deckers 2021-02-06 10:25:16 +09:00
parent 319fd9584b
commit c5ee55adb0
22 changed files with 211 additions and 98 deletions

View file

@ -67,7 +67,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val packages = HashMap<String, FieldMap>() val packages = HashMap<String, FieldMap>()
fun addPackageDetails(intent: Intent) { fun addPackageDetails(intent: Intent) {
// apps tend to use their name in English when creating folders // apps tend to use their name in English when creating directories
// so we get their names in English as well as the current locale // so we get their names in English as well as the current locale
val englishConfig = Configuration().apply { setLocale(Locale.ENGLISH) } val englishConfig = Configuration().apply { setLocale(Locale.ENGLISH) }

View file

@ -42,6 +42,8 @@ class LocationFilter extends CollectionFilter {
String get countryNameAndCode => '$_location$locationSeparator$_countryCode'; String get countryNameAndCode => '$_location$locationSeparator$_countryCode';
String get countryCode => _countryCode;
@override @override
EntryFilter get filter => _filter; EntryFilter get filter => _filter;

View file

@ -1,5 +1,6 @@
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -55,7 +56,7 @@ class QueryFilter extends CollectionFilter {
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.text, size: size); Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.text, size: size);
@override @override
Future<Color> color(BuildContext context) => colorful ? super.color(context) : SynchronousFuture(Colors.white); Future<Color> color(BuildContext context) => colorful ? super.color(context) : SynchronousFuture(AvesFilterChip.defaultOutlineColor);
@override @override
String get typeKey => type; String get typeKey => type;

View file

@ -116,11 +116,11 @@ class MetadataDb {
debugPrint('$runtimeType clearEntries deleted $count entries'); debugPrint('$runtimeType clearEntries deleted $count entries');
} }
Future<List<AvesEntry>> loadEntries() async { Future<Set<AvesEntry>> loadEntries() async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
final maps = await db.query(entryTable); final maps = await db.query(entryTable);
final entries = maps.map((map) => AvesEntry.fromMap(map)).toList(); final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet();
debugPrint('$runtimeType loadEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries'); debugPrint('$runtimeType loadEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
return entries; return entries;
} }

View file

@ -7,9 +7,9 @@ import 'package:collection/collection.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
mixin AlbumMixin on SourceBase { mixin AlbumMixin on SourceBase {
final Set<String> _folderPaths = {}; final Set<String> _directories = {};
List<String> sortedAlbums = List.unmodifiable([]); List<String> get rawAlbums => List.unmodifiable(_directories);
int compareAlbumsByName(String a, String b) { int compareAlbumsByName(String a, String b) {
final ua = getUniqueAlbumName(a); final ua = getUniqueAlbumName(a);
@ -21,15 +21,10 @@ mixin AlbumMixin on SourceBase {
return compareAsciiUpperCase(va, vb); return compareAsciiUpperCase(va, vb);
} }
void updateAlbums() { void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
final sorted = _folderPaths.toList()..sort(compareAlbumsByName);
sortedAlbums = List.unmodifiable(sorted);
invalidateFilterEntryCounts();
eventBus.fire(AlbumsChangedEvent());
}
String getUniqueAlbumName(String album) { String getUniqueAlbumName(String album) {
final otherAlbums = _folderPaths.where((item) => item != album); final otherAlbums = _directories.where((item) => item != album);
final parts = album.split(separator); final parts = album.split(separator);
var partCount = 0; var partCount = 0;
String testName; String testName;
@ -51,9 +46,9 @@ mixin AlbumMixin on SourceBase {
} }
Map<String, AvesEntry> getAlbumEntries() { Map<String, AvesEntry> getAlbumEntries() {
final entries = sortedEntriesForFilterList; final entries = sortedEntriesByDate;
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[]; final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
for (final album in sortedAlbums) { for (final album in rawAlbums) {
switch (androidFileUtils.getAlbumType(album)) { switch (androidFileUtils.getAlbumType(album)) {
case AlbumType.regular: case AlbumType.regular:
regularAlbums.add(album); regularAlbums.add(album);
@ -72,13 +67,17 @@ mixin AlbumMixin on SourceBase {
))); )));
} }
void addFolderPath(Iterable<String> albums) => _folderPaths.addAll(albums); void addDirectory(Iterable<String> albums) {
_directories.addAll(albums);
_notifyAlbumChange();
}
void cleanEmptyAlbums([Set<String> albums]) { void cleanEmptyAlbums([Set<String> albums]) {
final emptyAlbums = (albums ?? _folderPaths).where(_isEmptyAlbum).toList(); final emptyAlbums = (albums ?? _directories).where(_isEmptyAlbum).toSet();
if (emptyAlbums.isNotEmpty) { if (emptyAlbums.isNotEmpty) {
_folderPaths.removeAll(emptyAlbums); _directories.removeAll(emptyAlbums);
updateAlbums(); _notifyAlbumChange();
invalidateAlbumFilterSummary(directories: emptyAlbums);
final pinnedFilters = settings.pinnedFilters; final pinnedFilters = settings.pinnedFilters;
emptyAlbums.forEach((album) => pinnedFilters.remove(AlbumFilter(album, getUniqueAlbumName(album)))); emptyAlbums.forEach((album) => pinnedFilters.remove(AlbumFilter(album, getUniqueAlbumName(album))));
@ -87,6 +86,31 @@ mixin AlbumMixin on SourceBase {
} }
bool _isEmptyAlbum(String album) => !rawEntries.any((entry) => entry.directory == album); bool _isEmptyAlbum(String album) => !rawEntries.any((entry) => entry.directory == album);
// filter summary
// by directory
final Map<String, int> _filterEntryCountMap = {};
final Map<String, AvesEntry> _filterRecentEntryMap = {};
void invalidateAlbumFilterSummary({Set<AvesEntry> entries, Set<String> directories}) {
if (entries == null && directories == null) {
_filterEntryCountMap.clear();
_filterRecentEntryMap.clear();
} else {
directories ??= entries.map((entry) => entry.directory).toSet();
directories.forEach(_filterEntryCountMap.remove);
directories.forEach(_filterRecentEntryMap.remove);
}
}
int albumEntryCount(AlbumFilter filter) {
return _filterEntryCountMap.putIfAbsent(filter.album, () => rawEntries.where((entry) => filter.filter(entry)).length);
}
AvesEntry albumRecentEntry(AlbumFilter filter) {
return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry)));
}
} }
class AlbumsChangedEvent {} class AlbumsChangedEvent {}

View file

@ -42,7 +42,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
sortFactor = sortFactor ?? settings.collectionSortFactor { sortFactor = sortFactor ?? settings.collectionSortFactor {
id ??= hashCode; id ??= hashCode;
if (listenToSource) { if (listenToSource) {
_subscriptions.add(source.eventBus.on<EntryAddedEvent>().listen((e) => _refresh())); _subscriptions.add(source.eventBus.on<EntryAddedEvent>().listen((e) => onEntryAdded(e.entries)));
_subscriptions.add(source.eventBus.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(e.entries))); _subscriptions.add(source.eventBus.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(e.entries)));
_subscriptions.add(source.eventBus.on<EntryMovedEvent>().listen((e) => _refresh())); _subscriptions.add(source.eventBus.on<EntryMovedEvent>().listen((e) => _refresh()));
_subscriptions.add(source.eventBus.on<CatalogMetadataChangedEvent>().listen((e) => _refresh())); _subscriptions.add(source.eventBus.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
@ -167,7 +167,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
break; break;
case EntrySortFactor.name: case EntrySortFactor.name:
final byAlbum = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory)); final byAlbum = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
sections = SplayTreeMap<EntryAlbumSectionKey, List<AvesEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath)); sections = SplayTreeMap<EntryAlbumSectionKey, List<AvesEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.directory, b.directory));
break; break;
} }
sections = Map.unmodifiable(sections); sections = Map.unmodifiable(sections);
@ -183,7 +183,11 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
_applyGroup(); _applyGroup();
} }
void onEntryRemoved(Iterable<AvesEntry> entries) { void onEntryAdded(Set<AvesEntry> entries) {
_refresh();
}
void onEntryRemoved(Set<AvesEntry> entries) {
// 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

View file

@ -2,19 +2,19 @@ import 'dart:async';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
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.dart';
import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_lens.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';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/image_op_events.dart';
import 'package:event_bus/event_bus.dart'; import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'enums.dart';
mixin SourceBase { mixin SourceBase {
final List<AvesEntry> _rawEntries = []; final List<AvesEntry> _rawEntries = [];
@ -24,11 +24,7 @@ mixin SourceBase {
EventBus get eventBus => _eventBus; EventBus get eventBus => _eventBus;
List<AvesEntry> get sortedEntriesForFilterList; List<AvesEntry> get sortedEntriesByDate;
final Map<CollectionFilter, int> _filterEntryCountMap = {};
void invalidateFilterEntryCounts() => _filterEntryCountMap.clear();
final StreamController<ProgressEvent> _progressStreamController = StreamController.broadcast(); final StreamController<ProgressEvent> _progressStreamController = StreamController.broadcast();
@ -38,12 +34,13 @@ mixin SourceBase {
} }
abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin { abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
List<AvesEntry> _sortedEntriesByDate;
@override @override
List<AvesEntry> get sortedEntriesForFilterList => CollectionLens( List<AvesEntry> get sortedEntriesByDate {
source: this, _sortedEntriesByDate ??= List.of(_rawEntries)..sort(AvesEntry.compareByDate);
groupFactor: EntryGroupFactor.none, return _sortedEntriesByDate;
sortFactor: EntrySortFactor.date, }
).sortedEntries;
ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready); ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready);
@ -55,7 +52,7 @@ 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(Iterable<AvesEntry> entries) { void addAll(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).toList();
@ -66,9 +63,9 @@ 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);
addFolderPath(_rawEntries.map((entry) => entry.directory)); addDirectory(_rawEntries.map((entry) => entry.directory));
invalidateFilterEntryCounts(); _invalidateFilterSummaries(entries);
eventBus.fire(EntryAddedEvent()); eventBus.fire(EntryAddedEvent(entries));
} }
void removeEntries(Set<AvesEntry> entries) { void removeEntries(Set<AvesEntry> entries) {
@ -78,17 +75,16 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet()); cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet());
updateLocations(); updateLocations();
updateTags(); updateTags();
invalidateFilterEntryCounts(); _invalidateFilterSummaries(entries);
eventBus.fire(EntryRemovedEvent(entries)); eventBus.fire(EntryRemovedEvent(entries));
} }
void clearEntries() { void clearEntries() {
_rawEntries.clear(); _rawEntries.clear();
cleanEmptyAlbums(); cleanEmptyAlbums();
updateAlbums();
updateLocations(); updateLocations();
updateTags(); updateTags();
invalidateFilterEntryCounts(); _invalidateFilterSummaries();
} }
Future<void> _moveEntry(AvesEntry entry, Map newFields, bool isFavourite) async { Future<void> _moveEntry(AvesEntry entry, Map newFields, bool isFavourite) async {
@ -122,7 +118,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
if (movedOps.isEmpty) return; if (movedOps.isEmpty) return;
final fromAlbums = <String>{}; final fromAlbums = <String>{};
final movedEntries = <AvesEntry>[]; final movedEntries = <AvesEntry>{};
if (copy) { if (copy) {
movedOps.forEach((movedOp) { movedOps.forEach((movedOp) {
final sourceUri = movedOp.uri; final sourceUri = movedOp.uri;
@ -161,17 +157,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
addAll(movedEntries); addAll(movedEntries);
} else { } else {
cleanEmptyAlbums(fromAlbums); cleanEmptyAlbums(fromAlbums);
addFolderPath({destinationAlbum}); addDirectory({destinationAlbum});
} }
updateAlbums(); invalidateAlbumFilterSummary(directories: fromAlbums);
invalidateFilterEntryCounts(); _invalidateFilterSummaries(movedEntries);
eventBus.fire(EntryMovedEvent(movedEntries)); eventBus.fire(EntryMovedEvent(movedEntries));
} }
int count(CollectionFilter filter) {
return _filterEntryCountMap.putIfAbsent(filter, () => _rawEntries.where((entry) => filter.filter(entry)).length);
}
bool get initialized => false; bool get initialized => false;
Future<void> init(); Future<void> init();
@ -179,18 +171,41 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
Future<void> refresh(); Future<void> refresh();
Future<void> refreshMetadata(Set<AvesEntry> entries); Future<void> refreshMetadata(Set<AvesEntry> entries);
// 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);
if (filter is TagFilter) return tagEntryCount(filter);
return 0;
}
AvesEntry recentEntry(CollectionFilter filter) {
if (filter is AlbumFilter) return albumRecentEntry(filter);
if (filter is LocationFilter) return countryRecentEntry(filter);
if (filter is TagFilter) return tagRecentEntry(filter);
return null;
}
} }
enum SourceState { loading, cataloguing, locating, ready } enum SourceState { loading, cataloguing, locating, ready }
class EntryAddedEvent { class EntryAddedEvent {
final AvesEntry entry; final Set<AvesEntry> entries;
const EntryAddedEvent([this.entry]); const EntryAddedEvent([this.entries]);
} }
class EntryRemovedEvent { class EntryRemovedEvent {
final Iterable<AvesEntry> entries; final Set<AvesEntry> entries;
const EntryRemovedEvent(this.entries); const EntryRemovedEvent(this.entries);
} }

View file

@ -100,9 +100,33 @@ mixin LocationMixin on SourceBase {
final countriesByCode = Map.fromEntries(locations.map((address) => MapEntry(address.countryCode, address.countryName)).where((kv) => kv.key != null && kv.key.isNotEmpty)); final countriesByCode = Map.fromEntries(locations.map((address) => MapEntry(address.countryCode, address.countryName)).where((kv) => kv.key != null && kv.key.isNotEmpty));
sortedCountries = List<String>.unmodifiable(countriesByCode.entries.map((kv) => '${kv.value}${LocationFilter.locationSeparator}${kv.key}').toList()..sort(compareAsciiUpperCase)); sortedCountries = List<String>.unmodifiable(countriesByCode.entries.map((kv) => '${kv.value}${LocationFilter.locationSeparator}${kv.key}').toList()..sort(compareAsciiUpperCase));
invalidateFilterEntryCounts(); invalidateCountryFilterSummary();
eventBus.fire(LocationsChangedEvent()); eventBus.fire(LocationsChangedEvent());
} }
// filter summary
// by country code
final Map<String, int> _filterEntryCountMap = {};
final Map<String, AvesEntry> _filterRecentEntryMap = {};
void invalidateCountryFilterSummary([Set<AvesEntry> entries]) {
if (entries == null) {
_filterEntryCountMap.clear();
_filterRecentEntryMap.clear();
} else {
final countryCodes = entries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails.countryCode).toSet();
countryCodes.forEach(_filterEntryCountMap.remove);
}
}
int countryEntryCount(LocationFilter filter) {
return _filterEntryCountMap.putIfAbsent(filter.countryCode, () => rawEntries.where((entry) => filter.filter(entry)).length);
}
AvesEntry countryRecentEntry(LocationFilter filter) {
return _filterRecentEntryMap.putIfAbsent(filter.countryCode, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry)));
}
} }
class AddressMetadataChangedEvent {} class AddressMetadataChangedEvent {}

View file

@ -66,7 +66,7 @@ class MediaStoreSource extends CollectionSource {
// refresh after the first 10 entries, then after 100 more, then every 1000 entries // refresh after the first 10 entries, then after 100 more, then every 1000 entries
var refreshCount = 10; var refreshCount = 10;
const refreshCountMax = 1000; const refreshCountMax = 1000;
final allNewEntries = <AvesEntry>[], pendingNewEntries = <AvesEntry>[]; final allNewEntries = <AvesEntry>{}, pendingNewEntries = <AvesEntry>{};
void addPendingEntries() { void addPendingEntries() {
allNewEntries.addAll(pendingNewEntries); allNewEntries.addAll(pendingNewEntries);
addAll(pendingNewEntries); addAll(pendingNewEntries);
@ -86,10 +86,11 @@ class MediaStoreSource extends CollectionSource {
debugPrint('$runtimeType refresh loaded ${allNewEntries.length} new entries, elapsed=${stopwatch.elapsed}'); debugPrint('$runtimeType refresh loaded ${allNewEntries.length} new entries, elapsed=${stopwatch.elapsed}');
await metadataDb.saveEntries(allNewEntries); // 700ms for 5500 entries await metadataDb.saveEntries(allNewEntries); // 700ms for 5500 entries
updateAlbums(); 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(rawEntries.length, 3)).toString()));
unawaited(analytics.setUserProperty(name: 'album_count', value: (ceilBy(sortedAlbums.length, 1)).toString())); unawaited(analytics.setUserProperty(name: 'album_count', value: (ceilBy(rawAlbums.length, 1)).toString()));
stateNotifier.value = SourceState.cataloguing; stateNotifier.value = SourceState.cataloguing;
await catalogEntries(); await catalogEntries();
@ -128,7 +129,7 @@ class MediaStoreSource extends CollectionSource {
removeEntries(obsoleteEntries); removeEntries(obsoleteEntries);
// fetch new entries // fetch new entries
final newEntries = <AvesEntry>[]; final newEntries = <AvesEntry>{};
for (final kv in uriByContentId.entries) { for (final kv in uriByContentId.entries) {
final contentId = kv.key; final contentId = kv.key;
final uri = kv.value; final uri = kv.value;
@ -150,7 +151,7 @@ class MediaStoreSource extends CollectionSource {
if (newEntries.isNotEmpty) { if (newEntries.isNotEmpty) {
addAll(newEntries); addAll(newEntries);
await metadataDb.saveEntries(newEntries); await metadataDb.saveEntries(newEntries);
updateAlbums(); invalidateAlbumFilterSummary(entries: newEntries);
stateNotifier.value = SourceState.cataloguing; stateNotifier.value = SourceState.cataloguing;
await catalogEntries(); await catalogEntries();

View file

@ -5,21 +5,21 @@ class SectionKey {
} }
class EntryAlbumSectionKey extends SectionKey { class EntryAlbumSectionKey extends SectionKey {
final String folderPath; final String directory;
const EntryAlbumSectionKey(this.folderPath); const EntryAlbumSectionKey(this.directory);
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false; if (other.runtimeType != runtimeType) return false;
return other is EntryAlbumSectionKey && other.folderPath == folderPath; return other is EntryAlbumSectionKey && other.directory == directory;
} }
@override @override
int get hashCode => folderPath.hashCode; int get hashCode => directory.hashCode;
@override @override
String toString() => '$runtimeType#${shortHash(this)}{folderPath=$folderPath}'; String toString() => '$runtimeType#${shortHash(this)}{directory=$directory}';
} }
class EntryDateSectionKey extends SectionKey { class EntryDateSectionKey extends SectionKey {

View file

@ -1,4 +1,5 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.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/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
@ -56,9 +57,34 @@ mixin TagMixin on SourceBase {
void updateTags() { void updateTags() {
final tags = rawEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase); final tags = rawEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase);
sortedTags = List.unmodifiable(tags); sortedTags = List.unmodifiable(tags);
invalidateFilterEntryCounts();
invalidateTagFilterSummary();
eventBus.fire(TagsChangedEvent()); eventBus.fire(TagsChangedEvent());
} }
// filter summary
// by tag
final Map<String, int> _filterEntryCountMap = {};
final Map<String, AvesEntry> _filterRecentEntryMap = {};
void invalidateTagFilterSummary([Set<AvesEntry> entries]) {
if (entries == null) {
_filterEntryCountMap.clear();
_filterRecentEntryMap.clear();
} else {
final tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.xmpSubjects).toSet();
tags.forEach(_filterEntryCountMap.remove);
}
}
int tagEntryCount(TagFilter filter) {
return _filterEntryCountMap.putIfAbsent(filter.tag, () => rawEntries.where((entry) => filter.filter(entry)).length);
}
AvesEntry tagRecentEntry(TagFilter filter) {
return _filterRecentEntryMap.putIfAbsent(filter.tag, () => sortedEntriesByDate.firstWhere((entry) => filter.filter(entry)));
}
} }
class CatalogMetadataChangedEvent {} class CatalogMetadataChangedEvent {}

View file

@ -8,13 +8,26 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class CollectionPage extends StatelessWidget { class CollectionPage extends StatefulWidget {
static const routeName = '/collection'; static const routeName = '/collection';
final CollectionLens collection; final CollectionLens collection;
const CollectionPage(this.collection); const CollectionPage(this.collection);
@override
_CollectionPageState createState() => _CollectionPageState();
}
class _CollectionPageState extends State<CollectionPage> {
CollectionLens get collection => widget.collection;
@override
void dispose() {
collection.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MediaQueryDataProvider( return MediaQueryDataProvider(

View file

@ -7,18 +7,18 @@ import 'package:aves/widgets/common/identity/aves_icons.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AlbumSectionHeader extends StatelessWidget { class AlbumSectionHeader extends StatelessWidget {
final String folderPath, albumName; final String directory, albumName;
AlbumSectionHeader({ AlbumSectionHeader({
Key key, Key key,
@required CollectionSource source, @required CollectionSource source,
@required this.folderPath, @required this.directory,
}) : albumName = source.getUniqueAlbumName(folderPath), }) : albumName = source.getUniqueAlbumName(directory),
super(key: key); super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var albumIcon = IconUtils.getAlbumIcon(context: context, album: folderPath); var albumIcon = IconUtils.getAlbumIcon(context: context, album: directory);
if (albumIcon != null) { if (albumIcon != null) {
albumIcon = Material( albumIcon = Material(
type: MaterialType.circle, type: MaterialType.circle,
@ -29,10 +29,10 @@ class AlbumSectionHeader extends StatelessWidget {
); );
} }
return SectionHeader( return SectionHeader(
sectionKey: EntryAlbumSectionKey(folderPath), sectionKey: EntryAlbumSectionKey(directory),
leading: albumIcon, leading: albumIcon,
title: albumName, title: albumName,
trailing: androidFileUtils.isOnRemovableStorage(folderPath) trailing: androidFileUtils.isOnRemovableStorage(directory)
? Icon( ? Icon(
AIcons.removableStorage, AIcons.removableStorage,
size: 16, size: 16,
@ -43,13 +43,13 @@ class AlbumSectionHeader extends StatelessWidget {
} }
static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, EntryAlbumSectionKey sectionKey) { static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, EntryAlbumSectionKey sectionKey) {
final folderPath = sectionKey.folderPath; final directory = sectionKey.directory;
return SectionHeader.getPreferredHeight( return SectionHeader.getPreferredHeight(
context: context, context: context,
maxWidth: maxWidth, maxWidth: maxWidth,
title: source.getUniqueAlbumName(folderPath), title: source.getUniqueAlbumName(directory),
hasLeading: androidFileUtils.getAlbumType(folderPath) != AlbumType.regular, hasLeading: androidFileUtils.getAlbumType(directory) != AlbumType.regular,
hasTrailing: androidFileUtils.isOnRemovableStorage(folderPath), hasTrailing: androidFileUtils.isOnRemovableStorage(directory),
); );
} }
} }

View file

@ -36,7 +36,7 @@ class CollectionSectionHeader extends StatelessWidget {
Widget _buildAlbumHeader() => AlbumSectionHeader( Widget _buildAlbumHeader() => AlbumSectionHeader(
key: ValueKey(sectionKey), key: ValueKey(sectionKey),
source: collection.source, source: collection.source,
folderPath: (sectionKey as EntryAlbumSectionKey).folderPath, directory: (sectionKey as EntryAlbumSectionKey).directory,
); );
switch (collection.sortFactor) { switch (collection.sortFactor) {

View file

@ -20,6 +20,7 @@ class AvesFilterChip extends StatefulWidget {
final OffsetFilterCallback onLongPress; final OffsetFilterCallback onLongPress;
final BorderRadius borderRadius; final BorderRadius borderRadius;
static const Color defaultOutlineColor = Colors.white;
static const double defaultRadius = 32; static const double defaultRadius = 32;
static const double outlineWidth = 2; static const double outlineWidth = 2;
static const double minChipHeight = kMinInteractiveDimension; static const double minChipHeight = kMinInteractiveDimension;
@ -82,7 +83,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
// the existing widget FutureBuilder cycles again from the start, with a frame in `waiting` state and no data. // the existing widget FutureBuilder cycles again from the start, with a frame in `waiting` state and no data.
// So we save the result of the Future to a local variable because of this specific case. // So we save the result of the Future to a local variable because of this specific case.
_colorFuture = filter.color(context); _colorFuture = filter.color(context);
_outlineColor = Colors.transparent; _outlineColor = AvesFilterChip.defaultOutlineColor;
} }
@override @override

View file

@ -13,7 +13,7 @@ class DebugAppDatabaseSection extends StatefulWidget {
class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with AutomaticKeepAliveClientMixin { class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with AutomaticKeepAliveClientMixin {
Future<int> _dbFileSizeLoader; Future<int> _dbFileSizeLoader;
Future<List<AvesEntry>> _dbEntryLoader; Future<Set<AvesEntry>> _dbEntryLoader;
Future<List<DateMetadata>> _dbDateLoader; Future<List<DateMetadata>> _dbDateLoader;
Future<List<CatalogMetadata>> _dbMetadataLoader; Future<List<CatalogMetadata>> _dbMetadataLoader;
Future<List<AddressDetails>> _dbAddressLoader; Future<List<AddressDetails>> _dbAddressLoader;
@ -57,7 +57,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
); );
}, },
), ),
FutureBuilder<List>( FutureBuilder<Set<AvesEntry>>(
future: _dbEntryLoader, future: _dbEntryLoader,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.hasError) return Text(snapshot.error.toString());

View file

@ -142,10 +142,11 @@ class _AppDrawerState extends State<AppDrawer> {
return StreamBuilder( return StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(), stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) { builder: (context, snapshot) {
final specialAlbums = source.sortedAlbums.where((album) { final specialAlbums = source.rawAlbums.where((album) {
final type = androidFileUtils.getAlbumType(album); final type = androidFileUtils.getAlbumType(album);
return [AlbumType.camera, AlbumType.screenshots].contains(type); return [AlbumType.camera, AlbumType.screenshots].contains(type);
}); }).toList()
..sort(source.compareAlbumsByName);
if (specialAlbums.isEmpty) return SizedBox.shrink(); if (specialAlbums.isEmpty) return SizedBox.shrink();
return Column( return Column(
@ -185,7 +186,7 @@ class _AppDrawerState extends State<AppDrawer> {
title: 'Albums', title: 'Albums',
trailing: StreamBuilder( trailing: StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(), stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, _) => Text('${source.sortedAlbums.length}'), builder: (context, _) => Text('${source.rawAlbums.length}'),
), ),
routeName: AlbumListPage.routeName, routeName: AlbumListPage.routeName,
pageBuilder: (_) => AlbumListPage(source: source), pageBuilder: (_) => AlbumListPage(source: source),

View file

@ -60,8 +60,7 @@ class AlbumListPage extends StatelessWidget {
// common with album selection page to move/copy entries // common with album selection page to move/copy entries
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> getAlbumEntries(CollectionSource source) { static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> getAlbumEntries(CollectionSource source) {
// albums are initially sorted by name at the source level final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getUniqueAlbumName(album))).toSet();
final filters = source.sortedAlbums.map((album) => AlbumFilter(album, source.getUniqueAlbumName(album)));
final sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters); final sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters);
return _group(sorted); return _group(sorted);

View file

@ -160,19 +160,22 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
return c != 0 ? c : a.key.compareTo(b.key); return c != 0 ? c : a.key.compareTo(b.key);
} }
static Iterable<FilterGridItem<T>> sort<T extends CollectionFilter>(ChipSortFactor sortFactor, CollectionSource source, Iterable<T> filters) { static int compareFiltersByName(FilterGridItem<CollectionFilter> a, FilterGridItem<CollectionFilter> b) {
return a.filter.compareTo(b.filter);
}
static Iterable<FilterGridItem<T>> sort<T extends CollectionFilter>(ChipSortFactor sortFactor, CollectionSource source, Set<T> filters) {
Iterable<FilterGridItem<T>> toGridItem(CollectionSource source, Iterable<T> filters) { Iterable<FilterGridItem<T>> toGridItem(CollectionSource source, Iterable<T> filters) {
final entriesByDate = source.sortedEntriesForFilterList;
return filters.map((filter) => FilterGridItem( return filters.map((filter) => FilterGridItem(
filter, filter,
entriesByDate.firstWhere(filter.filter, orElse: () => null), source.recentEntry(filter),
)); ));
} }
Iterable<FilterGridItem<T>> allMapEntries; Iterable<FilterGridItem<T>> allMapEntries;
switch (sortFactor) { switch (sortFactor) {
case ChipSortFactor.name: case ChipSortFactor.name:
allMapEntries = toGridItem(source, filters); allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByName);
break; break;
case ChipSortFactor.date: case ChipSortFactor.date:
allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByDate); allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByDate);
@ -180,7 +183,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
case ChipSortFactor.count: case ChipSortFactor.count:
final filtersWithCount = List.of(filters.map((filter) => MapEntry(filter, source.count(filter)))); final filtersWithCount = List.of(filters.map((filter) => MapEntry(filter, source.count(filter))));
filtersWithCount.sort(compareFiltersByEntryCount); filtersWithCount.sort(compareFiltersByEntryCount);
filters = filtersWithCount.map((kv) => kv.key).toList(); filters = filtersWithCount.map((kv) => kv.key).toSet();
allMapEntries = toGridItem(source, filters); allMapEntries = toGridItem(source, filters);
break; break;
} }

View file

@ -50,8 +50,7 @@ class CountryListPage extends StatelessWidget {
} }
Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _getCountryEntries() { Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _getCountryEntries() {
// countries are initially sorted by name at the source level final filters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location)).toSet();
final filters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location));
final sorted = FilterNavigationPage.sort(settings.countrySortFactor, source, filters); final sorted = FilterNavigationPage.sort(settings.countrySortFactor, source, filters);
return _group(sorted); return _group(sorted);

View file

@ -50,8 +50,7 @@ class TagListPage extends StatelessWidget {
} }
Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _getTagEntries() { Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _getTagEntries() {
// tags are initially sorted by name at the source level final filters = source.sortedTags.map((tag) => TagFilter(tag)).toSet();
final filters = source.sortedTags.map((tag) => TagFilter(tag));
final sorted = FilterNavigationPage.sort(settings.tagSortFactor, source, filters); final sorted = FilterNavigationPage.sort(settings.tagSortFactor, source, filters);
return _group(sorted); return _group(sorted);

View file

@ -86,7 +86,7 @@ class CollectionSearchDelegate {
MimeFilter(MimeFilter.sphericalVideo), MimeFilter(MimeFilter.sphericalVideo),
MimeFilter(MimeFilter.geotiff), MimeFilter(MimeFilter.geotiff),
MimeFilter(MimeTypes.svg), MimeFilter(MimeTypes.svg),
].where((f) => f != null && containQuery(f.label)), ].where((f) => f != null && containQuery(f.label)).toList(),
// usually perform hero animation only on tapped chips, // usually perform hero animation only on tapped chips,
// but we also need to animate the query chip when it is selected by submitting the search query // but we also need to animate the query chip when it is selected by submitting the search query
heroTypeBuilder: (filter) => filter == queryFilter ? HeroType.always : HeroType.onTap, heroTypeBuilder: (filter) => filter == queryFilter ? HeroType.always : HeroType.onTap,
@ -100,7 +100,8 @@ class CollectionSearchDelegate {
StreamBuilder( StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(), stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) { builder: (context, snapshot) {
final filters = source.sortedAlbums.where(containQuery).map((s) => AlbumFilter(s, source.getUniqueAlbumName(s))).where((f) => containQuery(f.uniqueName)); // filter twice: full path, and then unique name
final filters = source.rawAlbums.where(containQuery).map((s) => AlbumFilter(s, source.getUniqueAlbumName(s))).where((f) => containQuery(f.uniqueName)).toList()..sort();
return _buildFilterRow( return _buildFilterRow(
context: context, context: context,
title: 'Albums', title: 'Albums',
@ -110,7 +111,7 @@ class CollectionSearchDelegate {
StreamBuilder( StreamBuilder(
stream: source.eventBus.on<LocationsChangedEvent>(), stream: source.eventBus.on<LocationsChangedEvent>(),
builder: (context, snapshot) { builder: (context, snapshot) {
final filters = source.sortedCountries.where(containQuery).map((s) => LocationFilter(LocationLevel.country, s)); final filters = source.sortedCountries.where(containQuery).map((s) => LocationFilter(LocationLevel.country, s)).toList();
return _buildFilterRow( return _buildFilterRow(
context: context, context: context,
title: 'Countries', title: 'Countries',
@ -154,7 +155,7 @@ class CollectionSearchDelegate {
Widget _buildFilterRow({ Widget _buildFilterRow({
@required BuildContext context, @required BuildContext context,
String title, String title,
@required Iterable<CollectionFilter> filters, @required List<CollectionFilter> filters,
HeroType Function(CollectionFilter filter) heroTypeBuilder, HeroType Function(CollectionFilter filter) heroTypeBuilder,
}) { }) {
return ExpandableFilterRow( return ExpandableFilterRow(