Browse Source

feat: manual stack assets (#4198)

shenlong 1 year ago
parent
commit
cf08ac7538
59 changed files with 2190 additions and 138 deletions
  1. 137 0
      cli/src/api/open-api/api.ts
  2. 7 1
      mobile/assets/i18n/en-US.json
  3. 3 1
      mobile/lib/modules/album/views/album_viewer_page.dart
  4. 11 10
      mobile/lib/modules/album/views/asset_selection_page.dart
  5. 5 2
      mobile/lib/modules/album/views/create_album_page.dart
  6. 50 0
      mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart
  7. 17 0
      mobile/lib/modules/asset_viewer/providers/render_list.provider.dart
  8. 72 0
      mobile/lib/modules/asset_viewer/services/asset_stack.service.dart
  9. 252 58
      mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
  10. 47 0
      mobile/lib/modules/home/models/selection_state.dart
  11. 3 0
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
  12. 11 6
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart
  13. 35 2
      mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart
  14. 19 7
      mobile/lib/modules/home/ui/control_bottom_app_bar.dart
  15. 58 4
      mobile/lib/modules/home/views/home_page.dart
  16. 1 0
      mobile/lib/modules/trash/views/trash_page.dart
  17. 1 0
      mobile/lib/routing/router.dart
  18. 19 7
      mobile/lib/routing/router.gr.dart
  19. 29 4
      mobile/lib/shared/models/asset.dart
  20. 308 11
      mobile/lib/shared/models/asset.g.dart
  21. 37 11
      mobile/lib/shared/providers/asset.provider.dart
  22. 1 0
      mobile/lib/shared/providers/websocket.provider.dart
  23. 3 0
      mobile/openapi/.openapi-generator/FILES
  24. 2 0
      mobile/openapi/README.md
  25. 55 0
      mobile/openapi/doc/AssetApi.md
  26. 2 0
      mobile/openapi/doc/AssetBulkUpdateDto.md
  27. 3 0
      mobile/openapi/doc/AssetResponseDto.md
  28. 16 0
      mobile/openapi/doc/UpdateStackParentDto.md
  29. 1 0
      mobile/openapi/lib/api.dart
  30. 39 0
      mobile/openapi/lib/api/asset_api.dart
  31. 2 0
      mobile/openapi/lib/api_client.dart
  32. 37 3
      mobile/openapi/lib/model/asset_bulk_update_dto.dart
  33. 27 1
      mobile/openapi/lib/model/asset_response_dto.dart
  34. 106 0
      mobile/openapi/lib/model/update_stack_parent_dto.dart
  35. 5 0
      mobile/openapi/test/asset_api_test.dart
  36. 10 0
      mobile/openapi/test/asset_bulk_update_dto_test.dart
  37. 15 0
      mobile/openapi/test/asset_response_dto_test.dart
  38. 32 0
      mobile/openapi/test/update_stack_parent_dto_test.dart
  39. 1 0
      mobile/test/asset_grid_data_structure_test.dart
  40. 1 0
      mobile/test/sync_service_test.dart
  41. 73 0
      server/immich-openapi-specs.json
  42. 163 2
      server/src/domain/asset/asset.service.spec.ts
  43. 49 2
      server/src/domain/asset/asset.service.ts
  44. 9 0
      server/src/domain/asset/dto/asset-stack.dto.ts
  45. 9 1
      server/src/domain/asset/dto/asset.dto.ts
  46. 1 0
      server/src/domain/asset/dto/index.ts
  47. 15 1
      server/src/domain/asset/response-dto/asset-response.dto.ts
  48. 1 0
      server/src/domain/repositories/communication.repository.ts
  49. 1 1
      server/src/domain/shared-link/shared-link-response.dto.ts
  50. 2 0
      server/src/immich/api-v1/asset/asset-repository.ts
  51. 2 2
      server/src/immich/api-v1/asset/asset.service.ts
  52. 7 0
      server/src/immich/controllers/asset.controller.ts
  53. 10 0
      server/src/infra/entities/asset.entity.ts
  54. 16 0
      server/src/infra/migrations/1695354433573-AddStackParentIdToAssets.ts
  55. 8 0
      server/src/infra/repositories/asset.repository.ts
  56. 163 0
      server/test/e2e/asset.e2e-spec.ts
  57. 43 1
      server/test/fixtures/asset.stub.ts
  58. 1 0
      server/test/fixtures/shared-link.stub.ts
  59. 137 0
      web/src/api/open-api/api.ts

+ 137 - 0
cli/src/api/open-api/api.ts

@@ -399,6 +399,18 @@ export interface AssetBulkUpdateDto {
      * @memberof AssetBulkUpdateDto
      * @memberof AssetBulkUpdateDto
      */
      */
     'isFavorite'?: boolean;
     'isFavorite'?: boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetBulkUpdateDto
+     */
+    'removeParent'?: boolean;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetBulkUpdateDto
+     */
+    'stackParentId'?: string;
 }
 }
 /**
 /**
  * 
  * 
@@ -748,6 +760,24 @@ export interface AssetResponseDto {
      * @memberof AssetResponseDto
      * @memberof AssetResponseDto
      */
      */
     'smartInfo'?: SmartInfoResponseDto;
     'smartInfo'?: SmartInfoResponseDto;
+    /**
+     * 
+     * @type {Array<AssetResponseDto>}
+     * @memberof AssetResponseDto
+     */
+    'stack'?: Array<AssetResponseDto>;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetResponseDto
+     */
+    'stackCount': number;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetResponseDto
+     */
+    'stackParentId'?: string | null;
     /**
     /**
      * 
      * 
      * @type {Array<TagResponseDto>}
      * @type {Array<TagResponseDto>}
@@ -3981,6 +4011,25 @@ export interface UpdateLibraryDto {
      */
      */
     'name'?: string;
     'name'?: string;
 }
 }
