mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	* Try staggered layout for home page * Introduce setting for dynamic layout * Fix some provider related bugs * Make asset grouping configurable * Add translation keys, refactor group title * Rename enum values * Fix enum names * Reformat long if statement * Fix timezone related bug * Minor clean up * Fix unit test * Add second assets check back to home screen
		
			
				
	
	
		
			313 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			313 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:collection';
 | |
| 
 | |
| import 'package:flutter/foundation.dart';
 | |
| import 'package:hive/hive.dart';
 | |
| import 'package:hooks_riverpod/hooks_riverpod.dart';
 | |
| import 'package:immich_mobile/constants/hive_box.dart';
 | |
| import 'package:immich_mobile/shared/models/store.dart';
 | |
| import 'package:immich_mobile/shared/services/asset.service.dart';
 | |
| import 'package:immich_mobile/shared/services/asset_cache.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:immich_mobile/shared/services/device_info.service.dart';
 | |
| import 'package:collection/collection.dart';
 | |
| import 'package:immich_mobile/utils/tuple.dart';
 | |
| import 'package:intl/intl.dart';
 | |
| import 'package:logging/logging.dart';
 | |
| import 'package:openapi/api.dart';
 | |
| import 'package:photo_manager/photo_manager.dart';
 | |
| 
 | |
| 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 _CombineAssetsComputeParameters {
 | |
|   final Iterable<Asset> local;
 | |
|   final Iterable<Asset> remote;
 | |
|   final String deviceId;
 | |
| 
 | |
|   _CombineAssetsComputeParameters(this.local, this.remote, this.deviceId);
 | |
| }
 | |
| 
 | |
| class AssetNotifier extends StateNotifier<AssetsState> {
 | |
|   final AssetService _assetService;
 | |
|   final AssetCacheService _assetCacheService;
 | |
|   final AppSettingsService _settingsService;
 | |
|   final log = Logger('AssetNotifier');
 | |
|   final DeviceInfoService _deviceInfoService = DeviceInfoService();
 | |
|   bool _getAllAssetInProgress = false;
 | |
|   bool _deleteInProgress = false;
 | |
| 
 | |
|   AssetNotifier(
 | |
|     this._assetService,
 | |
|     this._assetCacheService,
 | |
|     this._settingsService,
 | |
|   ) : super(AssetsState.fromAssetList([]));
 | |
| 
 | |
|   Future<void> _updateAssetsState(
 | |
|     List<Asset> newAssetList, {
 | |
|     bool cache = true,
 | |
|   }) async {
 | |
|     if (cache) {
 | |
|       _assetCacheService.put(newAssetList);
 | |
|     }
 | |
| 
 | |
|     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, cache: false);
 | |
|   }
 | |
| 
 | |
