mirror of
https://github.com/immich-app/immich.git
synced 2026-07-10 06:03:14 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3dd742a1f |
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user