mirror of
https://github.com/immich-app/immich.git
synced 2026-07-28 14:47:30 -07:00
refactor: mobile restore action
This commit is contained in:
@@ -1,35 +1,46 @@
|
||||
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/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
import 'package:immich_mobile/utils/error_handler.dart';
|
||||
|
||||
class RestoreAction extends BaseAction {
|
||||
final List<String> assetIds;
|
||||
final _stateProvider = Provider.family.autoDispose<List<String>?, ActionSource>((ref, source) {
|
||||
final AssetsActionState(:ownedAssets) = ref.watch(assetsActionProvider(source));
|
||||
final assetIds = ownedAssets.trashed().map((asset) => asset.id).toList(growable: false);
|
||||
return assetIds.isEmpty ? null : 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,
|
||||
);
|
||||
}
|
||||
class RestoreAction extends AssetActionBuilder {
|
||||
const RestoreAction({required super.source});
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref, :context) = scope;
|
||||
ActionData? build(BuildContext context, WidgetRef ref) {
|
||||
if (!ref.watch(_stateProvider(source).select((state) => state != null))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await ref.read(assetServiceProvider).restoreTrash(assetIds);
|
||||
ref.read(toastRepositoryProvider).success(context.t.assets_restored_count(count: assetIds.length));
|
||||
return .new(icon: Icons.history_rounded, label: context.t.restore, onAction: () => _restore(context, ref));
|
||||
}
|
||||
|
||||
Future<void> _restore(BuildContext context, WidgetRef ref) async {
|
||||
final assetIds = ref.read(_stateProvider(source));
|
||||
if (assetIds == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final message = context.t.assets_restored_count(count: assetIds.length);
|
||||
final toast = ref.read(toastRepositoryProvider);
|
||||
final selection = ref.read(assetsActionProvider(source).notifier);
|
||||
|
||||
try {
|
||||
await ref.read(assetServiceProvider).restoreTrash(assetIds);
|
||||
toast.success(message);
|
||||
selection.clearSelect();
|
||||
} catch (error, stack) {
|
||||
handleError(error, stack: stack, description: "Failed to restore assets");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class RestoreActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const RestoreActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).restoreTrash(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.history_rounded,
|
||||
label: 'restore'.t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
maxWidth: 100.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class RestoreTrashActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
|
||||
const RestoreTrashActionButton({super.key, required this.source});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).restoreTrash(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return TextButton.icon(
|
||||
icon: const Icon(Icons.history_rounded),
|
||||
label: Text('restore'.t(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,13 @@ import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart';
|
||||
@@ -43,10 +44,8 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
final originalTheme = context.themeData;
|
||||
|
||||
final actions = <Widget>[
|
||||
if (isInTrash && isOwner && asset.hasRemote)
|
||||
const RestoreActionButton(source: ActionSource.viewer)
|
||||
else
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
const ActionColumnButton(action: RestoreAction(source: .viewer)),
|
||||
const ShareActionButton(source: .viewer),
|
||||
|
||||
if (!isInLockedView) ...[
|
||||
if (!isInTrash) ...[
|
||||
|
||||
@@ -2,8 +2,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_trash_action_button.widget.dart';
|
||||
|
||||
class TrashBottomBar extends ConsumerWidget {
|
||||
const TrashBottomBar({super.key});
|
||||
@@ -21,7 +22,7 @@ class TrashBottomBar extends ConsumerWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
DeleteTrashActionButton(source: ActionSource.timeline),
|
||||
RestoreTrashActionButton(source: ActionSource.timeline),
|
||||
ActionColumnButton(action: RestoreAction(source: .timeline)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -194,17 +194,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> restoreTrash(ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
try {
|
||||
await _service.restoreTrash(ids);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to restore trash assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> emptyTrash(String userId) async {
|
||||
try {
|
||||
final count = await _service.emptyTrash(userId);
|
||||
|
||||
@@ -98,11 +98,6 @@ class ActionService {
|
||||
await _remoteAssetRepository.trash(remoteIds);
|
||||
}
|
||||
|
||||
Future<void> restoreTrash(List<String> ids) async {
|
||||
await _assetApiRepository.restoreTrash(ids);
|
||||
await _remoteAssetRepository.restoreTrash(ids);
|
||||
}
|
||||
|
||||
Future<int> emptyTrash(String userId) async {
|
||||
final count = await _assetApiRepository.emptyTrash();
|
||||
await _remoteAssetRepository.emptyTrash(userId);
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
||||
@@ -21,7 +22,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
@@ -208,11 +208,7 @@ enum ActionButtonType {
|
||||
),
|
||||
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.restoreTrash => RestoreActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.restoreTrash => ActionMenuItem(action: RestoreAction(source: context.source)),
|
||||
ActionButtonType.deletePermanent => DeletePermanentActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
|
||||
@@ -36,7 +36,6 @@ class _AbortCallbackWrapper {
|
||||
|
||||
class _MockAbortCallbackWrapper extends Mock implements _AbortCallbackWrapper {}
|
||||
|
||||
|
||||
void main() {
|
||||
late SyncStreamService sut;
|
||||
late SyncStreamRepository mockSyncStreamRepo;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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/restore.action.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../service.mocks.dart';
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
late MockAssetService assetService;
|
||||
|
||||
setUp(() async {
|
||||
context = await PresentationContext.create();
|
||||
assetService = context.service.asset.service;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
RemoteAsset owned({bool trashed = true}) =>
|
||||
RemoteAssetFactory.create(ownerId: context.currentUser.id, deletedAt: trashed ? DateTime(2020) : null);
|
||||
|
||||
Future<void> pumpRestore(WidgetTester tester, Set<BaseAsset> selection) =>
|
||||
tester.pumpTestAction(context, const RestoreAction(source: .timeline), overrides: context.selected(selection));
|
||||
|
||||
group('RestoreAction', () {
|
||||
testWidgets('restores the eligible owned trashed assets', (tester) async {
|
||||
final asset = owned();
|
||||
|
||||
await pumpRestore(tester, {asset});
|
||||
|
||||
verify(() => assetService.restoreTrash([asset.id])).called(1);
|
||||
});
|
||||
|
||||
testWidgets('ignores assets owned by someone else', (tester) async {
|
||||
final mine = owned();
|
||||
final theirs = RemoteAssetFactory.create(deletedAt: DateTime(2020));
|
||||
|
||||
await pumpRestore(tester, {mine, theirs});
|
||||
|
||||
verify(() => assetService.restoreTrash([mine.id])).called(1);
|
||||
});
|
||||
|
||||
testWidgets('skips owned assets that are not trashed', (tester) async {
|
||||
final trashed = owned();
|
||||
final live = owned(trashed: false);
|
||||
|
||||
await pumpRestore(tester, {trashed, live});
|
||||
|
||||
verify(() => assetService.restoreTrash([trashed.id])).called(1);
|
||||
});
|
||||
|
||||
testWidgets('batches every eligible owned asset into a single call', (tester) async {
|
||||
final first = owned();
|
||||
final second = owned();
|
||||
|
||||
await pumpRestore(tester, {first, second});
|
||||
|
||||
verify(() => assetService.restoreTrash([first.id, second.id])).called(1);
|
||||
});
|
||||
|
||||
testWidgets('clears the selection once the restore succeeds', (tester) async {
|
||||
await pumpRestore(tester, {owned()});
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing, reason: 'an empty selection hides the action');
|
||||
});
|
||||
|
||||
testWidgets('is hidden when no owned asset is trashed', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
const ActionIconButton(action: RestoreAction(source: .timeline)),
|
||||
overrides: context.selected({owned(trashed: false)}),
|
||||
);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user