import 'package:flutter/material.dart'; import '../api/errors/error_mapper.dart'; import 'app_progress_indicator.dart'; import 'info_dialog.dart'; Future 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 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 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 _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 createState() => _AsyncListTileState(); } class _AsyncListTileState extends State { final AsyncActionController _controller = AsyncActionController(); @override void dispose() { _controller.dispose(); super.dispose(); } Future _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 createState() => _AsyncDialogActionState(); } class _AsyncDialogActionState extends State { 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), ), ], ), ], ); }, ); }