import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/shared/models/exif_info.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/services/asset.service.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/services/sync.service.dart'; import 'package:immich_mobile/shared/services/user.service.dart'; import 'package:immich_mobile/utils/db.dart'; import 'package:immich_mobile/utils/renderlist_generator.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:photo_manager/photo_manager.dart'; class AssetNotifier extends StateNotifier { final AssetService _assetService; final AlbumService _albumService; final UserService _userService; final SyncService _syncService; final Isar _db; final log = Logger('AssetNotifier'); bool _getAllAssetInProgress = false; bool _deleteInProgress = false; bool _getPartnerAssetsInProgress = false; AssetNotifier( this._assetService, this._albumService, this._userService, this._syncService, this._db, ) : super(false); Future getAllAsset({bool clear = false}) async { if (_getAllAssetInProgress || _deleteInProgress) { // guard against multiple calls to this method while it's still working return; } final stopwatch = Stopwatch()..start(); try { _getAllAssetInProgress = true; state = true; if (clear) { await clearAssetsAndAlbums(_db); log.info("Manual refresh requested, cleared assets and albums from db"); } final bool newRemote = await _assetService.refreshRemoteAssets(); final bool newLocal = await _albumService.refreshDeviceAlbums(); debugPrint("newRemote: $newRemote, newLocal: $newLocal"); log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms"); } finally { _getAllAssetInProgress = false; state = false; } } Future getPartnerAssets([User? partner]) async { if (_getPartnerAssetsInProgress) return; try { final stopwatch = Stopwatch()..start(); _getPartnerAssetsInProgress = true; if (partner == null) { await _userService.refreshUsers(); final List partners = await _db.users.filter().isPartnerSharedWithEqualTo(true).findAll(); for (User u in partners) { await _assetService.refreshRemoteAssets(u); } } else { await _assetService.refreshRemoteAssets(partner); } log.info("Load partner assets: ${stopwatch.elapsedMilliseconds}ms"); } finally { _getPartnerAssetsInProgress = false; } } Future clearAllAsset() { return clearAssetsAndAlbums(_db); } Future onNewAssetUploaded(Asset newAsset) async { // eTag on device is not valid after partially modifying the assets Store.delete(StoreKey.assetETag); await _syncService.syncNewAssetToDb(newAsset); } Future deleteLocalOnlyAssets( Iterable deleteAssets, { bool onlyBackedUp = false, }) async { _deleteInProgress = true; state = true; try { final assets = onlyBackedUp ? deleteAssets.where((e) => e.storage == AssetState.merged) : deleteAssets; final localDeleted = await _deleteLocalAssets(assets); if (localDeleted.isNotEmpty) { final localOnlyIds = deleteAssets .where((e) => e.storage == AssetState.local) .map((e) => e.id) .toList(); // Update merged assets to remote only final mergedAssets = deleteAssets.where((e) => e.storage == AssetState.merged).map((e) { e.localId = null; return e; }).toList(); await _db.writeTxn(() async { if (mergedAssets.isNotEmpty) { await _db.assets.putAll(mergedAssets); } await _db.exifInfos.deleteAll(localOnlyIds); await _db.assets.deleteAll(localOnlyIds); }); return true; } } finally { _deleteInProgress = false; state = false; } return false; } Future deleteRemoteOnlyAssets( Iterable deleteAssets, { bool force = false, }) async { _deleteInProgress = true; state = true; try { final remoteDeleted = await _deleteRemoteAssets(deleteAssets, force); if (remoteDeleted.isNotEmpty) { final assetsToUpdate = force /// If force, only update merged only assets and remove remote assets ? remoteDeleted .where((e) => e.storage == AssetState.merged) .map((e) { e.remoteId = null; return e; }) // If not force, trash everything : remoteDeleted.where((e) => e.isRemote).map((e) { e.isTrashed = true; return e; }); await _db.writeTxn(() async { if (assetsToUpdate.isNotEmpty) { await _db.assets.putAll(assetsToUpdate.toList()); } if (force) { final remoteOnly = remoteDeleted .where((e) => e.storage == AssetState.remote) .map((e) => e.id) .toList(); await _db.exifInfos.deleteAll(remoteOnly); await _db.assets.deleteAll(remoteOnly); } }); return true; } } finally { _deleteInProgress = false; state = false; } return false; } Future deleteAssets( Iterable deleteAssets, { bool force = false, }) async { _deleteInProgress = true; state = true; try { final hasLocal = deleteAssets.any((a) => a.storage != AssetState.remote); final localDeleted = await _deleteLocalAssets(deleteAssets); final remoteDeleted = (hasLocal && localDeleted.isNotEmpty) || !hasLocal ? await _deleteRemoteAssets(deleteAssets, force) : []; if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) { final dbIds = []; final dbUpdates = []; // Local assets are removed if (localDeleted.isNotEmpty) { // Permanently remove local only assets from isar dbIds.addAll( deleteAssets .where((a) => a.storage == AssetState.local) .map((e) => e.id), ); if (remoteDeleted.any((e) => e.isLocal)) { // Force delete: Add all local assets including merged assets if (force) { dbIds.addAll(remoteDeleted.map((e) => e.id)); // Soft delete: Remove local Id from asset and trash it } else { dbUpdates.addAll( remoteDeleted.map((e) { e.localId = null; e.isTrashed = true; return e; }), ); } } } // Handle remote deletion if (remoteDeleted.isNotEmpty) { if (force) { // Remove remote only assets dbIds.addAll( deleteAssets .where((a) => a.storage == AssetState.remote) .map((e) => e.id), ); // Local assets are not removed and there are merged assets final hasLocal = remoteDeleted.any((e) => e.isLocal); if (localDeleted.isEmpty && hasLocal) { // Remove remote Id from local assets dbUpdates.addAll( remoteDeleted.map((e) { e.remoteId = null; // Remove from trashed if remote asset is removed e.isTrashed = false; return e; }), ); } } else { dbUpdates.addAll( remoteDeleted.map((e) { e.isTrashed = true; return e; }), ); } } await _db.writeTxn(() async { await _db.assets.putAll(dbUpdates); await _db.exifInfos.deleteAll(dbIds); await _db.assets.deleteAll(dbIds); }); return true; } } finally { _deleteInProgress = false; state = false; } return false; } Future> _deleteLocalAssets( Iterable assetsToDelete, ) async { final List local = assetsToDelete.where((a) => a.isLocal).map((a) => a.localId!).toList(); // Delete asset from device if (local.isNotEmpty) { try { return await PhotoManager.editor.deleteWithIds(local); } catch (e, stack) { log.severe("Failed to delete asset from device", e, stack); } } return []; } Future> _deleteRemoteAssets( Iterable assetsToDelete, bool? force, ) async { final Iterable remote = assetsToDelete.where((e) => e.isRemote); final isSuccess = await _assetService.deleteAssets(remote, force: force); return isSuccess ? remote.toList() : []; } Future toggleFavorite(List assets, [bool? status]) async { status ??= !assets.every((a) => a.isFavorite); final newAssets = await _assetService.changeFavoriteStatus(assets, status); for (Asset? newAsset in newAssets) { if (newAsset == null) { log.severe("Change favorite status failed for asset"); continue; } } } Future toggleArchive(List assets, [bool? status]) async { status ??= !assets.every((a) => a.isArchived); final newAssets = await _assetService.changeArchiveStatus(assets, status); int i = 0; for (Asset oldAsset in assets) { final newAsset = newAssets[i++]; if (newAsset == null) { log.severe("Change archive status failed for asset ${oldAsset.id}"); continue; } } } } final assetProvider = StateNotifierProvider((ref) { return AssetNotifier( ref.watch(assetServiceProvider), ref.watch(albumServiceProvider), ref.watch(userServiceProvider), ref.watch(syncServiceProvider), ref.watch(dbProvider), ); }); final assetDetailProvider = StreamProvider.autoDispose.family((ref, asset) async* { yield await ref.watch(assetServiceProvider).loadExif(asset); final db = ref.watch(dbProvider); await for (final a in db.assets.watchObject(asset.id)) { if (a != null) yield await ref.watch(assetServiceProvider).loadExif(a); } }); final assetWatcher = StreamProvider.autoDispose.family((ref, asset) { final db = ref.watch(dbProvider); return db.assets.watchObject(asset.id, fireImmediately: true); }); final assetsProvider = StreamProvider.family((ref, userId) { if (userId == null) return const Stream.empty(); final query = _commonFilterAndSort( _assets(ref).where().ownerIdEqualToAnyChecksum(userId), ); return renderListGenerator(query, ref); }); final multiUserAssetsProvider = StreamProvider.family>((ref, userIds) { if (userIds.isEmpty) return const Stream.empty(); final query = _commonFilterAndSort( _assets(ref) .where() .anyOf(userIds, (q, u) => q.ownerIdEqualToAnyChecksum(u)), ); return renderListGenerator(query, ref); }); QueryBuilder? getRemoteAssetQuery(WidgetRef ref) { final userId = ref.watch(currentUserProvider)?.isarId; if (userId == null) { return null; } return ref .watch(dbProvider) .assets .where() .remoteIdIsNotNull() .filter() .ownerIdEqualTo(userId) .isTrashedEqualTo(false) .stackParentIdIsNull() .sortByFileCreatedAtDesc(); } IsarCollection _assets(StreamProviderRef ref) => ref.watch(dbProvider).assets; QueryBuilder _commonFilterAndSort( QueryBuilder query, ) { return query .filter() .isArchivedEqualTo(false) .isTrashedEqualTo(false) .stackParentIdIsNull() .sortByFileCreatedAtDesc(); }