refactor: existing actions to new structure

This commit is contained in:
shenlong-tanwen
2026-07-27 19:46:59 +05:30
parent 04a38ba91c
commit 40709edde3
27 changed files with 280 additions and 501 deletions
@@ -171,13 +171,4 @@ class AssetService {
Future<LocalAsset?> getLocalAsset(String id) {
return _localRepository.get(id);
}
Future<void> updateFavorite(List<String> remoteIds, bool isFavorite) async {
if (remoteIds.isEmpty) {
return;
}
await _apiRepository.updateFavorite(remoteIds, isFavorite);
await _remoteRepository.updateFavorite(remoteIds, isFavorite);
}
}
@@ -117,18 +117,6 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}).get();
}
Future<void> updateFavorite(List<String> ids, bool isFavorite) {
return _db.batch((batch) async {
for (final id in ids) {
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(isFavorite: Value(isFavorite)),
where: (e) => e.id.equals(id),
);
}
});
}
Future<void> updateVisibility(List<String> ids, AssetVisibility visibility) {
return _db.batch((batch) async {
for (final id in ids) {
@@ -33,7 +33,7 @@ class PartnerPage extends ConsumerWidget {
title: Text(context.t.partners),
elevation: 0,
centerTitle: false,
actions: const [ActionIconButtonWidget(action: PartnerAddAction())],
actions: const [ActionIconButton(action: PartnerAddAction())],
),
body: sharedByAsync.when(
data: (partners) => PartnerSharedByList(partners: partners.toList(growable: false)),
@@ -60,7 +60,7 @@ class _EmptyPartners extends StatelessWidget {
),
const Align(
alignment: .center,
child: ActionButtonWidget(action: PartnerAddAction()),
child: ActionButton(action: PartnerAddAction()),
),
],
),
@@ -88,7 +88,7 @@ class PartnerSharedByList extends StatelessWidget {
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
title: Text(partner.name),
subtitle: Text(partner.email),
trailing: ActionIconButtonWidget(
trailing: ActionIconButton(
action: PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
),
);
+45 -20
View File
@@ -1,32 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class ActionScope {
final BuildContext context;
final WidgetRef ref;
final UserDto authUser;
class ActionData {
final IconData icon;
final String label;
final Future<void> Function() onAction;
final Future<void> Function()? onSecondaryAction;
const ActionScope({required this.context, required this.ref, required this.authUser});
const ActionData({required this.icon, required this.label, required this.onAction, this.onSecondaryAction});
}
abstract class BaseAction {
const BaseAction();
abstract class ActionBuilder {
const ActionBuilder();
IconData get icon;
String label(ActionScope scope);
bool isVisible(ActionScope scope) => true;
Future<void> onAction(ActionScope scope);
ActionData? build(BuildContext context, WidgetRef ref);
}
abstract class AssetAction<T extends BaseAsset> extends BaseAction {
final Iterable<BaseAsset> assets;
typedef AssetsActionState = ({AssetFilter<BaseAsset> assets, AssetFilter<RemoteAsset> ownedAssets});
const AssetAction({required this.assets});
class AssetsActionNotifier extends AutoDisposeFamilyNotifier<AssetsActionState, ActionSource> {
@override
AssetsActionState build(ActionSource source) {
final selected = switch (source) {
.timeline => ref.watch(multiSelectProvider.select((s) => s.selectedAssets)),
.viewer => switch (ref.watch(assetViewerProvider.select((s) => s.currentAsset))) {
BaseAsset asset => {asset},
null => const <BaseAsset>{},
},
};
Iterable<T> filter(ActionScope scope) => assets.whereType<T>();
final assets = AssetFilter(selected);
return (assets: assets, ownedAssets: assets.owned(ref.watch(authUserProvider).id));
}
void clearSelect() {
if (arg == .timeline) {
ref.read(multiSelectProvider.notifier).reset();
}
}
}
final assetsActionProvider = NotifierProvider.family.autoDispose<AssetsActionNotifier, AssetsActionState, ActionSource>(
AssetsActionNotifier.new,
);
abstract class AssetActionBuilder extends ActionBuilder {
final ActionSource source;
const AssetActionBuilder({required this.source});
}
@@ -1,95 +1,71 @@
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 String label;
final VoidCallback onAction;
abstract class ActionWidget extends ConsumerWidget {
final ActionBuilder action;
const _ActionWidgetScope({required this.label, required this.onAction});
}
const ActionWidget({super.key, required this.action});
class _ActionWidget extends ConsumerWidget {
final BaseAction action;
final Widget Function(_ActionWidgetScope context) builder;
const _ActionWidget({required this.action, required this.builder});
Future<void> _onAction(ActionScope scope) async {
try {
await action.onAction(scope);
} catch (error, stackTrace) {
handleError(scope.context, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
}
}
Widget builder(BuildContext context, WidgetRef ref, ActionData action);
@override
Widget build(BuildContext context, WidgetRef ref) {
final authUser = ref.watch(currentUserProvider);
if (authUser == null) {
final resolved = action.build(context, ref);
if (resolved == null) {
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(context, ref, resolved);
}
}
class ActionIconButtonWidget extends StatelessWidget {
final BaseAction action;
class ActionColumnButton extends ActionWidget {
const ActionColumnButton({super.key, required super.action});
@override
Widget builder(BuildContext context, WidgetRef ref, ActionData action) => ImmichColumnButton(
icon: action.icon,
label: action.label,
onPressed: action.onAction,
// onLongPress: action.onSecondaryAction,
);
}
class ActionIconButton extends ActionWidget {
final ImmichVariant variant;
const ActionIconButtonWidget({super.key, required this.action, this.variant = .ghost});
const ActionIconButton({super.key, required super.action, this.variant = .ghost});
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichIconButton(icon: action.icon, onPressed: ctx.onAction, variant: variant),
Widget builder(BuildContext context, WidgetRef ref, ActionData action) => ImmichIconButton(
icon: action.icon,
onPressed: action.onAction,
// onLongPress: action.onSecondaryAction,
variant: variant,
);
}
class ActionButtonWidget extends StatelessWidget {
final BaseAction action;
class ActionButton extends ActionWidget {
final ImmichVariant variant;
const ActionButtonWidget({super.key, required this.action, this.variant = .ghost});
const ActionButton({super.key, required super.action, this.variant = .ghost});
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) =>
ImmichTextButton(labelText: ctx.label, icon: action.icon, onPressed: ctx.onAction, variant: variant),
Widget builder(BuildContext context, WidgetRef ref, ActionData action) => ImmichTextButton(
labelText: action.label,
icon: action.icon,
onPressed: action.onAction,
// onLongPress: action.onSecondaryAction,
variant: variant,
);
}
class ActionColumnButtonWidget extends StatelessWidget {
final BaseAction action;
const ActionColumnButtonWidget({super.key, required this.action});
class ActionMenuItem extends ActionWidget {
const ActionMenuItem({super.key, required super.action});
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichColumnButton(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
);
}
class ActionMenuItemWidget extends StatelessWidget {
final BaseAction action;
const ActionMenuItemWidget({super.key, required this.action});
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichMenuItem(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
);
Widget builder(BuildContext context, WidgetRef ref, ActionData action) =>
ImmichMenuItem(icon: action.icon, label: action.label, onPressed: action.onAction);
}
@@ -2,26 +2,27 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class AssetDebugAction extends AssetAction<BaseAsset> {
const AssetDebugAction({required super.assets});
class AssetDebugAction extends AssetActionBuilder {
const AssetDebugAction({required super.source});
@override
IconData get icon => Icons.help_outline_rounded;
ActionData? build(BuildContext context, WidgetRef ref) {
final assets = ref.watch(assetsActionProvider(source)).assets;
final troubleshootEnabled = ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
if (!troubleshootEnabled || assets.length != 1) {
return null;
}
@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)));
return .new(
icon: Icons.help_outline_rounded,
label: context.t.troubleshoot,
onAction: () async => unawaited(context.pushRoute(AssetTroubleshootRoute(asset: assets.single))),
);
}
}
@@ -1,38 +1,61 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/utils/asset_filter.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/error_handler.dart';
class FavoriteAction extends AssetAction<RemoteAsset> {
final bool favorite;
typedef _State = ({bool shouldFavorite, List<String> assetIds});
FavoriteAction({required super.assets}) : favorite = assets.any((asset) => !asset.isFavorite);
final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, source) {
final AssetsActionState(:ownedAssets) = ref.watch(assetsActionProvider(source));
if (ownedAssets.isEmpty) {
return null;
}
final shouldFavorite = ownedAssets.favorite(isFavorite: false).isNotEmpty;
final assetIds = ownedAssets.favorite(isFavorite: !shouldFavorite).map((asset) => asset.id).toList(growable: false);
return (shouldFavorite: shouldFavorite, assetIds: assetIds);
});
class FavoriteAction extends AssetActionBuilder {
const FavoriteAction({required super.source});
@override
IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
ActionData? build(BuildContext context, WidgetRef ref) {
final shouldFavorite = ref.watch(_stateProvider(source).select((state) => state?.shouldFavorite));
if (shouldFavorite == null) {
return null;
}
@override
String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite;
return .new(
icon: shouldFavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded,
label: shouldFavorite ? context.t.favorite : context.t.unfavorite,
onAction: () => _favorite(context, ref),
);
}
@override
Iterable<RemoteAsset> filter(ActionScope scope) =>
AssetFilter(assets).owned(scope.authUser.id).favorite(isFavorite: !favorite);
Future<void> _favorite(BuildContext context, WidgetRef ref) async {
final state = ref.read(_stateProvider(source));
if (state == null) {
return;
}
@override
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
final _State(:shouldFavorite, :assetIds) = state;
final message = shouldFavorite
? context.t.favorite_action_prompt(count: assetIds.length)
: context.t.unfavorite_action_prompt(count: assetIds.length);
final toast = ref.read(toastRepositoryProvider);
final selection = ref.read(assetsActionProvider(source).notifier);
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:ref) = scope;
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateFavorite(assets, favorite);
final message = favorite
? StaticTranslations.instance.favorite_action_prompt(count: assets.length)
: StaticTranslations.instance.unfavorite_action_prompt(count: assets.length);
snackbar.success(message);
try {
await ref.read(assetServiceProvider).update(assetIds, isFavorite: .some(shouldFavorite));
toast.success(message);
selection.clearSelect();
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to update favorite status for assets");
}
}
}
@@ -6,44 +6,46 @@ import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/error_handler.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
class PartnerAddAction extends BaseAction {
class PartnerAddAction extends ActionBuilder {
const PartnerAddAction();
@override
IconData get icon => Icons.person_add_rounded;
ActionData build(BuildContext context, WidgetRef ref) =>
ActionData(icon: Icons.person_add_rounded, label: context.t.add_partner, onAction: () => _add(context, ref));
@override
String label(ActionScope scope) => scope.context.t.add_partner;
Future<void> _add(BuildContext context, WidgetRef ref) async {
final partnerService = ref.read(partnerServiceProvider);
final authUserId = ref.read(authUserProvider).id;
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:context, :ref, :authUser) = scope;
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
if (selected == null) {
return;
}
await ref.read(partnerServiceProvider).create(sharedById: authUser.id, sharedWithId: selected.id);
try {
await partnerService.create(sharedById: authUserId, sharedWithId: selected.id);
} catch (error, stack) {
handleError(error, stack: stack, description: 'Failed to add partner');
}
}
}
class PartnerRemoveAction extends BaseAction {
class PartnerRemoveAction extends ActionBuilder {
const PartnerRemoveAction({required this.sharedWithId, required this.partnerName});
final String sharedWithId;
final String partnerName;
@override
IconData get icon => Icons.person_remove_rounded;
ActionData build(BuildContext context, WidgetRef ref) =>
ActionData(icon: Icons.person_remove_rounded, label: context.t.remove, onAction: () => _remove(context, ref));
@override
String label(ActionScope scope) => scope.context.t.remove;
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:context, :ref, :authUser) = scope;
Future<void> _remove(BuildContext context, WidgetRef ref) async {
final partnerService = ref.read(partnerServiceProvider);
final authUserId = ref.read(authUserProvider).id;
final confirmed = await showDialog<bool>(
context: context,
@@ -56,7 +58,11 @@ class PartnerRemoveAction extends BaseAction {
return;
}
await ref.read(partnerServiceProvider).delete(sharedById: authUser.id, sharedWithId: sharedWithId);
try {
await partnerService.delete(sharedById: authUserId, sharedWithId: sharedWithId);
} catch (error, stack) {
handleError(error, stack: stack, description: 'Failed to remove partner');
}
}
}
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class RestoreAction extends BaseAction {
final List<String> assetIds;
const RestoreAction._({required this.assetIds, required super.scope, required super.icon, required super.label, super.isVisible});
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,
icon: Icons.history_rounded,
label: scope.context.t.restore,
isVisible: assetIds.isNotEmpty,
);
}
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
await ref.read(assetServiceProvider).restoreTrash(assetIds);
ref.read(toastRepositoryProvider).success(context.t.assets_restored_count(count: assetIds.length));
}
}
@@ -1,24 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class TimelineAction extends BaseAction {
final BaseAction action;
const TimelineAction({required this.action});
@override
IconData get icon => action.icon;
@override
String label(ActionScope scope) => action.label(scope);
@override
bool isVisible(ActionScope scope) => action.isVisible(scope);
@override
Future<void> onAction(ActionScope scope) async {
await action.onAction(scope);
scope.ref.read(multiSelectProvider.notifier).reset();
}
}
@@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class FavoriteActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const FavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).favorite(source);
if (source == ActionSource.viewer) {
if (result.success) {
final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset is RemoteAsset && !currentAsset.isFavorite) {
ref.read(assetViewerProvider.notifier).setAsset(currentAsset.copyWith(isFavorite: true));
}
}
return;
}
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'favorite_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.favorite_border_rounded,
label: "favorite".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UnFavoriteActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UnFavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).unFavorite(source);
if (source == ActionSource.viewer) {
if (result.success) {
final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset is RemoteAsset && currentAsset.isFavorite) {
ref.read(assetViewerProvider.notifier).setAsset(currentAsset.copyWith(isFavorite: false));
}
}
return;
}
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unfavorite_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.favorite_rounded,
label: "unfavorite".t(context: context),
onPressed: () => _onTap(context, ref),
iconOnly: iconOnly,
menuItem: menuItem,
);
}
}
@@ -43,7 +43,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
final originalTheme = context.themeData;
final assetForAction = [asset];
final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
@@ -61,7 +60,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
},
),
ActionIconButtonWidget(action: FavoriteAction(assets: assetForAction)),
const ActionIconButton(action: FavoriteAction(source: .viewer)),
ImmichColorOverride(color: null, child: ViewerKebabMenu(originalTheme: originalTheme)),
];
@@ -5,7 +5,6 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
@@ -76,9 +75,6 @@ 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)];
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.25,
@@ -89,7 +85,7 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
const UnArchiveActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const ActionColumnButton(action: FavoriteAction(source: .timeline)),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
@@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
@@ -74,9 +73,6 @@ class FavoriteBottomSheet extends ConsumerWidget {
ref.read(multiSelectProvider.notifier).reset();
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
return BaseBottomSheet(
initialChildSize: 0.4,
maxChildSize: 0.7,
@@ -85,7 +81,7 @@ 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))),
const ActionColumnButton(action: FavoriteAction(source: .timeline)),
const ArchiveActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
@@ -4,8 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
@@ -14,7 +13,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/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
@@ -83,9 +81,6 @@ 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)];
return BaseBottomSheet(
controller: sheetController,
initialChildSize: widget.minChildSize ?? 0.15,
@@ -93,7 +88,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const ActionColumnButton(action: FavoriteAction(source: .timeline)),
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
@@ -101,7 +96,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
const ActionColumnButton(action: FavoriteAction(source: .timeline)),
const ArchiveActionButton(source: ActionSource.timeline),
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
@@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
@@ -85,9 +84,6 @@ 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)];
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.22,
@@ -101,7 +97,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
if (ownsAlbum) ...[
const ArchiveActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const ActionColumnButton(action: FavoriteAction(source: .timeline)),
],
const DownloadActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
@@ -137,28 +137,6 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> favorite(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.favorite(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to favorite assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> unFavorite(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.unFavorite(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to unfavorite assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> archive(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
+8
View File
@@ -30,3 +30,11 @@ class CurrentUserProvider extends StateNotifier<UserDto?> {
final currentUserProvider = StateNotifierProvider<CurrentUserProvider, UserDto?>((ref) {
return CurrentUserProvider(ref.watch(userServiceProvider));
});
final authUserProvider = Provider<UserDto>((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access this provider');
}
return user;
});
@@ -111,11 +111,6 @@ class AssetApiRepository extends ApiRepository {
);
}
// TODO(shenlong): remove after action migration
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(
-10
View File
@@ -68,16 +68,6 @@ class ActionService {
unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds)));
}
Future<void> favorite(List<String> remoteIds) async {
await _assetApiRepository.updateFavorite(remoteIds, true);
await _remoteAssetRepository.updateFavorite(remoteIds, true);
}
Future<void> unFavorite(List<String> remoteIds) async {
await _assetApiRepository.updateFavorite(remoteIds, false);
await _remoteAssetRepository.updateFavorite(remoteIds, false);
}
Future<void> archive(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, .archive);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive);
+1 -1
View File
@@ -192,7 +192,7 @@ enum ActionButtonType {
bool menuItem = false,
]) {
return switch (this) {
ActionButtonType.advancedInfo => ActionMenuItemWidget(action: AssetDebugAction(assets: [context.asset])),
ActionButtonType.advancedInfo => ActionMenuItem(action: AssetDebugAction(source: context.source)),
ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.shareLink => ShareLinkActionButton(
source: context.source,
@@ -24,7 +24,8 @@ void main() {
testWidgets('visible for a single asset when advanced troubleshooting is on', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
const ActionIconButton(action: AssetDebugAction(source: .timeline)),
overrides: context.selected({RemoteAssetFactory.create()}),
);
expect(find.byType(ImmichIconButton), findsOneWidget);
@@ -33,9 +34,8 @@ void main() {
testWidgets('hidden for multiple assets', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(
action: AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]),
),
const ActionIconButton(action: AssetDebugAction(source: .timeline)),
overrides: context.selected({RemoteAssetFactory.create(), RemoteAssetFactory.create()}),
);
expect(find.byType(ImmichIconButton), findsNothing);
@@ -43,9 +43,11 @@ void main() {
testWidgets('hidden when advanced troubleshooting is off', (tester) async {
await StoreService.I.put(StoreKey.advancedTroubleshooting, false);
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
const ActionIconButton(action: AssetDebugAction(source: .timeline)),
overrides: context.selected({RemoteAssetFactory.create()}),
);
expect(find.byType(ImmichIconButton), findsNothing);
@@ -1,7 +1,10 @@
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/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
import '../../../service.mocks.dart';
@@ -24,55 +27,75 @@ void main() {
RemoteAsset owned({bool isFavorite = false}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite);
Future<void> pumpFavorite(WidgetTester tester, Set<BaseAsset> selection) =>
tester.pumpTestAction(context, const FavoriteAction(source: .timeline), overrides: context.selected(selection));
group('FavoriteAction', () {
testWidgets('favorites the eligible owned assets', (tester) async {
final asset = owned();
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
await pumpFavorite(tester, {asset});
verify(() => assetService.updateFavorite([asset.id], true)).called(1);
verify(() => assetService.update([asset.id], isFavorite: const Option.some(true))).called(1);
});
testWidgets('unfavorite the eligible owned assets', (tester) async {
final asset = owned(isFavorite: true);
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
await pumpFavorite(tester, {asset});
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
verify(() => assetService.update([asset.id], isFavorite: const Option.some(false))).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(context, FavoriteAction(assets: [mine, theirs]));
await pumpFavorite(tester, {mine, theirs});
verify(() => assetService.updateFavorite([mine.id], true)).called(1);
verify(() => assetService.update([mine.id], isFavorite: const Option.some(true))).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 pumpFavorite(tester, {first, second});
verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1);
verify(() => assetService.update([first.id, second.id], isFavorite: const Option.some(true))).called(1);
});
testWidgets('skips owned assets already in the target state', (tester) async {
final stale = owned();
final alreadyFavorite = owned(isFavorite: true);
await tester.pumpTestAction(context, FavoriteAction(assets: [stale, alreadyFavorite]));
await pumpFavorite(tester, {stale, alreadyFavorite});
verify(() => assetService.updateFavorite([stale.id], true)).called(1);
verify(() => assetService.update([stale.id], isFavorite: const Option.some(true))).called(1);
});
testWidgets('shows a confirmation snackbar on success', (tester) async {
await tester.pumpTestAction(context, FavoriteAction(assets: [owned()]));
await pumpFavorite(tester, {owned()});
await tester.pumpUntilFound(find.byType(SnackBar));
expect(find.byType(SnackBar), findsOneWidget);
});
testWidgets('clears the selection once the update succeeds', (tester) async {
await pumpFavorite(tester, {owned()});
await tester.pumpAndSettle();
expect(find.byType(ImmichIconButton), findsNothing, reason: 'an empty selection hides the action');
});
testWidgets('is hidden when none of the selected assets are owned', (tester) async {
await tester.pumpTestWidget(
context,
const ActionIconButton(action: FavoriteAction(source: .timeline)),
overrides: context.selected({RemoteAssetFactory.create()}),
);
expect(find.byType(ImmichIconButton), findsNothing);
});
});
}
@@ -1,108 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
class _FakeAction extends BaseAction {
_FakeAction({this.visible = true, this.error});
final bool visible;
final Object? error;
bool ran = false;
bool? selectionDuringOnAction;
@override
IconData get icon => Icons.bolt;
@override
String label(ActionScope scope) => 'fake';
@override
bool isVisible(ActionScope scope) => visible;
@override
Future<void> onAction(ActionScope scope) async {
ran = true;
selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled;
if (error != null) {
throw error!;
}
}
}
void main() {
late PresentationContext context;
setUp(() async {
context = await PresentationContext.create();
});
tearDown(() {
context.dispose();
});
List<Override> overrides() => [
multiSelectProvider.overrideWith(
() => MultiSelectNotifier(
MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}),
),
),
];
Future<(ActionScope, ProviderContainer)> pumpScope(WidgetTester tester) async {
late ActionScope scope;
late ProviderContainer container;
await tester.pumpTestWidget(
context,
Consumer(
builder: (innerContext, ref, _) {
scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser);
container = ProviderScope.containerOf(innerContext, listen: false);
return const SizedBox.shrink();
},
),
overrides: overrides(),
);
return (scope, container);
}
group('TimelineAction', () {
testWidgets('runs the wrapped action and then clears the selection', (tester) async {
final inner = _FakeAction();
final (scope, container) = await pumpScope(tester);
await TimelineAction(action: inner).onAction(scope);
expect(inner.ran, isTrue);
expect(inner.selectionDuringOnAction, isTrue, reason: 'reset must run after the inner action, not before');
expect(container.read(multiSelectProvider).isEnabled, isFalse);
});
testWidgets('rethrows and keeps the selection when the wrapped action throws', (tester) async {
final error = Exception('boom');
final inner = _FakeAction(error: error);
final (scope, container) = await pumpScope(tester);
await expectLater(TimelineAction(action: inner).onAction(scope), throwsA(same(error)));
expect(inner.ran, isTrue);
expect(container.read(multiSelectProvider).isEnabled, isTrue);
});
testWidgets('delegates visibility to the wrapped action', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: TimelineAction(action: _FakeAction(visible: false))),
);
expect(find.byType(ActionIconButtonWidget), findsOneWidget);
expect(find.byIcon(Icons.bolt), findsNothing);
});
});
}
@@ -3,7 +3,9 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/pages/library/partner/partner.page.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/partner.action.dart';
import 'package:immich_ui/immich_ui.dart';
import '../factories/partner_user_factory.dart';
import '../factories/user_factory.dart';
@@ -17,12 +19,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.descendant(of: find.byType(ActionButton), matching: find.byType(ImmichTextButton)), findsOneWidget);
});
testWidgets('renders a tile per partner with name and email', (tester) async {
@@ -39,9 +39,11 @@ 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.descendant(of: find.byType(ActionIconButton), matching: find.byType(ImmichIconButton)),
findsNWidgets(2),
);
});
});
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
@@ -16,6 +17,7 @@ import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/gcast.service.dart';
import 'package:immich_mobile/services/server_info.service.dart';
@@ -52,6 +54,12 @@ class PresentationContext {
inLockedViewProvider.overrideWithValue(false),
];
List<Override> selected(Set<BaseAsset> assets) => [
multiSelectProvider.overrideWith(
() => MultiSelectNotifier(MultiSelectState(selectedAssets: assets, lockedSelectionAssets: const {})),
),
];
static Future<PresentationContext> create() async {
TestUtils.init();
if (_db == null) {
@@ -103,10 +111,10 @@ extension PumpPresentationWidget on WidgetTester {
Future<void> pumpTestAction(
PresentationContext context,
BaseAction action, {
ActionBuilder action, {
List<Override> overrides = const [],
}) async {
await pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides);
await pumpTestWidget(context, ActionIconButton(action: action), overrides: overrides);
await tap(find.byType(ImmichIconButton));
await pump();
}