various collection model fixes
This commit is contained in:
parent
319fd9584b
commit
c5ee55adb0
22 changed files with 211 additions and 98 deletions
|
@ -67,7 +67,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
val packages = HashMap<String, FieldMap>()
|
||||
|
||||
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
|
||||
val englishConfig = Configuration().apply { setLocale(Locale.ENGLISH) }
|
||||
|
||||
|
|
|
@ -42,6 +42,8 @@ class LocationFilter extends CollectionFilter {
|
|||
|
||||
String get countryNameAndCode => '$_location$locationSeparator$_countryCode';
|
||||
|
||||
String get countryCode => _countryCode;
|
||||
|
||||
@override
|
||||
EntryFilter get filter => _filter;
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:aves/model/filters/filters.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/material.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);
|
||||
|
||||
@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
|
||||
String get typeKey => type;
|
||||
|
|
|
@ -116,11 +116,11 @@ class MetadataDb {
|
|||
debugPrint('$runtimeType clearEntries deleted $count entries');
|
||||
}
|
||||
|
||||
Future<List<AvesEntry>> loadEntries() async {
|
||||
Future<Set<AvesEntry>> loadEntries() async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
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');
|
||||
return entries;
|
||||
}
|
||||
|
|
|
@ -7,9 +7,9 @@ import 'package:collection/collection.dart';
|
|||
import 'package:path/path.dart';
|
||||
|
||||
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) {
|
||||
final ua = getUniqueAlbumName(a);
|
||||
|
@ -21,15 +21,10 @@ mixin AlbumMixin on SourceBase {
|
|||
return compareAsciiUpperCase(va, vb);
|
||||
}
|
||||
|
||||
void updateAlbums() {
|
||||
final sorted = _folderPaths.toList()..sort(compareAlbumsByName);
|
||||
sortedAlbums = List.unmodifiable(sorted);
|
||||
invalidateFilterEntryCounts();
|
||||
eventBus.fire(AlbumsChangedEvent());
|
||||
}
|
||||
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
|
||||
|
||||
String getUniqueAlbumName(String album) {
|
||||
final otherAlbums = _folderPaths.where((item) => item != album);
|
||||
final otherAlbums = _directories.where((item) => item != album);
|
||||
final parts = album.split(separator);
|
||||
var partCount = 0;
|
||||
String testName;
|
||||
|
@ -51,9 +46,9 @@ mixin AlbumMixin on SourceBase {
|
|||
}
|
||||
|
||||
Map<String, AvesEntry> getAlbumEntries() {
|
||||
final entries = sortedEntriesForFilterList;
|
||||
final entries = sortedEntriesByDate;
|
||||
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
|
||||
for (final album in sortedAlbums) {
|
||||
for (final album in rawAlbums) {
|
||||
switch (androidFileUtils.getAlbumType(album)) {
|
||||
case AlbumType.regular:
|
||||
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]) {
|
||||
final emptyAlbums = (albums ?? _folderPaths).where(_isEmptyAlbum).toList();
|
||||
final emptyAlbums = (albums ?? _directories).where(_isEmptyAlbum).toSet();
|
||||
if (emptyAlbums.isNotEmpty) {
|
||||
_folderPaths.removeAll(emptyAlbums);
|
||||
updateAlbums();
|
||||
_directories.removeAll(emptyAlbums);
|
||||
_notifyAlbumChange();
|
||||
invalidateAlbumFilterSummary(directories: emptyAlbums);
|
||||
|
||||
final pinnedFilters = settings.pinnedFilters;
|
||||
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);
|
||||
|
||||
// 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 {}
|
||||
|
|
|
@ -42,7 +42,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
sortFactor = sortFactor ?? settings.collectionSortFactor {
|
||||
id ??= hashCode;
|
||||
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<EntryMovedEvent>().listen((e) => _refresh()));
|
||||
_subscriptions.add(source.eventBus.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
|
||||
|
@ -167,7 +167,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
break;
|
||||
case EntrySortFactor.name:
|
||||
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;
|
||||
}
|
||||
sections = Map.unmodifiable(sections);
|
||||
|
@ -183,7 +183,11 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
_applyGroup();
|
||||
}
|
||||
|
||||
void onEntryRemoved(Iterable<AvesEntry> entries) {
|
||||
void onEntryAdded(Set<AvesEntry> entries) {
|
||||
_refresh();
|
||||
}
|
||||
|
||||
void onEntryRemoved(Set<AvesEntry> entries) {
|
||||
// we should remove obsolete entries and sections
|
||||
// but do not apply sort/group
|
||||
// as section order change would surprise the user while browsing
|
||||
|
|
|
@ -2,19 +2,19 @@ import 'dart:async';
|
|||
|
||||
import 'package:aves/model/entry.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/location.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/metadata.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:aves/model/source/album.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/location.dart';
|
||||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/services/image_op_events.dart';
|
||||
import 'package:event_bus/event_bus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'enums.dart';
|
||||
|
||||
mixin SourceBase {
|
||||
final List<AvesEntry> _rawEntries = [];
|
||||
|
||||
|
@ -24,11 +24,7 @@ mixin SourceBase {
|
|||
|
||||
EventBus get eventBus => _eventBus;
|
||||
|
||||
List<AvesEntry> get sortedEntriesForFilterList;
|
||||
|
||||
final Map<CollectionFilter, int> _filterEntryCountMap = {};
|
||||
|
||||
void invalidateFilterEntryCounts() => _filterEntryCountMap.clear();
|
||||
List<AvesEntry> get sortedEntriesByDate;
|
||||
|
||||
final StreamController<ProgressEvent> _progressStreamController = StreamController.broadcast();
|
||||
|
||||
|
@ -38,12 +34,13 @@ mixin SourceBase {
|
|||
}
|
||||
|
||||
abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
|
||||
List<AvesEntry> _sortedEntriesByDate;
|
||||
|
||||
@override
|
||||
List<AvesEntry> get sortedEntriesForFilterList => CollectionLens(
|
||||
source: this,
|
||||
groupFactor: EntryGroupFactor.none,
|
||||
sortFactor: EntrySortFactor.date,
|
||||
).sortedEntries;
|
||||
List<AvesEntry> get sortedEntriesByDate {
|
||||
_sortedEntriesByDate ??= List.of(_rawEntries)..sort(AvesEntry.compareByDate);
|
||||
return _sortedEntriesByDate;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
void addAll(Iterable<AvesEntry> entries) {
|
||||
void addAll(Set<AvesEntry> entries) {
|
||||
if (entries.isEmpty) return;
|
||||
if (_rawEntries.isNotEmpty) {
|
||||
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;
|
||||
});
|
||||
_rawEntries.addAll(entries);
|
||||
addFolderPath(_rawEntries.map((entry) => entry.directory));
|
||||
invalidateFilterEntryCounts();
|
||||
eventBus.fire(EntryAddedEvent());
|
||||
addDirectory(_rawEntries.map((entry) => entry.directory));
|
||||
_invalidateFilterSummaries(entries);
|
||||
eventBus.fire(EntryAddedEvent(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());
|
||||
updateLocations();
|
||||
updateTags();
|
||||
invalidateFilterEntryCounts();
|
||||
_invalidateFilterSummaries(entries);
|
||||
eventBus.fire(EntryRemovedEvent(entries));
|
||||
}
|
||||
|
||||
void clearEntries() {
|
||||
_rawEntries.clear();
|
||||
cleanEmptyAlbums();
|
||||
updateAlbums();
|
||||
updateLocations();
|
||||
updateTags();
|
||||
invalidateFilterEntryCounts();
|
||||
_invalidateFilterSummaries();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
final fromAlbums = <String>{};
|
||||
final movedEntries = <AvesEntry>[];
|
||||
final movedEntries = <AvesEntry>{};
|
||||
if (copy) {
|
||||
movedOps.forEach((movedOp) {
|
||||
final sourceUri = movedOp.uri;
|
||||
|
@ -161,17 +157,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
addAll(movedEntries);
|
||||
} else {
|
||||
cleanEmptyAlbums(fromAlbums);
|
||||
addFolderPath({destinationAlbum});
|
||||
addDirectory({destinationAlbum});
|
||||
}
|
||||
updateAlbums();
|
||||
invalidateFilterEntryCounts();
|
||||
invalidateAlbumFilterSummary(directories: fromAlbums);
|
||||
_invalidateFilterSummaries(movedEntries);
|
||||
eventBus.fire(EntryMovedEvent(movedEntries));
|
||||
}
|
||||
|
||||
int count(CollectionFilter filter) {
|
||||
return _filterEntryCountMap.putIfAbsent(filter, () => _rawEntries.where((entry) => filter.filter(entry)).length);
|
||||
}
|
||||
|
||||
bool get initialized => false;
|
||||
|
||||
Future<void> init();
|
||||
|
@ -179,18 +171,41 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
Future<void> refresh();
|
||||
|
||||
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 }
|
||||
|
||||
class EntryAddedEvent {
|
||||
final AvesEntry entry;
|
||||
final Set<AvesEntry> entries;
|
||||
|
||||
const EntryAddedEvent([this.entry]);
|
||||
const EntryAddedEvent([this.entries]);
|
||||
}
|
||||
|
||||
class EntryRemovedEvent {
|
||||
final Iterable<AvesEntry> entries;
|
||||
final Set<AvesEntry> entries;
|
||||
|
||||
const EntryRemovedEvent(this.entries);
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
sortedCountries = List<String>.unmodifiable(countriesByCode.entries.map((kv) => '${kv.value}${LocationFilter.locationSeparator}${kv.key}').toList()..sort(compareAsciiUpperCase));
|
||||
|
||||
invalidateFilterEntryCounts();
|
||||
invalidateCountryFilterSummary();
|
||||
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 {}
|
||||
|
|
|
@ -66,7 +66,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
// refresh after the first 10 entries, then after 100 more, then every 1000 entries
|
||||
var refreshCount = 10;
|
||||
const refreshCountMax = 1000;
|
||||
final allNewEntries = <AvesEntry>[], pendingNewEntries = <AvesEntry>[];
|
||||
final allNewEntries = <AvesEntry>{}, pendingNewEntries = <AvesEntry>{};
|
||||
void addPendingEntries() {
|
||||
allNewEntries.addAll(pendingNewEntries);
|
||||
addAll(pendingNewEntries);
|
||||
|
@ -86,10 +86,11 @@ class MediaStoreSource extends CollectionSource {
|
|||
debugPrint('$runtimeType refresh loaded ${allNewEntries.length} new entries, elapsed=${stopwatch.elapsed}');
|
||||
|
||||
await metadataDb.saveEntries(allNewEntries); // 700ms for 5500 entries
|
||||
updateAlbums();
|
||||
invalidateAlbumFilterSummary(entries: allNewEntries);
|
||||
|
||||
final analytics = FirebaseAnalytics();
|
||||
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;
|
||||
await catalogEntries();
|
||||
|
@ -128,7 +129,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
removeEntries(obsoleteEntries);
|
||||
|
||||
// fetch new entries
|
||||
final newEntries = <AvesEntry>[];
|
||||
final newEntries = <AvesEntry>{};
|
||||
for (final kv in uriByContentId.entries) {
|
||||
final contentId = kv.key;
|
||||
final uri = kv.value;
|
||||
|
@ -150,7 +151,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
if (newEntries.isNotEmpty) {
|
||||
addAll(newEntries);
|
||||
await metadataDb.saveEntries(newEntries);
|
||||
updateAlbums();
|
||||
invalidateAlbumFilterSummary(entries: newEntries);
|
||||
|
||||
stateNotifier.value = SourceState.cataloguing;
|
||||
await catalogEntries();
|
||||
|
|
|
@ -5,21 +5,21 @@ class SectionKey {
|
|||
}
|
||||
|
||||
class EntryAlbumSectionKey extends SectionKey {
|
||||
final String folderPath;
|
||||
final String directory;
|
||||
|
||||
const EntryAlbumSectionKey(this.folderPath);
|
||||
const EntryAlbumSectionKey(this.directory);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is EntryAlbumSectionKey && other.folderPath == folderPath;
|
||||
return other is EntryAlbumSectionKey && other.directory == directory;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => folderPath.hashCode;
|
||||
int get hashCode => directory.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{folderPath=$folderPath}';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{directory=$directory}';
|
||||
}
|
||||
|
||||
class EntryDateSectionKey extends SectionKey {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/metadata.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
|
@ -56,9 +57,34 @@ mixin TagMixin on SourceBase {
|
|||
void updateTags() {
|
||||
final tags = rawEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase);
|
||||
sortedTags = List.unmodifiable(tags);
|
||||
invalidateFilterEntryCounts();
|
||||
|
||||
invalidateTagFilterSummary();
|
||||
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 {}
|
||||
|
|
|
@ -8,13 +8,26 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class CollectionPage extends StatelessWidget {
|
||||
class CollectionPage extends StatefulWidget {
|
||||
static const routeName = '/collection';
|
||||
|
||||
final CollectionLens 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
|
||||
Widget build(BuildContext context) {
|
||||
return MediaQueryDataProvider(
|
||||
|
|
|
@ -7,18 +7,18 @@ import 'package:aves/widgets/common/identity/aves_icons.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class AlbumSectionHeader extends StatelessWidget {
|
||||
final String folderPath, albumName;
|
||||
final String directory, albumName;
|
||||
|
||||
AlbumSectionHeader({
|
||||
Key key,
|
||||
@required CollectionSource source,
|
||||
@required this.folderPath,
|
||||
}) : albumName = source.getUniqueAlbumName(folderPath),
|
||||
@required this.directory,
|
||||
}) : albumName = source.getUniqueAlbumName(directory),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var albumIcon = IconUtils.getAlbumIcon(context: context, album: folderPath);
|
||||
var albumIcon = IconUtils.getAlbumIcon(context: context, album: directory);
|
||||
if (albumIcon != null) {
|
||||
albumIcon = Material(
|
||||
type: MaterialType.circle,
|
||||
|
@ -29,10 +29,10 @@ class AlbumSectionHeader extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
return SectionHeader(
|
||||
sectionKey: EntryAlbumSectionKey(folderPath),
|
||||
sectionKey: EntryAlbumSectionKey(directory),
|
||||
leading: albumIcon,
|
||||
title: albumName,
|
||||
trailing: androidFileUtils.isOnRemovableStorage(folderPath)
|
||||
trailing: androidFileUtils.isOnRemovableStorage(directory)
|
||||
? Icon(
|
||||
AIcons.removableStorage,
|
||||
size: 16,
|
||||
|
@ -43,13 +43,13 @@ class AlbumSectionHeader extends StatelessWidget {
|
|||
}
|
||||
|
||||
static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, EntryAlbumSectionKey sectionKey) {
|
||||
final folderPath = sectionKey.folderPath;
|
||||
final directory = sectionKey.directory;
|
||||
return SectionHeader.getPreferredHeight(
|
||||
context: context,
|
||||
maxWidth: maxWidth,
|
||||
title: source.getUniqueAlbumName(folderPath),
|
||||
hasLeading: androidFileUtils.getAlbumType(folderPath) != AlbumType.regular,
|
||||
hasTrailing: androidFileUtils.isOnRemovableStorage(folderPath),
|
||||
title: source.getUniqueAlbumName(directory),
|
||||
hasLeading: androidFileUtils.getAlbumType(directory) != AlbumType.regular,
|
||||
hasTrailing: androidFileUtils.isOnRemovableStorage(directory),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ class CollectionSectionHeader extends StatelessWidget {
|
|||
Widget _buildAlbumHeader() => AlbumSectionHeader(
|
||||
key: ValueKey(sectionKey),
|
||||
source: collection.source,
|
||||
folderPath: (sectionKey as EntryAlbumSectionKey).folderPath,
|
||||
directory: (sectionKey as EntryAlbumSectionKey).directory,
|
||||
);
|
||||
|
||||
switch (collection.sortFactor) {
|
||||
|
|
|
@ -20,6 +20,7 @@ class AvesFilterChip extends StatefulWidget {
|
|||
final OffsetFilterCallback onLongPress;
|
||||
final BorderRadius borderRadius;
|
||||
|
||||
static const Color defaultOutlineColor = Colors.white;
|
||||
static const double defaultRadius = 32;
|
||||
static const double outlineWidth = 2;
|
||||
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.
|
||||
// So we save the result of the Future to a local variable because of this specific case.
|
||||
_colorFuture = filter.color(context);
|
||||
_outlineColor = Colors.transparent;
|
||||
_outlineColor = AvesFilterChip.defaultOutlineColor;
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
@ -13,7 +13,7 @@ class DebugAppDatabaseSection extends StatefulWidget {
|
|||
|
||||
class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with AutomaticKeepAliveClientMixin {
|
||||
Future<int> _dbFileSizeLoader;
|
||||
Future<List<AvesEntry>> _dbEntryLoader;
|
||||
Future<Set<AvesEntry>> _dbEntryLoader;
|
||||
Future<List<DateMetadata>> _dbDateLoader;
|
||||
Future<List<CatalogMetadata>> _dbMetadataLoader;
|
||||
Future<List<AddressDetails>> _dbAddressLoader;
|
||||
|
@ -57,7 +57,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
|
|||
);
|
||||
},
|
||||
),
|
||||
FutureBuilder<List>(
|
||||
FutureBuilder<Set<AvesEntry>>(
|
||||
future: _dbEntryLoader,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
|
|
|
@ -142,10 +142,11 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
return StreamBuilder(
|
||||
stream: source.eventBus.on<AlbumsChangedEvent>(),
|
||||
builder: (context, snapshot) {
|
||||
final specialAlbums = source.sortedAlbums.where((album) {
|
||||
final specialAlbums = source.rawAlbums.where((album) {
|
||||
final type = androidFileUtils.getAlbumType(album);
|
||||
return [AlbumType.camera, AlbumType.screenshots].contains(type);
|
||||
});
|
||||
}).toList()
|
||||
..sort(source.compareAlbumsByName);
|
||||
|
||||
if (specialAlbums.isEmpty) return SizedBox.shrink();
|
||||
return Column(
|
||||
|
@ -185,7 +186,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
title: 'Albums',
|
||||
trailing: StreamBuilder(
|
||||
stream: source.eventBus.on<AlbumsChangedEvent>(),
|
||||
builder: (context, _) => Text('${source.sortedAlbums.length}'),
|
||||
builder: (context, _) => Text('${source.rawAlbums.length}'),
|
||||
),
|
||||
routeName: AlbumListPage.routeName,
|
||||
pageBuilder: (_) => AlbumListPage(source: source),
|
||||
|
|
|
@ -60,8 +60,7 @@ class AlbumListPage extends StatelessWidget {
|
|||
// common with album selection page to move/copy entries
|
||||
|
||||
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> getAlbumEntries(CollectionSource source) {
|
||||
// albums are initially sorted by name at the source level
|
||||
final filters = source.sortedAlbums.map((album) => AlbumFilter(album, source.getUniqueAlbumName(album)));
|
||||
final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getUniqueAlbumName(album))).toSet();
|
||||
|
||||
final sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters);
|
||||
return _group(sorted);
|
||||
|
|
|
@ -160,19 +160,22 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
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) {
|
||||
final entriesByDate = source.sortedEntriesForFilterList;
|
||||
return filters.map((filter) => FilterGridItem(
|
||||
filter,
|
||||
entriesByDate.firstWhere(filter.filter, orElse: () => null),
|
||||
source.recentEntry(filter),
|
||||
));
|
||||
}
|
||||
|
||||
Iterable<FilterGridItem<T>> allMapEntries;
|
||||
switch (sortFactor) {
|
||||
case ChipSortFactor.name:
|
||||
allMapEntries = toGridItem(source, filters);
|
||||
allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByName);
|
||||
break;
|
||||
case ChipSortFactor.date:
|
||||
allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByDate);
|
||||
|
@ -180,7 +183,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
case ChipSortFactor.count:
|
||||
final filtersWithCount = List.of(filters.map((filter) => MapEntry(filter, source.count(filter))));
|
||||
filtersWithCount.sort(compareFiltersByEntryCount);
|
||||
filters = filtersWithCount.map((kv) => kv.key).toList();
|
||||
filters = filtersWithCount.map((kv) => kv.key).toSet();
|
||||
allMapEntries = toGridItem(source, filters);
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -50,8 +50,7 @@ class CountryListPage extends StatelessWidget {
|
|||
}
|
||||
|
||||
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));
|
||||
final filters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location)).toSet();
|
||||
|
||||
final sorted = FilterNavigationPage.sort(settings.countrySortFactor, source, filters);
|
||||
return _group(sorted);
|
||||
|
|
|
@ -50,8 +50,7 @@ class TagListPage extends StatelessWidget {
|
|||
}
|
||||
|
||||
Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _getTagEntries() {
|
||||
// tags are initially sorted by name at the source level
|
||||
final filters = source.sortedTags.map((tag) => TagFilter(tag));
|
||||
final filters = source.sortedTags.map((tag) => TagFilter(tag)).toSet();
|
||||
|
||||
final sorted = FilterNavigationPage.sort(settings.tagSortFactor, source, filters);
|
||||
return _group(sorted);
|
||||
|
|
|
@ -86,7 +86,7 @@ class CollectionSearchDelegate {
|
|||
MimeFilter(MimeFilter.sphericalVideo),
|
||||
MimeFilter(MimeFilter.geotiff),
|
||||
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,
|
||||
// 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,
|
||||
|
@ -100,7 +100,8 @@ class CollectionSearchDelegate {
|
|||
StreamBuilder(
|
||||
stream: source.eventBus.on<AlbumsChangedEvent>(),
|
||||
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(
|
||||
context: context,
|
||||
title: 'Albums',
|
||||
|
@ -110,7 +111,7 @@ class CollectionSearchDelegate {
|
|||
StreamBuilder(
|
||||
stream: source.eventBus.on<LocationsChangedEvent>(),
|
||||
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(
|
||||
context: context,
|
||||
title: 'Countries',
|
||||
|
@ -154,7 +155,7 @@ class CollectionSearchDelegate {
|
|||
Widget _buildFilterRow({
|
||||
@required BuildContext context,
|
||||
String title,
|
||||
@required Iterable<CollectionFilter> filters,
|
||||
@required List<CollectionFilter> filters,
|
||||
HeroType Function(CollectionFilter filter) heroTypeBuilder,
|
||||
}) {
|
||||
return ExpandableFilterRow(
|
||||
|
|
Loading…
Reference in a new issue