921 lines
26 KiB
Dart
921 lines
26 KiB
Dart
// 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 = true;
|
||
|
||
bool get remoteVisible {
|
||
if (debugForceRemoteVisible) return true;
|
||
final state = RemoteSyncBus.instance.stateNotifier.value;
|
||
return state == RemoteSyncState.syncing || 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);
|
||
}
|
||
|
||
@mustCallSuper
|
||
void dispose() {
|
||
if (kFlutterMemoryAllocationsEnabled) {
|
||
LeakTracking.dispatchObjectDisposed(object: this);
|
||
}
|
||
|
||
vaults.removeListener(_onVaultsChanged);
|
||
_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 = {};
|
||
|
||
@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() async {
|
||
// Evita di sovrascrivere una folderMap valida con una vuota durante startup.
|
||
if (_rawEntries.isEmpty) {
|
||
debugPrint('[folders] computeFolderMap skipped rawEntries=0');
|
||
return;
|
||
}
|
||
|
||
// Reset cache derivate.
|
||
_visibleEntries = null;
|
||
_trashedEntries = null;
|
||
_sortedEntriesByDate = null;
|
||
|
||
final entries = visibleEntries;
|
||
|
||
debugPrint(
|
||
'[folders] computeFolderMap start '
|
||
'rawEntries=${_rawEntries.length} '
|
||
'visibleEntries=${entries.length}',
|
||
);
|
||
|
||
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 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();
|
||
}
|
||
|
||
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();
|
||
|
||
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() è l’unica 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);
|
||
}
|
||
}
|
||
|
||
class AspectRatioChangedEvent {}
|