mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
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:
parent
b9a9a3956c
commit
c25556bb08
26 changed files with 768 additions and 968 deletions
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue