Files
Client/lib/widget/async_action_button.dart
T

544 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import '../api/errors/error_mapper.dart';
import 'app_progress_indicator.dart';
import 'info_dialog.dart';
Future<bool> runWithErrorDialog(
BuildContext context,
AsyncActionCallback action, {
AsyncErrorBuilder? errorBuilder,
}) async {
try {
await action();
return true;
} catch (e) {
if (!context.mounted) return false;
final message = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e);
final details = errorToTechnicalDetails(e);
final body = details != null && details != message ? '$message\n\n$details' : message;
InfoDialog.show(context, body, copyable: true, title: 'Fehler');
return false;
}
}
typedef AsyncActionCallback = Future<void> Function();
typedef AsyncErrorBuilder = String Function(Object error);
class AsyncActionController extends ChangeNotifier {
bool _busy = false;
String? _error;
bool get busy => _busy;
String? get error => _error;
Future<bool> run(
AsyncActionCallback action, {
AsyncErrorBuilder? errorBuilder,
}) async {
if (_busy) return false;
_busy = true;
_error = null;
notifyListeners();
try {
await action();
_busy = false;
notifyListeners();
return true;
} catch (e) {
_busy = false;
_error = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e);
notifyListeners();
return false;
}
}
void clearError() {
if (_error == null) return;
_error = null;
notifyListeners();
}
}
class _AsyncMixin extends StatefulWidget {
final AsyncActionCallback? onPressed;
final AsyncActionController? controller;
final AsyncErrorBuilder? errorBuilder;
final void Function(String message)? onError;
final VoidCallback? onSuccess;
final Widget Function(BuildContext context, bool busy, VoidCallback? handler) builder;
const _AsyncMixin({
required this.onPressed,
required this.builder,
this.controller,
this.errorBuilder,
this.onError,
this.onSuccess,
});
@override
State<_AsyncMixin> createState() => _AsyncMixinState();
}
class _AsyncMixinState extends State<_AsyncMixin> {
late final AsyncActionController _internal;
AsyncActionController get _controller => widget.controller ?? _internal;
@override
void initState() {
super.initState();
if (widget.controller == null) {
_internal = AsyncActionController();
}
_controller.addListener(_onControllerChange);
}
@override
void didUpdateWidget(covariant _AsyncMixin oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
(oldWidget.controller ?? _internal).removeListener(_onControllerChange);
_controller.addListener(_onControllerChange);
}
}
@override
void dispose() {
_controller.removeListener(_onControllerChange);
if (widget.controller == null) {
_internal.dispose();
}
super.dispose();
}
void _onControllerChange() {
if (mounted) setState(() {});
}
Future<void> _trigger() async {
final action = widget.onPressed;
if (action == null) return;
final success = await _controller.run(action, errorBuilder: widget.errorBuilder);
if (!mounted) return;
if (success) {
widget.onSuccess?.call();
} else if (widget.onError != null && _controller.error != null) {
widget.onError!(_controller.error!);
}
}
@override
Widget build(BuildContext context) {
final handler = widget.onPressed == null ? null : _trigger;
return widget.builder(context, _controller.busy, _controller.busy ? null : handler);
}
}
class AsyncActionButton extends StatelessWidget {
final AsyncActionCallback? onPressed;
final Widget child;
final IconData? icon;
final ButtonStyle? style;
final AsyncActionController? controller;
final AsyncErrorBuilder? errorBuilder;
final void Function(String message)? onError;
final VoidCallback? onSuccess;
final bool showInlineError;
const AsyncActionButton({
required this.onPressed,
required this.child,
this.icon,
this.style,
this.controller,
this.errorBuilder,
this.onError,
this.onSuccess,
this.showInlineError = true,
super.key,
});
@override
Widget build(BuildContext context) => _AsyncMixin(
onPressed: onPressed,
controller: controller,
errorBuilder: errorBuilder,
onError: onError,
onSuccess: onSuccess,
builder: (context, busy, handler) {
final spinner = AppProgressIndicator.small(
color: Theme.of(context).colorScheme.onPrimary,
);
final content = busy
? Row(
mainAxisSize: MainAxisSize.min,
children: [spinner, const SizedBox(width: 8), child],
)
: (icon != null
? Row(
mainAxisSize: MainAxisSize.min,
children: [Icon(icon), const SizedBox(width: 8), child],
)
: child);
final button = ElevatedButton(
onPressed: handler,
style: style,
child: content,
);
return _withInlineError(context, button);
},
);
Widget _withInlineError(BuildContext context, Widget button) {
if (!showInlineError) return button;
return _InlineErrorWrapper(controller: controller, child: button);
}
}
class AsyncTextButton extends StatelessWidget {
final AsyncActionCallback? onPressed;
final Widget child;
final AsyncActionController? controller;
final AsyncErrorBuilder? errorBuilder;
final void Function(String message)? onError;
final VoidCallback? onSuccess;
final bool showInlineError;
const AsyncTextButton({
required this.onPressed,
required this.child,
this.controller,
this.errorBuilder,
this.onError,
this.onSuccess,
this.showInlineError = true,
super.key,
});
@override
Widget build(BuildContext context) => _AsyncMixin(
onPressed: onPressed,
controller: controller,
errorBuilder: errorBuilder,
onError: onError,
onSuccess: onSuccess,
builder: (context, busy, handler) {
final content = busy
? Row(
mainAxisSize: MainAxisSize.min,
children: [
AppProgressIndicator.small(
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
child,
],
)
: child;
return _InlineErrorWrapper(
controller: controller,
child: TextButton(onPressed: handler, child: content),
);
},
);
}
class AsyncIconButton extends StatelessWidget {
final AsyncActionCallback? onPressed;
final IconData icon;
final Color? color;
final String? tooltip;
final AsyncActionController? controller;
final AsyncErrorBuilder? errorBuilder;
final void Function(String message)? onError;
final VoidCallback? onSuccess;
const AsyncIconButton({
required this.onPressed,
required this.icon,
this.color,
this.tooltip,
this.controller,
this.errorBuilder,
this.onError,
this.onSuccess,
super.key,
});
@override
Widget build(BuildContext context) => _AsyncMixin(
onPressed: onPressed,
controller: controller,
errorBuilder: errorBuilder,
onError: onError,
onSuccess: onSuccess,
builder: (context, busy, handler) {
if (busy) {
return Padding(
padding: const EdgeInsets.all(12),
child: AppProgressIndicator.small(color: color),
);
}
return IconButton(
icon: Icon(icon, color: color),
tooltip: tooltip,
onPressed: handler,
);
},
);
}
class AsyncFab extends StatelessWidget {
final AsyncActionCallback? onPressed;
final IconData icon;
final Color? backgroundColor;
final Color? foregroundColor;
final Object? heroTag;
final AsyncActionController? controller;
final AsyncErrorBuilder? errorBuilder;
final void Function(String message)? onError;
final VoidCallback? onSuccess;
final bool mini;
const AsyncFab({
required this.onPressed,
required this.icon,
this.backgroundColor,
this.foregroundColor,
this.heroTag,
this.controller,
this.errorBuilder,
this.onError,
this.onSuccess,
this.mini = false,
super.key,
});
@override
Widget build(BuildContext context) => _AsyncMixin(
onPressed: onPressed,
controller: controller,
errorBuilder: errorBuilder,
onError: onError,
onSuccess: onSuccess,
builder: (context, busy, handler) {
final fg = foregroundColor ?? Theme.of(context).colorScheme.onPrimary;
return FloatingActionButton(
heroTag: heroTag,
backgroundColor: backgroundColor,
foregroundColor: fg,
mini: mini,
onPressed: handler,
child: busy ? AppProgressIndicator.small(color: fg) : Icon(icon),
);
},
);
}
class AsyncListTile extends StatefulWidget {
final AsyncActionCallback onPressed;
final Widget? leading;
final Widget title;
final Widget? subtitle;
final bool closeOnSuccess;
final VoidCallback? onSuccess;
final AsyncErrorBuilder? errorBuilder;
final bool enabled;
const AsyncListTile({
required this.onPressed,
required this.title,
this.leading,
this.subtitle,
this.closeOnSuccess = true,
this.onSuccess,
this.errorBuilder,
this.enabled = true,
super.key,
});
@override
State<AsyncListTile> createState() => _AsyncListTileState();
}
class _AsyncListTileState extends State<AsyncListTile> {
final AsyncActionController _controller = AsyncActionController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _handleTap() async {
final ok = await _controller.run(widget.onPressed, errorBuilder: widget.errorBuilder);
if (!mounted) return;
if (ok) {
widget.onSuccess?.call();
if (widget.closeOnSuccess && Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
}
}
@override
Widget build(BuildContext context) => AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final busy = _controller.busy;
final err = _controller.error;
final leading = busy
? const SizedBox(
width: 24,
height: 24,
child: AppProgressIndicator.small(),
)
: widget.leading;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
leading: leading,
title: widget.title,
subtitle: widget.subtitle,
enabled: widget.enabled && !busy,
onTap: busy ? null : _handleTap,
),
if (err != null)
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
child: Text(
err,
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13),
),
),
],
);
},
);
}
class _InlineErrorWrapper extends StatelessWidget {
final AsyncActionController? controller;
final Widget child;
const _InlineErrorWrapper({required this.controller, required this.child});
@override
Widget build(BuildContext context) {
final c = controller;
if (c == null) return child;
return AnimatedBuilder(
animation: c,
builder: (context, _) {
final err = c.error;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
child,
if (err != null) ...[
const SizedBox(height: 8),
Text(
err,
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13),
),
],
],
);
},
);
}
}
class AsyncDialogAction extends StatefulWidget {
final String confirmLabel;
final AsyncActionCallback onConfirm;
final String? cancelLabel;
final AsyncErrorBuilder? errorBuilder;
final ButtonStyle? confirmStyle;
const AsyncDialogAction({
required this.confirmLabel,
required this.onConfirm,
this.cancelLabel = 'Abbrechen',
this.errorBuilder,
this.confirmStyle,
super.key,
});
@override
State<AsyncDialogAction> createState() => _AsyncDialogActionState();
}
class _AsyncDialogActionState extends State<AsyncDialogAction> {
final AsyncActionController _controller = AsyncActionController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final err = _controller.error;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (err != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
err,
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (widget.cancelLabel != null)
TextButton(
onPressed: _controller.busy ? null : () => Navigator.of(context).pop(),
child: Text(widget.cancelLabel!),
),
TextButton(
style: widget.confirmStyle,
onPressed: _controller.busy
? null
: () async {
final ok = await _controller.run(
widget.onConfirm,
errorBuilder: widget.errorBuilder,
);
if (ok && context.mounted) {
Navigator.of(context).pop(true);
}
},
child: _controller.busy
? Row(
mainAxisSize: MainAxisSize.min,
children: [
AppProgressIndicator.small(
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(widget.confirmLabel),
],
)
: Text(widget.confirmLabel),
),
],
),
],
);
},
);
}