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/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 allAssets; final RenderList? renderList; AssetsState(this.allAssets, {this.renderList}); Future withRenderDataStructure(int groupSize) async { return AssetsState( allAssets, renderList: await RenderList.fromAssetGroups(await _groupByDate(), groupSize), ); } AssetsState withAdditionalAssets(List toAdd) { return AssetsState([...allAssets, ...toAdd]); } Future>> _groupByDate() async { sortCompare(List assets) { assets.sortByCompare( (e) => e.createdAt, (a, b) => b.compareTo(a), ); return assets.groupListsBy( (element) => DateFormat('y-MM-dd').format(element.createdAt.toLocal()), ); } return await compute(sortCompare, allAssets.toList()); } static AssetsState fromAssetList(List assets) { return AssetsState(assets); } static AssetsState empty() { return AssetsState([]); } } class _CombineAssetsComputeParameters { final Iterable local; final Iterable remote; final String deviceId; _CombineAssetsComputeParameters(this.local, this.remote, this.deviceId); } class AssetNotifier extends StateNotifier { 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 _updateAssetsState( List newAssetList, { bool cache = true, }) async { if (cache) { _assetCacheService.put(newAssetList); } state = await AssetsState.fromAssetList(newAssetList).withRenderDataStructure( _settingsService.getSetting(AppSettingsEnum.tilesPerRow), ); } 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(); final Box box = Hive.box(userInfoBox); if (isCacheValid && state.allAssets.isEmpty) { final List? 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 ? box.get(assetEtagKey) : null, ); int remoteBegin = state.allAssets.indexWhere((a) => a.isRemote); remoteBegin = remoteBegin == -1 ? state.allAssets.length : remoteBegin; final List currentLocal = state.allAssets.slice(0, remoteBegin); final Pair?, String?> remoteResult = await remoteTask; List? newRemote = remoteResult.first; List? 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"); box.put(assetEtagKey, remoteResult.second); } finally { _getAllAssetInProgress = false; } } static Future> _computeCombine( _CombineAssetsComputeParameters data, ) async { var local = data.local; var remote = data.remote; final deviceId = data.deviceId; final List assets = []; if (remote.isNotEmpty && local.isNotEmpty) { final Set 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> _combineLocalAndRemoteAssets({ required Iterable local, required List 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 deleteAssets) async { _deleteInProgress = true; try { final localDeleted = await _deleteLocalAssets(deleteAssets); final remoteDeleted = await _deleteRemoteAssets(deleteAssets); final Set 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> _deleteLocalAssets(Set assetsToDelete) async { var deviceInfo = await _deviceInfoService.getDeviceInfo(); var deviceId = deviceInfo["deviceId"]; final List 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> _deleteRemoteAssets( Set assetsToDelete, ) async { final Iterable remote = assetsToDelete.where((e) => e.isRemote); final List deleteAssetResult = await _assetService.deleteAssets(remote) ?? []; return deleteAssetResult .where((a) => a.status == DeleteAssetStatus.SUCCESS) .map((a) => a.id); } Future 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.removeAt(index); state.allAssets.insert(index, Asset.remote(newAsset)); _updateAssetsState(state.allAssets); } return newAsset.isFavorite; } } final assetProvider = StateNotifierProvider((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( (e) => e.createdAt, (a, b) => b.compareTo(a), ); return assets.groupListsBy( (element) => DateFormat('MMMM, y').format(element.createdAt.toLocal()), ); });