Compare commits

..

8 Commits

Author SHA1 Message Date
shenlong-tanwen 50c7d94064 feat: archive action 2026-06-30 05:25:11 +05:30
shenlong-tanwen b6952304ef review changes 2026-06-30 05:11:12 +05:30
shenlong-tanwen 3533d4ef3a feat: stack action
# Conflicts:
#	mobile/lib/domain/services/asset.service.dart
#	mobile/lib/presentation/actions/favorite.action.dart
#	mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart
#	mobile/lib/utils/action_button.utils.dart
#	mobile/test/unit/factories/remote_asset_factory.dart
#	mobile/test/unit/mocks.dart
2026-06-30 05:00:29 +05:30
shenlong-tanwen f3ececcdc5 chore: migrate general bottom sheet favorite 2026-06-30 04:56:58 +05:30
shenlong-tanwen 034889ca60 feat: restore action 2026-06-30 04:56:42 +05:30
shenlong-tanwen 7849816877 refactor: feedback repository 2026-06-30 04:20:51 +05:30
shenlong-tanwen 1ded04d29d refactor: add asset update method 2026-06-30 04:20:31 +05:30
shenlong-tanwen 29797dba9e refactor: owned action assets filter 2026-06-30 04:19:55 +05:30
52 changed files with 936 additions and 794 deletions
+2 -2
View File
@@ -103,7 +103,7 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'zulu'
java-version: '17'
@@ -237,7 +237,7 @@ jobs:
run: flutter build ios --config-only --no-codesign
- name: Setup Ruby
uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1.314.0
uses: ruby/setup-ruby@89f90524b88a01fe6e0b732220432cc6142926af # v1.313.0
with:
ruby-version: '3.3'
bundler-cache: true
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@ccc2442df0d99f8c419ed73e3de88641c91b3bc6 # v0.1.3
uses: oasdiff/oasdiff-action/breaking@e24529087d93f837b28b50bb66ba9016380a7fcc # v0.1.2
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
+1 -1
View File
@@ -149,7 +149,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
draft: true
prerelease: ${{ needs.bump_version.outputs.rc }}
-2
View File
@@ -9,8 +9,6 @@ enum SortOrder {
enum TextSearchType { context, filename, description, ocr }
enum AssetVisibilityEnum { timeline, hidden, archive, locked }
enum ActionSource { timeline, viewer }
enum ShareAssetType { original, preview }
@@ -52,6 +52,8 @@ class RemoteAsset extends BaseAsset {
bool get isTrashed => deletedAt != null;
bool get isStacked => stackId != null;
@override
String toString() {
return '''Asset {
@@ -77,4 +77,40 @@ class AssetService {
await _apiRepository.updateFavorite(remoteIds, isFavorite);
await _remoteRepository.updateFavorite(remoteIds, isFavorite);
}
Future<void> restoreTrash(List<String> remoteIds) async {
if (remoteIds.isEmpty) {
return;
}
await _apiRepository.restoreTrash(remoteIds);
await _remoteRepository.restoreTrash(remoteIds);
}
Future<void> stack(String userId, List<String> remoteIds) async {
if (remoteIds.isEmpty) {
return;
}
final stack = await _apiRepository.stack(remoteIds);
await _remoteRepository.stack(userId, stack);
}
Future<void> unstack(List<String> stackIds) async {
if (stackIds.isEmpty) {
return;
}
await _remoteRepository.unStack(stackIds);
await _apiRepository.unStack(stackIds);
}
Future<void> updateVisibility(List<String> remoteIds, AssetVisibility visibility) async {
if (remoteIds.isEmpty) {
return;
}
await _apiRepository.updateVisibility(remoteIds, visibility);
await _remoteRepository.updateVisibility(remoteIds, visibility);
}
}
@@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class RemoteAssetRepository extends DriftDatabaseRepository {
@@ -286,4 +287,20 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
..orderBy([(row) => OrderingTerm.asc(row.sequence)]);
return query.map((row) => row.toDto()!).get();
}
Future<void> update(
List<String> remoteIds, {
Option<bool> isFavorite = const .none(),
Option<AssetVisibility> visibility = const .none(),
}) {
final companion = RemoteAssetEntityCompanion(
visibility: visibility.toDriftValue(),
isFavorite: isFavorite.toDriftValue(),
);
return _db.batch((batch) {
for (final remoteId in remoteIds) {
batch.update(_db.remoteAssetEntity, companion, where: (e) => e.id.equals(remoteId));
}
});
}
}
@@ -0,0 +1,46 @@
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_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class ArchiveAction extends AssetAction<RemoteAsset> {
final bool archive;
ArchiveAction({required super.assets})
: archive = assets.any((asset) => asset is RemoteAsset && asset.visibility != .archive);
@override
IconData get icon => archive ? Icons.archive_outlined : Icons.unarchive_outlined;
@override
String label(ActionScope scope) => archive ? scope.context.t.archive : scope.context.t.unarchive;
@override
Iterable<RemoteAsset> filter(ActionScope scope) {
final owned = AssetFilter(assets).owned(scope.authUser.id);
if (archive) {
return owned.visibility(.timeline);
} else {
return owned.visibility(.archive);
}
}
@override
bool isVisible(ActionScope scope) => !scope.ref.watch(inLockedViewProvider) && filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:ref, :context) = scope;
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateVisibility(assets, archive ? .archive : .timeline);
final message = archive
? context.t.archive_action_prompt(count: assets.length)
: context.t.unarchive_action_prompt(count: assets.length);
ref.read(toastRepositoryProvider).success(message);
}
}
@@ -3,38 +3,42 @@ 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';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class FavoriteAction extends AssetAction<RemoteAsset> {
final bool shouldFavorite;
final bool favorite;
FavoriteAction({required super.assets}) : shouldFavorite = assets.any((asset) => !asset.isFavorite);
FavoriteAction({required super.assets}) : favorite = assets.any((asset) => !asset.isFavorite);
@override
IconData get icon => shouldFavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
@override
String label(ActionScope scope) => shouldFavorite ? scope.context.t.favorite : scope.context.t.unfavorite;
String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite;
@override
Iterable<RemoteAsset> filter(ActionScope scope) => assets
.where(
(asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
)
.cast<RemoteAsset>();
Iterable<RemoteAsset> filter(ActionScope scope) {
final owned = AssetFilter(assets).owned(scope.authUser.id);
if (favorite) {
return owned.notFavorites();
} else {
return owned.favorites();
}
}
@override
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:ref) = scope;
final ActionScope(:ref, :context) = 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);
await ref.read(assetServiceProvider).updateFavorite(assets, favorite);
final message = favorite
? context.t.favorite_action_prompt(count: assets.length)
: context.t.unfavorite_action_prompt(count: assets.length);
ref.read(toastRepositoryProvider).success(message);
}
}
@@ -0,0 +1,31 @@
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_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class RestoreAction extends AssetAction<RemoteAsset> {
const RestoreAction({required super.assets});
@override
IconData get icon => Icons.history_rounded;
@override
String label(ActionScope scope) => scope.context.t.restore;
@override
Iterable<RemoteAsset> filter(ActionScope scope) => AssetFilter(assets).owned(scope.authUser.id).trashed();
@override
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:ref, :context) = scope;
final ids = filter(scope).map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).restoreTrash(ids);
ref.read(toastRepositoryProvider).success(context.t.assets_restored_count(count: ids.length));
}
}
@@ -0,0 +1,44 @@
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_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class StackAction extends AssetAction<RemoteAsset> {
final bool shouldStack;
StackAction({required super.assets})
: shouldStack = assets.any((asset) => asset is RemoteAsset && asset.stackId == null);
@override
IconData get icon => shouldStack ? Icons.filter_none_rounded : Icons.layers_clear_outlined;
@override
String label(ActionScope scope) => shouldStack ? scope.context.t.stack : scope.context.t.unstack;
@override
Iterable<RemoteAsset> filter(ActionScope scope) => AssetFilter(assets).owned(scope.authUser.id);
@override
bool isVisible(ActionScope scope) => shouldStack ? filter(scope).length > 1 : filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:ref, :context) = scope;
final assets = filter(scope).toList(growable: false);
final service = ref.read(assetServiceProvider);
if (shouldStack) {
await service.stack(scope.authUser.id, assets.map((asset) => asset.id).toList(growable: false));
} else {
await service.unstack(assets.map((asset) => asset.stackId).nonNulls.toList(growable: false));
}
final message = shouldStack
? context.t.stacked_assets_count(count: assets.length)
: context.t.unstacked_assets_count(count: assets.length);
ref.read(toastRepositoryProvider).success(message);
}
}
@@ -1,26 +1,24 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/providers/user.provider.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/constants/enums.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_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/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_ui/immich_ui.dart';
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
enum AddToMenuItem { album, lockedFolder }
class AddActionButton extends ConsumerStatefulWidget {
const AddActionButton({super.key, this.originalTheme});
@@ -37,12 +35,6 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
case AddToMenuItem.album:
_openAlbumSelector();
break;
case AddToMenuItem.archive:
performArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.unarchive:
performUnArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.lockedFolder:
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
break;
@@ -57,11 +49,6 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
final user = ref.read(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final hasRemote = asset is RemoteAsset;
final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived;
final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived;
return [
Padding(
@@ -81,20 +68,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
),
if (showArchive)
BaseActionButton(
iconData: Icons.archive_outlined,
label: "archive".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.archive),
),
if (showUnarchive)
BaseActionButton(
iconData: Icons.unarchive_outlined,
label: "unarchive".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.unarchive),
),
ActionMenuItemWidget(action: ArchiveAction(assets: [asset])),
BaseActionButton(
iconData: Icons.lock_outline,
label: "locked_folder".tr(),
@@ -184,7 +158,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
final themeData = widget.originalTheme ?? context.themeData;
return MenuAnchor(
return ImmichMenu(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor),
@@ -195,7 +169,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: widget.originalTheme != null
children: widget.originalTheme != null
? [
Theme(
data: widget.originalTheme!,
@@ -1,59 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.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';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
// used to allow performing archive action from different sources (without duplicating code)
Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) {
return;
}
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).archive(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
class ArchiveActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const ArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
await performArchiveAction(context, ref, source: source);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.archive_outlined,
label: "to_archive".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.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/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class FavoriteActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const FavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).favorite(source);
if (source == ActionSource.viewer) {
if (result.success) {
final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset is RemoteAsset && !currentAsset.isFavorite) {
ref.read(assetViewerProvider.notifier).setAsset(currentAsset.copyWith(isFavorite: true));
}
}
return;
}
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'favorite_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.favorite_border_rounded,
label: "favorite".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,55 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.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';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RestoreActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const RestoreActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).restoreTrash(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.history_rounded,
label: 'restore'.t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100.0,
);
}
}
@@ -1,43 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.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/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RestoreTrashActionButton extends ConsumerWidget {
final ActionSource source;
const RestoreTrashActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).restoreTrash(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return TextButton.icon(
icon: const Icon(Icons.history_rounded),
label: Text('restore'.t(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,50 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.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';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class StackActionButton extends ConsumerWidget {
final ActionSource source;
const StackActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access stack action');
}
final result = await ref.read(actionProvider.notifier).stack(user.id, source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'stack_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.filter_none_rounded,
label: "stack".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,61 +0,0 @@
// dart
// File: `lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart`
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.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';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
// used to allow performing unarchive action from different sources (without duplicating code)
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) {
return;
}
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).unArchive(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
class UnArchiveActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UnArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
await performUnArchiveAction(context, ref, source: source);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.unarchive_outlined,
label: "unarchive".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.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/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UnFavoriteActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UnFavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).unFavorite(source);
if (source == ActionSource.viewer) {
if (result.success) {
final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset is RemoteAsset && currentAsset.isFavorite) {
ref.read(assetViewerProvider.notifier).setAsset(currentAsset.copyWith(isFavorite: false));
}
}
return;
}
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unfavorite_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.favorite_rounded,
label: "unfavorite".t(context: context),
onPressed: () => _onTap(context, ref),
iconOnly: iconOnly,
menuItem: menuItem,
);
}
}
@@ -1,48 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.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';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UnStackActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UnStackActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).unStack(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unstack_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.layers_clear_outlined,
label: "unstack".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -4,12 +4,13 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/restore.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_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/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_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/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart';
@@ -42,11 +43,10 @@ class ViewerBottomBar extends ConsumerWidget {
final originalTheme = context.themeData;
final assets = [asset];
final actions = <Widget>[
if (isInTrash && isOwner && asset.hasRemote)
const RestoreActionButton(source: ActionSource.viewer)
else
const ShareActionButton(source: ActionSource.viewer),
ActionColumnButtonWidget(action: RestoreAction(assets: assets)),
const ShareActionButton(source: ActionSource.viewer),
if (!isInLockedView) ...[
if (!isInTrash) ...[
@@ -4,7 +4,9 @@ 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/archive.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/stack.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';
@@ -14,10 +16,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_
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';
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/unarchive_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';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
@@ -77,7 +76,7 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
final actions = [FavoriteAction(assets: assets), ArchiveAction(assets: assets), StackAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
@@ -88,7 +87,6 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
const UnArchiveActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
@@ -97,8 +95,6 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
],
@@ -5,9 +5,10 @@ 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/archive.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/stack.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';
@@ -16,9 +17,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_
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';
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/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';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -68,7 +67,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
final actions = [FavoriteAction(assets: assets), ArchiveAction(assets: assets), StackAction(assets: assets)];
return BaseBottomSheet(
initialChildSize: 0.4,
@@ -79,7 +78,6 @@ class FavoriteBottomSheet extends ConsumerWidget {
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(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
? const TrashActionButton(source: ActionSource.timeline)
@@ -87,8 +85,6 @@ class FavoriteBottomSheet extends ConsumerWidget {
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
],
@@ -4,9 +4,11 @@ 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/archive.action.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/stack.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';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
@@ -14,13 +16,10 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permane
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';
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/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_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';
@@ -84,7 +83,12 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [AssetDebugAction(assets: assets)];
final actions = [
AssetDebugAction(assets: assets),
FavoriteAction(assets: assets),
ArchiveAction(assets: assets),
StackAction(assets: assets),
];
return BaseBottomSheet(
controller: sheetController,
@@ -101,14 +105,10 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline),
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
if (multiselect.onlyLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
],
if (multiselect.onlyLocal || multiselect.hasMerged)
@@ -4,9 +4,10 @@ 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/archive.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/stack.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';
@@ -17,9 +18,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_al
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.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';
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/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';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
@@ -86,7 +85,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
final actions = [FavoriteAction(assets: assets), ArchiveAction(assets: assets), StackAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
@@ -100,7 +99,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const ShareLinkActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
const ArchiveActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
],
const DownloadActionButton(source: ActionSource.timeline),
@@ -111,8 +109,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
@@ -2,26 +2,33 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/restore.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_trash_action_button.widget.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class TrashBottomBar extends ConsumerWidget {
const TrashBottomBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = ref.watch(multiSelectProvider.select((s) => s.selectedAssets)).toList(growable: false);
return Align(
alignment: Alignment.bottomCenter,
child: Container(
color: context.themeData.canvasColor,
padding: const EdgeInsets.symmetric(vertical: 8),
child: const SafeArea(
child: SafeArea(
top: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
DeleteTrashActionButton(source: ActionSource.timeline),
RestoreTrashActionButton(source: ActionSource.timeline),
const DeleteTrashActionButton(source: ActionSource.timeline),
ActionColumnButtonWidget(
action: TimelineAction(action: RestoreAction(assets: assets)),
),
],
),
),
@@ -156,50 +156,6 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> favorite(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.favorite(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to favorite assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> unFavorite(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.unFavorite(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to unfavorite assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> archive(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.archive(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to archive assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> unArchive(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.unArchive(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to unarchive assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> moveToLockFolder(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
final localIds = _getLocalIdsForSource(source, ignoreLocalOnly: true);
@@ -235,17 +191,6 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> restoreTrash(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.restoreTrash(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to restore trash assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> emptyTrash(String userId) async {
try {
final count = await _service.emptyTrash(userId);
@@ -0,0 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/repositories/toast.repository.dart';
final toastRepositoryProvider = Provider<ToastRepository>((ref) => const .new());
@@ -22,8 +22,6 @@ class MultiSelectState {
bool get hasRemote =>
selectedAssets.any((asset) => asset.storage == AssetState.remote || asset.storage == AssetState.merged);
bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null);
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);
bool get onlyLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
@@ -1,12 +1,14 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart' hide AssetEditAction;
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:openapi/api.dart';
import 'package:openapi/api.dart' as api show AssetVisibility;
import 'package:openapi/api.dart' hide AssetVisibility;
final assetApiRepositoryProvider = Provider(
(ref) => AssetApiRepository(
@@ -41,7 +43,7 @@ class AssetApiRepository extends ApiRepository {
return response?.count ?? 0;
}
Future<void> updateVisibility(List<String> ids, AssetVisibilityEnum visibility) async {
Future<void> updateVisibility(List<String> ids, AssetVisibility visibility) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility))));
}
@@ -77,11 +79,11 @@ class AssetApiRepository extends ApiRepository {
return _api.downloadAssetWithHttpInfo(id, edited: edited);
}
_mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) {
AssetVisibilityEnum.timeline => AssetVisibility.timeline,
AssetVisibilityEnum.hidden => AssetVisibility.hidden,
AssetVisibilityEnum.locked => AssetVisibility.locked,
AssetVisibilityEnum.archive => AssetVisibility.archive,
api.AssetVisibility _mapVisibility(AssetVisibility visibility) => switch (visibility) {
AssetVisibility.timeline => api.AssetVisibility.timeline,
AssetVisibility.hidden => api.AssetVisibility.hidden,
AssetVisibility.locked => api.AssetVisibility.locked,
AssetVisibility.archive => api.AssetVisibility.archive,
};
Future<String?> getAssetMIMEType(String assetId) async {
@@ -106,6 +108,20 @@ class AssetApiRepository extends ApiRepository {
Future<void> removeEdits(String assetId) async {
return _api.removeAssetEdits(assetId);
}
Future<void> update(
List<String> remoteIds, {
Option<bool> isFavorite = const .none(),
Option<AssetVisibility> visibility = const .none(),
}) {
return _api.updateAssets(
AssetBulkUpdateDto(
ids: remoteIds,
isFavorite: isFavorite.toOptional(),
visibility: visibility.map(_mapVisibility).toOptional(),
),
);
}
}
extension on StackResponseDto {
@@ -0,0 +1,26 @@
import 'dart:async';
import 'package:immich_ui/immich_ui.dart';
class ToastOption {
final Duration? timeout;
final FutureOr<void> Function()? onUndo;
const ToastOption({this.timeout, this.onUndo});
}
class ToastRepository {
const ToastRepository();
FutureOr<void> success(String message, {ToastOption? toast}) {
snackbar.success(message, duration: toast?.timeout);
}
FutureOr<void> info(String message, {ToastOption? toast}) {
snackbar.info(message, duration: toast?.timeout);
}
FutureOr<void> error(String message, {ToastOption? toast}) {
snackbar.error(message, duration: toast?.timeout);
}
}
+2 -27
View File
@@ -68,28 +68,8 @@ class ActionService {
unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds)));
}
Future<void> favorite(List<String> remoteIds) async {
await _assetApiRepository.updateFavorite(remoteIds, true);
await _remoteAssetRepository.updateFavorite(remoteIds, true);
}
Future<void> unFavorite(List<String> remoteIds) async {
await _assetApiRepository.updateFavorite(remoteIds, false);
await _remoteAssetRepository.updateFavorite(remoteIds, false);
}
Future<void> archive(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.archive);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive);
}
Future<void> unArchive(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
}
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked);
await _assetApiRepository.updateVisibility(remoteIds, .locked);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
// Ask user if they want to delete local copies
@@ -99,7 +79,7 @@ class ActionService {
}
Future<void> removeFromLockFolder(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
}
@@ -108,11 +88,6 @@ class ActionService {
await _remoteAssetRepository.trash(remoteIds);
}
Future<void> restoreTrash(List<String> ids) async {
await _assetApiRepository.restoreTrash(ids);
await _remoteAssetRepository.restoreTrash(ids);
}
Future<int> emptyTrash(String userId) async {
final count = await _assetApiRepository.emptyTrash();
await _remoteAssetRepository.emptyTrash(userId);
+7 -16
View File
@@ -8,8 +8,10 @@ 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/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/archive.action.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/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/restore.action.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';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
@@ -21,7 +23,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_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/remove_from_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
@@ -29,8 +30,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act
import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_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/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -200,19 +199,11 @@ enum ActionButtonType {
menuItem: menuItem,
),
ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unarchive => UnArchiveActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.archive ||
ActionButtonType.unarchive => ActionMenuItemWidget(action: ArchiveAction(assets: [context.asset])),
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.restoreTrash => RestoreActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.restoreTrash => ActionMenuItemWidget(action: RestoreAction(assets: [context.asset])),
ActionButtonType.deletePermanent => DeletePermanentActionButton(
source: context.source,
iconOnly: iconOnly,
@@ -248,7 +239,7 @@ enum ActionButtonType {
menuItem: menuItem,
),
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unstack => ActionMenuItemWidget(action: StackAction(assets: [context.asset])),
ActionButtonType.openInBrowser => OpenInBrowserActionButton(
remoteId: context.asset.remoteId!,
origin: context.timelineOrigin,
+31
View File
@@ -0,0 +1,31 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
extension type const AssetFilter<T extends BaseAsset>(Iterable<T> assets) implements Iterable<T> {
AssetFilter<T> where(bool Function(T asset) test) => AssetFilter(assets.where(test));
AssetFilter<T> whereNot(bool Function(T asset) test) => AssetFilter(assets.where((asset) => !test(asset)));
AssetFilter<T> type(AssetType type) => where((asset) => asset.type == type);
AssetFilter<T> favorites() => where(_isFavorite);
AssetFilter<T> notFavorites() => whereNot(_isFavorite);
AssetFilter<RemoteAsset> remote() => AssetFilter(assets.whereType<RemoteAsset>());
AssetFilter<RemoteAsset> owned(String ownerId) => remote().where((asset) => asset.ownerId == ownerId);
AssetFilter<RemoteAsset> visibility(AssetVisibility visibility) => remote().where(_hasVisibility(visibility));
AssetFilter<RemoteAsset> notVisibility(AssetVisibility visibility) => remote().whereNot(_hasVisibility(visibility));
AssetFilter<RemoteAsset> archived() => visibility(.archive);
AssetFilter<RemoteAsset> notArchived() => notVisibility(.archive);
AssetFilter<RemoteAsset> stacked() => remote().where(_isStacked);
AssetFilter<RemoteAsset> notStacked() => remote().whereNot(_isStacked);
AssetFilter<RemoteAsset> trashed() => remote().where(_isTrashed);
AssetFilter<RemoteAsset> notTrashed() => remote().whereNot(_isTrashed);
AssetFilter<LocalAsset> local() => AssetFilter(assets.whereType<LocalAsset>());
AssetFilter<LocalAsset> backedUp() => local().where(_isBackedUp);
}
bool _isFavorite(BaseAsset asset) => asset.isFavorite;
bool _isStacked(RemoteAsset asset) => asset.isStacked;
bool _isTrashed(RemoteAsset asset) => asset.isTrashed;
bool _isBackedUp(LocalAsset asset) => asset.remoteAssetId != null;
bool Function(RemoteAsset asset) _hasVisibility(AssetVisibility visibility) =>
(asset) => asset.visibility == visibility;
+13
View File
@@ -1,3 +1,4 @@
import 'package:drift/drift.dart';
import 'package:openapi/api.dart' show Optional;
sealed class Option<T> {
@@ -21,6 +22,11 @@ sealed class Option<T> {
None() => null,
};
Option<U> map<U>(U Function(T value) f) => switch (this) {
Some(:final value) => Some(f(value)),
None() => None<U>(),
};
U fold<U>(U Function(T value) onSome, U Function() onNone) => switch (this) {
Some(:final value) => onSome(value),
None() => onNone(),
@@ -65,3 +71,10 @@ extension OptionToOptional<T> on Option<T> {
Some(:final value) => Optional.present(value),
};
}
extension OptionToDriftValue<T> on Option<T> {
Value<T> toDriftValue() => switch (this) {
Some(:final value) => Value(value),
None() => const Value.absent(),
};
}
+15 -7
View File
@@ -6,18 +6,23 @@ final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
class SnackbarManager {
const SnackbarManager();
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(String message, SnackbarType type) {
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(
String message,
SnackbarType type, {
Duration? duration,
}) {
final messenger = scaffoldMessengerKey.currentState;
final context = scaffoldMessengerKey.currentContext;
if (messenger == null || context == null) {
return null;
}
duration ??= const .new(seconds: 4);
messenger.hideCurrentSnackBar();
return messenger.showSnackBar(_build(context, message, type));
return messenger.showSnackBar(_build(context, message, type, duration));
}
SnackBar _build(BuildContext context, String message, SnackbarType type) {
SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration) {
final theme = Theme.of(context);
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
final (IconData icon, Color background, Color foreground) = switch (type) {
@@ -29,7 +34,7 @@ class SnackbarManager {
return SnackBar(
behavior: .floating,
backgroundColor: background,
duration: const .new(seconds: 4),
duration: duration,
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
content: Row(
children: [
@@ -48,11 +53,14 @@ class SnackbarManager {
);
}
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message) => show(message, .info);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message, {Duration? duration}) =>
show(message, .info, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message) => show(message, .success);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message, {Duration? duration}) =>
show(message, .success, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message) => show(message, .error);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message, {Duration? duration}) =>
show(message, .error, duration: duration);
}
const snackbar = SnackbarManager();
@@ -3,9 +3,9 @@ import 'package:immich_mobile/infrastructure/repositories/local_album.repository
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
@@ -15,6 +15,7 @@ import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.re
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/repositories/toast.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:mocktail/mocktail.dart';
@@ -50,6 +51,8 @@ class MockUserRepository extends Mock implements UserRepository {}
class MockPartnerRepository extends Mock implements PartnerRepository {}
class MockToastRepository extends Mock implements ToastRepository {}
// API Repos
class MockUserApiRepository extends Mock implements UserApiRepository {}
@@ -5,7 +5,15 @@ import '../../utils.dart';
class RemoteAssetFactory {
const RemoteAssetFactory();
static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) {
static RemoteAsset create({
String? id,
String? name,
String? ownerId,
bool isFavorite = false,
AssetVisibility visibility = AssetVisibility.timeline,
String? stackId,
DateTime? deletedAt,
}) {
id = TestUtils.uuid(id);
return RemoteAsset(
@@ -17,7 +25,10 @@ class RemoteAssetFactory {
createdAt: TestUtils.yesterday(),
updatedAt: TestUtils.now(),
isFavorite: isFavorite,
visibility: visibility,
stackId: stackId,
isEdited: false,
deletedAt: deletedAt,
);
}
}
+19
View File
@@ -18,6 +18,7 @@ class RepositoryMocks {
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
final trashedAsset = MockTrashedLocalAssetRepository();
final toast = MockToastRepository();
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
@@ -31,6 +32,7 @@ class RepositoryMocks {
localAsset.reset();
reset(trashedAsset);
nativeApi.reset();
reset(toast);
_stubLocalAlbumRepository();
_stubLocalAssetRepository();
_stubNativeSyncApi();
@@ -89,6 +91,10 @@ class ServiceMocks {
void _stubAssetService() {
when(asset.updateFavorite).thenAnswer((_) async {});
when(asset.stack).thenAnswer((_) async {});
when(asset.unstack).thenAnswer((_) async {});
when(asset.restoreTrash).thenAnswer((_) async {});
when(asset.updateVisibility).thenAnswer((_) async {});
}
}
@@ -96,6 +102,7 @@ void _registerFallbacks() {
registerFallbackValue(LocalAlbumFactory.create());
registerFallbackValue(LocalAssetFactory.create());
registerFallbackValue(Uint8List(0));
registerFallbackValue(AssetVisibility.timeline);
}
extension type const Stub<T extends Mock>(T mockedClass) {
@@ -167,6 +174,18 @@ extension type const UserServiceStub(MockUserService service) implements Stub<Mo
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
Future<void> Function() get updateFavorite =>
() => service.updateFavorite(any(), any());
Future<void> Function() get stack =>
() => service.stack(any(), any());
Future<void> Function() get unstack =>
() => service.unstack(any());
Future<void> Function() get restoreTrash =>
() => service.restoreTrash(any());
Future<void> Function() get updateVisibility =>
() => service.updateVisibility(any(), any());
}
extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<MockNativeSyncApi> {
@@ -0,0 +1,97 @@
import 'package:flutter_test/flutter_test.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/archive.action.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockAssetService assetService;
setUp(() async {
context = await PresentationContext.create();
assetService = context.service.asset.service;
});
tearDown(() {
context.dispose();
});
RemoteAsset owned({AssetVisibility visibility = AssetVisibility.timeline}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, visibility: visibility);
group('ArchiveAction', () {
testWidgets('archives the eligible owned assets', (tester) async {
final asset = owned();
await tester.pumpTestAction(context, ArchiveAction(assets: [asset]));
verify(() => assetService.updateVisibility([asset.id], AssetVisibility.archive)).called(1);
});
testWidgets('unarchive the eligible owned assets', (tester) async {
final asset = owned(visibility: AssetVisibility.archive);
await tester.pumpTestAction(context, ArchiveAction(assets: [asset]));
verify(() => assetService.updateVisibility([asset.id], AssetVisibility.timeline)).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(context, ArchiveAction(assets: [mine, theirs]));
verify(() => assetService.updateVisibility([mine.id], AssetVisibility.archive)).called(1);
});
testWidgets('batches every eligible owned asset into a single call', (tester) async {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, ArchiveAction(assets: [first, second]));
verify(() => assetService.updateVisibility([first.id, second.id], AssetVisibility.archive)).called(1);
});
testWidgets('skips owned assets already in the target state', (tester) async {
final stale = owned();
final alreadyArchived = owned(visibility: AssetVisibility.archive);
await tester.pumpTestAction(context, ArchiveAction(assets: [stale, alreadyArchived]));
verify(() => assetService.updateVisibility([stale.id], AssetVisibility.archive)).called(1);
});
testWidgets('reports the archived count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(context, ArchiveAction(assets: [owned(), owned()]));
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.archive_action_prompt(count: 2));
});
testWidgets('reports the unarchive count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(
context,
ArchiveAction(
assets: [
owned(visibility: AssetVisibility.archive),
owned(visibility: AssetVisibility.archive),
],
),
);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.unarchive_action_prompt(count: 2));
});
});
}
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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/favorite.action.dart';
import 'package:mocktail/mocktail.dart';
@@ -68,11 +68,22 @@ void main() {
verify(() => assetService.updateFavorite([stale.id], true)).called(1);
});
testWidgets('shows a confirmation snackbar on success', (tester) async {
await tester.pumpTestAction(context, FavoriteAction(assets: [owned()]));
await tester.pumpUntilFound(find.byType(SnackBar));
testWidgets('reports the favorite count through the toast repository', (tester) async {
final toast = context.repository.toast;
expect(find.byType(SnackBar), findsOneWidget);
await tester.pumpTestAction(context, FavoriteAction(assets: [owned(), owned()]));
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.favorite_action_prompt(count: 2));
});
testWidgets('reports the unfavorite count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(context, FavoriteAction(assets: [owned(isFavorite: true), owned(isFavorite: true)]));
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.unfavorite_action_prompt(count: 2));
});
});
}
@@ -0,0 +1,71 @@
import 'package:flutter_test/flutter_test.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/restore.action.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockAssetService assetService;
setUp(() async {
context = await PresentationContext.create();
assetService = context.service.asset.service;
});
tearDown(() {
context.dispose();
});
RemoteAsset owned({bool trashed = true}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, deletedAt: trashed ? DateTime(2020) : null);
group('RestoreAction', () {
testWidgets('restores the eligible owned trashed assets', (tester) async {
final asset = owned();
await tester.pumpTestAction(context, RestoreAction(assets: [asset]));
verify(() => assetService.restoreTrash([asset.id])).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create(deletedAt: DateTime(2020));
await tester.pumpTestAction(context, RestoreAction(assets: [mine, theirs]));
verify(() => assetService.restoreTrash([mine.id])).called(1);
});
testWidgets('skips owned assets that are not trashed', (tester) async {
final trashed = owned();
final live = owned(trashed: false);
await tester.pumpTestAction(context, RestoreAction(assets: [trashed, live]));
verify(() => assetService.restoreTrash([trashed.id])).called(1);
});
testWidgets('batches every eligible owned asset into a single call', (tester) async {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, RestoreAction(assets: [first, second]));
verify(() => assetService.restoreTrash([first.id, second.id])).called(1);
});
testWidgets('reports success through the toast repository with the restored count', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(context, RestoreAction(assets: [owned(), owned()]));
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.assets_restored_count(count: 2));
});
});
}
@@ -0,0 +1,98 @@
import 'package:flutter_test/flutter_test.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/stack.action.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockAssetService assetService;
setUp(() async {
context = await PresentationContext.create();
assetService = context.service.asset.service;
});
tearDown(() {
context.dispose();
});
RemoteAsset owned({String? stackId}) => RemoteAssetFactory.create(ownerId: context.currentUser.id, stackId: stackId);
group('StackAction', () {
testWidgets('stacks the eligible owned assets', (tester) async {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
});
testWidgets('unstacks the eligible owned assets', (tester) async {
final asset = owned(stackId: 'stack');
await tester.pumpTestAction(context, StackAction(assets: [asset]));
verify(() => assetService.unstack(['stack'])).called(1);
});
testWidgets('prioritizes stack when mixed state', (tester) async {
final first = owned();
final second = owned(stackId: 'stack');
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
final other = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(context, StackAction(assets: [mine, other, theirs]));
verify(() => assetService.stack(context.currentUser.id, [mine.id, other.id])).called(1);
});
testWidgets('unstacks every selected stack in a single call', (tester) async {
final first = owned(stackId: 'stack-1');
final second = owned(stackId: 'stack-2');
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
verify(() => assetService.unstack(['stack-1', 'stack-2'])).called(1);
});
testWidgets('reports the stacked count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(context, StackAction(assets: [owned(), owned()]));
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.stacked_assets_count(count: 2));
});
testWidgets('reports the unstacked count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(
context,
StackAction(
assets: [
owned(stackId: 'stack-1'),
owned(stackId: 'stack-2'),
],
),
);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.unstacked_assets_count(count: 2));
});
});
}
@@ -14,6 +14,7 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_ui/immich_ui.dart';
@@ -43,6 +44,7 @@ class PresentationContext {
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
assetServiceProvider.overrideWithValue(service.asset.service),
partnerServiceProvider.overrideWithValue(service.partner.service),
toastRepositoryProvider.overrideWithValue(repository.toast),
];
static Future<PresentationContext> create() async {
@@ -0,0 +1,200 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
import '../factories/local_asset_factory.dart';
import '../factories/remote_asset_factory.dart';
void main() {
group('AssetFilter', () {
group('type promotion', () {
test('a bare filter retains every BaseAsset', () {
final remoteAsset = RemoteAssetFactory.create();
final localAsset = LocalAssetFactory.create();
final AssetFilter<BaseAsset> filter = AssetFilter(<BaseAsset>[remoteAsset, localAsset]);
expect(filter.toList(), [remoteAsset, localAsset]);
});
test('remote keeps only remote assets', () {
final remoteAsset = RemoteAssetFactory.create();
final localAsset = LocalAssetFactory.create();
final AssetFilter<RemoteAsset> remoteOnly = AssetFilter(<BaseAsset>[remoteAsset, localAsset]).remote();
expect(remoteOnly.toList(), [remoteAsset]);
});
test('local keeps only local assets', () {
final remoteAsset = RemoteAssetFactory.create();
final localAsset = LocalAssetFactory.create();
final AssetFilter<LocalAsset> localOnly = AssetFilter(<BaseAsset>[remoteAsset, localAsset]).local();
expect(localOnly.toList(), [localAsset]);
});
test('owned promotes to RemoteAsset and drops local assets', () {
final remoteAsset = RemoteAssetFactory.create();
final localAsset = LocalAssetFactory.create();
final AssetFilter<RemoteAsset> remoteOnly = AssetFilter(<BaseAsset>[
remoteAsset,
localAsset,
]).owned(remoteAsset.ownerId);
expect(remoteOnly.toList(), [remoteAsset]);
});
test('backedUp promotes to LocalAsset and drops remote assets', () {
final syncedPhoto = LocalAssetFactory.create().copyWith(remoteId: 'remote');
final offlinePhoto = LocalAssetFactory.create();
final remotePhoto = RemoteAssetFactory.create();
final AssetFilter<LocalAsset> syncedPhotos = AssetFilter(<BaseAsset>[
syncedPhoto,
offlinePhoto,
remotePhoto,
]).backedUp();
expect(syncedPhotos.toList(), [syncedPhoto]);
});
});
group('named filters', () {
test('owned keeps only assets of the given owner', () {
final asset1 = RemoteAssetFactory.create();
final asset2 = RemoteAssetFactory.create();
final alexPhotos = AssetFilter([asset1, asset2]).owned(asset1.ownerId);
expect(alexPhotos.toList(), [asset1]);
});
test('favorites keeps only favorite assets', () {
final asset1 = RemoteAssetFactory.create(isFavorite: true);
final asset2 = RemoteAssetFactory.create(ownerId: asset1.ownerId);
final favorites = AssetFilter([asset1, asset2]).favorites();
expect(favorites.toList(), [asset1]);
});
test('type keeps only assets of the given type', () {
final image = RemoteAssetFactory.create();
final video = RemoteAssetFactory.create(ownerId: image.ownerId).copyWith(type: .video);
final videos = AssetFilter([image, video]).type(.video);
expect(videos.toList(), [video]);
});
test('visibility keeps only assets with the given visibility', () {
final locked = RemoteAssetFactory.create(visibility: AssetVisibility.locked);
final onTimeline = RemoteAssetFactory.create(ownerId: locked.ownerId);
final lockedPhotos = AssetFilter([locked, onTimeline]).visibility(.locked);
expect(lockedPhotos.toList(), [locked]);
});
test('archived keeps only archived assets', () {
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId);
final archivedPhotos = AssetFilter([archived, onTimeline]).archived();
expect(archivedPhotos.toList(), [archived]);
});
test('stacked keeps only assets belonging to a stack', () {
final stacked = RemoteAssetFactory.create(stackId: 'stack');
final loose = RemoteAssetFactory.create(ownerId: stacked.ownerId);
final stackedPhotos = AssetFilter([stacked, loose]).stacked();
expect(stackedPhotos.toList(), [stacked]);
});
});
group('inversion', () {
test('notArchived keeps every non-archived visibility', () {
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.timeline);
final hidden = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.hidden);
final locked = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.locked);
final visiblePhotos = AssetFilter([archived, onTimeline, hidden, locked]).notArchived();
expect(visiblePhotos.toSet(), {onTimeline, hidden, locked});
});
test('notVisibility keeps every asset not at the target visibility', () {
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.timeline);
final locked = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.locked);
final toArchive = AssetFilter([archived, onTimeline, locked]).notVisibility(.archive);
expect(toArchive.toSet(), {onTimeline, locked});
});
test('notStacked keeps only assets without a stack', () {
final stacked = RemoteAssetFactory.create(stackId: 'stack');
final loose = RemoteAssetFactory.create(ownerId: stacked.ownerId);
final loosePhotos = AssetFilter([stacked, loose]).notStacked();
expect(loosePhotos.toList(), [loose]);
});
test('whereNot inverts an arbitrary predicate', () {
final favorite = RemoteAssetFactory.create(isFavorite: true);
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
final nonFavorites = AssetFilter([favorite, regular]).whereNot((asset) => asset.isFavorite);
expect(nonFavorites.toList(), [regular]);
});
test('notFavorites keeps only non-favorite assets', () {
final favorite = RemoteAssetFactory.create(isFavorite: true);
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
final nonFavorites = AssetFilter([favorite, regular]).notFavorites();
expect(nonFavorites.toList(), [regular]);
});
});
group('chaining', () {
test('combines predicates across owner, visibility and stack', () {
final asset = RemoteAssetFactory.create();
final wrongOwner = RemoteAssetFactory.create();
final archived = RemoteAssetFactory.create(ownerId: asset.ownerId, visibility: AssetVisibility.archive);
final stacked = RemoteAssetFactory.create(ownerId: asset.ownerId, stackId: 'stack-1');
final localPhoto = LocalAssetFactory.create();
final result = AssetFilter(<BaseAsset>[
asset,
wrongOwner,
archived,
stacked,
localPhoto,
]).owned(asset.ownerId).notArchived().notStacked();
expect(result.toList(), [asset]);
});
test('a base filter after a promotion retains the promoted type', () {
final favorite = RemoteAssetFactory.create(isFavorite: true);
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
final AssetFilter<RemoteAsset> result = AssetFilter([favorite, regular]).owned(favorite.ownerId).favorites();
expect(result.toList(), [favorite]);
});
});
});
}
+33 -28
View File
@@ -7,17 +7,18 @@ from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
order by
"asset"."fileCreatedAt" desc
limit
$5
offset
$6
offset
$7
-- SearchRepository.searchStatistics
select
@@ -26,10 +27,11 @@ from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
-- SearchRepository.searchRandom
@@ -39,15 +41,16 @@ from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
order by
random()
limit
$5
$6
-- SearchRepository.searchLargeAssets
select
@@ -57,16 +60,17 @@ from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
and "asset_exif"."fileSizeInByte" > $5
and "asset_exif"."fileSizeInByte" > $6
order by
"asset_exif"."fileSizeInByte" desc
limit
$6
$7
-- SearchRepository.searchSmart
begin
@@ -79,17 +83,18 @@ from
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "smart_search" on "asset"."id" = "smart_search"."assetId"
where
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
order by
smart_search.embedding <=> $5
smart_search.embedding <=> $6
limit
$6
offset
$7
offset
$8
commit
-- SearchRepository.getEmbedding
+3 -4
View File
@@ -117,8 +117,7 @@ type BaseAssetSearchOptions = SearchDateOptions &
SearchAlbumOptions &
SearchOcrOptions;
export type AssetSearchOptions = Omit<BaseAssetSearchOptions, 'visibility'> &
SearchRelationOptions & { visibility?: AssetVisibility | 'not-locked' };
export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions;
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
@@ -126,11 +125,11 @@ export type SmartSearchOptions = SearchDateOptions &
SearchEmbeddingOptions &
SearchExifOptions &
SearchOneToOneRelationOptions &
Omit<SearchStatusOptions, 'visibility'> &
SearchStatusOptions &
SearchUserIdOptions &
SearchPeopleOptions &
SearchTagOptions &
SearchOcrOptions & { visibility?: AssetVisibility | 'not-locked' };
SearchOcrOptions;
export type OcrSearchOptions = SearchDateOptions & SearchOcrOptions;
+1 -1
View File
@@ -250,7 +250,7 @@ describe(SearchService.name, () => {
);
expect(mocks.search.searchSmart).toHaveBeenCalledWith(
{ page: 1, size: 100 },
{ query: 'test', embedding: '[1, 2, 3]', userIds: [authStub.user1.user.id], visibility: 'not-locked' },
{ query: 'test', embedding: '[1, 2, 3]', userIds: [authStub.user1.user.id] },
);
});
+3 -24
View File
@@ -73,22 +73,14 @@ export class SearchService extends BaseService {
checksum = Buffer.from(dto.checksum, encoding);
}
let userIds: string[] | undefined;
if (dto.albumIds && dto.albumIds.length > 0) {
await this.requireAccess({ auth, ids: dto.albumIds, permission: Permission.AlbumRead });
} else {
userIds = await this.getUserIdsToSearch(auth, dto.visibility);
}
const page = dto.page ?? 1;
const size = dto.size || 250;
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const { hasNextPage, items } = await this.searchRepository.searchMetadata(
{ page, size },
{
...dto,
checksum,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds,
orderDirection: dto.order ?? AssetOrder.Desc,
},
@@ -99,13 +91,9 @@ export class SearchService extends BaseService {
async searchStatistics(auth: AuthDto, dto: StatisticsSearchDto): Promise<SearchStatisticsResponseDto> {
const userIds = await this.getUserIdsToSearch(auth);
if (dto.visibility === AssetVisibility.Locked) {
requireElevatedPermission(auth);
}
return await this.searchRepository.searchStatistics({
...dto,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds,
});
}
@@ -126,11 +114,7 @@ export class SearchService extends BaseService {
}
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, {
...dto,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds,
});
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds });
return items.map((item) => mapAsset(item, { auth }));
}
@@ -171,12 +155,7 @@ export class SearchService extends BaseService {
const size = dto.size || 100;
const { hasNextPage, items } = await this.searchRepository.searchSmart(
{ page, size },
{
...dto,
userIds: await userIds,
embedding,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
},
{ ...dto, userIds: await userIds, embedding },
);
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
+2 -5
View File
@@ -373,15 +373,12 @@ const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline);
const visibility = options.visibility == null ? AssetVisibility.Timeline : options.visibility;
return kysely
.withPlugin(joinDeduplicationPlugin)
.selectFrom('asset')
.$if(!!options.visibility, (qb) =>
options.visibility === 'not-locked'
? qb.where('asset.visibility', '!=', AssetVisibility.Locked)
: qb.where('asset.visibility', '=', options.visibility!),
)
.where('asset.visibility', '=', visibility)
.$if(!!options.albumIds && options.albumIds.length > 0, (qb) => inAlbums(qb, options.albumIds!))
.$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!))
.$if(options.tagIds === null, (qb) =>
@@ -1,6 +1,5 @@
import { Kysely } from 'kysely';
import { SearchSuggestionType } from 'src/dtos/search.dto';
import { AlbumUserRole, AssetVisibility } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
@@ -109,71 +108,6 @@ describe(SearchService.name, () => {
expect(response.assets.items.length).toBe(1);
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
});
describe('visibility', () => {
it('should filter out locked assets in a default session', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, { withStacked: false });
expect(response.assets.items.length).toBe(0);
});
it('should return locked assets in an elevated session', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
const auth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } });
const response = await sut.searchMetadata(auth, { withStacked: false });
expect(response.assets.items.length).toBe(1);
});
});
});
describe('albumIds option', () => {
it('should return assets from shared album', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: otherUser.id });
const { album } = await ctx.newAlbum({ ownerId: otherUser.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.Editor });
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, { albumIds: [album.id] });
expect(response.assets.items.length).toBe(1);
});
it('should not return assets for album, a user is not in, when partner sharing is enabled', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
await ctx.newPartner({ sharedById: otherUser.id, sharedWithId: user.id });
const { asset } = await ctx.newAsset({ ownerId: otherUser.id });
const { album } = await ctx.newAlbum({ ownerId: otherUser.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
const auth = factory.auth({ user: { id: user.id } });
await expect(sut.searchMetadata(auth, { albumIds: [album.id] })).rejects.toThrow(
'Not found or no album.read access',
);
});
});
describe('getSearchSuggestions', () => {
+3 -3
View File
@@ -27,7 +27,7 @@ const authFactory = ({
user,
}: {
apiKey?: Partial<AuthApiKey>;
session?: { id?: string; hasElevatedPermission?: boolean };
session?: { id: string };
user?: Omit<
Partial<UserAdmin>,
'createdAt' | 'updatedAt' | 'deletedAt' | 'fileCreatedAt' | 'fileModifiedAt' | 'localDateTime' | 'profileChangedAt'
@@ -46,8 +46,8 @@ const authFactory = ({
if (session) {
auth.session = {
id: session.id ?? newUuid(),
hasElevatedPermission: session.hasElevatedPermission ?? false,
id: session.id,
hasElevatedPermission: false,
};
}