aves_mio0.31/lib/model/source/collection_source.dart.ok
2026-07-18 13:39:22 +02:00

1013 lines
29 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// lib/model/source/collection_source.dart
import 'dart:async';
import 'dart:ui';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/catalog.dart';
import 'package:aves/model/entry/extensions/keys.dart';
import 'package:aves/model/entry/extensions/location.dart';
import 'package:aves/model/entry/sort.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/container/album_group.dart';
import 'package:aves/model/filters/container/tag_group.dart';
import 'package:aves/model/filters/covered/location.dart';
import 'package:aves/model/filters/covered/stored_album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/trash.dart';
import 'package:aves/model/grouping/common.dart';
import 'package:aves/model/grouping/convert.dart';
import 'package:aves/model/metadata/trash.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/events.dart';
import 'package:aves/model/source/location/country.dart';
import 'package:aves/model/source/location/location.dart';
import 'package:aves/model/source/location/place.dart';
import 'package:aves/model/source/location/state.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/model/source/trash.dart';
import 'package:aves/model/vaults/vaults.dart';
import 'package:aves/services/analysis_service.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/widgets/aves_app.dart';
import 'package:aves_model/aves_model.dart';
import 'package:collection/collection.dart';
import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart';
import 'package:leak_tracker/leak_tracker.dart';
import 'package:aves/model/db/folder_stats.dart';
import 'package:aves/model/db/db_sqflite.dart';
// Remote origin
import 'package:aves/remote/remote_origin.dart';
import 'package:aves/remote/remote_sync_bus.dart';
typedef SourceScope = Set<CollectionFilter>?;
// Trace opzionale: mostra chi nasconderebbe i remoti, senza nasconderli.
const bool kTraceHiddenRemotes = true;
mixin SourceBase {
EventBus get eventBus;
Map<int, AvesEntry> get entryById;
Map<String, FolderStats> get folderMap;
Set<AvesEntry> get allEntries;
Set<AvesEntry> get visibleEntries;
Set<AvesEntry> get trashedEntries;
List<AvesEntry> get sortedEntriesByDate;
ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready);
set state(SourceState value) => stateNotifier.value = value;
SourceState get state => stateNotifier.value;
bool get isReady => state == SourceState.ready;
// Debug: forza visibilità remota per testing.
bool debugForceRemoteVisible = false;
bool get remoteVisible {
if (debugForceRemoteVisible) return true;
final state = RemoteSyncBus.instance.stateNotifier.value;
return state == RemoteSyncState.upToDate;
}
ValueNotifier<ProgressEvent> progressNotifier =
ValueNotifier(const ProgressEvent(done: 0, total: 0));
void setProgress({required int done, required int total}) =>
progressNotifier.value = ProgressEvent(done: done, total: total);
void invalidateEntries();
}
abstract class CollectionSource
with
SourceBase,
AlbumMixin,
CountryMixin,
PlaceMixin,
StateMixin,
LocationMixin,
TagMixin,
TrashMixin {
static const fullScope = <CollectionFilter>{};
CollectionSource() {
if (kFlutterMemoryAllocationsEnabled) {
LeakTracking.dispatchObjectCreated(
library: 'aves',
className: '$CollectionSource',
object: this,
);
}
settings.updateStream
.where((event) => event.key == SettingKeys.localeKey)
.listen((_) => invalidateStoredAlbumDisplayNames());
settings.updateStream
.where((event) => event.key == SettingKeys.hiddenFiltersKey)
.listen((event) {
final oldValue = event.oldValue;
if (oldValue is List<String>?) {
final oldHiddenFilters =
(oldValue ?? []).map(CollectionFilter.fromJson).nonNulls.toSet();
final newlyVisibleFilters =
oldHiddenFilters.whereNot(settings.hiddenFilters.contains).toSet();
_onFilterVisibilityChanged(newlyVisibleFilters);
}
});
vaults.addListener(_onVaultsChanged);
RemoteSyncBus.instance.stateNotifier.addListener(_onRemoteVisibilityChanged);
_lastRemoteVisible = remoteVisible;
}
@mustCallSuper
void dispose() {
if (kFlutterMemoryAllocationsEnabled) {
LeakTracking.dispatchObjectDisposed(object: this);
}
vaults.removeListener(_onVaultsChanged);
RemoteSyncBus.instance.stateNotifier.removeListener(_onRemoteVisibilityChanged);
_rawEntries.forEach((v) => v.dispose());
}
set canAnalyze(bool enabled);
final EventBus _eventBus = EventBus();
@override
EventBus get eventBus => _eventBus;
final Map<int, AvesEntry> _entryById = {};
@override
Map<int, AvesEntry> get entryById => Map.unmodifiable(_entryById);
final Set<AvesEntry> _rawEntries = {};
bool? _lastRemoteVisible;
@override
Set<AvesEntry> get allEntries => Set.unmodifiable(_rawEntries);
Set<AvesEntry>? _visibleEntries, _trashedEntries;
@override
Set<AvesEntry> get visibleEntries {
_visibleEntries ??= Set.unmodifiable(_applyHiddenFilters(_rawEntries));
return _visibleEntries!;
}
@override
Set<AvesEntry> get trashedEntries {
_trashedEntries ??= Set.unmodifiable(_applyTrashFilter(_rawEntries));
return _trashedEntries!;
}
List<AvesEntry>? _sortedEntriesByDate;
@override
List<AvesEntry> get sortedEntriesByDate {
_sortedEntriesByDate ??=
List.unmodifiable(visibleEntries.toList()..sort(AvesEntrySort.compareByDate));
return _sortedEntriesByDate!;
}
// known date by entry ID
late Map<int?, int?> _savedDates;
// === FOLDER MAP: fonte dati unica per AlbumPage e InfoPage ===
late Map<String, FolderStats> _folderMap = {};
@override
Map<String, FolderStats> get folderMap => _folderMap;
Future<void> computeFolderMap({String reason = 'unknown'}) async {
debugPrint(
'[folders] computeFolderMap requested '
'reason=$reason '
'rawEntries=${_rawEntries.length} '
'remoteVisible=$remoteVisible',
);
// Evita di sovrascrivere una folderMap valida con una vuota durante startup.
if (_rawEntries.isEmpty) {
debugPrint('[folders] computeFolderMap skipped rawEntries=0');
return;
}
// Reset cache derivate.
// Nota: questo NON cambia folderMap, ma forza il ricalcolo di visibleEntries
// secondo lo stato remoteVisible corrente.
_visibleEntries = null;
_trashedEntries = null;
_sortedEntriesByDate = null;
// FolderMap canonica:
// deve rappresentare tutte le cartelle note nella source,
// indipendentemente da remoteVisible.
//
// Non usare visibleEntries qui, perché visibleEntries cambia quando
// remoteVisible passa da false a true.
//
// Usiamo _rawEntries, escludendo solo il cestino e directory nulle/vuote.
final entries = _rawEntries.where((entry) {
if (entry.trashed) return false;
final dir = entry.directory;
if (dir == null || dir.isEmpty) return false;
return true;
}).toList();
debugPrint(
'[folders] computeFolderMap start '
'reason=$reason '
'rawEntries=${_rawEntries.length} '
'sourceEntries=${entries.length} '
'visibleEntries=${visibleEntries.length} '
'remoteVisible=$remoteVisible',
);
final map = <String, FolderStats>{};
for (final entry in entries) {
final dir = entry.directory;
if (dir == null || dir.isEmpty) continue;
map.putIfAbsent(dir, () {
return FolderStats(
path: dir,
displayName: dir.split('/').last,
fileCount: 0,
totalSize: 0,
thumbEntryId: entry.id,
thumbDate: entry.dateModifiedMillis,
albumType: 0,
priority: 0,
isRemote: entry.origin,
user: null,
deviceId: null,
hybridType: null,
hidden: 0,
);
});
final stats = map[dir]!;
stats.fileCount++;
stats.totalSize += entry.sizeBytes ?? 0;
// Usa come thumbnail l'entry più recente della cartella.
final currentThumbDate = stats.thumbDate ?? 0;
final entryDate = entry.dateModifiedMillis ?? 0;
if (entryDate > currentThumbDate) {
stats.thumbEntryId = entry.id;
stats.thumbDate = entry.dateModifiedMillis;
}
}
debugPrint(
'[folders] computeFolderMap built '
'reason=$reason '
'folders=${map.length}',
);
await finalizeFolders(map);
}
Future<void> finalizeFolders(Map<String, FolderStats> map) async {
debugPrint(
'[folders] finalizeFolders start folders=${map.length}',
);
await localMediaDb.saveFoldersBatch(map.values.toList());
_folderMap = Map.unmodifiable(map);
debugPrint(
'[folders] finalizeFolders done folderMap=${_folderMap.length}',
);
eventBus.fire(AlbumsChangedEvent());
}
List<String> foldersForEntry(AvesEntry entry) {
final dir = entry.directory;
if (dir == null) return const [];
return _folderMap.containsKey(dir) ? [dir] : const [];
}
Future<void> loadDates() async {
_savedDates = Map.unmodifiable(await localMediaDb.loadDates());
}
Set<CollectionFilter> _getAppHiddenFilters() => {
...settings.hiddenFilters,
...vaults.vaultDirectories
.where(vaults.isLocked)
.map((v) => StoredAlbumFilter(v, null)),
};
Iterable<AvesEntry> _applyHiddenFilters(Iterable<AvesEntry> entries) {
final hiddenFilters = {
TrashFilter.instance,
..._getAppHiddenFilters(),
};
final remoteOn = remoteVisible;
if (!remoteOn) {
entries = entries.where((e) => e.origin == 0).toList();
}
return entries.where((entry) {
if (entry.origin == RemoteOrigin.value) {
if (kTraceHiddenRemotes) {
final hiddenBy = hiddenFilters.firstWhereOrNull((f) => f.test(entry));
if (hiddenBy != null && !TrashFilter.instance.test(entry)) {
debugPrint(
'[hidden][trace] remote id=${entry.id} '
'rid=${entry.remoteId} by=${hiddenBy.runtimeType}',
);
}
}
// Remoti: nascondi solo se nel cestino.
return !TrashFilter.instance.test(entry);
}
// Locali: logica originale.
return !hiddenFilters.any((filter) => filter.test(entry));
});
}
Iterable<AvesEntry> _applyTrashFilter(Iterable<AvesEntry> entries) {
final hiddenFilters = _getAppHiddenFilters();
return entries
.where(TrashFilter.instance.test)
.where((entry) => !hiddenFilters.any((filter) => filter.test(entry)));
}
void _invalidate({Set<AvesEntry>? entries, bool notify = true}) {
if (_rawEntries.isEmpty) return;
invalidateEntries();
// invalidateAlbumFilterSummary(entries: entries, notify: notify);
invalidateCountryFilterSummary(entries: entries, notify: notify);
invalidatePlaceFilterSummary(entries: entries, notify: notify);
invalidateStateFilterSummary(entries: entries, notify: notify);
invalidateTagFilterSummary(entries: entries, notify: notify);
}
@override
void invalidateEntries() {
_visibleEntries = null;
_trashedEntries = null;
_sortedEntriesByDate = null;
}
Future<void> reloadEntriesFromDb() async {
final dbEntries = await localMediaDb.loadEntries();
_rawEntries
..clear()
..addAll(dbEntries);
_entryById
..clear()
..addEntries(dbEntries.map((e) => MapEntry(e.id, e)));
await computeFolderMap(reason: 'reloadEntriesFromDb');
}
void updateDerivedFilters([Set<AvesEntry>? entries]) {
_invalidate(entries: entries);
// It is possible for entries hidden by a filter type to impact other types.
// updateDirectories() è gestito dalla folderMap/computeFolderMap.
updateLocations();
updateTags();
}
Future<void> addEntries(Set<AvesEntry> entries, {bool notify = true}) async {
if (entries.isEmpty) return;
await localMediaDb.insertEntries(entries);
final dbEntries = await localMediaDb.loadEntries();
_rawEntries
..clear()
..addAll(dbEntries);
_entryById
..clear()
..addEntries(dbEntries.map((e) => MapEntry(e.id, e)));
_invalidate(entries: entries, notify: notify);
//await computeFolderMap();
await computeFolderMap(reason: 'addEntries');
if (notify) {
eventBus.fire(EntryAddedEvent(entries));
}
}
void removeEntriesFromMemory(Set<AvesEntry> entries, {bool notify = true}) {
if (entries.isEmpty) return;
for (final e in entries) {
_entryById.remove(e.id);
}
_rawEntries.removeAll(entries);
updateDerivedFilters(entries);
unawaited(computeFolderMap());
if (notify) {
eventBus.fire(EntryRemovedEvent(entries));
}
}
Future<void> removeEntries(Set<String> uris, {required bool includeTrash}) async {
if (uris.isEmpty) return;
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
if (!includeTrash) {
entries.removeWhere(TrashFilter.instance.test);
}
if (entries.isEmpty) return;
final ids = entries.map((entry) => entry.id).toSet();
await favourites.removeIds(ids);
await covers.removeIds(ids);
await localMediaDb.removeIds(ids);
for (final id in ids) {
_entryById.remove(id);
}
_rawEntries.removeAll(entries);
updateDerivedFilters(entries);
await computeFolderMap();
eventBus.fire(EntryRemovedEvent(entries));
}
void clearEntries() {
_entryById.clear();
_rawEntries.clear();
_folderMap = {};
_invalidate();
// Do not update directories/locations/tags here.
}
/// Carica dal DB tutte le entry remote e le aggiunge alla sorgente corrente.
Future<void> appendRemoteEntries({bool notify = true}) async {
final remotes = await localMediaDb.loadEntries(origin: RemoteOrigin.value);
if (remotes.isEmpty) return;
final visibleRemotes = remotes.where((e) => !e.trashed).toSet();
if (visibleRemotes.isEmpty) return;
await localMediaDb.insertEntries(visibleRemotes);
final dbEntries = await localMediaDb.loadEntries();
_rawEntries
..clear()
..addAll(dbEntries);
_entryById
..clear()
..addEntries(dbEntries.map((e) => MapEntry(e.id, e)));
_invalidate(entries: visibleRemotes, notify: notify);
await computeFolderMap();
if (notify) {
eventBus.fire(EntryAddedEvent(visibleRemotes));
}
}
Future<void> _moveEntry(
AvesEntry entry,
Map newFields, {
required bool persist,
}) async {
newFields.keys.forEach((key) {
final newValue = newFields[key];
switch (key) {
case EntryFields.contentId:
entry.contentId = newValue as int?;
case EntryFields.dateModifiedMillis:
entry.dateModifiedMillis = newValue as int?;
case EntryFields.path:
entry.path = newValue as String?;
case EntryFields.title:
entry.sourceTitle = newValue as String?;
case EntryFields.trashed:
final trashed = newValue as bool;
entry.trashed = trashed;
entry.trashDetails = trashed
? TrashDetails(
id: entry.id,
path: newFields[EntryFields.trashPath] as String,
dateMillis: DateTime.now().millisecondsSinceEpoch,
)
: null;
case EntryFields.uri:
entry.uri = newValue as String;
case EntryFields.origin:
entry.origin = newValue as int;
}
});
if (entry.trashed) {
final trashPath = entry.trashDetails?.path;
if (trashPath != null) {
entry.contentId = null;
entry.uri = Uri.file(trashPath).toString();
} else {
debugPrint('failed to update uri from unknown trash path for uri=${entry.uri}');
}
}
if (persist) {
await covers.moveEntry(entry);
final id = entry.id;
await localMediaDb.updateEntry(id, entry);
await localMediaDb.updateCatalogMetadata(id, entry.catalogMetadata);
await localMediaDb.updateAddress(id, entry.addressDetails);
await localMediaDb.updateTrash(id, entry.trashDetails);
}
}
Future<void> updateAfterMove({
required Set<AvesEntry> todoEntries,
required MoveType moveType,
required Set<String> destinationAlbums,
required Set<MoveOpEvent> movedOps,
}) async {
if (movedOps.isEmpty) return;
final replacedUris = movedOps
.map((movedOp) => movedOp.newFields[EntryFields.path] as String?)
.map((targetPath) {
final existingEntry = _rawEntries.firstWhereOrNull(
(entry) => entry.path == targetPath && !entry.trashed,
);
return existingEntry?.uri;
})
.nonNulls
.toSet();
await removeEntries(replacedUris, includeTrash: false);
final fromAlbums = <String?>{};
final movedEntries = <AvesEntry>{};
final copy = moveType == MoveType.copy;
if (copy) {
movedOps.forEach((movedOp) {
final sourceUri = movedOp.uri;
final newFields = movedOp.newFields;
final sourceEntry =
todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri);
if (sourceEntry != null) {
fromAlbums.add(sourceEntry.directory);
movedEntries.add(
sourceEntry.copyWith(
id: localMediaDb.nextId,
uri: newFields[EntryFields.uri] as String?,
path: newFields[EntryFields.path] as String?,
contentId: newFields[EntryFields.contentId] as int?,
title: newFields[EntryFields.title] as String?,
dateAddedSecs: newFields[EntryFields.dateAddedSecs] as int?,
dateModifiedMillis:
newFields[EntryFields.dateModifiedMillis] as int?,
origin: newFields[EntryFields.origin] as int?,
),
);
} else {
debugPrint('failed to find source entry with uri=$sourceUri');
}
});
await localMediaDb.insertEntries(movedEntries);
await localMediaDb.saveCatalogMetadata(
movedEntries.map((entry) => entry.catalogMetadata).nonNulls.toSet(),
);
await localMediaDb.saveAddresses(
movedEntries.map((entry) => entry.addressDetails).nonNulls.toSet(),
);
} else {
await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async {
final newFields = movedOp.newFields;
if (newFields.isNotEmpty) {
final sourceUri = movedOp.uri;
final entry = todoEntries.firstWhereOrNull(
(entry) => entry.uri == sourceUri,
);
if (entry != null) {
if (moveType == MoveType.fromBin) {
newFields[EntryFields.trashed] = false;
} else {
fromAlbums.add(entry.directory);
}
movedEntries.add(entry);
await _moveEntry(entry, newFields, persist: true);
}
}
});
}
switch (moveType) {
case MoveType.copy:
await addEntries(movedEntries);
case MoveType.move:
case MoveType.export:
cleanEmptyAlbums(fromAlbums.nonNulls.toSet());
addDirectories(albums: destinationAlbums);
case MoveType.toBin:
case MoveType.fromBin:
updateDerivedFilters(movedEntries);
}
invalidateAlbumFilterSummary(directories: fromAlbums);
_invalidate(entries: movedEntries);
await computeFolderMap();
eventBus.fire(EntryMovedEvent(moveType, movedEntries));
}
Future<void> updateAfterRename({
required Set<AvesEntry> todoEntries,
required Set<MoveOpEvent> movedOps,
required bool persist,
}) async {
if (movedOps.isEmpty) return;
final movedEntries = <AvesEntry>{};
await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async {
final newFields = movedOp.newFields;
if (newFields.isNotEmpty) {
final sourceUri = movedOp.uri;
final entry =
todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri);
if (entry != null) {
movedEntries.add(entry);
await _moveEntry(entry, newFields, persist: persist);
}
}
});
await computeFolderMap();
eventBus.fire(EntryMovedEvent(MoveType.move, movedEntries));
}
SourceScope get loadedScope;
SourceScope get targetScope;
@override
Future<void> init({
required SourceScope scope,
AnalysisController? analysisController,
bool loadTopEntriesFirst = false,
}) async {
// MediaStoreSource.init() è lunica vera implementazione.
}
Future<Set<String>> refreshUris(
Set<String> changedUris, {
AnalysisController? analysisController,
});
Future<void> refreshEntries(
Set<AvesEntry> entries,
Set<EntryDataType> dataTypes,
) async {
const background = false;
const persist = true;
await Future.forEach(entries, (entry) async {
await entry.refresh(
background: background,
persist: persist,
dataTypes: dataTypes,
);
});
if (dataTypes.contains(EntryDataType.aspectRatio)) {
onAspectRatioChanged();
}
if (dataTypes.contains(EntryDataType.catalog)) {
await deviceService.requestGarbageCollection();
await Future.forEach(entries, (entry) async {
await entry.catalog(
background: background,
force: dataTypes.contains(EntryDataType.catalog),
persist: persist,
);
await localMediaDb.updateCatalogMetadata(entry.id, entry.catalogMetadata);
});
onCatalogMetadataChanged();
}
if (dataTypes.contains(EntryDataType.address)) {
await Future.forEach(entries, (entry) async {
await entry.locate(
background: background,
force: dataTypes.contains(EntryDataType.address),
geocoderLocale: settings.appliedLocale,
);
await localMediaDb.updateAddress(entry.id, entry.addressDetails);
});
onAddressMetadataChanged();
}
updateDerivedFilters(entries);
await computeFolderMap();
eventBus.fire(EntryRefreshedEvent(entries));
}
Future<void> analyze(
AnalysisController? analysisController, {
Set<AvesEntry>? entries,
}) async {
final todoEntries = entries ?? allEntries;
final defaultAnalysisController = AnalysisController();
final _analysisController = analysisController ?? defaultAnalysisController;
final force = _analysisController.force;
if (!_analysisController.isStopping) {
var startAnalysisService = false;
if (_analysisController.canStartService && settings.canUseAnalysisService) {
if (!startAnalysisService) {
final opCount =
(force ? todoEntries : todoEntries.where(TagMixin.catalogEntriesTest))
.length;
startAnalysisService = opCount > TagMixin.commitCountThreshold;
}
if (!startAnalysisService && await availability.canLocatePlaces) {
final opCount = (force
? todoEntries.where((entry) => entry.hasGps)
: todoEntries.where(LocationMixin.locatePlacesTest))
.length;
startAnalysisService = opCount > LocationMixin.commitCountThreshold;
}
}
debugPrint(
'analyze ${todoEntries.length} entries, '
'force=$force, starting service=$startAnalysisService',
);
if (startAnalysisService) {
final lifecycleState = AvesApp.lifecycleStateNotifier.value;
switch (lifecycleState) {
case AppLifecycleState.resumed:
case AppLifecycleState.inactive:
await AnalysisService.startService(
force: force,
entryIds: entries?.map((entry) => entry.id).toList(),
);
default:
unawaited(
reportService.log(
'analysis service not started because app is in state=$lifecycleState',
),
);
}
} else {
await deviceService.requestGarbageCollection();
await catalogEntries(_analysisController, todoEntries);
updateDerivedFilters(todoEntries);
await locateEntries(_analysisController, todoEntries);
updateDerivedFilters(todoEntries);
}
}
defaultAnalysisController.dispose();
//await computeFolderMap();
state = SourceState.ready;
}
void onAspectRatioChanged() => eventBus.fire(AspectRatioChangedEvent());
// Monitoring
bool _canRefresh = true;
void pauseMonitoring() => _canRefresh = false;
void resumeMonitoring() => _canRefresh = true;
bool get canRefresh => _canRefresh;
// Filter summary
int count(CollectionFilter filter) {
switch (filter) {
case AlbumBaseFilter _:
return albumEntryCount(filter);
case LocationFilter(level: LocationLevel.country):
return countryEntryCount(filter);
case LocationFilter(level: LocationLevel.state):
return stateEntryCount(filter);
case LocationFilter(level: LocationLevel.place):
return placeEntryCount(filter);
case TagBaseFilter _:
return tagEntryCount(filter);
}
return 0;
}
int size(CollectionFilter filter) {
switch (filter) {
case AlbumBaseFilter _:
return albumSize(filter);
case LocationFilter(level: LocationLevel.country):
return countrySize(filter);
case LocationFilter(level: LocationLevel.state):
return stateSize(filter);
case LocationFilter(level: LocationLevel.place):
return placeSize(filter);
case TagBaseFilter _:
return tagSize(filter);
}
return 0;
}
AvesEntry? recentEntry(CollectionFilter filter) {
switch (filter) {
case AlbumBaseFilter _:
return albumRecentEntry(filter);
case LocationFilter(level: LocationLevel.country):
return countryRecentEntry(filter);
case LocationFilter(level: LocationLevel.state):
return stateRecentEntry(filter);
case LocationFilter(level: LocationLevel.place):
return placeRecentEntry(filter);
case TagBaseFilter _:
return tagRecentEntry(filter);
}
return null;
}
AvesEntry? coverEntry(CollectionFilter filter) {
final id = covers.of(filter)?.$1;
if (id != null) {
final entry = visibleEntries.firstWhereOrNull((e) => e.id == id);
if (entry != null) return entry;
}
if (filter is StoredAlbumFilter) {
final album = filter.album;
final e = sortedEntriesByDate.firstWhereOrNull(
(entry) => entry.directory == album,
);
if (e != null) return e;
}
return recentEntry(filter);
}
void _onFilterVisibilityChanged(Set<CollectionFilter> newlyVisibleFilters) {
updateDerivedFilters();
eventBus.fire(const FilterVisibilityChangedEvent());
unawaited(computeFolderMap());
if (newlyVisibleFilters.isNotEmpty) {
final candidateEntries = visibleEntries
.where((entry) => newlyVisibleFilters.any((f) => f.test(entry)))
.toSet();
analyze(null, entries: candidateEntries);
}
}
void _onVaultsChanged() {
final newlyVisibleFilters = vaults.vaultDirectories
.whereNot(vaults.isLocked)
.map((v) => StoredAlbumFilter(v, null))
.toSet();
_onFilterVisibilityChanged(newlyVisibleFilters);
}
void _onRemoteVisibilityChanged() {
final currentRemoteVisible = remoteVisible;
final previousRemoteVisible = _lastRemoteVisible;
debugPrint(
'[remote] visibility changed '
'previous=$previousRemoteVisible '
'current=$currentRemoteVisible '
'state=${RemoteSyncBus.instance.stateNotifier.value}',
);
// Se cambia solo lo stato icona, ad esempio:
// disabled -> syncing
// ma remoteVisible resta false,
// non fare nulla sulla collection.
if (previousRemoteVisible == currentRemoteVisible) {
return;
}
_lastRemoteVisible = currentRemoteVisible;
// Cambia solo la visibilità delle entries.
invalidateEntries();
// Solo cache album/count/cover.
invalidateAlbumFilterSummary(notify: false);
// IMPORTANTE:
// Non chiamare updateDerivedFilters().
// Non chiamare updateLocations().
// Non chiamare updateTags().
// Non chiamare computeFolderMap().
//
// La folderMap ora è canonica e contiene tutte le folder note.
// Remote ON/OFF deve solo filtrare cosa si vede.
unawaited(() async {
await updateDirectories();
eventBus.fire(AlbumsChangedEvent());
eventBus.fire(const FilterVisibilityChangedEvent());
debugPrint(
'[remote] visibility refresh done '
'remoteVisible=$remoteVisible '
'folderMap=${folderMap.length} '
'visibleEntries=${visibleEntries.length}',
);
}());
}
}
class AspectRatioChangedEvent {}