mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:49:11 -04:00 
			
		
		
		
	* refactor(mobile): add AssetState and proper asset updating * generate files --------- Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
		
			
				
	
	
		
			300 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			300 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| 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/services/asset.service.dart';
 | |
| import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
 | |
| import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 | |
| import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 | |
| import 'package:immich_mobile/shared/models/asset.dart';
 | |
| import 'package:collection/collection.dart';
 | |
| import 'package:immich_mobile/shared/services/sync.service.dart';
 | |
| import 'package:immich_mobile/utils/async_mutex.dart';
 | |
| import 'package:immich_mobile/utils/db.dart';
 | |
| import 'package:intl/intl.dart';
 | |
| import 'package:isar/isar.dart';
 | |
| import 'package:logging/logging.dart';
 | |
| import 'package:openapi/api.dart';
 | |
| import 'package:photo_manager/photo_manager.dart';
 | |
| 
 | |
| /// State does not contain archived assets.
 | |
| /// Use database provider if you want to access the isArchived assets
 | |
| class AssetsState {
 | |
|   final List<Asset> allAssets;
 | |
|   final RenderList? renderList;
 | |
| 
 | |
|   AssetsState(this.allAssets, {this.renderList});
 | |
| 
 | |
|   Future<AssetsState> withRenderDataStructure(
 | |
|     AssetGridLayoutParameters layout,
 | |
|   ) async {
 | |
|     return AssetsState(
 | |
|       allAssets,
 | |
|       renderList: await RenderList.fromAssets(
 | |
|         allAssets,
 | |
|         layout,
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   AssetsState withAdditionalAssets(List<Asset> toAdd) {
 | |
|     return AssetsState([...allAssets, ...toAdd]);
 | |
|   }
 | |
| 
 | |
|   static AssetsState fromAssetList(List<Asset> assets) {
 | |
|     return AssetsState(assets);
 | |
|   }
 | |
| 
 | |
|   static AssetsState empty() {
 | |
|     return AssetsState([]);
 | |
|   }
 | |
| }
 | |
| 
 | |
| class AssetNotifier extends StateNotifier<AssetsState> {
 | |
|   final AssetService _assetService;
 | |
|   final AppSettingsService _settingsService;
 | |
|   final AlbumService _albumService;
 | |
|   final SyncService _syncService;
 | |
|   final Isar _db;
 | |
|   final log = Logger('AssetNotifier');
 | |
|   bool _getAllAssetInProgress = false;
 | |
|   bool _deleteInProgress = false;
 | |
|   final AsyncMutex _stateUpdateLock = AsyncMutex();
 | |
| 
 | |
|   AssetNotifier(
 | |
|     this._assetService,
 | |
|     this._settingsService,
 | |
|     this._albumService,
 | |
|     this._syncService,
 | |
|     this._db,
 | |
|   ) : super(AssetsState.fromAssetList([]));
 | |
| 
 | |
|   Future<void> _updateAssetsState(List<Asset> newAssetList) async {
 | |
|     final layout = AssetGridLayoutParameters(
 | |
|       _settingsService.getSetting(AppSettingsEnum.tilesPerRow),
 | |
|       _settingsService.getSetting(AppSettingsEnum.dynamicLayout),
 | |
|       GroupAssetsBy
 | |
|           .values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)],
 | |
|     );
 | |
| 
 | |
|     state = await AssetsState.fromAssetList(newAssetList)
 | |
|         .withRenderDataStructure(layout);
 | |
|   }
 | |
| 
 | |
|   // Just a little helper to trigger a rebuild of the state object
 | |
|   Future<void> rebuildAssetGridDataStructure() async {
 | |
|     await _updateAssetsState(state.allAssets);
 | |
|   }
 | |
| 
 | |
|   Future<void> 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;
 | |
|       final User me = Store.get(StoreKey.currentUser);
 | |
|       if (clear) {
 | |
|         await clearAssetsAndAlbums(_db);
 | |
|         log.info("Manual refresh requested, cleared assets and albums from db");
 | |
|       } else if (_stateUpdateLock.enqueued <= 1) {
 | |
|         final int cachedCount = await _userAssetQuery(me.isarId).count();
 | |
|         if (cachedCount > 0 && cachedCount != state.allAssets.length) {
 | |
|           await _stateUpdateLock.run(
 | |
|             () async => _updateAssetsState(await _getUserAssets(me.isarId)),
 | |
|           );
 | |
|           log.info(
 | |
|             "Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms",
 | |
|           );
 | |
|           stopwatch.reset();
 | |
|         }
 | |
|       }
 | |
|       final bool newRemote = await _assetService.refreshRemoteAssets();
 | |
|       final bool newLocal = await _albumService.refreshDeviceAlbums();
 | |
|       debugPrint("newRemote: $newRemote, newLocal: $newLocal");
 | |
|       log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
 | |
|       stopwatch.reset();
 | |
|       if (!newRemote &&
 | |
|           !newLocal &&
 | |
|           state.allAssets.length == await _userAssetQuery(me.isarId).count()) {
 | |
|         log.info("state is already up-to-date");
 | |
|         return;
 | |
|       }
 | |
|       stopwatch.reset();
 | |
|       if (_stateUpdateLock.enqueued <= 1) {
 | |
|         _stateUpdateLock.run(() async {
 | |
|           final assets = await _getUserAssets(me.isarId);
 | |
|           if (!const ListEquality().equals(assets, state.allAssets)) {
 | |
|             log.info("setting new asset state");
 | |
|             await _updateAssetsState(assets);
 | |
|           }
 | |
|         });
 | |
|       }
 | |
|     } finally {
 | |
|       _getAllAssetInProgress = false;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Future<List<Asset>> _getUserAssets(int userId) =>
 | |
|       _userAssetQuery(userId).sortByFileCreatedAtDesc().findAll();
 | |
| 
 | |
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> _userAssetQuery(
 | |
|     int userId,
 | |
|   ) =>
 | |
|       _db.assets.filter().ownerIdEqualTo(userId).isArchivedEqualTo(false);
 | |
| 
 | |
|   Future<void> clearAllAsset() {
 | |
|     state = AssetsState.empty();
 | |
|     return clearAssetsAndAlbums(_db);
 | |
|   }
 | |
| 
 | |
|   Future<void> onNewAssetUploaded(Asset newAsset) async {
 | |
|     final bool ok = await _syncService.syncNewAssetToDb(newAsset);
 | |
|     if (ok && _stateUpdateLock.enqueued <= 1) {
 | |
|       // run this sequentially if there is at most 1 other task waiting
 | |
|       await _stateUpdateLock.run(() async {
 | |
|         final userId = Store.get(StoreKey.currentUser).isarId;
 | |
|         final assets = await _getUserAssets(userId);
 | |
|         await _updateAssetsState(assets);
 | |
|       });
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Future<void> deleteAssets(Set<Asset> deleteAssets) async {
 | |
|     _deleteInProgress = true;
 | |
|     try {
 | |
|       _updateAssetsState(
 | |
|         state.allAssets.whereNot(deleteAssets.contains).toList(),
 | |
|       );
 | |
|       final localDeleted = await _deleteLocalAssets(deleteAssets);
 | |
|       final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
 | |
|       if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) {
 | |
|         final dbIds = deleteAssets.map((e) => e.id).toList();
 | |
|         await _db.writeTxn(() async {
 | |
|           await _db.exifInfos.deleteAll(dbIds);
 | |
|           await _db.assets.deleteAll(dbIds);
 | |
|         });
 | |
|       }
 | |
|     } finally {
 | |
|       _deleteInProgress = false;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Future<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async {
 | |
|     final int deviceId = Store.get(StoreKey.deviceIdHash);
 | |
|     final List<String> local = [];
 | |
|     // Delete asset from device
 | |
|     for (final Asset asset in assetsToDelete) {
 | |
|       if (asset.isLocal) {
 | |
|         local.add(asset.localId);
 | |
|       } else if (asset.deviceId == deviceId) {
 | |
|         // Delete asset on device if it is still present
 | |
|         var localAsset = await AssetEntity.fromId(asset.localId);
 | |
|         if (localAsset != null) {
 | |
|           local.add(localAsset.id);
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|     if (local.isNotEmpty) {
 | |
|       try {
 | |
|         await PhotoManager.editor.deleteWithIds(local);
 | |
|       } catch (e, stack) {
 | |
|         log.severe("Failed to delete asset from device", e, stack);
 | |
|       }
 | |
|     }
 | |
|     return [];
 | |
|   }
 | |
| 
 | |
|   Future<Iterable<String>> _deleteRemoteAssets(
 | |
|     Set<Asset> assetsToDelete,
 | |
|   ) async {
 | |
|     final Iterable<Asset> remote = assetsToDelete.where((e) => e.isRemote);
 | |
|     final List<DeleteAssetResponseDto> deleteAssetResult =
 | |
|         await _assetService.deleteAssets(remote) ?? [];
 | |
|     return deleteAssetResult
 | |
|         .where((a) => a.status == DeleteAssetStatus.SUCCESS)
 | |
|         .map((a) => a.id);
 | |
|   }
 | |
| 
 | |
|   Future<bool> toggleFavorite(Asset asset, bool status) async {
 | |
|     final newAsset = await _assetService.changeFavoriteStatus(asset, status);
 | |
| 
 | |
|     if (newAsset == null) {
 | |
|       log.severe("Change favorite status failed for asset ${asset.id}");
 | |
|       return asset.isFavorite;
 | |
|     }
 | |
| 
 | |
|     final index = state.allAssets.indexWhere((a) => asset.id == a.id);
 | |
|     if (index != -1) {
 | |
|       state.allAssets[index] = newAsset;
 | |
|       _updateAssetsState(state.allAssets);
 | |
|     }
 | |
| 
 | |
|     return newAsset.isFavorite;
 | |
|   }
 | |
| 
 | |
|   Future<void> toggleArchive(Iterable<Asset> assets, bool status) async {
 | |
|     final newAssets = await Future.wait(
 | |
|       assets.map((a) => _assetService.changeArchiveStatus(a, status)),
 | |
|     );
 | |
|     int i = 0;
 | |
|     bool unArchived = false;
 | |
|     for (Asset oldAsset in assets) {
 | |
|       final newAsset = newAssets[i++];
 | |
|       if (newAsset == null) {
 | |
|         log.severe("Change archive status failed for asset ${oldAsset.id}");
 | |
|         continue;
 | |
|       }
 | |
|       final index = state.allAssets.indexWhere((a) => oldAsset.id == a.id);
 | |
|       if (newAsset.isArchived) {
 | |
|         // remove from state
 | |
|         if (index != -1) {
 | |
|           state.allAssets.removeAt(index);
 | |
|         }
 | |
|       } else {
 | |
|         // add to state is difficult because the list is sorted
 | |
|         unArchived = true;
 | |
|       }
 | |
|     }
 | |
|     if (unArchived) {
 | |
|       final User me = Store.get(StoreKey.currentUser);
 | |
|       await _stateUpdateLock.run(
 | |
|         () async => _updateAssetsState(await _getUserAssets(me.isarId)),
 | |
|       );
 | |
|     } else {
 | |
|       _updateAssetsState(state.allAssets);
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {
 | |
|   return AssetNotifier(
 | |
|     ref.watch(assetServiceProvider),
 | |
|     ref.watch(appSettingsServiceProvider),
 | |
|     ref.watch(albumServiceProvider),
 | |
|     ref.watch(syncServiceProvider),
 | |
|     ref.watch(dbProvider),
 | |
|   );
 | |
| });
 | |
| 
 | |
| final assetGroupByMonthYearProvider = StateProvider((ref) {
 | |
|   // TODO: remove `where` once temporary workaround is no longer needed (to only
 | |
|   // allow remote assets to be added to album). Keep `toList()` as to NOT sort
 | |
|   // the original list/state
 | |
|   final assets =
 | |
|       ref.watch(assetProvider).allAssets.where((e) => e.isRemote).toList();
 | |
| 
 | |
|   assets.sortByCompare<DateTime>(
 | |
|     (e) => e.fileCreatedAt,
 | |
|     (a, b) => b.compareTo(a),
 | |
|   );
 | |
| 
 | |
|   return assets.groupListsBy(
 | |
|     (element) => DateFormat('MMMM, y').format(element.fileCreatedAt.toLocal()),
 | |
|   );
 | |
| });
 |