From d377cf0d0272d26870b5fa15fc446efb03197e3c Mon Sep 17 00:00:00 2001 From: martyfuhry Date: Fri, 27 Jan 2023 00:16:28 -0500 Subject: [PATCH] feat(mobile): Add to album from asset detail view (#1413) * add to album from asset detail view * layout and design * added shared albums * fixed remote, asset update, and hit test * made static size * fixed create album * suppress shared expansion tile if there are no shared albums * updates album * padding on tile --- .../modules/album/ui/add_to_album_list.dart | 129 ++++++++++++++++++ .../album/ui/album_thumbnail_listtile.dart | 115 ++++++++++++++++ .../album/views/create_album_page.dart | 10 +- .../asset_viewer/ui/top_control_app_bar.dart | 14 ++ .../asset_viewer/views/gallery_viewer.dart | 18 +++ mobile/lib/routing/router.gr.dart | 35 +++-- 6 files changed, 309 insertions(+), 12 deletions(-) create mode 100644 mobile/lib/modules/album/ui/add_to_album_list.dart create mode 100644 mobile/lib/modules/album/ui/album_thumbnail_listtile.dart diff --git a/mobile/lib/modules/album/ui/add_to_album_list.dart b/mobile/lib/modules/album/ui/add_to_album_list.dart new file mode 100644 index 0000000000..30cb064022 --- /dev/null +++ b/mobile/lib/modules/album/ui/add_to_album_list.dart @@ -0,0 +1,129 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/album/providers/album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/asset_selection.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/ui/album_thumbnail_listtile.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/ui/drag_sheet.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; +import 'package:openapi/api.dart'; + +class AddToAlbumList extends HookConsumerWidget { + + /// The asset to add to an album + final Asset asset; + + const AddToAlbumList({ + Key? key, + required this.asset, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = ref.watch(albumProvider); + final albumService = ref.watch(albumServiceProvider); + final sharedAlbums = ref.watch(sharedAlbumProvider); + + useEffect( + () { + // Fetch album updates, e.g., cover image + ref.read(albumProvider.notifier).getAllAlbums(); + ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); + + return null; + }, + [], + ); + + void addToAlbum(AlbumResponseDto album) async { + final result = await albumService.addAdditionalAssetToAlbum( + [asset], + album.id, + ); + + if (result != null) { + if (result.alreadyInAlbum.isNotEmpty) { + ImmichToast.show( + context: context, + msg: 'Already in ${album.albumName}', + ); + } else { + ImmichToast.show( + context: context, + msg: 'Added to ${album.albumName}', + ); + } + } + + ref.read(albumProvider.notifier).getAllAlbums(); + ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); + + Navigator.pop(context); + } + + return Card( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(15), + topRight: Radius.circular(15), + ), + ), + child: ListView( + padding: const EdgeInsets.all(18.0), + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Align( + alignment: Alignment.center, + child: CustomDraggingHandle(), + ), + const SizedBox(height: 12), + Text('Add to album', + style: Theme.of(context).textTheme.headline1, + ), + TextButton.icon( + icon: const Icon(Icons.add), + label: const Text('New album'), + onPressed: () { + ref.watch(assetSelectionProvider.notifier).removeAll(); + ref.watch(assetSelectionProvider.notifier).addNewAssets([asset]); + AutoRouter.of(context).push( + CreateAlbumRoute( + isSharedAlbum: false, + initialAssets: [asset], + ), + ); + }, + ), + ], + ), + if (sharedAlbums.isNotEmpty) + ExpansionTile( + title: const Text('Shared'), + tilePadding: const EdgeInsets.symmetric(horizontal: 10.0), + leading: const Icon(Icons.group), + children: sharedAlbums.map((album) => + AlbumThumbnailListTile( + album: album, + onTap: () => addToAlbum(album), + ), + ).toList(), + ), + const SizedBox(height: 12), + ... albums.map((album) => + AlbumThumbnailListTile( + album: album, + onTap: () => addToAlbum(album), + ), + ).toList(), + ], + ), + ); + } +} diff --git a/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart b/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart new file mode 100644 index 0000000000..2366924a85 --- /dev/null +++ b/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart @@ -0,0 +1,115 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:openapi/api.dart'; + +class AlbumThumbnailListTile extends StatelessWidget { + const AlbumThumbnailListTile({ + Key? key, + required this.album, + this.onTap, + }) : super(key: key); + + final AlbumResponseDto album; + final void Function()? onTap; + + @override + Widget build(BuildContext context) { + var box = Hive.box(userInfoBox); + var cardSize = 68.0; + var isDarkMode = Theme.of(context).brightness == Brightness.dark; + + buildEmptyThumbnail() { + return Container( + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[800] : Colors.grey[200], + ), + child: SizedBox( + height: cardSize, + width: cardSize, + child: const Center( + child: Icon(Icons.no_photography), + ), + ), + ); + } + + buildAlbumThumbnail() { + return CachedNetworkImage( + width: cardSize, + height: cardSize, + fit: BoxFit.cover, + fadeInDuration: const Duration(milliseconds: 200), + imageUrl: getAlbumThumbnailUrl( + album, + type: ThumbnailFormat.JPEG, + ), + httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, + cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG), + ); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap ?? () { + AutoRouter.of(context).push(AlbumViewerRoute(albumId: album.id)); + }, + child: Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: album.albumThumbnailAssetId == null + ? buildEmptyThumbnail() + : buildAlbumThumbnail(), + ), + Padding( + padding: const EdgeInsets.only( + left: 8.0, + right: 8.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + album.albumName, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + album.assetCount == 1 + ? 'album_thumbnail_card_item' + : 'album_thumbnail_card_items', + style: const TextStyle( + fontSize: 12, + ), + ).tr(args: ['${album.assetCount}']), + if (album.shared) + const Text( + 'album_thumbnail_card_shared', + style: TextStyle( + fontSize: 12, + ), + ).tr() + ], + ) + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/album/views/create_album_page.dart b/mobile/lib/modules/album/views/create_album_page.dart index 18d3d978a8..fa3db46968 100644 --- a/mobile/lib/modules/album/views/create_album_page.dart +++ b/mobile/lib/modules/album/views/create_album_page.dart @@ -11,12 +11,18 @@ import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart'; import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; // ignore: must_be_immutable class CreateAlbumPage extends HookConsumerWidget { - bool isSharedAlbum; + final bool isSharedAlbum; + final List? initialAssets; - CreateAlbumPage({Key? key, required this.isSharedAlbum}) : super(key: key); + const CreateAlbumPage({ + Key? key, + required this.isSharedAlbum, + this.initialAssets, + }) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart index 0d45719dfe..a5c80dde89 100644 --- a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart +++ b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart @@ -11,6 +11,7 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget { required this.onDownloadPressed, required this.onSharePressed, required this.onDeletePressed, + required this.onAddToAlbumPressed, required this.onToggleMotionVideo, required this.isPlayingMotionVideo, }) : super(key: key); @@ -20,6 +21,7 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget { final VoidCallback? onDownloadPressed; final VoidCallback onToggleMotionVideo; final VoidCallback onDeletePressed; + final VoidCallback onAddToAlbumPressed; final Function onSharePressed; final bool isPlayingMotionVideo; @@ -80,6 +82,18 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget { color: Colors.grey[200], ), ), + if (asset.isRemote) + IconButton( + iconSize: iconSize, + splashRadius: iconSize, + onPressed: () { + onAddToAlbumPressed(); + }, + icon: Icon( + Icons.add, + color: Colors.grey[200], + ), + ), IconButton( iconSize: iconSize, splashRadius: iconSize, diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 000d85c699..22375da341 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -5,6 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/modules/album/ui/add_to_album_list.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; @@ -105,6 +106,22 @@ class GalleryViewerPage extends HookConsumerWidget { ); } + void addToAlbum(Asset addToAlbumAsset) { + showModalBottomSheet( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15.0), + ), + barrierColor: Colors.transparent, + backgroundColor: Colors.transparent, + context: context, + builder: (BuildContext _) { + return AddToAlbumList( + asset: addToAlbumAsset, + ); + }, + ); + } + return Scaffold( backgroundColor: Colors.black, appBar: TopControlAppBar( @@ -130,6 +147,7 @@ class GalleryViewerPage extends HookConsumerWidget { isPlayingMotionVideo.value = !isPlayingMotionVideo.value; }), onDeletePressed: () => handleDelete((assetList[indexOfAsset.value])), + onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]), ), body: SafeArea( child: PageView.builder( diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index d2100398f7..897b532225 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -60,7 +60,8 @@ class _$AppRouter extends RootStackRouter { isZoomedFunction: args.isZoomedFunction, isZoomedListener: args.isZoomedListener, loadPreview: args.loadPreview, - loadOriginal: args.loadOriginal)); + loadOriginal: args.loadOriginal, + showExifSheet: args.showExifSheet)); }, VideoViewerRoute.name: (routeData) { final args = routeData.argsAs(); @@ -87,7 +88,9 @@ class _$AppRouter extends RootStackRouter { return MaterialPageX( routeData: routeData, child: CreateAlbumPage( - key: args.key, isSharedAlbum: args.isSharedAlbum)); + key: args.key, + isSharedAlbum: args.isSharedAlbum, + initialAssets: args.initialAssets)); }, AssetSelectionRoute.name: (routeData) { return CustomPage( @@ -307,7 +310,8 @@ class ImageViewerRoute extends PageRouteInfo { required void Function() isZoomedFunction, required ValueNotifier isZoomedListener, required bool loadPreview, - required bool loadOriginal}) + required bool loadOriginal, + void Function()? showExifSheet}) : super(ImageViewerRoute.name, path: '/image-viewer-page', args: ImageViewerRouteArgs( @@ -318,7 +322,8 @@ class ImageViewerRoute extends PageRouteInfo { isZoomedFunction: isZoomedFunction, isZoomedListener: isZoomedListener, loadPreview: loadPreview, - loadOriginal: loadOriginal)); + loadOriginal: loadOriginal, + showExifSheet: showExifSheet)); static const String name = 'ImageViewerRoute'; } @@ -332,7 +337,8 @@ class ImageViewerRouteArgs { required this.isZoomedFunction, required this.isZoomedListener, required this.loadPreview, - required this.loadOriginal}); + required this.loadOriginal, + this.showExifSheet}); final Key? key; @@ -350,9 +356,11 @@ class ImageViewerRouteArgs { final bool loadOriginal; + final void Function()? showExifSheet; + @override String toString() { - return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, loadPreview: $loadPreview, loadOriginal: $loadOriginal}'; + return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, loadPreview: $loadPreview, loadOriginal: $loadOriginal, showExifSheet: $showExifSheet}'; } } @@ -432,24 +440,31 @@ class SearchResultRouteArgs { /// generated route for /// [CreateAlbumPage] class CreateAlbumRoute extends PageRouteInfo { - CreateAlbumRoute({Key? key, required bool isSharedAlbum}) + CreateAlbumRoute( + {Key? key, required bool isSharedAlbum, List? initialAssets}) : super(CreateAlbumRoute.name, path: '/create-album-page', - args: CreateAlbumRouteArgs(key: key, isSharedAlbum: isSharedAlbum)); + args: CreateAlbumRouteArgs( + key: key, + isSharedAlbum: isSharedAlbum, + initialAssets: initialAssets)); static const String name = 'CreateAlbumRoute'; } class CreateAlbumRouteArgs { - const CreateAlbumRouteArgs({this.key, required this.isSharedAlbum}); + const CreateAlbumRouteArgs( + {this.key, required this.isSharedAlbum, this.initialAssets}); final Key? key; final bool isSharedAlbum; + final List? initialAssets; + @override String toString() { - return 'CreateAlbumRouteArgs{key: $key, isSharedAlbum: $isSharedAlbum}'; + return 'CreateAlbumRouteArgs{key: $key, isSharedAlbum: $isSharedAlbum, initialAssets: $initialAssets}'; } }