feat(mobile): unify asset grid multiselect actions (#5407)

* feat(mobile): unify asset grid multiselect actions

* add favorite & archive page

* show edit date&place on main photos screen

* Reposition exit button

* Sort favorite with the same order as other view

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Fynn Petersen-Frey 2023-12-07 16:38:22 +01:00 committed by GitHub
parent b9a9a3956c
commit c25556bb08
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 768 additions and 968 deletions

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class DisableMultiSelectButton extends ConsumerWidget {
const DisableMultiSelectButton({
@ -13,24 +14,25 @@ class DisableMultiSelectButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
return Align(
alignment: Alignment.topLeft,
child: Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ElevatedButton.icon(
onPressed: () {
onPressed();
},
onPressed: () => onPressed(),
icon: const Icon(Icons.close_rounded),
label: Text(
'$selectedItemCount',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
style: context.textTheme.titleMedium?.copyWith(
height: 2.5,
color: context.isDarkTheme ? Colors.black : Colors.white,
),
),
),
),
),
);
}
}

View file

@ -33,8 +33,6 @@ class ImmichAssetGrid extends HookConsumerWidget {
final bool shrinkWrap;
final bool showDragScroll;
final bool showStack;
final bool isOwner;
final String? sharedAlbumId;
const ImmichAssetGrid({
super.key,
@ -55,8 +53,6 @@ class ImmichAssetGrid extends HookConsumerWidget {
this.shrinkWrap = false,
this.showDragScroll = true,
this.showStack = false,
this.isOwner = true,
this.sharedAlbumId,
});
@override
@ -121,8 +117,6 @@ class ImmichAssetGrid extends HookConsumerWidget {
shrinkWrap: shrinkWrap,
showDragScroll: showDragScroll,
showStack: showStack,
isOwner: isOwner,
sharedAlbumId: sharedAlbumId,
),
);
}

View file

@ -39,8 +39,6 @@ class ImmichAssetGridView extends StatefulWidget {
final bool shrinkWrap;
final bool showDragScroll;
final bool showStack;
final bool isOwner;
final String? sharedAlbumId;
const ImmichAssetGridView({
super.key,
@ -61,8 +59,6 @@ class ImmichAssetGridView extends StatefulWidget {
this.shrinkWrap = false,
this.showDragScroll = true,
this.showStack = false,
this.isOwner = true,
this.sharedAlbumId,
});
@override
@ -143,8 +139,6 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
showStorageIndicator: widget.showStorageIndicator,
heroOffset: widget.heroOffset,
showStack: widget.showStack,
isOwner: widget.isOwner,
sharedAlbumId: widget.sharedAlbumId,
);
}

View file

