544 lines
15 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|