Compare commits

...

2 Commits

Author SHA1 Message Date
shenlong-tanwen 249259da0a refactor: action view 2026-07-02 22:20:31 +05:30
shenlong-tanwen 7130553634 refactor: return AssetFilter from asset action filter 2026-07-01 22:29:04 +05:30
17 changed files with 354 additions and 181 deletions
+31 -8
View File
@@ -1,7 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class ActionScope {
final BuildContext context;
@@ -14,13 +17,7 @@ class ActionScope {
abstract class BaseAction {
const BaseAction();
IconData get icon;
String label(ActionScope scope);
bool isVisible(ActionScope scope) => true;
Future<void> onAction(ActionScope scope);
ActionView resolve(ActionScope scope);
}
abstract class AssetAction<T extends BaseAsset> extends BaseAction {
@@ -28,5 +25,31 @@ abstract class AssetAction<T extends BaseAsset> extends BaseAction {
const AssetAction({required this.assets});
Iterable<T> filter(ActionScope scope) => assets.whereType<T>();
@override
AssetActionView resolve(ActionScope scope);
}
abstract class ActionView {
final ActionScope scope;
const ActionView({required this.scope});
IconData get icon;
String get label;
bool get isVisible => true;
FutureOr<void> onAction();
}
abstract class AssetActionView<T extends BaseAsset> extends ActionView {
final Iterable<BaseAsset> assets;
const AssetActionView({required this.assets, required super.scope});
AssetFilter<T> get filter => .new(assets.whereType<T>());
@override
bool get isVisible => filter.isNotEmpty;
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -7,10 +9,11 @@ import 'package:immich_mobile/utils/error_handler.dart';
import 'package:immich_ui/immich_ui.dart';
class _ActionWidgetScope {
final IconData icon;
final String label;
final VoidCallback onAction;
final FutureOr<void> Function() onAction;
const _ActionWidgetScope({required this.label, required this.onAction});
const _ActionWidgetScope({required this.icon, required this.label, required this.onAction});
}
class _ActionWidget extends ConsumerWidget {
@@ -19,11 +22,11 @@ class _ActionWidget extends ConsumerWidget {
const _ActionWidget({required this.action, required this.builder});
Future<void> _onAction(ActionScope scope) async {
Future<void> _onAction(FutureOr<void> Function() action) async {
try {
await action.onAction(scope);
await action();
} catch (error, stackTrace) {
handleError(scope.context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
handleError(error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
}
}
@@ -35,11 +38,13 @@ class _ActionWidget extends ConsumerWidget {
}
final scope = ActionScope(context: context, ref: ref, authUser: authUser);
if (!action.isVisible(scope)) {
final view = action.resolve(scope);
if (!view.isVisible) {
return const SizedBox.shrink();
}
return builder(.new(label: action.label(scope), onAction: () => _onAction(scope)));
return builder(.new(icon: view.icon, label: view.label, onAction: () => _onAction(view.onAction)));
}
}
@@ -52,7 +57,7 @@ class ActionIconButtonWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichIconButton(icon: action.icon, onPressed: ctx.onAction, variant: variant),
builder: (ctx) => ImmichIconButton(icon: ctx.icon, onPressed: ctx.onAction, variant: variant),
);
}
@@ -65,8 +70,7 @@ class ActionButtonWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) =>
ImmichTextButton(labelText: ctx.label, icon: action.icon, onPressed: ctx.onAction, variant: variant),
builder: (ctx) => ImmichTextButton(labelText: ctx.label, icon: ctx.icon, onPressed: ctx.onAction, variant: variant),
);
}
@@ -78,7 +82,7 @@ class ActionColumnButtonWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichColumnButton(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
builder: (ctx) => ImmichColumnButton(icon: ctx.icon, label: ctx.label, onPressed: ctx.onAction),
);
}
@@ -90,6 +94,6 @@ class ActionMenuItemWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichMenuItem(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
builder: (ctx) => ImmichMenuItem(icon: ctx.icon, label: ctx.label, onPressed: ctx.onAction),
);
}
@@ -8,39 +8,61 @@ import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class ArchiveAction extends AssetAction<RemoteAsset> {
final bool archive;
ArchiveAction({required super.assets})
: archive = assets.any((asset) => asset is RemoteAsset && asset.visibility != .archive);
const ArchiveAction({required super.assets});
@override
IconData get icon => archive ? Icons.archive_outlined : Icons.unarchive_outlined;
@override
String label(ActionScope scope) => archive ? scope.context.t.archive : scope.context.t.unarchive;
@override
Iterable<RemoteAsset> filter(ActionScope scope) {
final owned = AssetFilter(assets).owned(scope.authUser.id);
if (archive) {
return owned.visibility(.timeline);
} else {
return owned.visibility(.archive);
}
}
@override
bool isVisible(ActionScope scope) => !scope.ref.watch(inLockedViewProvider) && filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:ref, :context) = scope;
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateVisibility(assets, archive ? .archive : .timeline);
final message = archive
? context.t.archive_action_prompt(count: assets.length)
: context.t.unarchive_action_prompt(count: assets.length);
ref.read(toastRepositoryProvider).success(message);
AssetActionView<RemoteAsset> resolve(ActionScope scope) {
final hasNonArchived = AssetFilter(assets).owned(scope.authUser.id).any((asset) => asset.visibility != .archive);
return hasNonArchived ? ArchiveView(assets: assets, scope: scope) : UnarchiveView(assets: assets, scope: scope);
}
}
@visibleForTesting
class ArchiveView extends AssetActionView<RemoteAsset> {
const ArchiveView({required super.assets, required super.scope});
@override
IconData get icon => Icons.archive_outlined;
@override
String get label => scope.context.t.archive;
@override
AssetFilter<RemoteAsset> get filter => .new(assets).owned(scope.authUser.id).archived(isArchived: false);
@override
bool get isVisible => !scope.ref.watch(inLockedViewProvider) && filter.isNotEmpty;
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
final ids = filter.map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateVisibility(ids, .archive);
ref.read(toastRepositoryProvider).success(context.t.archive_action_prompt(count: ids.length));
}
}
@visibleForTesting
class UnarchiveView extends AssetActionView<RemoteAsset> {
const UnarchiveView({required super.assets, required super.scope});
@override
IconData get icon => Icons.unarchive_outlined;
@override
String get label => scope.context.t.unarchive;
@override
AssetFilter<RemoteAsset> get filter => .new(assets).owned(scope.authUser.id).archived();
@override
bool get isVisible => !scope.ref.watch(inLockedViewProvider) && filter.isNotEmpty;
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
final ids = filter.map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateVisibility(ids, .timeline);
ref.read(toastRepositoryProvider).success(context.t.unarchive_action_prompt(count: ids.length));
}
}
@@ -11,17 +11,23 @@ import 'package:immich_mobile/routing/router.dart';
class AssetDebugAction extends AssetAction<BaseAsset> {
const AssetDebugAction({required super.assets});
@override
AssetDebugActionView resolve(ActionScope scope) => .new(assets: assets, scope: scope);
}
@visibleForTesting
class AssetDebugActionView extends AssetActionView<BaseAsset> {
const AssetDebugActionView({required super.assets, required super.scope});
@override
IconData get icon => Icons.help_outline_rounded;
@override
String label(ActionScope scope) => scope.context.t.troubleshoot;
String get label => scope.context.t.troubleshoot;
@override
bool isVisible(ActionScope scope) =>
assets.length == 1 && scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
bool get isVisible => scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting) && assets.length == 1;
@override
Future<void> onAction(ActionScope scope) async =>
unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
Future<void> onAction() async => unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
}
@@ -6,33 +6,58 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class FavoriteAction extends AssetAction<RemoteAsset> {
final bool favorite;
class FavoriteAction extends BaseAction {
final Iterable<BaseAsset> assets;
FavoriteAction({required super.assets}) : favorite = assets.any((asset) => !asset.isFavorite);
const FavoriteAction({required this.assets});
@override
IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
@override
String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite;
@override
Iterable<RemoteAsset> filter(ActionScope scope) =>
AssetFilter(assets).owned(scope.authUser.id).favorite(isFavorite: !favorite);
@override
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:ref, :context) = scope;
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateFavorite(assets, favorite);
final message = favorite
? context.t.favorite_action_prompt(count: assets.length)
: context.t.unfavorite_action_prompt(count: assets.length);
ref.read(toastRepositoryProvider).success(message);
AssetActionView<RemoteAsset> resolve(ActionScope scope) {
final hasNonFavorite = AssetFilter(assets).owned(scope.authUser.id).any((asset) => !asset.isFavorite);
return hasNonFavorite ? FavoriteView(assets: assets, scope: scope) : UnfavoriteView(assets: assets, scope: scope);
}
}
@visibleForTesting
class FavoriteView extends AssetActionView<RemoteAsset> {
const FavoriteView({required super.assets, required super.scope});
@override
IconData get icon => Icons.favorite_border_rounded;
@override
String get label => scope.context.t.favorite;
@override
AssetFilter<RemoteAsset> get filter => .new(assets).owned(scope.authUser.id).favorite(isFavorite: false);
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
final ids = filter.map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateFavorite(ids, true);
ref.read(toastRepositoryProvider).success(context.t.favorite_action_prompt(count: ids.length));
}
}
@visibleForTesting
class UnfavoriteView extends AssetActionView<RemoteAsset> {
const UnfavoriteView({required super.assets, required super.scope});
@override
IconData get icon => Icons.favorite_rounded;
@override
String get label => scope.context.t.unfavorite;
@override
AssetFilter<RemoteAsset> get filter => .new(assets).owned(scope.authUser.id).favorite();
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
final ids = filter.map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateFavorite(ids, false);
ref.read(toastRepositoryProvider).success(context.t.unfavorite_action_prompt(count: ids.length));
}
}
@@ -11,14 +11,22 @@ import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
class PartnerAddAction extends BaseAction {
const PartnerAddAction();
@override
PartnerAddActionView resolve(ActionScope scope) => .new(scope: scope);
}
@visibleForTesting
class PartnerAddActionView extends ActionView {
const PartnerAddActionView({required super.scope});
@override
IconData get icon => Icons.person_add_rounded;
@override
String label(ActionScope scope) => scope.context.t.add_partner;
String get label => scope.context.t.add_partner;
@override
Future<void> onAction(ActionScope scope) async {
Future<void> onAction() async {
final ActionScope(:context, :ref, :authUser) = scope;
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
if (selected == null) {
@@ -35,14 +43,26 @@ class PartnerRemoveAction extends BaseAction {
final String sharedWithId;
final String partnerName;
@override
PartnerRemoveActionView resolve(ActionScope scope) =>
.new(sharedWithId: sharedWithId, partnerName: partnerName, scope: scope);
}
@visibleForTesting
class PartnerRemoveActionView extends ActionView {
final String sharedWithId;
final String partnerName;
const PartnerRemoveActionView({required this.sharedWithId, required this.partnerName, required super.scope});
@override
IconData get icon => Icons.person_remove_rounded;
@override
String label(ActionScope scope) => scope.context.t.remove;
String get label => scope.context.t.remove;
@override
Future<void> onAction(ActionScope scope) async {
Future<void> onAction() async {
final ActionScope(:context, :ref, :authUser) = scope;
final confirmed = await showDialog<bool>(
@@ -9,22 +9,27 @@ import 'package:immich_mobile/utils/asset_filter.dart';
class RestoreAction extends AssetAction<RemoteAsset> {
const RestoreAction({required super.assets});
@override
RestoreActionView resolve(ActionScope scope) => .new(assets: assets, scope: scope);
}
@visibleForTesting
class RestoreActionView extends AssetActionView<RemoteAsset> {
const RestoreActionView({required super.assets, required super.scope});
@override
IconData get icon => Icons.history_rounded;
@override
String label(ActionScope scope) => scope.context.t.restore;
String get label => scope.context.t.restore;
@override
Iterable<RemoteAsset> filter(ActionScope scope) => AssetFilter(assets).owned(scope.authUser.id).trashed();
AssetFilter<RemoteAsset> get filter => .new(assets).owned(scope.authUser.id).trashed();
@override
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
final ids = filter(scope).map((asset) => asset.id).toList(growable: false);
final ids = filter.map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).restoreTrash(ids);
ref.read(toastRepositoryProvider).success(context.t.assets_restored_count(count: ids.length));
}
@@ -7,37 +7,59 @@ import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class StackAction extends AssetAction<RemoteAsset> {
final bool stack;
StackAction({required super.assets}) : stack = assets.any((asset) => asset is RemoteAsset && asset.stackId == null);
const StackAction({required super.assets});
@override
IconData get icon => stack ? Icons.filter_none_rounded : Icons.layers_clear_outlined;
@override
String label(ActionScope scope) => stack ? scope.context.t.stack : scope.context.t.unstack;
@override
Iterable<RemoteAsset> filter(ActionScope scope) => AssetFilter(assets).owned(scope.authUser.id);
@override
bool isVisible(ActionScope scope) => stack ? filter(scope).length > 1 : filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:ref, :context) = scope;
final assets = filter(scope).toList(growable: false);
final service = ref.read(assetServiceProvider);
if (stack) {
await service.stack(scope.authUser.id, assets.map((asset) => asset.id).toList(growable: false));
} else {
await service.unstack(assets.map((asset) => asset.stackId).nonNulls.toList(growable: false));
}
final message = stack
? context.t.stacked_assets_count(count: assets.length)
: context.t.unstacked_assets_count(count: assets.length);
ref.read(toastRepositoryProvider).success(message);
AssetActionView<RemoteAsset> resolve(ActionScope scope) {
final unstacked = AssetFilter(assets).owned(scope.authUser.id).any((asset) => asset.stackId == null);
return unstacked ? StackView(assets: assets, scope: scope) : UnstackView(assets: assets, scope: scope);
}
}
@visibleForTesting
class StackView extends AssetActionView<RemoteAsset> {
const StackView({required super.assets, required super.scope});
@override
IconData get icon => Icons.filter_none_rounded;
@override
String get label => scope.context.t.stack;
@override
AssetFilter<RemoteAsset> get filter => .new(assets).owned(scope.authUser.id);
@override
bool get isVisible => filter.length > 1;
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
final ids = filter.map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).stack(scope.authUser.id, ids);
ref.read(toastRepositoryProvider).success(context.t.stacked_assets_count(count: ids.length));
}
}
@visibleForTesting
class UnstackView extends AssetActionView<RemoteAsset> {
const UnstackView({required super.assets, required super.scope});
@override
IconData get icon => Icons.layers_clear_outlined;
@override
String get label => scope.context.t.unstack;
@override
AssetFilter<RemoteAsset> get filter => .new(assets).owned(scope.authUser.id);
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
final assets = filter.toList(growable: false);
final stackIds = assets.map((asset) => asset.stackId).nonNulls.toList(growable: false);
await ref.read(assetServiceProvider).unstack(stackIds);
ref.read(toastRepositoryProvider).success(context.t.unstacked_assets_count(count: assets.length));
}
}
@@ -8,17 +8,27 @@ class TimelineAction extends BaseAction {
const TimelineAction({required this.action});
@override
IconData get icon => action.icon;
TimelineActionView resolve(ActionScope scope) => .new(view: action.resolve(scope), scope: scope);
}
@visibleForTesting
class TimelineActionView extends ActionView {
final ActionView view;
const TimelineActionView({required this.view, required super.scope});
@override
String label(ActionScope scope) => action.label(scope);
IconData get icon => view.icon;
@override
bool isVisible(ActionScope scope) => action.isVisible(scope);
String get label => view.label;
@override
Future<void> onAction(ActionScope scope) async {
await action.onAction(scope);
bool get isVisible => view.isVisible;
@override
Future<void> onAction() async {
await view.onAction();
scope.ref.read(multiSelectProvider.notifier).reset();
}
}
+3 -7
View File
@@ -8,7 +8,7 @@ import 'package:openapi/api.dart';
// ignore: depend_on_referenced_packages
import 'package:stack_trace/stack_trace.dart';
void handleError(BuildContext context, Object error, {StackTrace? stack, String? description}) {
void handleError(Object error, {StackTrace? stack, String? description}) {
String? stackTrace;
if (stack != null) {
final trace = Trace.from(stack);
@@ -23,17 +23,13 @@ void handleError(BuildContext context, Object error, {StackTrace? stack, String?
() => 'Error${description != null ? ' ($description)' : ''}: $error${stackTrace != null ? '\n$stackTrace' : ''}',
);
if (!context.mounted) {
return;
}
final String message;
if (serverErrorMessage(error) case String serverMessage) {
message = serverMessage;
} else if (isConnectionError(error)) {
message = context.t.login_form_server_error;
message = StaticTranslations.instance.login_form_server_error;
} else {
message = context.t.scaffold_body_error_occurred;
message = StaticTranslations.instance.scaffold_body_error_occurred;
}
snackbar.error(message);
@@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
@@ -27,27 +28,33 @@ void main() {
group('ArchiveAction', () {
testWidgets('archives the eligible owned assets', (tester) async {
final asset = owned();
final view = await tester.pumpTestAction(context, ArchiveAction(assets: [asset]));
await tester.pumpTestAction(context, ArchiveAction(assets: [asset]));
expect(view, isA<ArchiveView>());
expect(view.icon, Icons.archive_outlined);
expect(view.label, StaticTranslations.instance.archive);
verify(() => assetService.updateVisibility([asset.id], AssetVisibility.archive)).called(1);
verify(() => assetService.updateVisibility([asset.id], .archive)).called(1);
});
testWidgets('unarchive the eligible owned assets', (tester) async {
final asset = owned(visibility: AssetVisibility.archive);
final asset = owned(visibility: .archive);
final view = await tester.pumpTestAction(context, ArchiveAction(assets: [asset]));
await tester.pumpTestAction(context, ArchiveAction(assets: [asset]));
expect(view, isA<UnarchiveView>());
expect(view.icon, Icons.unarchive_outlined);
expect(view.label, StaticTranslations.instance.unarchive);
verify(() => assetService.updateVisibility([asset.id], AssetVisibility.timeline)).called(1);
verify(() => assetService.updateVisibility([asset.id], .timeline)).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
final mine = owned(visibility: .archive);
final theirs = RemoteAssetFactory.create();
final view = await tester.pumpTestAction(context, ArchiveAction(assets: [mine, theirs]));
expect(view, isA<UnarchiveView>());
await tester.pumpTestAction(context, ArchiveAction(assets: [mine, theirs]));
verify(() => assetService.updateVisibility([mine.id], AssetVisibility.archive)).called(1);
verify(() => assetService.updateVisibility([mine.id], .timeline)).called(1);
});
testWidgets('batches every eligible owned asset into a single call', (tester) async {
@@ -56,16 +63,16 @@ void main() {
await tester.pumpTestAction(context, ArchiveAction(assets: [first, second]));
verify(() => assetService.updateVisibility([first.id, second.id], AssetVisibility.archive)).called(1);
verify(() => assetService.updateVisibility([first.id, second.id], .archive)).called(1);
});
testWidgets('skips owned assets already in the target state', (tester) async {
testWidgets('archives only the owned assets not already archived', (tester) async {
final stale = owned();
final alreadyArchived = owned(visibility: AssetVisibility.archive);
final alreadyArchived = owned(visibility: .archive);
await tester.pumpTestAction(context, ArchiveAction(assets: [stale, alreadyArchived]));
verify(() => assetService.updateVisibility([stale.id], AssetVisibility.archive)).called(1);
verify(() => assetService.updateVisibility([stale.id], .archive)).called(1);
});
testWidgets('reports the archived count through the toast repository', (tester) async {
@@ -84,8 +91,8 @@ void main() {
context,
ArchiveAction(
assets: [
owned(visibility: AssetVisibility.archive),
owned(visibility: AssetVisibility.archive),
owned(visibility: .archive),
owned(visibility: .archive),
],
),
);
@@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
@@ -27,27 +28,33 @@ void main() {
group('FavoriteAction', () {
testWidgets('favorites the eligible owned assets', (tester) async {
final asset = owned();
final view = await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
expect(view, isA<FavoriteView>());
expect(view.icon, Icons.favorite_border_rounded);
expect(view.label, StaticTranslations.instance.favorite);
verify(() => assetService.updateFavorite([asset.id], true)).called(1);
});
testWidgets('unfavorite the eligible owned assets', (tester) async {
final asset = owned(isFavorite: true);
final view = await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
expect(view, isA<UnfavoriteView>());
expect(view.icon, Icons.favorite_rounded);
expect(view.label, StaticTranslations.instance.unfavorite);
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
final mine = owned(isFavorite: true);
final theirs = RemoteAssetFactory.create();
final view = await tester.pumpTestAction(context, FavoriteAction(assets: [mine, theirs]));
expect(view, isA<UnfavoriteView>());
await tester.pumpTestAction(context, FavoriteAction(assets: [mine, theirs]));
verify(() => assetService.updateFavorite([mine.id], true)).called(1);
verify(() => assetService.updateFavorite([mine.id], false)).called(1);
});
testWidgets('batches every eligible owned asset into a single call', (tester) async {
@@ -59,7 +66,7 @@ void main() {
verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1);
});
testWidgets('skips owned assets already in the target state', (tester) async {
testWidgets('favorites only the owned assets not already favorite', (tester) async {
final stale = owned();
final alreadyFavorite = owned(isFavorite: true);
@@ -42,7 +42,7 @@ void main() {
verify(() => assetService.restoreTrash([mine.id])).called(1);
});
testWidgets('skips owned assets that are not trashed', (tester) async {
testWidgets('restores only the owned assets that are trashed', (tester) async {
final trashed = owned();
final live = owned(trashed: false);
@@ -62,6 +62,7 @@ void main() {
testWidgets('reports success through the toast repository with the restored count', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(context, RestoreAction(assets: [owned(), owned()]));
final message = verify(() => toast.success(captureAny())).captured.single as String;
@@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
@@ -27,30 +28,37 @@ void main() {
testWidgets('stacks the eligible owned assets', (tester) async {
final first = owned();
final second = owned();
final view = await tester.pumpTestAction(context, StackAction(assets: [first, second]));
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
expect(view, isA<StackView>());
expect(view.icon, Icons.filter_none_rounded);
expect(view.label, StaticTranslations.instance.stack);
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
});
testWidgets('unstacks the eligible owned assets', (tester) async {
final asset = owned(stackId: 'stack');
final view = await tester.pumpTestAction(context, StackAction(assets: [asset]));
await tester.pumpTestAction(context, StackAction(assets: [asset]));
expect(view, isA<UnstackView>());
expect(view.icon, Icons.layers_clear_outlined);
expect(view.label, StaticTranslations.instance.unstack);
verify(() => assetService.unstack(['stack'])).called(1);
});
testWidgets('prioritizes stack when mixed state', (tester) async {
testWidgets('prioritizes stack when the owned selection is mixed', (tester) async {
final first = owned();
final second = owned(stackId: 'stack');
final view = await tester.pumpTestAction(context, StackAction(assets: [first, second]));
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
expect(view, isA<StackView>());
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
final mine = owned();
final other = owned();
final theirs = RemoteAssetFactory.create();
@@ -18,21 +18,30 @@ class _FakeAction extends BaseAction {
bool ran = false;
bool? selectionDuringOnAction;
@override
ActionView resolve(ActionScope scope) => _FakeActionView(this, scope);
}
class _FakeActionView extends ActionView {
final _FakeAction action;
_FakeActionView(this.action, ActionScope scope) : super(scope: scope);
@override
IconData get icon => Icons.bolt;
@override
String label(ActionScope scope) => 'fake';
String get label => 'fake';
@override
bool isVisible(ActionScope scope) => visible;
bool get isVisible => action.visible;
@override
Future<void> onAction(ActionScope scope) async {
ran = true;
selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled;
if (error != null) {
throw error!;
Future<void> onAction() async {
action.ran = true;
action.selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled;
if (action.error != null) {
throw action.error!;
}
}
}
@@ -77,7 +86,7 @@ void main() {
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);
await TimelineAction(action: inner).resolve(scope).onAction();
expect(inner.ran, isTrue);
expect(inner.selectionDuringOnAction, isTrue, reason: 'reset must run after the inner action, not before');
@@ -89,7 +98,7 @@ void main() {
final inner = _FakeAction(error: error);
final (scope, container) = await pumpScope(tester);
await expectLater(TimelineAction(action: inner).onAction(scope), throwsA(same(error)));
await expectLater(TimelineAction(action: inner).resolve(scope).onAction(), throwsA(same(error)));
expect(inner.ran, isTrue);
expect(container.read(multiSelectProvider).isEnabled, isTrue);
@@ -17,12 +17,10 @@ void main() {
group('PartnerSharedByList', () {
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
final action = const PartnerAddAction();
await tester.pumpTestWidget(context, const PartnerSharedByList(partners: []));
expect(find.byType(ListView), findsNothing);
expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget);
expect(find.widgetWithIcon(TextButton, Icons.person_add_rounded), findsOneWidget);
});
testWidgets('renders a tile per partner with name and email', (tester) async {
@@ -39,9 +37,8 @@ void main() {
testWidgets('renders a remove action for each partner', (tester) async {
final partner1 = PartnerFactory.create(inTimeline: true);
final partner2 = PartnerFactory.create();
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
expect(find.byIcon(action.icon), findsNWidgets(2));
expect(find.byIcon(Icons.person_remove_rounded), findsNWidgets(2));
});
});
@@ -98,14 +98,25 @@ extension PumpPresentationWidget on WidgetTester {
await pumpAndSettle();
}
Future<void> pumpTestAction(
Future<ActionView> pumpTestAction(
PresentationContext context,
BaseAction action, {
List<Override> overrides = const [],
}) async {
await pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides);
late ActionView view;
await pumpTestWidget(
context,
Consumer(
builder: (innerContext, ref, _) {
view = action.resolve(ActionScope(context: innerContext, ref: ref, authUser: context.currentUser));
return ActionIconButtonWidget(action: action);
},
),
overrides: overrides,
);
await tap(find.byType(ImmichIconButton));
await pump();
return view;
}
Future<void> pumpUntilFound(Finder finder, {int maxFrames = 10}) async {