1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-28 09:33:27 +02: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:
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

@ -31,7 +31,6 @@
"app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_content": "Are you sure you want to sign out?",
"app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_ok": "Yes",
"app_bar_signout_dialog_title": "Sign out", "app_bar_signout_dialog_title": "Sign out",
"archive_page_no_archived_assets": "No archived assets found",
"archive_page_title": "Archive ({})", "archive_page_title": "Archive ({})",
"asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout",
"asset_list_layout_settings_group_automatically": "Automatic", "asset_list_layout_settings_group_automatically": "Automatic",
@ -139,6 +138,7 @@
"control_bottom_app_bar_create_new_album": "Create new album", "control_bottom_app_bar_create_new_album": "Create new album",
"control_bottom_app_bar_delete": "Delete", "control_bottom_app_bar_delete": "Delete",
"control_bottom_app_bar_favorite": "Favorite", "control_bottom_app_bar_favorite": "Favorite",
"control_bottom_app_bar_unfavorite": "Unfavorite",
"control_bottom_app_bar_share": "Share", "control_bottom_app_bar_share": "Share",
"control_bottom_app_bar_share_to": "Share To", "control_bottom_app_bar_share_to": "Share To",
"control_bottom_app_bar_stack": "Stack", "control_bottom_app_bar_stack": "Stack",
@ -172,7 +172,6 @@
"experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_new_asset_list_title": "Enable experimental photo grid",
"experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_subtitle": "Use at your own risk!",
"experimental_settings_title": "Experimental", "experimental_settings_title": "Experimental",
"favorites_page_no_favorites": "No favorite assets found",
"favorites_page_title": "Favorites", "favorites_page_title": "Favorites",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",

View File

@ -2,11 +2,13 @@ import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/utils/renderlist_generator.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
class AlbumNotifier extends StateNotifier<List<Album>> { class AlbumNotifier extends StateNotifier<List<Album>> {
@ -49,3 +51,24 @@ final albumProvider =
ref.watch(dbProvider), ref.watch(dbProvider),
); );
}); });
final albumWatcher =
StreamProvider.autoDispose.family<Album, int>((ref, albumId) async* {
final db = ref.watch(dbProvider);
final a = await db.albums.get(albumId);
if (a != null) yield a;
await for (final a in db.albums.watchObject(albumId, fireImmediately: true)) {
if (a != null) yield a;
}
});
final albumRenderlistProvider =
StreamProvider.autoDispose.family<RenderList, int>((ref, albumId) {
final album = ref.watch(albumWatcher(albumId)).value;
if (album != null) {
final query =
album.assets.filter().isTrashedEqualTo(false).sortByFileCreatedAtDesc();
return renderListGeneratorWithGroupBy(query, GroupAssetsBy.none);
}
return const Stream.empty();
});

View File

@ -1,21 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
final albumDetailProvider =
StreamProvider.family<Album, int>((ref, albumId) async* {
final user = ref.watch(currentUserProvider);
if (user == null) return;
final AlbumService service = ref.watch(albumServiceProvider);
await for (final a in service.watchAlbum(albumId)) {
if (a == null) {
throw Exception("Album with ID=$albumId does not exist anymore!");
}
await for (final _ in a.watchRenderList(GroupAssetsBy.none)) {
yield a;
}
}
});

View File

@ -0,0 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/album.dart';
final currentAlbumProvider = StateProvider<Album?>((ref) {
return null;
});

View File

@ -2,7 +2,6 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
@ -11,7 +10,7 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
class SharedAlbumNotifier extends StateNotifier<List<Album>> { class SharedAlbumNotifier extends StateNotifier<List<Album>> {
SharedAlbumNotifier(this._albumService, Isar db, this._ref) : super([]) { SharedAlbumNotifier(this._albumService, Isar db) : super([]) {
final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc(); final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
query.findAll().then((value) => state = value); query.findAll().then((value) => state = value);
_streamSub = query.watch().listen((data) => state = data); _streamSub = query.watch().listen((data) => state = data);
@ -19,7 +18,6 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
final AlbumService _albumService; final AlbumService _albumService;
late final StreamSubscription<List<Album>> _streamSub; late final StreamSubscription<List<Album>> _streamSub;
final Ref _ref;
Future<Album?> createSharedAlbum( Future<Album?> createSharedAlbum(
String albumName, String albumName,
@ -68,15 +66,8 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
return result; return result;
} }
Future<bool> setActivityEnabled(Album album, bool activityEnabled) async { Future<bool> setActivityEnabled(Album album, bool activityEnabled) {
final result = return _albumService.setActivityEnabled(album, activityEnabled);
await _albumService.setActivityEnabled(album, activityEnabled);
if (result) {
_ref.invalidate(albumDetailProvider(album.id));
}
return result;
} }
@override @override
@ -91,6 +82,5 @@ final sharedAlbumProvider =
return SharedAlbumNotifier( return SharedAlbumNotifier(
ref.watch(albumServiceProvider), ref.watch(albumServiceProvider),
ref.watch(dbProvider), ref.watch(dbProvider),
ref,
); );
}); });

View File

