mirror of
https://github.com/immich-app/immich.git
synced 2024-12-26 10:50:29 +02: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
|
* @memberof AssetBulkUpdateDto
|
||||||
*/
|
*/
|
||||||
'isFavorite'?: boolean;
|
'isFavorite'?: boolean;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof AssetBulkUpdateDto
|
||||||
|
*/
|
||||||
|
'removeParent'?: boolean;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof AssetBulkUpdateDto
|
||||||
|
*/
|
||||||
|
'stackParentId'?: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -748,6 +760,24 @@ export interface AssetResponseDto {
|
|||||||
* @memberof AssetResponseDto
|
* @memberof AssetResponseDto
|
||||||
*/
|
*/
|
||||||
'smartInfo'?: SmartInfoResponseDto;
|
'smartInfo'?: SmartInfoResponseDto;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {Array<AssetResponseDto>}
|
||||||
|
* @memberof AssetResponseDto
|
||||||
|
*/
|
||||||
|
'stack'?: Array<AssetResponseDto>;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
* @memberof AssetResponseDto
|
||||||
|
*/
|
||||||
|
'stackCount': number;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof AssetResponseDto
|
||||||
|
*/
|
||||||
|
'stackParentId'?: string | null;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {Array<TagResponseDto>}
|
* @type {Array<TagResponseDto>}
|
||||||
@ -3981,6 +4011,25 @@ export interface UpdateLibraryDto {
|
|||||||
*/
|
*/
|
||||||
'name'?: string;
|
'name'?: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @interface UpdateStackParentDto
|
||||||
|
*/
|
||||||
|
export interface UpdateStackParentDto {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof UpdateStackParentDto
|
||||||
|
*/
|
||||||
|
'newParentId': string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof UpdateStackParentDto
|
||||||
|
*/
|
||||||
|
'oldParentId': string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @export
|
* @export
|
||||||
@ -7135,6 +7184,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
|||||||
options: localVarRequestOptions,
|
options: localVarRequestOptions,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {UpdateStackParentDto} updateStackParentDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
updateStackParent: async (updateStackParentDto: UpdateStackParentDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
// verify required parameter 'updateStackParentDto' is not null or undefined
|
||||||
|
assertParamExists('updateStackParent', 'updateStackParentDto', updateStackParentDto)
|
||||||
|
const localVarPath = `/asset/stack/parent`;
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
let baseOptions;
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
|
||||||
|
const localVarHeaderParameter = {} as any;
|
||||||
|
const localVarQueryParameter = {} as any;
|
||||||
|
|
||||||
|
// authentication cookie required
|
||||||
|
|
||||||
|
// authentication api_key required
|
||||||
|
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||||
|
|
||||||
|
// authentication bearer required
|
||||||
|
// http bearer authentication required
|
||||||
|
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
|
localVarRequestOptions.data = serializeDataIfNeeded(updateStackParentDto, localVarRequestOptions, configuration)
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {File} assetData
|
* @param {File} assetData
|
||||||
@ -7601,6 +7694,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
|
|||||||
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
|
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
|
||||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {UpdateStackParentDto} updateStackParentDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async updateStackParent(updateStackParentDto: UpdateStackParentDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.updateStackParent(updateStackParentDto, options);
|
||||||
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {File} assetData
|
* @param {File} assetData
|
||||||
@ -7892,6 +7995,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
|
|||||||
updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
|
updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
|
||||||
return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
|
return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters.
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
|
||||||
|
return localVarFp.updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.
|
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.
|
||||||
@ -8499,6 +8611,20 @@ export interface AssetApiUpdateAssetsRequest {
|
|||||||
readonly assetBulkUpdateDto: AssetBulkUpdateDto
|
readonly assetBulkUpdateDto: AssetBulkUpdateDto
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request parameters for updateStackParent operation in AssetApi.
|
||||||
|
* @export
|
||||||
|
* @interface AssetApiUpdateStackParentRequest
|
||||||
|
*/
|
||||||
|
export interface AssetApiUpdateStackParentRequest {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {UpdateStackParentDto}
|
||||||
|
* @memberof AssetApiUpdateStackParent
|
||||||
|
*/
|
||||||
|
readonly updateStackParentDto: UpdateStackParentDto
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request parameters for uploadFile operation in AssetApi.
|
* Request parameters for uploadFile operation in AssetApi.
|
||||||
* @export
|
* @export
|
||||||
@ -8939,6 +9065,17 @@ export class AssetApi extends BaseAPI {
|
|||||||
return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
|
return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters.
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof AssetApi
|
||||||
|
*/
|
||||||
|
public updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig) {
|
||||||
|
return AssetApiFp(this.configuration).updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.
|
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.
|
||||||
|
@ -130,7 +130,9 @@
|
|||||||
"control_bottom_app_bar_delete": "Delete",
|
"control_bottom_app_bar_delete": "Delete",
|
||||||
"control_bottom_app_bar_favorite": "Favorite",
|
"control_bottom_app_bar_favorite": "Favorite",
|
||||||
"control_bottom_app_bar_share": "Share",
|
"control_bottom_app_bar_share": "Share",
|
||||||
|
"control_bottom_app_bar_stack": "Stack",
|
||||||
"control_bottom_app_bar_unarchive": "Unarchive",
|
"control_bottom_app_bar_unarchive": "Unarchive",
|
||||||
|
"control_bottom_app_bar_upload": "Upload",
|
||||||
"create_album_page_untitled": "Untitled",
|
"create_album_page_untitled": "Untitled",
|
||||||
"create_shared_album_page_create": "Create",
|
"create_shared_album_page_create": "Create",
|
||||||
"create_shared_album_page_share": "Share",
|
"create_shared_album_page_share": "Share",
|
||||||
@ -275,6 +277,7 @@
|
|||||||
"setting_pages_app_bar_settings": "Settings",
|
"setting_pages_app_bar_settings": "Settings",
|
||||||
"settings_require_restart": "Please restart Immich to apply this setting",
|
"settings_require_restart": "Please restart Immich to apply this setting",
|
||||||
"share_add": "Add",
|
"share_add": "Add",
|
||||||
|
"share_done": "Done",
|
||||||
"share_add_photos": "Add photos",
|
"share_add_photos": "Add photos",
|
||||||
"share_add_title": "Add a title",
|
"share_add_title": "Add a title",
|
||||||
"share_create_album": "Create album",
|
"share_create_album": "Create album",
|
||||||
@ -337,5 +340,8 @@
|
|||||||
"trash_page_select_assets_btn": "Select assets",
|
"trash_page_select_assets_btn": "Select assets",
|
||||||
"trash_page_empty_trash_btn": "Empty trash",
|
"trash_page_empty_trash_btn": "Empty trash",
|
||||||
"trash_page_empty_trash_dialog_ok": "Ok",
|
"trash_page_empty_trash_dialog_ok": "Ok",
|
||||||
"trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich"
|
"trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich",
|
||||||
|
"viewer_stack_use_as_main_asset": "Use as Main Asset",
|
||||||
|
"viewer_remove_from_stack": "Remove from Stack",
|
||||||
|
"viewer_unstack": "Un-Stack"
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,7 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
|
|||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/models/album.dart';
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||||
@ -69,7 +70,8 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||||||
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
|
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
|
||||||
AssetSelectionRoute(
|
AssetSelectionRoute(
|
||||||
existingAssets: albumInfo.assets,
|
existingAssets: albumInfo.assets,
|
||||||
isNewAlbum: false,
|
canDeselect: false,
|
||||||
|
query: getRemoteAssetQuery(ref),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -4,26 +4,27 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
|
||||||
|
|
||||||
class AssetSelectionPage extends HookConsumerWidget {
|
class AssetSelectionPage extends HookConsumerWidget {
|
||||||
const AssetSelectionPage({
|
const AssetSelectionPage({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.existingAssets,
|
required this.existingAssets,
|
||||||
this.isNewAlbum = false,
|
this.canDeselect = false,
|
||||||
|
required this.query,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final Set<Asset> existingAssets;
|
final Set<Asset> existingAssets;
|
||||||
final bool isNewAlbum;
|
final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
|
||||||
|
final bool canDeselect;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final currentUser = ref.watch(currentUserProvider);
|
final renderList = ref.watch(renderListQueryProvider(query));
|
||||||
final renderList = ref.watch(remoteAssetsProvider(currentUser?.isarId));
|
|
||||||
final selected = useState<Set<Asset>>(existingAssets);
|
final selected = useState<Set<Asset>>(existingAssets);
|
||||||
final selectionEnabledHook = useState(true);
|
final selectionEnabledHook = useState(true);
|
||||||
|
|
||||||
@ -39,8 +40,8 @@ class AssetSelectionPage extends HookConsumerWidget {
|
|||||||
selected.value = assets;
|
selected.value = assets;
|
||||||
},
|
},
|
||||||
selectionActive: true,
|
selectionActive: true,
|
||||||
preselectedAssets: isNewAlbum ? selected.value : existingAssets,
|
preselectedAssets: existingAssets,
|
||||||
canDeselect: isNewAlbum,
|
canDeselect: canDeselect,
|
||||||
showMultiSelectIndicator: false,
|
showMultiSelectIndicator: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -65,7 +66,7 @@ class AssetSelectionPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
if (selected.value.isNotEmpty)
|
if (selected.value.isNotEmpty || canDeselect)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
var payload =
|
var payload =
|
||||||
@ -74,7 +75,7 @@ class AssetSelectionPage extends HookConsumerWidget {
|
|||||||
.popForced<AssetSelectionPageResult>(payload);
|
.popForced<AssetSelectionPageResult>(payload);
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
"share_add",
|
canDeselect ? "share_done" : "share_add",
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Theme.of(context).primaryColor,
|
color: Theme.of(context).primaryColor,
|
||||||
|
@ -11,6 +11,7 @@ import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart';
|
|||||||
import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart';
|
import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
|
|
||||||
// ignore: must_be_immutable
|
// ignore: must_be_immutable
|
||||||
class CreateAlbumPage extends HookConsumerWidget {
|
class CreateAlbumPage extends HookConsumerWidget {
|
||||||
@ -31,7 +32,8 @@ class CreateAlbumPage extends HookConsumerWidget {
|
|||||||
final isAlbumTitleTextFieldFocus = useState(false);
|
final isAlbumTitleTextFieldFocus = useState(false);
|
||||||
final isAlbumTitleEmpty = useState(true);
|
final isAlbumTitleEmpty = useState(true);
|
||||||
final selectedAssets = useState<Set<Asset>>(
|
final selectedAssets = useState<Set<Asset>>(
|
||||||
initialAssets != null ? Set.from(initialAssets!) : const {},);
|
initialAssets != null ? Set.from(initialAssets!) : const {},
|
||||||
|
);
|
||||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
showSelectUserPage() async {
|
showSelectUserPage() async {
|
||||||
@ -59,7 +61,8 @@ class CreateAlbumPage extends HookConsumerWidget {
|
|||||||
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
|
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
|
||||||
AssetSelectionRoute(
|
AssetSelectionRoute(
|
||||||
existingAssets: selectedAssets.value,
|
existingAssets: selectedAssets.value,
|
||||||
isNewAlbum: true,
|
canDeselect: true,
|
||||||
|
query: getRemoteAssetQuery(ref),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (selectedAsset == null) {
|
if (selectedAsset == null) {
|
||||||
|
@ -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/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
final renderListProvider =
|
final renderListProvider =
|
||||||
FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
|
FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
|
||||||
@ -13,3 +14,19 @@ final renderListProvider =
|
|||||||
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
|
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final renderListQueryProvider = StreamProvider.family<RenderList,
|
||||||
|
QueryBuilder<Asset, Asset, QAfterSortBy>?>(
|
||||||
|
(ref, query) async* {
|
||||||
|
if (query == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final settings = ref.watch(appSettingsServiceProvider);
|
||||||
|
final groupBy = GroupAssetsBy
|
||||||
|
.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
|
||||||
|
yield await RenderList.fromQuery(query, groupBy);
|
||||||
|
await for (final _ in query.watchLazy()) {
|
||||||
|
yield await RenderList.fromQuery(query, groupBy);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
@ -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:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
|
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
|
import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
|
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart';
|
import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
|
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
|
||||||
@ -44,6 +46,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
final int totalAssets;
|
final int totalAssets;
|
||||||
final int initialIndex;
|
final int initialIndex;
|
||||||
final int heroOffset;
|
final int heroOffset;
|
||||||
|
final bool showStack;
|
||||||
|
|
||||||
GalleryViewerPage({
|
GalleryViewerPage({
|
||||||
super.key,
|
super.key,
|
||||||
@ -51,6 +54,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
required this.loadAsset,
|
required this.loadAsset,
|
||||||
required this.totalAssets,
|
required this.totalAssets,
|
||||||
this.heroOffset = 0,
|
this.heroOffset = 0,
|
||||||
|
this.showStack = false,
|
||||||
}) : controller = PageController(initialPage: initialIndex);
|
}) : controller = PageController(initialPage: initialIndex);
|
||||||
|
|
||||||
final PageController controller;
|
final PageController controller;
|
||||||
@ -77,8 +81,17 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
final isFromTrash = isTrashEnabled &&
|
final isFromTrash = isTrashEnabled &&
|
||||||
navStack.length > 2 &&
|
navStack.length > 2 &&
|
||||||
navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
|
navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
|
||||||
|
final stackIndex = useState(-1);
|
||||||
|
final stack = showStack && currentAsset.stackCount > 0
|
||||||
|
? ref.watch(assetStackStateProvider(currentAsset))
|
||||||
|
: <Asset>[];
|
||||||
|
final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
|
||||||
|
|
||||||
Asset asset() => currentAsset;
|
Asset asset() => stackIndex.value == -1
|
||||||
|
? currentAsset
|
||||||
|
: stackElements.elementAt(stackIndex.value);
|
||||||
|
|
||||||
|
bool isParent = stackIndex.value == -1 || stackIndex.value == 0;
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
@ -165,19 +178,28 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
),
|
),
|
||||||
child: ExifBottomSheet(asset: currentAsset),
|
child: ExifBottomSheet(asset: asset()),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void removeAssetFromStack() {
|
||||||
|
if (stackIndex.value > 0 && showStack) {
|
||||||
|
ref
|
||||||
|
.read(assetStackStateProvider(currentAsset).notifier)
|
||||||
|
.removeChild(stackIndex.value - 1);
|
||||||
|
stackIndex.value = stackIndex.value - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void handleDelete(Asset deleteAsset) async {
|
void handleDelete(Asset deleteAsset) async {
|
||||||
Future<bool> onDelete(bool force) async {
|
Future<bool> onDelete(bool force) async {
|
||||||
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
|
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
|
||||||
{deleteAsset},
|
{deleteAsset},
|
||||||
force: force,
|
force: force,
|
||||||
);
|
);
|
||||||
if (isDeleted) {
|
if (isDeleted && isParent) {
|
||||||
if (totalAssets == 1) {
|
if (totalAssets == 1) {
|
||||||
// Handle only one asset
|
// Handle only one asset
|
||||||
AutoRouter.of(context).pop();
|
AutoRouter.of(context).pop();
|
||||||
@ -195,8 +217,9 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
// Asset is trashed
|
// Asset is trashed
|
||||||
if (isTrashEnabled && !isFromTrash) {
|
if (isTrashEnabled && !isFromTrash) {
|
||||||
final isDeleted = await onDelete(false);
|
final isDeleted = await onDelete(false);
|
||||||
|
if (isDeleted) {
|
||||||
// Can only trash assets stored in server. Local assets are always permanently removed for now
|
// Can only trash assets stored in server. Local assets are always permanently removed for now
|
||||||
if (context.mounted && isDeleted && deleteAsset.isRemote) {
|
if (context.mounted && deleteAsset.isRemote && isParent) {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
durationInSecond: 1,
|
durationInSecond: 1,
|
||||||
context: context,
|
context: context,
|
||||||
@ -204,6 +227,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
gravity: ToastGravity.BOTTOM,
|
gravity: ToastGravity.BOTTOM,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
removeAssetFromStack();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,7 +236,14 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext _) {
|
builder: (BuildContext _) {
|
||||||
return DeleteDialog(onDelete: () => onDelete(true));
|
return DeleteDialog(
|
||||||
|
onDelete: () async {
|
||||||
|
final isDeleted = await onDelete(true);
|
||||||
|
if (isDeleted) {
|
||||||
|
removeAssetFromStack();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -268,7 +300,11 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
ref
|
ref
|
||||||
.watch(assetProvider.notifier)
|
.watch(assetProvider.notifier)
|
||||||
.toggleArchive([asset], !asset.isArchived);
|
.toggleArchive([asset], !asset.isArchived);
|
||||||
|
if (isParent) {
|
||||||
AutoRouter.of(context).pop();
|
AutoRouter.of(context).pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeAssetFromStack();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleUpload(Asset asset) {
|
handleUpload(Asset asset) {
|
||||||
@ -385,7 +421,186 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildBottomBar() {
|
Widget buildStackedChildren() {
|
||||||
|
return ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: stackElements.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final assetId = stackElements.elementAt(index).remoteId;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 10),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => stackIndex.value = index,
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: index == stackIndex.value
|
||||||
|
? Border.all(
|
||||||
|
color: Colors.white,
|
||||||
|
width: 2,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
imageUrl:
|
||||||
|
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$assetId',
|
||||||
|
httpHeaders: {
|
||||||
|
"Authorization":
|
||||||
|
"Bearer ${Store.get(StoreKey.accessToken)}",
|
||||||
|
},
|
||||||
|
errorWidget: (context, url, error) =>
|
||||||
|
const Icon(Icons.image_not_supported_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void showStackActionItems() {
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
enableDrag: false,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 24.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (!isParent)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.bookmark_border_outlined,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
await ref
|
||||||
|
.read(assetStackServiceProvider)
|
||||||
|
.updateStackParent(
|
||||||
|
currentAsset,
|
||||||
|
stackElements.elementAt(stackIndex.value),
|
||||||
|
);
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
AutoRouter.of(context).pop();
|
||||||
|
},
|
||||||
|
title: const Text(
|
||||||
|
"viewer_stack_use_as_main_asset",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.copy_all_outlined,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
if (isParent) {
|
||||||
|
await ref
|
||||||
|
.read(assetStackServiceProvider)
|
||||||
|
.updateStackParent(
|
||||||
|
currentAsset,
|
||||||
|
stackElements
|
||||||
|
.elementAt(1), // Next asset as parent
|
||||||
|
);
|
||||||
|
// Remove itself from stack
|
||||||
|
await ref.read(assetStackServiceProvider).updateStack(
|
||||||
|
stackElements.elementAt(1),
|
||||||
|
childrenToRemove: [currentAsset],
|
||||||
|
);
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
AutoRouter.of(context).pop();
|
||||||
|
} else {
|
||||||
|
await ref.read(assetStackServiceProvider).updateStack(
|
||||||
|
currentAsset,
|
||||||
|
childrenToRemove: [
|
||||||
|
stackElements.elementAt(stackIndex.value),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
removeAssetFromStack();
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: const Text(
|
||||||
|
"viewer_remove_from_stack",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.filter_none_outlined,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
await ref.read(assetStackServiceProvider).updateStack(
|
||||||
|
currentAsset,
|
||||||
|
childrenToRemove: stack,
|
||||||
|
);
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
AutoRouter.of(context).pop();
|
||||||
|
},
|
||||||
|
title: const Text(
|
||||||
|
"viewer_unstack",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildBottomBar() {
|
||||||
|
// !!!! itemsList and actionlist should always be in sync
|
||||||
|
final itemsList = [
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(
|
||||||
|
Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
|
||||||
|
),
|
||||||
|
label: 'control_bottom_app_bar_share'.tr(),
|
||||||
|
tooltip: 'control_bottom_app_bar_share'.tr(),
|
||||||
|
),
|
||||||
|
asset().isArchived
|
||||||
|
? BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.unarchive_rounded),
|
||||||
|
label: 'control_bottom_app_bar_unarchive'.tr(),
|
||||||
|
tooltip: 'control_bottom_app_bar_unarchive'.tr(),
|
||||||
|
)
|
||||||
|
: BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.archive_outlined),
|
||||||
|
label: 'control_bottom_app_bar_archive'.tr(),
|
||||||
|
tooltip: 'control_bottom_app_bar_archive'.tr(),
|
||||||
|
),
|
||||||
|
if (stack.isNotEmpty)
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.burst_mode_outlined),
|
||||||
|
label: 'control_bottom_app_bar_stack'.tr(),
|
||||||
|
tooltip: 'control_bottom_app_bar_stack'.tr(),
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
label: 'control_bottom_app_bar_delete'.tr(),
|
||||||
|
tooltip: 'control_bottom_app_bar_delete'.tr(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<Function(int)> actionslist = [
|
||||||
|
(_) => shareAsset(),
|
||||||
|
(_) => handleArchive(asset()),
|
||||||
|
if (stack.isNotEmpty) (_) => showStackActionItems(),
|
||||||
|
(_) => handleDelete(asset()),
|
||||||
|
];
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
ignoring: !ref.watch(showControlsProvider),
|
ignoring: !ref.watch(showControlsProvider),
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
@ -393,6 +608,17 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
if (stack.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: 10,
|
||||||
|
bottom: 30,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: buildStackedChildren(),
|
||||||
|
),
|
||||||
|
),
|
||||||
Visibility(
|
Visibility(
|
||||||
visible: !asset().isImage && !isPlayingMotionVideo.value,
|
visible: !asset().isImage && !isPlayingMotionVideo.value,
|
||||||
child: Container(
|
child: Container(
|
||||||
@ -421,44 +647,10 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
selectedLabelStyle: const TextStyle(color: Colors.black),
|
selectedLabelStyle: const TextStyle(color: Colors.black),
|
||||||
showSelectedLabels: false,
|
showSelectedLabels: false,
|
||||||
showUnselectedLabels: false,
|
showUnselectedLabels: false,
|
||||||
items: [
|
items: 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(),
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: const Icon(Icons.delete_outline),
|
|
||||||
label: 'control_bottom_app_bar_delete'.tr(),
|
|
||||||
tooltip: 'control_bottom_app_bar_delete'.tr(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onTap: (index) {
|
onTap: (index) {
|
||||||
switch (index) {
|
if (index < actionslist.length) {
|
||||||
case 0:
|
actionslist[index].call(index);
|
||||||
shareAsset();
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
handleArchive(asset());
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
handleDelete(asset());
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -504,6 +696,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
final next = currentIndex.value < value ? value + 1 : value - 1;
|
final next = currentIndex.value < value ? value + 1 : value - 1;
|
||||||
precacheNextImage(next);
|
precacheNextImage(next);
|
||||||
currentIndex.value = value;
|
currentIndex.value = value;
|
||||||
|
stackIndex.value = -1;
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
},
|
},
|
||||||
loadingBuilder: (context, event, index) {
|
loadingBuilder: (context, event, index) {
|
||||||
@ -544,10 +737,11 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
: webPThumbnail;
|
: webPThumbnail;
|
||||||
},
|
},
|
||||||
builder: (context, index) {
|
builder: (context, index) {
|
||||||
final asset = loadAsset(index);
|
final a =
|
||||||
final ImageProvider provider = finalImageProvider(asset);
|
index == currentIndex.value ? asset() : loadAsset(index);
|
||||||
|
final ImageProvider provider = finalImageProvider(a);
|
||||||
|
|
||||||
if (asset.isImage && !isPlayingMotionVideo.value) {
|
if (a.isImage && !isPlayingMotionVideo.value) {
|
||||||
return PhotoViewGalleryPageOptions(
|
return PhotoViewGalleryPageOptions(
|
||||||
onDragStart: (_, details, __) =>
|
onDragStart: (_, details, __) =>
|
||||||
localPosition = details.localPosition,
|
localPosition = details.localPosition,
|
||||||
@ -558,13 +752,13 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
imageProvider: provider,
|
imageProvider: provider,
|
||||||
heroAttributes: PhotoViewHeroAttributes(
|
heroAttributes: PhotoViewHeroAttributes(
|
||||||
tag: asset.id + heroOffset,
|
tag: a.id + heroOffset,
|
||||||
),
|
),
|
||||||
filterQuality: FilterQuality.high,
|
filterQuality: FilterQuality.high,
|
||||||
tightMode: true,
|
tightMode: true,
|
||||||
minScale: PhotoViewComputedScale.contained,
|
minScale: PhotoViewComputedScale.contained,
|
||||||
errorBuilder: (context, error, stackTrace) => ImmichImage(
|
errorBuilder: (context, error, stackTrace) => ImmichImage(
|
||||||
asset,
|
a,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -575,7 +769,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
onDragUpdate: (_, details, __) =>
|
onDragUpdate: (_, details, __) =>
|
||||||
handleSwipeUpDown(details),
|
handleSwipeUpDown(details),
|
||||||
heroAttributes: PhotoViewHeroAttributes(
|
heroAttributes: PhotoViewHeroAttributes(
|
||||||
tag: asset.id + heroOffset,
|
tag: a.id + heroOffset,
|
||||||
),
|
),
|
||||||
filterQuality: FilterQuality.high,
|
filterQuality: FilterQuality.high,
|
||||||
maxScale: 1.0,
|
maxScale: 1.0,
|
||||||
@ -584,7 +778,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
child: VideoViewerPage(
|
child: VideoViewerPage(
|
||||||
onPlaying: () => isPlayingVideo.value = true,
|
onPlaying: () => isPlayingVideo.value = true,
|
||||||
onPaused: () => isPlayingVideo.value = false,
|
onPaused: () => isPlayingVideo.value = false,
|
||||||
asset: asset,
|
asset: a,
|
||||||
isMotionVideo: isPlayingMotionVideo.value,
|
isMotionVideo: isPlayingMotionVideo.value,
|
||||||
placeholder: Image(
|
placeholder: Image(
|
||||||
image: provider,
|
image: provider,
|
||||||
|
47
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 Widget? topWidget;
|
||||||
final bool shrinkWrap;
|
final bool shrinkWrap;
|
||||||
final bool showDragScroll;
|
final bool showDragScroll;
|
||||||
|
final bool showStack;
|
||||||
|
|
||||||
const ImmichAssetGrid({
|
const ImmichAssetGrid({
|
||||||
super.key,
|
super.key,
|
||||||
@ -51,6 +52,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
|||||||
this.topWidget,
|
this.topWidget,
|
||||||
this.shrinkWrap = false,
|
this.shrinkWrap = false,
|
||||||
this.showDragScroll = true,
|
this.showDragScroll = true,
|
||||||
|
this.showStack = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -114,6 +116,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
|||||||
heroOffset: heroOffset(),
|
heroOffset: heroOffset(),
|
||||||
shrinkWrap: shrinkWrap,
|
shrinkWrap: shrinkWrap,
|
||||||
showDragScroll: showDragScroll,
|
showDragScroll: showDragScroll,
|
||||||
|
showStack: showStack,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -37,6 +37,7 @@ class ImmichAssetGridView extends StatefulWidget {
|
|||||||
final int heroOffset;
|
final int heroOffset;
|
||||||
final bool shrinkWrap;
|
final bool shrinkWrap;
|
||||||
final bool showDragScroll;
|
final bool showDragScroll;
|
||||||
|
final bool showStack;
|
||||||
|
|
||||||
const ImmichAssetGridView({
|
const ImmichAssetGridView({
|
||||||
super.key,
|
super.key,
|
||||||
@ -56,6 +57,7 @@ class ImmichAssetGridView extends StatefulWidget {
|
|||||||
this.heroOffset = 0,
|
this.heroOffset = 0,
|
||||||
this.shrinkWrap = false,
|
this.shrinkWrap = false,
|
||||||
this.showDragScroll = true,
|
this.showDragScroll = true,
|
||||||
|
this.showStack = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -71,7 +73,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
|
|
||||||
bool _scrolling = false;
|
bool _scrolling = false;
|
||||||
final Set<Asset> _selectedAssets =
|
final Set<Asset> _selectedAssets =
|
||||||
HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
|
LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
|
||||||
|
|
||||||
Set<Asset> _getSelectedAssets() {
|
Set<Asset> _getSelectedAssets() {
|
||||||
return Set.from(_selectedAssets);
|
return Set.from(_selectedAssets);
|
||||||
@ -90,7 +92,13 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
|
|
||||||
void _deselectAssets(List<Asset> assets) {
|
void _deselectAssets(List<Asset> assets) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedAssets.removeAll(assets);
|
_selectedAssets.removeAll(
|
||||||
|
assets.where(
|
||||||
|
(a) =>
|
||||||
|
widget.canDeselect ||
|
||||||
|
!(widget.preselectedAssets?.contains(a) ?? false),
|
||||||
|
),
|
||||||
|
);
|
||||||
_callSelectionListener(_selectedAssets.isNotEmpty);
|
_callSelectionListener(_selectedAssets.isNotEmpty);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -129,6 +137,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
useGrayBoxPlaceholder: true,
|
useGrayBoxPlaceholder: true,
|
||||||
showStorageIndicator: widget.showStorageIndicator,
|
showStorageIndicator: widget.showStorageIndicator,
|
||||||
heroOffset: widget.heroOffset,
|
heroOffset: widget.heroOffset,
|
||||||
|
showStack: widget.showStack,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -377,10 +386,6 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_selectedAssets.clear();
|
_selectedAssets.clear();
|
||||||
});
|
});
|
||||||
} else if (widget.preselectedAssets != null) {
|
|
||||||
setState(() {
|
|
||||||
_selectedAssets.addAll(widget.preselectedAssets!);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ class ThumbnailImage extends StatelessWidget {
|
|||||||
final Asset Function(int index) loadAsset;
|
final Asset Function(int index) loadAsset;
|
||||||
final int totalAssets;
|
final int totalAssets;
|
||||||
final bool showStorageIndicator;
|
final bool showStorageIndicator;
|
||||||
|
final bool showStack;
|
||||||
final bool useGrayBoxPlaceholder;
|
final bool useGrayBoxPlaceholder;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final bool multiselectEnabled;
|
final bool multiselectEnabled;
|
||||||
@ -26,6 +27,7 @@ class ThumbnailImage extends StatelessWidget {
|
|||||||
required this.loadAsset,
|
required this.loadAsset,
|
||||||
required this.totalAssets,
|
required this.totalAssets,
|
||||||
this.showStorageIndicator = true,
|
this.showStorageIndicator = true,
|
||||||
|
this.showStack = false,
|
||||||
this.useGrayBoxPlaceholder = false,
|
this.useGrayBoxPlaceholder = false,
|
||||||
this.isSelected = false,
|
this.isSelected = false,
|
||||||
this.multiselectEnabled = false,
|
this.multiselectEnabled = false,
|
||||||
@ -93,6 +95,35 @@ class ThumbnailImage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget buildStackIcon() {
|
||||||
|
return Positioned(
|
||||||
|
top: 5,
|
||||||
|
right: 5,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (asset.stackCount > 1)
|
||||||
|
Text(
|
||||||
|
"${asset.stackCount}",
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (asset.stackCount > 1)
|
||||||
|
const SizedBox(
|
||||||
|
width: 3,
|
||||||
|
),
|
||||||
|
const Icon(
|
||||||
|
Icons.burst_mode_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget buildImage() {
|
Widget buildImage() {
|
||||||
final image = SizedBox(
|
final image = SizedBox(
|
||||||
width: 300,
|
width: 300,
|
||||||
@ -113,9 +144,9 @@ class ThumbnailImage extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
width: 0,
|
width: 0,
|
||||||
color: assetContainerColor,
|
color: onDeselect == null ? Colors.grey : assetContainerColor,
|
||||||
),
|
),
|
||||||
color: assetContainerColor,
|
color: onDeselect == null ? Colors.grey : assetContainerColor,
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: const BorderRadius.only(
|
borderRadius: const BorderRadius.only(
|
||||||
@ -144,6 +175,7 @@ class ThumbnailImage extends StatelessWidget {
|
|||||||
loadAsset: loadAsset,
|
loadAsset: loadAsset,
|
||||||
totalAssets: totalAssets,
|
totalAssets: totalAssets,
|
||||||
heroOffset: heroOffset,
|
heroOffset: heroOffset,
|
||||||
|
showStack: showStack,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -196,6 +228,7 @@ class ThumbnailImage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!asset.isImage) buildVideoIcon(),
|
if (!asset.isImage) buildVideoIcon(),
|
||||||
|
if (asset.isImage && asset.stackCount > 0) buildStackIcon(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -4,9 +4,9 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
|
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/models/selection_state.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
|
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
|
||||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||||
import 'package:immich_mobile/shared/models/album.dart';
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
@ -19,11 +19,12 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||||||
final Function(Album album) onAddToAlbum;
|
final Function(Album album) onAddToAlbum;
|
||||||
final void Function() onCreateNewAlbum;
|
final void Function() onCreateNewAlbum;
|
||||||
final void Function() onUpload;
|
final void Function() onUpload;
|
||||||
|
final void Function() onStack;
|
||||||
|
|
||||||
final List<Album> albums;
|
final List<Album> albums;
|
||||||
final List<Album> sharedAlbums;
|
final List<Album> sharedAlbums;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final AssetState selectionAssetState;
|
final SelectionAssetState selectionAssetState;
|
||||||
|
|
||||||
const ControlBottomAppBar({
|
const ControlBottomAppBar({
|
||||||
Key? key,
|
Key? key,
|
||||||
@ -36,19 +37,24 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||||||
required this.onAddToAlbum,
|
required this.onAddToAlbum,
|
||||||
required this.onCreateNewAlbum,
|
required this.onCreateNewAlbum,
|
||||||
required this.onUpload,
|
required this.onUpload,
|
||||||
this.selectionAssetState = AssetState.remote,
|
required this.onStack,
|
||||||
|
this.selectionAssetState = const SelectionAssetState(),
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||||
var hasRemote = selectionAssetState == AssetState.remote;
|
var hasRemote =
|
||||||
|
selectionAssetState.hasRemote || selectionAssetState.hasMerged;
|
||||||
|
var hasLocal = selectionAssetState.hasLocal;
|
||||||
final trashEnabled =
|
final trashEnabled =
|
||||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
||||||
|
|
||||||
Widget renderActionButtons() {
|
Widget renderActionButtons() {
|
||||||
return Row(
|
return Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 15,
|
||||||
children: [
|
children: [
|
||||||
ControlBoxButton(
|
ControlBoxButton(
|
||||||
iconData: Platform.isAndroid
|
iconData: Platform.isAndroid
|
||||||
@ -92,7 +98,7 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||||||
if (!hasRemote)
|
if (!hasRemote)
|
||||||
ControlBoxButton(
|
ControlBoxButton(
|
||||||
iconData: Icons.backup_outlined,
|
iconData: Icons.backup_outlined,
|
||||||
label: "Upload",
|
label: "control_bottom_app_bar_upload".tr(),
|
||||||
onPressed: enabled
|
onPressed: enabled
|
||||||
? () => showDialog(
|
? () => showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@ -104,6 +110,12 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
if (!hasLocal)
|
||||||
|
ControlBoxButton(
|
||||||
|
iconData: Icons.filter_none_rounded,
|
||||||
|
label: "control_bottom_app_bar_stack".tr(),
|
||||||
|
onPressed: enabled ? onStack : null,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -111,7 +123,7 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||||||
return DraggableScrollableSheet(
|
return DraggableScrollableSheet(
|
||||||
initialChildSize: hasRemote ? 0.30 : 0.18,
|
initialChildSize: hasRemote ? 0.30 : 0.18,
|
||||||
minChildSize: 0.18,
|
minChildSize: 0.18,
|
||||||
maxChildSize: hasRemote ? 0.57 : 0.18,
|
maxChildSize: hasRemote ? 0.60 : 0.18,
|
||||||
snap: true,
|
snap: true,
|
||||||
builder: (
|
builder: (
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
|
@ -7,11 +7,15 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart';
|
||||||
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
|
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/models/selection_state.dart';
|
||||||
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
|
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
|
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
|
||||||
@ -36,7 +40,7 @@ class HomePage extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final multiselectEnabled = ref.watch(multiselectProvider.notifier);
|
final multiselectEnabled = ref.watch(multiselectProvider.notifier);
|
||||||
final selectionEnabledHook = useState(false);
|
final selectionEnabledHook = useState(false);
|
||||||
final selectionAssetState = useState(AssetState.remote);
|
final selectionAssetState = useState(const SelectionAssetState());
|
||||||
|
|
||||||
final selection = useState(<Asset>{});
|
final selection = useState(<Asset>{});
|
||||||
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
|
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
|
||||||
@ -83,9 +87,8 @@ class HomePage extends HookConsumerWidget {
|
|||||||
) {
|
) {
|
||||||
selectionEnabledHook.value = multiselect;
|
selectionEnabledHook.value = multiselect;
|
||||||
selection.value = selectedAssets;
|
selection.value = selectedAssets;
|
||||||
selectionAssetState.value = selectedAssets.any((e) => e.isRemote)
|
selectionAssetState.value =
|
||||||
? AssetState.remote
|
SelectionAssetState.fromSelection(selectedAssets);
|
||||||
: AssetState.local;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void onShareAssets() {
|
void onShareAssets() {
|
||||||
@ -246,6 +249,55 @@ class HomePage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void onStack() async {
|
||||||
|
try {
|
||||||
|
processing.value = true;
|
||||||
|
if (!selectionEnabledHook.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final selectedAsset = selection.value.elementAt(0);
|
||||||
|
|
||||||
|
if (selection.value.length == 1) {
|
||||||
|
final stackChildren =
|
||||||
|
(await ref.read(assetStackProvider(selectedAsset).future))
|
||||||
|
.toSet();
|
||||||
|
AssetSelectionPageResult? returnPayload =
|
||||||
|
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
|
||||||
|
AssetSelectionRoute(
|
||||||
|
existingAssets: stackChildren,
|
||||||
|
canDeselect: true,
|
||||||
|
query: getAssetStackSelectionQuery(ref, selectedAsset),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (returnPayload != null) {
|
||||||
|
Set<Asset> selectedAssets = returnPayload.selectedAssets;
|
||||||
|
// Do not add itself as its stack child
|
||||||
|
selectedAssets.remove(selectedAsset);
|
||||||
|
final removedChildren = stackChildren.difference(selectedAssets);
|
||||||
|
final addedChildren = selectedAssets.difference(stackChildren);
|
||||||
|
await ref.read(assetStackServiceProvider).updateStack(
|
||||||
|
selectedAsset,
|
||||||
|
childrenToAdd: addedChildren.toList(),
|
||||||
|
childrenToRemove: removedChildren.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Merge assets
|
||||||
|
selection.value.remove(selectedAsset);
|
||||||
|
final selectedAssets = selection.value;
|
||||||
|
await ref.read(assetStackServiceProvider).updateStack(
|
||||||
|
selectedAsset,
|
||||||
|
childrenToAdd: selectedAssets.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
processing.value = false;
|
||||||
|
selectionEnabledHook.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> refreshAssets() async {
|
Future<void> refreshAssets() async {
|
||||||
final fullRefresh = refreshCount.value > 0;
|
final fullRefresh = refreshCount.value > 0;
|
||||||
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
|
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
|
||||||
@ -322,6 +374,7 @@ class HomePage extends HookConsumerWidget {
|
|||||||
currentUser.memoryEnabled!)
|
currentUser.memoryEnabled!)
|
||||||
? const MemoryLane()
|
? const MemoryLane()
|
||||||
: const SizedBox(),
|
: const SizedBox(),
|
||||||
|
showStack: true,
|
||||||
),
|
),
|
||||||
error: (error, _) => Center(child: Text(error.toString())),
|
error: (error, _) => Center(child: Text(error.toString())),
|
||||||
loading: buildLoadingIndicator,
|
loading: buildLoadingIndicator,
|
||||||
@ -339,6 +392,7 @@ class HomePage extends HookConsumerWidget {
|
|||||||
onUpload: onUpload,
|
onUpload: onUpload,
|
||||||
enabled: !processing.value,
|
enabled: !processing.value,
|
||||||
selectionAssetState: selectionAssetState.value,
|
selectionAssetState: selectionAssetState.value,
|
||||||
|
onStack: onStack,
|
||||||
),
|
),
|
||||||
if (processing.value) const Center(child: ImmichLoadingIndicator()),
|
if (processing.value) const Center(child: ImmichLoadingIndicator()),
|
||||||
],
|
],
|
||||||
|
@ -252,6 +252,7 @@ class TrashPage extends HookConsumerWidget {
|
|||||||
listener: selectionListener,
|
listener: selectionListener,
|
||||||
selectionActive: selectionEnabledHook.value,
|
selectionActive: selectionEnabledHook.value,
|
||||||
showMultiSelectIndicator: false,
|
showMultiSelectIndicator: false,
|
||||||
|
showStack: true,
|
||||||
topWidget: Padding(
|
topWidget: Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
top: 24,
|
top: 24,
|
||||||
|
@ -51,6 +51,7 @@ import 'package:immich_mobile/shared/views/app_log_detail_page.dart';
|
|||||||
import 'package:immich_mobile/shared/views/app_log_page.dart';
|
import 'package:immich_mobile/shared/views/app_log_page.dart';
|
||||||
import 'package:immich_mobile/shared/views/splash_screen.dart';
|
import 'package:immich_mobile/shared/views/splash_screen.dart';
|
||||||
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
|
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
part 'router.gr.dart';
|
part 'router.gr.dart';
|
||||||
|
@ -71,6 +71,7 @@ class _$AppRouter extends RootStackRouter {
|
|||||||
loadAsset: args.loadAsset,
|
loadAsset: args.loadAsset,
|
||||||
totalAssets: args.totalAssets,
|
totalAssets: args.totalAssets,
|
||||||
heroOffset: args.heroOffset,
|
heroOffset: args.heroOffset,
|
||||||
|
showStack: args.showStack,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -153,7 +154,8 @@ class _$AppRouter extends RootStackRouter {
|
|||||||
child: AssetSelectionPage(
|
child: AssetSelectionPage(
|
||||||
key: args.key,
|
key: args.key,
|
||||||
existingAssets: args.existingAssets,
|
existingAssets: args.existingAssets,
|
||||||
isNewAlbum: args.isNewAlbum,
|
canDeselect: args.canDeselect,
|
||||||
|
query: args.query,
|
||||||
),
|
),
|
||||||
transitionsBuilder: TransitionsBuilders.slideBottom,
|
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||||
opaque: true,
|
opaque: true,
|
||||||
@ -711,6 +713,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
|
|||||||
required Asset Function(int) loadAsset,
|
required Asset Function(int) loadAsset,
|
||||||
required int totalAssets,
|
required int totalAssets,
|
||||||
int heroOffset = 0,
|
int heroOffset = 0,
|
||||||
|
bool showStack = false,
|
||||||
}) : super(
|
}) : super(
|
||||||
GalleryViewerRoute.name,
|
GalleryViewerRoute.name,
|
||||||
path: '/gallery-viewer-page',
|
path: '/gallery-viewer-page',
|
||||||
@ -720,6 +723,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
|
|||||||
loadAsset: loadAsset,
|
loadAsset: loadAsset,
|
||||||
totalAssets: totalAssets,
|
totalAssets: totalAssets,
|
||||||
heroOffset: heroOffset,
|
heroOffset: heroOffset,
|
||||||
|
showStack: showStack,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -733,6 +737,7 @@ class GalleryViewerRouteArgs {
|
|||||||
required this.loadAsset,
|
required this.loadAsset,
|
||||||
required this.totalAssets,
|
required this.totalAssets,
|
||||||
this.heroOffset = 0,
|
this.heroOffset = 0,
|
||||||
|
this.showStack = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Key? key;
|
final Key? key;
|
||||||
@ -745,9 +750,11 @@ class GalleryViewerRouteArgs {
|
|||||||
|
|
||||||
final int heroOffset;
|
final int heroOffset;
|
||||||
|
|
||||||
|
final bool showStack;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset}';
|
return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -961,14 +968,16 @@ class AssetSelectionRoute extends PageRouteInfo<AssetSelectionRouteArgs> {
|
|||||||
AssetSelectionRoute({
|
AssetSelectionRoute({
|
||||||
Key? key,
|
Key? key,
|
||||||
required Set<Asset> existingAssets,
|
required Set<Asset> existingAssets,
|
||||||
bool isNewAlbum = false,
|
bool canDeselect = false,
|
||||||
|
required QueryBuilder<Asset, Asset, QAfterSortBy>? query,
|
||||||
}) : super(
|
}) : super(
|
||||||
AssetSelectionRoute.name,
|
AssetSelectionRoute.name,
|
||||||
path: '/asset-selection-page',
|
path: '/asset-selection-page',
|
||||||
args: AssetSelectionRouteArgs(
|
args: AssetSelectionRouteArgs(
|
||||||
key: key,
|
key: key,
|
||||||
existingAssets: existingAssets,
|
existingAssets: existingAssets,
|
||||||
isNewAlbum: isNewAlbum,
|
canDeselect: canDeselect,
|
||||||
|
query: query,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -979,18 +988,21 @@ class AssetSelectionRouteArgs {
|
|||||||
const AssetSelectionRouteArgs({
|
const AssetSelectionRouteArgs({
|
||||||
this.key,
|
this.key,
|
||||||
required this.existingAssets,
|
required this.existingAssets,
|
||||||
this.isNewAlbum = false,
|
this.canDeselect = false,
|
||||||
|
required this.query,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Key? key;
|
final Key? key;
|
||||||
|
|
||||||
final Set<Asset> existingAssets;
|
final Set<Asset> existingAssets;
|
||||||
|
|
||||||
final bool isNewAlbum;
|
final bool canDeselect;
|
||||||
|
|
||||||
|
final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, isNewAlbum: $isNewAlbum}';
|
return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, canDeselect: $canDeselect, query: $query}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,7 +31,9 @@ class Asset {
|
|||||||
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
|
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
|
||||||
isFavorite = remote.isFavorite,
|
isFavorite = remote.isFavorite,
|
||||||
isArchived = remote.isArchived,
|
isArchived = remote.isArchived,
|
||||||
isTrashed = remote.isTrashed;
|
isTrashed = remote.isTrashed,
|
||||||
|
stackParentId = remote.stackParentId,
|
||||||
|
stackCount = remote.stackCount;
|
||||||
|
|
||||||
Asset.local(AssetEntity local, List<int> hash)
|
Asset.local(AssetEntity local, List<int> hash)
|
||||||
: localId = local.id,
|
: localId = local.id,
|
||||||
@ -47,6 +49,7 @@ class Asset {
|
|||||||
isFavorite = local.isFavorite,
|
isFavorite = local.isFavorite,
|
||||||
isArchived = false,
|
isArchived = false,
|
||||||
isTrashed = false,
|
isTrashed = false,
|
||||||
|
stackCount = 0,
|
||||||
fileCreatedAt = local.createDateTime {
|
fileCreatedAt = local.createDateTime {
|
||||||
if (fileCreatedAt.year == 1970) {
|
if (fileCreatedAt.year == 1970) {
|
||||||
fileCreatedAt = fileModifiedAt;
|
fileCreatedAt = fileModifiedAt;
|
||||||
@ -77,6 +80,8 @@ class Asset {
|
|||||||
required this.isFavorite,
|
required this.isFavorite,
|
||||||
required this.isArchived,
|
required this.isArchived,
|
||||||
required this.isTrashed,
|
required this.isTrashed,
|
||||||
|
this.stackParentId,
|
||||||
|
required this.stackCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ignore
|
@ignore
|
||||||
@ -146,6 +151,10 @@ class Asset {
|
|||||||
@ignore
|
@ignore
|
||||||
ExifInfo? exifInfo;
|
ExifInfo? exifInfo;
|
||||||
|
|
||||||
|
String? stackParentId;
|
||||||
|
|
||||||
|
int stackCount;
|
||||||
|
|
||||||
/// `true` if this [Asset] is present on the device
|
/// `true` if this [Asset] is present on the device
|
||||||
@ignore
|
@ignore
|
||||||
bool get isLocal => localId != null;
|
bool get isLocal => localId != null;
|
||||||
@ -200,7 +209,9 @@ class Asset {
|
|||||||
isFavorite == other.isFavorite &&
|
isFavorite == other.isFavorite &&
|
||||||
isLocal == other.isLocal &&
|
isLocal == other.isLocal &&
|
||||||
isArchived == other.isArchived &&
|
isArchived == other.isArchived &&
|
||||||
isTrashed == other.isTrashed;
|
isTrashed == other.isTrashed &&
|
||||||
|
stackCount == other.stackCount &&
|
||||||
|
stackParentId == other.stackParentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -223,7 +234,9 @@ class Asset {
|
|||||||
isFavorite.hashCode ^
|
isFavorite.hashCode ^
|
||||||
isLocal.hashCode ^
|
isLocal.hashCode ^
|
||||||
isArchived.hashCode ^
|
isArchived.hashCode ^
|
||||||
isTrashed.hashCode;
|
isTrashed.hashCode ^
|
||||||
|
stackCount.hashCode ^
|
||||||
|
stackParentId.hashCode;
|
||||||
|
|
||||||
/// Returns `true` if this [Asset] can updated with values from parameter [a]
|
/// Returns `true` if this [Asset] can updated with values from parameter [a]
|
||||||
bool canUpdate(Asset a) {
|
bool canUpdate(Asset a) {
|
||||||
@ -236,9 +249,11 @@ class Asset {
|
|||||||
width == null && a.width != null ||
|
width == null && a.width != null ||
|
||||||
height == null && a.height != null ||
|
height == null && a.height != null ||
|
||||||
livePhotoVideoId == null && a.livePhotoVideoId != null ||
|
livePhotoVideoId == null && a.livePhotoVideoId != null ||
|
||||||
|
stackParentId == null && a.stackParentId != null ||
|
||||||
isFavorite != a.isFavorite ||
|
isFavorite != a.isFavorite ||
|
||||||
isArchived != a.isArchived ||
|
isArchived != a.isArchived ||
|
||||||
isTrashed != a.isTrashed;
|
isTrashed != a.isTrashed ||
|
||||||
|
stackCount != a.stackCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a new [Asset] with values from this and merged & updated with [a]
|
/// Returns a new [Asset] with values from this and merged & updated with [a]
|
||||||
@ -267,6 +282,8 @@ class Asset {
|
|||||||
id: id,
|
id: id,
|
||||||
remoteId: remoteId,
|
remoteId: remoteId,
|
||||||
livePhotoVideoId: livePhotoVideoId,
|
livePhotoVideoId: livePhotoVideoId,
|
||||||
|
stackParentId: stackParentId,
|
||||||
|
stackCount: stackCount,
|
||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
isArchived: isArchived,
|
isArchived: isArchived,
|
||||||
isTrashed: isTrashed,
|
isTrashed: isTrashed,
|
||||||
@ -281,6 +298,8 @@ class Asset {
|
|||||||
width: a.width,
|
width: a.width,
|
||||||
height: a.height,
|
height: a.height,
|
||||||
livePhotoVideoId: a.livePhotoVideoId,
|
livePhotoVideoId: a.livePhotoVideoId,
|
||||||
|
stackParentId: a.stackParentId,
|
||||||
|
stackCount: a.stackCount,
|
||||||
// isFavorite + isArchived are not set by device-only assets
|
// isFavorite + isArchived are not set by device-only assets
|
||||||
isFavorite: a.isFavorite,
|
isFavorite: a.isFavorite,
|
||||||
isArchived: a.isArchived,
|
isArchived: a.isArchived,
|
||||||
@ -318,6 +337,8 @@ class Asset {
|
|||||||
bool? isArchived,
|
bool? isArchived,
|
||||||
bool? isTrashed,
|
bool? isTrashed,
|
||||||
ExifInfo? exifInfo,
|
ExifInfo? exifInfo,
|
||||||
|
String? stackParentId,
|
||||||
|
int? stackCount,
|
||||||
}) =>
|
}) =>
|
||||||
Asset(
|
Asset(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@ -338,6 +359,8 @@ class Asset {
|
|||||||
isArchived: isArchived ?? this.isArchived,
|
isArchived: isArchived ?? this.isArchived,
|
||||||
isTrashed: isTrashed ?? this.isTrashed,
|
isTrashed: isTrashed ?? this.isTrashed,
|
||||||
exifInfo: exifInfo ?? this.exifInfo,
|
exifInfo: exifInfo ?? this.exifInfo,
|
||||||
|
stackParentId: stackParentId ?? this.stackParentId,
|
||||||
|
stackCount: stackCount ?? this.stackCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<void> put(Isar db) async {
|
Future<void> put(Isar db) async {
|
||||||
@ -379,6 +402,8 @@ class Asset {
|
|||||||
"checksum": "$checksum",
|
"checksum": "$checksum",
|
||||||
"ownerId": $ownerId,
|
"ownerId": $ownerId,
|
||||||
"livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
|
"livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
|
||||||
|
"stackCount": "$stackCount",
|
||||||
|
"stackParentId": "${stackParentId ?? "N/A"}",
|
||||||
"fileCreatedAt": "$fileCreatedAt",
|
"fileCreatedAt": "$fileCreatedAt",
|
||||||
"fileModifiedAt": "$fileModifiedAt",
|
"fileModifiedAt": "$fileModifiedAt",
|
||||||
"updatedAt": "$updatedAt",
|
"updatedAt": "$updatedAt",
|
||||||
|
Binary file not shown.
@ -5,6 +5,7 @@ import 'package:immich_mobile/shared/models/exif_info.dart';
|
|||||||
import 'package:immich_mobile/shared/models/store.dart';
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
import 'package:immich_mobile/shared/models/user.dart';
|
import 'package:immich_mobile/shared/models/user.dart';
|
||||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/shared/services/asset.service.dart';
|
import 'package:immich_mobile/shared/services/asset.service.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||||
@ -217,6 +218,7 @@ final assetsProvider =
|
|||||||
.filter()
|
.filter()
|
||||||
.isArchivedEqualTo(false)
|
.isArchivedEqualTo(false)
|
||||||
.isTrashedEqualTo(false)
|
.isTrashedEqualTo(false)
|
||||||
|
.stackParentIdIsNull()
|
||||||
.sortByFileCreatedAtDesc();
|
.sortByFileCreatedAtDesc();
|
||||||
final settings = ref.watch(appSettingsServiceProvider);
|
final settings = ref.watch(appSettingsServiceProvider);
|
||||||
final groupBy =
|
final groupBy =
|
||||||
@ -227,10 +229,12 @@ final assetsProvider =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
final remoteAssetsProvider =
|
QueryBuilder<Asset, Asset, QAfterSortBy>? getRemoteAssetQuery(WidgetRef ref) {
|
||||||
StreamProvider.family<RenderList, int?>((ref, userId) async* {
|
final userId = ref.watch(currentUserProvider)?.isarId;
|
||||||
if (userId == null) return;
|
if (userId == null) {
|
||||||
final query = ref
|
return null;
|
||||||
|
}
|
||||||
|
return ref
|
||||||
.watch(dbProvider)
|
.watch(dbProvider)
|
||||||
.assets
|
.assets
|
||||||
.where()
|
.where()
|
||||||
@ -238,12 +242,34 @@ final remoteAssetsProvider =
|
|||||||
.filter()
|
.filter()
|
||||||
.ownerIdEqualTo(userId)
|
.ownerIdEqualTo(userId)
|
||||||
.isTrashedEqualTo(false)
|
.isTrashedEqualTo(false)
|
||||||
|
.stackParentIdIsNull()
|
||||||
.sortByFileCreatedAtDesc();
|
.sortByFileCreatedAtDesc();
|
||||||
final settings = ref.watch(appSettingsServiceProvider);
|
}
|
||||||
final groupBy =
|
|
||||||
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
|
QueryBuilder<Asset, Asset, QAfterSortBy>? getAssetStackSelectionQuery(
|
||||||
yield await RenderList.fromQuery(query, groupBy);
|
WidgetRef ref,
|
||||||
await for (final _ in query.watchLazy()) {
|
Asset parentAsset,
|
||||||
yield await RenderList.fromQuery(query, groupBy);
|
) {
|
||||||
|
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_delete', _handleOnAssetDelete);
|
||||||
socket.on('on_asset_trash', _handleServerUpdates);
|
socket.on('on_asset_trash', _handleServerUpdates);
|
||||||
socket.on('on_asset_restore', _handleServerUpdates);
|
socket.on('on_asset_restore', _handleServerUpdates);
|
||||||
|
socket.on('on_asset_update', _handleServerUpdates);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
|
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
|
||||||
}
|
}
|
||||||
|
3
mobile/openapi/.openapi-generator/FILES
generated
3
mobile/openapi/.openapi-generator/FILES
generated
@ -149,6 +149,7 @@ doc/TranscodePolicy.md
|
|||||||
doc/UpdateAlbumDto.md
|
doc/UpdateAlbumDto.md
|
||||||
doc/UpdateAssetDto.md
|
doc/UpdateAssetDto.md
|
||||||
doc/UpdateLibraryDto.md
|
doc/UpdateLibraryDto.md
|
||||||
|
doc/UpdateStackParentDto.md
|
||||||
doc/UpdateTagDto.md
|
doc/UpdateTagDto.md
|
||||||
doc/UpdateUserDto.md
|
doc/UpdateUserDto.md
|
||||||
doc/UsageByUserDto.md
|
doc/UsageByUserDto.md
|
||||||
@ -314,6 +315,7 @@ lib/model/transcode_policy.dart
|
|||||||
lib/model/update_album_dto.dart
|
lib/model/update_album_dto.dart
|
||||||
lib/model/update_asset_dto.dart
|
lib/model/update_asset_dto.dart
|
||||||
lib/model/update_library_dto.dart
|
lib/model/update_library_dto.dart
|
||||||
|
lib/model/update_stack_parent_dto.dart
|
||||||
lib/model/update_tag_dto.dart
|
lib/model/update_tag_dto.dart
|
||||||
lib/model/update_user_dto.dart
|
lib/model/update_user_dto.dart
|
||||||
lib/model/usage_by_user_dto.dart
|
lib/model/usage_by_user_dto.dart
|
||||||
@ -468,6 +470,7 @@ test/transcode_policy_test.dart
|
|||||||
test/update_album_dto_test.dart
|
test/update_album_dto_test.dart
|
||||||
test/update_asset_dto_test.dart
|
test/update_asset_dto_test.dart
|
||||||
test/update_library_dto_test.dart
|
test/update_library_dto_test.dart
|
||||||
|
test/update_stack_parent_dto_test.dart
|
||||||
test/update_tag_dto_test.dart
|
test/update_tag_dto_test.dart
|
||||||
test/update_user_dto_test.dart
|
test/update_user_dto_test.dart
|
||||||
test/usage_by_user_dto_test.dart
|
test/usage_by_user_dto_test.dart
|
||||||
|
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/AssetApi.md
generated
BIN
mobile/openapi/doc/AssetApi.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/AssetBulkUpdateDto.md
generated
BIN
mobile/openapi/doc/AssetBulkUpdateDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/AssetResponseDto.md
generated
BIN
mobile/openapi/doc/AssetResponseDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/UpdateStackParentDto.md
generated
Normal file
BIN
mobile/openapi/doc/UpdateStackParentDto.md
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/asset_api.dart
generated
BIN
mobile/openapi/lib/api/asset_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/asset_bulk_update_dto.dart
generated
BIN
mobile/openapi/lib/model/asset_bulk_update_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/asset_response_dto.dart
generated
BIN
mobile/openapi/lib/model/asset_response_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/update_stack_parent_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/update_stack_parent_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/test/asset_api_test.dart
generated
BIN
mobile/openapi/test/asset_api_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/asset_bulk_update_dto_test.dart
generated
BIN
mobile/openapi/test/asset_bulk_update_dto_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/asset_response_dto_test.dart
generated
BIN
mobile/openapi/test/asset_response_dto_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/update_stack_parent_dto_test.dart
generated
Normal file
BIN
mobile/openapi/test/update_stack_parent_dto_test.dart
generated
Normal file
Binary file not shown.
@ -25,6 +25,7 @@ void main() {
|
|||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
isTrashed: false,
|
isTrashed: false,
|
||||||
|
stackCount: 0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -35,6 +35,7 @@ void main() {
|
|||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
isTrashed: 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": {
|
"/asset/statistics": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getAssetStats",
|
"operationId": "getAssetStats",
|
||||||
@ -5696,6 +5731,13 @@
|
|||||||
},
|
},
|
||||||
"isFavorite": {
|
"isFavorite": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"removeParent": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"stackParentId": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@ -5941,6 +5983,19 @@
|
|||||||
"smartInfo": {
|
"smartInfo": {
|
||||||
"$ref": "#/components/schemas/SmartInfoResponseDto"
|
"$ref": "#/components/schemas/SmartInfoResponseDto"
|
||||||
},
|
},
|
||||||
|
"stack": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/AssetResponseDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"stackCount": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"stackParentId": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/TagResponseDto"
|
"$ref": "#/components/schemas/TagResponseDto"
|
||||||
@ -5961,6 +6016,7 @@
|
|||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"type",
|
"type",
|
||||||
|
"stackCount",
|
||||||
"deviceAssetId",
|
"deviceAssetId",
|
||||||
"deviceId",
|
"deviceId",
|
||||||
"ownerId",
|
"ownerId",
|
||||||
@ -8521,6 +8577,23 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"UpdateStackParentDto": {
|
||||||
|
"properties": {
|
||||||
|
"newParentId": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"oldParentId": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"oldParentId",
|
||||||
|
"newParentId"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"UpdateTagDto": {
|
"UpdateTagDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
|
@ -20,6 +20,7 @@ import { Readable } from 'stream';
|
|||||||
import { JobName } from '../job';
|
import { JobName } from '../job';
|
||||||
import {
|
import {
|
||||||
AssetStats,
|
AssetStats,
|
||||||
|
CommunicationEvent,
|
||||||
IAssetRepository,
|
IAssetRepository,
|
||||||
ICommunicationRepository,
|
ICommunicationRepository,
|
||||||
ICryptoRepository,
|
ICryptoRepository,
|
||||||
@ -636,10 +637,89 @@ describe(AssetService.name, () => {
|
|||||||
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
|
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
|
||||||
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
|
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Stack related
|
||||||
|
|
||||||
|
it('should require asset update access for parent', async () => {
|
||||||
|
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'parent').mockResolvedValue(false);
|
||||||
|
await expect(
|
||||||
|
sut.updateAll(authStub.user1, {
|
||||||
|
ids: ['asset-1'],
|
||||||
|
stackParentId: 'parent',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update parent asset when children are added', async () => {
|
||||||
|
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
await sut.updateAll(authStub.user1, {
|
||||||
|
ids: [],
|
||||||
|
stackParentId: 'parent',
|
||||||
|
}),
|
||||||
|
expect(assetMock.updateAll).toHaveBeenCalledWith(['parent'], { stackParentId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update parent asset when children are removed', async () => {
|
||||||
|
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
assetMock.getByIds.mockResolvedValue([{ id: 'child-1', stackParentId: 'parent' } as AssetEntity]);
|
||||||
|
|
||||||
|
await sut.updateAll(authStub.user1, {
|
||||||
|
ids: ['child-1'],
|
||||||
|
removeParent: true,
|
||||||
|
}),
|
||||||
|
expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['parent']), { stackParentId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update parentId for new children', async () => {
|
||||||
|
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
await sut.updateAll(authStub.user1, {
|
||||||
|
stackParentId: 'parent',
|
||||||
|
ids: ['child-1', 'child-2'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nullify parentId for remove children', async () => {
|
||||||
|
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
await sut.updateAll(authStub.user1, {
|
||||||
|
removeParent: true,
|
||||||
|
ids: ['child-1', 'child-2'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merge stacks if new child has children', async () => {
|
||||||
|
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
assetMock.getByIds.mockResolvedValue([
|
||||||
|
{ id: 'child-1', stack: [{ id: 'child-2' } as AssetEntity] } as AssetEntity,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await sut.updateAll(authStub.user1, {
|
||||||
|
ids: ['child-1'],
|
||||||
|
stackParentId: 'parent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send ws asset update event', async () => {
|
||||||
|
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
await sut.updateAll(authStub.user1, {
|
||||||
|
ids: ['asset-1'],
|
||||||
|
stackParentId: 'parent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(communicationMock.send).toHaveBeenCalledWith(CommunicationEvent.ASSET_UPDATE, authStub.user1.id, [
|
||||||
|
'asset-1',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteAll', () => {
|
describe('deleteAll', () => {
|
||||||
it('should required asset delete access for all ids', async () => {
|
it('should require asset delete access for all ids', async () => {
|
||||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||||
await expect(
|
await expect(
|
||||||
sut.deleteAll(authStub.user1, {
|
sut.deleteAll(authStub.user1, {
|
||||||
@ -677,7 +757,7 @@ describe(AssetService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('restoreAll', () => {
|
describe('restoreAll', () => {
|
||||||
it('should required asset restore access for all ids', async () => {
|
it('should require asset restore access for all ids', async () => {
|
||||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||||
await expect(
|
await expect(
|
||||||
sut.deleteAll(authStub.user1, {
|
sut.deleteAll(authStub.user1, {
|
||||||
@ -757,6 +837,21 @@ describe(AssetService.name, () => {
|
|||||||
expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace);
|
expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should update stack parent if asset has stack children', async () => {
|
||||||
|
when(assetMock.getById)
|
||||||
|
.calledWith(assetStub.primaryImage.id)
|
||||||
|
.mockResolvedValue(assetStub.primaryImage as AssetEntity);
|
||||||
|
|
||||||
|
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id });
|
||||||
|
|
||||||
|
expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-2'], {
|
||||||
|
stackParentId: 'stack-child-asset-1',
|
||||||
|
});
|
||||||
|
expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-1'], {
|
||||||
|
stackParentId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should not schedule delete-files job for readonly assets', async () => {
|
it('should not schedule delete-files job for readonly assets', async () => {
|
||||||
when(assetMock.getById)
|
when(assetMock.getById)
|
||||||
.calledWith(assetStub.readOnly.id)
|
.calledWith(assetStub.readOnly.id)
|
||||||
@ -854,4 +949,70 @@ describe(AssetService.name, () => {
|
|||||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } });
|
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('updateStackParent', () => {
|
||||||
|
it('should require asset update access for new parent', async () => {
|
||||||
|
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(true);
|
||||||
|
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(false);
|
||||||
|
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||||
|
await expect(
|
||||||
|
sut.updateStackParent(authStub.user1, {
|
||||||
|
oldParentId: 'old',
|
||||||
|
newParentId: 'new',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require asset read access for old parent', async () => {
|
||||||
|
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(false);
|
||||||
|
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(true);
|
||||||
|
await expect(
|
||||||
|
sut.updateStackParent(authStub.user1, {
|
||||||
|
oldParentId: 'old',
|
||||||
|
newParentId: 'new',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('make old parent the child of new parent', async () => {
|
||||||
|
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
when(assetMock.getById)
|
||||||
|
.calledWith(assetStub.image.id)
|
||||||
|
.mockResolvedValue(assetStub.image as AssetEntity);
|
||||||
|
|
||||||
|
await sut.updateStackParent(authStub.user1, {
|
||||||
|
oldParentId: assetStub.image.id,
|
||||||
|
newParentId: 'new',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assetMock.updateAll).toBeCalledWith([assetStub.image.id], { stackParentId: 'new' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove stackParentId of new parent', async () => {
|
||||||
|
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
await sut.updateStackParent(authStub.user1, {
|
||||||
|
oldParentId: assetStub.primaryImage.id,
|
||||||
|
newParentId: 'new',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assetMock.updateAll).toBeCalledWith(['new'], { stackParentId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update stackParentId of old parents children to new parent', async () => {
|
||||||
|
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
when(assetMock.getById)
|
||||||
|
.calledWith(assetStub.primaryImage.id)
|
||||||
|
.mockResolvedValue(assetStub.primaryImage as AssetEntity);
|
||||||
|
|
||||||
|
await sut.updateStackParent(authStub.user1, {
|
||||||
|
oldParentId: assetStub.primaryImage.id,
|
||||||
|
newParentId: 'new',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assetMock.updateAll).toBeCalledWith(
|
||||||
|
[assetStub.primaryImage.id, 'stack-child-asset-1', 'stack-child-asset-2'],
|
||||||
|
{ stackParentId: 'new' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -40,6 +40,7 @@ import {
|
|||||||
TimeBucketDto,
|
TimeBucketDto,
|
||||||
TrashAction,
|
TrashAction,
|
||||||
UpdateAssetDto,
|
UpdateAssetDto,
|
||||||
|
UpdateStackParentDto,
|
||||||
mapStats,
|
mapStats,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import {
|
import {
|
||||||
@ -208,7 +209,7 @@ export class AssetService {
|
|||||||
if (authUser.isShowMetadata) {
|
if (authUser.isShowMetadata) {
|
||||||
return assets.map((asset) => mapAsset(asset));
|
return assets.map((asset) => mapAsset(asset));
|
||||||
} else {
|
} else {
|
||||||
return assets.map((asset) => mapAsset(asset, true));
|
return assets.map((asset) => mapAsset(asset, { stripMetadata: true }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -338,10 +339,29 @@ export class AssetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise<void> {
|
async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise<void> {
|
||||||
const { ids, ...options } = dto;
|
const { ids, removeParent, ...options } = dto;
|
||||||
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
|
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
|
||||||
|
|
||||||
|
if (removeParent) {
|
||||||
|
(options as Partial<AssetEntity>).stackParentId = null;
|
||||||
|
const assets = await this.assetRepository.getByIds(ids);
|
||||||
|
// This updates the updatedAt column of the parents to indicate that one of its children is removed
|
||||||
|
// All the unique parent's -> parent is set to null
|
||||||
|
ids.push(...new Set(assets.filter((a) => !!a.stackParentId).map((a) => a.stackParentId!)));
|
||||||
|
} else if (options.stackParentId) {
|
||||||
|
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, options.stackParentId);
|
||||||
|
// Merge stacks
|
||||||
|
const assets = await this.assetRepository.getByIds(ids);
|
||||||
|
const assetsWithChildren = assets.filter((a) => a.stack && a.stack.length > 0);
|
||||||
|
ids.push(...assetsWithChildren.flatMap((child) => child.stack!.map((gChild) => gChild.id)));
|
||||||
|
|
||||||
|
// This updates the updatedAt column of the parent to indicate that a new child has been added
|
||||||
|
await this.assetRepository.updateAll([options.stackParentId], { stackParentId: null });
|
||||||
|
}
|
||||||
|
|
||||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
|
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
|
||||||
await this.assetRepository.updateAll(ids, options);
|
await this.assetRepository.updateAll(ids, options);
|
||||||
|
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleAssetDeletionCheck() {
|
async handleAssetDeletionCheck() {
|
||||||
@ -384,6 +404,14 @@ export class AssetService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replace the parent of the stack children with a new asset
|
||||||
|
if (asset.stack && asset.stack.length != 0) {
|
||||||
|
const stackIds = asset.stack.map((a) => a.id);
|
||||||
|
const newParentId = stackIds[0];
|
||||||
|
await this.assetRepository.updateAll(stackIds.slice(1), { stackParentId: newParentId });
|
||||||
|
await this.assetRepository.updateAll([newParentId], { stackParentId: null });
|
||||||
|
}
|
||||||
|
|
||||||
await this.assetRepository.remove(asset);
|
await this.assetRepository.remove(asset);
|
||||||
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } });
|
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } });
|
||||||
this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, id);
|
this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, id);
|
||||||
@ -454,6 +482,25 @@ export class AssetService {
|
|||||||
this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids);
|
this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateStackParent(authUser: AuthUserDto, dto: UpdateStackParentDto): Promise<void> {
|
||||||
|
const { oldParentId, newParentId } = dto;
|
||||||
|
await this.access.requirePermission(authUser, Permission.ASSET_READ, oldParentId);
|
||||||
|
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, newParentId);
|
||||||
|
|
||||||
|
const childIds: string[] = [];
|
||||||
|
const oldParent = await this.assetRepository.getById(oldParentId);
|
||||||
|
if (oldParent != null) {
|
||||||
|
childIds.push(oldParent.id);
|
||||||
|
// Get all children of old parent
|
||||||
|
childIds.push(...(oldParent.stack?.map((a) => a.id) ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, [...childIds, newParentId]);
|
||||||
|
await this.assetRepository.updateAll(childIds, { stackParentId: newParentId });
|
||||||
|
// Remove ParentId of new parent if this was previously a child of some other asset
|
||||||
|
return this.assetRepository.updateAll([newParentId], { stackParentId: null });
|
||||||
|
}
|
||||||
|
|
||||||
async run(authUser: AuthUserDto, dto: AssetJobsDto) {
|
async run(authUser: AuthUserDto, dto: AssetJobsDto) {
|
||||||
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds);
|
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds);
|
||||||
|
|
||||||
|
9
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 { Type } from 'class-transformer';
|
||||||
import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator';
|
import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator';
|
||||||
import { Optional } from '../../domain.util';
|
import { Optional, ValidateUUID } from '../../domain.util';
|
||||||
import { BulkIdsDto } from '../response-dto';
|
import { BulkIdsDto } from '../response-dto';
|
||||||
|
|
||||||
export class AssetBulkUpdateDto extends BulkIdsDto {
|
export class AssetBulkUpdateDto extends BulkIdsDto {
|
||||||
@ -11,6 +11,14 @@ export class AssetBulkUpdateDto extends BulkIdsDto {
|
|||||||
@Optional()
|
@Optional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isArchived?: boolean;
|
isArchived?: boolean;
|
||||||
|
|
||||||
|
@Optional()
|
||||||
|
@ValidateUUID()
|
||||||
|
stackParentId?: string;
|
||||||
|
|
||||||
|
@Optional()
|
||||||
|
@IsBoolean()
|
||||||
|
removeParent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateAssetDto {
|
export class UpdateAssetDto {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
export * from './asset-ids.dto';
|
export * from './asset-ids.dto';
|
||||||
|
export * from './asset-stack.dto';
|
||||||
export * from './asset-statistics.dto';
|
export * from './asset-statistics.dto';
|
||||||
export * from './asset.dto';
|
export * from './asset.dto';
|
||||||
export * from './download.dto';
|
export * from './download.dto';
|
||||||
|
@ -42,9 +42,20 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
|||||||
people?: PersonResponseDto[];
|
people?: PersonResponseDto[];
|
||||||
/**base64 encoded sha1 hash */
|
/**base64 encoded sha1 hash */
|
||||||
checksum!: string;
|
checksum!: string;
|
||||||
|
stackParentId?: string | null;
|
||||||
|
stack?: AssetResponseDto[];
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
stackCount!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetResponseDto {
|
export type AssetMapOptions = {
|
||||||
|
stripMetadata?: boolean;
|
||||||
|
withStack?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
|
||||||
|
const { stripMetadata = false, withStack = false } = options;
|
||||||
|
|
||||||
const sanitizedAssetResponse: SanitizedAssetResponseDto = {
|
const sanitizedAssetResponse: SanitizedAssetResponseDto = {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
type: entity.type,
|
type: entity.type,
|
||||||
@ -87,6 +98,9 @@ export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetRespo
|
|||||||
tags: entity.tags?.map(mapTag),
|
tags: entity.tags?.map(mapTag),
|
||||||
people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
|
people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
|
||||||
checksum: entity.checksum.toString('base64'),
|
checksum: entity.checksum.toString('base64'),
|
||||||
|
stackParentId: entity.stackParentId,
|
||||||
|
stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined,
|
||||||
|
stackCount: entity.stack?.length ?? 0,
|
||||||
isExternal: entity.isExternal,
|
isExternal: entity.isExternal,
|
||||||
isOffline: entity.isOffline,
|
isOffline: entity.isOffline,
|
||||||
isReadOnly: entity.isReadOnly,
|
isReadOnly: entity.isReadOnly,
|
||||||
|
@ -4,6 +4,7 @@ export enum CommunicationEvent {
|
|||||||
UPLOAD_SUCCESS = 'on_upload_success',
|
UPLOAD_SUCCESS = 'on_upload_success',
|
||||||
ASSET_DELETE = 'on_asset_delete',
|
ASSET_DELETE = 'on_asset_delete',
|
||||||
ASSET_TRASH = 'on_asset_trash',
|
ASSET_TRASH = 'on_asset_trash',
|
||||||
|
ASSET_UPDATE = 'on_asset_update',
|
||||||
ASSET_RESTORE = 'on_asset_restore',
|
ASSET_RESTORE = 'on_asset_restore',
|
||||||
PERSON_THUMBNAIL = 'on_person_thumbnail',
|
PERSON_THUMBNAIL = 'on_person_thumbnail',
|
||||||
SERVER_VERSION = 'on_server_version',
|
SERVER_VERSION = 'on_server_version',
|
||||||
|
@ -58,7 +58,7 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): Shar
|
|||||||
type: sharedLink.type,
|
type: sharedLink.type,
|
||||||
createdAt: sharedLink.createdAt,
|
createdAt: sharedLink.createdAt,
|
||||||
expiresAt: sharedLink.expiresAt,
|
expiresAt: sharedLink.expiresAt,
|
||||||
assets: assets.map((asset) => mapAsset(asset, true)) as AssetResponseDto[],
|
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })) as AssetResponseDto[],
|
||||||
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
|
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
|
||||||
allowUpload: sharedLink.allowUpload,
|
allowUpload: sharedLink.allowUpload,
|
||||||
allowDownload: sharedLink.allowDownload,
|
allowDownload: sharedLink.allowDownload,
|
||||||
|
@ -109,6 +109,7 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
faces: {
|
faces: {
|
||||||
person: true,
|
person: true,
|
||||||
},
|
},
|
||||||
|
stack: true,
|
||||||
},
|
},
|
||||||
// We are specifically asking for this asset. Return it even if it is soft deleted
|
// We are specifically asking for this asset. Return it even if it is soft deleted
|
||||||
withDeleted: true,
|
withDeleted: true,
|
||||||
@ -131,6 +132,7 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
relations: {
|
relations: {
|
||||||
exifInfo: true,
|
exifInfo: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
|
stack: true,
|
||||||
},
|
},
|
||||||
skip: dto.skip || 0,
|
skip: dto.skip || 0,
|
||||||
order: {
|
order: {
|
||||||
|
@ -196,7 +196,7 @@ export class AssetService {
|
|||||||
const includeMetadata = this.getExifPermission(authUser);
|
const includeMetadata = this.getExifPermission(authUser);
|
||||||
const asset = await this._assetRepository.getById(assetId);
|
const asset = await this._assetRepository.getById(assetId);
|
||||||
if (includeMetadata) {
|
if (includeMetadata) {
|
||||||
const data = mapAsset(asset);
|
const data = mapAsset(asset, { withStack: true });
|
||||||
|
|
||||||
if (data.ownerId !== authUser.id) {
|
if (data.ownerId !== authUser.id) {
|
||||||
data.people = [];
|
data.people = [];
|
||||||
@ -208,7 +208,7 @@ export class AssetService {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} else {
|
} else {
|
||||||
return mapAsset(asset, true);
|
return mapAsset(asset, { stripMetadata: true, withStack: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,6 +21,7 @@ import {
|
|||||||
TimeBucketResponseDto,
|
TimeBucketResponseDto,
|
||||||
TrashAction,
|
TrashAction,
|
||||||
UpdateAssetDto as UpdateDto,
|
UpdateAssetDto as UpdateDto,
|
||||||
|
UpdateStackParentDto,
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
@ -137,6 +138,12 @@ export class AssetController {
|
|||||||
return this.service.handleTrashAction(authUser, TrashAction.RESTORE_ALL);
|
return this.service.handleTrashAction(authUser, TrashAction.RESTORE_ALL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('stack/parent')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
updateStackParent(@AuthUser() authUser: AuthUserDto, @Body() dto: UpdateStackParentDto): Promise<void> {
|
||||||
|
return this.service.updateStackParent(authUser, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
updateAsset(
|
updateAsset(
|
||||||
@AuthUser() authUser: AuthUserDto,
|
@AuthUser() authUser: AuthUserDto,
|
||||||
|
@ -148,6 +148,16 @@ export class AssetEntity {
|
|||||||
|
|
||||||
@OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset)
|
@OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset)
|
||||||
faces!: AssetFaceEntity[];
|
faces!: AssetFaceEntity[];
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
stackParentId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => AssetEntity, (asset) => asset.stack, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'stackParentId' })
|
||||||
|
stackParent?: AssetEntity | null;
|
||||||
|
|
||||||
|
@OneToMany(() => AssetEntity, (asset) => asset.stackParent)
|
||||||
|
stack?: AssetEntity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AssetType {
|
export enum AssetType {
|
||||||
|
@ -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: {
|
faces: {
|
||||||
person: true,
|
person: true,
|
||||||
},
|
},
|
||||||
|
stack: true,
|
||||||
},
|
},
|
||||||
withDeleted: true,
|
withDeleted: true,
|
||||||
});
|
});
|
||||||
@ -192,6 +193,7 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
person: true,
|
person: true,
|
||||||
},
|
},
|
||||||
library: true,
|
library: true,
|
||||||
|
stack: true,
|
||||||
},
|
},
|
||||||
// We are specifically asking for this asset. Return it even if it is soft deleted
|
// We are specifically asking for this asset. Return it even if it is soft deleted
|
||||||
withDeleted: true,
|
withDeleted: true,
|
||||||
@ -538,6 +540,12 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
.andWhere('person.id = :personId', { personId });
|
.andWhere('person.id = :personId', { personId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide stack children only in main timeline
|
||||||
|
// Uncomment after adding support for stacked assets in web client
|
||||||
|
// if (!isArchived && !isFavorite && !personId && !albumId && !isTrashed) {
|
||||||
|
// builder = builder.andWhere('asset.stackParent IS NULL');
|
||||||
|
// }
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -626,4 +626,167 @@ describe(`${AssetController.name} (e2e)`, () => {
|
|||||||
expect(body).toEqual([expect.objectContaining({ id: asset2.id })]);
|
expect(body).toEqual([expect.objectContaining({ id: asset2.id })]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('PUT /asset', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { status } = await request(server)
|
||||||
|
.put('/asset')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] });
|
||||||
|
|
||||||
|
expect(status).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const { status, body } = await request(server).put('/asset');
|
||||||
|
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body).toEqual(errorStub.unauthorized);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require a valid parent id', async () => {
|
||||||
|
const { status, body } = await request(server)
|
||||||
|
.put('/asset')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({ stackParentId: uuidStub.invalid, ids: [asset1.id] });
|
||||||
|
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorStub.badRequest(['stackParentId must be a UUID']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require access to the parent', async () => {
|
||||||
|
const { status, body } = await request(server)
|
||||||
|
.put('/asset')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({ stackParentId: asset4.id, ids: [asset1.id] });
|
||||||
|
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorStub.noPermission);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add stack children', async () => {
|
||||||
|
const parent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
|
||||||
|
const child = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
|
||||||
|
|
||||||
|
const { status } = await request(server)
|
||||||
|
.put('/asset')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({ stackParentId: parent.id, ids: [child.id] });
|
||||||
|
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
|
const asset = await api.assetApi.get(server, user1.accessToken, parent.id);
|
||||||
|
expect(asset.stack).not.toBeUndefined();
|
||||||
|
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: child.id })]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove stack children', async () => {
|
||||||
|
const { status } = await request(server)
|
||||||
|
.put('/asset')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({ removeParent: true, ids: [asset2.id] });
|
||||||
|
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
|
const asset = await api.assetApi.get(server, user1.accessToken, asset1.id);
|
||||||
|
expect(asset.stack).not.toBeUndefined();
|
||||||
|
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove all stack children', async () => {
|
||||||
|
const { status } = await request(server)
|
||||||
|
.put('/asset')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({ removeParent: true, ids: [asset2.id, asset3.id] });
|
||||||
|
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
|
const asset = await api.assetApi.get(server, user1.accessToken, asset1.id);
|
||||||
|
expect(asset.stack).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge stack children', async () => {
|
||||||
|
const newParent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
|
||||||
|
const { status } = await request(server)
|
||||||
|
.put('/asset')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({ stackParentId: newParent.id, ids: [asset1.id] });
|
||||||
|
|
||||||
|
expect(status).toBe(204);
|
||||||
|
|
||||||
|
const asset = await api.assetApi.get(server, user1.accessToken, newParent.id);
|
||||||
|
expect(asset.stack).not.toBeUndefined();
|
||||||
|
expect(asset.stack).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ id: asset1.id }),
|
||||||
|
expect.objectContaining({ id: asset2.id }),
|
||||||
|
expect.objectContaining({ id: asset3.id }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /asset/stack/parent', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { status } = await request(server)
|
||||||
|
.put('/asset')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] });
|
||||||
|
|
||||||
|
expect(status).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const { status, body } = await request(server).put('/asset/stack/parent');
|
||||||
|
|
||||||
|
expect(status).toBe(401);
|
||||||
|
expect(body).toEqual(errorStub.unauthorized);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require a valid id', async () => {
|
||||||
|
const { status, body } = await request(server)
|
||||||
|
.put('/asset/stack/parent')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({ oldParentId: uuidStub.invalid, newParentId: uuidStub.invalid });
|
||||||
|
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorStub.badRequest());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require access', async () => {
|
||||||
|
const { status, body } = await request(server)
|
||||||
|
.put('/asset/stack/parent')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({ oldParentId: asset4.id, newParentId: asset1.id });
|
||||||
|
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorStub.noPermission);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make old parent child of new parent', async () => {
|
||||||
|
const { status } = await request(server)
|
||||||
|
.put('/asset/stack/parent')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({ oldParentId: asset1.id, newParentId: asset2.id });
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
|
||||||
|
const asset = await api.assetApi.get(server, user1.accessToken, asset2.id);
|
||||||
|
expect(asset.stack).not.toBeUndefined();
|
||||||
|
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset1.id })]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make all childrens of old parent, a child of new parent', async () => {
|
||||||
|
const { status } = await request(server)
|
||||||
|
.put('/asset/stack/parent')
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({ oldParentId: asset1.id, newParentId: asset2.id });
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
|
||||||
|
const asset = await api.assetApi.get(server, user1.accessToken, asset2.id);
|
||||||
|
expect(asset.stack).not.toBeUndefined();
|
||||||
|
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })]));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
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',
|
libraryId: 'library-id',
|
||||||
library: libraryStub.uploadLibrary1,
|
library: libraryStub.uploadLibrary1,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
noWebpPath: Object.freeze<AssetEntity>({
|
noWebpPath: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
deviceAssetId: 'device-asset-id',
|
deviceAssetId: 'device-asset-id',
|
||||||
@ -80,6 +81,7 @@ export const assetStub = {
|
|||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
noThumbhash: Object.freeze<AssetEntity>({
|
noThumbhash: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
deviceAssetId: 'device-asset-id',
|
deviceAssetId: 'device-asset-id',
|
||||||
@ -116,6 +118,7 @@ export const assetStub = {
|
|||||||
sidecarPath: null,
|
sidecarPath: null,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
primaryImage: Object.freeze<AssetEntity>({
|
primaryImage: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
deviceAssetId: 'device-asset-id',
|
deviceAssetId: 'device-asset-id',
|
||||||
@ -154,7 +157,9 @@ export const assetStub = {
|
|||||||
exifInfo: {
|
exifInfo: {
|
||||||
fileSizeInByte: 5_000,
|
fileSizeInByte: 5_000,
|
||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
|
stack: [{ id: 'stack-child-asset-1' } as AssetEntity, { id: 'stack-child-asset-2' } as AssetEntity],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
image: Object.freeze<AssetEntity>({
|
image: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
deviceAssetId: 'device-asset-id',
|
deviceAssetId: 'device-asset-id',
|
||||||
@ -194,6 +199,7 @@ export const assetStub = {
|
|||||||
fileSizeInByte: 5_000,
|
fileSizeInByte: 5_000,
|
||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
external: Object.freeze<AssetEntity>({
|
external: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
deviceAssetId: 'device-asset-id',
|
deviceAssetId: 'device-asset-id',
|
||||||
@ -233,6 +239,7 @@ export const assetStub = {
|
|||||||
fileSizeInByte: 5_000,
|
fileSizeInByte: 5_000,
|
||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
offline: Object.freeze<AssetEntity>({
|
offline: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
deviceAssetId: 'device-asset-id',
|
deviceAssetId: 'device-asset-id',
|
||||||
@ -272,6 +279,7 @@ export const assetStub = {
|
|||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
image1: Object.freeze<AssetEntity>({
|
image1: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id-1',
|
id: 'asset-id-1',
|
||||||
deviceAssetId: 'device-asset-id',
|
deviceAssetId: 'device-asset-id',
|
||||||
@ -311,6 +319,7 @@ export const assetStub = {
|
|||||||
fileSizeInByte: 5_000,
|
fileSizeInByte: 5_000,
|
||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
imageFrom2015: Object.freeze<AssetEntity>({
|
imageFrom2015: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id-1',
|
id: 'asset-id-1',
|
||||||
deviceAssetId: 'device-asset-id',
|
deviceAssetId: 'device-asset-id',
|
||||||
@ -350,6 +359,7 @@ export const assetStub = {
|
|||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
video: Object.freeze<AssetEntity>({
|
video: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
originalFileName: 'asset-id.ext',
|
originalFileName: 'asset-id.ext',
|
||||||
@ -389,6 +399,7 @@ export const assetStub = {
|
|||||||
} as ExifEntity,
|
} as ExifEntity,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
livePhotoMotionAsset: Object.freeze({
|
livePhotoMotionAsset: Object.freeze({
|
||||||
id: 'live-photo-motion-asset',
|
id: 'live-photo-motion-asset',
|
||||||
originalPath: fileStub.livePhotoMotion.originalPath,
|
originalPath: fileStub.livePhotoMotion.originalPath,
|
||||||
@ -497,10 +508,41 @@ export const assetStub = {
|
|||||||
sidecarPath: '/original/path.ext.xmp',
|
sidecarPath: '/original/path.ext.xmp',
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
readOnly: Object.freeze({
|
|
||||||
|
readOnly: Object.freeze<AssetEntity>({
|
||||||
id: 'read-only-asset',
|
id: 'read-only-asset',
|
||||||
|
deviceAssetId: 'device-asset-id',
|
||||||
|
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
|
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
|
owner: userStub.user1,
|
||||||
|
ownerId: 'user-id',
|
||||||
|
deviceId: 'device-id',
|
||||||
|
originalPath: '/original/path.ext',
|
||||||
|
resizePath: '/uploads/user-id/thumbs/path.ext',
|
||||||
|
thumbhash: null,
|
||||||
|
checksum: Buffer.from('file hash', 'utf8'),
|
||||||
|
type: AssetType.IMAGE,
|
||||||
|
webpPath: null,
|
||||||
|
encodedVideoPath: null,
|
||||||
|
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
|
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
|
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
|
isFavorite: true,
|
||||||
|
isArchived: false,
|
||||||
isReadOnly: true,
|
isReadOnly: true,
|
||||||
|
isExternal: false,
|
||||||
|
isOffline: false,
|
||||||
libraryId: 'library-id',
|
libraryId: 'library-id',
|
||||||
library: libraryStub.uploadLibrary1,
|
library: libraryStub.uploadLibrary1,
|
||||||
|
duration: null,
|
||||||
|
isVisible: true,
|
||||||
|
livePhotoVideo: null,
|
||||||
|
livePhotoVideoId: null,
|
||||||
|
tags: [],
|
||||||
|
sharedLinks: [],
|
||||||
|
originalFileName: 'asset-id.ext',
|
||||||
|
faces: [],
|
||||||
|
sidecarPath: '/original/path.ext.xmp',
|
||||||
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
1
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,
|
isTrashed: false,
|
||||||
libraryId: 'library-id',
|
libraryId: 'library-id',
|
||||||
hasMetadata: true,
|
hasMetadata: true,
|
||||||
|
stackCount: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const assetResponseWithoutMetadata = {
|
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
|
* @memberof AssetBulkUpdateDto
|
||||||
*/
|
*/
|
||||||
'isFavorite'?: boolean;
|
'isFavorite'?: boolean;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof AssetBulkUpdateDto
|
||||||
|
*/
|
||||||
|
'removeParent'?: boolean;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof AssetBulkUpdateDto
|
||||||
|
*/
|
||||||
|
'stackParentId'?: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -748,6 +760,24 @@ export interface AssetResponseDto {
|
|||||||
* @memberof AssetResponseDto
|
* @memberof AssetResponseDto
|
||||||
*/
|
*/
|
||||||
'smartInfo'?: SmartInfoResponseDto;
|
'smartInfo'?: SmartInfoResponseDto;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {Array<AssetResponseDto>}
|
||||||
|
* @memberof AssetResponseDto
|
||||||
|
*/
|
||||||
|
'stack'?: Array<AssetResponseDto>;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
* @memberof AssetResponseDto
|
||||||
|
*/
|
||||||
|
'stackCount': number;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof AssetResponseDto
|
||||||
|
*/
|
||||||
|
'stackParentId'?: string | null;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {Array<TagResponseDto>}
|
* @type {Array<TagResponseDto>}
|
||||||
@ -3981,6 +4011,25 @@ export interface UpdateLibraryDto {
|
|||||||
*/
|
*/
|
||||||
'name'?: string;
|
'name'?: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @interface UpdateStackParentDto
|
||||||
|
*/
|
||||||
|
export interface UpdateStackParentDto {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof UpdateStackParentDto
|
||||||
|
*/
|
||||||
|
'newParentId': string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof UpdateStackParentDto
|
||||||
|
*/
|
||||||
|
'oldParentId': string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @export
|
* @export
|
||||||
@ -7135,6 +7184,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
|||||||
options: localVarRequestOptions,
|
options: localVarRequestOptions,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {UpdateStackParentDto} updateStackParentDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
updateStackParent: async (updateStackParentDto: UpdateStackParentDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
// verify required parameter 'updateStackParentDto' is not null or undefined
|
||||||
|
assertParamExists('updateStackParent', 'updateStackParentDto', updateStackParentDto)
|
||||||
|
const localVarPath = `/asset/stack/parent`;
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
let baseOptions;
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
|
||||||
|
const localVarHeaderParameter = {} as any;
|
||||||
|
const localVarQueryParameter = {} as any;
|
||||||
|
|
||||||
|
// authentication cookie required
|
||||||
|
|
||||||
|
// authentication api_key required
|
||||||
|
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||||
|
|
||||||
|
// authentication bearer required
|
||||||
|
// http bearer authentication required
|
||||||
|
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
|
localVarRequestOptions.data = serializeDataIfNeeded(updateStackParentDto, localVarRequestOptions, configuration)
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {File} assetData
|
* @param {File} assetData
|
||||||
@ -7601,6 +7694,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
|
|||||||
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
|
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
|
||||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {UpdateStackParentDto} updateStackParentDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async updateStackParent(updateStackParentDto: UpdateStackParentDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.updateStackParent(updateStackParentDto, options);
|
||||||
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {File} assetData
|
* @param {File} assetData
|
||||||
@ -7892,6 +7995,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
|
|||||||
updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
|
updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
|
||||||
return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
|
return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters.
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
|
||||||
|
return localVarFp.updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.
|
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.
|
||||||
@ -8499,6 +8611,20 @@ export interface AssetApiUpdateAssetsRequest {
|
|||||||
readonly assetBulkUpdateDto: AssetBulkUpdateDto
|
readonly assetBulkUpdateDto: AssetBulkUpdateDto
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request parameters for updateStackParent operation in AssetApi.
|
||||||
|
* @export
|
||||||
|
* @interface AssetApiUpdateStackParentRequest
|
||||||
|
*/
|
||||||
|
export interface AssetApiUpdateStackParentRequest {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {UpdateStackParentDto}
|
||||||
|
* @memberof AssetApiUpdateStackParent
|
||||||
|
*/
|
||||||
|
readonly updateStackParentDto: UpdateStackParentDto
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request parameters for uploadFile operation in AssetApi.
|
* Request parameters for uploadFile operation in AssetApi.
|
||||||
* @export
|
* @export
|
||||||
@ -8939,6 +9065,17 @@ export class AssetApi extends BaseAPI {
|
|||||||
return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
|
return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters.
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof AssetApi
|
||||||
|
*/
|
||||||
|
public updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig) {
|
||||||
|
return AssetApiFp(this.configuration).updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.
|
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.
|
||||||
|
Loading…
Reference in New Issue
Block a user