mirror of
https://github.com/immich-app/immich.git
synced 2026-07-28 14:47:30 -07:00
refactor: existing actions to new structure
This commit is contained in:
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user