@ -219,11 +219,6 @@ class AlbumService {
); );
} }
Stream<Album?> watchAlbum(int albumId) async* {
yield await _db.albums.get(albumId);
yield* _db.albums.watchObject(albumId);
}
Future<AddAssetsResponse?> addAdditionalAssetToAlbum( Future<AddAssetsResponse?> addAdditionalAssetToAlbum(
Iterable<Asset> assets, Iterable<Asset> assets,
Album album, Album album,
@ -248,8 +243,12 @@ class AlbumService {
} }
} }
album.assets.addAll(successAssets); await _db.writeTxn(() async {
await _db.writeTxn(() => album.assets.save()); await album.assets.update(link: successAssets);
final a = await _db.albums.get(album.id);
// trigger watcher
await _db.albums.put(a!);
});
return AddAssetsResponse( return AddAssetsResponse(
alreadyInAlbum: duplicatedAssets, alreadyInAlbum: duplicatedAssets,
@ -359,8 +358,12 @@ class AlbumService {
ids: assets.map((asset) => asset.remoteId!).toList(), ids: assets.map((asset) => asset.remoteId!).toList(),
), ),
); );
album.assets.removeAll(assets); await _db.writeTxn(() async {
await _db.writeTxn(() => album.assets.update(unlink: assets)); await album.assets.update(unlink: assets);
final a = await _db.albums.get(album.id);
// trigger watcher
await _db.albums.put(a!);
});
return true; return true;
} catch (e) { } catch (e) {
@ -380,7 +383,12 @@ class AlbumService {
); );
album.sharedUsers.remove(user); album.sharedUsers.remove(user);
await _db.writeTxn(() => album.sharedUsers.update(unlink: [user])); await _db.writeTxn(() async {
await album.sharedUsers.update(unlink: [user]);
final a = await _db.albums.get(album.id);
// trigger watcher
await _db.albums.put(a!);
});
return true; return true;
} catch (e) { } catch (e) {

View File

@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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.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/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
@ -63,8 +62,6 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
); );
} }
} }
ref.invalidate(albumDetailProvider(album.id));
context.pop(); context.pop();
} }

View File