@ -14,14 +14,12 @@ class ThumbnailImage extends StatelessWidget {
final int totalAssets;
final bool showStorageIndicator;
final bool showStack;
final bool isOwner;
final bool useGrayBoxPlaceholder;
final bool isSelected;
final bool multiselectEnabled;
final Function? onSelect;
final Function? onDeselect;
final int heroOffset;
final String? sharedAlbumId;
const ThumbnailImage({
Key? key,
@ -31,8 +29,6 @@ class ThumbnailImage extends StatelessWidget {
required this.totalAssets,
this.showStorageIndicator = true,
this.showStack = false,
this.isOwner = true,
this.sharedAlbumId,
this.useGrayBoxPlaceholder = false,
this.isSelected = false,
this.multiselectEnabled = false,
@ -185,8 +181,6 @@ class ThumbnailImage extends StatelessWidget {
totalAssets: totalAssets,
heroOffset: heroOffset,
showStack: showStack,
isOwner: isOwner,
sharedAlbumId: sharedAlbumId,
),
);
}

View file

@ -2,6 +2,8 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_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/ui/add_to_album_sliverlist.dart';
import 'package:immich_mobile/modules/home/models/selection_state.dart';
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
@ -12,37 +14,39 @@ import 'package:immich_mobile/shared/models/album.dart';
class ControlBottomAppBar extends ConsumerWidget {
final void Function(bool shareLocal) onShare;
final void Function() onFavorite;
final void Function() onArchive;
final void Function() onDelete;
final void Function()? onFavorite;
final void Function()? onArchive;
final void Function()? onDelete;
final Function(Album album) onAddToAlbum;
final void Function() onCreateNewAlbum;
final void Function() onUpload;
final void Function() onStack;
final void Function() onEditTime;
final void Function() onEditLocation;
final void Function()? onStack;
final void Function()? onEditTime;
final void Function()? onEditLocation;
final void Function()? onRemoveFromAlbum;
final List<Album> albums;
final List<Album> sharedAlbums;
final bool enabled;
final bool unfavorite;
final bool unarchive;
final SelectionAssetState selectionAssetState;
const ControlBottomAppBar({
Key? key,
required this.onShare,
required this.onFavorite,
required this.onArchive,
required this.onDelete,
required this.sharedAlbums,
required this.albums,
this.onFavorite,
this.onArchive,
this.onDelete,
required this.onAddToAlbum,
required this.onCreateNewAlbum,
required this.onUpload,
required this.onStack,
required this.onEditTime,
required this.onEditLocation,
this.onStack,
this.onEditTime,
this.onEditLocation,
this.onRemoveFromAlbum,
this.selectionAssetState = const SelectionAssetState(),
this.enabled = true,
this.unarchive = false,
this.unfavorite = false,
}) : super(key: key);
@override
@ -52,6 +56,8 @@ class ControlBottomAppBar extends ConsumerWidget {
var hasLocal = selectionAssetState.hasLocal;
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
final sharedAlbums = ref.watch(sharedAlbumProvider);
List<Widget> renderActionButtons() {
return [
@ -66,56 +72,73 @@ class ControlBottomAppBar extends ConsumerWidget {
label: "control_bottom_app_bar_share_to".tr(),
onPressed: enabled ? () => onShare(true) : null,
),
if (hasRemote)
if (hasRemote && onArchive != null)
ControlBoxButton(
iconData: Icons.archive,
label: "control_bottom_app_bar_archive".tr(),
iconData: unarchive ? Icons.unarchive : Icons.archive,
label: (unarchive
? "control_bottom_app_bar_unarchive"
: "control_bottom_app_bar_archive")
.tr(),
onPressed: enabled ? onArchive : null,
),
if (hasRemote)
if (hasRemote && onFavorite != null)
ControlBoxButton(
iconData: Icons.favorite_border_rounded,
label: "control_bottom_app_bar_favorite".tr(),
iconData: unfavorite
? Icons.favorite_border_rounded
: Icons.favorite_rounded,
label: (unfavorite
? "control_bottom_app_bar_unfavorite"
: "control_bottom_app_bar_favorite")
.tr(),
onPressed: enabled ? onFavorite : null,
),
if (hasRemote)
if (hasRemote && onEditTime != null)
ControlBoxButton(
iconData: Icons.edit_calendar_outlined,
label: "control_bottom_app_bar_edit_time".tr(),
onPressed: enabled ? onEditTime : null,
),
if (hasRemote)
if (hasRemote && onEditLocation != null)
ControlBoxButton(
iconData: Icons.edit_location_alt_outlined,
label: "control_bottom_app_bar_edit_location".tr(),
onPressed: enabled ? onEditLocation : null,
),
ControlBoxButton(
iconData: Icons.delete_outline_rounded,
label: "control_bottom_app_bar_delete".tr(),
onPressed: enabled
? () {
if (!trashEnabled) {
showDialog(
context: context,
builder: (BuildContext context) {
return DeleteDialog(
onDelete: onDelete,
);
},
);
} else {
onDelete();
if (onDelete != null)
ControlBoxButton(
iconData: Icons.delete_outline_rounded,
label: "control_bottom_app_bar_delete".tr(),
onPressed: enabled
? () {
if (!trashEnabled) {
showDialog(
context: context,
builder: (BuildContext context) {
return DeleteDialog(
onDelete: onDelete!,
);
},
);
} else {
onDelete!();
}
}
}
: null,
),
if (!hasLocal && selectionAssetState.selectedCount > 1)
: null,
),
if (!hasLocal &&
selectionAssetState.selectedCount > 1 &&
onStack != null)
ControlBoxButton(
iconData: Icons.filter_none_rounded,
label: "control_bottom_app_bar_stack".tr(),
onPressed: enabled ? onStack : null,
),
if (onRemoveFromAlbum != null)
ControlBoxButton(
iconData: Icons.delete_sweep_rounded,
label: 'album_viewer_appbar_share_remove'.tr(),
onPressed: enabled ? onRemoveFromAlbum : null,
),
if (hasLocal)
ControlBoxButton(
iconData: Icons.backup_outlined,

View file

@ -1,57 +1,30 @@
import 'dart:async';
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/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.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/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
import 'package:immich_mobile/modules/memories/ui/memory_lane.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/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/shared/ui/immich_app_bar.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 HomePage extends HookConsumerWidget {
const HomePage({Key? key}) : super(key: key);
@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(<Asset>{});
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
final sharedAlbums = ref.watch(sharedAlbumProvider);
final albumService = ref.watch(albumServiceProvider);
final currentUser = ref.watch(currentUserProvider);
final timelineUsers = ref.watch(timelineUsersIdsProvider);
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final tipOneOpacity = useState(0.0);
final refreshCount = useState(0);
final processing = useProcessingOverlay();
useEffect(
() {
@ -61,394 +34,82 @@ class HomePage extends HookConsumerWidget {
ref.read(albumProvider.notifier).getAllAlbums();
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
ref.read(serverInfoProvider.notifier).getServerInfo();
selectionEnabledHook.addListener(() {
multiselectEnabled.state = selectionEnabledHook.value;
});
return () {
// This does not work in tests
if (kReleaseMode) {
selectionEnabledHook.dispose();
}
};
return;
},
[],
);
Widget buildLoadingIndicator() {
Timer(const Duration(seconds: 2), () => tipOneOpacity.value = 1);
Widget buildBody() {
void selectionListener(
bool multiselect,
Set<Asset> 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<Asset> remoteOnly(
Iterable<Asset> assets, {
void Function()? errorCallback,
}) {
final bool onlyRemote = assets.every((e) => e.isRemote);
if (!onlyRemote) {
if (errorCallback != null) errorCallback();
return assets.where((a) => a.isRemote);
}
return assets;
}
Iterable<Asset> ownedOnly(
Iterable<Asset> assets, {
void Function()? errorCallback,
}) {
if (currentUser == null) return [];
final userId = currentUser.isarId;
final bool onlyOwned = assets.every((e) => e.ownerId == userId);
if (!onlyOwned) {
if (errorCallback != null) errorCallback();
return assets.where((a) => a.ownerId == userId);
}
return assets;
}
Iterable<Asset> ownedRemoteSelection({
String? localErrorMessage,
String? ownerErrorMessage,
}) {
final assets = selection.value;
return remoteOnly(
ownedOnly(assets, errorCallback: errorBuilder(ownerErrorMessage)),
errorCallback: errorBuilder(localErrorMessage),
);
}
Iterable<Asset> remoteSelection({String? errorMessage}) => remoteOnly(
selection.value,
errorCallback: errorBuilder(errorMessage),
);
void onShareAssets(bool shareLocal) {
processing.value = true;
if (shareLocal) {
handleShareAssets(ref, context, selection.value.toList());
} else {
final ids =
remoteSelection(errorMessage: "home_page_share_err_local".tr())
.map((e) => e.remoteId!);
context.autoPush(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() async {
processing.value = true;
try {
final toDelete = ownedOnly(
selection.value,
errorCallback: errorBuilder('home_page_delete_err_partner'.tr()),
).toList();
await ref
.read(assetProvider.notifier)
.deleteAssets(toDelete, force: !trashEnabled);
final hasRemote = toDelete.any((a) => a.isRemote);
final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset';
final trashOrRemoved =
!trashEnabled ? 'deleted permanently' : 'trashed';
if (hasRemote) {
ImmichToast.show(
context: context,
msg: '${selection.value.length} $assetOrAssets $trashOrRemoved',
gravity: ToastGravity.BOTTOM,
);
}
selectionEnabledHook.value = false;
} finally {
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<Asset> assets = remoteSelection(
errorMessage: "home_page_add_to_album_err_local".tr(),
);
if (assets.isEmpty) {
return;
}
final result = await albumService.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,
);
ref.watch(albumProvider.notifier).getAllAlbums();
ref.invalidate(albumDetailProvider(album.id));
}
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
void onCreateNewAlbum() async {
processing.value = true;
try {
final Iterable<Asset> assets = remoteSelection(
errorMessage: "home_page_add_to_album_err_local".tr(),
);
if (assets.isEmpty) {
return;
}
final result =
await albumService.createAlbumWithGeneratedName(assets);
if (result != null) {
ref.watch(albumProvider.notifier).getAllAlbums();
ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
selectionEnabledHook.value = false;
context.autoPush(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(),
);
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(),
);
if (remoteAssets.isNotEmpty) {
handleEditLocation(ref, context, remoteAssets.toList());
}
} finally {
selectionEnabledHook.value = false;
}
}
Future<void> refreshAssets() async {
final fullRefresh = refreshCount.value > 0;
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
if (timelineUsers.length > 1) {
await ref.read(assetProvider.notifier).getPartnerAssets();
}
if (fullRefresh) {
// refresh was forced: user requested another refresh within 2 seconds
refreshCount.value = 0;
} else {
refreshCount.value++;
// set counter back to 0 if user does not request refresh again
Timer(const Duration(seconds: 4), () => refreshCount.value = 0);
}
}
buildLoadingIndicator() {
Timer(const Duration(seconds: 2), () => tipOneOpacity.value = 1);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const ImmichLoadingIndicator(),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
'home_page_building_timeline',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
color: context.primaryColor,
),
).tr(),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: tipOneOpacity.value,
child: SizedBox(
width: 250,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: const Text(
'home_page_first_time_notice',
textAlign: TextAlign.justify,
style: TextStyle(
fontSize: 12,
),
).tr(),
),
),
),
],
),
);
}
return SafeArea(
top: true,
bottom: false,
child: Stack(
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ref
.watch(
timelineUsers.length > 1
? multiUserAssetsProvider(timelineUsers)
: assetsProvider(currentUser?.isarId),
)
.when(
data: (data) => data.isEmpty
? buildLoadingIndicator()
: ImmichAssetGrid(
renderList: data,
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
onRefresh: refreshAssets,
topWidget:
(currentUser != null && currentUser.memoryEnabled)
? const MemoryLane()
: const SizedBox(),
showStack: true,
),
error: (error, _) => Center(child: Text(error.toString())),
loading: buildLoadingIndicator,
const ImmichLoadingIndicator(),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
'home_page_building_timeline',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
color: context.primaryColor,
),
).tr(),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: tipOneOpacity.value,
child: SizedBox(
width: 250,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: const Text(
'home_page_first_time_notice',
textAlign: TextAlign.justify,
style: TextStyle(
fontSize: 12,
),
).tr(),
),
if (selectionEnabledHook.value)
ControlBottomAppBar(
onShare: onShareAssets,
onFavorite: onFavoriteAssets,
onArchive: onArchiveAsset,
onDelete: onDelete,
onAddToAlbum: onAddToAlbum,
albums: albums,
sharedAlbums: sharedAlbums,
onCreateNewAlbum: onCreateNewAlbum,
onUpload: onUpload,
enabled: !processing.value,
selectionAssetState: selectionAssetState.value,
onStack: onStack,
onEditTime: onEditTime,
onEditLocation: onEditLocation,
),
),
],
),
);
}
Future<void> refreshAssets() async {
final fullRefresh = refreshCount.value > 0;
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
if (timelineUsers.length > 1) {
await ref.read(assetProvider.notifier).getPartnerAssets();
}
if (fullRefresh) {
// refresh was forced: user requested another refresh within 2 seconds
refreshCount.value = 0;
} else {
refreshCount.value++;
// set counter back to 0 if user does not request refresh again
Timer(const Duration(seconds: 4), () => refreshCount.value = 0);
}
}
Widget buildBody() {
return MultiselectGrid(
renderListProvider: timelineUsers.length > 1
? multiUserAssetsProvider(timelineUsers)
: assetsProvider(currentUser?.isarId),
buildLoadingIndicator: buildLoadingIndicator,
onRefresh: refreshAssets,
stackEnabled: true,
archiveEnabled: true,
editEnabled: true,
);
}
return Scaffold(
appBar: !selectionEnabledHook.value ? const ImmichAppBar() : null,
appBar: ref.watch(multiselectProvider) ? null : const ImmichAppBar(),
body: buildBody(),
);
}