Compare commits

..

1 Commits

Author SHA1 Message Date
shenlong-tanwen f3dd742a1f refactor: action secondary handler 2026-07-10 04:34:41 +05:30
7 changed files with 187 additions and 18 deletions
@@ -31,4 +31,6 @@ abstract class BaseAction {
const BaseAction({required this.scope, required this.icon, required this.label, this.isVisible = true});
Future<void> onAction();
Future<void> Function()? get onSecondaryAction => null;
}
@@ -11,8 +11,9 @@ class _ActionWidgetScope {
final IconData icon;
final String label;
final FutureOr<void> Function() onAction;
final FutureOr<void> Function()? onSecondaryAction;
const _ActionWidgetScope({required this.icon, required this.label, required this.onAction});
const _ActionWidgetScope({required this.icon, required this.label, required this.onAction, this.onSecondaryAction});
}
class _ActionWidget extends ConsumerWidget {
@@ -21,21 +22,35 @@ class _ActionWidget extends ConsumerWidget {
const _ActionWidget({required this.action, required this.builder});
Future<void> _onAction() async {
Future<void> _guard(Future<void> Function() handler) async {
try {
await action.onAction();
await handler();
} catch (error, stackTrace) {
handleError(error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
}
}
Future<void> Function() get _onAction =>
() => _guard(action.onAction);
Future<void> Function()? get _onSecondaryAction {
final onSecondaryAction = action.onSecondaryAction;
if (onSecondaryAction == null) {
return null;
}
return () => _guard(onSecondaryAction);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
if (!action.isVisible) {
return const SizedBox.shrink();
}
return builder(.new(icon: action.icon, label: action.label, onAction: _onAction));
return builder(
.new(icon: action.icon, label: action.label, onAction: _onAction, onSecondaryAction: _onSecondaryAction),
);
}
}
@@ -48,7 +63,8 @@ class ActionIconButtonWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichIconButton(icon: ctx.icon, onPressed: ctx.onAction, variant: variant),
builder: (ctx) =>
ImmichIconButton(icon: ctx.icon, onPressed: ctx.onAction, onLongPress: ctx.onSecondaryAction, variant: variant),
);
}
@@ -61,7 +77,13 @@ class ActionButtonWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichTextButton(labelText: ctx.label, icon: ctx.icon, onPressed: ctx.onAction, variant: variant),
builder: (ctx) => ImmichTextButton(
labelText: ctx.label,
icon: ctx.icon,
onPressed: ctx.onAction,
onLongPress: ctx.onSecondaryAction,
variant: variant,
),
);
}
@@ -73,7 +95,12 @@ class ActionColumnButtonWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichColumnButton(icon: ctx.icon, label: ctx.label, onPressed: ctx.onAction),
builder: (ctx) => ImmichColumnButton(
icon: ctx.icon,
label: ctx.label,
onPressed: ctx.onAction,
onLongPress: ctx.onSecondaryAction,
),
);
}
@@ -12,4 +12,17 @@ class TimelineAction extends BaseAction {
await action.onAction();
scope.ref.read(multiSelectProvider.notifier).reset();
}
@override
Future<void> Function()? get onSecondaryAction {
final inner = action.onSecondaryAction;
if (inner == null) {
return null;
}
return () async {
await inner();
scope.ref.read(multiSelectProvider.notifier).reset();
};
}
}
@@ -8,6 +8,7 @@ class ImmichColumnButton extends StatefulWidget {
final IconData icon;
final String label;
final FutureOr<void> Function() onPressed;
final FutureOr<void> Function()? onLongPress;
final bool disabled;
final bool? loading;
@@ -16,6 +17,7 @@ class ImmichColumnButton extends StatefulWidget {
required this.icon,
required this.label,
required this.onPressed,
this.onLongPress,
this.disabled = false,
this.loading,
});
@@ -28,10 +30,10 @@ class _ImmichColumnButtonState extends State<ImmichColumnButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
Future<void> _run(FutureOr<void> Function() action) async {
setState(() => _loading = true);
try {
await widget.onPressed();
await action();
} finally {
if (mounted) {
setState(() => _loading = false);
@@ -42,9 +44,12 @@ class _ImmichColumnButtonState extends State<ImmichColumnButton> {
@override
Widget build(BuildContext context) {
final foreground = context.colorOverride ?? Theme.of(context).colorScheme.onSurface;
final handlerDisabled = widget.disabled || _isLoading;
final onLongPress = widget.onLongPress;
return TextButton(
onPressed: widget.disabled || _isLoading ? null : _onPressed,
onPressed: handlerDisabled ? null : () => _run(widget.onPressed),
onLongPress: handlerDisabled || onLongPress == null ? null : () => _run(onLongPress),
style: TextButton.styleFrom(
foregroundColor: foreground,
padding: const .symmetric(horizontal: ImmichSpacing.sm, vertical: ImmichSpacing.md),
@@ -7,6 +7,7 @@ import 'package:immich_ui/src/internal.dart';
class ImmichIconButton extends StatefulWidget {
final IconData icon;
final FutureOr<void> Function() onPressed;
final FutureOr<void> Function()? onLongPress;
final ImmichVariant variant;
final ImmichColor color;
final bool disabled;
@@ -16,6 +17,7 @@ class ImmichIconButton extends StatefulWidget {
super.key,
required this.icon,
required this.onPressed,
this.onLongPress,
this.color = .primary,
this.variant = .filled,
this.disabled = false,
@@ -30,10 +32,10 @@ class _ImmichIconButtonState extends State<ImmichIconButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
Future<void> _run(FutureOr<void> Function() action) async {
setState(() => _loading = true);
try {
await widget.onPressed();
await action();
} finally {
if (mounted) {
setState(() => _loading = false);
@@ -66,6 +68,9 @@ class _ImmichIconButtonState extends State<ImmichIconButton> {
},
};
final handlerDisabled = widget.disabled || _isLoading;
final onLongPress = widget.onLongPress;
return IconButton(
icon: _isLoading
? const SizedBox.square(
@@ -73,7 +78,8 @@ class _ImmichIconButtonState extends State<ImmichIconButton> {
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.md),
)
: Icon(widget.icon),
onPressed: widget.disabled || _isLoading ? null : _onPressed,
onPressed: handlerDisabled ? null : () => _run(widget.onPressed),
onLongPress: handlerDisabled || onLongPress == null ? null : () => _run(onLongPress),
style: IconButton.styleFrom(backgroundColor: background, foregroundColor: foreground),
);
}
@@ -7,6 +7,7 @@ class ImmichTextButton extends StatefulWidget {
final String labelText;
final IconData? icon;
final FutureOr<void> Function() onPressed;
final FutureOr<void> Function()? onLongPress;
final ImmichVariant variant;
final bool expanded;
final bool disabled;
@@ -17,6 +18,7 @@ class ImmichTextButton extends StatefulWidget {
required this.labelText,
this.icon,
required this.onPressed,
this.onLongPress,
this.variant = .filled,
this.expanded = true,
@@ -32,10 +34,10 @@ class _ImmichTextButtonState extends State<ImmichTextButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
Future<void> _run(FutureOr<void> Function() action) async {
setState(() => _loading = true);
try {
await widget.onPressed();
await action();
} finally {
if (mounted) {
setState(() => _loading = false);
@@ -59,11 +61,26 @@ class _ImmichTextButtonState extends State<ImmichTextButton> {
style: const .new(fontSize: ImmichTextSize.body, fontWeight: .bold),
);
final style = ElevatedButton.styleFrom(padding: const .symmetric(vertical: ImmichSpacing.md));
final onPressed = widget.disabled || _isLoading ? null : _onPressed;
final handlerDisabled = widget.disabled || _isLoading;
final longPress = widget.onLongPress;
final onPressed = handlerDisabled ? null : () => _run(widget.onPressed);
final onLongPress = handlerDisabled || longPress == null ? null : () => _run(longPress);
final button = switch (widget.variant) {
ImmichVariant.filled => ElevatedButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
ImmichVariant.ghost => TextButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
ImmichVariant.filled => ElevatedButton.icon(
style: style,
onPressed: onPressed,
onLongPress: onLongPress,
icon: icon,
label: label,
),
ImmichVariant.ghost => TextButton.icon(
style: style,
onPressed: onPressed,
onLongPress: onLongPress,
icon: icon,
label: label,
),
};
if (widget.expanded) {
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_ui/immich_ui.dart';
import '../presentation_context.dart';
class _RecordingAction extends BaseAction {
final void Function() onTap;
final void Function()? onLong;
const _RecordingAction._({
required this.onTap,
required this.onLong,
required super.scope,
required super.icon,
required super.label,
super.isVisible,
});
factory _RecordingAction(
ActionScope scope, {
required void Function() onTap,
void Function()? onLong,
bool isVisible = true,
}) => _RecordingAction._(
scope: scope,
onTap: onTap,
onLong: onLong,
icon: Icons.bug_report_outlined,
label: 'test',
isVisible: isVisible,
);
@override
Future<void> onAction() async => onTap();
@override
Future<void> Function()? get onSecondaryAction {
final callback = onLong;
return callback == null ? null : () async => callback();
}
}
void main() {
late PresentationContext context;
setUp(() async {
context = await PresentationContext.create();
});
tearDown(() {
context.dispose();
});
group('ActionIconButtonWidget', () {
testWidgets('renders nothing when the action is not visible', (tester) async {
await tester.pumpActionButton(context, (scope) => _RecordingAction(scope, onTap: () {}, isVisible: false));
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('wires no long press handler when the action has no secondary action', (tester) async {
await tester.pumpActionButton(context, (scope) => _RecordingAction(scope, onTap: () {}));
expect(tester.widget<ImmichIconButton>(find.byType(ImmichIconButton)).onLongPress, isNull);
});
testWidgets('tap runs the primary action', (tester) async {
var taps = 0;
var longPresses = 0;
await tester.pumpActionButton(
context,
(scope) => _RecordingAction(scope, onTap: () => taps++, onLong: () => longPresses++),
);
await tester.tap(find.byType(ImmichIconButton));
await tester.pump();
expect(taps, 1);
expect(longPresses, 0);
});
testWidgets('long press runs the secondary action, not the primary', (tester) async {
var taps = 0;
var longPresses = 0;
await tester.pumpActionButton(
context,
(scope) => _RecordingAction(scope, onTap: () => taps++, onLong: () => longPresses++),
);
await tester.longPress(find.byType(ImmichIconButton));
await tester.pump();
expect(longPresses, 1);
expect(taps, 0);
});
});
}