+/**
+ * 
+ * @export
+ * @interface UpdateStackParentDto
+ */
+export interface UpdateStackParentDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof UpdateStackParentDto
+     */
+    'newParentId': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof UpdateStackParentDto
+     */
+    'oldParentId': string;
+}
 /**
 /**
  * 
  * 
  * @export
  * @export
@@ -7135,6 +7184,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 options: localVarRequestOptions,
                 options: localVarRequestOptions,
             };
             };
         },
         },
+        /**
+         * 
+         * @param {UpdateStackParentDto} updateStackParentDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        updateStackParent: async (updateStackParentDto: UpdateStackParentDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'updateStackParentDto' is not null or undefined
+            assertParamExists('updateStackParent', 'updateStackParentDto', updateStackParentDto)
+            const localVarPath = `/asset/stack/parent`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(updateStackParentDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
         /**
         /**
          * 
          * 
          * @param {File} assetData 
          * @param {File} assetData 
@@ -7601,6 +7694,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
+        /**
+         * 
+         * @param {UpdateStackParentDto} updateStackParentDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async updateStackParent(updateStackParentDto: UpdateStackParentDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.updateStackParent(updateStackParentDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
         /**
          * 
          * 
          * @param {File} assetData 
          * @param {File} assetData 
@@ -7892,6 +7995,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
         updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
             return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
             return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
         },
         },
+        /**
+         * 
+         * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
+            return localVarFp.updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(axios, basePath));
+        },
         /**
         /**
          * 
          * 
          * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
          * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
@@ -8499,6 +8611,20 @@ export interface AssetApiUpdateAssetsRequest {
     readonly assetBulkUpdateDto: AssetBulkUpdateDto
     readonly assetBulkUpdateDto: AssetBulkUpdateDto
 }
 }
 
 
+/**
+ * Request parameters for updateStackParent operation in AssetApi.
+ * @export
+ * @interface AssetApiUpdateStackParentRequest
+ */
+export interface AssetApiUpdateStackParentRequest {
+    /**
+     * 
+     * @type {UpdateStackParentDto}
+     * @memberof AssetApiUpdateStackParent
+     */
+    readonly updateStackParentDto: UpdateStackParentDto
+}
+
 /**
 /**
  * Request parameters for uploadFile operation in AssetApi.
  * Request parameters for uploadFile operation in AssetApi.
  * @export
  * @export
@@ -8939,6 +9065,17 @@ export class AssetApi extends BaseAPI {
         return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
         return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
+    /**
+     * 
+     * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
     /**
      * 
      * 
      * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
      * @param {AssetApiUploadFileRequest} requestParameters Request parameters.

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

@@ -130,7 +130,9 @@
   "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_share": "Share",
   "control_bottom_app_bar_share": "Share",
+  "control_bottom_app_bar_stack": "Stack",
   "control_bottom_app_bar_unarchive": "Unarchive",
   "control_bottom_app_bar_unarchive": "Unarchive",
+  "control_bottom_app_bar_upload": "Upload",
   "create_album_page_untitled": "Untitled",
   "create_album_page_untitled": "Untitled",
   "create_shared_album_page_create": "Create",
   "create_shared_album_page_create": "Create",
   "create_shared_album_page_share": "Share",
   "create_shared_album_page_share": "Share",
@@ -275,6 +277,7 @@
   "setting_pages_app_bar_settings": "Settings",
   "setting_pages_app_bar_settings": "Settings",
   "settings_require_restart": "Please restart Immich to apply this setting",
   "settings_require_restart": "Please restart Immich to apply this setting",
   "share_add": "Add",
   "share_add": "Add",
+  "share_done": "Done",
   "share_add_photos": "Add photos",
   "share_add_photos": "Add photos",
   "share_add_title": "Add a title",
   "share_add_title": "Add a title",
   "share_create_album": "Create album",
   "share_create_album": "Create album",
@@ -337,5 +340,8 @@
   "trash_page_select_assets_btn": "Select assets",
   "trash_page_select_assets_btn": "Select assets",
   "trash_page_empty_trash_btn": "Empty trash",
   "trash_page_empty_trash_btn": "Empty trash",
   "trash_page_empty_trash_dialog_ok": "Ok",
   "trash_page_empty_trash_dialog_ok": "Ok",
-  "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich"
+  "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich",
+  "viewer_stack_use_as_main_asset": "Use as Main Asset",
+  "viewer_remove_from_stack": "Remove from Stack",
+  "viewer_unstack": "Un-Stack"
 }
 }

+ 3 - 1
mobile/lib/modules/album/views/album_viewer_page.dart

@@ -16,6 +16,7 @@ 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/ui/immich_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.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';
@@ -69,7 +70,8 @@ class AlbumViewerPage extends HookConsumerWidget {
           await AutoRouter.of(context).push<AssetSelectionPageResult?>(
           await AutoRouter.of(context).push<AssetSelectionPageResult?>(
         AssetSelectionRoute(
         AssetSelectionRoute(
           existingAssets: albumInfo.assets,
           existingAssets: albumInfo.assets,
-          isNewAlbum: false,
+          canDeselect: false,
+          query: getRemoteAssetQuery(ref),
         ),
         ),
       );
       );
 
 

+ 11 - 10
mobile/lib/modules/album/views/asset_selection_page.dart

@@ -4,26 +4,27 @@ 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/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/asset_viewer/providers/render_list.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/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/asset_grid/immich_asset_grid.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/user.provider.dart';
+import 'package:isar/isar.dart';
 
 
 class AssetSelectionPage extends HookConsumerWidget {
 class AssetSelectionPage extends HookConsumerWidget {
   const AssetSelectionPage({
   const AssetSelectionPage({
     Key? key,
     Key? key,
     required this.existingAssets,
     required this.existingAssets,
-    this.isNewAlbum = false,
+    this.canDeselect = false,
+    required this.query,
   }) : super(key: key);
   }) : super(key: key);
 
 
   final Set<Asset> existingAssets;
   final Set<Asset> existingAssets;
-  final bool isNewAlbum;
+  final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
+  final bool canDeselect;
 
 
   @override
   @override
   Widget build(BuildContext context, WidgetRef ref) {
   Widget build(BuildContext context, WidgetRef ref) {
-    final currentUser = ref.watch(currentUserProvider);
-    final renderList = ref.watch(remoteAssetsProvider(currentUser?.isarId));
+    final renderList = ref.watch(renderListQueryProvider(query));
     final selected = useState<Set<Asset>>(existingAssets);
     final selected = useState<Set<Asset>>(existingAssets);
     final selectionEnabledHook = useState(true);
     final selectionEnabledHook = useState(true);
 
 
@@ -39,8 +40,8 @@ class AssetSelectionPage extends HookConsumerWidget {
           selected.value = assets;
           selected.value = assets;
         },
         },
         selectionActive: true,
         selectionActive: true,
-        preselectedAssets: isNewAlbum ? selected.value : existingAssets,
-        canDeselect: isNewAlbum,
+        preselectedAssets: existingAssets,
+        canDeselect: canDeselect,
         showMultiSelectIndicator: false,
         showMultiSelectIndicator: false,
       );
       );
     }
     }
@@ -65,7 +66,7 @@ class AssetSelectionPage extends HookConsumerWidget {
               ),
               ),
         centerTitle: false,
         centerTitle: false,
         actions: [
         actions: [
-          if (selected.value.isNotEmpty)
+          if (selected.value.isNotEmpty || canDeselect)
             TextButton(
             TextButton(
               onPressed: () {
               onPressed: () {
                 var payload =
                 var payload =
@@ -74,7 +75,7 @@ class AssetSelectionPage extends HookConsumerWidget {
                     .popForced<AssetSelectionPageResult>(payload);
                     .popForced<AssetSelectionPageResult>(payload);
               },
               },
               child: Text(
               child: Text(
-                "share_add",
+                canDeselect ? "share_done" : "share_add",
                 style: TextStyle(
                 style: TextStyle(
                   fontWeight: FontWeight.bold,
                   fontWeight: FontWeight.bold,
                   color: Theme.of(context).primaryColor,
                   color: Theme.of(context).primaryColor,

+ 5 - 2
mobile/lib/modules/album/views/create_album_page.dart

@@ -11,6 +11,7 @@ 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/modules/album/ui/shared_album_thumbnail_image.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/routing/router.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';
 
 
 // ignore: must_be_immutable
 // ignore: must_be_immutable
 class CreateAlbumPage extends HookConsumerWidget {
 class CreateAlbumPage extends HookConsumerWidget {
@@ -31,7 +32,8 @@ class CreateAlbumPage extends HookConsumerWidget {
     final isAlbumTitleTextFieldFocus = useState(false);
     final isAlbumTitleTextFieldFocus = useState(false);
     final isAlbumTitleEmpty = useState(true);
     final isAlbumTitleEmpty = useState(true);
     final selectedAssets = useState<Set<Asset>>(
     final selectedAssets = useState<Set<Asset>>(
-        initialAssets != null ? Set.from(initialAssets!) : const {},);
+      initialAssets != null ? Set.from(initialAssets!) : const {},
+    );
     final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
     final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
 
 
     showSelectUserPage() async {
     showSelectUserPage() async {
@@ -59,7 +61,8 @@ class CreateAlbumPage extends HookConsumerWidget {
           await AutoRouter.of(context).push<AssetSelectionPageResult?>(
           await AutoRouter.of(context).push<AssetSelectionPageResult?>(
         AssetSelectionRoute(
         AssetSelectionRoute(
           existingAssets: selectedAssets.value,
           existingAssets: selectedAssets.value,
-          isNewAlbum: true,
+          canDeselect: true,
+          query: getRemoteAssetQuery(ref),
         ),
         ),
       );
       );
       if (selectedAsset == null) {
       if (selectedAsset == null) {

+ 50 - 0
mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart

@@ -0,0 +1,50 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/providers/db.provider.dart';
+import 'package:isar/isar.dart';
+
+class AssetStackNotifier extends StateNotifier<List<Asset>> {
+  final Asset _asset;
+  final Ref _ref;
+
+  AssetStackNotifier(
+    this._asset,
+    this._ref,
+  ) : super([]) {
+    fetchStackChildren();
+  }
+
+  void fetchStackChildren() async {
+    if (mounted) {
+      state = await _ref.read(assetStackProvider(_asset).future);
+    }
+  }
+
+  removeChild(int index) {
+    if (index < state.length) {
+      state.removeAt(index);
+    }
+  }
+}
+
+final assetStackStateProvider = StateNotifierProvider.autoDispose
+    .family<AssetStackNotifier, List<Asset>, Asset>(
+  (ref, asset) => AssetStackNotifier(asset, ref),
+);
+
+final assetStackProvider =
+    FutureProvider.autoDispose.family<List<Asset>, Asset>((ref, asset) async {
+  // Guard [local asset]
+  if (asset.remoteId == null) {
+    return [];
+  }
+
+  return await ref
+      .watch(dbProvider)
+      .assets
+      .filter()
+      .isArchivedEqualTo(false)
+      .isTrashedEqualTo(false)
+      .stackParentIdEqualTo(asset.remoteId)
+      .findAll();
+});

+ 17 - 0
mobile/lib/modules/asset_viewer/providers/render_list.provider.dart

@@ -3,6 +3,7 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu
 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/models/asset.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:isar/isar.dart';
 
 
 final renderListProvider =
 final renderListProvider =
     FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
     FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
@@ -13,3 +14,19 @@ final renderListProvider =
     GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
     GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
   );
   );
 });
 });
+
+final renderListQueryProvider = StreamProvider.family<RenderList,
+    QueryBuilder<Asset, Asset, QAfterSortBy>?>(
+  (ref, query) async* {
+    if (query == null) {
+      return;
+    }
+    final settings = ref.watch(appSettingsServiceProvider);
+    final groupBy = GroupAssetsBy
+        .values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
+    yield await RenderList.fromQuery(query, groupBy);
+    await for (final _ in query.watchLazy()) {
+      yield await RenderList.fromQuery(query, groupBy);
+    }
+  },
+);

+ 72 - 0
mobile/lib/modules/asset_viewer/services/asset_stack.service.dart

@@ -0,0 +1,72 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/providers/api.provider.dart';
+import 'package:immich_mobile/shared/services/api.service.dart';
+import 'package:openapi/api.dart';
+
+class AssetStackService {
+  AssetStackService(this._api);
+
+  final ApiService _api;
+
+  updateStack(
+    Asset parentAsset, {
+    List<Asset>? childrenToAdd,
+    List<Asset>? childrenToRemove,
+  }) async {
+    // Guard [local asset]
+    if (parentAsset.remoteId == null) {
+      return;
+    }
+
+    try {
+      if (childrenToAdd != null) {
+        final toAdd = childrenToAdd
+            .where((e) => e.isRemote)
+            .map((e) => e.remoteId!)
+            .toList();
+
+        await _api.assetApi.updateAssets(
+          AssetBulkUpdateDto(ids: toAdd, stackParentId: parentAsset.remoteId),
+        );
+      }
+
+      if (childrenToRemove != null) {
+        final toRemove = childrenToRemove
+            .where((e) => e.isRemote)
+            .map((e) => e.remoteId!)
+            .toList();
+        await _api.assetApi.updateAssets(
+          AssetBulkUpdateDto(ids: toRemove, removeParent: true),
+        );
+      }
+    } catch (error) {
+      debugPrint("Error while updating stack children: ${error.toString()}");
+    }
+  }
+
+  updateStackParent(Asset oldParent, Asset newParent) async {
+    // Guard [local asset]
+    if (oldParent.remoteId == null || newParent.remoteId == null) {
+      return;
+    }
+
+    try {
+      await _api.assetApi.updateStackParent(
+        UpdateStackParentDto(
+          oldParentId: oldParent.remoteId!,
+          newParentId: newParent.remoteId!,
+        ),
+      );
+    } catch (error) {
+      debugPrint("Error while updating stack parent: ${error.toString()}");
+    }
+  }
+}
+
+final assetStackServiceProvider = Provider(
+  (ref) => AssetStackService(
+    ref.watch(apiServiceProvider),
+  ),
+);

+ 252 - 58
mobile/lib/modules/asset_viewer/views/gallery_viewer.dart

@@ -8,11 +8,13 @@ import 'package:flutter/services.dart';
 import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
 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/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';
 import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
 import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
+import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.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';
 import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
@@ -44,6 +46,7 @@ class GalleryViewerPage extends HookConsumerWidget {
   final int totalAssets;
   final int totalAssets;
   final int initialIndex;
   final int initialIndex;
   final int heroOffset;
   final int heroOffset;
+  final bool showStack;
 
 
   GalleryViewerPage({
   GalleryViewerPage({
     super.key,
     super.key,
@@ -51,6 +54,7 @@ class GalleryViewerPage extends HookConsumerWidget {
     required this.loadAsset,
     required this.loadAsset,
     required this.totalAssets,
     required this.totalAssets,
     this.heroOffset = 0,
     this.heroOffset = 0,
+    this.showStack = false,
   }) : controller = PageController(initialPage: initialIndex);
   }) : controller = PageController(initialPage: initialIndex);
 
 
   final PageController controller;
   final PageController controller;
@@ -77,8 +81,17 @@ class GalleryViewerPage extends HookConsumerWidget {
     final isFromTrash = isTrashEnabled &&
     final isFromTrash = isTrashEnabled &&
         navStack.length > 2 &&
         navStack.length > 2 &&
         navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
         navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
+    final stackIndex = useState(-1);
+    final stack = showStack && currentAsset.stackCount > 0
+        ? ref.watch(assetStackStateProvider(currentAsset))
+        : <Asset>[];
+    final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
 
 
-    Asset asset() => currentAsset;
+    Asset asset() => stackIndex.value == -1
+        ? currentAsset
+        : stackElements.elementAt(stackIndex.value);
+
+    bool isParent = stackIndex.value == -1 || stackIndex.value == 0;
 
 
     useEffect(
     useEffect(
       () {
       () {
@@ -165,19 +178,28 @@ class GalleryViewerPage extends HookConsumerWidget {
             padding: EdgeInsets.only(
             padding: EdgeInsets.only(
               bottom: MediaQuery.of(context).viewInsets.bottom,
               bottom: MediaQuery.of(context).viewInsets.bottom,
             ),
             ),
-            child: ExifBottomSheet(asset: currentAsset),
+            child: ExifBottomSheet(asset: asset()),
           );
           );
         },
         },
       );
       );
     }
     }
 
 
+    void removeAssetFromStack() {
+      if (stackIndex.value > 0 && showStack) {
+        ref
+            .read(assetStackStateProvider(currentAsset).notifier)
+            .removeChild(stackIndex.value - 1);
+        stackIndex.value = stackIndex.value - 1;
+      }
+    }
+
     void handleDelete(Asset deleteAsset) async {
     void handleDelete(Asset deleteAsset) async {
       Future<bool> onDelete(bool force) async {
       Future<bool> onDelete(bool force) async {
         final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
         final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
           {deleteAsset},
           {deleteAsset},
           force: force,
           force: force,
         );
         );
-        if (isDeleted) {
+        if (isDeleted && isParent) {
           if (totalAssets == 1) {
           if (totalAssets == 1) {
             // Handle only one asset
             // Handle only one asset
             AutoRouter.of(context).pop();
             AutoRouter.of(context).pop();
@@ -195,14 +217,17 @@ class GalleryViewerPage extends HookConsumerWidget {
       // Asset is trashed
       // Asset is trashed
       if (isTrashEnabled && !isFromTrash) {
       if (isTrashEnabled && !isFromTrash) {
         final isDeleted = await onDelete(false);
         final isDeleted = await onDelete(false);
-        // Can only trash assets stored in server. Local assets are always permanently removed for now
-        if (context.mounted && isDeleted && deleteAsset.isRemote) {
-          ImmichToast.show(
-            durationInSecond: 1,
-            context: context,
-            msg: 'Asset trashed',
-            gravity: ToastGravity.BOTTOM,
-          );
+        if (isDeleted) {
+          // Can only trash assets stored in server. Local assets are always permanently removed for now
+          if (context.mounted && deleteAsset.isRemote && isParent) {
+            ImmichToast.show(
+              durationInSecond: 1,
+              context: context,
+              msg: 'Asset trashed',
+              gravity: ToastGravity.BOTTOM,
+            );
+          }
+          removeAssetFromStack();
         }
         }
         return;
         return;
       }
       }
@@ -211,7 +236,14 @@ class GalleryViewerPage extends HookConsumerWidget {
       showDialog(
       showDialog(
         context: context,
         context: context,
         builder: (BuildContext _) {
         builder: (BuildContext _) {
-          return DeleteDialog(onDelete: () => onDelete(true));
+          return DeleteDialog(
+            onDelete: () async {
+              final isDeleted = await onDelete(true);
+              if (isDeleted) {
+                removeAssetFromStack();
+              }
+            },
+          );
         },
         },
       );
       );
     }
     }
@@ -268,7 +300,11 @@ class GalleryViewerPage extends HookConsumerWidget {
       ref
       ref
           .watch(assetProvider.notifier)
           .watch(assetProvider.notifier)
           .toggleArchive([asset], !asset.isArchived);
           .toggleArchive([asset], !asset.isArchived);
-      AutoRouter.of(context).pop();
+      if (isParent) {
+        AutoRouter.of(context).pop();
+        return;
+      }
+      removeAssetFromStack();
     }
     }
 
 
     handleUpload(Asset asset) {
     handleUpload(Asset asset) {
@@ -385,7 +421,186 @@ class GalleryViewerPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    buildBottomBar() {
+    Widget buildStackedChildren() {
+      return ListView.builder(
+        shrinkWrap: true,
+        scrollDirection: Axis.horizontal,
+        itemCount: stackElements.length,
+        itemBuilder: (context, index) {
+          final assetId = stackElements.elementAt(index).remoteId;
+          return Padding(
+            padding: const EdgeInsets.only(right: 10),
+            child: GestureDetector(
+              onTap: () => stackIndex.value = index,
+              child: Container(
+                width: 40,
+                decoration: BoxDecoration(
+                  color: Colors.white,
+                  borderRadius: BorderRadius.circular(6),
+                  border: index == stackIndex.value
+                      ? Border.all(
+                          color: Colors.white,
+                          width: 2,
+                        )
+                      : null,
+                ),
+                child: ClipRRect(
+                  borderRadius: BorderRadius.circular(4),
+                  child: CachedNetworkImage(
+                    fit: BoxFit.cover,
+                    imageUrl:
+                        '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$assetId',
+                    httpHeaders: {
+                      "Authorization":
+                          "Bearer ${Store.get(StoreKey.accessToken)}",
+                    },
+                    errorWidget: (context, url, error) =>
+                        const Icon(Icons.image_not_supported_outlined),
+                  ),
+                ),
+              ),
+            ),
+          );
+        },
+      );
+    }
+
+    void showStackActionItems() {
+      showModalBottomSheet<void>(
+        context: context,
+        enableDrag: false,
+        builder: (BuildContext ctx) {
+          return SafeArea(
+            child: Padding(
+              padding: const EdgeInsets.only(top: 24.0),
+              child: Column(
+                mainAxisSize: MainAxisSize.min,
+                children: [
+                  if (!isParent)
+                    ListTile(
+                      leading: const Icon(
+                        Icons.bookmark_border_outlined,
+                        size: 24,
+                      ),
+                      onTap: () async {
+                        await ref
+                            .read(assetStackServiceProvider)
+                            .updateStackParent(
+                              currentAsset,
+                              stackElements.elementAt(stackIndex.value),
+                            );
+                        Navigator.pop(ctx);
+                        AutoRouter.of(context).pop();
+                      },
+                      title: const Text(
+                        "viewer_stack_use_as_main_asset",
+                        style: TextStyle(fontWeight: FontWeight.bold),
+                      ).tr(),
+                    ),
+                  ListTile(
+                    leading: const Icon(
+                      Icons.copy_all_outlined,
+                      size: 24,
+                    ),
+                    onTap: () async {
+                      if (isParent) {
+                        await ref
+                            .read(assetStackServiceProvider)
+                            .updateStackParent(
+                              currentAsset,
+                              stackElements
+                                  .elementAt(1), // Next asset as parent
+                            );
+                        // Remove itself from stack
+                        await ref.read(assetStackServiceProvider).updateStack(
+                          stackElements.elementAt(1),
+                          childrenToRemove: [currentAsset],
+                        );
+                        Navigator.pop(ctx);
+                        AutoRouter.of(context).pop();
+                      } else {
+                        await ref.read(assetStackServiceProvider).updateStack(
+                          currentAsset,
+                          childrenToRemove: [
+                            stackElements.elementAt(stackIndex.value),
+                          ],
+                        );
+                        removeAssetFromStack();
+                        Navigator.pop(ctx);
+                      }
+                    },
+                    title: const Text(
+                      "viewer_remove_from_stack",
+                      style: TextStyle(fontWeight: FontWeight.bold),
+                    ).tr(),
+                  ),
+                  ListTile(
+                    leading: const Icon(
+                      Icons.filter_none_outlined,
+                      size: 18,
+                    ),
+                    onTap: () async {
+                      await ref.read(assetStackServiceProvider).updateStack(
+                            currentAsset,
+                            childrenToRemove: stack,
+                          );
+                      Navigator.pop(ctx);
+                      AutoRouter.of(context).pop();
+                    },
+                    title: const Text(
+                      "viewer_unstack",
+                      style: TextStyle(fontWeight: FontWeight.bold),
+                    ).tr(),
+                  ),
+                ],
+              ),
+            ),
+          );
+        },
+      );
+    }
+
+    Widget buildBottomBar() {
+      // !!!! itemsList and actionlist should always be in sync
+      final itemsList = [
+        BottomNavigationBarItem(
+          icon: Icon(
+            Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
+          ),
+          label: 'control_bottom_app_bar_share'.tr(),
+          tooltip: 'control_bottom_app_bar_share'.tr(),
+        ),
+        asset().isArchived
+            ? BottomNavigationBarItem(
+                icon: const Icon(Icons.unarchive_rounded),
+                label: 'control_bottom_app_bar_unarchive'.tr(),
+                tooltip: 'control_bottom_app_bar_unarchive'.tr(),
+              )
+            : BottomNavigationBarItem(
+                icon: const Icon(Icons.archive_outlined),
+                label: 'control_bottom_app_bar_archive'.tr(),
+                tooltip: 'control_bottom_app_bar_archive'.tr(),
+              ),
+        if (stack.isNotEmpty)
+          BottomNavigationBarItem(
+            icon: const Icon(Icons.burst_mode_outlined),
+            label: 'control_bottom_app_bar_stack'.tr(),
+            tooltip: 'control_bottom_app_bar_stack'.tr(),
+          ),
+        BottomNavigationBarItem(
+          icon: const Icon(Icons.delete_outline),
+          label: 'control_bottom_app_bar_delete'.tr(),
+          tooltip: 'control_bottom_app_bar_delete'.tr(),
+        ),
+      ];
+
+      List<Function(int)> actionslist = [
+        (_) => shareAsset(),
+        (_) => handleArchive(asset()),
+        if (stack.isNotEmpty) (_) => showStackActionItems(),
+        (_) => handleDelete(asset()),
+      ];
+
       return IgnorePointer(
       return IgnorePointer(
         ignoring: !ref.watch(showControlsProvider),
         ignoring: !ref.watch(showControlsProvider),
         child: AnimatedOpacity(
         child: AnimatedOpacity(
@@ -393,6 +608,17 @@ class GalleryViewerPage extends HookConsumerWidget {
           opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
           opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
           child: Column(
           child: Column(
             children: [
             children: [
+              if (stack.isNotEmpty)
+                Padding(
+                  padding: const EdgeInsets.only(
+                    left: 10,
+                    bottom: 30,
+                  ),
+                  child: SizedBox(
+                    height: 40,
+                    child: buildStackedChildren(),
+                  ),
+                ),
               Visibility(
               Visibility(
                 visible: !asset().isImage && !isPlayingMotionVideo.value,
                 visible: !asset().isImage && !isPlayingMotionVideo.value,
                 child: Container(
                 child: Container(
@@ -421,44 +647,10 @@ class GalleryViewerPage extends HookConsumerWidget {
                 selectedLabelStyle: const TextStyle(color: Colors.black),
                 selectedLabelStyle: const TextStyle(color: Colors.black),
                 showSelectedLabels: false,
                 showSelectedLabels: false,
                 showUnselectedLabels: false,
                 showUnselectedLabels: false,
-                items: [
-                  BottomNavigationBarItem(
-                    icon: Icon(
-                      Platform.isAndroid
-                          ? Icons.share_rounded
-                          : Icons.ios_share_rounded,
-                    ),
-                    label: 'control_bottom_app_bar_share'.tr(),
-                    tooltip: 'control_bottom_app_bar_share'.tr(),
-                  ),
-                  asset().isArchived
-                      ? BottomNavigationBarItem(
-                          icon: const Icon(Icons.unarchive_rounded),
-                          label: 'control_bottom_app_bar_unarchive'.tr(),
-                          tooltip: 'control_bottom_app_bar_unarchive'.tr(),
-                        )
-                      : BottomNavigationBarItem(
-                          icon: const Icon(Icons.archive_outlined),
-                          label: 'control_bottom_app_bar_archive'.tr(),
-                          tooltip: 'control_bottom_app_bar_archive'.tr(),
-                        ),
-                  BottomNavigationBarItem(
-                    icon: const Icon(Icons.delete_outline),
-                    label: 'control_bottom_app_bar_delete'.tr(),
-                    tooltip: 'control_bottom_app_bar_delete'.tr(),
-                  ),
-                ],
+                items: itemsList,
                 onTap: (index) {
                 onTap: (index) {
-                  switch (index) {
-                    case 0:
-                      shareAsset();
-                      break;
-                    case 1:
-                      handleArchive(asset());
-                      break;
-                    case 2:
-                      handleDelete(asset());
-                      break;
+                  if (index < actionslist.length) {
+                    actionslist[index].call(index);
                   }
                   }
                 },
                 },
               ),
               ),
@@ -504,6 +696,7 @@ class GalleryViewerPage extends HookConsumerWidget {
                 final next = currentIndex.value < value ? value + 1 : value - 1;
                 final next = currentIndex.value < value ? value + 1 : value - 1;
                 precacheNextImage(next);
                 precacheNextImage(next);
                 currentIndex.value = value;
                 currentIndex.value = value;
+                stackIndex.value = -1;
                 HapticFeedback.selectionClick();
                 HapticFeedback.selectionClick();
               },
               },
               loadingBuilder: (context, event, index) {
               loadingBuilder: (context, event, index) {
@@ -544,10 +737,11 @@ class GalleryViewerPage extends HookConsumerWidget {
                     : webPThumbnail;
                     : webPThumbnail;
               },
               },
               builder: (context, index) {
               builder: (context, index) {
-                final asset = loadAsset(index);
-                final ImageProvider provider = finalImageProvider(asset);
+                final a =
+                    index == currentIndex.value ? asset() : loadAsset(index);
+                final ImageProvider provider = finalImageProvider(a);
 
 
-                if (asset.isImage && !isPlayingMotionVideo.value) {
+                if (a.isImage && !isPlayingMotionVideo.value) {
                   return PhotoViewGalleryPageOptions(
                   return PhotoViewGalleryPageOptions(
                     onDragStart: (_, details, __) =>
                     onDragStart: (_, details, __) =>
                         localPosition = details.localPosition,
                         localPosition = details.localPosition,
@@ -558,13 +752,13 @@ class GalleryViewerPage extends HookConsumerWidget {
                     },
                     },
                     imageProvider: provider,
                     imageProvider: provider,
                     heroAttributes: PhotoViewHeroAttributes(
                     heroAttributes: PhotoViewHeroAttributes(
-                      tag: asset.id + heroOffset,
+                      tag: a.id + heroOffset,
                     ),
                     ),
                     filterQuality: FilterQuality.high,
                     filterQuality: FilterQuality.high,
                     tightMode: true,
                     tightMode: true,
                     minScale: PhotoViewComputedScale.contained,
                     minScale: PhotoViewComputedScale.contained,
                     errorBuilder: (context, error, stackTrace) => ImmichImage(
                     errorBuilder: (context, error, stackTrace) => ImmichImage(
-                      asset,
+                      a,
                       fit: BoxFit.contain,
                       fit: BoxFit.contain,
                     ),
                     ),
                   );
                   );
@@ -575,7 +769,7 @@ class GalleryViewerPage extends HookConsumerWidget {
                     onDragUpdate: (_, details, __) =>
                     onDragUpdate: (_, details, __) =>
                         handleSwipeUpDown(details),
                         handleSwipeUpDown(details),
                     heroAttributes: PhotoViewHeroAttributes(
                     heroAttributes: PhotoViewHeroAttributes(
-                      tag: asset.id + heroOffset,
+                      tag: a.id + heroOffset,
                     ),
                     ),
                     filterQuality: FilterQuality.high,
                     filterQuality: FilterQuality.high,
                     maxScale: 1.0,
                     maxScale: 1.0,
@@ -584,7 +778,7 @@ class GalleryViewerPage extends HookConsumerWidget {
                     child: VideoViewerPage(
                     child: VideoViewerPage(
                       onPlaying: () => isPlayingVideo.value = true,
                       onPlaying: () => isPlayingVideo.value = true,
                       onPaused: () => isPlayingVideo.value = false,
                       onPaused: () => isPlayingVideo.value = false,
-                      asset: asset,
+                      asset: a,
                       isMotionVideo: isPlayingMotionVideo.value,
                       isMotionVideo: isPlayingMotionVideo.value,
                       placeholder: Image(
                       placeholder: Image(
                         image: provider,
                         image: provider,

+ 47 - 0
mobile/lib/modules/home/models/selection_state.dart

@@ -0,0 +1,47 @@
+import 'package:immich_mobile/shared/models/asset.dart';
+
+class SelectionAssetState {
+  final bool hasRemote;
+  final bool hasLocal;
+  final bool hasMerged;
+
+  const SelectionAssetState({
+    this.hasRemote = false,
+    this.hasLocal = false,
+    this.hasMerged = false,
+  });
+
+  SelectionAssetState copyWith({
+    bool? hasRemote,
+    bool? hasLocal,
+    bool? hasMerged,
+  }) {
+    return SelectionAssetState(
+      hasRemote: hasRemote ?? this.hasRemote,
+      hasLocal: hasLocal ?? this.hasLocal,
+      hasMerged: hasMerged ?? this.hasMerged,
+    );
+  }
+
+  SelectionAssetState.fromSelection(Set<Asset> selection)
+      : hasLocal = selection.any((e) => e.storage == AssetState.local),
+        hasMerged = selection.any((e) => e.storage == AssetState.merged),
+        hasRemote = selection.any((e) => e.storage == AssetState.remote);
+
+  @override
+  String toString() =>
+      'SelectionAssetState(hasRemote: $hasRemote, hasMerged: $hasMerged, hasMerged: $hasMerged)';
+
+  @override
+  bool operator ==(covariant SelectionAssetState other) {
+    if (identical(this, other)) return true;
+
+    return other.hasRemote == hasRemote &&
+        other.hasLocal == hasLocal &&
+        other.hasMerged == hasMerged;
+  }
+
+  @override
+  int get hashCode =>
+      hasRemote.hashCode ^ hasLocal.hashCode ^ hasMerged.hashCode;
+}

+ 3 - 0
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart

@@ -32,6 +32,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
   final Widget? topWidget;
   final Widget? topWidget;
   final bool shrinkWrap;
   final bool shrinkWrap;
   final bool showDragScroll;
   final bool showDragScroll;
+  final bool showStack;
 
 
   const ImmichAssetGrid({
   const ImmichAssetGrid({
     super.key,
     super.key,
@@ -51,6 +52,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
     this.topWidget,
     this.topWidget,
     this.shrinkWrap = false,
     this.shrinkWrap = false,
     this.showDragScroll = true,
     this.showDragScroll = true,
+    this.showStack = false,
   });
   });
 
 
   @override
   @override
@@ -114,6 +116,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
           heroOffset: heroOffset(),
           heroOffset: heroOffset(),
           shrinkWrap: shrinkWrap,
           shrinkWrap: shrinkWrap,
           showDragScroll: showDragScroll,
           showDragScroll: showDragScroll,
+          showStack: showStack,
         ),
         ),
       );
       );
     }
     }

+ 11 - 6
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart

@@ -37,6 +37,7 @@ class ImmichAssetGridView extends StatefulWidget {
   final int heroOffset;
   final int heroOffset;
   final bool shrinkWrap;
   final bool shrinkWrap;
   final bool showDragScroll;
   final bool showDragScroll;
+  final bool showStack;
 
 
   const ImmichAssetGridView({
   const ImmichAssetGridView({
     super.key,
     super.key,
@@ -56,6 +57,7 @@ class ImmichAssetGridView extends StatefulWidget {
     this.heroOffset = 0,
     this.heroOffset = 0,
     this.shrinkWrap = false,
     this.shrinkWrap = false,
     this.showDragScroll = true,
     this.showDragScroll = true,
+    this.showStack = false,
   });
   });
 
 
   @override
   @override
@@ -71,7 +73,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
 
 
   bool _scrolling = false;
   bool _scrolling = false;
   final Set<Asset> _selectedAssets =
   final Set<Asset> _selectedAssets =
-      HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
+      LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
 
 
   Set<Asset> _getSelectedAssets() {
   Set<Asset> _getSelectedAssets() {
     return Set.from(_selectedAssets);
     return Set.from(_selectedAssets);
@@ -90,7 +92,13 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
 
 
   void _deselectAssets(List<Asset> assets) {
   void _deselectAssets(List<Asset> assets) {
     setState(() {
     setState(() {
-      _selectedAssets.removeAll(assets);
+      _selectedAssets.removeAll(
+        assets.where(
+          (a) =>
+              widget.canDeselect ||
+              !(widget.preselectedAssets?.contains(a) ?? false),
+        ),
+      );
       _callSelectionListener(_selectedAssets.isNotEmpty);
       _callSelectionListener(_selectedAssets.isNotEmpty);
     });
     });
   }
   }
@@ -129,6 +137,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
       useGrayBoxPlaceholder: true,
       useGrayBoxPlaceholder: true,
       showStorageIndicator: widget.showStorageIndicator,
       showStorageIndicator: widget.showStorageIndicator,
       heroOffset: widget.heroOffset,
       heroOffset: widget.heroOffset,
+      showStack: widget.showStack,
     );
     );
   }
   }
 
 
@@ -377,10 +386,6 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
       setState(() {
       setState(() {
         _selectedAssets.clear();
         _selectedAssets.clear();
       });
       });
-    } else if (widget.preselectedAssets != null) {
-      setState(() {
-        _selectedAssets.addAll(widget.preselectedAssets!);
-      });
     }
     }
   }
   }
 
 

+ 35 - 2
mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart

@@ -12,6 +12,7 @@ class ThumbnailImage extends StatelessWidget {
   final Asset Function(int index) loadAsset;
   final Asset Function(int index) loadAsset;
   final int totalAssets;
   final int totalAssets;
   final bool showStorageIndicator;
   final bool showStorageIndicator;
+  final bool showStack;
   final bool useGrayBoxPlaceholder;
   final bool useGrayBoxPlaceholder;
   final bool isSelected;
   final bool isSelected;
   final bool multiselectEnabled;
   final bool multiselectEnabled;
@@ -26,6 +27,7 @@ class ThumbnailImage extends StatelessWidget {
     required this.loadAsset,
     required this.loadAsset,
     required this.totalAssets,
     required this.totalAssets,
     this.showStorageIndicator = true,
     this.showStorageIndicator = true,
+    this.showStack = false,
     this.useGrayBoxPlaceholder = false,
     this.useGrayBoxPlaceholder = false,
     this.isSelected = false,
     this.isSelected = false,
     this.multiselectEnabled = false,
     this.multiselectEnabled = false,
@@ -93,6 +95,35 @@ class ThumbnailImage extends StatelessWidget {
       );
       );
     }
     }
 
 
+    Widget buildStackIcon() {
+      return Positioned(
+        top: 5,
+        right: 5,
+        child: Row(
+          children: [
+            if (asset.stackCount > 1)
+              Text(
+                "${asset.stackCount}",
+                style: const TextStyle(
+                  color: Colors.white,
+                  fontSize: 10,
+                  fontWeight: FontWeight.bold,
+                ),
+              ),
+            if (asset.stackCount > 1)
+              const SizedBox(
+                width: 3,
+              ),
+            const Icon(
+              Icons.burst_mode_rounded,
+              color: Colors.white,
+              size: 18,
+            ),
+          ],
+        ),
+      );
+    }
+
     Widget buildImage() {
     Widget buildImage() {
       final image = SizedBox(
       final image = SizedBox(
         width: 300,
         width: 300,
@@ -113,9 +144,9 @@ class ThumbnailImage extends StatelessWidget {
         decoration: BoxDecoration(
         decoration: BoxDecoration(
           border: Border.all(
           border: Border.all(
             width: 0,
             width: 0,
-            color: assetContainerColor,
+            color: onDeselect == null ? Colors.grey : assetContainerColor,
           ),
           ),
-          color: assetContainerColor,
+          color: onDeselect == null ? Colors.grey : assetContainerColor,
         ),
         ),
         child: ClipRRect(
         child: ClipRRect(
           borderRadius: const BorderRadius.only(
           borderRadius: const BorderRadius.only(
@@ -144,6 +175,7 @@ class ThumbnailImage extends StatelessWidget {
               loadAsset: loadAsset,
               loadAsset: loadAsset,
               totalAssets: totalAssets,
               totalAssets: totalAssets,
               heroOffset: heroOffset,
               heroOffset: heroOffset,
+              showStack: showStack,
             ),
             ),
           );
           );
         }
         }
@@ -196,6 +228,7 @@ class ThumbnailImage extends StatelessWidget {
               ),
               ),
             ),
             ),
           if (!asset.isImage) buildVideoIcon(),
           if (!asset.isImage) buildVideoIcon(),
+          if (asset.isImage && asset.stackCount > 0) buildStackIcon(),
         ],
         ],
       ),
       ),
     );
     );

+ 19 - 7
mobile/lib/modules/home/ui/control_bottom_app_bar.dart

@@ -4,9 +4,9 @@ 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/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/ui/delete_dialog.dart';
 import 'package:immich_mobile/modules/home/ui/delete_dialog.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/shared/models/asset.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/ui/drag_sheet.dart';
 import 'package:immich_mobile/shared/ui/drag_sheet.dart';
 import 'package:immich_mobile/shared/models/album.dart';
 import 'package:immich_mobile/shared/models/album.dart';
@@ -19,11 +19,12 @@ class ControlBottomAppBar extends ConsumerWidget {
   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 List<Album> albums;
   final List<Album> albums;
   final List<Album> sharedAlbums;
   final List<Album> sharedAlbums;
   final bool enabled;
   final bool enabled;
-  final AssetState selectionAssetState;
+  final SelectionAssetState selectionAssetState;
 
 
   const ControlBottomAppBar({
   const ControlBottomAppBar({
     Key? key,
     Key? key,
@@ -36,19 +37,24 @@ class ControlBottomAppBar extends ConsumerWidget {
     required this.onAddToAlbum,
     required this.onAddToAlbum,
     required this.onCreateNewAlbum,
     required this.onCreateNewAlbum,
     required this.onUpload,
     required this.onUpload,
-    this.selectionAssetState = AssetState.remote,
+    required this.onStack,
+    this.selectionAssetState = const SelectionAssetState(),
     this.enabled = true,
     this.enabled = true,
   }) : super(key: key);
   }) : super(key: key);
 
 
   @override
   @override
   Widget build(BuildContext context, WidgetRef ref) {
   Widget build(BuildContext context, WidgetRef ref) {
     var isDarkMode = Theme.of(context).brightness == Brightness.dark;
     var isDarkMode = Theme.of(context).brightness == Brightness.dark;
-    var hasRemote = selectionAssetState == AssetState.remote;
+    var hasRemote =
+        selectionAssetState.hasRemote || selectionAssetState.hasMerged;
+    var hasLocal = selectionAssetState.hasLocal;
     final trashEnabled =
     final trashEnabled =
         ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
         ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
 
 
     Widget renderActionButtons() {
     Widget renderActionButtons() {
-      return Row(
+      return Wrap(
+        spacing: 10,
+        runSpacing: 15,
         children: [
         children: [
           ControlBoxButton(
           ControlBoxButton(
             iconData: Platform.isAndroid
             iconData: Platform.isAndroid
@@ -92,7 +98,7 @@ class ControlBottomAppBar extends ConsumerWidget {
           if (!hasRemote)
           if (!hasRemote)
             ControlBoxButton(
             ControlBoxButton(
               iconData: Icons.backup_outlined,
               iconData: Icons.backup_outlined,
-              label: "Upload",
+              label: "control_bottom_app_bar_upload".tr(),
               onPressed: enabled
               onPressed: enabled
                   ? () => showDialog(
                   ? () => showDialog(
                         context: context,
                         context: context,
@@ -104,6 +110,12 @@ class ControlBottomAppBar extends ConsumerWidget {
                       )
                       )
                   : null,
                   : null,
             ),
             ),
+          if (!hasLocal)
+            ControlBoxButton(
+              iconData: Icons.filter_none_rounded,
+              label: "control_bottom_app_bar_stack".tr(),
+              onPressed: enabled ? onStack : null,
+            ),
         ],
         ],
       );
       );
     }
     }
@@ -111,7 +123,7 @@ class ControlBottomAppBar extends ConsumerWidget {
     return DraggableScrollableSheet(
     return DraggableScrollableSheet(
       initialChildSize: hasRemote ? 0.30 : 0.18,
       initialChildSize: hasRemote ? 0.30 : 0.18,
       minChildSize: 0.18,
       minChildSize: 0.18,
-      maxChildSize: hasRemote ? 0.57 : 0.18,
+      maxChildSize: hasRemote ? 0.60 : 0.18,
       snap: true,
       snap: true,
       builder: (
       builder: (
         BuildContext context,
         BuildContext context,

+ 58 - 4
mobile/lib/modules/home/views/home_page.dart

@@ -7,11 +7,15 @@ 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:fluttertoast/fluttertoast.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.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_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/asset_viewer/providers/asset_stack.provider.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/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/asset_grid/immich_asset_grid.dart';
 import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
 import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
@@ -36,7 +40,7 @@ class HomePage extends HookConsumerWidget {
   Widget build(BuildContext context, WidgetRef ref) {
   Widget build(BuildContext context, WidgetRef ref) {
     final multiselectEnabled = ref.watch(multiselectProvider.notifier);
     final multiselectEnabled = ref.watch(multiselectProvider.notifier);
     final selectionEnabledHook = useState(false);
     final selectionEnabledHook = useState(false);
-    final selectionAssetState = useState(AssetState.remote);
+    final selectionAssetState = useState(const SelectionAssetState());
 
 
     final selection = useState(<Asset>{});
     final selection = useState(<Asset>{});
     final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
     final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
@@ -83,9 +87,8 @@ class HomePage extends HookConsumerWidget {
       ) {
       ) {
         selectionEnabledHook.value = multiselect;
         selectionEnabledHook.value = multiselect;
         selection.value = selectedAssets;
         selection.value = selectedAssets;
-        selectionAssetState.value = selectedAssets.any((e) => e.isRemote)
-            ? AssetState.remote
-            : AssetState.local;
+        selectionAssetState.value =
+            SelectionAssetState.fromSelection(selectedAssets);
       }
       }
 
 
       void onShareAssets() {
       void onShareAssets() {
@@ -246,6 +249,55 @@ class HomePage extends HookConsumerWidget {
         }
         }
       }
       }
 
 
+      void onStack() async {
+        try {
+          processing.value = true;
+          if (!selectionEnabledHook.value) {
+            return;
+          }
+
+          final selectedAsset = selection.value.elementAt(0);
+
+          if (selection.value.length == 1) {
+            final stackChildren =
+                (await ref.read(assetStackProvider(selectedAsset).future))
+                    .toSet();
+            AssetSelectionPageResult? returnPayload =
+                await AutoRouter.of(context).push<AssetSelectionPageResult?>(
+              AssetSelectionRoute(
+                existingAssets: stackChildren,
+                canDeselect: true,
+                query: getAssetStackSelectionQuery(ref, selectedAsset),
+              ),
+            );
+
+            if (returnPayload != null) {
+              Set<Asset> selectedAssets = returnPayload.selectedAssets;
+              // Do not add itself as its stack child
+              selectedAssets.remove(selectedAsset);
+              final removedChildren = stackChildren.difference(selectedAssets);
+              final addedChildren = selectedAssets.difference(stackChildren);
+              await ref.read(assetStackServiceProvider).updateStack(
+                    selectedAsset,
+                    childrenToAdd: addedChildren.toList(),
+                    childrenToRemove: removedChildren.toList(),
+                  );
+            }
+          } else {
+            // Merge assets
+            selection.value.remove(selectedAsset);
+            final selectedAssets = selection.value;
+            await ref.read(assetStackServiceProvider).updateStack(
+                  selectedAsset,
+                  childrenToAdd: selectedAssets.toList(),
+                );
+          }
+        } finally {
+          processing.value = false;
+          selectionEnabledHook.value = false;
+        }
+      }
+
       Future<void> refreshAssets() async {
       Future<void> refreshAssets() async {
         final fullRefresh = refreshCount.value > 0;
         final fullRefresh = refreshCount.value > 0;
         await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
         await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
@@ -322,6 +374,7 @@ class HomePage extends HookConsumerWidget {
                                   currentUser.memoryEnabled!)
                                   currentUser.memoryEnabled!)
                               ? const MemoryLane()
                               ? const MemoryLane()
                               : const SizedBox(),
                               : const SizedBox(),
+                          showStack: true,
                         ),
                         ),
                   error: (error, _) => Center(child: Text(error.toString())),
                   error: (error, _) => Center(child: Text(error.toString())),
                   loading: buildLoadingIndicator,
                   loading: buildLoadingIndicator,
@@ -339,6 +392,7 @@ class HomePage extends HookConsumerWidget {
                 onUpload: onUpload,
                 onUpload: onUpload,
                 enabled: !processing.value,
                 enabled: !processing.value,
                 selectionAssetState: selectionAssetState.value,
                 selectionAssetState: selectionAssetState.value,
+                onStack: onStack,
               ),
               ),
             if (processing.value) const Center(child: ImmichLoadingIndicator()),
             if (processing.value) const Center(child: ImmichLoadingIndicator()),
           ],
           ],

+ 1 - 0
mobile/lib/modules/trash/views/trash_page.dart

@@ -252,6 +252,7 @@ class TrashPage extends HookConsumerWidget {
                       listener: selectionListener,
                       listener: selectionListener,
                       selectionActive: selectionEnabledHook.value,
                       selectionActive: selectionEnabledHook.value,
                       showMultiSelectIndicator: false,
                       showMultiSelectIndicator: false,
+                      showStack: true,
                       topWidget: Padding(
                       topWidget: Padding(
                         padding: const EdgeInsets.only(
                         padding: const EdgeInsets.only(
                           top: 24,
                           top: 24,

+ 1 - 0
mobile/lib/routing/router.dart

@@ -51,6 +51,7 @@ import 'package:immich_mobile/shared/views/app_log_detail_page.dart';
 import 'package:immich_mobile/shared/views/app_log_page.dart';
 import 'package:immich_mobile/shared/views/app_log_page.dart';
 import 'package:immich_mobile/shared/views/splash_screen.dart';
 import 'package:immich_mobile/shared/views/splash_screen.dart';
 import 'package:immich_mobile/shared/views/tab_controller_page.dart';
 import 'package:immich_mobile/shared/views/tab_controller_page.dart';
+import 'package:isar/isar.dart';
 import 'package:photo_manager/photo_manager.dart';
 import 'package:photo_manager/photo_manager.dart';
 
 
 part 'router.gr.dart';
 part 'router.gr.dart';

+ 19 - 7
mobile/lib/routing/router.gr.dart

@@ -71,6 +71,7 @@ class _$AppRouter extends RootStackRouter {
           loadAsset: args.loadAsset,
           loadAsset: args.loadAsset,
           totalAssets: args.totalAssets,
           totalAssets: args.totalAssets,
           heroOffset: args.heroOffset,
           heroOffset: args.heroOffset,
+          showStack: args.showStack,
         ),
         ),
       );
       );
     },
     },
@@ -153,7 +154,8 @@ class _$AppRouter extends RootStackRouter {
         child: AssetSelectionPage(
         child: AssetSelectionPage(
           key: args.key,
           key: args.key,
           existingAssets: args.existingAssets,
           existingAssets: args.existingAssets,
-          isNewAlbum: args.isNewAlbum,
+          canDeselect: args.canDeselect,
+          query: args.query,
         ),
         ),
         transitionsBuilder: TransitionsBuilders.slideBottom,
         transitionsBuilder: TransitionsBuilders.slideBottom,
         opaque: true,
         opaque: true,
@@ -711,6 +713,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
     required Asset Function(int) loadAsset,
     required Asset Function(int) loadAsset,
     required int totalAssets,
     required int totalAssets,
     int heroOffset = 0,
     int heroOffset = 0,
+    bool showStack = false,
   }) : super(
   }) : super(
           GalleryViewerRoute.name,
           GalleryViewerRoute.name,
           path: '/gallery-viewer-page',
           path: '/gallery-viewer-page',
@@ -720,6 +723,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
             loadAsset: loadAsset,
             loadAsset: loadAsset,
             totalAssets: totalAssets,
             totalAssets: totalAssets,
             heroOffset: heroOffset,
             heroOffset: heroOffset,
+            showStack: showStack,
           ),
           ),
         );
         );
 
 
@@ -733,6 +737,7 @@ class GalleryViewerRouteArgs {
     required this.loadAsset,
     required this.loadAsset,
     required this.totalAssets,
     required this.totalAssets,
     this.heroOffset = 0,
     this.heroOffset = 0,
+    this.showStack = false,
   });
   });
 
 
   final Key? key;
   final Key? key;
@@ -745,9 +750,11 @@ class GalleryViewerRouteArgs {
 
 
   final int heroOffset;
   final int heroOffset;
 
 
+  final bool showStack;
+
   @override
   @override
   String toString() {
   String toString() {
-    return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset}';
+    return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack}';
   }
   }
 }
 }
 
 
@@ -961,14 +968,16 @@ class AssetSelectionRoute extends PageRouteInfo<AssetSelectionRouteArgs> {
   AssetSelectionRoute({
   AssetSelectionRoute({
     Key? key,
     Key? key,
     required Set<Asset> existingAssets,
     required Set<Asset> existingAssets,
-    bool isNewAlbum = false,
+    bool canDeselect = false,
+    required QueryBuilder<Asset, Asset, QAfterSortBy>? query,
   }) : super(
   }) : super(
           AssetSelectionRoute.name,
           AssetSelectionRoute.name,
           path: '/asset-selection-page',
           path: '/asset-selection-page',
           args: AssetSelectionRouteArgs(
           args: AssetSelectionRouteArgs(
             key: key,
             key: key,
             existingAssets: existingAssets,
             existingAssets: existingAssets,
-            isNewAlbum: isNewAlbum,
+            canDeselect: canDeselect,
+            query: query,
           ),
           ),
         );
         );
 
 
@@ -979,18 +988,21 @@ class AssetSelectionRouteArgs {
   const AssetSelectionRouteArgs({
   const AssetSelectionRouteArgs({
     this.key,
     this.key,
     required this.existingAssets,
     required this.existingAssets,
-    this.isNewAlbum = false,
+    this.canDeselect = false,
+    required this.query,
   });
   });
 
 
   final Key? key;
   final Key? key;
 
 
   final Set<Asset> existingAssets;
   final Set<Asset> existingAssets;
 
 
-  final bool isNewAlbum;
+  final bool canDeselect;
+
+  final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
 
 
   @override
   @override
   String toString() {
   String toString() {
-    return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, isNewAlbum: $isNewAlbum}';
+    return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, canDeselect: $canDeselect, query: $query}';
   }
   }
 }
 }
 
 

+ 29 - 4
mobile/lib/shared/models/asset.dart

@@ -31,7 +31,9 @@ class Asset {
             remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
             remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
         isFavorite = remote.isFavorite,
         isFavorite = remote.isFavorite,
         isArchived = remote.isArchived,
         isArchived = remote.isArchived,
-        isTrashed = remote.isTrashed;
+        isTrashed = remote.isTrashed,
+        stackParentId = remote.stackParentId,
+        stackCount = remote.stackCount;
 
 
   Asset.local(AssetEntity local, List<int> hash)
   Asset.local(AssetEntity local, List<int> hash)
       : localId = local.id,
       : localId = local.id,
@@ -47,6 +49,7 @@ class Asset {
         isFavorite = local.isFavorite,
         isFavorite = local.isFavorite,
         isArchived = false,
         isArchived = false,
         isTrashed = false,
         isTrashed = false,
+        stackCount = 0,
         fileCreatedAt = local.createDateTime {
         fileCreatedAt = local.createDateTime {
     if (fileCreatedAt.year == 1970) {
     if (fileCreatedAt.year == 1970) {
       fileCreatedAt = fileModifiedAt;
       fileCreatedAt = fileModifiedAt;
@@ -77,6 +80,8 @@ class Asset {
     required this.isFavorite,
     required this.isFavorite,
     required this.isArchived,
     required this.isArchived,
     required this.isTrashed,
     required this.isTrashed,
+    this.stackParentId,
+    required this.stackCount,
   });
   });
 
 
   @ignore
   @ignore
@@ -146,6 +151,10 @@ class Asset {
   @ignore
   @ignore
   ExifInfo? exifInfo;
   ExifInfo? exifInfo;
 
 
+  String? stackParentId;
+
+  int stackCount;
+
   /// `true` if this [Asset] is present on the device
   /// `true` if this [Asset] is present on the device
   @ignore
   @ignore
   bool get isLocal => localId != null;
   bool get isLocal => localId != null;
@@ -200,7 +209,9 @@ class Asset {
         isFavorite == other.isFavorite &&
         isFavorite == other.isFavorite &&
         isLocal == other.isLocal &&
         isLocal == other.isLocal &&
         isArchived == other.isArchived &&
         isArchived == other.isArchived &&
-        isTrashed == other.isTrashed;
+        isTrashed == other.isTrashed &&
+        stackCount == other.stackCount &&
+        stackParentId == other.stackParentId;
   }
   }
 
 
   @override
   @override
@@ -223,7 +234,9 @@ class Asset {
       isFavorite.hashCode ^
       isFavorite.hashCode ^
       isLocal.hashCode ^
       isLocal.hashCode ^
       isArchived.hashCode ^
       isArchived.hashCode ^
-      isTrashed.hashCode;
+      isTrashed.hashCode ^
+      stackCount.hashCode ^
+      stackParentId.hashCode;
 
 
   /// Returns `true` if this [Asset] can updated with values from parameter [a]
   /// Returns `true` if this [Asset] can updated with values from parameter [a]
   bool canUpdate(Asset a) {
   bool canUpdate(Asset a) {
@@ -236,9 +249,11 @@ class Asset {
         width == null && a.width != null ||
         width == null && a.width != null ||
         height == null && a.height != null ||
         height == null && a.height != null ||
         livePhotoVideoId == null && a.livePhotoVideoId != null ||
         livePhotoVideoId == null && a.livePhotoVideoId != null ||
+        stackParentId == null && a.stackParentId != null ||
         isFavorite != a.isFavorite ||
         isFavorite != a.isFavorite ||
         isArchived != a.isArchived ||
         isArchived != a.isArchived ||
-        isTrashed != a.isTrashed;
+        isTrashed != a.isTrashed ||
+        stackCount != a.stackCount;
   }
   }
 
 
   /// Returns a new [Asset] with values from this and merged & updated with [a]
   /// Returns a new [Asset] with values from this and merged & updated with [a]
@@ -267,6 +282,8 @@ class Asset {
           id: id,
           id: id,
           remoteId: remoteId,
           remoteId: remoteId,
           livePhotoVideoId: livePhotoVideoId,
           livePhotoVideoId: livePhotoVideoId,
+          stackParentId: stackParentId,
+          stackCount: stackCount,
           isFavorite: isFavorite,
           isFavorite: isFavorite,
           isArchived: isArchived,
           isArchived: isArchived,
           isTrashed: isTrashed,
           isTrashed: isTrashed,
@@ -281,6 +298,8 @@ class Asset {
           width: a.width,
           width: a.width,
           height: a.height,
           height: a.height,
           livePhotoVideoId: a.livePhotoVideoId,
           livePhotoVideoId: a.livePhotoVideoId,
+          stackParentId: a.stackParentId,
+          stackCount: a.stackCount,
           // isFavorite + isArchived are not set by device-only assets
           // isFavorite + isArchived are not set by device-only assets
           isFavorite: a.isFavorite,
           isFavorite: a.isFavorite,
           isArchived: a.isArchived,
           isArchived: a.isArchived,
@@ -318,6 +337,8 @@ class Asset {
     bool? isArchived,
     bool? isArchived,
     bool? isTrashed,
     bool? isTrashed,
     ExifInfo? exifInfo,
     ExifInfo? exifInfo,
+    String? stackParentId,
+    int? stackCount,
   }) =>
   }) =>
       Asset(
       Asset(
         id: id ?? this.id,
         id: id ?? this.id,
@@ -338,6 +359,8 @@ class Asset {
         isArchived: isArchived ?? this.isArchived,
         isArchived: isArchived ?? this.isArchived,
         isTrashed: isTrashed ?? this.isTrashed,
         isTrashed: isTrashed ?? this.isTrashed,
         exifInfo: exifInfo ?? this.exifInfo,
         exifInfo: exifInfo ?? this.exifInfo,
+        stackParentId: stackParentId ?? this.stackParentId,
+        stackCount: stackCount ?? this.stackCount,
       );
       );
 
 
   Future<void> put(Isar db) async {
   Future<void> put(Isar db) async {
@@ -379,6 +402,8 @@ class Asset {
   "checksum": "$checksum",
   "checksum": "$checksum",
   "ownerId": $ownerId, 
   "ownerId": $ownerId, 
   "livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
   "livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
+  "stackCount": "$stackCount",
+  "stackParentId": "${stackParentId ?? "N/A"}",
   "fileCreatedAt": "$fileCreatedAt",
   "fileCreatedAt": "$fileCreatedAt",
   "fileModifiedAt": "$fileModifiedAt", 
   "fileModifiedAt": "$fileModifiedAt", 
   "updatedAt": "$updatedAt", 
   "updatedAt": "$updatedAt", 

+ 308 - 11
mobile/lib/shared/models/asset.g.dart

@@ -82,19 +82,29 @@ const AssetSchema = CollectionSchema(
       name: r'remoteId',
       name: r'remoteId',
       type: IsarType.string,
       type: IsarType.string,
     ),
     ),
-    r'type': PropertySchema(
+    r'stackCount': PropertySchema(
       id: 13,
       id: 13,
+      name: r'stackCount',
+      type: IsarType.long,
+    ),
+    r'stackParentId': PropertySchema(
+      id: 14,
+      name: r'stackParentId',
+      type: IsarType.string,
+    ),
+    r'type': PropertySchema(
+      id: 15,
       name: r'type',
       name: r'type',
       type: IsarType.byte,
       type: IsarType.byte,
       enumMap: _AssettypeEnumValueMap,
       enumMap: _AssettypeEnumValueMap,
     ),
     ),
     r'updatedAt': PropertySchema(
     r'updatedAt': PropertySchema(
-      id: 14,
+      id: 16,
       name: r'updatedAt',
       name: r'updatedAt',
       type: IsarType.dateTime,
       type: IsarType.dateTime,
     ),
     ),
     r'width': PropertySchema(
     r'width': PropertySchema(
-      id: 15,
+      id: 17,
       name: r'width',
       name: r'width',
       type: IsarType.int,
       type: IsarType.int,
     )
     )
@@ -184,6 +194,12 @@ int _assetEstimateSize(
       bytesCount += 3 + value.length * 3;
       bytesCount += 3 + value.length * 3;
     }
     }
   }
   }
+  {
+    final value = object.stackParentId;
+    if (value != null) {
+      bytesCount += 3 + value.length * 3;
+    }
+  }
   return bytesCount;
   return bytesCount;
 }
 }
 
 
@@ -206,9 +222,11 @@ void _assetSerialize(
   writer.writeString(offsets[10], object.localId);
   writer.writeString(offsets[10], object.localId);
   writer.writeLong(offsets[11], object.ownerId);
   writer.writeLong(offsets[11], object.ownerId);
   writer.writeString(offsets[12], object.remoteId);
   writer.writeString(offsets[12], object.remoteId);
-  writer.writeByte(offsets[13], object.type.index);
-  writer.writeDateTime(offsets[14], object.updatedAt);
-  writer.writeInt(offsets[15], object.width);
+  writer.writeLong(offsets[13], object.stackCount);
+  writer.writeString(offsets[14], object.stackParentId);
+  writer.writeByte(offsets[15], object.type.index);
+  writer.writeDateTime(offsets[16], object.updatedAt);
+  writer.writeInt(offsets[17], object.width);
 }
 }
 
 
 Asset _assetDeserialize(
 Asset _assetDeserialize(
@@ -232,10 +250,12 @@ Asset _assetDeserialize(
     localId: reader.readStringOrNull(offsets[10]),
     localId: reader.readStringOrNull(offsets[10]),
     ownerId: reader.readLong(offsets[11]),
     ownerId: reader.readLong(offsets[11]),
     remoteId: reader.readStringOrNull(offsets[12]),
     remoteId: reader.readStringOrNull(offsets[12]),
-    type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[13])] ??
+    stackCount: reader.readLong(offsets[13]),
+    stackParentId: reader.readStringOrNull(offsets[14]),
+    type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[15])] ??
         AssetType.other,
         AssetType.other,
-    updatedAt: reader.readDateTime(offsets[14]),
-    width: reader.readIntOrNull(offsets[15]),
+    updatedAt: reader.readDateTime(offsets[16]),
+    width: reader.readIntOrNull(offsets[17]),
   );
   );
   return object;
   return object;
 }
 }
@@ -274,11 +294,15 @@ P _assetDeserializeProp<P>(
     case 12:
     case 12:
       return (reader.readStringOrNull(offset)) as P;
       return (reader.readStringOrNull(offset)) as P;
     case 13:
     case 13:
+      return (reader.readLong(offset)) as P;
+    case 14:
+      return (reader.readStringOrNull(offset)) as P;
+    case 15:
       return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
       return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
           AssetType.other) as P;
           AssetType.other) as P;
-    case 14:
+    case 16:
       return (reader.readDateTime(offset)) as P;
       return (reader.readDateTime(offset)) as P;
-    case 15:
+    case 17:
       return (reader.readIntOrNull(offset)) as P;
       return (reader.readIntOrNull(offset)) as P;
     default:
     default:
       throw IsarError('Unknown property with id $propertyId');
       throw IsarError('Unknown property with id $propertyId');
@@ -1801,6 +1825,205 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
     });
     });
   }
   }
 
 
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountEqualTo(
+      int value) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'stackCount',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountGreaterThan(
+    int value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'stackCount',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountLessThan(
+    int value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'stackCount',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountBetween(
+    int lower,
+    int upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'stackCount',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNull(
+        property: r'stackParentId',
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsNotNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNotNull(
+        property: r'stackParentId',
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdEqualTo(
+    String? value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'stackParentId',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdGreaterThan(
+    String? value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'stackParentId',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdLessThan(
+    String? value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'stackParentId',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdBetween(
+    String? lower,
+    String? upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'stackParentId',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdStartsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.startsWith(
+        property: r'stackParentId',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdEndsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.endsWith(
+        property: r'stackParentId',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdContains(
+      String value,
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.contains(
+        property: r'stackParentId',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdMatches(
+      String pattern,
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.matches(
+        property: r'stackParentId',
+        wildcard: pattern,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'stackParentId',
+        value: '',
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsNotEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        property: r'stackParentId',
+        value: '',
+      ));
+    });
+  }
+
   QueryBuilder<Asset, Asset, QAfterFilterCondition> typeEqualTo(
   QueryBuilder<Asset, Asset, QAfterFilterCondition> typeEqualTo(
       AssetType value) {
       AssetType value) {
     return QueryBuilder.apply(this, (query) {
     return QueryBuilder.apply(this, (query) {
@@ -2137,6 +2360,30 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
     });
     });
   }
   }
 
 
+  QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackCount() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'stackCount', Sort.asc);
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackCountDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'stackCount', Sort.desc);
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackParentId() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'stackParentId', Sort.asc);
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackParentIdDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'stackParentId', Sort.desc);
+    });
+  }
+
   QueryBuilder<Asset, Asset, QAfterSortBy> sortByType() {
   QueryBuilder<Asset, Asset, QAfterSortBy> sortByType() {
     return QueryBuilder.apply(this, (query) {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'type', Sort.asc);
       return query.addSortBy(r'type', Sort.asc);
@@ -2343,6 +2590,30 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
     });
     });
   }
   }
 
 
+  QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackCount() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'stackCount', Sort.asc);
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackCountDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'stackCount', Sort.desc);
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackParentId() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'stackParentId', Sort.asc);
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackParentIdDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'stackParentId', Sort.desc);
+    });
+  }
+
   QueryBuilder<Asset, Asset, QAfterSortBy> thenByType() {
   QueryBuilder<Asset, Asset, QAfterSortBy> thenByType() {
     return QueryBuilder.apply(this, (query) {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'type', Sort.asc);
       return query.addSortBy(r'type', Sort.asc);
@@ -2465,6 +2736,20 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
     });
     });
   }
   }
 
 
+  QueryBuilder<Asset, Asset, QDistinct> distinctByStackCount() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'stackCount');
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QDistinct> distinctByStackParentId(
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'stackParentId',
+          caseSensitive: caseSensitive);
+    });
+  }
+
   QueryBuilder<Asset, Asset, QDistinct> distinctByType() {
   QueryBuilder<Asset, Asset, QDistinct> distinctByType() {
     return QueryBuilder.apply(this, (query) {
     return QueryBuilder.apply(this, (query) {
       return query.addDistinctBy(r'type');
       return query.addDistinctBy(r'type');
@@ -2569,6 +2854,18 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
     });
     });
   }
   }
 
 
+  QueryBuilder<Asset, int, QQueryOperations> stackCountProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'stackCount');
+    });
+  }
+
+  QueryBuilder<Asset, String?, QQueryOperations> stackParentIdProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'stackParentId');
+    });
+  }
+
   QueryBuilder<Asset, AssetType, QQueryOperations> typeProperty() {
   QueryBuilder<Asset, AssetType, QQueryOperations> typeProperty() {
     return QueryBuilder.apply(this, (query) {
     return QueryBuilder.apply(this, (query) {
       return query.addPropertyName(r'type');
       return query.addPropertyName(r'type');

+ 37 - 11
mobile/lib/shared/providers/asset.provider.dart

@@ -5,6 +5,7 @@ import 'package:immich_mobile/shared/models/exif_info.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/shared/providers/user.provider.dart';
 import 'package:immich_mobile/shared/services/asset.service.dart';
 import 'package:immich_mobile/shared/services/asset.service.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
@@ -217,6 +218,7 @@ final assetsProvider =
       .filter()
       .filter()
       .isArchivedEqualTo(false)
       .isArchivedEqualTo(false)
       .isTrashedEqualTo(false)
       .isTrashedEqualTo(false)
+      .stackParentIdIsNull()
       .sortByFileCreatedAtDesc();
       .sortByFileCreatedAtDesc();
   final settings = ref.watch(appSettingsServiceProvider);
   final settings = ref.watch(appSettingsServiceProvider);
   final groupBy =
   final groupBy =
@@ -227,10 +229,12 @@ final assetsProvider =
   }
   }
 });
 });
 
 
-final remoteAssetsProvider =
-    StreamProvider.family<RenderList, int?>((ref, userId) async* {
-  if (userId == null) return;
-  final query = ref
+QueryBuilder<Asset, Asset, QAfterSortBy>? getRemoteAssetQuery(WidgetRef ref) {
+  final userId = ref.watch(currentUserProvider)?.isarId;
+  if (userId == null) {
+    return null;
+  }
+  return ref
       .watch(dbProvider)
       .watch(dbProvider)
       .assets
       .assets
       .where()
       .where()
@@ -238,12 +242,34 @@ final remoteAssetsProvider =
       .filter()
       .filter()
       .ownerIdEqualTo(userId)
       .ownerIdEqualTo(userId)
       .isTrashedEqualTo(false)
       .isTrashedEqualTo(false)
+      .stackParentIdIsNull()
       .sortByFileCreatedAtDesc();
       .sortByFileCreatedAtDesc();
-  final settings = ref.watch(appSettingsServiceProvider);
-  final groupBy =
-      GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
-  yield await RenderList.fromQuery(query, groupBy);
-  await for (final _ in query.watchLazy()) {
-    yield await RenderList.fromQuery(query, groupBy);
+}
+
+QueryBuilder<Asset, Asset, QAfterSortBy>? getAssetStackSelectionQuery(
+  WidgetRef ref,
+  Asset parentAsset,
+) {
+  final userId = ref.watch(currentUserProvider)?.isarId;
+  if (userId == null || !parentAsset.isRemote) {
+    return null;
   }
   }
-});
+  return ref
+      .watch(dbProvider)
+      .assets
+      .where()
+      .remoteIdIsNotNull()
+      .filter()
+      .isArchivedEqualTo(false)
+      .ownerIdEqualTo(userId)
+      .not()
+      .remoteIdEqualTo(parentAsset.remoteId)
+      // Show existing stack children in selection page
+      .group(
+        (q) => q
+            .stackParentIdIsNull()
+            .or()
+            .stackParentIdEqualTo(parentAsset.remoteId),
+      )
+      .sortByFileCreatedAtDesc();
+}

+ 1 - 0
mobile/lib/shared/providers/websocket.provider.dart

@@ -133,6 +133,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
         socket.on('on_asset_delete', _handleOnAssetDelete);
         socket.on('on_asset_delete', _handleOnAssetDelete);
         socket.on('on_asset_trash', _handleServerUpdates);
         socket.on('on_asset_trash', _handleServerUpdates);
         socket.on('on_asset_restore', _handleServerUpdates);
         socket.on('on_asset_restore', _handleServerUpdates);
+        socket.on('on_asset_update', _handleServerUpdates);
       } catch (e) {
       } catch (e) {
         debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
         debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
       }
       }

+ 3 - 0
mobile/openapi/.openapi-generator/FILES

@@ -149,6 +149,7 @@ doc/TranscodePolicy.md
 doc/UpdateAlbumDto.md
 doc/UpdateAlbumDto.md
 doc/UpdateAssetDto.md
 doc/UpdateAssetDto.md
 doc/UpdateLibraryDto.md
 doc/UpdateLibraryDto.md
+doc/UpdateStackParentDto.md
 doc/UpdateTagDto.md
 doc/UpdateTagDto.md
 doc/UpdateUserDto.md
 doc/UpdateUserDto.md
 doc/UsageByUserDto.md
 doc/UsageByUserDto.md
@@ -314,6 +315,7 @@ lib/model/transcode_policy.dart
 lib/model/update_album_dto.dart
 lib/model/update_album_dto.dart
 lib/model/update_asset_dto.dart
 lib/model/update_asset_dto.dart
 lib/model/update_library_dto.dart
 lib/model/update_library_dto.dart
+lib/model/update_stack_parent_dto.dart
 lib/model/update_tag_dto.dart
 lib/model/update_tag_dto.dart
 lib/model/update_user_dto.dart
 lib/model/update_user_dto.dart
 lib/model/usage_by_user_dto.dart
 lib/model/usage_by_user_dto.dart
@@ -468,6 +470,7 @@ test/transcode_policy_test.dart
 test/update_album_dto_test.dart
 test/update_album_dto_test.dart
 test/update_asset_dto_test.dart
 test/update_asset_dto_test.dart
 test/update_library_dto_test.dart
 test/update_library_dto_test.dart
+test/update_stack_parent_dto_test.dart
 test/update_tag_dto_test.dart
 test/update_tag_dto_test.dart
 test/update_user_dto_test.dart
 test/update_user_dto_test.dart
 test/usage_by_user_dto_test.dart
 test/usage_by_user_dto_test.dart

+ 2 - 0
mobile/openapi/README.md

@@ -116,6 +116,7 @@ Class | Method | HTTP request | Description
 *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 *AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} | 
 *AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} | 
 *AssetApi* | [**updateAssets**](doc//AssetApi.md#updateassets) | **PUT** /asset | 
 *AssetApi* | [**updateAssets**](doc//AssetApi.md#updateassets) | **PUT** /asset | 
+*AssetApi* | [**updateStackParent**](doc//AssetApi.md#updatestackparent) | **PUT** /asset/stack/parent | 
 *AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload | 
 *AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload | 
 *AuditApi* | [**fixAuditFiles**](doc//AuditApi.md#fixauditfiles) | **POST** /audit/file-report/fix | 
 *AuditApi* | [**fixAuditFiles**](doc//AuditApi.md#fixauditfiles) | **POST** /audit/file-report/fix | 
 *AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes | 
 *AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes | 
@@ -330,6 +331,7 @@ Class | Method | HTTP request | Description
  - [UpdateAlbumDto](doc//UpdateAlbumDto.md)
  - [UpdateAlbumDto](doc//UpdateAlbumDto.md)
  - [UpdateAssetDto](doc//UpdateAssetDto.md)
  - [UpdateAssetDto](doc//UpdateAssetDto.md)
  - [UpdateLibraryDto](doc//UpdateLibraryDto.md)
  - [UpdateLibraryDto](doc//UpdateLibraryDto.md)
+ - [UpdateStackParentDto](doc//UpdateStackParentDto.md)
  - [UpdateTagDto](doc//UpdateTagDto.md)
  - [UpdateTagDto](doc//UpdateTagDto.md)
  - [UpdateUserDto](doc//UpdateUserDto.md)
  - [UpdateUserDto](doc//UpdateUserDto.md)
  - [UsageByUserDto](doc//UsageByUserDto.md)
  - [UsageByUserDto](doc//UsageByUserDto.md)

+ 55 - 0
mobile/openapi/doc/AssetApi.md

@@ -38,6 +38,7 @@ Method | HTTP request | Description
 [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 [**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} | 
 [**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} | 
 [**updateAssets**](AssetApi.md#updateassets) | **PUT** /asset | 
 [**updateAssets**](AssetApi.md#updateassets) | **PUT** /asset | 
+[**updateStackParent**](AssetApi.md#updatestackparent) | **PUT** /asset/stack/parent | 
 [**uploadFile**](AssetApi.md#uploadfile) | **POST** /asset/upload | 
 [**uploadFile**](AssetApi.md#uploadfile) | **POST** /asset/upload | 
 
 
 
 
@@ -1696,6 +1697,60 @@ void (empty response body)
 
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
+# **updateStackParent**
+> updateStackParent(updateStackParentDto)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = AssetApi();
+final updateStackParentDto = UpdateStackParentDto(); // UpdateStackParentDto | 
+
+try {
+    api_instance.updateStackParent(updateStackParentDto);
+} catch (e) {
+    print('Exception when calling AssetApi->updateStackParent: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **updateStackParentDto** | [**UpdateStackParentDto**](UpdateStackParentDto.md)|  | 
+
+### Return type
+
+void (empty response body)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **Accept**: Not defined
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **uploadFile**
 # **uploadFile**
 > AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData)
 > AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData)
 
 

+ 2 - 0
mobile/openapi/doc/AssetBulkUpdateDto.md

@@ -11,6 +11,8 @@ Name | Type | Description | Notes
 **ids** | **List<String>** |  | [default to const []]
 **ids** | **List<String>** |  | [default to const []]
 **isArchived** | **bool** |  | [optional] 
 **isArchived** | **bool** |  | [optional] 
 **isFavorite** | **bool** |  | [optional] 
 **isFavorite** | **bool** |  | [optional] 
+**removeParent** | **bool** |  | [optional] 
+**stackParentId** | **String** |  | [optional] 
 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 
 

+ 3 - 0
mobile/openapi/doc/AssetResponseDto.md

@@ -33,6 +33,9 @@ Name | Type | Description | Notes
 **people** | [**List<PersonResponseDto>**](PersonResponseDto.md) |  | [optional] [default to const []]
 **people** | [**List<PersonResponseDto>**](PersonResponseDto.md) |  | [optional] [default to const []]
 **resized** | **bool** |  | 
 **resized** | **bool** |  | 
 **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) |  | [optional] 
 **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) |  | [optional] 
+**stack** | [**List<AssetResponseDto>**](AssetResponseDto.md) |  | [optional] [default to const []]
+**stackCount** | **int** |  | 
+**stackParentId** | **String** |  | [optional] 
 **tags** | [**List<TagResponseDto>**](TagResponseDto.md) |  | [optional] [default to const []]
 **tags** | [**List<TagResponseDto>**](TagResponseDto.md) |  | [optional] [default to const []]
 **thumbhash** | **String** |  | 
 **thumbhash** | **String** |  | 
 **type** | [**AssetTypeEnum**](AssetTypeEnum.md) |  | 
 **type** | [**AssetTypeEnum**](AssetTypeEnum.md) |  | 

+ 16 - 0
mobile/openapi/doc/UpdateStackParentDto.md

@@ -0,0 +1,16 @@
+# openapi.model.UpdateStackParentDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**newParentId** | **String** |  | 
+**oldParentId** | **String** |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 1 - 0
mobile/openapi/lib/api.dart

@@ -176,6 +176,7 @@ part 'model/transcode_policy.dart';
 part 'model/update_album_dto.dart';
 part 'model/update_album_dto.dart';
 part 'model/update_asset_dto.dart';
 part 'model/update_asset_dto.dart';
 part 'model/update_library_dto.dart';
 part 'model/update_library_dto.dart';
+part 'model/update_stack_parent_dto.dart';
 part 'model/update_tag_dto.dart';
 part 'model/update_tag_dto.dart';
 part 'model/update_user_dto.dart';
 part 'model/update_user_dto.dart';
 part 'model/usage_by_user_dto.dart';
 part 'model/usage_by_user_dto.dart';

+ 39 - 0
mobile/openapi/lib/api/asset_api.dart

@@ -1654,6 +1654,45 @@ class AssetApi {
     }
     }
   }
   }
 
 
+  /// Performs an HTTP 'PUT /asset/stack/parent' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [UpdateStackParentDto] updateStackParentDto (required):
+  Future<Response> updateStackParentWithHttpInfo(UpdateStackParentDto updateStackParentDto,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/asset/stack/parent';
+
+    // ignore: prefer_final_locals
+    Object? postBody = updateStackParentDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'PUT',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [UpdateStackParentDto] updateStackParentDto (required):
+  Future<void> updateStackParent(UpdateStackParentDto updateStackParentDto,) async {
+    final response = await updateStackParentWithHttpInfo(updateStackParentDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+
   /// Performs an HTTP 'POST /asset/upload' operation and returns the [Response].
   /// Performs an HTTP 'POST /asset/upload' operation and returns the [Response].
   /// Parameters:
   /// Parameters:
   ///
   ///

+ 2 - 0
mobile/openapi/lib/api_client.dart

@@ -443,6 +443,8 @@ class ApiClient {
           return UpdateAssetDto.fromJson(value);
           return UpdateAssetDto.fromJson(value);
         case 'UpdateLibraryDto':
         case 'UpdateLibraryDto':
           return UpdateLibraryDto.fromJson(value);
           return UpdateLibraryDto.fromJson(value);
+        case 'UpdateStackParentDto':
+          return UpdateStackParentDto.fromJson(value);
         case 'UpdateTagDto':
         case 'UpdateTagDto':
           return UpdateTagDto.fromJson(value);
           return UpdateTagDto.fromJson(value);
         case 'UpdateUserDto':
         case 'UpdateUserDto':

+ 37 - 3
mobile/openapi/lib/model/asset_bulk_update_dto.dart

@@ -16,6 +16,8 @@ class AssetBulkUpdateDto {
     this.ids = const [],
     this.ids = const [],
     this.isArchived,
     this.isArchived,
     this.isFavorite,
     this.isFavorite,
+    this.removeParent,
+    this.stackParentId,
   });
   });
 
 
   List<String> ids;
   List<String> ids;
@@ -36,21 +38,41 @@ class AssetBulkUpdateDto {
   ///
   ///
   bool? isFavorite;
   bool? isFavorite;
 
 
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  bool? removeParent;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  String? stackParentId;
+
   @override
   @override
   bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
   bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
      other.ids == ids &&
      other.ids == ids &&
      other.isArchived == isArchived &&
      other.isArchived == isArchived &&
-     other.isFavorite == isFavorite;
+     other.isFavorite == isFavorite &&
+     other.removeParent == removeParent &&
+     other.stackParentId == stackParentId;
 
 
   @override
   @override
   int get hashCode =>
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     // ignore: unnecessary_parenthesis
     (ids.hashCode) +
     (ids.hashCode) +
     (isArchived == null ? 0 : isArchived!.hashCode) +
     (isArchived == null ? 0 : isArchived!.hashCode) +
-    (isFavorite == null ? 0 : isFavorite!.hashCode);
+    (isFavorite == null ? 0 : isFavorite!.hashCode) +
+    (removeParent == null ? 0 : removeParent!.hashCode) +
+    (stackParentId == null ? 0 : stackParentId!.hashCode);
 
 
   @override
   @override
-  String toString() => 'AssetBulkUpdateDto[ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite]';
+  String toString() => 'AssetBulkUpdateDto[ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, removeParent=$removeParent, stackParentId=$stackParentId]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
@@ -65,6 +87,16 @@ class AssetBulkUpdateDto {
     } else {
     } else {
     //  json[r'isFavorite'] = null;
     //  json[r'isFavorite'] = null;
     }
     }
+    if (this.removeParent != null) {
+      json[r'removeParent'] = this.removeParent;
+    } else {
+    //  json[r'removeParent'] = null;
+    }
+    if (this.stackParentId != null) {
+      json[r'stackParentId'] = this.stackParentId;
+    } else {
+    //  json[r'stackParentId'] = null;
+    }
     return json;
     return json;
   }
   }
 
 
@@ -81,6 +113,8 @@ class AssetBulkUpdateDto {
             : const [],
             : const [],
         isArchived: mapValueOfType<bool>(json, r'isArchived'),
         isArchived: mapValueOfType<bool>(json, r'isArchived'),
         isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
         isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
+        removeParent: mapValueOfType<bool>(json, r'removeParent'),
+        stackParentId: mapValueOfType<String>(json, r'stackParentId'),
       );
       );
     }
     }
     return null;
     return null;

+ 27 - 1
mobile/openapi/lib/model/asset_response_dto.dart

@@ -38,6 +38,9 @@ class AssetResponseDto {
     this.people = const [],
     this.people = const [],
     required this.resized,
     required this.resized,
     this.smartInfo,
     this.smartInfo,
+    this.stack = const [],
+    required this.stackCount,
+    this.stackParentId,
     this.tags = const [],
     this.tags = const [],
     required this.thumbhash,
     required this.thumbhash,
     required this.type,
     required this.type,
@@ -113,6 +116,12 @@ class AssetResponseDto {
   ///
   ///
   SmartInfoResponseDto? smartInfo;
   SmartInfoResponseDto? smartInfo;
 
 
+  List<AssetResponseDto> stack;
+
+  int stackCount;
+
+  String? stackParentId;
+
   List<TagResponseDto> tags;
   List<TagResponseDto> tags;
 
 
   String? thumbhash;
   String? thumbhash;
@@ -148,6 +157,9 @@ class AssetResponseDto {
      other.people == people &&
      other.people == people &&
      other.resized == resized &&
      other.resized == resized &&
      other.smartInfo == smartInfo &&
      other.smartInfo == smartInfo &&
+     other.stack == stack &&
+     other.stackCount == stackCount &&
+     other.stackParentId == stackParentId &&
      other.tags == tags &&
      other.tags == tags &&
      other.thumbhash == thumbhash &&
      other.thumbhash == thumbhash &&
      other.type == type &&
      other.type == type &&
@@ -181,13 +193,16 @@ class AssetResponseDto {
     (people.hashCode) +
     (people.hashCode) +
     (resized.hashCode) +
     (resized.hashCode) +
     (smartInfo == null ? 0 : smartInfo!.hashCode) +
     (smartInfo == null ? 0 : smartInfo!.hashCode) +
+    (stack.hashCode) +
+    (stackCount.hashCode) +
+    (stackParentId == null ? 0 : stackParentId!.hashCode) +
     (tags.hashCode) +
     (tags.hashCode) +
     (thumbhash == null ? 0 : thumbhash!.hashCode) +
     (thumbhash == null ? 0 : thumbhash!.hashCode) +
     (type.hashCode) +
     (type.hashCode) +
     (updatedAt.hashCode);
     (updatedAt.hashCode);
 
 
   @override
   @override
-  String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
+  String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, stackCount=$stackCount, stackParentId=$stackParentId, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
@@ -231,6 +246,13 @@ class AssetResponseDto {
       json[r'smartInfo'] = this.smartInfo;
       json[r'smartInfo'] = this.smartInfo;
     } else {
     } else {
     //  json[r'smartInfo'] = null;
     //  json[r'smartInfo'] = null;
+    }
+      json[r'stack'] = this.stack;
+      json[r'stackCount'] = this.stackCount;
+    if (this.stackParentId != null) {
+      json[r'stackParentId'] = this.stackParentId;
+    } else {
+    //  json[r'stackParentId'] = null;
     }
     }
       json[r'tags'] = this.tags;
       json[r'tags'] = this.tags;
     if (this.thumbhash != null) {
     if (this.thumbhash != null) {
@@ -276,6 +298,9 @@ class AssetResponseDto {
         people: PersonResponseDto.listFromJson(json[r'people']),
         people: PersonResponseDto.listFromJson(json[r'people']),
         resized: mapValueOfType<bool>(json, r'resized')!,
         resized: mapValueOfType<bool>(json, r'resized')!,
         smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
         smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
+        stack: AssetResponseDto.listFromJson(json[r'stack']),
+        stackCount: mapValueOfType<int>(json, r'stackCount')!,
+        stackParentId: mapValueOfType<String>(json, r'stackParentId'),
         tags: TagResponseDto.listFromJson(json[r'tags']),
         tags: TagResponseDto.listFromJson(json[r'tags']),
         thumbhash: mapValueOfType<String>(json, r'thumbhash'),
         thumbhash: mapValueOfType<String>(json, r'thumbhash'),
         type: AssetTypeEnum.fromJson(json[r'type'])!,
         type: AssetTypeEnum.fromJson(json[r'type'])!,
@@ -347,6 +372,7 @@ class AssetResponseDto {
     'originalPath',
     'originalPath',
     'ownerId',
     'ownerId',
     'resized',
     'resized',
+    'stackCount',
     'thumbhash',
     'thumbhash',
     'type',
     'type',
     'updatedAt',
     'updatedAt',

+ 106 - 0
mobile/openapi/lib/model/update_stack_parent_dto.dart

@@ -0,0 +1,106 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class UpdateStackParentDto {
+  /// Returns a new [UpdateStackParentDto] instance.
+  UpdateStackParentDto({
+    required this.newParentId,
+    required this.oldParentId,
+  });
+
+  String newParentId;
+
+  String oldParentId;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is UpdateStackParentDto &&
+     other.newParentId == newParentId &&
+     other.oldParentId == oldParentId;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (newParentId.hashCode) +
+    (oldParentId.hashCode);
+
+  @override
+  String toString() => 'UpdateStackParentDto[newParentId=$newParentId, oldParentId=$oldParentId]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'newParentId'] = this.newParentId;
+      json[r'oldParentId'] = this.oldParentId;
+    return json;
+  }
+
+  /// Returns a new [UpdateStackParentDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static UpdateStackParentDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return UpdateStackParentDto(
+        newParentId: mapValueOfType<String>(json, r'newParentId')!,
+        oldParentId: mapValueOfType<String>(json, r'oldParentId')!,
+      );
+    }
+    return null;
+  }
+
+  static List<UpdateStackParentDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <UpdateStackParentDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = UpdateStackParentDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, UpdateStackParentDto> mapFromJson(dynamic json) {
+    final map = <String, UpdateStackParentDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = UpdateStackParentDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of UpdateStackParentDto-objects as value to a dart map
+  static Map<String, List<UpdateStackParentDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<UpdateStackParentDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = UpdateStackParentDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'newParentId',
+    'oldParentId',
+  };
+}
+

+ 5 - 0
mobile/openapi/test/asset_api_test.dart

@@ -174,6 +174,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    //Future updateStackParent(UpdateStackParentDto updateStackParentDto) async
+    test('test updateStackParent', () async {
+      // TODO
+    });
+
     //Future<AssetFileUploadResponseDto> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String key, String duration, bool isArchived, bool isExternal, bool isOffline, bool isReadOnly, bool isVisible, String libraryId, MultipartFile livePhotoData, MultipartFile sidecarData }) async
     //Future<AssetFileUploadResponseDto> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String key, String duration, bool isArchived, bool isExternal, bool isOffline, bool isReadOnly, bool isVisible, String libraryId, MultipartFile livePhotoData, MultipartFile sidecarData }) async
     test('test uploadFile', () async {
     test('test uploadFile', () async {
       // TODO
       // TODO

+ 10 - 0
mobile/openapi/test/asset_bulk_update_dto_test.dart

@@ -31,6 +31,16 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    // bool removeParent
+    test('to test the property `removeParent`', () async {
+      // TODO
+    });
+
+    // String stackParentId
+    test('to test the property `stackParentId`', () async {
+      // TODO
+    });
+
 
 
   });
   });
 
 

+ 15 - 0
mobile/openapi/test/asset_response_dto_test.dart

@@ -142,6 +142,21 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    // List<AssetResponseDto> stack (default value: const [])
+    test('to test the property `stack`', () async {
+      // TODO
+    });
+
+    // int stackCount
+    test('to test the property `stackCount`', () async {
+      // TODO
+    });
+
+    // String stackParentId
+    test('to test the property `stackParentId`', () async {
+      // TODO
+    });
+
     // List<TagResponseDto> tags (default value: const [])
     // List<TagResponseDto> tags (default value: const [])
     test('to test the property `tags`', () async {
     test('to test the property `tags`', () async {
       // TODO
       // TODO

+ 32 - 0
mobile/openapi/test/update_stack_parent_dto_test.dart

@@ -0,0 +1,32 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for UpdateStackParentDto
+void main() {
+  // final instance = UpdateStackParentDto();
+
+  group('test UpdateStackParentDto', () {
+    // String newParentId
+    test('to test the property `newParentId`', () async {
+      // TODO
+    });
+
+    // String oldParentId
+    test('to test the property `oldParentId`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 1 - 0
mobile/test/asset_grid_data_structure_test.dart

@@ -25,6 +25,7 @@ void main() {
         isFavorite: false,
         isFavorite: false,
         isArchived: false,
         isArchived: false,
         isTrashed: false,
         isTrashed: false,
+        stackCount: 0,
       ),
       ),
     );
     );
   }
   }

+ 1 - 0
mobile/test/sync_service_test.dart

@@ -35,6 +35,7 @@ void main() {
       isFavorite: false,
       isFavorite: false,
       isArchived: false,
       isArchived: false,
       isTrashed: false,
       isTrashed: false,
+      stackCount: 0,
     );
     );
   }
   }
 
 

+ 73 - 0
server/immich-openapi-specs.json

@@ -1673,6 +1673,41 @@
         ]
         ]
       }
       }
     },
     },
+    "/asset/stack/parent": {
+      "put": {
+        "operationId": "updateStackParent",
+        "parameters": [],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/UpdateStackParentDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Asset"
+        ]
+      }
+    },
     "/asset/statistics": {
     "/asset/statistics": {
       "get": {
       "get": {
         "operationId": "getAssetStats",
         "operationId": "getAssetStats",
@@ -5696,6 +5731,13 @@
           },
           },
           "isFavorite": {
           "isFavorite": {
             "type": "boolean"
             "type": "boolean"
+          },
+          "removeParent": {
+            "type": "boolean"
+          },
+          "stackParentId": {
+            "format": "uuid",
+            "type": "string"
           }
           }
         },
         },
         "required": [
         "required": [
@@ -5941,6 +5983,19 @@
           "smartInfo": {
           "smartInfo": {
             "$ref": "#/components/schemas/SmartInfoResponseDto"
             "$ref": "#/components/schemas/SmartInfoResponseDto"
           },
           },
+          "stack": {
+            "items": {
+              "$ref": "#/components/schemas/AssetResponseDto"
+            },
+            "type": "array"
+          },
+          "stackCount": {
+            "type": "integer"
+          },
+          "stackParentId": {
+            "nullable": true,
+            "type": "string"
+          },
           "tags": {
           "tags": {
             "items": {
             "items": {
               "$ref": "#/components/schemas/TagResponseDto"
               "$ref": "#/components/schemas/TagResponseDto"
@@ -5961,6 +6016,7 @@
         },
         },
         "required": [
         "required": [
           "type",
           "type",
+          "stackCount",
           "deviceAssetId",
           "deviceAssetId",
           "deviceId",
           "deviceId",
           "ownerId",
           "ownerId",
@@ -8521,6 +8577,23 @@
         },
         },
         "type": "object"
         "type": "object"
       },
       },
+      "UpdateStackParentDto": {
+        "properties": {
+          "newParentId": {
+            "format": "uuid",
+            "type": "string"
+          },
+          "oldParentId": {
+            "format": "uuid",
+            "type": "string"
+          }
+        },
+        "required": [
+          "oldParentId",
+          "newParentId"
+        ],
+        "type": "object"
+      },
       "UpdateTagDto": {
       "UpdateTagDto": {
         "properties": {
         "properties": {
           "name": {
           "name": {

+ 163 - 2
server/src/domain/asset/asset.service.spec.ts

@@ -20,6 +20,7 @@ import { Readable } from 'stream';
 import { JobName } from '../job';
 import { JobName } from '../job';
 import {
 import {
   AssetStats,
   AssetStats,
+  CommunicationEvent,
   IAssetRepository,
   IAssetRepository,
   ICommunicationRepository,
   ICommunicationRepository,
   ICryptoRepository,
   ICryptoRepository,
@@ -636,10 +637,89 @@ describe(AssetService.name, () => {
       await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
       await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
       expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
       expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
     });
     });
+
+    /// Stack related
+
+    it('should require asset update access for parent', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+      when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'parent').mockResolvedValue(false);
+      await expect(
+        sut.updateAll(authStub.user1, {
+          ids: ['asset-1'],
+          stackParentId: 'parent',
+        }),
+      ).rejects.toBeInstanceOf(BadRequestException);
+    });
+
+    it('should update parent asset when children are added', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+      await sut.updateAll(authStub.user1, {
+        ids: [],
+        stackParentId: 'parent',
+      }),
+        expect(assetMock.updateAll).toHaveBeenCalledWith(['parent'], { stackParentId: null });
+    });
+
+    it('should update parent asset when children are removed', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+      assetMock.getByIds.mockResolvedValue([{ id: 'child-1', stackParentId: 'parent' } as AssetEntity]);
+
+      await sut.updateAll(authStub.user1, {
+        ids: ['child-1'],
+        removeParent: true,
+      }),
+        expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['parent']), { stackParentId: null });
+    });
+
+    it('update parentId for new children', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+      await sut.updateAll(authStub.user1, {
+        stackParentId: 'parent',
+        ids: ['child-1', 'child-2'],
+      });
+
+      expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' });
+    });
+
+    it('nullify parentId for remove children', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+      await sut.updateAll(authStub.user1, {
+        removeParent: true,
+        ids: ['child-1', 'child-2'],
+      });
+
+      expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: null });
+    });
+
+    it('merge stacks if new child has children', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+      assetMock.getByIds.mockResolvedValue([
+        { id: 'child-1', stack: [{ id: 'child-2' } as AssetEntity] } as AssetEntity,
+      ]);
+
+      await sut.updateAll(authStub.user1, {
+        ids: ['child-1'],
+        stackParentId: 'parent',
+      });
+
+      expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' });
+    });
+
+    it('should send ws asset update event', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+      await sut.updateAll(authStub.user1, {
+        ids: ['asset-1'],
+        stackParentId: 'parent',
+      });
+
+      expect(communicationMock.send).toHaveBeenCalledWith(CommunicationEvent.ASSET_UPDATE, authStub.user1.id, [
+        'asset-1',
+      ]);
+    });
   });
   });
 
 
   describe('deleteAll', () => {
   describe('deleteAll', () => {
-    it('should required asset delete access for all ids', async () => {
+    it('should require asset delete access for all ids', async () => {
       accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
       accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
       await expect(
       await expect(
         sut.deleteAll(authStub.user1, {
         sut.deleteAll(authStub.user1, {
@@ -677,7 +757,7 @@ describe(AssetService.name, () => {
   });
   });
 
 
   describe('restoreAll', () => {
   describe('restoreAll', () => {
-    it('should required asset restore access for all ids', async () => {
+    it('should require asset restore access for all ids', async () => {
       accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
       accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
       await expect(
       await expect(
         sut.deleteAll(authStub.user1, {
         sut.deleteAll(authStub.user1, {
@@ -757,6 +837,21 @@ describe(AssetService.name, () => {
       expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace);
       expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace);
     });
     });
 
 
+    it('should update stack parent if asset has stack children', async () => {
+      when(assetMock.getById)
+        .calledWith(assetStub.primaryImage.id)
+        .mockResolvedValue(assetStub.primaryImage as AssetEntity);
+
+      await sut.handleAssetDeletion({ id: assetStub.primaryImage.id });
+
+      expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-2'], {
+        stackParentId: 'stack-child-asset-1',
+      });
+      expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-1'], {
+        stackParentId: null,
+      });
+    });
+
     it('should not schedule delete-files job for readonly assets', async () => {
     it('should not schedule delete-files job for readonly assets', async () => {
       when(assetMock.getById)
       when(assetMock.getById)
         .calledWith(assetStub.readOnly.id)
         .calledWith(assetStub.readOnly.id)
@@ -854,4 +949,70 @@ describe(AssetService.name, () => {
         expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } });
         expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } });
     });
     });
   });
   });
+
+  describe('updateStackParent', () => {
+    it('should require asset update access for new parent', async () => {
+      when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(true);
+      when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(false);
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
+      await expect(
+        sut.updateStackParent(authStub.user1, {
+          oldParentId: 'old',
+          newParentId: 'new',
+        }),
+      ).rejects.toBeInstanceOf(BadRequestException);
+    });
+
+    it('should require asset read access for old parent', async () => {
+      when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(false);
+      when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(true);
+      await expect(
+        sut.updateStackParent(authStub.user1, {
+          oldParentId: 'old',
+          newParentId: 'new',
+        }),
+      ).rejects.toBeInstanceOf(BadRequestException);
+    });
+
+    it('make old parent the child of new parent', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+      when(assetMock.getById)
+        .calledWith(assetStub.image.id)
+        .mockResolvedValue(assetStub.image as AssetEntity);
+
+      await sut.updateStackParent(authStub.user1, {
+        oldParentId: assetStub.image.id,
+        newParentId: 'new',
+      });
+
+      expect(assetMock.updateAll).toBeCalledWith([assetStub.image.id], { stackParentId: 'new' });
+    });
+
+    it('remove stackParentId of new parent', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+      await sut.updateStackParent(authStub.user1, {
+        oldParentId: assetStub.primaryImage.id,
+        newParentId: 'new',
+      });
+
+      expect(assetMock.updateAll).toBeCalledWith(['new'], { stackParentId: null });
+    });
+
+    it('update stackParentId of old parents children to new parent', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+      when(assetMock.getById)
+        .calledWith(assetStub.primaryImage.id)
+        .mockResolvedValue(assetStub.primaryImage as AssetEntity);
+
+      await sut.updateStackParent(authStub.user1, {
+        oldParentId: assetStub.primaryImage.id,
+        newParentId: 'new',
+      });
+
+      expect(assetMock.updateAll).toBeCalledWith(
+        [assetStub.primaryImage.id, 'stack-child-asset-1', 'stack-child-asset-2'],
+        { stackParentId: 'new' },
+      );
+    });
+  });
 });
 });

+ 49 - 2
server/src/domain/asset/asset.service.ts

@@ -40,6 +40,7 @@ import {
   TimeBucketDto,
   TimeBucketDto,
   TrashAction,
   TrashAction,
   UpdateAssetDto,
   UpdateAssetDto,
+  UpdateStackParentDto,
   mapStats,
   mapStats,
 } from './dto';
 } from './dto';
 import {
 import {
@@ -208,7 +209,7 @@ export class AssetService {
     if (authUser.isShowMetadata) {
     if (authUser.isShowMetadata) {
       return assets.map((asset) => mapAsset(asset));
       return assets.map((asset) => mapAsset(asset));
     } else {
     } else {
-      return assets.map((asset) => mapAsset(asset, true));
+      return assets.map((asset) => mapAsset(asset, { stripMetadata: true }));
     }
     }
   }
   }
 
 
@@ -338,10 +339,29 @@ export class AssetService {
   }
   }
 
 
   async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise<void> {
   async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise<void> {
-    const { ids, ...options } = dto;
+    const { ids, removeParent, ...options } = dto;
     await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
     await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
+
+    if (removeParent) {
+      (options as Partial<AssetEntity>).stackParentId = null;
+      const assets = await this.assetRepository.getByIds(ids);
+      // This updates the updatedAt column of the parents to indicate that one of its children is removed
+      // All the unique parent's -> parent is set to null
+      ids.push(...new Set(assets.filter((a) => !!a.stackParentId).map((a) => a.stackParentId!)));
+    } else if (options.stackParentId) {
+      await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, options.stackParentId);
+      // Merge stacks
+      const assets = await this.assetRepository.getByIds(ids);
+      const assetsWithChildren = assets.filter((a) => a.stack && a.stack.length > 0);
+      ids.push(...assetsWithChildren.flatMap((child) => child.stack!.map((gChild) => gChild.id)));
+
+      // This updates the updatedAt column of the parent to indicate that a new child has been added
+      await this.assetRepository.updateAll([options.stackParentId], { stackParentId: null });
+    }
+
     await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
     await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
     await this.assetRepository.updateAll(ids, options);
     await this.assetRepository.updateAll(ids, options);
+    this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids);
   }
   }
 
 
   async handleAssetDeletionCheck() {
   async handleAssetDeletionCheck() {
@@ -384,6 +404,14 @@ export class AssetService {
       );
       );
     }
     }
 
 
+    // Replace the parent of the stack children with a new asset
+    if (asset.stack && asset.stack.length != 0) {
+      const stackIds = asset.stack.map((a) => a.id);
+      const newParentId = stackIds[0];
+      await this.assetRepository.updateAll(stackIds.slice(1), { stackParentId: newParentId });
+      await this.assetRepository.updateAll([newParentId], { stackParentId: null });
+    }
+
     await this.assetRepository.remove(asset);
     await this.assetRepository.remove(asset);
     await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } });
     await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } });
     this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, id);
     this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, id);
@@ -454,6 +482,25 @@ export class AssetService {
     this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids);
     this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids);
   }
   }
 
 
+  async updateStackParent(authUser: AuthUserDto, dto: UpdateStackParentDto): Promise<void> {
+    const { oldParentId, newParentId } = dto;
+    await this.access.requirePermission(authUser, Permission.ASSET_READ, oldParentId);
+    await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, newParentId);
+
+    const childIds: string[] = [];
+    const oldParent = await this.assetRepository.getById(oldParentId);
+    if (oldParent != null) {
+      childIds.push(oldParent.id);
+      // Get all children of old parent
+      childIds.push(...(oldParent.stack?.map((a) => a.id) ?? []));
+    }
+
+    this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, [...childIds, newParentId]);
+    await this.assetRepository.updateAll(childIds, { stackParentId: newParentId });
+    // Remove ParentId of new parent if this was previously a child of some other asset
+    return this.assetRepository.updateAll([newParentId], { stackParentId: null });
+  }
+
   async run(authUser: AuthUserDto, dto: AssetJobsDto) {
   async run(authUser: AuthUserDto, dto: AssetJobsDto) {
     await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds);
     await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds);
 
 

+ 9 - 0
server/src/domain/asset/dto/asset-stack.dto.ts

@@ -0,0 +1,9 @@
+import { ValidateUUID } from '../../domain.util';
+
+export class UpdateStackParentDto {
+  @ValidateUUID()
+  oldParentId!: string;
+
+  @ValidateUUID()
+  newParentId!: string;
+}

+ 9 - 1
server/src/domain/asset/dto/asset.dto.ts

@@ -1,6 +1,6 @@
 import { Type } from 'class-transformer';
 import { Type } from 'class-transformer';
 import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator';
 import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator';
-import { Optional } from '../../domain.util';
+import { Optional, ValidateUUID } from '../../domain.util';
 import { BulkIdsDto } from '../response-dto';
 import { BulkIdsDto } from '../response-dto';
 
 
 export class AssetBulkUpdateDto extends BulkIdsDto {
 export class AssetBulkUpdateDto extends BulkIdsDto {
@@ -11,6 +11,14 @@ export class AssetBulkUpdateDto extends BulkIdsDto {
   @Optional()
   @Optional()
   @IsBoolean()
   @IsBoolean()
   isArchived?: boolean;
   isArchived?: boolean;
+
+  @Optional()
+  @ValidateUUID()
+  stackParentId?: string;
+
+  @Optional()
+  @IsBoolean()
+  removeParent?: boolean;
 }
 }
 
 
 export class UpdateAssetDto {
 export class UpdateAssetDto {

+ 1 - 0
server/src/domain/asset/dto/index.ts

@@ -1,4 +1,5 @@
 export * from './asset-ids.dto';
 export * from './asset-ids.dto';
+export * from './asset-stack.dto';
 export * from './asset-statistics.dto';
 export * from './asset-statistics.dto';
 export * from './asset.dto';
 export * from './asset.dto';
 export * from './download.dto';
 export * from './download.dto';

+ 15 - 1
server/src/domain/asset/response-dto/asset-response.dto.ts

@@ -42,9 +42,20 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
   people?: PersonResponseDto[];
   people?: PersonResponseDto[];
   /**base64 encoded sha1 hash */
   /**base64 encoded sha1 hash */
   checksum!: string;
   checksum!: string;
