// 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?; // Trace opzionale: mostra chi nasconderebbe i remoti, senza nasconderli. const bool kTraceHiddenRemotes = true; mixin SourceBase { EventBus get eventBus; Map get entryById; Map get folderMap; Set get allEntries; Set get visibleEntries; Set get trashedEntries; List get sortedEntriesByDate; ValueNotifier 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 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 = {}; 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?) { 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 _entryById = {}; @override Map get entryById => Map.unmodifiable(_entryById); final Set _rawEntries = {}; bool? _lastRemoteVisible; @override Set get allEntries => Set.unmodifiable(_rawEntries); Set? _visibleEntries, _trashedEntries; @override Set get visibleEntries { _visibleEntries ??= Set.unmodifiable(_applyHiddenFilters(_rawEntries)); return _visibleEntries!; } @override Set get trashedEntries { _trashedEntries ??= Set.unmodifiable(_applyTrashFilter(_rawEntries)); return _trashedEntries!; } List? _sortedEntriesByDate; @override List get sortedEntriesByDate { _sortedEntriesByDate ??= List.unmodifiable(visibleEntries.toList()..sort(AvesEntrySort.compareByDate)); return _sortedEntriesByDate!; } // known date by entry ID late Map _savedDates; // === FOLDER MAP: fonte dati unica per AlbumPage e InfoPage === late Map _folderMap = {}; @override Map get folderMap => _folderMap; Future 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 = {}; 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 finalizeFolders(Map 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 foldersForEntry(AvesEntry entry) { final dir = entry.directory; if (dir == null) return const []; return _folderMap.containsKey(dir) ? [dir] : const []; } Future loadDates() async { _savedDates = Map.unmodifiable(await localMediaDb.loadDates()); } Set _getAppHiddenFilters() => { ...settings.hiddenFilters, ...vaults.vaultDirectories .where(vaults.isLocked) .map((v) => StoredAlbumFilter(v, null)), }; Iterable _applyHiddenFilters(Iterable 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 _applyTrashFilter(Iterable entries) { final hiddenFilters = _getAppHiddenFilters(); return entries .where(TrashFilter.instance.test) .where((entry) => !hiddenFilters.any((filter) => filter.test(entry))); } void _invalidate({Set? 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 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? 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 replaceEntriesInMemory( Set entries, { required String reason, bool notify = false, bool updateFolders = true, }) async { _rawEntries ..clear() ..addAll(entries); _entryById ..clear() ..addEntries(entries.map((entry) => MapEntry(entry.id, entry))); invalidateEntries(); // Non tocchiamo country/place/tag qui. // I metadata/address verranno caricati dopo e updateDerivedFilters() // farà il suo lavoro. invalidateAlbumFilterSummary(notify: false); if (updateFolders) { await computeFolderMap(reason: reason); } if (notify) { eventBus.fire(EntryAddedEvent(entries)); } debugPrint( '[entries] replaceEntriesInMemory ' 'reason=$reason ' 'entries=${entries.length} ' 'notify=$notify ' 'updateFolders=$updateFolders ' 'rawEntries=${_rawEntries.length}', ); } Future mergeEntriesInMemory( Set entries, { required String reason, bool notify = true, bool updateFolders = true, }) async { if (entries.isEmpty) return; for (final entry in entries) { final old = _entryById[entry.id]; if (old != null) { _rawEntries.remove(old); } _entryById[entry.id] = entry; _rawEntries.add(entry); } invalidateEntries(); invalidateAlbumFilterSummary(notify: false); if (updateFolders) { await computeFolderMap(reason: reason); } if (notify) { eventBus.fire(EntryAddedEvent(entries)); } debugPrint( '[entries] mergeEntriesInMemory ' 'reason=$reason ' 'entries=${entries.length} ' 'notify=$notify ' 'updateFolders=$updateFolders ' 'rawEntries=${_rawEntries.length}', ); } Future addEntries(Set 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 entries, {bool notify = true}) { if (entries.isEmpty) return; for (final e in entries) { _entryById.remove(e.id); } _rawEntries.removeAll(entries); updateDerivedFilters(entries); //unawaited(computeFolderMap()); unawaited(computeFolderMap(reason: 'removeEntriesFromMemory')); if (notify) { eventBus.fire(EntryRemovedEvent(entries)); } } Future removeEntries(Set 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(); await computeFolderMap(reason: 'removeEntries'); 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 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(); await computeFolderMap(reason: 'appendRemoteEntries'); if (notify) { eventBus.fire(EntryAddedEvent(visibleRemotes)); } } Future _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 updateAfterMove({ required Set todoEntries, required MoveType moveType, required Set destinationAlbums, required Set 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 = {}; final movedEntries = {}; 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(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(); await computeFolderMap(reason: 'updateAfterMove'); eventBus.fire(EntryMovedEvent(moveType, movedEntries)); } Future updateAfterRename({ required Set todoEntries, required Set movedOps, required bool persist, }) async { if (movedOps.isEmpty) return; final movedEntries = {}; await Future.forEach(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(); await computeFolderMap(reason: 'updateAfterRename'); eventBus.fire(EntryMovedEvent(MoveType.move, movedEntries)); } SourceScope get loadedScope; SourceScope get targetScope; @override Future init({ required SourceScope scope, AnalysisController? analysisController, bool loadTopEntriesFirst = false, }) async { // MediaStoreSource.init() è l’unica vera implementazione. } Future> refreshUris( Set changedUris, { AnalysisController? analysisController, }); Future refreshEntries( Set entries, Set 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(); await computeFolderMap(reason: 'refreshEntries'); eventBus.fire(EntryRefreshedEvent(entries)); } Future analyze( AnalysisController? analysisController, { Set? 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 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); // Quando i remoti diventano visibili, ora possiamo aggiornare // anche i filtri derivati location/tag, perché: // - la source locale è già pronta; // - metadata/address sono già stati ricaricati; // - remoteVisible è true, quindi updateLocations include anche i remoti. // // Quando invece i remoti vengono nascosti, NON aggiorniamo qui // i filtri derivati, così evitiamo di svuotare Paesi/Luoghi. if (currentRemoteVisible) { final remoteEntries = _rawEntries .where((entry) => entry.origin == RemoteOrigin.value && !entry.trashed) .toSet(); if (remoteEntries.isNotEmpty) { debugPrint( '[remote][locations] update derived filters for remotes ' 'count=${remoteEntries.length}', ); updateDerivedFilters(remoteEntries); } else { debugPrint('[remote][locations] no remote entries for derived filters'); } } 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 {}