Browse Source

feat(mobile): Add selected assets to album (#901)

* First implementation that uses new API

* Various UI improvements

* Create new album from home screen

* Fix padding when in multiselect mode

* Alex Suggestions

* Change to album after creation
Matthias Rupp 2 years ago
parent
commit
b5751a3fa8

+ 7 - 1
mobile/assets/i18n/en-US.json

@@ -171,5 +171,11 @@
   "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
   "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
   "experimental_settings_title": "Experimental",
-  "experimental_settings_subtitle": "Use at your own risk!"
+  "experimental_settings_subtitle": "Use at your own risk!",
+  "control_bottom_app_bar_add_to_album": "Add to album",
+  "home_page_add_to_album_success": "Added {added} assets to album {album}.",
+  "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
+  "control_bottom_app_bar_album_info": "{} items",
+  "control_bottom_app_bar_album_info_shared": "{} items · Shared",
+  "control_bottom_app_bar_create_new_album": "Create new album"
 }

+ 28 - 3
mobile/lib/modules/album/services/album.service.dart

@@ -46,6 +46,31 @@ class AlbumService {
     }
   }
 
+  /*
+   * Creates names like Untitled, Untitled (1), Untitled (2), ...
+   */
+  String _getNextAlbumName(List<AlbumResponseDto>? albums) {
+    const baseName = "Untitled";
+
+    if (albums != null) {
+      for (int round = 0; round < albums.length; round++) {
+        final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
+
+        if (albums.where((a) => a.albumName == proposedName).isEmpty) {
+          return proposedName;
+        }
+      }
+    }
+    return baseName;
+  }
+
+  Future<AlbumResponseDto?> createAlbumWithGeneratedName(
+    Set<AssetResponseDto> assets,
+  ) async {
+    return createAlbum(
+        _getNextAlbumName(await getAlbums(isShared: false)), assets, []);
+  }
+
   Future<AlbumResponseDto?> getAlbumDetail(String albumId) async {
     try {
       return await _apiService.albumApi.getAlbumInfo(albumId);
@@ -55,7 +80,7 @@ class AlbumService {
     }
   }
 
-  Future<bool> addAdditionalAssetToAlbum(
+  Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
     Set<AssetResponseDto> assets,
     String albumId,
   ) async {
@@ -64,10 +89,10 @@ class AlbumService {
         albumId,
         AddAssetsDto(assetIds: assets.map((asset) => asset.id).toList()),
       );
-      return result != null;
+      return result;
     } catch (e) {
       debugPrint("Error addAdditionalAssetToAlbum  ${e.toString()}");
-      return false;
+      return null;
     }
   }
 

+ 2 - 2
mobile/lib/modules/album/views/album_viewer_page.dart

