mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	feat: manual stack assets (#4198)
This commit is contained in:
		
							parent
							
								
									5ead4af2dc
								
							
						
					
					
						commit
						cf08ac7538
					
				
							
								
								
									
										137
									
								
								cli/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										137
									
								
								cli/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							@ -399,6 +399,18 @@ export interface AssetBulkUpdateDto {
 | 
			
		||||
     * @memberof AssetBulkUpdateDto
 | 
			
		||||
     */
 | 
			
		||||
    'isFavorite'?: boolean;
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {boolean}
 | 
			
		||||
     * @memberof AssetBulkUpdateDto
 | 
			
		||||
     */
 | 
			
		||||
    'removeParent'?: boolean;
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     * @memberof AssetBulkUpdateDto
 | 
			
		||||
     */
 | 
			
		||||
    'stackParentId'?: string;
 | 
			
		||||
}
 | 
			
		||||
/**
 | 
			
		||||
 * 
 | 
			
		||||
@ -748,6 +760,24 @@ export interface AssetResponseDto {
 | 
			
		||||
     * @memberof AssetResponseDto
 | 
			
		||||
     */
 | 
			
		||||
    '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>}
 | 
			
		||||
@ -3981,6 +4011,25 @@ export interface UpdateLibraryDto {
 | 
			
		||||
     */
 | 
			
		||||
    'name'?: string;
 | 
			
		||||
}
 | 
			
		||||
/**
 | 
			
		||||
 * 
 | 
			
		||||
 * @export
 | 
			
		||||
 * @interface UpdateStackParentDto
 | 
			
		||||
 */
 | 
			
		||||
export interface UpdateStackParentDto {
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     * @memberof UpdateStackParentDto
 | 
			
		||||
     */
 | 
			
		||||
    'newParentId': string;
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     * @memberof UpdateStackParentDto
 | 
			
		||||
     */
 | 
			
		||||
    'oldParentId': string;
 | 
			
		||||
}
 | 
			
		||||
/**
 | 
			
		||||
 * 
 | 
			
		||||
 * @export
 | 
			
		||||
@ -7135,6 +7184,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
 | 
			
		||||
                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 
 | 
			
		||||
@ -7601,6 +7694,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
 | 
			
		||||
            const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
 | 
			
		||||
            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 
 | 
			
		||||
@ -7892,6 +7995,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
 | 
			
		||||
        updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
 | 
			
		||||
            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.
 | 
			
		||||
@ -8499,6 +8611,20 @@ export interface AssetApiUpdateAssetsRequest {
 | 
			
		||||
    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.
 | 
			
		||||
 * @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));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @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.
 | 
			
		||||
 | 
			
		||||
@ -130,7 +130,9 @@
 | 
			
		||||
  "control_bottom_app_bar_delete": "Delete",
 | 
			
		||||
  "control_bottom_app_bar_favorite": "Favorite",
 | 
			
		||||
  "control_bottom_app_bar_share": "Share",
 | 
			
		||||
  "control_bottom_app_bar_stack": "Stack",
 | 
			
		||||
  "control_bottom_app_bar_unarchive": "Unarchive",
 | 
			
		||||
  "control_bottom_app_bar_upload": "Upload",
 | 
			
		||||
  "create_album_page_untitled": "Untitled",
 | 
			
		||||
  "create_shared_album_page_create": "Create",
 | 
			
		||||
  "create_shared_album_page_share": "Share",
 | 
			
		||||
@ -275,6 +277,7 @@
 | 
			
		||||
  "setting_pages_app_bar_settings": "Settings",
 | 
			
		||||
  "settings_require_restart": "Please restart Immich to apply this setting",
 | 
			
		||||
  "share_add": "Add",
 | 
			
		||||
  "share_done": "Done",
 | 
			
		||||
  "share_add_photos": "Add photos",
 | 
			
		||||
  "share_add_title": "Add a title",
 | 
			
		||||
  "share_create_album": "Create album",
 | 
			
		||||
@ -337,5 +340,8 @@
 | 
			
		||||
  "trash_page_select_assets_btn": "Select assets",
 | 
			
		||||
  "trash_page_empty_trash_btn": "Empty trash",
 | 
			
		||||
  "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"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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/shared/models/album.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.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?>(
 | 
			
		||||
        AssetSelectionRoute(
 | 
			
		||||
          existingAssets: albumInfo.assets,
 | 
			
		||||
          isNewAlbum: false,
 | 
			
		||||
          canDeselect: false,
 | 
			
		||||
          query: getRemoteAssetQuery(ref),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -4,26 +4,27 @@ import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/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/immich_asset_grid.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 {
 | 
			
		||||
  const AssetSelectionPage({
 | 
			
		||||
    Key? key,
 | 
			
		||||
    required this.existingAssets,
 | 
			
		||||
    this.isNewAlbum = false,
 | 
			
		||||
    this.canDeselect = false,
 | 
			
		||||
    required this.query,
 | 
			
		||||
  }) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  final Set<Asset> existingAssets;
 | 
			
		||||
  final bool isNewAlbum;
 | 
			
		||||
  final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
 | 
			
		||||
  final bool canDeselect;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  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 selectionEnabledHook = useState(true);
 | 
			
		||||
 | 
			
		||||
@ -39,8 +40,8 @@ class AssetSelectionPage extends HookConsumerWidget {
 | 
			
		||||
          selected.value = assets;
 | 
			
		||||
        },
 | 
			
		||||
        selectionActive: true,
 | 
			
		||||
        preselectedAssets: isNewAlbum ? selected.value : existingAssets,
 | 
			
		||||
        canDeselect: isNewAlbum,
 | 
			
		||||
        preselectedAssets: existingAssets,
 | 
			
		||||
        canDeselect: canDeselect,
 | 
			
		||||
        showMultiSelectIndicator: false,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
@ -65,7 +66,7 @@ class AssetSelectionPage extends HookConsumerWidget {
 | 
			
		||||
              ),
 | 
			
		||||
        centerTitle: false,
 | 
			
		||||
        actions: [
 | 
			
		||||
          if (selected.value.isNotEmpty)
 | 
			
		||||
          if (selected.value.isNotEmpty || canDeselect)
 | 
			
		||||
            TextButton(
 | 
			
		||||
              onPressed: () {
 | 
			
		||||
                var payload =
 | 
			
		||||
@ -74,7 +75,7 @@ class AssetSelectionPage extends HookConsumerWidget {
 | 
			
		||||
                    .popForced<AssetSelectionPageResult>(payload);
 | 
			
		||||
              },
 | 
			
		||||
              child: Text(
 | 
			
		||||
                "share_add",
 | 
			
		||||
                canDeselect ? "share_done" : "share_add",
 | 
			
		||||
                style: TextStyle(
 | 
			
		||||
                  fontWeight: FontWeight.bold,
 | 
			
		||||
                  color: Theme.of(context).primaryColor,
 | 
			
		||||
 | 
			
		||||
@ -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/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
 | 
			
		||||
 | 
			
		||||
// ignore: must_be_immutable
 | 
			
		||||
class CreateAlbumPage extends HookConsumerWidget {
 | 
			
		||||
@ -31,7 +32,8 @@ class CreateAlbumPage extends HookConsumerWidget {
 | 
			
		||||
    final isAlbumTitleTextFieldFocus = useState(false);
 | 
			
		||||
    final isAlbumTitleEmpty = useState(true);
 | 
			
		||||
    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;
 | 
			
		||||
 | 
			
		||||
    showSelectUserPage() async {
 | 
			
		||||
@ -59,7 +61,8 @@ class CreateAlbumPage extends HookConsumerWidget {
 | 
			
		||||
          await AutoRouter.of(context).push<AssetSelectionPageResult?>(
 | 
			
		||||
        AssetSelectionRoute(
 | 
			
		||||
          existingAssets: selectedAssets.value,
 | 
			
		||||
          isNewAlbum: true,
 | 
			
		||||
          canDeselect: true,
 | 
			
		||||
          query: getRemoteAssetQuery(ref),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
      if (selectedAsset == null) {
 | 
			
		||||
 | 
			
		||||
@ -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();
 | 
			
		||||
});
 | 
			
		||||
@ -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/services/app_settings.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/asset.dart';
 | 
			
		||||
import 'package:isar/isar.dart';
 | 
			
		||||
 | 
			
		||||
final renderListProvider =
 | 
			
		||||
    FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
 | 
			
		||||
@ -13,3 +14,19 @@ final renderListProvider =
 | 
			
		||||
    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);
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
@ -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),
 | 
			
		||||
  ),
 | 
			
		||||
);
 | 
			
		||||
@ -8,11 +8,13 @@ import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
 | 
			
		||||
import 'package:fluttertoast/fluttertoast.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/video_player_controls_provider.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/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/exif_bottom_sheet.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 initialIndex;
 | 
			
		||||
  final int heroOffset;
 | 
			
		||||
  final bool showStack;
 | 
			
		||||
 | 
			
		||||
  GalleryViewerPage({
 | 
			
		||||
    super.key,
 | 
			
		||||
@ -51,6 +54,7 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
    required this.loadAsset,
 | 
			
		||||
    required this.totalAssets,
 | 
			
		||||
    this.heroOffset = 0,
 | 
			
		||||
    this.showStack = false,
 | 
			
		||||
  }) : controller = PageController(initialPage: initialIndex);
 | 
			
		||||
 | 
			
		||||
  final PageController controller;
 | 
			
		||||
@ -77,8 +81,17 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
    final isFromTrash = isTrashEnabled &&
 | 
			
		||||
        navStack.length > 2 &&
 | 
			
		||||
        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(
 | 
			
		||||
      () {
 | 
			
		||||
@ -165,19 +178,28 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
            padding: EdgeInsets.only(
 | 
			
		||||
              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 {
 | 
			
		||||
      Future<bool> onDelete(bool force) async {
 | 
			
		||||
        final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
 | 
			
		||||
          {deleteAsset},
 | 
			
		||||
          force: force,
 | 
			
		||||
        );
 | 
			
		||||
        if (isDeleted) {
 | 
			
		||||
        if (isDeleted && isParent) {
 | 
			
		||||
          if (totalAssets == 1) {
 | 
			
		||||
            // Handle only one asset
 | 
			
		||||
            AutoRouter.of(context).pop();
 | 
			
		||||
@ -195,14 +217,17 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
      // Asset is trashed
 | 
			
		||||
      if (isTrashEnabled && !isFromTrash) {
 | 
			
		||||
        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;
 | 
			
		||||
      }
 | 
			
		||||
@ -211,7 +236,14 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
      showDialog(
 | 
			
		||||
        context: context,
 | 
			
		||||
        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
 | 
			
		||||
          .watch(assetProvider.notifier)
 | 
			
		||||
          .toggleArchive([asset], !asset.isArchived);
 | 
			
		||||
      AutoRouter.of(context).pop();
 | 
			
		||||
      if (isParent) {
 | 
			
		||||
        AutoRouter.of(context).pop();
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      removeAssetFromStack();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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(
 | 
			
		||||
        ignoring: !ref.watch(showControlsProvider),
 | 
			
		||||
        child: AnimatedOpacity(
 | 
			
		||||
@ -393,6 +608,17 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
          opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
 | 
			
		||||
          child: Column(
 | 
			
		||||
            children: [
 | 
			
		||||
              if (stack.isNotEmpty)
 | 
			
		||||
                Padding(
 | 
			
		||||
                  padding: const EdgeInsets.only(
 | 
			
		||||
                    left: 10,
 | 
			
		||||
                    bottom: 30,
 | 
			
		||||
                  ),
 | 
			
		||||
                  child: SizedBox(
 | 
			
		||||
                    height: 40,
 | 
			
		||||
                    child: buildStackedChildren(),
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              Visibility(
 | 
			
		||||
                visible: !asset().isImage && !isPlayingMotionVideo.value,
 | 
			
		||||
                child: Container(
 | 
			
		||||
@ -421,44 +647,10 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
                selectedLabelStyle: const TextStyle(color: Colors.black),
 | 
			
		||||
                showSelectedLabels: 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) {
 | 
			
		||||
                  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;
 | 
			
		||||
                precacheNextImage(next);
 | 
			
		||||
                currentIndex.value = value;
 | 
			
		||||
                stackIndex.value = -1;
 | 
			
		||||
                HapticFeedback.selectionClick();
 | 
			
		||||
              },
 | 
			
		||||
              loadingBuilder: (context, event, index) {
 | 
			
		||||
@ -544,10 +737,11 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
                    : webPThumbnail;
 | 
			
		||||
              },
 | 
			
		||||
              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(
 | 
			
		||||
                    onDragStart: (_, details, __) =>
 | 
			
		||||
                        localPosition = details.localPosition,
 | 
			
		||||
@ -558,13 +752,13 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
                    },
 | 
			
		||||
                    imageProvider: provider,
 | 
			
		||||
                    heroAttributes: PhotoViewHeroAttributes(
 | 
			
		||||
                      tag: asset.id + heroOffset,
 | 
			
		||||
                      tag: a.id + heroOffset,
 | 
			
		||||
                    ),
 | 
			
		||||
                    filterQuality: FilterQuality.high,
 | 
			
		||||
                    tightMode: true,
 | 
			
		||||
                    minScale: PhotoViewComputedScale.contained,
 | 
			
		||||
                    errorBuilder: (context, error, stackTrace) => ImmichImage(
 | 
			
		||||
                      asset,
 | 
			
		||||
                      a,
 | 
			
		||||
                      fit: BoxFit.contain,
 | 
			
		||||
                    ),
 | 
			
		||||
                  );
 | 
			
		||||
@ -575,7 +769,7 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
                    onDragUpdate: (_, details, __) =>
 | 
			
		||||
                        handleSwipeUpDown(details),
 | 
			
		||||
                    heroAttributes: PhotoViewHeroAttributes(
 | 
			
		||||
                      tag: asset.id + heroOffset,
 | 
			
		||||
                      tag: a.id + heroOffset,
 | 
			
		||||
                    ),
 | 
			
		||||
                    filterQuality: FilterQuality.high,
 | 
			
		||||
                    maxScale: 1.0,
 | 
			
		||||
@ -584,7 +778,7 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
			
		||||
                    child: VideoViewerPage(
 | 
			
		||||
                      onPlaying: () => isPlayingVideo.value = true,
 | 
			
		||||
                      onPaused: () => isPlayingVideo.value = false,
 | 
			
		||||
                      asset: asset,
 | 
			
		||||
                      asset: a,
 | 
			
		||||
                      isMotionVideo: isPlayingMotionVideo.value,
 | 
			
		||||
                      placeholder: Image(
 | 
			
		||||
                        image: provider,
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										47
									
								
								mobile/lib/modules/home/models/selection_state.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								mobile/lib/modules/home/models/selection_state.dart
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
}
 | 
			
		||||
@ -32,6 +32,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
 | 
			
		||||
  final Widget? topWidget;
 | 
			
		||||
  final bool shrinkWrap;
 | 
			
		||||
  final bool showDragScroll;
 | 
			
		||||
  final bool showStack;
 | 
			
		||||
 | 
			
		||||
  const ImmichAssetGrid({
 | 
			
		||||
    super.key,
 | 
			
		||||
@ -51,6 +52,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
 | 
			
		||||
    this.topWidget,
 | 
			
		||||
    this.shrinkWrap = false,
 | 
			
		||||
    this.showDragScroll = true,
 | 
			
		||||
    this.showStack = false,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@ -114,6 +116,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
 | 
			
		||||
          heroOffset: heroOffset(),
 | 
			
		||||
          shrinkWrap: shrinkWrap,
 | 
			
		||||
          showDragScroll: showDragScroll,
 | 
			
		||||
          showStack: showStack,
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -37,6 +37,7 @@ class ImmichAssetGridView extends StatefulWidget {
 | 
			
		||||
  final int heroOffset;
 | 
			
		||||
  final bool shrinkWrap;
 | 
			
		||||
  final bool showDragScroll;
 | 
			
		||||
  final bool showStack;
 | 
			
		||||
 | 
			
		||||
  const ImmichAssetGridView({
 | 
			
		||||
    super.key,
 | 
			
		||||
@ -56,6 +57,7 @@ class ImmichAssetGridView extends StatefulWidget {
 | 
			
		||||
    this.heroOffset = 0,
 | 
			
		||||
    this.shrinkWrap = false,
 | 
			
		||||
    this.showDragScroll = true,
 | 
			
		||||
    this.showStack = false,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@ -71,7 +73,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
 | 
			
		||||
 | 
			
		||||
  bool _scrolling = false;
 | 
			
		||||
  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() {
 | 
			
		||||
    return Set.from(_selectedAssets);
 | 
			
		||||
@ -90,7 +92,13 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
 | 
			
		||||
 | 
			
		||||
  void _deselectAssets(List<Asset> assets) {
 | 
			
		||||
    setState(() {
 | 
			
		||||
      _selectedAssets.removeAll(assets);
 | 
			
		||||
      _selectedAssets.removeAll(
 | 
			
		||||
        assets.where(
 | 
			
		||||
          (a) =>
 | 
			
		||||
              widget.canDeselect ||
 | 
			
		||||
              !(widget.preselectedAssets?.contains(a) ?? false),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
      _callSelectionListener(_selectedAssets.isNotEmpty);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
@ -129,6 +137,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
 | 
			
		||||
      useGrayBoxPlaceholder: true,
 | 
			
		||||
      showStorageIndicator: widget.showStorageIndicator,
 | 
			
		||||
      heroOffset: widget.heroOffset,
 | 
			
		||||
      showStack: widget.showStack,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -377,10 +386,6 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
 | 
			
		||||
      setState(() {
 | 
			
		||||
        _selectedAssets.clear();
 | 
			
		||||
      });
 | 
			
		||||
    } else if (widget.preselectedAssets != null) {
 | 
			
		||||
      setState(() {
 | 
			
		||||
        _selectedAssets.addAll(widget.preselectedAssets!);
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@ class ThumbnailImage extends StatelessWidget {
 | 
			
		||||
  final Asset Function(int index) loadAsset;
 | 
			
		||||
  final int totalAssets;
 | 
			
		||||
  final bool showStorageIndicator;
 | 
			
		||||
  final bool showStack;
 | 
			
		||||
  final bool useGrayBoxPlaceholder;
 | 
			
		||||
  final bool isSelected;
 | 
			
		||||
  final bool multiselectEnabled;
 | 
			
		||||
@ -26,6 +27,7 @@ class ThumbnailImage extends StatelessWidget {
 | 
			
		||||
    required this.loadAsset,
 | 
			
		||||
    required this.totalAssets,
 | 
			
		||||
    this.showStorageIndicator = true,
 | 
			
		||||
    this.showStack = false,
 | 
			
		||||
    this.useGrayBoxPlaceholder = false,
 | 
			
		||||
    this.isSelected = 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() {
 | 
			
		||||
      final image = SizedBox(
 | 
			
		||||
        width: 300,
 | 
			
		||||
@ -113,9 +144,9 @@ class ThumbnailImage extends StatelessWidget {
 | 
			
		||||
        decoration: BoxDecoration(
 | 
			
		||||
          border: Border.all(
 | 
			
		||||
            width: 0,
 | 
			
		||||
            color: assetContainerColor,
 | 
			
		||||
            color: onDeselect == null ? Colors.grey : assetContainerColor,
 | 
			
		||||
          ),
 | 
			
		||||
          color: assetContainerColor,
 | 
			
		||||
          color: onDeselect == null ? Colors.grey : assetContainerColor,
 | 
			
		||||
        ),
 | 
			
		||||
        child: ClipRRect(
 | 
			
		||||
          borderRadius: const BorderRadius.only(
 | 
			
		||||
@ -144,6 +175,7 @@ class ThumbnailImage extends StatelessWidget {
 | 
			
		||||
              loadAsset: loadAsset,
 | 
			
		||||
              totalAssets: totalAssets,
 | 
			
		||||
              heroOffset: heroOffset,
 | 
			
		||||
              showStack: showStack,
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
@ -196,6 +228,7 @@ class ThumbnailImage extends StatelessWidget {
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          if (!asset.isImage) buildVideoIcon(),
 | 
			
		||||
          if (asset.isImage && asset.stackCount > 0) buildStackIcon(),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
@ -4,9 +4,9 @@ import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.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/home/models/selection_state.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/shared/models/asset.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/models/album.dart';
 | 
			
		||||
@ -19,11 +19,12 @@ class ControlBottomAppBar extends ConsumerWidget {
 | 
			
		||||
  final Function(Album album) onAddToAlbum;
 | 
			
		||||
  final void Function() onCreateNewAlbum;
 | 
			
		||||
  final void Function() onUpload;
 | 
			
		||||
  final void Function() onStack;
 | 
			
		||||
 | 
			
		||||
  final List<Album> albums;
 | 
			
		||||
  final List<Album> sharedAlbums;
 | 
			
		||||
  final bool enabled;
 | 
			
		||||
  final AssetState selectionAssetState;
 | 
			
		||||
  final SelectionAssetState selectionAssetState;
 | 
			
		||||
 | 
			
		||||
  const ControlBottomAppBar({
 | 
			
		||||
    Key? key,
 | 
			
		||||
@ -36,19 +37,24 @@ class ControlBottomAppBar extends ConsumerWidget {
 | 
			
		||||
    required this.onAddToAlbum,
 | 
			
		||||
    required this.onCreateNewAlbum,
 | 
			
		||||
    required this.onUpload,
 | 
			
		||||
    this.selectionAssetState = AssetState.remote,
 | 
			
		||||
    required this.onStack,
 | 
			
		||||
    this.selectionAssetState = const SelectionAssetState(),
 | 
			
		||||
    this.enabled = true,
 | 
			
		||||
  }) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    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 =
 | 
			
		||||
        ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
 | 
			
		||||
 | 
			
		||||
    Widget renderActionButtons() {
 | 
			
		||||
      return Row(
 | 
			
		||||
      return Wrap(
 | 
			
		||||
        spacing: 10,
 | 
			
		||||
        runSpacing: 15,
 | 
			
		||||
        children: [
 | 
			
		||||
          ControlBoxButton(
 | 
			
		||||
            iconData: Platform.isAndroid
 | 
			
		||||
@ -92,7 +98,7 @@ class ControlBottomAppBar extends ConsumerWidget {
 | 
			
		||||
          if (!hasRemote)
 | 
			
		||||
            ControlBoxButton(
 | 
			
		||||
              iconData: Icons.backup_outlined,
 | 
			
		||||
              label: "Upload",
 | 
			
		||||
              label: "control_bottom_app_bar_upload".tr(),
 | 
			
		||||
              onPressed: enabled
 | 
			
		||||
                  ? () => showDialog(
 | 
			
		||||
                        context: context,
 | 
			
		||||
@ -104,6 +110,12 @@ class ControlBottomAppBar extends ConsumerWidget {
 | 
			
		||||
                      )
 | 
			
		||||
                  : 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(
 | 
			
		||||
      initialChildSize: hasRemote ? 0.30 : 0.18,
 | 
			
		||||
      minChildSize: 0.18,
 | 
			
		||||
      maxChildSize: hasRemote ? 0.57 : 0.18,
 | 
			
		||||
      maxChildSize: hasRemote ? 0.60 : 0.18,
 | 
			
		||||
      snap: true,
 | 
			
		||||
      builder: (
 | 
			
		||||
        BuildContext context,
 | 
			
		||||
 | 
			
		||||
@ -7,11 +7,15 @@ import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:fluttertoast/fluttertoast.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/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_detail.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/asset_viewer/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/home/models/selection_state.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
 | 
			
		||||
@ -36,7 +40,7 @@ class HomePage extends HookConsumerWidget {
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final multiselectEnabled = ref.watch(multiselectProvider.notifier);
 | 
			
		||||
    final selectionEnabledHook = useState(false);
 | 
			
		||||
    final selectionAssetState = useState(AssetState.remote);
 | 
			
		||||
    final selectionAssetState = useState(const SelectionAssetState());
 | 
			
		||||
 | 
			
		||||
    final selection = useState(<Asset>{});
 | 
			
		||||
    final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
 | 
			
		||||
@ -83,9 +87,8 @@ class HomePage extends HookConsumerWidget {
 | 
			
		||||
      ) {
 | 
			
		||||
        selectionEnabledHook.value = multiselect;
 | 
			
		||||
        selection.value = selectedAssets;
 | 
			
		||||
        selectionAssetState.value = selectedAssets.any((e) => e.isRemote)
 | 
			
		||||
            ? AssetState.remote
 | 
			
		||||
            : AssetState.local;
 | 
			
		||||
        selectionAssetState.value =
 | 
			
		||||
            SelectionAssetState.fromSelection(selectedAssets);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      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 {
 | 
			
		||||
        final fullRefresh = refreshCount.value > 0;
 | 
			
		||||
        await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
 | 
			
		||||
@ -322,6 +374,7 @@ class HomePage extends HookConsumerWidget {
 | 
			
		||||
                                  currentUser.memoryEnabled!)
 | 
			
		||||
                              ? const MemoryLane()
 | 
			
		||||
                              : const SizedBox(),
 | 
			
		||||
                          showStack: true,
 | 
			
		||||
                        ),
 | 
			
		||||
                  error: (error, _) => Center(child: Text(error.toString())),
 | 
			
		||||
                  loading: buildLoadingIndicator,
 | 
			
		||||
@ -339,6 +392,7 @@ class HomePage extends HookConsumerWidget {
 | 
			
		||||
                onUpload: onUpload,
 | 
			
		||||
                enabled: !processing.value,
 | 
			
		||||
                selectionAssetState: selectionAssetState.value,
 | 
			
		||||
                onStack: onStack,
 | 
			
		||||
              ),
 | 
			
		||||
            if (processing.value) const Center(child: ImmichLoadingIndicator()),
 | 
			
		||||
          ],
 | 
			
		||||
 | 
			
		||||
@ -252,6 +252,7 @@ class TrashPage extends HookConsumerWidget {
 | 
			
		||||
                      listener: selectionListener,
 | 
			
		||||
                      selectionActive: selectionEnabledHook.value,
 | 
			
		||||
                      showMultiSelectIndicator: false,
 | 
			
		||||
                      showStack: true,
 | 
			
		||||
                      topWidget: Padding(
 | 
			
		||||
                        padding: const EdgeInsets.only(
 | 
			
		||||
                          top: 24,
 | 
			
		||||
 | 
			
		||||
@ -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/splash_screen.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
 | 
			
		||||
import 'package:isar/isar.dart';
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
 | 
			
		||||
part 'router.gr.dart';
 | 
			
		||||
 | 
			
		||||
@ -71,6 +71,7 @@ class _$AppRouter extends RootStackRouter {
 | 
			
		||||
          loadAsset: args.loadAsset,
 | 
			
		||||
          totalAssets: args.totalAssets,
 | 
			
		||||
          heroOffset: args.heroOffset,
 | 
			
		||||
          showStack: args.showStack,
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
@ -153,7 +154,8 @@ class _$AppRouter extends RootStackRouter {
 | 
			
		||||
        child: AssetSelectionPage(
 | 
			
		||||
          key: args.key,
 | 
			
		||||
          existingAssets: args.existingAssets,
 | 
			
		||||
          isNewAlbum: args.isNewAlbum,
 | 
			
		||||
          canDeselect: args.canDeselect,
 | 
			
		||||
          query: args.query,
 | 
			
		||||
        ),
 | 
			
		||||
        transitionsBuilder: TransitionsBuilders.slideBottom,
 | 
			
		||||
        opaque: true,
 | 
			
		||||
@ -711,6 +713,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
 | 
			
		||||
    required Asset Function(int) loadAsset,
 | 
			
		||||
    required int totalAssets,
 | 
			
		||||
    int heroOffset = 0,
 | 
			
		||||
    bool showStack = false,
 | 
			
		||||
  }) : super(
 | 
			
		||||
          GalleryViewerRoute.name,
 | 
			
		||||
          path: '/gallery-viewer-page',
 | 
			
		||||
@ -720,6 +723,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
 | 
			
		||||
            loadAsset: loadAsset,
 | 
			
		||||
            totalAssets: totalAssets,
 | 
			
		||||
            heroOffset: heroOffset,
 | 
			
		||||
            showStack: showStack,
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
@ -733,6 +737,7 @@ class GalleryViewerRouteArgs {
 | 
			
		||||
    required this.loadAsset,
 | 
			
		||||
    required this.totalAssets,
 | 
			
		||||
    this.heroOffset = 0,
 | 
			
		||||
    this.showStack = false,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  final Key? key;
 | 
			
		||||
@ -745,9 +750,11 @@ class GalleryViewerRouteArgs {
 | 
			
		||||
 | 
			
		||||
  final int heroOffset;
 | 
			
		||||
 | 
			
		||||
  final bool showStack;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  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({
 | 
			
		||||
    Key? key,
 | 
			
		||||
    required Set<Asset> existingAssets,
 | 
			
		||||
    bool isNewAlbum = false,
 | 
			
		||||
    bool canDeselect = false,
 | 
			
		||||
    required QueryBuilder<Asset, Asset, QAfterSortBy>? query,
 | 
			
		||||
  }) : super(
 | 
			
		||||
          AssetSelectionRoute.name,
 | 
			
		||||
          path: '/asset-selection-page',
 | 
			
		||||
          args: AssetSelectionRouteArgs(
 | 
			
		||||
            key: key,
 | 
			
		||||
            existingAssets: existingAssets,
 | 
			
		||||
            isNewAlbum: isNewAlbum,
 | 
			
		||||
            canDeselect: canDeselect,
 | 
			
		||||
            query: query,
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
@ -979,18 +988,21 @@ class AssetSelectionRouteArgs {
 | 
			
		||||
  const AssetSelectionRouteArgs({
 | 
			
		||||
    this.key,
 | 
			
		||||
    required this.existingAssets,
 | 
			
		||||
    this.isNewAlbum = false,
 | 
			
		||||
    this.canDeselect = false,
 | 
			
		||||
    required this.query,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  final Key? key;
 | 
			
		||||
 | 
			
		||||
  final Set<Asset> existingAssets;
 | 
			
		||||
 | 
			
		||||
  final bool isNewAlbum;
 | 
			
		||||
  final bool canDeselect;
 | 
			
		||||
 | 
			
		||||
  final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, isNewAlbum: $isNewAlbum}';
 | 
			
		||||
    return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, canDeselect: $canDeselect, query: $query}';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -31,7 +31,9 @@ class Asset {
 | 
			
		||||
            remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
 | 
			
		||||
        isFavorite = remote.isFavorite,
 | 
			
		||||
        isArchived = remote.isArchived,
 | 
			
		||||
        isTrashed = remote.isTrashed;
 | 
			
		||||
        isTrashed = remote.isTrashed,
 | 
			
		||||
        stackParentId = remote.stackParentId,
 | 
			
		||||
        stackCount = remote.stackCount;
 | 
			
		||||
 | 
			
		||||
  Asset.local(AssetEntity local, List<int> hash)
 | 
			
		||||
      : localId = local.id,
 | 
			
		||||
@ -47,6 +49,7 @@ class Asset {
 | 
			
		||||
        isFavorite = local.isFavorite,
 | 
			
		||||
        isArchived = false,
 | 
			
		||||
        isTrashed = false,
 | 
			
		||||
        stackCount = 0,
 | 
			
		||||
        fileCreatedAt = local.createDateTime {
 | 
			
		||||
    if (fileCreatedAt.year == 1970) {
 | 
			
		||||
      fileCreatedAt = fileModifiedAt;
 | 
			
		||||
@ -77,6 +80,8 @@ class Asset {
 | 
			
		||||
    required this.isFavorite,
 | 
			
		||||
    required this.isArchived,
 | 
			
		||||
    required this.isTrashed,
 | 
			
		||||
    this.stackParentId,
 | 
			
		||||
    required this.stackCount,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @ignore
 | 
			
		||||
@ -146,6 +151,10 @@ class Asset {
 | 
			
		||||
  @ignore
 | 
			
		||||
  ExifInfo? exifInfo;
 | 
			
		||||
 | 
			
		||||
  String? stackParentId;
 | 
			
		||||
 | 
			
		||||
  int stackCount;
 | 
			
		||||
 | 
			
		||||
  /// `true` if this [Asset] is present on the device
 | 
			
		||||
  @ignore
 | 
			
		||||
  bool get isLocal => localId != null;
 | 
			
		||||
@ -200,7 +209,9 @@ class Asset {
 | 
			
		||||
        isFavorite == other.isFavorite &&
 | 
			
		||||
        isLocal == other.isLocal &&
 | 
			
		||||
        isArchived == other.isArchived &&
 | 
			
		||||
        isTrashed == other.isTrashed;
 | 
			
		||||
        isTrashed == other.isTrashed &&
 | 
			
		||||
        stackCount == other.stackCount &&
 | 
			
		||||
        stackParentId == other.stackParentId;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@ -223,7 +234,9 @@ class Asset {
 | 
			
		||||
      isFavorite.hashCode ^
 | 
			
		||||
      isLocal.hashCode ^
 | 
			
		||||
      isArchived.hashCode ^
 | 
			
		||||
      isTrashed.hashCode;
 | 
			
		||||
      isTrashed.hashCode ^
 | 
			
		||||
      stackCount.hashCode ^
 | 
			
		||||
      stackParentId.hashCode;
 | 
			
		||||
 | 
			
		||||
  /// Returns `true` if this [Asset] can updated with values from parameter [a]
 | 
			
		||||
  bool canUpdate(Asset a) {
 | 
			
		||||
@ -236,9 +249,11 @@ class Asset {
 | 
			
		||||
        width == null && a.width != null ||
 | 
			
		||||
        height == null && a.height != null ||
 | 
			
		||||
        livePhotoVideoId == null && a.livePhotoVideoId != null ||
 | 
			
		||||
        stackParentId == null && a.stackParentId != null ||
 | 
			
		||||
        isFavorite != a.isFavorite ||
 | 
			
		||||
        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]
 | 
			
		||||
@ -267,6 +282,8 @@ class Asset {
 | 
			
		||||
          id: id,
 | 
			
		||||
          remoteId: remoteId,
 | 
			
		||||
          livePhotoVideoId: livePhotoVideoId,
 | 
			
		||||
          stackParentId: stackParentId,
 | 
			
		||||
          stackCount: stackCount,
 | 
			
		||||
          isFavorite: isFavorite,
 | 
			
		||||
          isArchived: isArchived,
 | 
			
		||||
          isTrashed: isTrashed,
 | 
			
		||||
@ -281,6 +298,8 @@ class Asset {
 | 
			
		||||
          width: a.width,
 | 
			
		||||
          height: a.height,
 | 
			
		||||
          livePhotoVideoId: a.livePhotoVideoId,
 | 
			
		||||
          stackParentId: a.stackParentId,
 | 
			
		||||
          stackCount: a.stackCount,
 | 
			
		||||
          // isFavorite + isArchived are not set by device-only assets
 | 
			
		||||
          isFavorite: a.isFavorite,
 | 
			
		||||
          isArchived: a.isArchived,
 | 
			
		||||
@ -318,6 +337,8 @@ class Asset {
 | 
			
		||||
    bool? isArchived,
 | 
			
		||||
    bool? isTrashed,
 | 
			
		||||
    ExifInfo? exifInfo,
 | 
			
		||||
    String? stackParentId,
 | 
			
		||||
    int? stackCount,
 | 
			
		||||
  }) =>
 | 
			
		||||
      Asset(
 | 
			
		||||
        id: id ?? this.id,
 | 
			
		||||
@ -338,6 +359,8 @@ class Asset {
 | 
			
		||||
        isArchived: isArchived ?? this.isArchived,
 | 
			
		||||
        isTrashed: isTrashed ?? this.isTrashed,
 | 
			
		||||
        exifInfo: exifInfo ?? this.exifInfo,
 | 
			
		||||
        stackParentId: stackParentId ?? this.stackParentId,
 | 
			
		||||
        stackCount: stackCount ?? this.stackCount,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
  Future<void> put(Isar db) async {
 | 
			
		||||
@ -379,6 +402,8 @@ class Asset {
 | 
			
		||||
  "checksum": "$checksum",
 | 
			
		||||
  "ownerId": $ownerId, 
 | 
			
		||||
  "livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
 | 
			
		||||
  "stackCount": "$stackCount",
 | 
			
		||||
  "stackParentId": "${stackParentId ?? "N/A"}",
 | 
			
		||||
  "fileCreatedAt": "$fileCreatedAt",
 | 
			
		||||
  "fileModifiedAt": "$fileModifiedAt", 
 | 
			
		||||
  "updatedAt": "$updatedAt", 
 | 
			
		||||
 | 
			
		||||
@ -82,19 +82,29 @@ const AssetSchema = CollectionSchema(
 | 
			
		||||
      name: r'remoteId',
 | 
			
		||||
      type: IsarType.string,
 | 
			
		||||
    ),
 | 
			
		||||
    r'type': PropertySchema(
 | 
			
		||||
    r'stackCount': PropertySchema(
 | 
			
		||||
      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',
 | 
			
		||||
      type: IsarType.byte,
 | 
			
		||||
      enumMap: _AssettypeEnumValueMap,
 | 
			
		||||
    ),
 | 
			
		||||
    r'updatedAt': PropertySchema(
 | 
			
		||||
      id: 14,
 | 
			
		||||
      id: 16,
 | 
			
		||||
      name: r'updatedAt',
 | 
			
		||||
      type: IsarType.dateTime,
 | 
			
		||||
    ),
 | 
			
		||||
    r'width': PropertySchema(
 | 
			
		||||
      id: 15,
 | 
			
		||||
      id: 17,
 | 
			
		||||
      name: r'width',
 | 
			
		||||
      type: IsarType.int,
 | 
			
		||||
    )
 | 
			
		||||
@ -184,6 +194,12 @@ int _assetEstimateSize(
 | 
			
		||||
      bytesCount += 3 + value.length * 3;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  {
 | 
			
		||||
    final value = object.stackParentId;
 | 
			
		||||
    if (value != null) {
 | 
			
		||||
      bytesCount += 3 + value.length * 3;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  return bytesCount;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -206,9 +222,11 @@ void _assetSerialize(
 | 
			
		||||
  writer.writeString(offsets[10], object.localId);
 | 
			
		||||
  writer.writeLong(offsets[11], object.ownerId);
 | 
			
		||||
  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(
 | 
			
		||||
@ -232,10 +250,12 @@ Asset _assetDeserialize(
 | 
			
		||||
    localId: reader.readStringOrNull(offsets[10]),
 | 
			
		||||
    ownerId: reader.readLong(offsets[11]),
 | 
			
		||||
    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,
 | 
			
		||||
    updatedAt: reader.readDateTime(offsets[14]),
 | 
			
		||||
    width: reader.readIntOrNull(offsets[15]),
 | 
			
		||||
    updatedAt: reader.readDateTime(offsets[16]),
 | 
			
		||||
    width: reader.readIntOrNull(offsets[17]),
 | 
			
		||||
  );
 | 
			
		||||
  return object;
 | 
			
		||||
}
 | 
			
		||||
@ -274,11 +294,15 @@ P _assetDeserializeProp<P>(
 | 
			
		||||
    case 12:
 | 
			
		||||
      return (reader.readStringOrNull(offset)) as P;
 | 
			
		||||
    case 13:
 | 
			
		||||
      return (reader.readLong(offset)) as P;
 | 
			
		||||
    case 14:
 | 
			
		||||
      return (reader.readStringOrNull(offset)) as P;
 | 
			
		||||
    case 15:
 | 
			
		||||
      return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
 | 
			
		||||
          AssetType.other) as P;
 | 
			
		||||
    case 14:
 | 
			
		||||
    case 16:
 | 
			
		||||
      return (reader.readDateTime(offset)) as P;
 | 
			
		||||
    case 15:
 | 
			
		||||
    case 17:
 | 
			
		||||
      return (reader.readIntOrNull(offset)) as P;
 | 
			
		||||
    default:
 | 
			
		||||
      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(
 | 
			
		||||
      AssetType value) {
 | 
			
		||||
    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() {
 | 
			
		||||
    return QueryBuilder.apply(this, (query) {
 | 
			
		||||
      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() {
 | 
			
		||||
    return QueryBuilder.apply(this, (query) {
 | 
			
		||||
      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() {
 | 
			
		||||
    return QueryBuilder.apply(this, (query) {
 | 
			
		||||
      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() {
 | 
			
		||||
    return QueryBuilder.apply(this, (query) {
 | 
			
		||||
      return query.addPropertyName(r'type');
 | 
			
		||||
 | 
			
		||||
@ -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/user.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/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 | 
			
		||||
@ -217,6 +218,7 @@ final assetsProvider =
 | 
			
		||||
      .filter()
 | 
			
		||||
      .isArchivedEqualTo(false)
 | 
			
		||||
      .isTrashedEqualTo(false)
 | 
			
		||||
      .stackParentIdIsNull()
 | 
			
		||||
      .sortByFileCreatedAtDesc();
 | 
			
		||||
  final settings = ref.watch(appSettingsServiceProvider);
 | 
			
		||||
  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)
 | 
			
		||||
      .assets
 | 
			
		||||
      .where()
 | 
			
		||||
@ -238,12 +242,34 @@ final remoteAssetsProvider =
 | 
			
		||||
      .filter()
 | 
			
		||||
      .ownerIdEqualTo(userId)
 | 
			
		||||
      .isTrashedEqualTo(false)
 | 
			
		||||
      .stackParentIdIsNull()
 | 
			
		||||
      .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();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -133,6 +133,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
 | 
			
		||||
        socket.on('on_asset_delete', _handleOnAssetDelete);
 | 
			
		||||
        socket.on('on_asset_trash', _handleServerUpdates);
 | 
			
		||||
        socket.on('on_asset_restore', _handleServerUpdates);
 | 
			
		||||
        socket.on('on_asset_update', _handleServerUpdates);
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										3
									
								
								mobile/openapi/.openapi-generator/FILES
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/.openapi-generator/FILES
									
									
									
										generated
									
									
									
								
							@ -149,6 +149,7 @@ doc/TranscodePolicy.md
 | 
			
		||||
doc/UpdateAlbumDto.md
 | 
			
		||||
doc/UpdateAssetDto.md
 | 
			
		||||
doc/UpdateLibraryDto.md
 | 
			
		||||
doc/UpdateStackParentDto.md
 | 
			
		||||
doc/UpdateTagDto.md
 | 
			
		||||
doc/UpdateUserDto.md
 | 
			
		||||
doc/UsageByUserDto.md
 | 
			
		||||
@ -314,6 +315,7 @@ lib/model/transcode_policy.dart
 | 
			
		||||
lib/model/update_album_dto.dart
 | 
			
		||||
lib/model/update_asset_dto.dart
 | 
			
		||||
lib/model/update_library_dto.dart
 | 
			
		||||
lib/model/update_stack_parent_dto.dart
 | 
			
		||||
lib/model/update_tag_dto.dart
 | 
			
		||||
lib/model/update_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_asset_dto_test.dart
 | 
			
		||||
test/update_library_dto_test.dart
 | 
			
		||||
test/update_stack_parent_dto_test.dart
 | 
			
		||||
test/update_tag_dto_test.dart
 | 
			
		||||
test/update_user_dto_test.dart
 | 
			
		||||
test/usage_by_user_dto_test.dart
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							@ -116,6 +116,7 @@ Class | Method | HTTP request | Description
 | 
			
		||||
*AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 | 
			
		||||
*AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} | 
 | 
			
		||||
*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 | 
 | 
			
		||||
*AuditApi* | [**fixAuditFiles**](doc//AuditApi.md#fixauditfiles) | **POST** /audit/file-report/fix | 
 | 
			
		||||
*AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes | 
 | 
			
		||||
@ -330,6 +331,7 @@ Class | Method | HTTP request | Description
 | 
			
		||||
 - [UpdateAlbumDto](doc//UpdateAlbumDto.md)
 | 
			
		||||
 - [UpdateAssetDto](doc//UpdateAssetDto.md)
 | 
			
		||||
 - [UpdateLibraryDto](doc//UpdateLibraryDto.md)
 | 
			
		||||
 - [UpdateStackParentDto](doc//UpdateStackParentDto.md)
 | 
			
		||||
 - [UpdateTagDto](doc//UpdateTagDto.md)
 | 
			
		||||
 - [UpdateUserDto](doc//UpdateUserDto.md)
 | 
			
		||||
 - [UsageByUserDto](doc//UsageByUserDto.md)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										55
									
								
								mobile/openapi/doc/AssetApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										55
									
								
								mobile/openapi/doc/AssetApi.md
									
									
									
										generated
									
									
									
								
							@ -38,6 +38,7 @@ Method | HTTP request | Description
 | 
			
		||||
[**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 | 
			
		||||
[**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} | 
 | 
			
		||||
[**updateAssets**](AssetApi.md#updateassets) | **PUT** /asset | 
 | 
			
		||||
[**updateStackParent**](AssetApi.md#updatestackparent) | **PUT** /asset/stack/parent | 
 | 
			
		||||
[**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)
 | 
			
		||||
 | 
			
		||||
# **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**
 | 
			
		||||
> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2
									
								
								mobile/openapi/doc/AssetBulkUpdateDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/doc/AssetBulkUpdateDto.md
									
									
									
										generated
									
									
									
								
							@ -11,6 +11,8 @@ Name | Type | Description | Notes
 | 
			
		||||
**ids** | **List<String>** |  | [default to const []]
 | 
			
		||||
**isArchived** | **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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										3
									
								
								mobile/openapi/doc/AssetResponseDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/doc/AssetResponseDto.md
									
									
									
										generated
									
									
									
								
							@ -33,6 +33,9 @@ Name | Type | Description | Notes
 | 
			
		||||
**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) |  | [optional] [default to const []]
 | 
			
		||||
**resized** | **bool** |  | 
 | 
			
		||||
**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 []]
 | 
			
		||||
**thumbhash** | **String** |  | 
 | 
			
		||||
**type** | [**AssetTypeEnum**](AssetTypeEnum.md) |  | 
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										16
									
								
								mobile/openapi/doc/UpdateStackParentDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								mobile/openapi/doc/UpdateStackParentDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							@ -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
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							@ -176,6 +176,7 @@ part 'model/transcode_policy.dart';
 | 
			
		||||
part 'model/update_album_dto.dart';
 | 
			
		||||
part 'model/update_asset_dto.dart';
 | 
			
		||||
part 'model/update_library_dto.dart';
 | 
			
		||||
part 'model/update_stack_parent_dto.dart';
 | 
			
		||||
part 'model/update_tag_dto.dart';
 | 
			
		||||
part 'model/update_user_dto.dart';
 | 
			
		||||
part 'model/usage_by_user_dto.dart';
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										39
									
								
								mobile/openapi/lib/api/asset_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										39
									
								
								mobile/openapi/lib/api/asset_api.dart
									
									
									
										generated
									
									
									
								
							@ -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].
 | 
			
		||||
  /// Parameters:
 | 
			
		||||
  ///
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							@ -443,6 +443,8 @@ class ApiClient {
 | 
			
		||||
          return UpdateAssetDto.fromJson(value);
 | 
			
		||||
        case 'UpdateLibraryDto':
 | 
			
		||||
          return UpdateLibraryDto.fromJson(value);
 | 
			
		||||
        case 'UpdateStackParentDto':
 | 
			
		||||
          return UpdateStackParentDto.fromJson(value);
 | 
			
		||||
        case 'UpdateTagDto':
 | 
			
		||||
          return UpdateTagDto.fromJson(value);
 | 
			
		||||
        case 'UpdateUserDto':
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										40
									
								
								mobile/openapi/lib/model/asset_bulk_update_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										40
									
								
								mobile/openapi/lib/model/asset_bulk_update_dto.dart
									
									
									
										generated
									
									
									
								
							@ -16,6 +16,8 @@ class AssetBulkUpdateDto {
 | 
			
		||||
    this.ids = const [],
 | 
			
		||||
    this.isArchived,
 | 
			
		||||
    this.isFavorite,
 | 
			
		||||
    this.removeParent,
 | 
			
		||||
    this.stackParentId,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  List<String> ids;
 | 
			
		||||
@ -36,21 +38,41 @@ class AssetBulkUpdateDto {
 | 
			
		||||
  ///
 | 
			
		||||
  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
 | 
			
		||||
  bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
 | 
			
		||||
     other.ids == ids &&
 | 
			
		||||
     other.isArchived == isArchived &&
 | 
			
		||||
     other.isFavorite == isFavorite;
 | 
			
		||||
     other.isFavorite == isFavorite &&
 | 
			
		||||
     other.removeParent == removeParent &&
 | 
			
		||||
     other.stackParentId == stackParentId;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode =>
 | 
			
		||||
    // ignore: unnecessary_parenthesis
 | 
			
		||||
    (ids.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
 | 
			
		||||
  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() {
 | 
			
		||||
    final json = <String, dynamic>{};
 | 
			
		||||
@ -65,6 +87,16 @@ class AssetBulkUpdateDto {
 | 
			
		||||
    } else {
 | 
			
		||||
    //  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;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -81,6 +113,8 @@ class AssetBulkUpdateDto {
 | 
			
		||||
            : const [],
 | 
			
		||||
        isArchived: mapValueOfType<bool>(json, r'isArchived'),
 | 
			
		||||
        isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
 | 
			
		||||
        removeParent: mapValueOfType<bool>(json, r'removeParent'),
 | 
			
		||||
        stackParentId: mapValueOfType<String>(json, r'stackParentId'),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										28
									
								
								mobile/openapi/lib/model/asset_response_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										28
									
								
								mobile/openapi/lib/model/asset_response_dto.dart
									
									
									
										generated
									
									
									
								
							@ -38,6 +38,9 @@ class AssetResponseDto {
 | 
			
		||||
    this.people = const [],
 | 
			
		||||
    required this.resized,
 | 
			
		||||
    this.smartInfo,
 | 
			
		||||
    this.stack = const [],
 | 
			
		||||
    required this.stackCount,
 | 
			
		||||
    this.stackParentId,
 | 
			
		||||
    this.tags = const [],
 | 
			
		||||
    required this.thumbhash,
 | 
			
		||||
    required this.type,
 | 
			
		||||
@ -113,6 +116,12 @@ class AssetResponseDto {
 | 
			
		||||
  ///
 | 
			
		||||
  SmartInfoResponseDto? smartInfo;
 | 
			
		||||
 | 
			
		||||
  List<AssetResponseDto> stack;
 | 
			
		||||
 | 
			
		||||
  int stackCount;
 | 
			
		||||
 | 
			
		||||
  String? stackParentId;
 | 
			
		||||
 | 
			
		||||
  List<TagResponseDto> tags;
 | 
			
		||||
 | 
			
		||||
  String? thumbhash;
 | 
			
		||||
@ -148,6 +157,9 @@ class AssetResponseDto {
 | 
			
		||||
     other.people == people &&
 | 
			
		||||
     other.resized == resized &&
 | 
			
		||||
     other.smartInfo == smartInfo &&
 | 
			
		||||
     other.stack == stack &&
 | 
			
		||||
     other.stackCount == stackCount &&
 | 
			
		||||
     other.stackParentId == stackParentId &&
 | 
			
		||||
     other.tags == tags &&
 | 
			
		||||
     other.thumbhash == thumbhash &&
 | 
			
		||||
     other.type == type &&
 | 
			
		||||
@ -181,13 +193,16 @@ class AssetResponseDto {
 | 
			
		||||
    (people.hashCode) +
 | 
			
		||||
    (resized.hashCode) +
 | 
			
		||||
    (smartInfo == null ? 0 : smartInfo!.hashCode) +
 | 
			
		||||
    (stack.hashCode) +
 | 
			
		||||
    (stackCount.hashCode) +
 | 
			
		||||
    (stackParentId == null ? 0 : stackParentId!.hashCode) +
 | 
			
		||||
    (tags.hashCode) +
 | 
			
		||||
    (thumbhash == null ? 0 : thumbhash!.hashCode) +
 | 
			
		||||
    (type.hashCode) +
 | 
			
		||||
    (updatedAt.hashCode);
 | 
			
		||||
 | 
			
		||||
  @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() {
 | 
			
		||||
    final json = <String, dynamic>{};
 | 
			
		||||
@ -231,6 +246,13 @@ class AssetResponseDto {
 | 
			
		||||
      json[r'smartInfo'] = this.smartInfo;
 | 
			
		||||
    } else {
 | 
			
		||||
    //  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;
 | 
			
		||||
    if (this.thumbhash != null) {
 | 
			
		||||
@ -276,6 +298,9 @@ class AssetResponseDto {
 | 
			
		||||
        people: PersonResponseDto.listFromJson(json[r'people']),
 | 
			
		||||
        resized: mapValueOfType<bool>(json, r'resized')!,
 | 
			
		||||
        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']),
 | 
			
		||||
        thumbhash: mapValueOfType<String>(json, r'thumbhash'),
 | 
			
		||||
        type: AssetTypeEnum.fromJson(json[r'type'])!,
 | 
			
		||||
@ -347,6 +372,7 @@ class AssetResponseDto {
 | 
			
		||||
    'originalPath',
 | 
			
		||||
    'ownerId',
 | 
			
		||||
    'resized',
 | 
			
		||||
    'stackCount',
 | 
			
		||||
    'thumbhash',
 | 
			
		||||
    'type',
 | 
			
		||||
    'updatedAt',
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										106
									
								
								mobile/openapi/lib/model/update_stack_parent_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								mobile/openapi/lib/model/update_stack_parent_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							@ -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
									
								
								mobile/openapi/test/asset_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/asset_api_test.dart
									
									
									
										generated
									
									
									
								
							@ -174,6 +174,11 @@ void main() {
 | 
			
		||||
      // 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
 | 
			
		||||
    test('test uploadFile', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										10
									
								
								mobile/openapi/test/asset_bulk_update_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										10
									
								
								mobile/openapi/test/asset_bulk_update_dto_test.dart
									
									
									
										generated
									
									
									
								
							@ -31,6 +31,16 @@ void main() {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // bool removeParent
 | 
			
		||||
    test('to test the property `removeParent`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // String stackParentId
 | 
			
		||||
    test('to test the property `stackParentId`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										15
									
								
								mobile/openapi/test/asset_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										15
									
								
								mobile/openapi/test/asset_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							@ -142,6 +142,21 @@ void main() {
 | 
			
		||||
      // 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 [])
 | 
			
		||||
    test('to test the property `tags`', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										32
									
								
								mobile/openapi/test/update_stack_parent_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								mobile/openapi/test/update_stack_parent_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							@ -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
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@ -25,6 +25,7 @@ void main() {
 | 
			
		||||
        isFavorite: false,
 | 
			
		||||
        isArchived: false,
 | 
			
		||||
        isTrashed: false,
 | 
			
		||||
        stackCount: 0,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -35,6 +35,7 @@ void main() {
 | 
			
		||||
      isFavorite: false,
 | 
			
		||||
      isArchived: false,
 | 
			
		||||
      isTrashed: false,
 | 
			
		||||
      stackCount: 0,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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": {
 | 
			
		||||
      "get": {
 | 
			
		||||
        "operationId": "getAssetStats",
 | 
			
		||||
@ -5696,6 +5731,13 @@
 | 
			
		||||
          },
 | 
			
		||||
          "isFavorite": {
 | 
			
		||||
            "type": "boolean"
 | 
			
		||||
          },
 | 
			
		||||
          "removeParent": {
 | 
			
		||||
            "type": "boolean"
 | 
			
		||||
          },
 | 
			
		||||
          "stackParentId": {
 | 
			
		||||
            "format": "uuid",
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "required": [
 | 
			
		||||
@ -5941,6 +5983,19 @@
 | 
			
		||||
          "smartInfo": {
 | 
			
		||||
            "$ref": "#/components/schemas/SmartInfoResponseDto"
 | 
			
		||||
          },
 | 
			
		||||
          "stack": {
 | 
			
		||||
            "items": {
 | 
			
		||||
              "$ref": "#/components/schemas/AssetResponseDto"
 | 
			
		||||
            },
 | 
			
		||||
            "type": "array"
 | 
			
		||||
          },
 | 
			
		||||
          "stackCount": {
 | 
			
		||||
            "type": "integer"
 | 
			
		||||
          },
 | 
			
		||||
          "stackParentId": {
 | 
			
		||||
            "nullable": true,
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "tags": {
 | 
			
		||||
            "items": {
 | 
			
		||||
              "$ref": "#/components/schemas/TagResponseDto"
 | 
			
		||||
@ -5961,6 +6016,7 @@
 | 
			
		||||
        },
 | 
			
		||||
        "required": [
 | 
			
		||||
          "type",
 | 
			
		||||
          "stackCount",
 | 
			
		||||
          "deviceAssetId",
 | 
			
		||||
          "deviceId",
 | 
			
		||||
          "ownerId",
 | 
			
		||||
@ -8521,6 +8577,23 @@
 | 
			
		||||
        },
 | 
			
		||||
        "type": "object"
 | 
			
		||||
      },
 | 
			
		||||
      "UpdateStackParentDto": {
 | 
			
		||||
        "properties": {
 | 
			
		||||
          "newParentId": {
 | 
			
		||||
            "format": "uuid",
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          },
 | 
			
		||||
          "oldParentId": {
 | 
			
		||||
            "format": "uuid",
 | 
			
		||||
            "type": "string"
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "required": [
 | 
			
		||||
          "oldParentId",
 | 
			
		||||
          "newParentId"
 | 
			
		||||
        ],
 | 
			
		||||
        "type": "object"
 | 
			
		||||
      },
 | 
			
		||||
      "UpdateTagDto": {
 | 
			
		||||
        "properties": {
 | 
			
		||||
          "name": {
 | 
			
		||||
 | 
			
		||||
@ -20,6 +20,7 @@ import { Readable } from 'stream';
 | 
			
		||||
import { JobName } from '../job';
 | 
			
		||||
import {
 | 
			
		||||
  AssetStats,
 | 
			
		||||
  CommunicationEvent,
 | 
			
		||||
  IAssetRepository,
 | 
			
		||||
  ICommunicationRepository,
 | 
			
		||||
  ICryptoRepository,
 | 
			
		||||
@ -636,10 +637,89 @@ describe(AssetService.name, () => {
 | 
			
		||||
      await sut.updateAll(authStub.admin, { ids: ['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', () => {
 | 
			
		||||
    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);
 | 
			
		||||
      await expect(
 | 
			
		||||
        sut.deleteAll(authStub.user1, {
 | 
			
		||||
@ -677,7 +757,7 @@ describe(AssetService.name, () => {
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  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);
 | 
			
		||||
      await expect(
 | 
			
		||||
        sut.deleteAll(authStub.user1, {
 | 
			
		||||
@ -757,6 +837,21 @@ describe(AssetService.name, () => {
 | 
			
		||||
      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 () => {
 | 
			
		||||
      when(assetMock.getById)
 | 
			
		||||
        .calledWith(assetStub.readOnly.id)
 | 
			
		||||
@ -854,4 +949,70 @@ describe(AssetService.name, () => {
 | 
			
		||||
        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' },
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -40,6 +40,7 @@ import {
 | 
			
		||||
  TimeBucketDto,
 | 
			
		||||
  TrashAction,
 | 
			
		||||
  UpdateAssetDto,
 | 
			
		||||
  UpdateStackParentDto,
 | 
			
		||||
  mapStats,
 | 
			
		||||
} from './dto';
 | 
			
		||||
import {
 | 
			
		||||
@ -208,7 +209,7 @@ export class AssetService {
 | 
			
		||||
    if (authUser.isShowMetadata) {
 | 
			
		||||
      return assets.map((asset) => mapAsset(asset));
 | 
			
		||||
    } 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> {
 | 
			
		||||
    const { ids, ...options } = dto;
 | 
			
		||||
    const { ids, removeParent, ...options } = dto;
 | 
			
		||||
    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.assetRepository.updateAll(ids, options);
 | 
			
		||||
    this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.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);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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) {
 | 
			
		||||
    await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										9
									
								
								server/src/domain/asset/dto/asset-stack.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								server/src/domain/asset/dto/asset-stack.dto.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,9 @@
 | 
			
		||||
import { ValidateUUID } from '../../domain.util';
 | 
			
		||||
 | 
			
		||||
export class UpdateStackParentDto {
 | 
			
		||||
  @ValidateUUID()
 | 
			
		||||
  oldParentId!: string;
 | 
			
		||||
 | 
			
		||||
  @ValidateUUID()
 | 
			
		||||
  newParentId!: string;
 | 
			
		||||
}
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
import { Type } from 'class-transformer';
 | 
			
		||||
import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator';
 | 
			
		||||
import { Optional } from '../../domain.util';
 | 
			
		||||
import { Optional, ValidateUUID } from '../../domain.util';
 | 
			
		||||
import { BulkIdsDto } from '../response-dto';
 | 
			
		||||
 | 
			
		||||
export class AssetBulkUpdateDto extends BulkIdsDto {
 | 
			
		||||
@ -11,6 +11,14 @@ export class AssetBulkUpdateDto extends BulkIdsDto {
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @IsBoolean()
 | 
			
		||||
  isArchived?: boolean;
 | 
			
		||||
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @ValidateUUID()
 | 
			
		||||
  stackParentId?: string;
 | 
			
		||||
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @IsBoolean()
 | 
			
		||||
  removeParent?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class UpdateAssetDto {
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
export * from './asset-ids.dto';
 | 
			
		||||
export * from './asset-stack.dto';
 | 
			
		||||
export * from './asset-statistics.dto';
 | 
			
		||||
export * from './asset.dto';
 | 
			
		||||
export * from './download.dto';
 | 
			
		||||
 | 
			
		||||
@ -42,9 +42,20 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
 | 
			
		||||
  people?: PersonResponseDto[];
 | 
			
		||||
  /**base64 encoded sha1 hash */
 | 
			
		||||
  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 = {
 | 
			
		||||
    id: entity.id,
 | 
			
		||||
    type: entity.type,
 | 
			
		||||
@ -87,6 +98,9 @@ export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetRespo
 | 
			
		||||
    tags: entity.tags?.map(mapTag),
 | 
			
		||||
    people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
 | 
			
		||||
    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,
 | 
			
		||||
    isOffline: entity.isOffline,
 | 
			
		||||
    isReadOnly: entity.isReadOnly,
 | 
			
		||||
 | 
			
		||||
@ -4,6 +4,7 @@ export enum CommunicationEvent {
 | 
			
		||||
  UPLOAD_SUCCESS = 'on_upload_success',
 | 
			
		||||
  ASSET_DELETE = 'on_asset_delete',
 | 
			
		||||
  ASSET_TRASH = 'on_asset_trash',
 | 
			
		||||
  ASSET_UPDATE = 'on_asset_update',
 | 
			
		||||
  ASSET_RESTORE = 'on_asset_restore',
 | 
			
		||||
  PERSON_THUMBNAIL = 'on_person_thumbnail',
 | 
			
		||||
  SERVER_VERSION = 'on_server_version',
 | 
			
		||||
 | 
			
		||||
@ -58,7 +58,7 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): Shar
 | 
			
		||||
    type: sharedLink.type,
 | 
			
		||||
    createdAt: sharedLink.createdAt,
 | 
			
		||||
    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,
 | 
			
		||||
    allowUpload: sharedLink.allowUpload,
 | 
			
		||||
    allowDownload: sharedLink.allowDownload,
 | 
			
		||||
 | 
			
		||||
@ -109,6 +109,7 @@ export class AssetRepository implements IAssetRepository {
 | 
			
		||||
        faces: {
 | 
			
		||||
          person: true,
 | 
			
		||||
        },
 | 
			
		||||
        stack: true,
 | 
			
		||||
      },
 | 
			
		||||
      // We are specifically asking for this asset. Return it even if it is soft deleted
 | 
			
		||||
      withDeleted: true,
 | 
			
		||||
@ -131,6 +132,7 @@ export class AssetRepository implements IAssetRepository {
 | 
			
		||||
      relations: {
 | 
			
		||||
        exifInfo: true,
 | 
			
		||||
        tags: true,
 | 
			
		||||
        stack: true,
 | 
			
		||||
      },
 | 
			
		||||
      skip: dto.skip || 0,
 | 
			
		||||
      order: {
 | 
			
		||||
 | 
			
		||||
@ -196,7 +196,7 @@ export class AssetService {
 | 
			
		||||
    const includeMetadata = this.getExifPermission(authUser);
 | 
			
		||||
    const asset = await this._assetRepository.getById(assetId);
 | 
			
		||||
    if (includeMetadata) {
 | 
			
		||||
      const data = mapAsset(asset);
 | 
			
		||||
      const data = mapAsset(asset, { withStack: true });
 | 
			
		||||
 | 
			
		||||
      if (data.ownerId !== authUser.id) {
 | 
			
		||||
        data.people = [];
 | 
			
		||||
@ -208,7 +208,7 @@ export class AssetService {
 | 
			
		||||
 | 
			
		||||
      return data;
 | 
			
		||||
    } else {
 | 
			
		||||
      return mapAsset(asset, true);
 | 
			
		||||
      return mapAsset(asset, { stripMetadata: true, withStack: true });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -21,6 +21,7 @@ import {
 | 
			
		||||
  TimeBucketResponseDto,
 | 
			
		||||
  TrashAction,
 | 
			
		||||
  UpdateAssetDto as UpdateDto,
 | 
			
		||||
  UpdateStackParentDto,
 | 
			
		||||
} from '@app/domain';
 | 
			
		||||
import {
 | 
			
		||||
  Body,
 | 
			
		||||
@ -137,6 +138,12 @@ export class AssetController {
 | 
			
		||||
    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')
 | 
			
		||||
  updateAsset(
 | 
			
		||||
    @AuthUser() authUser: AuthUserDto,
 | 
			
		||||
 | 
			
		||||
@ -148,6 +148,16 @@ export class AssetEntity {
 | 
			
		||||
 | 
			
		||||
  @OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset)
 | 
			
		||||
  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 {
 | 
			
		||||
 | 
			
		||||
@ -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"`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@ -112,6 +112,7 @@ export class AssetRepository implements IAssetRepository {
 | 
			
		||||
        faces: {
 | 
			
		||||
          person: true,
 | 
			
		||||
        },
 | 
			
		||||
        stack: true,
 | 
			
		||||
      },
 | 
			
		||||
      withDeleted: true,
 | 
			
		||||
    });
 | 
			
		||||
@ -192,6 +193,7 @@ export class AssetRepository implements IAssetRepository {
 | 
			
		||||
          person: true,
 | 
			
		||||
        },
 | 
			
		||||
        library: true,
 | 
			
		||||
        stack: true,
 | 
			
		||||
      },
 | 
			
		||||
      // We are specifically asking for this asset. Return it even if it is soft deleted
 | 
			
		||||
      withDeleted: true,
 | 
			
		||||
@ -538,6 +540,12 @@ export class AssetRepository implements IAssetRepository {
 | 
			
		||||
        .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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -626,4 +626,167 @@ describe(`${AssetController.name} (e2e)`, () => {
 | 
			
		||||
      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 })]));
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										44
									
								
								server/test/fixtures/asset.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										44
									
								
								server/test/fixtures/asset.stub.ts
									
									
									
									
										vendored
									
									
								
							@ -41,6 +41,7 @@ export const assetStub = {
 | 
			
		||||
    libraryId: 'library-id',
 | 
			
		||||
    library: libraryStub.uploadLibrary1,
 | 
			
		||||
  }),
 | 
			
		||||
 | 
			
		||||
  noWebpPath: Object.freeze<AssetEntity>({
 | 
			
		||||
    id: 'asset-id',
 | 
			
		||||
    deviceAssetId: 'device-asset-id',
 | 
			
		||||
@ -80,6 +81,7 @@ export const assetStub = {
 | 
			
		||||
    } as ExifEntity,
 | 
			
		||||
    deletedAt: null,
 | 
			
		||||
  }),
 | 
			
		||||
 | 
			
		||||
  noThumbhash: Object.freeze<AssetEntity>({
 | 
			
		||||
    id: 'asset-id',
 | 
			
		||||
    deviceAssetId: 'device-asset-id',
 | 
			
		||||
@ -116,6 +118,7 @@ export const assetStub = {
 | 
			
		||||
    sidecarPath: null,
 | 
			
		||||
    deletedAt: null,
 | 
			
		||||
  }),
 | 
			
		||||
 | 
			
		||||
  primaryImage: Object.freeze<AssetEntity>({
 | 
			
		||||
    id: 'asset-id',
 | 
			
		||||
    deviceAssetId: 'device-asset-id',
 | 
			
		||||
@ -154,7 +157,9 @@ export const assetStub = {
 | 
			
		||||
    exifInfo: {
 | 
			
		||||
      fileSizeInByte: 5_000,
 | 
			
		||||
    } as ExifEntity,
 | 
			
		||||
    stack: [{ id: 'stack-child-asset-1' } as AssetEntity, { id: 'stack-child-asset-2' } as AssetEntity],
 | 
			
		||||
  }),
 | 
			
		||||
 | 
			
		||||
  image: Object.freeze<AssetEntity>({
 | 
			
		||||
    id: 'asset-id',
 | 
			
		||||
    deviceAssetId: 'device-asset-id',
 | 
			
		||||
@ -194,6 +199,7 @@ export const assetStub = {
 | 
			
		||||
      fileSizeInByte: 5_000,
 | 
			
		||||
    } as ExifEntity,
 | 
			
		||||
  }),
 | 
			
		||||
 | 
			
		||||
  external: Object.freeze<AssetEntity>({
 | 
			
		||||
    id: 'asset-id',
 | 
			
		||||
    deviceAssetId: 'device-asset-id',
 | 
			
		||||
@ -233,6 +239,7 @@ export const assetStub = {
 | 
			
		||||
      fileSizeInByte: 5_000,
 | 
			
		||||
    } as ExifEntity,
 | 
			
		||||
  }),
 | 
			
		||||
 | 
			
		||||
  offline: Object.freeze<AssetEntity>({
 | 
			
		||||
    id: 'asset-id',
 | 
			
		||||
    deviceAssetId: 'device-asset-id',
 | 
			
		||||
@ -272,6 +279,7 @@ export const assetStub = {
 | 
			
		||||
    } as ExifEntity,
 | 
			
		||||
    deletedAt: null,
 | 
			
		||||
  }),
 | 
			
		||||
 | 
			
		||||
  image1: Object.freeze<AssetEntity>({
 | 
			
		||||
    id: 'asset-id-1',
 | 
			
		||||
    deviceAssetId: 'device-asset-id',
 | 
			
		||||
@ -311,6 +319,7 @@ export const assetStub = {
 | 
			
		||||
      fileSizeInByte: 5_000,
 | 
			
		||||
    } as ExifEntity,
 | 
			
		||||
  }),
 | 
			
		||||
 | 
			
		||||
  imageFrom2015: Object.freeze<AssetEntity>({
 | 
			
		||||
    id: 'asset-id-1',
 | 
			
		||||
    deviceAssetId: 'device-asset-id',
 | 
			
		||||
@ -350,6 +359,7 @@ export const assetStub = {
 | 
			
		||||
    } as ExifEntity,
 | 
			
		||||
    deletedAt: null,
 | 
			
		||||
  }),
 | 
			
		||||
 | 
			
		||||
  video: Object.freeze<AssetEntity>({
 | 
			
		||||
    id: 'asset-id',
 | 
			
		||||
    originalFileName: 'asset-id.ext',
 | 
			
		||||
@ -389,6 +399,7 @@ export const assetStub = {
 | 
			
		||||
    } as ExifEntity,
 | 
			
		||||
    deletedAt: null,
 | 
			
		||||
  }),
 | 
			
		||||
 | 
			
		||||
  livePhotoMotionAsset: Object.freeze({
 | 
			
		||||
    id: 'live-photo-motion-asset',
 | 
			
		||||
    originalPath: fileStub.livePhotoMotion.originalPath,
 | 
			
		||||
@ -497,10 +508,41 @@ export const assetStub = {
 | 
			
		||||
    sidecarPath: '/original/path.ext.xmp',
 | 
			
		||||
    deletedAt: null,
 | 
			
		||||
  }),
 | 
			
		||||
  readOnly: Object.freeze({
 | 
			
		||||
 | 
			
		||||
  readOnly: Object.freeze<AssetEntity>({
 | 
			
		||||
    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,
 | 
			
		||||
    isExternal: false,
 | 
			
		||||
    isOffline: false,
 | 
			
		||||
    libraryId: 'library-id',
 | 
			
		||||
    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
									
								
								server/test/fixtures/shared-link.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								server/test/fixtures/shared-link.stub.ts
									
									
									
									
										vendored
									
									
								
							@ -72,6 +72,7 @@ const assetResponse: AssetResponseDto = {
 | 
			
		||||
  isTrashed: false,
 | 
			
		||||
  libraryId: 'library-id',
 | 
			
		||||
  hasMetadata: true,
 | 
			
		||||
  stackCount: 0,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const assetResponseWithoutMetadata = {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										137
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										137
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							@ -399,6 +399,18 @@ export interface AssetBulkUpdateDto {
 | 
			
		||||
     * @memberof AssetBulkUpdateDto
 | 
			
		||||
     */
 | 
			
		||||
    'isFavorite'?: boolean;
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {boolean}
 | 
			
		||||
     * @memberof AssetBulkUpdateDto
 | 
			
		||||
     */
 | 
			
		||||
    'removeParent'?: boolean;
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     * @memberof AssetBulkUpdateDto
 | 
			
		||||
     */
 | 
			
		||||
    'stackParentId'?: string;
 | 
			
		||||
}
 | 
			
		||||
/**
 | 
			
		||||
 * 
 | 
			
		||||
@ -748,6 +760,24 @@ export interface AssetResponseDto {
 | 
			
		||||
     * @memberof AssetResponseDto
 | 
			
		||||
     */
 | 
			
		||||
    '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>}
 | 
			
		||||
@ -3981,6 +4011,25 @@ export interface UpdateLibraryDto {
 | 
			
		||||
     */
 | 
			
		||||
    'name'?: string;
 | 
			
		||||
}
 | 
			
		||||
/**
 | 
			
		||||
 * 
 | 
			
		||||
 * @export
 | 
			
		||||
 * @interface UpdateStackParentDto
 | 
			
		||||
 */
 | 
			
		||||
export interface UpdateStackParentDto {
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     * @memberof UpdateStackParentDto
 | 
			
		||||
     */
 | 
			
		||||
    'newParentId': string;
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     * @memberof UpdateStackParentDto
 | 
			
		||||
     */
 | 
			
		||||
    'oldParentId': string;
 | 
			
		||||
}
 | 
			
		||||
/**
 | 
			
		||||
 * 
 | 
			
		||||
 * @export
 | 
			
		||||
@ -7135,6 +7184,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
 | 
			
		||||
                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 
 | 
			
		||||
@ -7601,6 +7694,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
 | 
			
		||||
            const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
 | 
			
		||||
            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 
 | 
			
		||||
@ -7892,6 +7995,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
 | 
			
		||||
        updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
 | 
			
		||||
            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.
 | 
			
		||||
@ -8499,6 +8611,20 @@ export interface AssetApiUpdateAssetsRequest {
 | 
			
		||||
    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.
 | 
			
		||||
 * @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));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @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.
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user