@ -5,14 +5,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart'; import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.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/album_viewer.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/shared/ui/share_dialog.dart';
import 'package:immich_mobile/shared/services/share.service.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@ -22,8 +18,6 @@ class AlbumViewerAppbar extends HookConsumerWidget
Key? key, Key? key,
required this.album, required this.album,
required this.userId, required this.userId,
required this.selected,
required this.selectionDisabled,
required this.titleFocusNode, required this.titleFocusNode,
this.onAddPhotos, this.onAddPhotos,
this.onAddUsers, this.onAddUsers,
@ -32,8 +26,6 @@ class AlbumViewerAppbar extends HookConsumerWidget
final Album album; final Album album;
final String userId; final String userId;
final Set<Asset> selected;
final void Function() selectionDisabled;
final FocusNode titleFocusNode; final FocusNode titleFocusNode;
final Function(Album album)? onAddPhotos; final Function(Album album)? onAddPhotos;
final Function(Album album)? onAddUsers; final Function(Album album)? onAddUsers;
@ -144,109 +136,27 @@ class AlbumViewerAppbar extends HookConsumerWidget
isProcessing.value = false; isProcessing.value = false;
} }
void onRemoveFromAlbumPressed() async {
isProcessing.value = true;
bool isSuccess =
await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum(
album,
selected,
);
if (isSuccess) {
context.pop();
selectionDisabled();
ref.watch(albumProvider.notifier).getAllAlbums();
ref.invalidate(albumDetailProvider(album.id));
} else {
context.pop();
ImmichToast.show(
context: context,
msg: "album_viewer_appbar_share_err_remove".tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
isProcessing.value = false;
}
void handleShareAssets(
WidgetRef ref,
BuildContext context,
Set<Asset> selection,
) {
showDialog(
context: context,
builder: (BuildContext buildContext) {
ref.watch(shareServiceProvider).shareAssets(selection.toList()).then(
(bool status) {
if (!status) {
ImmichToast.show(
context: context,
msg: 'image_viewer_page_state_provider_share_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
buildContext.pop();
},
);
return const ShareDialog();
},
barrierDismissible: false,
);
}
void onShareAssetsTo() async {
isProcessing.value = true;
handleShareAssets(ref, context, selected);
isProcessing.value = false;
}
buildBottomSheetActions() { buildBottomSheetActions() {
if (selected.isNotEmpty) { return [
return [ album.ownerId == userId
ListTile( ? ListTile(
leading: const Icon(Icons.ios_share_rounded), leading: const Icon(Icons.delete_forever_rounded),
title: const Text( title: const Text(
'album_viewer_appbar_share_to', 'album_viewer_appbar_share_delete',
style: TextStyle(fontWeight: FontWeight.w500), style: TextStyle(fontWeight: FontWeight.w500),
).tr(), ).tr(),
onTap: () => onShareAssetsTo(), onTap: () => onDeleteAlbumPressed(),
), )
album.ownerId == userId : ListTile(
? ListTile( leading: const Icon(Icons.person_remove_rounded),
leading: const Icon(Icons.delete_sweep_rounded), title: const Text(
title: const Text( 'album_viewer_appbar_share_leave',
'album_viewer_appbar_share_remove', style: TextStyle(fontWeight: FontWeight.w500),
style: TextStyle(fontWeight: FontWeight.w500), ).tr(),
).tr(), onTap: () => onLeaveAlbumPressed(),
onTap: () => onRemoveFromAlbumPressed(), ),
) ];
: const SizedBox(), // }
];
} else {
return [
album.ownerId == userId
? ListTile(
leading: const Icon(Icons.delete_forever_rounded),
title: const Text(
'album_viewer_appbar_share_delete',
style: TextStyle(fontWeight: FontWeight.w500),
).tr(),
onTap: () => onDeleteAlbumPressed(),
)
: ListTile(
leading: const Icon(Icons.person_remove_rounded),
title: const Text(
'album_viewer_appbar_share_leave',
style: TextStyle(fontWeight: FontWeight.w500),
).tr(),
onTap: () => onLeaveAlbumPressed(),
),
];
}
} }
void buildBottomSheet() { void buildBottomSheet() {
@ -308,10 +218,8 @@ class AlbumViewerAppbar extends HookConsumerWidget
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
...buildBottomSheetActions(), ...buildBottomSheetActions(),
if (selected.isEmpty && onAddPhotos != null) ...commonActions, if (onAddPhotos != null) ...commonActions,
if (selected.isEmpty && if (onAddPhotos != null && userId == album.ownerId)
onAddPhotos != null &&
userId == album.ownerId)
...ownerActions, ...ownerActions,
], ],
), ),
@ -349,13 +257,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
} }
buildLeadingButton() { buildLeadingButton() {
if (selected.isNotEmpty) { if (isEditAlbum) {
return IconButton(
onPressed: selectionDisabled,
icon: const Icon(Icons.close_rounded),
splashRadius: 25,
);
} else if (isEditAlbum) {
return IconButton( return IconButton(
onPressed: () async { onPressed: () async {
bool isSuccess = await ref bool isSuccess = await ref
@ -388,7 +290,6 @@ class AlbumViewerAppbar extends HookConsumerWidget
return AppBar( return AppBar(
elevation: 0, elevation: 0,
leading: buildLeadingButton(), leading: buildLeadingButton(),
title: selected.isNotEmpty ? Text('${selected.length}') : null,
centerTitle: false, centerTitle: false,
actions: [ actions: [
if (album.shared && (album.activityEnabled || comments != 0)) if (album.shared && (album.activityEnabled || comments != 0))

View File

@ -1,23 +1,26 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/current_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/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart'; import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart';
import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart'; import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart'; import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart'; import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@ -29,39 +32,30 @@ class AlbumViewerPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
FocusNode titleFocusNode = useFocusNode(); FocusNode titleFocusNode = useFocusNode();
final album = ref.watch(albumDetailProvider(albumId)); final album = ref.watch(albumWatcher(albumId));
album.whenData(
(value) =>
Future((() => ref.read(currentAlbumProvider.notifier).state = value)),
);
final userId = ref.watch(authenticationProvider).userId; final userId = ref.watch(authenticationProvider).userId;
final selection = useState<Set<Asset>>({});
final multiSelectEnabled = useState(false);
final isProcessing = useProcessingOverlay(); final isProcessing = useProcessingOverlay();
useEffect( Future<bool> onRemoveFromAlbumPressed(Iterable<Asset> assets) async {
() { final a = album.valueOrNull;
// Fetch album updates, e.g., cover image final bool isSuccess = a != null &&
ref.invalidate(albumDetailProvider(albumId)); await ref
return null; .read(sharedAlbumProvider.notifier)
}, .removeAssetFromAlbum(a, assets);
[],
);
Future<bool> onWillPop() async { if (!isSuccess) {
if (multiSelectEnabled.value) { ImmichToast.show(
selection.value = {}; context: context,
multiSelectEnabled.value = false; msg: "album_viewer_appbar_share_err_remove".tr(),
return false; toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
} }
return isSuccess;
return true;
}
void selectionListener(bool active, Set<Asset> selected) {
selection.value = selected;
multiSelectEnabled.value = selected.isNotEmpty;
}
void disableSelection() {
selection.value = {};
multiSelectEnabled.value = false;
} }
/// Find out if the assets in album exist on the device /// Find out if the assets in album exist on the device
@ -80,15 +74,10 @@ class AlbumViewerPage extends HookConsumerWidget {
// Check if there is new assets add // Check if there is new assets add
isProcessing.value = true; isProcessing.value = true;
var addAssetsResult = await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum(
await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum( returnPayload.selectedAssets,
returnPayload.selectedAssets, albumInfo,
albumInfo, );
);
if (addAssetsResult != null && addAssetsResult.successfullyAdded > 0) {
ref.invalidate(albumDetailProvider(albumId));
}
isProcessing.value = false; isProcessing.value = false;
} }
@ -102,14 +91,10 @@ class AlbumViewerPage extends HookConsumerWidget {
if (sharedUserIds != null) { if (sharedUserIds != null) {
isProcessing.value = true; isProcessing.value = true;
var isSuccess = await ref await ref
.watch(albumServiceProvider) .watch(albumServiceProvider)
.addAdditionalUserToAlbum(sharedUserIds, album); .addAdditionalUserToAlbum(sharedUserIds, album);
if (isSuccess) {
ref.invalidate(albumDetailProvider(album.id));
}
isProcessing.value = false; isProcessing.value = false;
} }
} }
@ -193,10 +178,7 @@ class AlbumViewerPage extends HookConsumerWidget {
Widget buildSharedUserIconsRow(Album album) { Widget buildSharedUserIconsRow(Album album) {
return GestureDetector( return GestureDetector(
onTap: () async { onTap: () => context.autoPush(AlbumOptionsRoute(album: album)),
await context.autoPush(AlbumOptionsRoute(album: album));
ref.invalidate(albumDetailProvider(album.id));
},
child: SizedBox( child: SizedBox(
height: 50, height: 50,
child: ListView.builder( child: ListView.builder(
@ -244,42 +226,32 @@ class AlbumViewerPage extends HookConsumerWidget {
} }
return Scaffold( return Scaffold(
appBar: album.when( appBar: ref.watch(multiselectProvider)
data: (data) => AlbumViewerAppbar( ? null
titleFocusNode: titleFocusNode, : album.when(
album: data, data: (data) => AlbumViewerAppbar(
userId: userId, titleFocusNode: titleFocusNode,
selected: selection.value, album: data,
selectionDisabled: disableSelection, userId: userId,
onAddPhotos: onAddPhotosPressed, onAddPhotos: onAddPhotosPressed,
onAddUsers: onAddUsersPressed, onAddUsers: onAddUsersPressed,
onActivities: onActivitiesPressed, onActivities: onActivitiesPressed,
),
error: (error, stackTrace) => AppBar(title: const Text("Error")),
loading: () => AppBar(),
),
body: album.widgetWhen(
onData: (data) => WillPopScope(
onWillPop: onWillPop,
child: GestureDetector(
onTap: () => titleFocusNode.unfocus(),
child: ImmichAssetGrid(
renderList: data.renderList,
listener: selectionListener,
selectionActive: multiSelectEnabled.value,
showMultiSelectIndicator: false,
topWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildHeader(data),
if (data.isRemote) buildControlButton(data),
],
), ),
isOwner: userId == data.ownerId, error: (error, stackTrace) => AppBar(title: const Text("Error")),
sharedAlbumId: loading: () => AppBar(),
data.shared && data.activityEnabled ? data.remoteId : null,
), ),
body: album.widgetWhen(
onData: (data) => MultiselectGrid(
renderListProvider: albumRenderlistProvider(albumId),
topWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildHeader(data),
if (data.isRemote) buildControlButton(data),
],
), ),
onRemoveFromAlbum: onRemoveFromAlbumPressed,
editEnabled: data.ownerId == userId,
), ),
), ),
); );

View File

@ -1,34 +1,19 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.dart'; import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
class ArchivePage extends HookConsumerWidget { class ArchivePage extends HookConsumerWidget {
const ArchivePage({super.key}); const ArchivePage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final archivedAssets = ref.watch(archiveProvider); AppBar buildAppBar() {
final selectionEnabledHook = useState(false); final archivedAssets = ref.watch(archiveProvider);
final selection = useState(<Asset>{}); final count = archivedAssets.value?.totalAssets.toString() ?? "?";
final processing = useState(false);
void selectionListener(
bool multiselect,
Set<Asset> selectedAssets,
) {
selectionEnabledHook.value = multiselect;
selection.value = selectedAssets;
}
AppBar buildAppBar(String count) {
return AppBar( return AppBar(
leading: IconButton( leading: IconButton(
onPressed: () => context.autoPop(), onPressed: () => context.autoPop(),
@ -42,69 +27,14 @@ class ArchivePage extends HookConsumerWidget {
); );
} }
Widget buildBottomBar() {
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Card(
child: ListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
leading: const Icon(
Icons.unarchive_rounded,
),
title: Text(
'control_bottom_app_bar_unarchive'.tr(),
style: const TextStyle(fontSize: 14),
),
onTap: processing.value
? null
: () async {
processing.value = true;
try {
await handleArchiveAssets(
ref,
context,
selection.value.toList(),
shouldArchive: false,
);
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
},
),
),
),
),
);
}
return Scaffold( return Scaffold(
appBar: archivedAssets.maybeWhen( appBar: ref.watch(multiselectProvider) ? null : buildAppBar(),
data: (data) => buildAppBar(data.totalAssets.toString()), body: MultiselectGrid(
orElse: () => buildAppBar("?"), renderListProvider: archiveProvider,
), unarchive: true,
body: archivedAssets.widgetWhen( archiveEnabled: true,
onData: (data) => data.isEmpty deleteEnabled: true,
? Center( editEnabled: true,
child: Text('archive_page_no_archived_assets'.tr()),
)
: Stack(
children: [
ImmichAssetGrid(
renderList: data,
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
),
if (selectionEnabledHook.value) buildBottomBar(),
if (processing.value)
const Center(child: ImmichLoadingIndicator()),
],
),
), ),
); );
} }

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart'; import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
@ -17,8 +18,8 @@ class TopControlAppBar extends HookConsumerWidget {
required this.onFavorite, required this.onFavorite,
required this.onUploadPressed, required this.onUploadPressed,
required this.isOwner, required this.isOwner,
required this.shareAlbumId,
required this.onActivitiesPressed, required this.onActivitiesPressed,
required this.isPartner,
}) : super(key: key); }) : super(key: key);
final Asset asset; final Asset asset;
@ -31,16 +32,17 @@ class TopControlAppBar extends HookConsumerWidget {
final Function(Asset) onFavorite; final Function(Asset) onFavorite;
final bool isPlayingMotionVideo; final bool isPlayingMotionVideo;
final bool isOwner; final bool isOwner;
final String? shareAlbumId; final bool isPartner;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
const double iconSize = 22.0; const double iconSize = 22.0;
final a = ref.watch(assetWatcher(asset)).value ?? asset; final a = ref.watch(assetWatcher(asset)).value ?? asset;
final comments = shareAlbumId != null final album = ref.watch(currentAlbumProvider);
final comments = album != null && album.remoteId != null
? ref.watch( ? ref.watch(
activityStatisticsStateProvider( activityStatisticsStateProvider(
(albumId: shareAlbumId!, assetId: asset.remoteId), (albumId: album.remoteId!, assetId: asset.remoteId),
), ),
) )
: 0; : 0;
@ -169,8 +171,8 @@ class TopControlAppBar extends HookConsumerWidget {
if (asset.livePhotoVideoId != null) buildLivePhotoButton(), if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
if (asset.isLocal && !asset.isRemote) buildUploadButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(),
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
if (asset.isRemote && isOwner) buildAddToAlbumButtom(), if (asset.isRemote && (isOwner || isPartner)) buildAddToAlbumButtom(),
if (shareAlbumId != null) buildActivitiesButton(), if (album != null && album.shared) buildActivitiesButton(),
buildMoreInfoButton(), buildMoreInfoButton(),
], ],
); );

View File

@ -9,6 +9,7 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
@ -22,6 +23,7 @@ import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
import 'package:immich_mobile/shared/cache/original_image_provider.dart'; import 'package:immich_mobile/shared/cache/original_image_provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/store.dart';
@ -29,6 +31,7 @@ import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.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/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/providers/server_info.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/ui/immich_image.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
@ -49,8 +52,6 @@ class GalleryViewerPage extends HookConsumerWidget {
final int initialIndex; final int initialIndex;
final int heroOffset; final int heroOffset;
final bool showStack; final bool showStack;
final bool isOwner;
final String? sharedAlbumId;
GalleryViewerPage({ GalleryViewerPage({
super.key, super.key,
@ -59,8 +60,6 @@ class GalleryViewerPage extends HookConsumerWidget {
required this.totalAssets, required this.totalAssets,
this.heroOffset = 0, this.heroOffset = 0,
this.showStack = false, this.showStack = false,
this.isOwner = true,
this.sharedAlbumId,
}) : controller = PageController(initialPage: initialIndex); }) : controller = PageController(initialPage: initialIndex);
final PageController controller; final PageController controller;
@ -94,10 +93,16 @@ class GalleryViewerPage extends HookConsumerWidget {
final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[]; final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
final isFromDto = currentAsset.id == Isar.autoIncrement; final isFromDto = currentAsset.id == Isar.autoIncrement;
final album = ref.watch(currentAlbumProvider);
Asset asset() => stackIndex.value == -1 Asset asset() => stackIndex.value == -1
? currentAsset ? currentAsset
: stackElements.elementAt(stackIndex.value); : stackElements.elementAt(stackIndex.value);
final isOwner = asset().ownerId == ref.watch(currentUserProvider)?.isarId;
final isPartner = ref
.watch(partnerSharedWithProvider)
.map((e) => e.isarId)
.contains(asset().ownerId);
bool isParent = stackIndex.value == -1 || stackIndex.value == 0; bool isParent = stackIndex.value == -1 || stackIndex.value == 0;
@ -113,9 +118,8 @@ class GalleryViewerPage extends HookConsumerWidget {
[], [],
); );
void toggleFavorite(Asset asset) => ref void toggleFavorite(Asset asset) =>
.watch(assetProvider.notifier) ref.read(assetProvider.notifier).toggleFavorite([asset]);
.toggleFavorite([asset], !asset.isFavorite);
/// Original (large) image of a remote asset. Required asset.isRemote /// Original (large) image of a remote asset. Required asset.isRemote
ImageProvider remoteOriginalProvider(Asset asset) => ImageProvider remoteOriginalProvider(Asset asset) =>
@ -305,9 +309,7 @@ class GalleryViewerPage extends HookConsumerWidget {
} }
handleArchive(Asset asset) { handleArchive(Asset asset) {
ref ref.watch(assetProvider.notifier).toggleArchive([asset]);
.watch(assetProvider.notifier)
.toggleArchive([asset], !asset.isArchived);
if (isParent) { if (isParent) {
context.autoPop(); context.autoPop();
return; return;
@ -331,10 +333,10 @@ class GalleryViewerPage extends HookConsumerWidget {
} }
handleActivities() { handleActivities() {
if (sharedAlbumId != null) { if (album != null && album.shared && album.remoteId != null) {
context.autoPush( context.autoPush(
ActivitiesRoute( ActivitiesRoute(
albumId: sharedAlbumId!, albumId: album.remoteId!,
assetId: asset().remoteId, assetId: asset().remoteId,
withAssetThumbs: false, withAssetThumbs: false,
isOwner: isOwner, isOwner: isOwner,
@ -353,6 +355,7 @@ class GalleryViewerPage extends HookConsumerWidget {
color: Colors.black.withOpacity(0.4), color: Colors.black.withOpacity(0.4),
child: TopControlAppBar( child: TopControlAppBar(
isOwner: isOwner, isOwner: isOwner,
isPartner: isPartner,
isPlayingMotionVideo: isPlayingMotionVideo.value, isPlayingMotionVideo: isPlayingMotionVideo.value,
asset: asset(), asset: asset(),
onMoreInfoPressed: showInfo, onMoreInfoPressed: showInfo,
@ -371,7 +374,6 @@ class GalleryViewerPage extends HookConsumerWidget {
isPlayingMotionVideo.value = !isPlayingMotionVideo.value; isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
}), }),
onAddToAlbumPressed: () => addToAlbum(asset()), onAddToAlbumPressed: () => addToAlbum(asset()),
shareAlbumId: sharedAlbumId,
onActivitiesPressed: handleActivities, onActivitiesPressed: handleActivities,
), ),
), ),

View File

@ -17,6 +17,6 @@ final favoriteAssetsProvider = StreamProvider<RenderList>((ref) {
.filter() .filter()
.isFavoriteEqualTo(true) .isFavoriteEqualTo(true)
.isTrashedEqualTo(false) .isTrashedEqualTo(false)
.sortByFileCreatedAt(); .sortByFileCreatedAtDesc();
return renderListGenerator(query, ref); return renderListGenerator(query, ref);
}); });

View File

@ -1,31 +1,16 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart'; import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
class FavoritesPage extends HookConsumerWidget { class FavoritesPage extends HookConsumerWidget {
const FavoritesPage({Key? key}) : super(key: key); const FavoritesPage({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final selectionEnabledHook = useState(false);
final selection = useState(<Asset>{});
final processing = useState(false);
void selectionListener(
bool multiselect,
Set<Asset> selectedAssets,
) {
selectionEnabledHook.value = multiselect;
selection.value = selectedAssets;
}
AppBar buildAppBar() { AppBar buildAppBar() {
return AppBar( return AppBar(
leading: IconButton( leading: IconButton(
@ -40,66 +25,14 @@ class FavoritesPage extends HookConsumerWidget {
); );
} }
void unfavorite() async {
try {
if (selection.value.isNotEmpty) {
await handleFavoriteAssets(
ref,
context,
selection.value.toList(),
shouldFavorite: false,
);
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
Widget buildBottomBar() {
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Card(
child: ListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
leading: const Icon(
Icons.star_border,
),
title: const Text(
"Unfavorite",
style: TextStyle(fontSize: 14),
),
onTap: processing.value ? null : unfavorite,
),
),
),
),
);
}
return Scaffold( return Scaffold(
appBar: buildAppBar(), appBar: ref.watch(multiselectProvider) ? null : buildAppBar(),
body: ref.watch(favoriteAssetsProvider).widgetWhen( body: MultiselectGrid(
onData: (data) => data.isEmpty renderListProvider: favoriteAssetsProvider,
? Center( favoriteEnabled: true,
child: Text('favorites_page_no_favorites'.tr()), editEnabled: true,
) unfavorite: true,
: Stack( ),
children: [
ImmichAssetGrid(
renderList: data,
selectionActive: selectionEnabledHook.value,
listener: selectionListener,
),
if (selectionEnabledHook.value) buildBottomBar(),
],
),
),
); );
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,57 +1,30 @@
import 'dart:async'; import 'dart:async';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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.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/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/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/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.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/user.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.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_app_bar.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.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 { class HomePage extends HookConsumerWidget {
const HomePage({Key? key}) : super(key: key); const HomePage({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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 currentUser = ref.watch(currentUserProvider);
final timelineUsers = ref.watch(timelineUsersIdsProvider); final timelineUsers = ref.watch(timelineUsersIdsProvider);
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final tipOneOpacity = useState(0.0); final tipOneOpacity = useState(0.0);
final refreshCount = useState(0); final refreshCount = useState(0);
final processing = useProcessingOverlay();
useEffect( useEffect(
() { () {
@ -61,394 +34,82 @@ class HomePage extends HookConsumerWidget {
ref.read(albumProvider.notifier).getAllAlbums(); ref.read(albumProvider.notifier).getAllAlbums();
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
ref.read(serverInfoProvider.notifier).getServerInfo(); ref.read(serverInfoProvider.notifier).getServerInfo();
return;
selectionEnabledHook.addListener(() {
multiselectEnabled.state = selectionEnabledHook.value;
});
return () {
// This does not work in tests
if (kReleaseMode) {
selectionEnabledHook.dispose();
}
};
}, },
[], [],
); );
Widget buildLoadingIndicator() {
Timer(const Duration(seconds: 2), () => tipOneOpacity.value = 1);
Widget buildBody() { return Center(
void selectionListener( child: Column(
bool multiselect, mainAxisAlignment: MainAxisAlignment.center,
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(
children: [ children: [
ref const ImmichLoadingIndicator(),
.watch( Padding(
timelineUsers.length > 1 padding: const EdgeInsets.only(top: 16.0),
? multiUserAssetsProvider(timelineUsers) child: Text(
: assetsProvider(currentUser?.isarId), 'home_page_building_timeline',
) style: TextStyle(
.when( fontWeight: FontWeight.w600,
data: (data) => data.isEmpty fontSize: 16,
? buildLoadingIndicator() color: context.primaryColor,
: ImmichAssetGrid( ),
renderList: data, ).tr(),
listener: selectionListener, ),
selectionActive: selectionEnabledHook.value, AnimatedOpacity(
onRefresh: refreshAssets, duration: const Duration(milliseconds: 500),
topWidget: opacity: tipOneOpacity.value,
(currentUser != null && currentUser.memoryEnabled) child: SizedBox(
? const MemoryLane() width: 250,
: const SizedBox(), child: Padding(
showStack: true, padding: const EdgeInsets.only(top: 8.0),
), child: const Text(
error: (error, _) => Center(child: Text(error.toString())), 'home_page_first_time_notice',
loading: buildLoadingIndicator, 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( return Scaffold(
appBar: !selectionEnabledHook.value ? const ImmichAppBar() : null, appBar: ref.watch(multiselectProvider) ? null : const ImmichAppBar(),
body: buildBody(), body: buildBody(),
); );
} }

View File

@ -1,11 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.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/partner/providers/partner.provider.dart'; import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
class PartnerDetailPage extends HookConsumerWidget { class PartnerDetailPage extends HookConsumerWidget {
@ -15,7 +15,6 @@ class PartnerDetailPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final assets = ref.watch(assetsProvider(partner.isarId));
final inTimeline = useState(partner.inTimeline); final inTimeline = useState(partner.inTimeline);
bool toggleInProcess = false; bool toggleInProcess = false;
@ -57,33 +56,30 @@ class PartnerDetailPage extends HookConsumerWidget {
} }
return Scaffold( return Scaffold(
appBar: AppBar( appBar: ref.watch(multiselectProvider)
title: Text(partner.name), ? null
elevation: 0, : AppBar(
centerTitle: false, title: Text(partner.name),
actions: [ elevation: 0,
IconButton( centerTitle: false,
onPressed: toggleInTimeline, actions: [
icon: Icon( IconButton(
inTimeline.value ? Icons.collections : Icons.collections_outlined, onPressed: toggleInTimeline,
icon: Icon(
inTimeline.value
? Icons.collections
: Icons.collections_outlined,
),
tooltip: "Show/hide photos on your main timeline",
),
],
), ),
tooltip: "Show/hide photos on your main timeline", body: MultiselectGrid(
), renderListProvider: assetsProvider(partner.isarId),
], onRefresh: () =>
), ref.read(assetProvider.notifier).getPartnerAssets(partner),
body: assets.widgetWhen( deleteEnabled: false,
onData: (renderList) => renderList.isEmpty favoriteEnabled: false,
? Padding(
padding: const EdgeInsets.all(16),
child: Text(
"It seems ${partner.name} does not have any photos...\n"
"Or your server version does not match the app version."),
)
: ImmichAssetGrid(
renderList: renderList,
onRefresh: () =>
ref.read(assetProvider.notifier).getPartnerAssets(partner),
),
), ),
); );
} }

View File

@ -72,8 +72,6 @@ class _$AppRouter extends RootStackRouter {
totalAssets: args.totalAssets, totalAssets: args.totalAssets,
heroOffset: args.heroOffset, heroOffset: args.heroOffset,
showStack: args.showStack, showStack: args.showStack,
isOwner: args.isOwner,
sharedAlbumId: args.sharedAlbumId,
), ),
transitionsBuilder: CustomTransitionsBuilders.zoomedPage, transitionsBuilder: CustomTransitionsBuilders.zoomedPage,
opaque: true, opaque: true,
@ -799,8 +797,6 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
required int totalAssets, required int totalAssets,
int heroOffset = 0, int heroOffset = 0,
bool showStack = false, bool showStack = false,
bool isOwner = true,
String? sharedAlbumId,
}) : super( }) : super(
GalleryViewerRoute.name, GalleryViewerRoute.name,
path: '/gallery-viewer-page', path: '/gallery-viewer-page',
@ -811,8 +807,6 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
totalAssets: totalAssets, totalAssets: totalAssets,
heroOffset: heroOffset, heroOffset: heroOffset,
showStack: showStack, showStack: showStack,
isOwner: isOwner,
sharedAlbumId: sharedAlbumId,
), ),
); );
@ -827,8 +821,6 @@ class GalleryViewerRouteArgs {
required this.totalAssets, required this.totalAssets,
this.heroOffset = 0, this.heroOffset = 0,
this.showStack = false, this.showStack = false,
this.isOwner = true,
this.sharedAlbumId,
}); });
final Key? key; final Key? key;
@ -843,13 +835,9 @@ class GalleryViewerRouteArgs {
final bool showStack; final bool showStack;
final bool isOwner;
final String? sharedAlbumId;
@override @override
String toString() { String toString() {
return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack, isOwner: $isOwner, sharedAlbumId: $sharedAlbumId}'; return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack}';
} }
} }

View File

@ -1,5 +1,4 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/user.dart';
@ -43,11 +42,6 @@ class Album {
final IsarLinks<User> sharedUsers = IsarLinks<User>(); final IsarLinks<User> sharedUsers = IsarLinks<User>();
final IsarLinks<Asset> assets = IsarLinks<Asset>(); final IsarLinks<Asset> assets = IsarLinks<Asset>();
RenderList _renderList = RenderList.empty();
@ignore
RenderList get renderList => _renderList;
@ignore @ignore
bool get isRemote => remoteId != null; bool get isRemote => remoteId != null;
@ -75,17 +69,6 @@ class Album {
return name.join(' '); return name.join(' ');
} }
Stream<void> watchRenderList(GroupAssetsBy groupAssetsBy) async* {
final query =
assets.filter().isTrashedEqualTo(false).sortByFileCreatedAtDesc();
_renderList = await RenderList.fromQuery(query, groupAssetsBy);
yield _renderList;
await for (final _ in query.watchLazy()) {
_renderList = await RenderList.fromQuery(query, groupAssetsBy);
yield _renderList;
}
}
@override @override
bool operator ==(other) { bool operator ==(other) {
if (other is! Album) return false; if (other is! Album) return false;

View File

@ -202,7 +202,8 @@ class AssetNotifier extends StateNotifier<bool> {
return isSuccess ? remote : []; return isSuccess ? remote : [];
} }
Future<void> toggleFavorite(List<Asset> assets, bool status) async { Future<void> toggleFavorite(List<Asset> assets, [bool? status]) async {
status ??= !assets.every((a) => a.isFavorite);
final newAssets = await _assetService.changeFavoriteStatus(assets, status); final newAssets = await _assetService.changeFavoriteStatus(assets, status);
for (Asset? newAsset in newAssets) { for (Asset? newAsset in newAssets) {
if (newAsset == null) { if (newAsset == null) {
@ -212,7 +213,8 @@ class AssetNotifier extends StateNotifier<bool> {
} }
} }
Future<void> toggleArchive(List<Asset> assets, bool status) async { Future<void> toggleArchive(List<Asset> assets, [bool? status]) async {
status ??= assets.every((a) => a.isArchived);
final newAssets = await _assetService.changeArchiveStatus(assets, status); final newAssets = await _assetService.changeArchiveStatus(assets, status);
int i = 0; int i = 0;
for (Asset oldAsset in assets) { for (Asset oldAsset in assets) {

View File

@ -0,0 +1,419 @@
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/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/server_info.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({
Key? 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,
}) : super(key: key);
final ProviderListenable<AsyncValue<RenderList>> renderListProvider;
final Future<void> Function()? onRefresh;
final Widget Function()? buildLoadingIndicator;
final Future<bool> Function(Iterable<Asset>)? 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(<Asset>{});
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<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 trashEnabled =
ref.read(serverInfoProvider.select((v) => v.serverFeatures.trash));
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 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<Asset> 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.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<T> Function() wrapLongRunningFun<T>(Future<T> Function() fun) =>
() async {
processing.value = true;
try {
final result = await fun();
if (result.runtimeType != bool || result == true) {
selectionEnabledHook.value = false;
}
return result;
} finally {
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!),
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,
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,
),
],
),
);
}
}

View File

@ -45,10 +45,11 @@ Future<void> handleArchiveAssets(
WidgetRef ref, WidgetRef ref,
BuildContext context, BuildContext context,
List<Asset> selection, { List<Asset> selection, {
bool shouldArchive = true, bool? shouldArchive,
ToastGravity toastGravity = ToastGravity.BOTTOM, ToastGravity toastGravity = ToastGravity.BOTTOM,
}) async { }) async {
if (selection.isNotEmpty) { if (selection.isNotEmpty) {
shouldArchive ??= !selection.every((a) => a.isArchived);
await ref await ref
.read(assetProvider.notifier) .read(assetProvider.notifier)
.toggleArchive(selection, shouldArchive); .toggleArchive(selection, shouldArchive);
@ -69,10 +70,11 @@ Future<void> handleFavoriteAssets(
WidgetRef ref, WidgetRef ref,
BuildContext context, BuildContext context,
List<Asset> selection, { List<Asset> selection, {
bool shouldFavorite = true, bool? shouldFavorite,
ToastGravity toastGravity = ToastGravity.BOTTOM, ToastGravity toastGravity = ToastGravity.BOTTOM,
}) async { }) async {
if (selection.isNotEmpty) { if (selection.isNotEmpty) {
shouldFavorite ??= !selection.every((a) => a.isFavorite);
await ref await ref
.watch(assetProvider.notifier) .watch(assetProvider.notifier)
.toggleFavorite(selection, shouldFavorite); .toggleFavorite(selection, shouldFavorite);