@@ -53,13 +53,13 @@ class AlbumViewerPage extends HookConsumerWidget {
         if (returnPayload.selectedAdditionalAsset.isNotEmpty) {
           ImmichLoadingOverlayController.appLoader.show();
 
-          var isSuccess =
+          var addAssetsResult =
               await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum(
                     returnPayload.selectedAdditionalAsset,
                     albumId,
                   );
 
-          if (isSuccess) {
+          if (addAssetsResult != null && addAssetsResult.successfullyAdded > 0) {
             ref.refresh(sharedAlbumDetailProvider(albumId));
           }
 

+ 1 - 1
mobile/lib/modules/home/ui/asset_grid/disable_multi_select_button.dart

@@ -14,7 +14,7 @@ class DisableMultiSelectButton extends ConsumerWidget {
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     return Padding(
-        padding: const EdgeInsets.only(left: 16.0, top: 15),
+        padding: const EdgeInsets.only(left: 16.0, top: 16.0),
         child: Padding(
           padding: const EdgeInsets.symmetric(horizontal: 4.0),
           child: ElevatedButton.icon(

+ 134 - 35
mobile/lib/modules/home/ui/control_bottom_app_bar.dart

@@ -1,62 +1,161 @@
+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:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
+import 'package:immich_mobile/utils/image_url_builder.dart';
+import 'package:openapi/api.dart';
 
 class ControlBottomAppBar extends ConsumerWidget {
   final Function onShare;
   final Function onDelete;
+  final Function(AlbumResponseDto album) onAddToAlbum;
+  final void Function() onCreateNewAlbum;
 
-  const ControlBottomAppBar(
-      {Key? key, required this.onShare, required this.onDelete})
-      : super(key: key);
+  final List<AlbumResponseDto> albums;
+
+  const ControlBottomAppBar({
+    Key? key,
+    required this.onShare,
+    required this.onDelete,
+    required this.albums,
+    required this.onAddToAlbum,
+    required this.onCreateNewAlbum,
+  }) : super(key: key);
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
+    Widget renderActionButtons() {
+      return Padding(
+        padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
+        child: Row(
+          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+          children: [
+            ControlBoxButton(
+              iconData: Icons.delete_forever_rounded,
+              label: "control_bottom_app_bar_delete".tr(),
+              onPressed: () {
+                showDialog(
+                  context: context,
+                  builder: (BuildContext context) {
+                    return DeleteDialog(
+                      onDelete: onDelete,
+                    );
+                  },
+                );
+              },
+            ),
+            ControlBoxButton(
+              iconData: Icons.share,
+              label: "control_bottom_app_bar_share".tr(),
+              onPressed: () {
+                onShare();
+              },
+            ),
+          ],
+        ),
+      );
+    }
+
+    Widget renderAlbums() {
+      Widget renderAlbum(AlbumResponseDto album) {
+        final box = Hive.box(userInfoBox);
+
+        return GestureDetector(
+          onTap: () => onAddToAlbum(album),
+          child: Container(
+            width: 112,
+            padding: const EdgeInsets.all(6),
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                ClipRRect(
+                  borderRadius: BorderRadius.circular(8),
+                  child: CachedNetworkImage(
+                    width: 100,
+                    height: 100,
+                    fit: BoxFit.cover,
+                    imageUrl:
+                        getAlbumThumbnailUrl(album, type: ThumbnailFormat.JPEG),
+                    httpHeaders: {
+                      "Authorization": "Bearer ${box.get(accessTokenKey)}"
+                    },
+                    cacheKey: "${album.albumThumbnailAssetId}",
+                  ),
+                ),
+                Padding(
+                  padding: const EdgeInsets.only(top: 12),
+                  child: Text(
+                    album.albumName,
+                    style: TextStyle(fontWeight: FontWeight.bold),
+                  ),
+                ),
+                Text(album.shared
+                        ? "control_bottom_app_bar_album_info_shared"
+                        : "control_bottom_app_bar_album_info")
+                    .tr(args: [album.assetCount.toString()]),
+              ],
+            ),
+          ),
+        );
+      }
+
+      return SizedBox(
+        height: 200,
+        child: ListView.builder(
+          scrollDirection: Axis.horizontal,
+          itemBuilder: (buildContext, i) => renderAlbum(albums[i]),
+          itemCount: albums.length,
+        ),
+      );
+    }
+
     return Positioned(
       bottom: 0,
       left: 0,
       child: Container(
         width: MediaQuery.of(context).size.width,
-        height: MediaQuery.of(context).size.height * 0.15,
         decoration: BoxDecoration(
           borderRadius: const BorderRadius.only(
-            topLeft: Radius.circular(8),
-            topRight: Radius.circular(8),
+            topLeft: Radius.circular(10),
+            topRight: Radius.circular(10),
           ),
-          color: Theme.of(context).scaffoldBackgroundColor.withOpacity(0.95),
+          color: Theme.of(context).scaffoldBackgroundColor,
         ),
         child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
           children: [
+            renderActionButtons(),
+            const Divider(
+              thickness: 2,
+            ),
             Padding(
-              padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
-              child: Row(
-                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
-                children: [
-                  ControlBoxButton(
-                    iconData: Icons.delete_forever_rounded,
-                    label: "control_bottom_app_bar_delete".tr(),
-                    onPressed: () {
-                      showDialog(
-                        context: context,
-                        builder: (BuildContext context) {
-                          return DeleteDialog(
-                            onDelete: onDelete,
-                          );
-                        },
-                      );
-                    },
-                  ),
-                  ControlBoxButton(
-                    iconData: Icons.share,
-                    label: "control_bottom_app_bar_share".tr(),
-                    onPressed: () {
-                      onShare();
-                    },
-                  ),
-                ],
-              ),
-            )
+                padding: const EdgeInsets.all(12),
+                child: Row(
+                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                  children: [
+                    const Text(
+                      "control_bottom_app_bar_add_to_album",
+                      style: TextStyle(
+                        fontSize: 16,
+                        fontWeight: FontWeight.bold,
+                      ),
+                    ).tr(),
+                    TextButton(
+                      onPressed: onCreateNewAlbum,
+                      child: Text(
+                        "control_bottom_app_bar_create_new_album",
+                        style: TextStyle(
+                          color: Theme.of(context).primaryColor,
+                          fontWeight: FontWeight.bold,
+                        ),
+                      ).tr(),
+                    ),
+                  ],
+                )),
+            renderAlbums(),
           ],
         ),
       ),

+ 80 - 18
mobile/lib/modules/home/views/home_page.dart

@@ -1,6 +1,11 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.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/shared_album.provider.dart';
+import 'package:immich_mobile/modules/album/services/album.service.dart';
 import 'package:immich_mobile/modules/home/providers/home_page_render_list_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';
@@ -9,10 +14,12 @@ import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
 import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.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/routing/router.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/websocket.provider.dart';
 import 'package:immich_mobile/shared/services/share.service.dart';
+import 'package:immich_mobile/shared/ui/immich_toast.dart';
 import 'package:openapi/api.dart';
 
 class HomePage extends HookConsumerWidget {
@@ -23,14 +30,26 @@ class HomePage extends HookConsumerWidget {
     final appSettingService = ref.watch(appSettingsServiceProvider);
     var renderList = ref.watch(renderListProvider);
     final multiselectEnabled = ref.watch(multiselectProvider.notifier);
+    final selectionEnabledHook = useState(false);
+
     final selection = useState(<AssetResponseDto>{});
+    final albums = ref.watch(albumProvider);
+    final albumService = ref.watch(albumServiceProvider);
 
     useEffect(
       () {
         ref.read(websocketProvider.notifier).connect();
         ref.read(assetProvider.notifier).getAllAsset();
+        ref.read(albumProvider.notifier).getAllAlbums();
         ref.watch(serverInfoProvider.notifier).getServerVersion();
-        return null;
+
+        selectionEnabledHook.addListener(() {
+          multiselectEnabled.state = selectionEnabledHook.value;
+        });
+
+        return () {
+          selectionEnabledHook.dispose();
+        };
       },
       [],
     );
@@ -44,41 +63,81 @@ class HomePage extends HookConsumerWidget {
         bool multiselect,
         Set<AssetResponseDto> selectedAssets,
       ) {
-        multiselectEnabled.state = multiselect;
+        selectionEnabledHook.value = multiselect;
         selection.value = selectedAssets;
       }
 
       void onShareAssets() {
         ref.watch(shareServiceProvider).shareAssets(selection.value.toList());
-        multiselectEnabled.state = false;
+        selectionEnabledHook.value = false;
       }
 
       void onDelete() {
         ref.watch(assetProvider.notifier).deleteAssets(selection.value);
-        multiselectEnabled.state = false;
+        selectionEnabledHook.value = false;
+      }
+
+      void onAddToAlbum(AlbumResponseDto album) async {
+        final result = await albumService.addAdditionalAssetToAlbum(
+            selection.value, album.id);
+
+        if (result != null) {
+
+          if (result.alreadyInAlbum.isNotEmpty) {
+            ImmichToast.show(
+              context: context,
+              msg: "home_page_add_to_album_conflicts".tr(
+                namedArgs: {
+                  "album": album.albumName,
+                  "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.albumName,
+                  "added": result.successfullyAdded.toString(),
+                },
+              ),
+            );
+          }
+
+          selectionEnabledHook.value = false;
+        }
+      }
+
+      void onCreateNewAlbum() async {
+        final result =
+            await albumService.createAlbumWithGeneratedName(selection.value);
+
+        if (result != null) {
+          ref.watch(albumProvider.notifier).getAllAlbums();
+          selectionEnabledHook.value = false;
+
+          AutoRouter.of(context).push(AlbumViewerRoute(albumId: result.id));
+        }
       }
 
       return SafeArea(
         bottom: !multiselectEnabled.state,
-        top: !multiselectEnabled.state,
+        top: true,
         child: Stack(
           children: [
             CustomScrollView(
               slivers: [
-                multiselectEnabled.state
-                    ? const SliverToBoxAdapter(
-                        child: SizedBox(
-                          height: 70,
-                          child: null,
-                        ),
-                      )
-                    : ImmichSliverAppBar(
-                        onPopBack: reloadAllAsset,
-                      ),
+                if (!multiselectEnabled.state)
+                ImmichSliverAppBar(
+                  onPopBack: reloadAllAsset,
+                ),
               ],
             ),
             Padding(
-              padding: const EdgeInsets.only(top: 60.0, bottom: 0.0),
+              padding: EdgeInsets.only(
+                  top: selectionEnabledHook.value ? 0 : 60, bottom: 0.0),
               child: ImmichAssetGrid(
                 renderList: renderList,
                 assetsPerRow:
@@ -86,13 +145,16 @@ class HomePage extends HookConsumerWidget {
                 showStorageIndicator: appSettingService
                     .getSetting(AppSettingsEnum.storageIndicator),
                 listener: selectionListener,
-                selectionActive: multiselectEnabled.state,
+                selectionActive: selectionEnabledHook.value,
               ),
             ),
-            if (multiselectEnabled.state) ...[
+            if (selectionEnabledHook.value) ...[
               ControlBottomAppBar(
                 onShare: onShareAssets,
                 onDelete: onDelete,
+                onAddToAlbum: onAddToAlbum,
+                albums: albums,
+                onCreateNewAlbum: onCreateNewAlbum,
               ),
             ],
           ],