+  stackParentId?: string | null;
+  stack?: AssetResponseDto[];
+  @ApiProperty({ type: 'integer' })
+  stackCount!: number;
 }
 }
 
 
-export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetResponseDto {
+export type AssetMapOptions = {
+  stripMetadata?: boolean;
+  withStack?: boolean;
+};
+
+export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
+  const { stripMetadata = false, withStack = false } = options;
+
   const sanitizedAssetResponse: SanitizedAssetResponseDto = {
   const sanitizedAssetResponse: SanitizedAssetResponseDto = {
     id: entity.id,
     id: entity.id,
     type: entity.type,
     type: entity.type,
@@ -87,6 +98,9 @@ export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetRespo
     tags: entity.tags?.map(mapTag),
     tags: entity.tags?.map(mapTag),
     people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
     people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
     checksum: entity.checksum.toString('base64'),
     checksum: entity.checksum.toString('base64'),
+    stackParentId: entity.stackParentId,
+    stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined,
+    stackCount: entity.stack?.length ?? 0,
     isExternal: entity.isExternal,
     isExternal: entity.isExternal,
     isOffline: entity.isOffline,
     isOffline: entity.isOffline,
     isReadOnly: entity.isReadOnly,
     isReadOnly: entity.isReadOnly,

+ 1 - 0
server/src/domain/repositories/communication.repository.ts

@@ -4,6 +4,7 @@ export enum CommunicationEvent {
   UPLOAD_SUCCESS = 'on_upload_success',
   UPLOAD_SUCCESS = 'on_upload_success',
   ASSET_DELETE = 'on_asset_delete',
   ASSET_DELETE = 'on_asset_delete',
   ASSET_TRASH = 'on_asset_trash',
   ASSET_TRASH = 'on_asset_trash',
+  ASSET_UPDATE = 'on_asset_update',
   ASSET_RESTORE = 'on_asset_restore',
   ASSET_RESTORE = 'on_asset_restore',
   PERSON_THUMBNAIL = 'on_person_thumbnail',
   PERSON_THUMBNAIL = 'on_person_thumbnail',
   SERVER_VERSION = 'on_server_version',
   SERVER_VERSION = 'on_server_version',

+ 1 - 1
server/src/domain/shared-link/shared-link-response.dto.ts

@@ -58,7 +58,7 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): Shar
     type: sharedLink.type,
     type: sharedLink.type,
     createdAt: sharedLink.createdAt,
     createdAt: sharedLink.createdAt,
     expiresAt: sharedLink.expiresAt,
     expiresAt: sharedLink.expiresAt,
-    assets: assets.map((asset) => mapAsset(asset, true)) as AssetResponseDto[],
+    assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })) as AssetResponseDto[],
     album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
     album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
     allowUpload: sharedLink.allowUpload,
     allowUpload: sharedLink.allowUpload,
     allowDownload: sharedLink.allowDownload,
     allowDownload: sharedLink.allowDownload,