|   getAllAsset() async {
 | |
|     if (_getAllAssetInProgress || _deleteInProgress) {
 | |
|       // guard against multiple calls to this method while it's still working
 | |
|       return;
 | |
|     }
 | |
|     final stopwatch = Stopwatch();
 | |
|     try {
 | |
|       _getAllAssetInProgress = true;
 | |
|       bool isCacheValid = await _assetCacheService.isValid();
 | |
|       stopwatch.start();
 | |
|       if (isCacheValid && state.allAssets.isEmpty) {
 | |
|         final List<Asset>? cachedData = await _assetCacheService.get();
 | |
|         if (cachedData == null) {
 | |
|           isCacheValid = false;
 | |
|           log.warning("Cached asset data is invalid, fetching new data");
 | |
|         } else {
 | |
|           await _updateAssetsState(cachedData, cache: false);
 | |
|           log.info(
 | |
|             "Reading assets ${state.allAssets.length} from cache: ${stopwatch.elapsedMilliseconds}ms",
 | |
|           );
 | |
|         }
 | |
|         stopwatch.reset();
 | |
|       }
 | |
|       final localTask = _assetService.getLocalAssets(urgent: !isCacheValid);
 | |
|       final remoteTask = _assetService.getRemoteAssets(
 | |
|         etag: isCacheValid ? Store.get(StoreKey.assetETag) : null,
 | |
|       );
 | |
| 
 | |
|       int remoteBegin = state.allAssets.indexWhere((a) => a.isRemote);
 | |
|       remoteBegin = remoteBegin == -1 ? state.allAssets.length : remoteBegin;
 | |
| 
 | |
|       final List<Asset> currentLocal = state.allAssets.slice(0, remoteBegin);
 | |
| 
 | |
|       final Pair<List<Asset>?, String?> remoteResult = await remoteTask;
 | |
|       List<Asset>? newRemote = remoteResult.first;
 | |
|       List<Asset>? newLocal = await localTask;
 | |
|       log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
 | |
|       stopwatch.reset();
 | |
|       if (newRemote == null &&
 | |
|           (newLocal == null || currentLocal.equals(newLocal))) {
 | |
|         log.info("state is already up-to-date");
 | |
|         return;
 | |
|       }
 | |
|       newRemote ??= state.allAssets.slice(remoteBegin);
 | |
|       newLocal ??= [];
 | |
| 
 | |
|       final combinedAssets = await _combineLocalAndRemoteAssets(
 | |
|         local: newLocal,
 | |
|         remote: newRemote,
 | |
|       );
 | |
|       await _updateAssetsState(combinedAssets);
 | |
| 
 | |
|       log.info("Combining assets: ${stopwatch.elapsedMilliseconds}ms");
 | |
| 
 | |
|       Store.put(StoreKey.assetETag, remoteResult.second);
 | |
|     } finally {
 | |
|       _getAllAssetInProgress = false;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   static Future<List<Asset>> _computeCombine(
 | |
|     _CombineAssetsComputeParameters data,
 | |
|   ) async {
 | |
|     var local = data.local;
 | |
|     var remote = data.remote;
 | |
|     final deviceId = data.deviceId;
 | |
| 
 | |
|     final List<Asset> assets = [];
 | |
|     if (remote.isNotEmpty && local.isNotEmpty) {
 | |
|       final Set<String> existingIds = remote
 | |
|           .where((e) => e.deviceId == deviceId)
 | |
|           .map((e) => e.deviceAssetId)
 | |
|           .toSet();
 | |
|       local = local.where((e) => !existingIds.contains(e.id));
 | |
|     }
 | |
|     assets.addAll(local);
 | |
|     // the order (first all local, then remote assets) is important!
 | |
|     assets.addAll(remote);
 | |
|     return assets;
 | |
|   }
 | |
| 
 | |
|   Future<List<Asset>> _combineLocalAndRemoteAssets({
 | |
|     required Iterable<Asset> local,
 | |
|     required List<Asset> remote,
 | |
|   }) async {
 | |
|     final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
 | |
|     return await compute(
 | |
|       _computeCombine,
 | |
|       _CombineAssetsComputeParameters(local, remote, deviceId),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   clearAllAsset() {
 | |
|     _updateAssetsState([]);
 | |
|   }
 | |
| 
 | |
|   void onNewAssetUploaded(Asset newAsset) {
 | |
|     final int i = state.allAssets.indexWhere(
 | |
|       (a) =>
 | |
|           a.isRemote ||
 | |
|           (a.id == newAsset.deviceAssetId && a.deviceId == newAsset.deviceId),
 | |
|     );
 | |
| 
 | |
|     if (i == -1 || state.allAssets[i].deviceAssetId != newAsset.deviceAssetId) {
 | |
|       _updateAssetsState([...state.allAssets, newAsset]);
 | |
|     } else {
 | |
|       // order is important to keep all local-only assets at the beginning!
 | |
|       _updateAssetsState([
 | |
|         ...state.allAssets.slice(0, i),
 | |
|         ...state.allAssets.slice(i + 1),
 | |
|         newAsset,
 | |
|       ]);
 | |
|       // TODO here is a place to unify local/remote assets by replacing the
 | |
|       // local-only asset in the state with a local&remote asset
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   deleteAssets(Set<Asset> deleteAssets) async {
 | |
|     _deleteInProgress = true;
 | |
|     try {
 | |
|       final localDeleted = await _deleteLocalAssets(deleteAssets);
 | |
|       final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
 | |
|       final Set<String> deleted = HashSet();
 | |
|       deleted.addAll(localDeleted);
 | |
|       deleted.addAll(remoteDeleted);
 | |
|       if (deleted.isNotEmpty) {
 | |
|         _updateAssetsState(
 | |
|           state.allAssets.where((a) => !deleted.contains(a.id)).toList(),
 | |
|         );
 | |
|       }
 | |
|     } finally {
 | |
|       _deleteInProgress = false;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Future<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async {
 | |
|     var deviceInfo = await _deviceInfoService.getDeviceInfo();
 | |
|     var deviceId = deviceInfo["deviceId"];
 | |
|     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.deviceAssetId);
 | |
|         if (localAsset != null) {
 | |
|           local.add(localAsset.id);
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|     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<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 > 0) {
 | |
|       state.allAssets[index] = newAsset;
 | |
|       _updateAssetsState(state.allAssets);
 | |
|     }
 | |
| 
 | |
|     return newAsset.isFavorite;
 | |
|   }
 | |
| }
 | |
| 
 | |
| final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {
 | |
|   return AssetNotifier(
 | |
|     ref.watch(assetServiceProvider),
 | |
|     ref.watch(assetCacheServiceProvider),
 | |
|     ref.watch(appSettingsServiceProvider),
 | |
|   );
 | |
| });
 | |
| 
 | |
| 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.createdAt,
 | |
|     (a, b) => b.compareTo(a),
 | |
|   );
 | |
| 
 | |
|   return assets.groupListsBy(
 | |
|     (element) => DateFormat('MMMM, y').format(element.createdAt.toLocal()),
 | |
|   );
 | |
| });
 |