Compare commits

...

3 Commits

Author SHA1 Message Date
shenlong-tanwen b033a18ce0 feat: lock action 2026-07-09 23:03:59 +05:30
shenlong-tanwen f67629ff07 refactor: base action 2026-07-06 03:35:18 +05:30
shenlong-tanwen 7130553634 refactor: return AssetFilter from asset action filter 2026-07-01 22:29:04 +05:30
43 changed files with 722 additions and 739 deletions
@@ -56,6 +56,8 @@ class RemoteAsset extends BaseAsset {
bool get isArchived => visibility == .archive;
bool get isLocked => visibility == .locked;
@override
String toString() {
return '''Asset {
+8 -12
View File
@@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/utils/option.dart';
class AssetService {
final RemoteAssetRepository _remoteRepository;
@@ -69,15 +70,6 @@ class AssetService {
return _localRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
}
Future<void> updateFavorite(List<String> remoteIds, bool isFavorite) async {
if (remoteIds.isEmpty) {
return;
}
await _apiRepository.updateFavorite(remoteIds, isFavorite);
await _remoteRepository.updateFavorite(remoteIds, isFavorite);
}
Future<void> restoreTrash(List<String> remoteIds) async {
if (remoteIds.isEmpty) {
return;
@@ -105,12 +97,16 @@ class AssetService {
await _apiRepository.unStack(stackIds);
}
Future<void> updateVisibility(List<String> remoteIds, AssetVisibility visibility) async {
Future<void> update(
List<String> remoteIds, {
Option<bool> isFavorite = const .none(),
Option<AssetVisibility> visibility = const .none(),
}) async {
if (remoteIds.isEmpty) {
return;
}
await _apiRepository.updateVisibility(remoteIds, visibility);
await _remoteRepository.updateVisibility(remoteIds, visibility);
await _apiRepository.update(remoteIds, isFavorite: isFavorite, visibility: visibility);
await _remoteRepository.update(remoteIds, isFavorite: isFavorite, visibility: visibility);
}
}
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/partner.action.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
@@ -27,13 +28,14 @@ class PartnerPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final sharedByAsync = ref.watch(partnersStateProvider);
final scope = ActionScope.from(context, ref);
return Scaffold(
appBar: AppBar(
title: Text(context.t.partners),
elevation: 0,
centerTitle: false,
actions: const [ActionIconButtonWidget(action: PartnerAddAction())],
actions: [ActionIconButtonWidget(action: PartnerAddAction(scope: scope))],
),
body: sharedByAsync.when(
data: (partners) => PartnerSharedByList(partners: partners.toList(growable: false)),
@@ -44,11 +46,12 @@ class PartnerPage extends ConsumerWidget {
}
}
class _EmptyPartners extends StatelessWidget {
class _EmptyPartners extends ConsumerWidget {
const _EmptyPartners();
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final scope = ActionScope.from(context, ref);
return Padding(
padding: const .symmetric(horizontal: 16.0),
child: Column(
@@ -58,9 +61,9 @@ class _EmptyPartners extends StatelessWidget {
padding: const .symmetric(vertical: 8),
child: Text(context.t.partner_page_empty_message, style: const TextStyle(fontSize: 14)),
),
const Align(
Align(
alignment: .center,
child: ActionButtonWidget(action: PartnerAddAction()),
child: ActionButtonWidget(action: PartnerAddAction(scope: scope)),
),
],
),
@@ -69,16 +72,17 @@ class _EmptyPartners extends StatelessWidget {
}
@visibleForTesting
class PartnerSharedByList extends StatelessWidget {
class PartnerSharedByList extends ConsumerWidget {
const PartnerSharedByList({super.key, required this.partners});
final List<Partner> partners;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
if (partners.isEmpty) {
return const _EmptyPartners();
}
final scope = ActionScope.from(context, ref);
return ListView.builder(
itemCount: partners.length,
@@ -89,7 +93,7 @@ class PartnerSharedByList extends StatelessWidget {
title: Text(partner.name),
subtitle: Text(partner.email),
trailing: ActionIconButtonWidget(
action: PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
action: PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, scope: scope),
),
);
},
+18 -16
View File
@@ -1,7 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/providers/user.provider.dart';
class ActionScope {
final BuildContext context;
@@ -9,24 +11,24 @@ class ActionScope {
final UserDto authUser;
const ActionScope({required this.context, required this.ref, required this.authUser});
factory ActionScope.from(BuildContext context, WidgetRef ref) {
final authUser = ref.watch(currentUserProvider);
if (authUser == null) {
throw StateError('Auth user is not available in ActionScope');
}
return ActionScope(context: context, ref: ref, authUser: authUser);
}
}
abstract class BaseAction {
const BaseAction();
final ActionScope scope;
final IconData icon;
final String label;
final bool isVisible;
IconData get icon;
const BaseAction({required this.scope, required this.icon, required this.label, this.isVisible = true});
String label(ActionScope scope);
bool isVisible(ActionScope scope) => true;
Future<void> onAction(ActionScope scope);
}
abstract class AssetAction<T extends BaseAsset> extends BaseAction {
final Iterable<BaseAsset> assets;
const AssetAction({required this.assets});
Iterable<T> filter(ActionScope scope) => assets.whereType<T>();
Future<void> onAction();
}
@@ -1,16 +1,18 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/error_handler.dart';
import 'package:immich_ui/immich_ui.dart';
class _ActionWidgetScope {
final IconData icon;
final String label;
final VoidCallback onAction;
final FutureOr<void> Function() onAction;
const _ActionWidgetScope({required this.label, required this.onAction});
const _ActionWidgetScope({required this.icon, required this.label, required this.onAction});
}
class _ActionWidget extends ConsumerWidget {
@@ -19,27 +21,21 @@ class _ActionWidget extends ConsumerWidget {
const _ActionWidget({required this.action, required this.builder});
Future<void> _onAction(ActionScope scope) async {
Future<void> _onAction() async {
try {
await action.onAction(scope);
await action.onAction();
} catch (error, stackTrace) {
handleError(scope.context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
handleError(error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final authUser = ref.watch(currentUserProvider);
if (authUser == null) {
if (!action.isVisible) {
return const SizedBox.shrink();
}
final scope = ActionScope(context: context, ref: ref, authUser: authUser);
if (!action.isVisible(scope)) {
return const SizedBox.shrink();
}
return builder(.new(label: action.label(scope), onAction: () => _onAction(scope)));
return builder(.new(icon: action.icon, label: action.label, onAction: _onAction));
}
}
@@ -52,7 +48,7 @@ class ActionIconButtonWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichIconButton(icon: action.icon, onPressed: ctx.onAction, variant: variant),
builder: (ctx) => ImmichIconButton(icon: ctx.icon, onPressed: ctx.onAction, variant: variant),
);
}
@@ -65,8 +61,7 @@ class ActionButtonWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) =>
ImmichTextButton(labelText: ctx.label, icon: action.icon, onPressed: ctx.onAction, variant: variant),
builder: (ctx) => ImmichTextButton(labelText: ctx.label, icon: ctx.icon, onPressed: ctx.onAction, variant: variant),
);
}
@@ -78,7 +73,7 @@ class ActionColumnButtonWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichColumnButton(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
builder: (ctx) => ImmichColumnButton(icon: ctx.icon, label: ctx.label, onPressed: ctx.onAction),
);
}
@@ -90,6 +85,6 @@ class ActionMenuItemWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichMenuItem(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
builder: (ctx) => ImmichMenuItem(icon: ctx.icon, label: ctx.label, onPressed: ctx.onAction),
);
}
@@ -4,43 +4,44 @@ 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> {
class ArchiveAction extends BaseAction {
final List<String> assetIds;
final bool archive;
ArchiveAction({required super.assets})
: archive = assets.any((asset) => asset is RemoteAsset && asset.visibility != .archive);
const ArchiveAction._({
required this.assetIds,
required this.archive,
required super.scope,
required super.icon,
required super.label,
super.isVisible,
});
@override
IconData get icon => archive ? Icons.archive_outlined : Icons.unarchive_outlined;
factory ArchiveAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final ownedAssets = AssetFilter(assets).owned(scope.authUser.id);
final archive = ownedAssets.archived(isArchived: false).isNotEmpty;
final assetIds = ownedAssets.archived(isArchived: !archive).map((asset) => asset.id).toList(growable: false);
@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);
}
return ArchiveAction._(
assetIds: assetIds,
archive: archive,
scope: scope,
icon: archive ? Icons.archive_outlined : Icons.unarchive_outlined,
label: archive ? scope.context.t.archive : scope.context.t.unarchive,
isVisible: assetIds.isNotEmpty,
);
}
@override
bool isVisible(ActionScope scope) => !scope.ref.watch(inLockedViewProvider) && filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
Future<void> onAction() 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);
await ref.read(assetServiceProvider).update(assetIds, visibility: .some(archive ? .archive : .timeline));
final message = archive
? context.t.archive_action_prompt(count: assets.length)
: context.t.unarchive_action_prompt(count: assets.length);
? context.t.archive_action_prompt(count: assetIds.length)
: context.t.unarchive_action_prompt(count: assetIds.length);
ref.read(toastRepositoryProvider).success(message);
}
}
@@ -0,0 +1,31 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/actions/action.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/lock.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
class AssetActions {
final AssetDebugAction debug;
final FavoriteAction favorite;
final ArchiveAction archive;
final StackAction stack;
final LockAction lock;
const AssetActions({
required this.debug,
required this.favorite,
required this.archive,
required this.stack,
required this.lock,
});
static AssetActions from(ActionScope scope, List<BaseAsset> assets) => .new(
debug: AssetDebugAction(assets: assets, scope: scope),
favorite: FavoriteAction(assets: assets, scope: scope),
archive: ArchiveAction(assets: assets, scope: scope),
stack: StackAction(assets: assets, scope: scope),
lock: LockAction(assets: assets, scope: scope),
);
}
@@ -8,20 +8,18 @@ import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class AssetDebugAction extends AssetAction<BaseAsset> {
const AssetDebugAction({required super.assets});
class AssetDebugAction extends BaseAction {
final List<BaseAsset> asset;
AssetDebugAction._({this.asset = const [], required super.scope, super.isVisible})
: super(icon: Icons.help_outline_rounded, label: scope.context.t.troubleshoot);
factory AssetDebugAction({required Iterable<BaseAsset> assets, required ActionScope scope}) => AssetDebugAction._(
asset: assets.toList(growable: false),
scope: scope,
isVisible: scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting) && assets.length == 1,
);
@override
IconData get icon => Icons.help_outline_rounded;
@override
String label(ActionScope scope) => scope.context.t.troubleshoot;
@override
bool isVisible(ActionScope scope) =>
assets.length == 1 && scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
@override
Future<void> onAction(ActionScope scope) async =>
unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
Future<void> onAction() async => unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: asset.first)));
}
@@ -6,33 +6,42 @@ 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 FavoriteAction extends AssetAction<RemoteAsset> {
class FavoriteAction extends BaseAction {
final List<String> assetIds;
final bool favorite;
FavoriteAction({required super.assets}) : favorite = assets.any((asset) => !asset.isFavorite);
const FavoriteAction._({
required this.assetIds,
required this.favorite,
required super.scope,
required super.icon,
required super.label,
super.isVisible,
});
factory FavoriteAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final ownedAssets = AssetFilter(assets).owned(scope.authUser.id);
final favorite = ownedAssets.favorite(isFavorite: false).isNotEmpty;
final assetIds = ownedAssets.favorite(isFavorite: !favorite).map((asset) => asset.id).toList(growable: false);
return FavoriteAction._(
assetIds: assetIds,
favorite: favorite,
scope: scope,
icon: favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded,
label: favorite ? scope.context.t.favorite : scope.context.t.unfavorite,
isVisible: assetIds.isNotEmpty,
);
}
@override
IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
@override
String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite;
@override
Iterable<RemoteAsset> filter(ActionScope scope) =>
AssetFilter(assets).owned(scope.authUser.id).favorite(isFavorite: !favorite);
@override
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateFavorite(assets, favorite);
await ref.read(assetServiceProvider).update(assetIds, isFavorite: .some(favorite));
final message = favorite
? context.t.favorite_action_prompt(count: assets.length)
: context.t.unfavorite_action_prompt(count: assets.length);
? context.t.favorite_action_prompt(count: assetIds.length)
: context.t.unfavorite_action_prompt(count: assetIds.length);
ref.read(toastRepositoryProvider).success(message);
}
}
@@ -0,0 +1,47 @@
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 LockAction extends BaseAction {
final List<String> assetIds;
final bool lock;
const LockAction._({
required this.assetIds,
required this.lock,
required super.scope,
required super.icon,
required super.label,
super.isVisible,
});
factory LockAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final ownedAssets = AssetFilter(assets).owned(scope.authUser.id);
final lock = ownedAssets.locked(isLocked: false).isNotEmpty;
final assetIds = ownedAssets.locked(isLocked: !lock).map((asset) => asset.id).toList(growable: false);
return LockAction._(
assetIds: assetIds,
lock: lock,
scope: scope,
icon: lock ? Icons.lock_rounded : Icons.lock_open_rounded,
label: lock ? scope.context.t.move_to_locked_folder : scope.context.t.remove_from_locked_folder,
isVisible: assetIds.isNotEmpty,
);
}
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
await ref.read(assetServiceProvider).update(assetIds, visibility: .some(lock ? .locked : .timeline));
final message = lock
? scope.context.t.move_to_lock_folder_action_prompt(count: assetIds.length)
: scope.context.t.remove_from_lock_folder_action_prompt(count: assetIds.length);
ref.read(toastRepositoryProvider).success(message);
}
}
@@ -9,16 +9,10 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
class PartnerAddAction extends BaseAction {
const PartnerAddAction();
PartnerAddAction({required super.scope}) : super(icon: Icons.person_add_rounded, label: scope.context.t.add_partner);
@override
IconData get icon => Icons.person_add_rounded;
@override
String label(ActionScope scope) => scope.context.t.add_partner;
@override
Future<void> onAction(ActionScope scope) async {
Future<void> onAction() async {
final ActionScope(:context, :ref, :authUser) = scope;
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
if (selected == null) {
@@ -30,19 +24,14 @@ class PartnerAddAction extends BaseAction {
}
class PartnerRemoveAction extends BaseAction {
const PartnerRemoveAction({required this.sharedWithId, required this.partnerName});
PartnerRemoveAction({required super.scope, required this.sharedWithId, required this.partnerName})
: super(icon: Icons.person_remove_rounded, label: scope.context.t.remove);
final String sharedWithId;
final String partnerName;
@override
IconData get icon => Icons.person_remove_rounded;
@override
String label(ActionScope scope) => scope.context.t.remove;
@override
Future<void> onAction(ActionScope scope) async {
Future<void> onAction() async {
final ActionScope(:context, :ref, :authUser) = scope;
final confirmed = await showDialog<bool>(
@@ -6,26 +6,24 @@ 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});
class RestoreAction extends BaseAction {
final List<String> assetIds;
RestoreAction._({required super.scope, required this.assetIds, super.isVisible})
: super(icon: Icons.history_rounded, label: scope.context.t.restore);
factory RestoreAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final assetIds = AssetFilter(
assets,
).owned(scope.authUser.id).trashed().map((asset) => asset.id).toList(growable: false);
return RestoreAction._(assetIds: assetIds, scope: scope, isVisible: assetIds.isNotEmpty);
}
@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 {
Future<void> onAction() 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));
await ref.read(assetServiceProvider).restoreTrash(assetIds);
ref.read(toastRepositoryProvider).success(context.t.assets_restored_count(count: assetIds.length));
}
}
@@ -6,38 +6,51 @@ 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> {
class StackAction extends BaseAction {
final List<String> assetIds;
final List<String> stackIds;
final bool stack;
StackAction({required super.assets}) : stack = assets.any((asset) => asset is RemoteAsset && asset.stackId == null);
const StackAction._({
required this.assetIds,
required this.stackIds,
required this.stack,
required super.scope,
required super.icon,
required super.label,
super.isVisible,
});
factory StackAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final ownedAssets = AssetFilter(assets).owned(scope.authUser.id);
// Stack when any owned asset is not yet stacked; otherwise unstack them all.
final stack = ownedAssets.stacked(isStacked: false).isNotEmpty;
final assetIds = ownedAssets.map((asset) => asset.id).toList(growable: false);
final stackIds = ownedAssets.map((asset) => asset.stackId).nonNulls.toList(growable: false);
return StackAction._(
assetIds: assetIds,
stackIds: stackIds,
stack: stack,
scope: scope,
icon: stack ? Icons.filter_none_rounded : Icons.layers_clear_outlined,
label: stack ? scope.context.t.stack : scope.context.t.unstack,
isVisible: stack ? assetIds.length > 1 : stackIds.isNotEmpty,
);
}
@override
IconData get icon => stack ? Icons.filter_none_rounded : Icons.layers_clear_outlined;
@override
String label(ActionScope scope) => stack ? 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) => stack ? filter(scope).length > 1 : filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
final assets = filter(scope).toList(growable: false);
final service = ref.read(assetServiceProvider);
if (stack) {
await service.stack(scope.authUser.id, assets.map((asset) => asset.id).toList(growable: false));
await ref.read(assetServiceProvider).stack(scope.authUser.id, assetIds);
} else {
await service.unstack(assets.map((asset) => asset.stackId).nonNulls.toList(growable: false));
await ref.read(assetServiceProvider).unstack(stackIds);
}
final message = stack
? context.t.stacked_assets_count(count: assets.length)
: context.t.unstacked_assets_count(count: assets.length);
? context.t.stacked_assets_count(count: assetIds.length)
: context.t.unstacked_assets_count(count: assetIds.length);
ref.read(toastRepositoryProvider).success(message);
}
}
@@ -1,24 +1,15 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class TimelineAction extends BaseAction {
final BaseAction action;
const TimelineAction({required this.action});
TimelineAction({required this.action})
: super(scope: action.scope, icon: action.icon, label: action.label, isVisible: action.isVisible);
@override
IconData get icon => action.icon;
@override
String label(ActionScope scope) => action.label(scope);
@override
bool isVisible(ActionScope scope) => action.isVisible(scope);
@override
Future<void> onAction(ActionScope scope) async {
await action.onAction(scope);
Future<void> onAction() async {
await action.onAction();
scope.ref.read(multiSelectProvider.notifier).reset();
}
}
@@ -5,10 +5,11 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.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/lock.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';
@@ -18,7 +19,7 @@ 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, lockedFolder }
enum AddToMenuItem { album }
class AddActionButton extends ConsumerStatefulWidget {
const AddActionButton({super.key, this.originalTheme});
@@ -35,9 +36,6 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
case AddToMenuItem.album:
_openAlbumSelector();
break;
case AddToMenuItem.lockedFolder:
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
break;
}
}
@@ -49,6 +47,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
final user = ref.read(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final scope = ActionScope.from(context, ref);
return [
Padding(
@@ -68,12 +67,11 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
),
ActionMenuItemWidget(action: ArchiveAction(assets: [asset])),
BaseActionButton(
iconData: Icons.lock_outline,
label: "locked_folder".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.lockedFolder),
ActionMenuItemWidget(
action: ArchiveAction(assets: [asset], scope: scope),
),
ActionMenuItemWidget(
action: LockAction(assets: [asset], scope: scope),
),
],
];
@@ -1,63 +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';
// Reusable helper: move to locked folder from any source (e.g called from menu)
Future<void> performMoveToLockFolderAction(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).moveToLockFolder(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'move_to_lock_folder_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 MoveToLockFolderActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const MoveToLockFolderActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
await performMoveToLockFolderAction(context, ref, source: source);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
maxWidth: 115.0,
iconData: Icons.lock_outline_rounded,
label: "move_to_locked_folder".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,57 +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 RemoveFromLockFolderActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const RemoveFromLockFolderActionButton({
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).removeFromLockFolder(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'remove_from_lock_folder_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(
maxWidth: 100.0,
iconData: Icons.lock_open_rounded,
label: "remove_from_locked_folder".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -4,6 +4,7 @@ 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.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';
@@ -44,8 +45,10 @@ class ViewerBottomBar extends ConsumerWidget {
final originalTheme = context.themeData;
final assets = [asset];
final scope = ActionScope.from(context, ref);
final restore = RestoreAction(assets: assets, scope: scope);
final actions = <Widget>[
ActionColumnButtonWidget(action: RestoreAction(assets: assets)),
if (restore.isVisible) ActionColumnButtonWidget(action: restore),
const ShareActionButton(source: ActionSource.viewer),
if (!isInLockedView) ...[
@@ -50,7 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget {
timelineOrigin: timelineOrigin,
);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
return ImmichMenu(
consumeOutsideTap: true,
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
@@ -44,6 +45,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final originalTheme = context.themeData;
final assetForAction = [asset];
final scope = ActionScope.from(context, ref);
final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
@@ -61,7 +63,9 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
},
),
ActionIconButtonWidget(action: FavoriteAction(assets: assetForAction)),
ActionIconButtonWidget(
action: FavoriteAction(assets: assetForAction, scope: scope),
),
ViewerKebabMenu(originalTheme: originalTheme),
];
@@ -3,17 +3,15 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.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/asset_actions.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/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/trash_action_button.widget.dart';
@@ -75,8 +73,8 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets), ArchiveAction(assets: assets), StackAction(assets: assets)];
final scope = ActionScope.from(context, ref);
final actions = AssetActions.from(scope, multiselect.selectedAssets.toList(growable: false));
return BaseBottomSheet(
controller: sheetController,
@@ -87,14 +85,18 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
...[
actions.favorite,
actions.archive,
actions.stack,
actions.lock,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
],
@@ -4,17 +4,15 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.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/asset_actions.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/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/trash_action_button.widget.dart';
@@ -66,8 +64,8 @@ class FavoriteBottomSheet extends ConsumerWidget {
ref.read(multiSelectProvider.notifier).reset();
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets), ArchiveAction(assets: assets), StackAction(assets: assets)];
final scope = ActionScope.from(context, ref);
final actions = AssetActions.from(scope, multiselect.selectedAssets.toList(growable: false));
return BaseBottomSheet(
initialChildSize: 0.4,
@@ -77,14 +75,18 @@ class FavoriteBottomSheet extends ConsumerWidget {
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
...[
actions.favorite,
actions.archive,
actions.stack,
actions.lock,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
],
@@ -3,11 +3,9 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.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/asset_actions.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.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';
@@ -16,7 +14,6 @@ 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/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/trash_action_button.widget.dart';
@@ -82,13 +79,8 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [
AssetDebugAction(assets: assets),
FavoriteAction(assets: assets),
ArchiveAction(assets: assets),
StackAction(assets: assets),
];
final scope = ActionScope.from(context, ref);
final actions = AssetActions.from(scope, multiselect.selectedAssets.toList(growable: false));
return BaseBottomSheet(
controller: sheetController,
@@ -97,7 +89,13 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
...[
actions.debug,
actions.favorite,
actions.archive,
actions.stack,
actions.lock,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
@@ -108,7 +106,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.onlyLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
],
if (multiselect.onlyLocal || multiselect.hasMerged)
@@ -1,26 +1,36 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_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/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class LockedFolderBottomSheet extends ConsumerWidget {
const LockedFolderBottomSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return const BaseBottomSheet(
final multiselect = ref.watch(multiSelectProvider);
final scope = ActionScope.from(context, ref);
return BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.4,
shouldCloseOnMinExtent: false,
actions: [
ShareActionButton(source: ActionSource.timeline),
DownloadActionButton(source: ActionSource.timeline),
DeletePermanentActionButton(source: ActionSource.timeline),
RemoveFromLockFolderActionButton(source: ActionSource.timeline),
const ShareActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
const DeletePermanentActionButton(source: ActionSource.timeline),
ActionColumnButtonWidget(
action: TimelineAction(
action: LockAction(assets: multiselect.selectedAssets.toList(growable: false), scope: scope),
),
),
],
);
}
@@ -3,17 +3,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.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/asset_actions.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
@@ -84,8 +82,8 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets), ArchiveAction(assets: assets), StackAction(assets: assets)];
final scope = ActionScope.from(context, ref);
final actions = AssetActions.from(scope, multiselect.selectedAssets.toList(growable: false));
return BaseBottomSheet(
controller: sheetController,
@@ -99,7 +97,12 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const ShareLinkActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
...[
actions.favorite,
actions.archive,
actions.stack,
actions.lock,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
],
const DownloadActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
@@ -108,7 +111,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
],
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
@@ -2,6 +2,7 @@ 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.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';
@@ -14,6 +15,7 @@ class TrashBottomBar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = ref.watch(multiSelectProvider.select((s) => s.selectedAssets)).toList(growable: false);
final scope = ActionScope.from(context, ref);
return Align(
alignment: Alignment.bottomCenter,
@@ -27,7 +29,9 @@ class TrashBottomBar extends ConsumerWidget {
children: [
const DeleteTrashActionButton(source: ActionSource.timeline),
ActionColumnButtonWidget(
action: TimelineAction(action: RestoreAction(assets: assets)),
action: TimelineAction(
action: RestoreAction(assets: assets, scope: scope),
),
),
],
),
@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -8,20 +7,17 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
@@ -49,7 +45,6 @@ class ActionNotifier extends Notifier<void> {
late ActionService _service;
late ForegroundUploadService _foregroundUploadService;
late DownloadService _downloadService;
late AssetService _assetService;
ActionNotifier() : super();
@@ -57,7 +52,6 @@ class ActionNotifier extends Notifier<void> {
void build() {
_foregroundUploadService = ref.watch(foregroundUploadServiceProvider);
_service = ref.watch(actionServiceProvider);
_assetService = ref.watch(assetServiceProvider);
_downloadService = ref.watch(downloadServiceProvider);
_downloadService.onImageDownloadStatus = _downloadImageCallback;
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
@@ -110,21 +104,6 @@ class ActionNotifier extends Notifier<void> {
return _getAssets(source).whereType<RemoteAsset>().ownedAssets(ownerId).toIds().toList(growable: false);
}
List<RemoteAsset> _getOwnedRemoteAssetsForSource(ActionSource source) {
final ownerId = ref.read(currentUserProvider)?.id;
return _getIdsForSource<RemoteAsset>(source).ownedAssets(ownerId).toList();
}
Iterable<T> _getIdsForSource<T extends BaseAsset>(ActionSource source) {
final Set<BaseAsset> assets = _getAssets(source);
return switch (T) {
const (RemoteAsset) => assets.whereType<RemoteAsset>(),
const (LocalAsset) => assets.whereType<LocalAsset>(),
_ => const [],
}
as Iterable<T>;
}
Set<BaseAsset> _getAssets(ActionSource source) {
return switch (source) {
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
@@ -135,16 +114,6 @@ class ActionNotifier extends Notifier<void> {
};
}
Future<ActionResult> troubleshoot(ActionSource source, BuildContext context) async {
final assets = _getAssets(source);
if (assets.length > 1) {
return ActionResult(count: assets.length, success: false, error: 'Cannot troubleshoot multiple assets');
}
unawaited(context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
return ActionResult(count: assets.length, success: true);
}
Future<ActionResult> shareLink(ActionSource source, BuildContext context) async {
final ids = _getRemoteIdsForSource(source);
try {
@@ -156,29 +125,6 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> moveToLockFolder(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
final localIds = _getLocalIdsForSource(source, ignoreLocalOnly: true);
try {
await _service.moveToLockFolder(ids, localIds);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to move assets to lock folder', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> removeFromLockFolder(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.removeFromLockFolder(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to remove assets from lock folder', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> trash(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
@@ -431,35 +377,6 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> stack(String userId, ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.stack(userId, ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to stack assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> unStack(ActionSource source) async {
final assets = _getOwnedRemoteAssetsForSource(source);
try {
await _service.unStack(assets.map((e) => e.stackId).nonNulls.toList());
if (source == ActionSource.viewer) {
final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
if (updatedParent != null) {
ref.read(assetViewerProvider.notifier).setAsset(updatedParent);
}
}
return ActionResult(count: assets.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to unstack assets', error, stack);
return ActionResult(count: assets.length, success: false);
}
}
Future<ActionResult> shareAssets(
ActionSource source,
BuildContext context, {
@@ -47,10 +47,6 @@ class AssetApiRepository extends ApiRepository {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility))));
}
Future<void> updateFavorite(List<String> ids, bool isFavorite) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, isFavorite: Optional.present(isFavorite)));
}
Future<void> updateLocation(List<String> ids, LatLng location) async {
return _api.updateAssets(
AssetBulkUpdateDto(
+36 -28
View File
@@ -1,17 +1,20 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/presentation/actions/action.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/stack.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/restore.action.dart';
import 'package:immich_mobile/presentation/actions/stack.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';
@@ -19,10 +22,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_a
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_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/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/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';
@@ -185,13 +186,18 @@ enum ActionButtonType {
}
Widget buildButton(
ActionButtonContext context, [
BuildContext? buildContext,
ActionButtonContext context,
BuildContext buildContext,
WidgetRef ref, [
bool iconOnly = false,
bool menuItem = false,
]) {
final scope = ActionScope.from(buildContext, ref);
return switch (this) {
ActionButtonType.advancedInfo => ActionMenuItemWidget(action: AssetDebugAction(assets: [context.asset])),
ActionButtonType.advancedInfo => ActionMenuItemWidget(
action: AssetDebugAction(assets: [context.asset], scope: scope),
),
ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.shareLink => ShareLinkActionButton(
source: context.source,
@@ -199,26 +205,25 @@ enum ActionButtonType {
menuItem: menuItem,
),
ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.archive ||
ActionButtonType.unarchive => ActionMenuItemWidget(action: ArchiveAction(assets: [context.asset])),
ActionButtonType.archive || ActionButtonType.unarchive => ActionMenuItemWidget(
action: ArchiveAction(assets: [context.asset], scope: scope),
),
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.restoreTrash => ActionMenuItemWidget(action: RestoreAction(assets: [context.asset])),
ActionButtonType.restoreTrash => ActionMenuItemWidget(
action: RestoreAction(assets: [context.asset], scope: scope),
),
ActionButtonType.deletePermanent => DeletePermanentActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.delete => DeleteActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.moveToLockFolder => ActionMenuItemWidget(
action: LockAction(assets: [context.asset], scope: scope),
),
ActionButtonType.removeFromLockFolder => RemoveFromLockFolderActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.removeFromLockFolder => ActionMenuItemWidget(
action: LockAction(assets: [context.asset], scope: scope),
),
ActionButtonType.deleteLocal => DeleteLocalActionButton(
source: context.source,
@@ -239,7 +244,9 @@ enum ActionButtonType {
menuItem: menuItem,
),
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unstack => ActionMenuItemWidget(action: StackAction(assets: [context.asset])),
ActionButtonType.unstack => ActionMenuItemWidget(
action: StackAction(assets: [context.asset], scope: scope),
),
ActionButtonType.openInBrowser => OpenInBrowserActionButton(
remoteId: context.asset.remoteId!,
origin: context.timelineOrigin,
@@ -267,12 +274,10 @@ enum ActionButtonType {
iconData: Icons.image_search,
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: buildContext == null
? null
: () async {
await buildContext.router.navigate(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
},
onPressed: () async {
await buildContext.router.navigate(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
},
),
ActionButtonType.cast => CastActionButton(iconOnly: iconOnly, menuItem: menuItem),
};
@@ -317,11 +322,14 @@ class ActionButtonBuilder {
ActionButtonType.deletePermanent,
};
static List<Widget> build(ActionButtonContext context) {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
static List<Widget> build(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
return _actionTypes
.where((type) => type.shouldShow(context))
.map((type) => type.buildButton(context, buildContext, ref))
.toList();
}
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) {
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
final visibleButtons = defaultViewerKebabMenuOrder
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
.toList();
@@ -337,7 +345,7 @@ class ActionButtonBuilder {
if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
result.add(const Divider(height: 1));
}
result.add(type.buildButton(context, buildContext, false, true));
result.add(type.buildButton(context, buildContext, ref, false, true));
lastGroup = type.kebabMenuGroup;
}
+1
View File
@@ -15,6 +15,7 @@ extension type const AssetFilter<T extends BaseAsset>(Iterable<T> assets) implem
remote().where((asset) => asset.visibility != visibility);
AssetFilter<RemoteAsset> archived({bool isArchived = true}) =>
remote().where((asset) => asset.isArchived == isArchived);
AssetFilter<RemoteAsset> locked({bool isLocked = true}) => remote().where((asset) => asset.isLocked == isLocked);
AssetFilter<RemoteAsset> stacked({bool isStacked = true}) => remote().where((asset) => asset.isStacked == isStacked);
AssetFilter<RemoteAsset> trashed({bool isTrashed = true}) => remote().where((asset) => asset.isTrashed == isTrashed);
+3 -7
View File
@@ -8,7 +8,7 @@ import 'package:openapi/api.dart';
// ignore: depend_on_referenced_packages
import 'package:stack_trace/stack_trace.dart';
void handleError(BuildContext context, Object error, {StackTrace? stack, String? description}) {
void handleError(Object error, {StackTrace? stack, String? description}) {
String? stackTrace;
if (stack != null) {
final trace = Trace.from(stack);
@@ -23,17 +23,13 @@ void handleError(BuildContext context, Object error, {StackTrace? stack, String?
() => 'Error${description != null ? ' ($description)' : ''}: $error${stackTrace != null ? '\n$stackTrace' : ''}',
);
if (!context.mounted) {
return;
}
final String message;
if (serverErrorMessage(error) case String serverMessage) {
message = serverMessage;
} else if (isConnectionError(error)) {
message = context.t.login_form_server_error;
message = StaticTranslations.instance.login_form_server_error;
} else {
message = context.t.scaffold_body_error_occurred;
message = StaticTranslations.instance.scaffold_body_error_occurred;
}
snackbar.error(message);
+10 -7
View File
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:mocktail/mocktail.dart' as mock;
import 'package:mocktail/mocktail.dart';
@@ -90,11 +91,10 @@ class ServiceMocks {
}
void _stubAssetService() {
when(asset.updateFavorite).thenAnswer((_) async {});
when(asset.update).thenAnswer((_) async {});
when(asset.stack).thenAnswer((_) async {});
when(asset.unstack).thenAnswer((_) async {});
when(asset.restoreTrash).thenAnswer((_) async {});
when(asset.updateVisibility).thenAnswer((_) async {});
}
}
@@ -103,6 +103,8 @@ void _registerFallbacks() {
registerFallbackValue(LocalAssetFactory.create());
registerFallbackValue(Uint8List(0));
registerFallbackValue(AssetVisibility.timeline);
registerFallbackValue(const Option<bool>.none());
registerFallbackValue(const Option<AssetVisibility>.none());
}
extension type const Stub<T extends Mock>(T mockedClass) {
@@ -172,8 +174,12 @@ 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 update =>
() => service.update(
any(),
isFavorite: any(named: 'isFavorite'),
visibility: any(named: 'visibility'),
);
Future<void> Function() get stack =>
() => service.stack(any(), any());
@@ -183,9 +189,6 @@ extension type const AssetServiceStub(MockAssetService service) implements Stub<
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> {
@@ -1,7 +1,9 @@
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/archive.action.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
@@ -27,51 +29,60 @@ void main() {
group('ArchiveAction', () {
testWidgets('archives the eligible owned assets', (tester) async {
final asset = owned();
final action = await tester.pumpTestAction(context, (scope) => ArchiveAction(assets: [asset], scope: scope));
await tester.pumpTestAction(context, ArchiveAction(assets: [asset]));
expect(action.icon, Icons.archive_outlined);
expect(action.label, StaticTranslations.instance.archive);
verify(() => assetService.updateVisibility([asset.id], AssetVisibility.archive)).called(1);
verify(() => assetService.update([asset.id], visibility: const Some(AssetVisibility.archive))).called(1);
});
testWidgets('unarchive the eligible owned assets', (tester) async {
final asset = owned(visibility: AssetVisibility.archive);
final asset = owned(visibility: .archive);
final action = await tester.pumpTestAction(context, (scope) => ArchiveAction(assets: [asset], scope: scope));
await tester.pumpTestAction(context, ArchiveAction(assets: [asset]));
expect(action.icon, Icons.unarchive_outlined);
expect(action.label, StaticTranslations.instance.unarchive);
verify(() => assetService.updateVisibility([asset.id], AssetVisibility.timeline)).called(1);
verify(() => assetService.update([asset.id], visibility: const Some(AssetVisibility.timeline))).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
final mine = owned(visibility: .archive);
final theirs = RemoteAssetFactory.create();
final action = await tester.pumpTestAction(
context,
(scope) => ArchiveAction(assets: [mine, theirs], scope: scope),
);
expect(action.label, StaticTranslations.instance.unarchive);
await tester.pumpTestAction(context, ArchiveAction(assets: [mine, theirs]));
verify(() => assetService.updateVisibility([mine.id], AssetVisibility.archive)).called(1);
verify(() => assetService.update([mine.id], visibility: const Some(AssetVisibility.timeline))).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]));
await tester.pumpTestAction(context, (scope) => ArchiveAction(assets: [first, second], scope: scope));
verify(() => assetService.updateVisibility([first.id, second.id], AssetVisibility.archive)).called(1);
verify(
() => assetService.update([first.id, second.id], visibility: const Some(AssetVisibility.archive)),
).called(1);
});
testWidgets('skips owned assets already in the target state', (tester) async {
testWidgets('archives only the owned assets not already archived', (tester) async {
final stale = owned();
final alreadyArchived = owned(visibility: AssetVisibility.archive);
final alreadyArchived = owned(visibility: .archive);
await tester.pumpTestAction(context, ArchiveAction(assets: [stale, alreadyArchived]));
await tester.pumpTestAction(context, (scope) => ArchiveAction(assets: [stale, alreadyArchived], scope: scope));
verify(() => assetService.updateVisibility([stale.id], AssetVisibility.archive)).called(1);
verify(() => assetService.update([stale.id], visibility: const Some(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()]));
await tester.pumpTestAction(context, (scope) => ArchiveAction(assets: [owned(), owned()], scope: scope));
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.archive_action_prompt(count: 2));
@@ -82,11 +93,12 @@ void main() {
await tester.pumpTestAction(
context,
ArchiveAction(
(scope) => ArchiveAction(
assets: [
owned(visibility: AssetVisibility.archive),
owned(visibility: AssetVisibility.archive),
owned(visibility: .archive),
owned(visibility: .archive),
],
scope: scope,
),
);
@@ -1,7 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_ui/immich_ui.dart';
@@ -22,20 +21,18 @@ void main() {
group('AssetDebugAction', () {
testWidgets('visible for a single asset when advanced troubleshooting is on', (tester) async {
await tester.pumpTestWidget(
await tester.pumpActionButton(
context,
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
(scope) => AssetDebugAction(assets: [RemoteAssetFactory.create()], scope: scope),
);
expect(find.byType(ImmichIconButton), findsOneWidget);
});
testWidgets('hidden for multiple assets', (tester) async {
await tester.pumpTestWidget(
await tester.pumpActionButton(
context,
ActionIconButtonWidget(
action: AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]),
),
(scope) => AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()], scope: scope),
);
expect(find.byType(ImmichIconButton), findsNothing);
@@ -43,9 +40,9 @@ void main() {
testWidgets('hidden when advanced troubleshooting is off', (tester) async {
await StoreService.I.put(StoreKey.advancedTroubleshooting, false);
await tester.pumpTestWidget(
await tester.pumpActionButton(
context,
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
(scope) => AssetDebugAction(assets: [RemoteAssetFactory.create()], scope: scope),
);
expect(find.byType(ImmichIconButton), findsNothing);
@@ -1,7 +1,9 @@
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:immich_mobile/utils/option.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
@@ -27,51 +29,58 @@ void main() {
group('FavoriteAction', () {
testWidgets('favorites the eligible owned assets', (tester) async {
final asset = owned();
final action = await tester.pumpTestAction(context, (scope) => FavoriteAction(assets: [asset], scope: scope));
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
expect(action.icon, Icons.favorite_border_rounded);
expect(action.label, StaticTranslations.instance.favorite);
verify(() => assetService.updateFavorite([asset.id], true)).called(1);
verify(() => assetService.update([asset.id], isFavorite: const Some(true))).called(1);
});
testWidgets('unfavorite the eligible owned assets', (tester) async {
final asset = owned(isFavorite: true);
final action = await tester.pumpTestAction(context, (scope) => FavoriteAction(assets: [asset], scope: scope));
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
expect(action.icon, Icons.favorite_rounded);
expect(action.label, StaticTranslations.instance.unfavorite);
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
verify(() => assetService.update([asset.id], isFavorite: const Some(false))).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
final mine = owned(isFavorite: true);
final theirs = RemoteAssetFactory.create();
final action = await tester.pumpTestAction(
context,
(scope) => FavoriteAction(assets: [mine, theirs], scope: scope),
);
expect(action.label, StaticTranslations.instance.unfavorite);
await tester.pumpTestAction(context, FavoriteAction(assets: [mine, theirs]));
verify(() => assetService.updateFavorite([mine.id], true)).called(1);
verify(() => assetService.update([mine.id], isFavorite: const Some(false))).called(1);
});
testWidgets('batches every eligible owned asset into a single call', (tester) async {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, FavoriteAction(assets: [first, second]));
await tester.pumpTestAction(context, (scope) => FavoriteAction(assets: [first, second], scope: scope));
verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1);
verify(() => assetService.update([first.id, second.id], isFavorite: const Some(true))).called(1);
});
testWidgets('skips owned assets already in the target state', (tester) async {
testWidgets('favorites only the owned assets not already favorite', (tester) async {
final stale = owned();
final alreadyFavorite = owned(isFavorite: true);
await tester.pumpTestAction(context, FavoriteAction(assets: [stale, alreadyFavorite]));
await tester.pumpTestAction(context, (scope) => FavoriteAction(assets: [stale, alreadyFavorite], scope: scope));
verify(() => assetService.updateFavorite([stale.id], true)).called(1);
verify(() => assetService.update([stale.id], isFavorite: const Some(true))).called(1);
});
testWidgets('reports the favorite count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(context, FavoriteAction(assets: [owned(), owned()]));
await tester.pumpTestAction(context, (scope) => FavoriteAction(assets: [owned(), owned()], scope: scope));
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.favorite_action_prompt(count: 2));
@@ -80,7 +89,10 @@ void main() {
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)]));
await tester.pumpTestAction(
context,
(scope) => FavoriteAction(assets: [owned(isFavorite: true), owned(isFavorite: true)], scope: scope),
);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.unfavorite_action_prompt(count: 2));
@@ -0,0 +1,104 @@
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/lock.action.dart';
import 'package:immich_mobile/utils/option.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 = .timeline}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, visibility: visibility);
group('LockAction', () {
testWidgets('locks the eligible owned assets', (tester) async {
final asset = owned();
final action = await tester.pumpTestAction(context, (scope) => LockAction(assets: [asset], scope: scope));
expect(action.icon, Icons.lock_rounded);
expect(action.label, StaticTranslations.instance.move_to_locked_folder);
verify(() => assetService.update([asset.id], visibility: const Some(.locked))).called(1);
});
testWidgets('unlocks the eligible owned assets', (tester) async {
final asset = owned(visibility: .locked);
final action = await tester.pumpTestAction(context, (scope) => LockAction(assets: [asset], scope: scope));
expect(action.icon, Icons.lock_open_rounded);
expect(action.label, StaticTranslations.instance.remove_from_locked_folder);
verify(() => assetService.update([asset.id], visibility: const Some(.timeline))).called(1);
});
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
final mine = owned(visibility: .locked);
final theirs = RemoteAssetFactory.create();
final action = await tester.pumpTestAction(context, (scope) => LockAction(assets: [mine, theirs], scope: scope));
expect(action.label, StaticTranslations.instance.remove_from_locked_folder);
verify(() => assetService.update([mine.id], visibility: const Some(.timeline))).called(1);
});
testWidgets('batches every eligible owned asset into a single call', (tester) async {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, (scope) => LockAction(assets: [first, second], scope: scope));
verify(() => assetService.update([first.id, second.id], visibility: const Some(.locked))).called(1);
});
testWidgets('locks only the owned assets not already locked', (tester) async {
final stale = owned();
final alreadyLocked = owned(visibility: .locked);
await tester.pumpTestAction(context, (scope) => LockAction(assets: [stale, alreadyLocked], scope: scope));
verify(() => assetService.update([stale.id], visibility: const Some(.locked))).called(1);
});
testWidgets('reports the locked count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(context, (scope) => LockAction(assets: [owned(), owned()], scope: scope));
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.move_to_lock_folder_action_prompt(count: 2));
});
testWidgets('reports the unlocked count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(
context,
(scope) => LockAction(
assets: [
owned(visibility: .locked),
owned(visibility: .locked),
],
scope: scope,
),
);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.remove_from_lock_folder_action_prompt(count: 2));
});
});
}
@@ -31,7 +31,11 @@ void main() {
testWidgets('creates a partner for the selected candidate', (tester) async {
final candidate = UserFactory.create();
await tester.pumpTestAction(context, const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
await tester.pumpTestAction(
context,
(scope) => PartnerAddAction(scope: scope),
overrides: overrides(candidates: [candidate]),
);
await tester.pumpUntilFound(find.text(candidate.name));
await tester.tap(find.text(candidate.name));
await tester.pumpAndSettle();
@@ -42,7 +46,7 @@ void main() {
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
await tester.pumpTestAction(
context,
const PartnerAddAction(),
(scope) => PartnerAddAction(scope: scope),
overrides: overrides(candidates: [UserFactory.create()]),
);
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
@@ -57,7 +61,7 @@ void main() {
final partner = UserFactory.create();
await tester.pumpTestAction(
context,
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
(scope) => PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, scope: scope),
overrides: overrides(),
);
await tester.tap(find.byType(TextButton).last); // confirm
@@ -70,7 +74,7 @@ void main() {
final partner = UserFactory.create();
await tester.pumpTestAction(
context,
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
(scope) => PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, scope: scope),
overrides: overrides(),
);
await tester.tap(find.byType(TextButton).first); // cancel
@@ -28,7 +28,7 @@ void main() {
testWidgets('restores the eligible owned trashed assets', (tester) async {
final asset = owned();
await tester.pumpTestAction(context, RestoreAction(assets: [asset]));
await tester.pumpTestAction(context, (scope) => RestoreAction(assets: [asset], scope: scope));
verify(() => assetService.restoreTrash([asset.id])).called(1);
});
@@ -37,16 +37,16 @@ void main() {
final mine = owned();
final theirs = RemoteAssetFactory.create(deletedAt: DateTime(2020));
await tester.pumpTestAction(context, RestoreAction(assets: [mine, theirs]));
await tester.pumpTestAction(context, (scope) => RestoreAction(assets: [mine, theirs], scope: scope));
verify(() => assetService.restoreTrash([mine.id])).called(1);
});
testWidgets('skips owned assets that are not trashed', (tester) async {
testWidgets('restores only the owned assets that are trashed', (tester) async {
final trashed = owned();
final live = owned(trashed: false);
await tester.pumpTestAction(context, RestoreAction(assets: [trashed, live]));
await tester.pumpTestAction(context, (scope) => RestoreAction(assets: [trashed, live], scope: scope));
verify(() => assetService.restoreTrash([trashed.id])).called(1);
});
@@ -55,14 +55,15 @@ void main() {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, RestoreAction(assets: [first, second]));
await tester.pumpTestAction(context, (scope) => RestoreAction(assets: [first, second], scope: scope));
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()]));
await tester.pumpTestAction(context, (scope) => RestoreAction(assets: [owned(), owned()], scope: scope));
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.assets_restored_count(count: 2));
@@ -1,3 +1,4 @@
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';
@@ -27,35 +28,46 @@ void main() {
testWidgets('stacks the eligible owned assets', (tester) async {
final first = owned();
final second = owned();
final action = await tester.pumpTestAction(
context,
(scope) => StackAction(assets: [first, second], scope: scope),
);
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
expect(action.icon, Icons.filter_none_rounded);
expect(action.label, StaticTranslations.instance.stack);
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');
final action = await tester.pumpTestAction(context, (scope) => StackAction(assets: [asset], scope: scope));
await tester.pumpTestAction(context, StackAction(assets: [asset]));
expect(action.icon, Icons.layers_clear_outlined);
expect(action.label, StaticTranslations.instance.unstack);
verify(() => assetService.unstack(['stack'])).called(1);
});
testWidgets('prioritizes stack when mixed state', (tester) async {
testWidgets('prioritizes stack when the owned selection is mixed', (tester) async {
final first = owned();
final second = owned(stackId: 'stack');
final action = await tester.pumpTestAction(
context,
(scope) => StackAction(assets: [first, second], scope: scope),
);
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
expect(action.label, StaticTranslations.instance.stack);
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
final mine = owned();
final other = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(context, StackAction(assets: [mine, other, theirs]));
await tester.pumpTestAction(context, (scope) => StackAction(assets: [mine, other, theirs], scope: scope));
verify(() => assetService.stack(context.currentUser.id, [mine.id, other.id])).called(1);
});
@@ -64,7 +76,7 @@ void main() {
final first = owned(stackId: 'stack-1');
final second = owned(stackId: 'stack-2');
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
await tester.pumpTestAction(context, (scope) => StackAction(assets: [first, second], scope: scope));
verify(() => assetService.unstack(['stack-1', 'stack-2'])).called(1);
});
@@ -72,7 +84,7 @@ void main() {
testWidgets('reports the stacked count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(context, StackAction(assets: [owned(), owned()]));
await tester.pumpTestAction(context, (scope) => StackAction(assets: [owned(), owned()], scope: scope));
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.stacked_assets_count(count: 2));
@@ -83,11 +95,12 @@ void main() {
await tester.pumpTestAction(
context,
StackAction(
(scope) => StackAction(
assets: [
owned(stackId: 'stack-1'),
owned(stackId: 'stack-2'),
],
scope: scope,
),
);
@@ -10,25 +10,16 @@ import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
class _FakeAction extends BaseAction {
_FakeAction({this.visible = true, this.error});
_FakeAction({required super.scope, bool visible = true, this.error})
: super(icon: Icons.bolt, label: 'fake', isVisible: visible);
final bool visible;
final Object? error;
bool ran = false;
bool? selectionDuringOnAction;
@override
IconData get icon => Icons.bolt;
@override
String label(ActionScope scope) => 'fake';
@override
bool isVisible(ActionScope scope) => visible;
@override
Future<void> onAction(ActionScope scope) async {
Future<void> onAction() async {
ran = true;
selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled;
if (error != null) {
@@ -75,9 +66,9 @@ void main() {
group('TimelineAction', () {
testWidgets('runs the wrapped action and then clears the selection', (tester) async {
final inner = _FakeAction();
final (scope, container) = await pumpScope(tester);
await TimelineAction(action: inner).onAction(scope);
final inner = _FakeAction(scope: scope);
await TimelineAction(action: inner).onAction();
expect(inner.ran, isTrue);
expect(inner.selectionDuringOnAction, isTrue, reason: 'reset must run after the inner action, not before');
@@ -86,19 +77,19 @@ void main() {
testWidgets('rethrows and keeps the selection when the wrapped action throws', (tester) async {
final error = Exception('boom');
final inner = _FakeAction(error: error);
final (scope, container) = await pumpScope(tester);
final inner = _FakeAction(scope: scope, error: error);
await expectLater(TimelineAction(action: inner).onAction(scope), throwsA(same(error)));
await expectLater(TimelineAction(action: inner).onAction(), throwsA(same(error)));
expect(inner.ran, isTrue);
expect(container.read(multiSelectProvider).isEnabled, isTrue);
});
testWidgets('delegates visibility to the wrapped action', (tester) async {
await tester.pumpTestWidget(
await tester.pumpActionButton(
context,
ActionIconButtonWidget(action: TimelineAction(action: _FakeAction(visible: false))),
(scope) => TimelineAction(action: _FakeAction(scope: scope, visible: false)),
);
expect(find.byType(ActionIconButtonWidget), findsOneWidget);
@@ -17,12 +17,10 @@ void main() {
group('PartnerSharedByList', () {
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
final action = const PartnerAddAction();
await tester.pumpTestWidget(context, const PartnerSharedByList(partners: []));
expect(find.byType(ListView), findsNothing);
expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget);
expect(find.widgetWithIcon(TextButton, Icons.person_add_rounded), findsOneWidget);
});
testWidgets('renders a tile per partner with name and email', (tester) async {
@@ -39,9 +37,8 @@ void main() {
testWidgets('renders a remove action for each partner', (tester) async {
final partner1 = PartnerFactory.create(inTimeline: true);
final partner2 = PartnerFactory.create();
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
expect(find.byIcon(action.icon), findsNWidgets(2));
expect(find.byIcon(Icons.person_remove_rounded), findsNWidgets(2));
});
});
@@ -98,14 +98,34 @@ extension PumpPresentationWidget on WidgetTester {
await pumpAndSettle();
}
Future<void> pumpTestAction(
Future<BaseAction> pumpTestAction(
PresentationContext context,
BaseAction action, {
BaseAction Function(ActionScope scope) build, {
List<Override> overrides = const [],
}) async {
await pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides);
final action = await pumpActionButton(context, build, overrides: overrides);
await tap(find.byType(ImmichIconButton));
await pump();
return action;
}
Future<BaseAction> pumpActionButton(
PresentationContext context,
BaseAction Function(ActionScope scope) build, {
List<Override> overrides = const [],
}) async {
late BaseAction action;
await pumpTestWidget(
context,
Consumer(
builder: (innerContext, ref, _) {
action = build(ActionScope(context: innerContext, ref: ref, authUser: context.currentUser));
return ActionIconButtonWidget(action: action);
},
),
overrides: overrides,
);
return action;
}
Future<void> pumpUntilFound(Finder finder, {int maxFrames = 10}) async {
@@ -1,11 +1,14 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
import '../unit/presentation/presentation_context.dart';
LocalAsset createLocalAsset({
String? remoteId,
String name = 'test.jpg',
@@ -77,6 +80,23 @@ RemoteAlbum createRemoteAlbum({
);
}
ActionButtonContext _buttonContext({
required BaseAsset asset,
RemoteAlbum? currentAlbum,
bool isArchived = false,
bool isStacked = false,
}) => ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: isArchived,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: currentAlbum,
advancedTroubleshooting: false,
isStacked: isStacked,
source: ActionSource.timeline,
);
void main() {
group('ActionButtonContext', () {
test('should create context with all required parameters', () {
@@ -1054,188 +1074,101 @@ void main() {
});
group('ActionButtonType.buildButton', () {
late PresentationContext presentation;
late BaseAsset asset;
late ActionButtonContext context;
setUp(() {
setUp(() async {
presentation = await PresentationContext.create();
asset = createLocalAsset(remoteId: 'remote-id');
context = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
});
test('should build correct widget for each button type', () {
for (final buttonType in ActionButtonType.values) {
var buttonContext = context;
tearDown(() {
presentation.dispose();
});
if (buttonType == ActionButtonType.removeFromAlbum) {
final album = createRemoteAlbum();
final contextWithAlbum = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
} else if (buttonType == ActionButtonType.similarPhotos) {
final contextWithAlbum = ActionButtonContext(
asset: createRemoteAsset(),
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
} else if (buttonType == ActionButtonType.setAlbumCover) {
final album = createRemoteAlbum();
final contextWithAlbum = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
} else if (buttonType == ActionButtonType.unstack) {
final album = createRemoteAlbum();
final contextWithAlbum = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: true,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
} else {
final widget = buttonType.buildButton(buttonContext);
expect(widget, isA<Widget>());
}
}
testWidgets('builds a widget for every button type', (tester) async {
final built = <Widget>[];
await tester.pumpTestWidget(
presentation,
Consumer(
builder: (buildContext, ref, _) {
for (final buttonType in ActionButtonType.values) {
final buttonContext = switch (buttonType) {
ActionButtonType.similarPhotos => _buttonContext(asset: createRemoteAsset()),
ActionButtonType.removeFromAlbum ||
ActionButtonType.setAlbumCover => _buttonContext(asset: asset, currentAlbum: createRemoteAlbum()),
ActionButtonType.unstack => _buttonContext(
asset: asset,
currentAlbum: createRemoteAlbum(),
isStacked: true,
),
_ => _buttonContext(asset: asset),
};
built.add(buttonType.buildButton(buttonContext, buildContext, ref));
}
return const SizedBox.shrink();
},
),
);
expect(built, hasLength(ActionButtonType.values.length));
expect(built, everyElement(isA<Widget>()));
});
});
group('ActionButtonBuilder', () {
test('should return buttons that should show', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
late PresentationContext presentation;
final widgets = ActionButtonBuilder.build(context);
expect(widgets, isNotEmpty);
expect(widgets.length, greaterThan(0));
setUp(() async {
presentation = await PresentationContext.create();
});
test('should include album-specific buttons when album is present', () {
final remoteAsset = createRemoteAsset();
final album = createRemoteAlbum(isActivityEnabled: true, isShared: true);
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
tearDown(() {
presentation.dispose();
});
Future<List<Widget>> buildButtons(WidgetTester tester, ActionButtonContext context) async {
late List<Widget> widgets;
await tester.pumpTestWidget(
presentation,
Consumer(
builder: (buildContext, ref, _) {
widgets = ActionButtonBuilder.build(context, buildContext, ref);
return const SizedBox.shrink();
},
),
);
return widgets;
}
final widgets = ActionButtonBuilder.build(context);
testWidgets('returns buttons that should show', (tester) async {
final widgets = await buildButtons(tester, _buttonContext(asset: createRemoteAsset()));
expect(widgets, isNotEmpty);
});
test('should only include local buttons for local assets', () {
final localAsset = createLocalAsset();
final context = ActionButtonContext(
asset: localAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
testWidgets('includes album-specific buttons when album is present', (tester) async {
final widgets = await buildButtons(
tester,
_buttonContext(
asset: createRemoteAsset(),
currentAlbum: createRemoteAlbum(isActivityEnabled: true, isShared: true),
),
);
final widgets = ActionButtonBuilder.build(context);
expect(widgets, isNotEmpty);
});
test('should respect archived state', () {
testWidgets('includes local buttons for local assets', (tester) async {
final widgets = await buildButtons(tester, _buttonContext(asset: createLocalAsset()));
expect(widgets, isNotEmpty);
});
testWidgets('respects archived state', (tester) async {
final remoteAsset = createRemoteAsset();
final archived = await buildButtons(tester, _buttonContext(asset: remoteAsset, isArchived: true));
final nonArchived = await buildButtons(tester, _buttonContext(asset: remoteAsset, isArchived: false));
final archivedContext = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: true,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
final archivedWidgets = ActionButtonBuilder.build(archivedContext);
final nonArchivedContext = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
final nonArchivedWidgets = ActionButtonBuilder.build(nonArchivedContext);
expect(archivedWidgets, isNotEmpty);
expect(nonArchivedWidgets, isNotEmpty);
expect(archived, isNotEmpty);
expect(nonArchived, isNotEmpty);
});
});
}