+ 2 - 0
server/src/immich/api-v1/asset/asset-repository.ts

@@ -109,6 +109,7 @@ export class AssetRepository implements IAssetRepository {
         faces: {
         faces: {
           person: true,
           person: true,
         },
         },
+        stack: true,
       },
       },
       // We are specifically asking for this asset. Return it even if it is soft deleted
       // We are specifically asking for this asset. Return it even if it is soft deleted
       withDeleted: true,
       withDeleted: true,
@@ -131,6 +132,7 @@ export class AssetRepository implements IAssetRepository {
       relations: {
       relations: {
         exifInfo: true,
         exifInfo: true,
         tags: true,
         tags: true,
+        stack: true,
       },
       },
       skip: dto.skip || 0,
       skip: dto.skip || 0,
       order: {
       order: {

+ 2 - 2
server/src/immich/api-v1/asset/asset.service.ts

@@ -196,7 +196,7 @@ export class AssetService {
     const includeMetadata = this.getExifPermission(authUser);
     const includeMetadata = this.getExifPermission(authUser);
     const asset = await this._assetRepository.getById(assetId);
     const asset = await this._assetRepository.getById(assetId);
     if (includeMetadata) {
     if (includeMetadata) {
-      const data = mapAsset(asset);
+      const data = mapAsset(asset, { withStack: true });
 
 
       if (data.ownerId !== authUser.id) {
       if (data.ownerId !== authUser.id) {
         data.people = [];
         data.people = [];
@@ -208,7 +208,7 @@ export class AssetService {
 
 
       return data;
       return data;
     } else {
     } else {
-      return mapAsset(asset, true);
+      return mapAsset(asset, { stripMetadata: true, withStack: true });
     }
     }
   }
   }
 
 

+ 7 - 0
server/src/immich/controllers/asset.controller.ts

@@ -21,6 +21,7 @@ import {
   TimeBucketResponseDto,
   TimeBucketResponseDto,
   TrashAction,
   TrashAction,
   UpdateAssetDto as UpdateDto,
   UpdateAssetDto as UpdateDto,
+  UpdateStackParentDto,
 } from '@app/domain';
 } from '@app/domain';
 import {
 import {
   Body,
   Body,
@@ -137,6 +138,12 @@ export class AssetController {
     return this.service.handleTrashAction(authUser, TrashAction.RESTORE_ALL);
     return this.service.handleTrashAction(authUser, TrashAction.RESTORE_ALL);
   }
   }
 
 
+  @Put('stack/parent')
+  @HttpCode(HttpStatus.OK)
+  updateStackParent(@AuthUser() authUser: AuthUserDto, @Body() dto: UpdateStackParentDto): Promise<void> {
+    return this.service.updateStackParent(authUser, dto);
+  }
+
   @Put(':id')
   @Put(':id')
   updateAsset(
   updateAsset(
     @AuthUser() authUser: AuthUserDto,
     @AuthUser() authUser: AuthUserDto,

+ 10 - 0
server/src/infra/entities/asset.entity.ts

@@ -148,6 +148,16 @@ export class AssetEntity {
 
 
   @OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset)
   @OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset)
   faces!: AssetFaceEntity[];
   faces!: AssetFaceEntity[];
+
+  @Column({ nullable: true })
+  stackParentId?: string | null;
+
+  @ManyToOne(() => AssetEntity, (asset) => asset.stack, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
+  @JoinColumn({ name: 'stackParentId' })
+  stackParent?: AssetEntity | null;
+
+  @OneToMany(() => AssetEntity, (asset) => asset.stackParent)
+  stack?: AssetEntity[];
 }
 }
 
 
 export enum AssetType {
 export enum AssetType {

+ 16 - 0
server/src/infra/migrations/1695354433573-AddStackParentIdToAssets.ts

@@ -0,0 +1,16 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class AddStackParentIdToAssets1695354433573 implements MigrationInterface {
+    name = 'AddStackParentIdToAssets1695354433573'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "assets" ADD "stackParentId" uuid`);
+        await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "FK_b463c8edb01364bf2beba08ef19" FOREIGN KEY ("stackParentId") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE CASCADE`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_b463c8edb01364bf2beba08ef19"`);
+        await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "stackParentId"`);
+    }
+
+}

+ 8 - 0
server/src/infra/repositories/asset.repository.ts

@@ -112,6 +112,7 @@ export class AssetRepository implements IAssetRepository {
         faces: {
         faces: {
           person: true,
           person: true,
         },
         },
+        stack: true,
       },
       },
       withDeleted: true,
       withDeleted: true,
     });
     });
@@ -192,6 +193,7 @@ export class AssetRepository implements IAssetRepository {
           person: true,
           person: true,
         },
         },
         library: true,
         library: true,
+        stack: true,
       },
       },
       // We are specifically asking for this asset. Return it even if it is soft deleted
       // We are specifically asking for this asset. Return it even if it is soft deleted
       withDeleted: true,
       withDeleted: true,
@@ -538,6 +540,12 @@ export class AssetRepository implements IAssetRepository {
         .andWhere('person.id = :personId', { personId });
         .andWhere('person.id = :personId', { personId });
     }
     }
 
 
+    // Hide stack children only in main timeline
+    // Uncomment after adding support for stacked assets in web client
+    // if (!isArchived && !isFavorite && !personId && !albumId && !isTrashed) {
+    //   builder = builder.andWhere('asset.stackParent IS NULL');
+    // }
+
     return builder;
     return builder;
   }
   }
 }
 }

+ 163 - 0
server/test/e2e/asset.e2e-spec.ts

@@ -626,4 +626,167 @@ describe(`${AssetController.name} (e2e)`, () => {
       expect(body).toEqual([expect.objectContaining({ id: asset2.id })]);
       expect(body).toEqual([expect.objectContaining({ id: asset2.id })]);
     });
     });
   });
   });
+
+  describe('PUT /asset', () => {
+    beforeEach(async () => {
+      const { status } = await request(server)
+        .put('/asset')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] });
+
+      expect(status).toBe(204);
+    });
+
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).put('/asset');
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should require a valid parent id', async () => {
+      const { status, body } = await request(server)
+        .put('/asset')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ stackParentId: uuidStub.invalid, ids: [asset1.id] });
+
+      expect(status).toBe(400);
+      expect(body).toEqual(errorStub.badRequest(['stackParentId must be a UUID']));
+    });
+
+    it('should require access to the parent', async () => {
+      const { status, body } = await request(server)
+        .put('/asset')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ stackParentId: asset4.id, ids: [asset1.id] });
+
+      expect(status).toBe(400);
+      expect(body).toEqual(errorStub.noPermission);
+    });
+
+    it('should add stack children', async () => {
+      const parent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
+      const child = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
+
+      const { status } = await request(server)
+        .put('/asset')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ stackParentId: parent.id, ids: [child.id] });
+
+      expect(status).toBe(204);
+
+      const asset = await api.assetApi.get(server, user1.accessToken, parent.id);
+      expect(asset.stack).not.toBeUndefined();
+      expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: child.id })]));
+    });
+
+    it('should remove stack children', async () => {
+      const { status } = await request(server)
+        .put('/asset')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ removeParent: true, ids: [asset2.id] });
+
+      expect(status).toBe(204);
+
+      const asset = await api.assetApi.get(server, user1.accessToken, asset1.id);
+      expect(asset.stack).not.toBeUndefined();
+      expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })]));
+    });
+
+    it('should remove all stack children', async () => {
+      const { status } = await request(server)
+        .put('/asset')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ removeParent: true, ids: [asset2.id, asset3.id] });
+
+      expect(status).toBe(204);
+
+      const asset = await api.assetApi.get(server, user1.accessToken, asset1.id);
+      expect(asset.stack).toHaveLength(0);
+    });
+
+    it('should merge stack children', async () => {
+      const newParent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
+      const { status } = await request(server)
+        .put('/asset')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ stackParentId: newParent.id, ids: [asset1.id] });
+
+      expect(status).toBe(204);
+
+      const asset = await api.assetApi.get(server, user1.accessToken, newParent.id);
+      expect(asset.stack).not.toBeUndefined();
+      expect(asset.stack).toEqual(
+        expect.arrayContaining([
+          expect.objectContaining({ id: asset1.id }),
+          expect.objectContaining({ id: asset2.id }),
+          expect.objectContaining({ id: asset3.id }),
+        ]),
+      );
+    });
+  });
+
+  describe('PUT /asset/stack/parent', () => {
+    beforeEach(async () => {
+      const { status } = await request(server)
+        .put('/asset')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] });
+
+      expect(status).toBe(204);
+    });
+
+    it('should require authentication', async () => {
+      const { status, body } = await request(server).put('/asset/stack/parent');
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should require a valid id', async () => {
+      const { status, body } = await request(server)
+        .put('/asset/stack/parent')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ oldParentId: uuidStub.invalid, newParentId: uuidStub.invalid });
+
+      expect(status).toBe(400);
+      expect(body).toEqual(errorStub.badRequest());
+    });
+
+    it('should require access', async () => {
+      const { status, body } = await request(server)
+        .put('/asset/stack/parent')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ oldParentId: asset4.id, newParentId: asset1.id });
+
+      expect(status).toBe(400);
+      expect(body).toEqual(errorStub.noPermission);
+    });
+
+    it('should make old parent child of new parent', async () => {
+      const { status } = await request(server)
+        .put('/asset/stack/parent')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ oldParentId: asset1.id, newParentId: asset2.id });
+
+      expect(status).toBe(200);
+
+      const asset = await api.assetApi.get(server, user1.accessToken, asset2.id);
+      expect(asset.stack).not.toBeUndefined();
+      expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset1.id })]));
+    });
+
+    it('should make all childrens of old parent, a child of new parent', async () => {
+      const { status } = await request(server)
+        .put('/asset/stack/parent')
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ oldParentId: asset1.id, newParentId: asset2.id });
+
+      expect(status).toBe(200);
+
+      const asset = await api.assetApi.get(server, user1.accessToken, asset2.id);
+      expect(asset.stack).not.toBeUndefined();
+      expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })]));
+    });
+  });
 });
 });

+ 43 - 1
server/test/fixtures/asset.stub.ts

@@ -41,6 +41,7 @@ export const assetStub = {
     libraryId: 'library-id',
     libraryId: 'library-id',
     library: libraryStub.uploadLibrary1,
     library: libraryStub.uploadLibrary1,
   }),
   }),
+
   noWebpPath: Object.freeze<AssetEntity>({
   noWebpPath: Object.freeze<AssetEntity>({
     id: 'asset-id',
     id: 'asset-id',
     deviceAssetId: 'device-asset-id',
     deviceAssetId: 'device-asset-id',
@@ -80,6 +81,7 @@ export const assetStub = {
     } as ExifEntity,
     } as ExifEntity,
     deletedAt: null,
     deletedAt: null,
   }),
   }),
+
   noThumbhash: Object.freeze<AssetEntity>({
   noThumbhash: Object.freeze<AssetEntity>({
     id: 'asset-id',
     id: 'asset-id',
     deviceAssetId: 'device-asset-id',
     deviceAssetId: 'device-asset-id',
@@ -116,6 +118,7 @@ export const assetStub = {
     sidecarPath: null,
     sidecarPath: null,
     deletedAt: null,
     deletedAt: null,
   }),
   }),
+
   primaryImage: Object.freeze<AssetEntity>({
   primaryImage: Object.freeze<AssetEntity>({
     id: 'asset-id',
     id: 'asset-id',
     deviceAssetId: 'device-asset-id',
     deviceAssetId: 'device-asset-id',
@@ -154,7 +157,9 @@ export const assetStub = {
     exifInfo: {
     exifInfo: {
       fileSizeInByte: 5_000,
       fileSizeInByte: 5_000,
     } as ExifEntity,
     } as ExifEntity,
+    stack: [{ id: 'stack-child-asset-1' } as AssetEntity, { id: 'stack-child-asset-2' } as AssetEntity],
   }),
   }),
+
   image: Object.freeze<AssetEntity>({
   image: Object.freeze<AssetEntity>({
     id: 'asset-id',
     id: 'asset-id',
     deviceAssetId: 'device-asset-id',
     deviceAssetId: 'device-asset-id',
@@ -194,6 +199,7 @@ export const assetStub = {
       fileSizeInByte: 5_000,
       fileSizeInByte: 5_000,
     } as ExifEntity,
     } as ExifEntity,
   }),
   }),
+
   external: Object.freeze<AssetEntity>({
   external: Object.freeze<AssetEntity>({
     id: 'asset-id',
     id: 'asset-id',
     deviceAssetId: 'device-asset-id',
     deviceAssetId: 'device-asset-id',
@@ -233,6 +239,7 @@ export const assetStub = {
       fileSizeInByte: 5_000,
       fileSizeInByte: 5_000,
     } as ExifEntity,
     } as ExifEntity,
   }),
   }),
+
   offline: Object.freeze<AssetEntity>({
   offline: Object.freeze<AssetEntity>({
     id: 'asset-id',
     id: 'asset-id',
     deviceAssetId: 'device-asset-id',
     deviceAssetId: 'device-asset-id',
@@ -272,6 +279,7 @@ export const assetStub = {
     } as ExifEntity,
     } as ExifEntity,
     deletedAt: null,
     deletedAt: null,
   }),
   }),
+
   image1: Object.freeze<AssetEntity>({
   image1: Object.freeze<AssetEntity>({
     id: 'asset-id-1',
     id: 'asset-id-1',
     deviceAssetId: 'device-asset-id',
     deviceAssetId: 'device-asset-id',
@@ -311,6 +319,7 @@ export const assetStub = {
       fileSizeInByte: 5_000,
       fileSizeInByte: 5_000,
     } as ExifEntity,
     } as ExifEntity,
   }),
   }),
+
   imageFrom2015: Object.freeze<AssetEntity>({
   imageFrom2015: Object.freeze<AssetEntity>({
     id: 'asset-id-1',
     id: 'asset-id-1',
     deviceAssetId: 'device-asset-id',
     deviceAssetId: 'device-asset-id',
@@ -350,6 +359,7 @@ export const assetStub = {
     } as ExifEntity,
     } as ExifEntity,
     deletedAt: null,
     deletedAt: null,
   }),
   }),
+
   video: Object.freeze<AssetEntity>({
   video: Object.freeze<AssetEntity>({
     id: 'asset-id',
     id: 'asset-id',
     originalFileName: 'asset-id.ext',
     originalFileName: 'asset-id.ext',
@@ -389,6 +399,7 @@ export const assetStub = {
     } as ExifEntity,
     } as ExifEntity,
     deletedAt: null,
     deletedAt: null,
   }),
   }),
+
   livePhotoMotionAsset: Object.freeze({
   livePhotoMotionAsset: Object.freeze({
     id: 'live-photo-motion-asset',
     id: 'live-photo-motion-asset',
     originalPath: fileStub.livePhotoMotion.originalPath,
     originalPath: fileStub.livePhotoMotion.originalPath,
@@ -497,10 +508,41 @@ export const assetStub = {
     sidecarPath: '/original/path.ext.xmp',
     sidecarPath: '/original/path.ext.xmp',
     deletedAt: null,
     deletedAt: null,
   }),
   }),
-  readOnly: Object.freeze({
+
+  readOnly: Object.freeze<AssetEntity>({
     id: 'read-only-asset',
     id: 'read-only-asset',
+    deviceAssetId: 'device-asset-id',
+    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
+    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    owner: userStub.user1,
+    ownerId: 'user-id',
+    deviceId: 'device-id',
+    originalPath: '/original/path.ext',
+    resizePath: '/uploads/user-id/thumbs/path.ext',
+    thumbhash: null,
+    checksum: Buffer.from('file hash', 'utf8'),
+    type: AssetType.IMAGE,
+    webpPath: null,
+    encodedVideoPath: null,
+    createdAt: new Date('2023-02-23T05:06:29.716Z'),
+    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    localDateTime: new Date('2023-02-23T05:06:29.716Z'),
+    isFavorite: true,
+    isArchived: false,
     isReadOnly: true,
     isReadOnly: true,
+    isExternal: false,
+    isOffline: false,
     libraryId: 'library-id',
     libraryId: 'library-id',
     library: libraryStub.uploadLibrary1,
     library: libraryStub.uploadLibrary1,
+    duration: null,
+    isVisible: true,
+    livePhotoVideo: null,
+    livePhotoVideoId: null,
+    tags: [],
+    sharedLinks: [],
+    originalFileName: 'asset-id.ext',
+    faces: [],
+    sidecarPath: '/original/path.ext.xmp',
+    deletedAt: null,
   }),
   }),
 };
 };

+ 1 - 0
server/test/fixtures/shared-link.stub.ts

@@ -72,6 +72,7 @@ const assetResponse: AssetResponseDto = {
   isTrashed: false,
   isTrashed: false,
   libraryId: 'library-id',
   libraryId: 'library-id',
   hasMetadata: true,
   hasMetadata: true,
+  stackCount: 0,
 };
 };
 
 
 const assetResponseWithoutMetadata = {
 const assetResponseWithoutMetadata = {

+ 137 - 0
web/src/api/open-api/api.ts

@@ -399,6 +399,18 @@ export interface AssetBulkUpdateDto {
      * @memberof AssetBulkUpdateDto
      * @memberof AssetBulkUpdateDto
      */
      */
     'isFavorite'?: boolean;
     'isFavorite'?: boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetBulkUpdateDto
+     */
+    'removeParent'?: boolean;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetBulkUpdateDto
+     */
+    'stackParentId'?: string;
 }
 }
 /**
 /**
  * 
  * 
@@ -748,6 +760,24 @@ export interface AssetResponseDto {
      * @memberof AssetResponseDto
      * @memberof AssetResponseDto
      */
      */
     'smartInfo'?: SmartInfoResponseDto;
     'smartInfo'?: SmartInfoResponseDto;
+    /**
+     * 
+     * @type {Array<AssetResponseDto>}
+     * @memberof AssetResponseDto
+     */
+    'stack'?: Array<AssetResponseDto>;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetResponseDto
+     */
+    'stackCount': number;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetResponseDto
+     */
+    'stackParentId'?: string | null;
     /**
     /**
      * 
      * 
      * @type {Array<TagResponseDto>}
      * @type {Array<TagResponseDto>}
@@ -3981,6 +4011,25 @@ export interface UpdateLibraryDto {
      */
      */
     'name'?: string;
     'name'?: string;
 }
 }
+/**
+ * 
+ * @export
+ * @interface UpdateStackParentDto
+ */
+export interface UpdateStackParentDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof UpdateStackParentDto
+     */
+    'newParentId': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof UpdateStackParentDto
+     */
+    'oldParentId': string;
+}
 /**
 /**
  * 
  * 
  * @export
  * @export
@@ -7135,6 +7184,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 options: localVarRequestOptions,
                 options: localVarRequestOptions,
             };
             };
         },
         },
+        /**
+         * 
+         * @param {UpdateStackParentDto} updateStackParentDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        updateStackParent: async (updateStackParentDto: UpdateStackParentDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'updateStackParentDto' is not null or undefined
+            assertParamExists('updateStackParent', 'updateStackParentDto', updateStackParentDto)
+            const localVarPath = `/asset/stack/parent`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(updateStackParentDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
         /**
         /**
          * 
          * 
          * @param {File} assetData 
          * @param {File} assetData 
@@ -7601,6 +7694,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
+        /**
+         * 
+         * @param {UpdateStackParentDto} updateStackParentDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async updateStackParent(updateStackParentDto: UpdateStackParentDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.updateStackParent(updateStackParentDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
         /**
          * 
          * 
          * @param {File} assetData 
          * @param {File} assetData 
@@ -7892,6 +7995,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
         updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
             return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
             return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
         },
         },
+        /**
+         * 
+         * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
+            return localVarFp.updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(axios, basePath));
+        },
         /**
         /**
          * 
          * 
          * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
          * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
@@ -8499,6 +8611,20 @@ export interface AssetApiUpdateAssetsRequest {
     readonly assetBulkUpdateDto: AssetBulkUpdateDto
     readonly assetBulkUpdateDto: AssetBulkUpdateDto
 }
 }
 
 
+/**
+ * Request parameters for updateStackParent operation in AssetApi.
+ * @export
+ * @interface AssetApiUpdateStackParentRequest
+ */
+export interface AssetApiUpdateStackParentRequest {
+    /**
+     * 
+     * @type {UpdateStackParentDto}
+     * @memberof AssetApiUpdateStackParent
+     */
+    readonly updateStackParentDto: UpdateStackParentDto
+}
+
 /**
 /**
  * Request parameters for uploadFile operation in AssetApi.
  * Request parameters for uploadFile operation in AssetApi.
  * @export
  * @export
@@ -8939,6 +9065,17 @@ export class AssetApi extends BaseAPI {
         return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
         return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
+    /**
+     * 
+     * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
     /**
      * 
      * 
      * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
      * @param {AssetApiUploadFileRequest} requestParameters Request parameters.