diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index b055ad38b1..ca16d1f980 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -3,33 +3,35 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; class AssetService { - final RemoteAssetRepository _remoteAssetRepository; - final DriftLocalAssetRepository _localAssetRepository; + final RemoteAssetRepository _remoteRepository; + final DriftLocalAssetRepository _localRepository; + final AssetApiRepository _apiRepository; - const AssetService({required this._remoteAssetRepository, required this._localAssetRepository}); + const AssetService({required this._remoteRepository, required this._localRepository, required this._apiRepository}); Future getAsset(BaseAsset asset) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id; - return asset is LocalAsset ? _localAssetRepository.get(id) : _remoteAssetRepository.get(id); + return asset is LocalAsset ? _localRepository.get(id) : _remoteRepository.get(id); } Stream watchAsset(BaseAsset asset) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id; - return asset is LocalAsset ? _localAssetRepository.watch(id) : _remoteAssetRepository.watch(id); + return asset is LocalAsset ? _localRepository.watch(id) : _remoteRepository.watch(id); } Future> getLocalAssetsByChecksum(String checksum) { - return _localAssetRepository.getByChecksum(checksum); + return _localRepository.getByChecksum(checksum); } Future getRemoteAssetByChecksum(String checksum) { - return _remoteAssetRepository.getByChecksum(checksum); + return _remoteRepository.getByChecksum(checksum); } Future getRemoteAsset(String id) { - return _remoteAssetRepository.get(id); + return _remoteRepository.get(id); } Future> getStack(RemoteAsset asset) async { @@ -37,7 +39,7 @@ class AssetService { return const []; } - final stack = await _remoteAssetRepository.getStackChildren(asset); + final stack = await _remoteRepository.getStackChildren(asset); // Include the primary asset in the stack as the first item return [asset, ...stack]; } @@ -48,22 +50,31 @@ class AssetService { } final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id; - return _remoteAssetRepository.getExif(id); + return _remoteRepository.getExif(id); } Future> getPlaces(String userId) { - return _remoteAssetRepository.getPlaces(userId); + return _remoteRepository.getPlaces(userId); } Future<(int local, int remote)> getAssetCounts() async { - return (await _localAssetRepository.getCount(), await _remoteAssetRepository.getCount()); + return (await _localRepository.getCount(), await _remoteRepository.getCount()); } Future getLocalHashedCount() { - return _localAssetRepository.getHashedCount(); + return _localRepository.getHashedCount(); } Future> getSourceAlbums(String localAssetId, {BackupSelection? backupSelection}) { - return _localAssetRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection); + return _localRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection); + } + + Future updateFavorite(List remoteIds, bool isFavorite) async { + if (remoteIds.isEmpty) { + return; + } + + await _apiRepository.updateFavorite(remoteIds, isFavorite); + await _remoteRepository.updateFavorite(remoteIds, isFavorite); } } diff --git a/mobile/lib/presentation/actions/action.dart b/mobile/lib/presentation/actions/action.dart index 5d37706aaa..5ceb2f855d 100644 --- a/mobile/lib/presentation/actions/action.dart +++ b/mobile/lib/presentation/actions/action.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; class ActionScope { @@ -21,3 +22,11 @@ abstract class BaseAction { Future onAction(ActionScope scope); } + +abstract class AssetAction extends BaseAction { + final Iterable assets; + + const AssetAction({required this.assets}); + + Iterable filter(ActionScope scope) => assets.whereType(); +} diff --git a/mobile/lib/presentation/actions/asset_debug.action.dart b/mobile/lib/presentation/actions/asset_debug.action.dart new file mode 100644 index 0000000000..aec99fc90b --- /dev/null +++ b/mobile/lib/presentation/actions/asset_debug.action.dart @@ -0,0 +1,27 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class AssetDebugAction extends AssetAction { + const AssetDebugAction({required super.assets}); + + @override + IconData get icon => Icons.help_outline_rounded; + + @override + String label(ActionScope scope) => scope.context.t.troubleshoot; + + @override + bool isVisible(ActionScope scope) => + assets.length == 1 && scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting); + + @override + Future onAction(ActionScope scope) async => + unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: assets.first))); +} diff --git a/mobile/lib/presentation/actions/favorite.action.dart b/mobile/lib/presentation/actions/favorite.action.dart new file mode 100644 index 0000000000..33d4bb3b6c --- /dev/null +++ b/mobile/lib/presentation/actions/favorite.action.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class FavoriteAction extends AssetAction { + final bool shouldFavorite; + + FavoriteAction({required super.assets}) : shouldFavorite = assets.any((asset) => !asset.isFavorite); + + @override + IconData get icon => shouldFavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded; + + @override + String label(ActionScope scope) => shouldFavorite ? scope.context.t.favorite : scope.context.t.unfavorite; + + @override + Iterable filter(ActionScope scope) => assets + .where( + (asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite, + ) + .cast(); + + @override + bool isVisible(ActionScope scope) => filter(scope).isNotEmpty; + + @override + Future onAction(ActionScope scope) async { + final ActionScope(:ref) = scope; + final assets = filter(scope).map((asset) => asset.id).toList(growable: false); + + await ref.read(assetServiceProvider).updateFavorite(assets, shouldFavorite); + final message = shouldFavorite + ? StaticTranslations.instance.favorite_action_prompt(count: assets.length) + : StaticTranslations.instance.unfavorite_action_prompt(count: assets.length); + snackbar.success(message); + } +} diff --git a/mobile/lib/presentation/actions/timeline.action.dart b/mobile/lib/presentation/actions/timeline.action.dart new file mode 100644 index 0000000000..d8d367f674 --- /dev/null +++ b/mobile/lib/presentation/actions/timeline.action.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; + +class TimelineAction extends BaseAction { + final BaseAction action; + + const TimelineAction({required this.action}); + + @override + IconData get icon => action.icon; + + @override + String label(ActionScope scope) => action.label(scope); + + @override + bool isVisible(ActionScope scope) => action.isVisible(scope); + + @override + Future onAction(ActionScope scope) async { + await action.onAction(scope); + scope.ref.read(multiSelectProvider.notifier).reset(); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart deleted file mode 100644 index b68ab69b26..0000000000 --- a/mobile/lib/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/enums.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; - -class AdvancedInfoActionButton extends ConsumerWidget { - final ActionSource source; - final bool iconOnly; - final bool menuItem; - - const AdvancedInfoActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); - - void _onTap(BuildContext context, WidgetRef ref) async { - if (!context.mounted) { - return; - } - - unawaited(ref.read(actionProvider.notifier).troubleshoot(source, context)); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - return BaseActionButton( - maxWidth: 115.0, - iconData: Icons.help_outline_rounded, - label: "troubleshoot".t(context: context), - iconOnly: iconOnly, - menuItem: menuItem, - onPressed: () => _onTap(context, ref), - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index 418d41e1f2..da0abad1dd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/utils/action_button.utils.dart'; +import 'package:immich_ui/immich_ui.dart'; class ViewerKebabMenu extends ConsumerWidget { const ViewerKebabMenu({super.key, this.originalTheme}); @@ -49,9 +50,9 @@ class ViewerKebabMenu extends ConsumerWidget { timelineOrigin: timelineOrigin, ); - final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref); + final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context); - return MenuAnchor( + return ImmichMenu( consumeOutsideTap: true, style: MenuStyle( backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor), @@ -62,7 +63,7 @@ class ViewerKebabMenu extends ConsumerWidget { ), padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)), ), - menuChildren: [ + children: [ ConstrainedBox( constraints: const BoxConstraints(minWidth: 150), child: Theme( diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 3b158c63a8..8d51b8cd2e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -2,12 +2,11 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; +import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/favorite.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; @@ -15,9 +14,9 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provid import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/timezone.dart'; +import 'package:immich_ui/immich_ui.dart'; class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { const ViewerTopAppBar({super.key}); @@ -31,8 +30,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final album = ref.watch(currentRemoteAlbumProvider); - final user = ref.watch(currentUserProvider); - final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final isInLockedView = ref.watch(inLockedViewProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); @@ -46,6 +43,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0); final originalTheme = context.themeData; + final assetForAction = [asset]; final actions = [ if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true), @@ -63,10 +61,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { }, ), - if (asset.hasRemote && isOwner && !asset.isFavorite) - const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true), - if (asset.hasRemote && isOwner && asset.isFavorite) - const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true), + ActionIconButtonWidget(action: FavoriteAction(assets: assetForAction)), ViewerKebabMenu(originalTheme: originalTheme), ]; @@ -107,7 +102,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { leading: const _AppBarBackButton(), middle: showingDetails ? null : _AssetInfoTitle(asset: asset), trailing: !showingDetails && !isReadonlyModeEnabled - ? Row(mainAxisSize: MainAxisSize.min, children: isInLockedView ? lockedViewActions : actions) + ? ImmichColorOverride( + color: Colors.white, + child: Row( + mainAxisSize: MainAxisSize.min, + children: isInLockedView ? lockedViewActions : actions, + ), + ) : null, ), ), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart index 2e3d2673c7..3c9c0c692e 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart @@ -3,12 +3,14 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/favorite.action.dart'; +import 'package:immich_mobile/presentation/actions/timeline.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; @@ -74,6 +76,9 @@ class _ArchiveBottomSheetState extends ConsumerState { return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut); } + final assets = multiselect.selectedAssets.toList(growable: false); + final actions = [FavoriteAction(assets: assets)]; + return BaseBottomSheet( controller: sheetController, initialChildSize: 0.25, @@ -84,7 +89,7 @@ class _ArchiveBottomSheetState extends ConsumerState { if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), const UnArchiveActionButton(source: ActionSource.timeline), - const FavoriteActionButton(source: ActionSource.timeline), + ...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))), if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline), isTrashEnable ? const TrashActionButton(source: ActionSource.timeline) diff --git a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart index 1dee0f6456..8438d5e8ac 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart @@ -4,6 +4,9 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/favorite.action.dart'; +import 'package:immich_mobile/presentation/actions/timeline.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; @@ -15,7 +18,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; @@ -65,6 +67,9 @@ class FavoriteBottomSheet extends ConsumerWidget { ref.read(multiSelectProvider.notifier).reset(); } + final assets = multiselect.selectedAssets.toList(growable: false); + final actions = [FavoriteAction(assets: assets)]; + return BaseBottomSheet( initialChildSize: 0.4, maxChildSize: 0.7, @@ -73,7 +78,7 @@ class FavoriteBottomSheet extends ConsumerWidget { const ShareActionButton(source: ActionSource.timeline), if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), - const UnFavoriteActionButton(source: ActionSource.timeline), + ...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))), const ArchiveActionButton(source: ActionSource.timeline), if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline), isTrashEnable diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index c3a569407a..1949a79495 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -3,8 +3,9 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; -import 'package:immich_mobile/domain/models/setting.model.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart'; +import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; +import 'package:immich_mobile/presentation/actions/timeline.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; @@ -24,7 +25,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_ import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -56,7 +56,6 @@ class _GeneralBottomSheetState extends ConsumerState { Widget build(BuildContext context) { final multiselect = ref.watch(multiSelectProvider); final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); - final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting); final tagsEnabled = ref.watch( userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false), ); @@ -84,6 +83,9 @@ class _GeneralBottomSheetState extends ConsumerState { return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut); } + final assets = multiselect.selectedAssets.toList(growable: false); + final actions = [AssetDebugAction(assets: assets)]; + return BaseBottomSheet( controller: sheetController, initialChildSize: widget.minChildSize ?? 0.15, @@ -91,9 +93,7 @@ class _GeneralBottomSheetState extends ConsumerState { maxChildSize: 0.85, shouldCloseOnMinExtent: false, actions: [ - if (multiselect.selectedAssets.length == 1 && advancedTroubleshooting) ...[ - const AdvancedInfoActionButton(source: ActionSource.timeline), - ], + ...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))), const ShareActionButton(source: ActionSource.timeline), if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index 6b914ed077..a292c1899c 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -3,13 +3,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/favorite.action.dart'; +import 'package:immich_mobile/presentation/actions/timeline.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; @@ -83,6 +85,9 @@ class _RemoteAlbumBottomSheetState extends ConsumerState return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut); } + final assets = multiselect.selectedAssets.toList(growable: false); + final actions = [FavoriteAction(assets: assets)]; + return BaseBottomSheet( controller: sheetController, initialChildSize: 0.22, @@ -96,7 +101,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState if (ownsAlbum) ...[ const ArchiveActionButton(source: ActionSource.timeline), - const FavoriteActionButton(source: ActionSource.timeline), + ...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))), ], const DownloadActionButton(source: ActionSource.timeline), if (ownsAlbum) ...[ diff --git a/mobile/lib/providers/infrastructure/asset.provider.dart b/mobile/lib/providers/infrastructure/asset.provider.dart index 70cb200bf1..6326d003e5 100644 --- a/mobile/lib/providers/infrastructure/asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset.provider.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/repositories/remote_asset.repositor import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; final localAssetRepository = Provider( (ref) => DriftLocalAssetRepository(ref.watch(driftProvider)), @@ -20,8 +21,9 @@ final trashedLocalAssetRepository = Provider( final assetServiceProvider = Provider( (ref) => AssetService( - remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider), - localAssetRepository: ref.watch(localAssetRepository), + remoteRepository: ref.watch(remoteAssetRepositoryProvider), + localRepository: ref.watch(localAssetRepository), + apiRepository: ref.watch(assetApiRepositoryProvider), ), ); diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index b9cff613fd..0e5a3123e7 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -1,14 +1,14 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart'; +import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart'; @@ -185,18 +185,14 @@ enum ActionButtonType { }; } - ConsumerWidget buildButton( + Widget buildButton( ActionButtonContext context, [ BuildContext? buildContext, bool iconOnly = false, bool menuItem = false, ]) { return switch (this) { - ActionButtonType.advancedInfo => AdvancedInfoActionButton( - source: context.source, - iconOnly: iconOnly, - menuItem: menuItem, - ), + ActionButtonType.advancedInfo => ActionMenuItemWidget(action: AssetDebugAction(assets: [context.asset])), ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.shareLink => ShareLinkActionButton( source: context.source, @@ -334,7 +330,7 @@ class ActionButtonBuilder { return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } - static List buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) { + static List buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) { final visibleButtons = defaultViewerKebabMenuOrder .where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context)) .toList(); @@ -350,7 +346,7 @@ class ActionButtonBuilder { if (lastGroup != null && type.kebabMenuGroup != lastGroup) { result.add(const Divider(height: 1)); } - result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref)); + result.add(type.buildButton(context, buildContext, false, true)); lastGroup = type.kebabMenuGroup; } diff --git a/mobile/packages/ui/lib/immich_ui.dart b/mobile/packages/ui/lib/immich_ui.dart index eaf7051637..8ea88135e5 100644 --- a/mobile/packages/ui/lib/immich_ui.dart +++ b/mobile/packages/ui/lib/immich_ui.dart @@ -1,3 +1,4 @@ +export 'src/color_override.dart'; export 'src/components/close_button.dart'; export 'src/components/column_button.dart'; export 'src/components/form.dart'; diff --git a/mobile/test/medium/repository_context.dart b/mobile/test/medium/repository_context.dart index 00390d455f..ed06774e82 100644 --- a/mobile/test/medium/repository_context.dart +++ b/mobile/test/medium/repository_context.dart @@ -217,8 +217,8 @@ class MediumRepositoryContext { } Future newFace({String? assetId, String? personId, int? imageWidth, int? imageHeight}) { - imageWidth ??= TestUtils.randInt(999) + 1; - imageHeight ??= TestUtils.randInt(999) + 1; + imageWidth ??= TestUtils.randInt(999) + 2; + imageHeight ??= TestUtils.randInt(999) + 2; final x1 = TestUtils.randInt(imageWidth - 1); final y1 = TestUtils.randInt(imageHeight - 1); diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart index 69260d343d..49bab00704 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -34,6 +34,7 @@ class RepositoryMocks { class ServiceMocks { final PartnerStub partner = PartnerStub(MockPartnerService()); final UserStub user = UserStub(MockUserService()); + final asset = AssetStub(MockAssetService()); ServiceMocks() { resetAll(); @@ -43,8 +44,10 @@ class ServiceMocks { _registerFallbacks(); partner.reset(); user.reset(); + asset.reset(); _stubUserService(); _stubPartnerService(); + _stubAssetService(); } void _stubUserService() { @@ -63,6 +66,10 @@ class ServiceMocks { when(partner.create).thenAnswer((_) async {}); when(partner.delete).thenAnswer((_) async {}); } + + void _stubAssetService() { + when(asset.updateFavorite).thenAnswer((_) async {}); + } } void _registerFallbacks() { @@ -119,3 +126,8 @@ extension type const UserStub(MockUserService service) implements Stub Function() get createProfileImage => () => service.createProfileImage(any(), any()); } + +extension type const AssetStub(MockAssetService service) implements Stub { + Future Function() get updateFavorite => + () => service.updateFavorite(any(), any()); +} diff --git a/mobile/test/unit/presentation/actions/asset_debug_action_test.dart b/mobile/test/unit/presentation/actions/asset_debug_action_test.dart new file mode 100644 index 0000000000..b720c8720e --- /dev/null +++ b/mobile/test/unit/presentation/actions/asset_debug_action_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; +import 'package:immich_ui/immich_ui.dart'; + +import '../../factories/remote_asset_factory.dart'; +import '../../presentation_context.dart'; + +void main() { + late PresentationContext context; + + setUp(() async { + context = await PresentationContext.create(); + await StoreService.I.put(StoreKey.advancedTroubleshooting, true); + }); + + tearDown(() { + context.dispose(); + }); + + group('AssetDebugAction', () { + testWidgets('visible for a single asset when advanced troubleshooting is on', (tester) async { + await tester.pumpTestWidget( + ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])), + overrides: context.overrides, + ); + + expect(find.byType(ImmichIconButton), findsOneWidget); + }); + + testWidgets('hidden for multiple assets', (tester) async { + await tester.pumpTestWidget( + ActionIconButtonWidget( + action: AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]), + ), + overrides: context.overrides, + ); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + + testWidgets('hidden when advanced troubleshooting is off', (tester) async { + await StoreService.I.put(StoreKey.advancedTroubleshooting, false); + await tester.pumpTestWidget( + ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])), + overrides: context.overrides, + ); + + expect(find.byType(ImmichIconButton), findsNothing); + }); + }); +} diff --git a/mobile/test/unit/presentation/actions/favorite_action_test.dart b/mobile/test/unit/presentation/actions/favorite_action_test.dart new file mode 100644 index 0000000000..cc8a2fc66c --- /dev/null +++ b/mobile/test/unit/presentation/actions/favorite_action_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/actions/favorite.action.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../factories/remote_asset_factory.dart'; +import '../../presentation_context.dart'; + +void main() { + late PresentationContext context; + + setUp(() async { + context = await PresentationContext.create(); + }); + + tearDown(() { + context.dispose(); + }); + + List overrides() => [ + ...context.overrides, + assetServiceProvider.overrideWithValue(context.mocks.asset.service), + ]; + + RemoteAsset owned({bool isFavorite = false}) => + RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite); + + group('FavoriteAction', () { + testWidgets('favorites the eligible owned assets', (tester) async { + final asset = owned(); + + await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides()); + + verify(() => context.mocks.asset.service.updateFavorite([asset.id], true)).called(1); + }); + + testWidgets('unfavorite the eligible owned assets', (tester) async { + final asset = owned(isFavorite: true); + + await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides()); + + verify(() => context.mocks.asset.service.updateFavorite([asset.id], false)).called(1); + }); + + testWidgets('ignores assets owned by someone else', (tester) async { + final mine = owned(); + final theirs = RemoteAssetFactory.create(); + + await tester.pumpTestAction(FavoriteAction(assets: [mine, theirs]), overrides: overrides()); + + verify(() => context.mocks.asset.service.updateFavorite([mine.id], true)).called(1); + }); + + testWidgets('batches every eligible owned asset into a single call', (tester) async { + final first = owned(); + final second = owned(); + + await tester.pumpTestAction(FavoriteAction(assets: [first, second]), overrides: overrides()); + + verify(() => context.mocks.asset.service.updateFavorite([first.id, second.id], true)).called(1); + }); + + testWidgets('skips owned assets already in the target state', (tester) async { + final stale = owned(); + final alreadyFavorite = owned(isFavorite: true); + + await tester.pumpTestAction(FavoriteAction(assets: [stale, alreadyFavorite]), overrides: overrides()); + + verify(() => context.mocks.asset.service.updateFavorite([stale.id], true)).called(1); + }); + + testWidgets('shows a confirmation snackbar on success', (tester) async { + await tester.pumpTestAction(FavoriteAction(assets: [owned()]), overrides: overrides()); + await tester.pumpUntilFound(find.byType(SnackBar)); + + expect(find.byType(SnackBar), findsOneWidget); + }); + }); +} diff --git a/mobile/test/unit/presentation/actions/partner_action_test.dart b/mobile/test/unit/presentation/actions/partner_action_test.dart index 1eb89fe4cc..0284371c91 100644 --- a/mobile/test/unit/presentation/actions/partner_action_test.dart +++ b/mobile/test/unit/presentation/actions/partner_action_test.dart @@ -5,32 +5,25 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/presentation/actions/partner.action.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; import 'package:mocktail/mocktail.dart'; import '../../factories/user_factory.dart'; -import '../../mocks.dart'; import '../../presentation_context.dart'; void main() { late PresentationContext context; - late UserDto currentUser; - final mocks = ServiceMocks(); setUp(() async { - currentUser = UserFactory.createDto(); context = await PresentationContext.create(); - when(mocks.user.tryGetMyUser).thenReturn(currentUser); }); - tearDown(() async { - mocks.resetAll(); - await context.dispose(); + tearDown(() { + context.dispose(); }); List overrides({List candidates = const []}) => [ - currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service)), - partnerServiceProvider.overrideWithValue(mocks.partner.service), + ...context.overrides, + partnerServiceProvider.overrideWithValue(context.mocks.partner.service), candidatesStateProvider.overrideWith((ref) => Stream>.value(candidates)), ]; @@ -43,7 +36,9 @@ void main() { await tester.tap(find.text(candidate.name)); await tester.pumpAndSettle(); - verify(() => mocks.partner.service.create(sharedById: currentUser.id, sharedWithId: candidate.id)).called(1); + verify( + () => context.mocks.partner.service.create(sharedById: context.currentUser.id, sharedWithId: candidate.id), + ).called(1); }); testWidgets('creates nothing when the selection dialog is dismissed', (tester) async { @@ -51,7 +46,7 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting await tester.pumpAndSettle(); - verifyNever(mocks.partner.create); + verifyNever(context.mocks.partner.create); }); }); @@ -65,7 +60,9 @@ void main() { await tester.tap(find.byType(TextButton).last); // confirm await tester.pumpAndSettle(); - verify(() => mocks.partner.service.delete(sharedById: currentUser.id, sharedWithId: partner.id)).called(1); + verify( + () => context.mocks.partner.service.delete(sharedById: context.currentUser.id, sharedWithId: partner.id), + ).called(1); }); testWidgets('deletes nothing when the confirmation is cancelled', (tester) async { @@ -77,7 +74,7 @@ void main() { await tester.tap(find.byType(TextButton).first); // cancel await tester.pumpAndSettle(); - verifyNever(mocks.partner.delete); + verifyNever(context.mocks.partner.delete); }); }); } diff --git a/mobile/test/unit/presentation/actions/timeline_action_test.dart b/mobile/test/unit/presentation/actions/timeline_action_test.dart new file mode 100644 index 0000000000..6c14053317 --- /dev/null +++ b/mobile/test/unit/presentation/actions/timeline_action_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/timeline.action.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; + +import '../../factories/remote_asset_factory.dart'; +import '../../presentation_context.dart'; + +class _FakeAction extends BaseAction { + _FakeAction({this.visible = true, this.error}); + + final bool visible; + final Object? error; + + bool ran = false; + bool? selectionDuringOnAction; + + @override + IconData get icon => Icons.bolt; + + @override + String label(ActionScope scope) => 'fake'; + + @override + bool isVisible(ActionScope scope) => visible; + + @override + Future onAction(ActionScope scope) async { + ran = true; + selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled; + if (error != null) { + throw error!; + } + } +} + +void main() { + late PresentationContext context; + + setUp(() async { + context = await PresentationContext.create(); + }); + + tearDown(() { + context.dispose(); + }); + + List seededOverrides() => [ + ...context.overrides, + multiSelectProvider.overrideWith( + () => MultiSelectNotifier( + MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}), + ), + ), + ]; + + Future<(ActionScope, ProviderContainer)> pumpScope(WidgetTester tester) async { + late ActionScope scope; + late ProviderContainer container; + await tester.pumpTestWidget( + Consumer( + builder: (innerContext, ref, _) { + scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser); + container = ProviderScope.containerOf(innerContext, listen: false); + return const SizedBox.shrink(); + }, + ), + overrides: seededOverrides(), + ); + return (scope, container); + } + + group('TimelineAction', () { + testWidgets('runs the wrapped action and then clears the selection', (tester) async { + final inner = _FakeAction(); + final (scope, container) = await pumpScope(tester); + await TimelineAction(action: inner).onAction(scope); + + expect(inner.ran, isTrue); + expect(inner.selectionDuringOnAction, isTrue, reason: 'reset must run after the inner action, not before'); + expect(container.read(multiSelectProvider).isEnabled, isFalse); + }); + + testWidgets('rethrows and keeps the selection when the wrapped action throws', (tester) async { + final error = Exception('boom'); + final inner = _FakeAction(error: error); + final (scope, container) = await pumpScope(tester); + + await expectLater(TimelineAction(action: inner).onAction(scope), throwsA(same(error))); + + expect(inner.ran, isTrue); + expect(container.read(multiSelectProvider).isEnabled, isTrue); + }); + + testWidgets('delegates visibility to the wrapped action', (tester) async { + await tester.pumpTestWidget( + ActionIconButtonWidget(action: TimelineAction(action: _FakeAction(visible: false))), + overrides: context.overrides, + ); + + expect(find.byType(ActionIconButtonWidget), findsOneWidget); + expect(find.byIcon(Icons.bolt), findsNothing); + }); + }); +} diff --git a/mobile/test/unit/presentation/partner_page_test.dart b/mobile/test/unit/presentation/partner_page_test.dart index 4557388dc0..6575b4a15c 100644 --- a/mobile/test/unit/presentation/partner_page_test.dart +++ b/mobile/test/unit/presentation/partner_page_test.dart @@ -13,7 +13,7 @@ void main() { late PresentationContext context; setUp(() async => context = await PresentationContext.create()); - tearDown(() async => await context.dispose()); + tearDown(() => context.dispose()); group('PartnerSharedByList', () { testWidgets('shows the empty-state add button when there are no partners', (tester) async { diff --git a/mobile/test/unit/presentation_context.dart b/mobile/test/unit/presentation_context.dart index e411b21802..d3998994c9 100644 --- a/mobile/test/unit/presentation_context.dart +++ b/mobile/test/unit/presentation_context.dart @@ -23,7 +23,7 @@ import 'mocks.dart'; class PresentationContext { PresentationContext._({required UserDto user}) : currentUser = user, mocks = ServiceMocks() { - when(mocks.user.tryGetMyUser).thenReturn(currentUser); + setup(); } static const String serverEndpoint = 'http://localhost:3000'; @@ -46,10 +46,14 @@ class PresentationContext { return PresentationContext._(user: UserFactory.createDto()); } - Future dispose() async { - // TODO: Dispose the store and database after each test. - // This is currently not possible because the store is a singleton and is used across tests. - // Refactor the store to be created per test to allow proper disposal. + void setup() { + when(mocks.user.tryGetMyUser).thenReturn(currentUser); + } + + void dispose() { + addTearDown(() { + mocks.resetAll(); + }); } } @@ -73,7 +77,7 @@ extension PumpPresentationWidget on WidgetTester { localizationsDelegates: context.localizationDelegates, supportedLocales: context.supportedLocales, locale: context.locale, - home: Material(child: widget), + home: Scaffold(body: widget), ), ), ), @@ -83,10 +87,7 @@ extension PumpPresentationWidget on WidgetTester { } Future pumpTestAction(BaseAction action, {List overrides = const []}) async { - await pumpTestWidget( - Scaffold(body: ActionIconButtonWidget(action: action)), - overrides: overrides, - ); + await pumpTestWidget(ActionIconButtonWidget(action: action), overrides: overrides); await tap(find.byType(ImmichIconButton)); await pump(); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 51ff6925ba..7a00608f5a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -17316,7 +17316,7 @@ }, { "name": "Faces", - "description": "A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created via manually." + "description": "A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created manually." }, { "name": "Integrity (admin)", diff --git a/packages/plugin-core/package.json b/packages/plugin-core/package.json index baddf4a6eb..5e4812bb1c 100644 --- a/packages/plugin-core/package.json +++ b/packages/plugin-core/package.json @@ -5,8 +5,8 @@ "main": "src/index.ts", "scripts": { "build": "pnpm build:tsc && pnpm build:wasm", - "build:tsc": "mkdir -p dist && echo \"type Manifest = $(cat manifest.json); \nexport default Manifest;\" > dist/manifest.d.ts && tsc --noEmit && node esbuild.js", - "build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm" + "build:tsc": "plugin-sdk prepareBuild && tsc --noEmit && node esbuild.js", + "build:wasm": "extism-js dist/index.js -i dist/index.d.ts -o dist/plugin.wasm" }, "keywords": [], "author": "", diff --git a/packages/plugin-core/src/index.d.ts b/packages/plugin-core/src/index.d.ts deleted file mode 100644 index 636cb03047..0000000000 --- a/packages/plugin-core/src/index.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -// keep in sync with plugin-sdk/host-functions.ts'; -declare module 'extism:host' { - interface user { - searchAlbums(ptr: PTR): I64; - createAlbum(ptr: PTR): I64; - addAssetsToAlbum(ptr: PTR): I64; - addAssetsToAlbums(ptr: PTR): I64; - } -} - -// keep in sync with manifest.json -declare module 'main' { - // filters - export function assetFileFilter(): I32; - export function assetMissingTimeZoneFilter(): I32; - export function assetLocationFilter(): I32; - export function assetTypeFilter(): I32; - - // updates - export function assetFavorite(): I32; - export function assetVisibility(): I32; - export function assetArchive(): I32; - export function assetLock(): I32; - export function assetTimeline(): I32; - // export function assetTrash(): I32; - export function assetAddToAlbums(): I32; -} diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index 12eaab404b..164f33d72b 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -1,175 +1,157 @@ import { getWrapper } from '@immich/plugin-sdk'; import { AssetVisibility } from '@immich/sdk'; -import type manifestType from '../dist/manifest'; +import type { Manifest } from '../dist/index.d.ts'; -const wrapper = getWrapper(); +const wrapper = getWrapper(); -export const assetFileFilter = () => { - return wrapper<'assetFileFilter'>(({ data, config }) => { - const { pattern, matchType = 'contains', caseSensitive = false } = config; +export const assetFileFilter = wrapper<'assetFileFilter'>(({ data, config }) => { + const { pattern, matchType = 'contains', caseSensitive = false } = config; - const { asset } = data; + const { asset } = data; - const fileName = asset.originalFileName || ''; - const searchName = caseSensitive ? fileName : fileName.toLowerCase(); - const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); + const fileName = asset.originalFileName || ''; + const searchName = caseSensitive ? fileName : fileName.toLowerCase(); + const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); - switch (matchType) { - case 'contains': { - return { workflow: { continue: searchName.includes(searchPattern) } }; - } - - case 'exact': { - return { workflow: { continue: searchName === searchPattern } }; - } - - case 'startsWith': { - return { workflow: { continue: searchName.startsWith(searchPattern) } }; - } - - case 'regex': { - const flags = caseSensitive ? '' : 'i'; - const regex = new RegExp(searchPattern, flags); - return { workflow: { continue: regex.test(fileName) } }; - } - - default: { - return {}; - } - } - }); -}; - -export const assetMissingTimeZoneFilter = () => { - return wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => { - const hasTimeZone = !!data.asset?.exifInfo?.timeZone; - const needsTimeZone = config.inverse ? true : false; - return { workflow: { continue: hasTimeZone === needsTimeZone } }; - }); -}; - -export const assetLocationFilter = () => { - return wrapper<'assetLocationFilter'>(({ config, data }) => { - if ( - (config.region?.country && config.region.country !== data.asset.exifInfo?.country) || - (config.region?.state && config.region.state !== data.asset.exifInfo?.state) || - (config.region?.city && config.region.city !== data.asset.exifInfo?.city) - ) { - return { workflow: { continue: false } }; + switch (matchType) { + case 'contains': { + return { workflow: { continue: searchName.includes(searchPattern) } }; } - const configLat = Number.parseFloat(config.coordinate?.latitude ?? ''); - const configLon = Number.parseFloat(config.coordinate?.longitude ?? ''); - - if (Number.isNaN(configLat) || Number.isNaN(configLat)) { - return { workflow: { continue: true } }; + case 'exact': { + return { workflow: { continue: searchName === searchPattern } }; } - const assetLat = data.asset.exifInfo?.latitude; - const assetLon = data.asset.exifInfo?.longitude; - - if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) { - return { workflow: { continue: false } }; + case 'startsWith': { + return { workflow: { continue: searchName.startsWith(searchPattern) } }; } - const earthDiameter = 12742; - const deg = Math.PI / 180; - const delta = Math.asin( - Math.sqrt( - Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) + - Math.cos(assetLat * deg) * - Math.cos(configLat * deg) * - Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2), - ), - ); - - return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } }; - }); -}; - -export const assetTypeFilter = () => { - return wrapper<'assetTypeFilter'>(({ config, data }) => { - return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } }; - }); -}; - -export const assetFavorite = () => { - return wrapper<'assetFavorite'>(({ config, data }) => { - const target = config.inverse ? false : true; - if (target !== data.asset.isFavorite) { - return { - changes: { - asset: { isFavorite: target }, - }, - }; - } - }); -}; - -export const assetVisibility = () => { - return wrapper<'assetVisibility'>(({ config }) => ({ - changes: { asset: { visibility: config.visibility as AssetVisibility } }, - })); -}; - -export const assetArchive = () => { - return wrapper<'assetArchive'>(({ config, data }) => { - if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) { - return { changes: { asset: { visibility: AssetVisibility.Archive } } }; + case 'regex': { + const flags = caseSensitive ? '' : 'i'; + const regex = new RegExp(searchPattern, flags); + return { workflow: { continue: regex.test(fileName) } }; } - if (config.inverse && data.asset.visibility === AssetVisibility.Archive) { - return { changes: { asset: { visibility: AssetVisibility.Timeline } } }; + default: { + return {}; } + } +}); - return {}; - }); -}; +export const assetMissingTimeZoneFilter = wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => { + const hasTimeZone = !!data.asset?.exifInfo?.timeZone; + const needsTimeZone = config.inverse ? true : false; + return { workflow: { continue: hasTimeZone === needsTimeZone } }; +}); -export const assetLock = () => { - return wrapper<'assetLock'>(({ config, data }) => { - if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) { - return { changes: { asset: { visibility: AssetVisibility.Locked } } }; - } +export const assetLocationFilter = wrapper<'assetLocationFilter'>(({ config, data }) => { + if ( + (config.region?.country && config.region.country !== data.asset.exifInfo?.country) || + (config.region?.state && config.region.state !== data.asset.exifInfo?.state) || + (config.region?.city && config.region.city !== data.asset.exifInfo?.city) + ) { + return { workflow: { continue: false } }; + } - if (config.inverse && data.asset.visibility === AssetVisibility.Locked) { - return { changes: { asset: { visibility: AssetVisibility.Timeline } } }; - } + const configLat = Number.parseFloat(config.coordinate?.latitude ?? ''); + const configLon = Number.parseFloat(config.coordinate?.longitude ?? ''); - return {}; - }); -}; + if (Number.isNaN(configLat) || Number.isNaN(configLat)) { + return { workflow: { continue: true } }; + } + + const assetLat = data.asset.exifInfo?.latitude; + const assetLon = data.asset.exifInfo?.longitude; + + if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) { + return { workflow: { continue: false } }; + } + + const earthDiameter = 12742; + const deg = Math.PI / 180; + const delta = Math.asin( + Math.sqrt( + Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) + + Math.cos(assetLat * deg) * + Math.cos(configLat * deg) * + Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2), + ), + ); + + return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } }; +}); + +export const assetTypeFilter = wrapper<'assetTypeFilter'>(({ config, data }) => { + return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } }; +}); + +export const assetFavorite = wrapper<'assetFavorite'>(({ config, data }) => { + const target = config.inverse ? false : true; + if (target !== data.asset.isFavorite) { + return { + changes: { + asset: { isFavorite: target }, + }, + }; + } +}); + +export const assetVisibility = wrapper<'assetVisibility'>(({ config }) => ({ + changes: { asset: { visibility: config.visibility as AssetVisibility } }, +})); + +export const assetArchive = wrapper<'assetArchive'>(({ config, data }) => { + if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) { + return { changes: { asset: { visibility: AssetVisibility.Archive } } }; + } + + if (config.inverse && data.asset.visibility === AssetVisibility.Archive) { + return { changes: { asset: { visibility: AssetVisibility.Timeline } } }; + } + + return {}; +}); + +export const assetLock = wrapper<'assetLock'>(({ config, data }) => { + if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) { + return { changes: { asset: { visibility: AssetVisibility.Locked } } }; + } + + if (config.inverse && data.asset.visibility === AssetVisibility.Locked) { + return { changes: { asset: { visibility: AssetVisibility.Timeline } } }; + } + + return {}; +}); // export const assetTrash = () => { // // TODO use trash/untrash host functions // return wrapper(() => ({})); // }; -export const assetAddToAlbums = () => { - return wrapper<'assetAddToAlbums'>(({ config, data, functions }) => { - const assetId = data.asset.id; +export const assetAddToAlbums = wrapper<'assetAddToAlbums'>(({ config, data, functions }) => { + const assetId = data.asset.id; - if (config.albumIds.length === 0) { - if (!config.albumName) { - return {}; - } - - const [existing] = functions.searchAlbums({ name: config.albumName }); - if (!existing) { - const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] }); - config.albumIds.push(created.id); - return {}; - } - - config.albumIds.push(existing.id); - } - - if (config.albumIds.length === 1) { - functions.addAssetsToAlbum(config.albumIds[0], [assetId]); + if (config.albumIds.length === 0) { + if (!config.albumName) { return {}; } - functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] }); + const [existing] = functions.searchAlbums({ name: config.albumName }); + if (!existing) { + const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] }); + config.albumIds.push(created.id); + return {}; + } + + config.albumIds.push(existing.id); + } + + if (config.albumIds.length === 1) { + functions.addAssetsToAlbum(config.albumIds[0], [assetId]); return {}; - }); -}; + } + + functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] }); + return {}; +}); diff --git a/packages/plugin-core/tsconfig.json b/packages/plugin-core/tsconfig.json index 23ab692016..24aab4851c 100644 --- a/packages/plugin-core/tsconfig.json +++ b/packages/plugin-core/tsconfig.json @@ -13,7 +13,7 @@ "skipLibCheck": true, // Skip type checking of declaration files "strict": true, // Enable all strict type-checking options "target": "es2020", // Specify ECMAScript target version - "types": ["./src/index.d.ts", "./node_modules/@extism/js-pdk"] // Specify a list of type definition files to be included in the compilation + "types": ["./dist/index.d.ts", "./node_modules/@extism/js-pdk"] // Specify a list of type definition files to be included in the compilation }, "exclude": [ "node_modules" // Exclude the node_modules directory diff --git a/packages/plugin-sdk/esbuild.js b/packages/plugin-sdk/esbuild.js index d2e036e5c7..61590b6b41 100644 --- a/packages/plugin-sdk/esbuild.js +++ b/packages/plugin-sdk/esbuild.js @@ -1,11 +1,12 @@ import esbuild from 'esbuild'; esbuild.build({ - entryPoints: ['src/index.ts'], + entryPoints: ['src/index.ts', 'src/cli.ts'], outdir: 'dist', bundle: true, sourcemap: false, minify: false, format: 'esm', + platform: 'node', target: ['es2020'], }); diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 3d0c75b0b7..37fe066ea6 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -21,6 +21,9 @@ "files": [ "dist" ], + "bin": { + "plugin-sdk": "./plugin-sdk.mjs" + }, "keywords": [], "author": "", "license": "GNU Affero General Public License version 3", @@ -35,5 +38,8 @@ }, "peerDependencies": { "@extism/js-pdk": "^1.1.1" + }, + "dependencies": { + "commander": "^15.0.0" } } diff --git a/packages/plugin-sdk/plugin-sdk.mjs b/packages/plugin-sdk/plugin-sdk.mjs new file mode 100755 index 0000000000..f1d46c5b32 --- /dev/null +++ b/packages/plugin-sdk/plugin-sdk.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import "./dist/cli.js"; diff --git a/packages/plugin-sdk/src/cli.ts b/packages/plugin-sdk/src/cli.ts new file mode 100644 index 0000000000..9035911134 --- /dev/null +++ b/packages/plugin-sdk/src/cli.ts @@ -0,0 +1,43 @@ +import { Command } from 'commander'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { availableFunctions } from 'src/host-functions.js'; + +const program = new Command('plugin-sdk'); + +program + .command('prepareBuild') + .description('Generate .d.ts file required for extism') + .argument( + '[manifest]', + "Path to the plugins's manifest file", + 'manifest.json', + ) + .option('-o --output', 'Output file for generated types', 'dist/index.d.ts') + .action((manifest: string, { output }) => { + const content = readFileSync(manifest, { encoding: 'utf-8' }); + const methods = ( + JSON.parse(content) as { methods: { name: string }[] } + ).methods.map(({ name }) => name); + mkdirSync(dirname(output), { recursive: true }); + + writeFileSync( + output, + ` +declare module 'extism:host' { + interface user { +${availableFunctions.map((functionName) => ` ${functionName}(ptr: PTR): I64;`).join('\n')} + } +} + +declare module 'main' { +${methods.map((method) => ` export function ${method}(): I32;`).join('\n')} +} + +export type Manifest = ${content}; + + `, + ); + }); + +program.parse(); diff --git a/packages/plugin-sdk/src/host-functions.ts b/packages/plugin-sdk/src/host-functions.ts index 281e27c83c..9bfe073a69 100644 --- a/packages/plugin-sdk/src/host-functions.ts +++ b/packages/plugin-sdk/src/host-functions.ts @@ -6,14 +6,11 @@ import { type CreateAlbumDto, } from '@immich/sdk'; -// keep in sync with plugin-core/src/index.d.ts'; declare module 'extism:host' { - interface user { - searchAlbums(ptr: PTR): I64; - createAlbum(ptr: PTR): I64; - addAssetsToAlbum(ptr: PTR): I64; - addAssetsToAlbums(ptr: PTR): I64; - } + interface user extends Record< + (typeof availableFunctions)[number], + (ptr: PTR) => I64 + > {} } type AlbumsToAssets = { @@ -34,6 +31,13 @@ type HostFunctionResult = type QueryParams any> = Parameters[0]; type AlbumSearchDto = QueryParams; +export const availableFunctions = [ + 'searchAlbums', + 'createAlbum', + 'addAssetsToAlbum', + 'addAssetsToAlbums', +] as const; + export const hostFunctions = (authToken: string) => { const host = Host.getFunctions(); type HostFunctionName = keyof typeof host; @@ -75,5 +79,5 @@ export const hostFunctions = (authToken: string) => { ), addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) => call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]), - }; + } satisfies Record<(typeof availableFunctions)[number], unknown>; }; diff --git a/packages/plugin-sdk/src/sdk.ts b/packages/plugin-sdk/src/sdk.ts index e428eafed5..8069c2cbec 100644 --- a/packages/plugin-sdk/src/sdk.ts +++ b/packages/plugin-sdk/src/sdk.ts @@ -67,7 +67,8 @@ export const getWrapper = functions: ReturnType; }, ) => WorkflowResponse | undefined, - ) => { + ) => + () => { const input = Host.inputString(); try { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d9e9ccd32..5ccaa0212c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -339,6 +339,10 @@ importers: version: 6.0.3 packages/plugin-sdk: + dependencies: + commander: + specifier: ^15.0.0 + version: 15.0.0 devDependencies: '@extism/js-pdk': specifier: ^1.1.1 diff --git a/server/src/constants.ts b/server/src/constants.ts index 7760fa8e8c..e0a6296e64 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -155,7 +155,7 @@ export const endpointTags: Record = { [ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.', [ApiTag.Duplicates]: 'Endpoints for managing and identifying duplicate assets.', [ApiTag.Faces]: - 'A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created via manually.', + 'A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created manually.', [ApiTag.Integrity]: 'Endpoints for viewing and managing integrity reports.', [ApiTag.Jobs]: 'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.',