From d377cf0d0272d26870b5fa15fc446efb03197e3c Mon Sep 17 00:00:00 2001
From: martyfuhry <martyfuhry@gmail.com>
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<Asset>? 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<VideoViewerRouteArgs>();
@@ -87,7 +88,9 @@ class _$AppRouter extends RootStackRouter {
       return MaterialPageX<dynamic>(
           routeData: routeData,
           child: CreateAlbumPage(
-              key: args.key, isSharedAlbum: args.isSharedAlbum));
+              key: args.key,
+              isSharedAlbum: args.isSharedAlbum,
+              initialAssets: args.initialAssets));
     },
     AssetSelectionRoute.name: (routeData) {
       return CustomPage<AssetSelectionPageResult?>(
@@ -307,7 +310,8 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
       required void Function() isZoomedFunction,
       required ValueNotifier<bool> 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<ImageViewerRouteArgs> {
                 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<CreateAlbumRouteArgs> {
-  CreateAlbumRoute({Key? key, required bool isSharedAlbum})
+  CreateAlbumRoute(
+      {Key? key, required bool isSharedAlbum, List<Asset>? 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<Asset>? initialAssets;
+
   @override
   String toString() {
-    return 'CreateAlbumRouteArgs{key: $key, isSharedAlbum: $isSharedAlbum}';
+    return 'CreateAlbumRouteArgs{key: $key, isSharedAlbum: $isSharedAlbum, initialAssets: $initialAssets}';
   }
 }