import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart'; import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; import 'package:immich_mobile/modules/home/models/selection_state.dart'; import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; import 'package:immich_mobile/utils/selection_handlers.dart'; class MultiselectGrid extends HookConsumerWidget { const MultiselectGrid({ super.key, required this.renderListProvider, this.onRefresh, this.buildLoadingIndicator, this.onRemoveFromAlbum, this.topWidget, this.stackEnabled = false, this.archiveEnabled = false, this.deleteEnabled = true, this.favoriteEnabled = true, this.editEnabled = false, this.unarchive = false, this.unfavorite = false, }); final ProviderListenable> renderListProvider; final Future Function()? onRefresh; final Widget Function()? buildLoadingIndicator; final Future Function(Iterable)? onRemoveFromAlbum; final Widget? topWidget; final bool stackEnabled; final bool archiveEnabled; final bool unarchive; final bool deleteEnabled; final bool favoriteEnabled; final bool unfavorite; final bool editEnabled; Widget buildDefaultLoadingIndicator() => const Center(child: ImmichLoadingIndicator()); Widget buildEmptyIndicator() => const Center(child: Text("No assets to show")); @override Widget build(BuildContext context, WidgetRef ref) { final multiselectEnabled = ref.watch(multiselectProvider.notifier); final selectionEnabledHook = useState(false); final selectionAssetState = useState(const SelectionAssetState()); final selection = useState({}); final currentUser = ref.watch(currentUserProvider); final processing = useProcessingOverlay(); useEffect( () { selectionEnabledHook.addListener(() { multiselectEnabled.state = selectionEnabledHook.value; }); return () { // This does not work in tests if (kReleaseMode) { selectionEnabledHook.dispose(); } }; }, [], ); void selectionListener( bool multiselect, Set selectedAssets, ) { selectionEnabledHook.value = multiselect; selection.value = selectedAssets; selectionAssetState.value = SelectionAssetState.fromSelection(selectedAssets); } errorBuilder(String? msg) => msg != null && msg.isNotEmpty ? () => ImmichToast.show( context: context, msg: msg, gravity: ToastGravity.BOTTOM, ) : null; Iterable ownedRemoteSelection({ String? localErrorMessage, String? ownerErrorMessage, }) { final assets = selection.value; return assets .remoteOnly(errorCallback: errorBuilder(localErrorMessage)) .ownedOnly( currentUser, errorCallback: errorBuilder(ownerErrorMessage), ); } Iterable remoteSelection({String? errorMessage}) => selection.value.remoteOnly( errorCallback: errorBuilder(errorMessage), ); void onShareAssets(bool shareLocal) { processing.value = true; if (shareLocal) { // Share = Download + Send to OS specific share sheet // Filter offline assets since we cannot fetch their original file final liveAssets = selection.value.nonOfflineOnly( errorCallback: errorBuilder('asset_action_share_err_offline'.tr()), ); handleShareAssets(ref, context, liveAssets); } else { final ids = remoteSelection(errorMessage: "home_page_share_err_local".tr()) .map((e) => e.remoteId!); context.pushRoute(SharedLinkEditRoute(assetsList: ids.toList())); } processing.value = false; selectionEnabledHook.value = false; } void onFavoriteAssets() async { processing.value = true; try { final remoteAssets = ownedRemoteSelection( localErrorMessage: 'home_page_favorite_err_local'.tr(), ownerErrorMessage: 'home_page_favorite_err_partner'.tr(), ); if (remoteAssets.isNotEmpty) { await handleFavoriteAssets(ref, context, remoteAssets.toList()); } } finally { processing.value = false; selectionEnabledHook.value = false; } } void onArchiveAsset() async { processing.value = true; try { final remoteAssets = ownedRemoteSelection( localErrorMessage: 'home_page_archive_err_local'.tr(), ownerErrorMessage: 'home_page_archive_err_partner'.tr(), ); await handleArchiveAssets(ref, context, remoteAssets.toList()); } finally { processing.value = false; selectionEnabledHook.value = false; } } void onDelete([bool force = false]) async { processing.value = true; try { final toDelete = selection.value .ownedOnly( currentUser, errorCallback: errorBuilder('home_page_delete_err_partner'.tr()), ) // Cannot delete readOnly / external assets. They are handled through library offline jobs .writableOnly( errorCallback: errorBuilder('asset_action_delete_err_read_only'.tr()), ) .toList(); final isDeleted = await ref .read(assetProvider.notifier) .deleteAssets(toDelete, force: force); if (isDeleted) { final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset'; final trashOrRemoved = force ? 'deleted permanently' : 'trashed'; ImmichToast.show( context: context, msg: '${selection.value.length} $assetOrAssets $trashOrRemoved', gravity: ToastGravity.BOTTOM, ); selectionEnabledHook.value = false; } } finally { processing.value = false; } } void onDeleteLocal(bool onlyBackedUp) async { processing.value = true; try { final localIds = selection.value.where((a) => a.isLocal).toList(); final isDeleted = await ref .read(assetProvider.notifier) .deleteLocalOnlyAssets(localIds, onlyBackedUp: onlyBackedUp); if (isDeleted) { final assetOrAssets = localIds.length > 1 ? 'assets' : 'asset'; ImmichToast.show( context: context, msg: '${localIds.length} $assetOrAssets removed permanently from your device', gravity: ToastGravity.BOTTOM, ); selectionEnabledHook.value = false; } } finally { processing.value = false; } } void onDeleteRemote([bool force = false]) async { processing.value = true; try { final toDelete = ownedRemoteSelection( localErrorMessage: 'home_page_delete_remote_err_local'.tr(), ownerErrorMessage: 'home_page_delete_err_partner'.tr(), ) // Cannot delete readOnly / external assets. They are handled through library offline jobs .writableOnly( errorCallback: errorBuilder('asset_action_delete_err_read_only'.tr()), ) .toList(); final isDeleted = await ref .read(assetProvider.notifier) .deleteRemoteOnlyAssets(toDelete, force: force); if (isDeleted) { final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset'; final trashOrRemoved = force ? 'deleted permanently' : 'trashed'; ImmichToast.show( context: context, msg: '${toDelete.length} $assetOrAssets $trashOrRemoved from the Immich server', gravity: ToastGravity.BOTTOM, ); } } finally { selectionEnabledHook.value = false; processing.value = false; } } void onUpload() { processing.value = true; selectionEnabledHook.value = false; try { ref.read(manualUploadProvider.notifier).uploadAssets( context, selection.value.where((a) => a.storage == AssetState.local), ); } finally { processing.value = false; } } void onAddToAlbum(Album album) async { processing.value = true; try { final Iterable assets = remoteSelection( errorMessage: "home_page_add_to_album_err_local".tr(), ); if (assets.isEmpty) { return; } final result = await ref.read(albumServiceProvider).addAdditionalAssetToAlbum( assets, album, ); if (result != null) { if (result.alreadyInAlbum.isNotEmpty) { ImmichToast.show( context: context, msg: "home_page_add_to_album_conflicts".tr( namedArgs: { "album": album.name, "added": result.successfullyAdded.toString(), "failed": result.alreadyInAlbum.length.toString(), }, ), ); } else { ImmichToast.show( context: context, msg: "home_page_add_to_album_success".tr( namedArgs: { "album": album.name, "added": result.successfullyAdded.toString(), }, ), toastType: ToastType.success, ); } } } finally { processing.value = false; selectionEnabledHook.value = false; } } void onCreateNewAlbum() async { processing.value = true; try { final Iterable assets = remoteSelection( errorMessage: "home_page_add_to_album_err_local".tr(), ); if (assets.isEmpty) { return; } final result = await ref .read(albumServiceProvider) .createAlbumWithGeneratedName(assets); if (result != null) { ref.watch(albumProvider.notifier).getAllAlbums(); ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums(); selectionEnabledHook.value = false; context.pushRoute(AlbumViewerRoute(albumId: result.id)); } } finally { processing.value = false; } } void onStack() async { try { processing.value = true; if (!selectionEnabledHook.value || selection.value.length < 2) { return; } final parent = selection.value.elementAt(0); selection.value.remove(parent); await ref.read(assetStackServiceProvider).updateStack( parent, childrenToAdd: selection.value.toList(), ); } finally { processing.value = false; selectionEnabledHook.value = false; } } void onEditTime() async { try { final remoteAssets = ownedRemoteSelection( localErrorMessage: 'home_page_favorite_err_local'.tr(), ownerErrorMessage: 'home_page_favorite_err_partner'.tr(), ).writableOnly( // Assume readOnly assets to be present in a read-only mount. So do not write sidecar errorCallback: errorBuilder( 'multiselect_grid_edit_date_time_err_read_only'.tr(), ), ); if (remoteAssets.isNotEmpty) { handleEditDateTime(ref, context, remoteAssets.toList()); } } finally { selectionEnabledHook.value = false; } } void onEditLocation() async { try { final remoteAssets = ownedRemoteSelection( localErrorMessage: 'home_page_favorite_err_local'.tr(), ownerErrorMessage: 'home_page_favorite_err_partner'.tr(), ).writableOnly( // Assume readOnly assets to be present in a read-only mount. So do not write sidecar errorCallback: errorBuilder( 'multiselect_grid_edit_gps_err_read_only'.tr(), ), ); if (remoteAssets.isNotEmpty) { handleEditLocation(ref, context, remoteAssets.toList()); } } finally { selectionEnabledHook.value = false; } } Future Function() wrapLongRunningFun( Future Function() fun, { bool showOverlay = true, }) => () async { if (showOverlay) processing.value = true; try { final result = await fun(); if (result.runtimeType != bool || result == true) { selectionEnabledHook.value = false; } return result; } finally { if (showOverlay) processing.value = false; } }; return SafeArea( top: true, bottom: false, child: Stack( children: [ ref.watch(renderListProvider).when( data: (data) => data.isEmpty && (buildLoadingIndicator != null || topWidget == null) ? (buildLoadingIndicator ?? buildEmptyIndicator)() : ImmichAssetGrid( renderList: data, listener: selectionListener, selectionActive: selectionEnabledHook.value, onRefresh: onRefresh == null ? null : wrapLongRunningFun( onRefresh!, showOverlay: false, ), topWidget: topWidget, showStack: stackEnabled, ), error: (error, _) => Center(child: Text(error.toString())), loading: buildLoadingIndicator ?? buildDefaultLoadingIndicator, ), if (selectionEnabledHook.value) ControlBottomAppBar( onShare: onShareAssets, onFavorite: favoriteEnabled ? onFavoriteAssets : null, onArchive: archiveEnabled ? onArchiveAsset : null, onDelete: deleteEnabled ? onDelete : null, onDeleteServer: deleteEnabled ? onDeleteRemote : null, /// local file deletion is allowed irrespective of [deleteEnabled] since it has /// nothing to do with the state of the asset in the Immich server onDeleteLocal: onDeleteLocal, onAddToAlbum: onAddToAlbum, onCreateNewAlbum: onCreateNewAlbum, onUpload: onUpload, enabled: !processing.value, selectionAssetState: selectionAssetState.value, onStack: stackEnabled ? onStack : null, onEditTime: editEnabled ? onEditTime : null, onEditLocation: editEnabled ? onEditLocation : null, unfavorite: unfavorite, unarchive: unarchive, onRemoveFromAlbum: onRemoveFromAlbum != null ? wrapLongRunningFun( () => onRemoveFromAlbum!(selection.value), ) : null, ), ], ), ); } }