// FaiErrorBox — selectable monospace error / output block with // a small copy-to-clipboard button in the top-right corner. // // Used wherever the operator might want to paste daemon stderr // into a chat or bug report: the update banner, install // progress dialog, system-AI editor, audit-clear failures, etc. // SelectableText alone works for keyboard users but the copy // affordance has to be explicit so trackpad operators see it. import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../data/friendly_error.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; class FaiErrorBox extends StatefulWidget { /// The text the operator wants to read (and copy). Rendered /// monospace, selectable, multi-line. Ignored when [error] is /// supplied. final String text; /// When true, the box border + foreground colour come from the /// error palette. False renders neutral chrome — useful for /// success / informational copy that still wants the copy /// button. final bool isError; /// Optional max-height before the content scrolls inside the /// box. Falls back to no constraint when null so short /// messages don't get a useless scrollbar. final double? maxHeight; /// When non-null, the box renders the [friendlyError] mapping /// of this error: a one-line headline, a recovery-hint line /// (when one applies), and the verbatim original message /// collapsed behind a "Details" expander. Use this instead of /// [text] anywhere we'd otherwise show a raw /// `e.toString()` — gRPC-Errors come out as walls of code + /// trailers otherwise. final Object? error; const FaiErrorBox({ super.key, this.text = '', this.isError = false, this.maxHeight, this.error, }) : assert( text != '' || error != null, 'FaiErrorBox needs either text or error', ); @override State createState() => _FaiErrorBoxState(); } class _FaiErrorBoxState extends State { bool _justCopied = false; bool _detailExpanded = false; Future _copy(String what) async { await Clipboard.setData(ClipboardData(text: what)); if (!mounted) return; setState(() => _justCopied = true); Future.delayed(const Duration(seconds: 2), () { if (mounted) setState(() => _justCopied = false); }); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final accent = widget.isError ? theme.colorScheme.error : theme.colorScheme.outlineVariant; final fg = widget.isError ? theme.colorScheme.error : theme.colorScheme.onSurface; final friendly = widget.error == null ? null : friendlyError(widget.error!, l); final copyText = friendly?.detail ?? widget.text; return Container( width: double.infinity, padding: const EdgeInsets.fromLTRB( FaiSpace.sm, FaiSpace.xs, FaiSpace.xs, FaiSpace.sm, ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.sm), border: Border.all(color: accent.withValues(alpha: 0.4)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Align( alignment: Alignment.centerRight, child: Tooltip( message: _justCopied ? l.buttonCopied : l.buttonCopy, child: IconButton( icon: Icon( _justCopied ? Icons.check : Icons.content_copy, size: 14, ), visualDensity: VisualDensity.compact, padding: const EdgeInsets.all(4), constraints: const BoxConstraints(), onPressed: () => _copy(copyText), ), ), ), if (friendly != null) ...[ SelectableText( friendly.headline, style: theme.textTheme.bodyMedium?.copyWith( color: fg, fontWeight: FontWeight.w600, ), ), if (friendly.hint != null) ...[ const SizedBox(height: 4), Text( friendly.hint!, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], if (friendly.detail.isNotEmpty) ...[ const SizedBox(height: FaiSpace.sm), InkWell( onTap: () => setState(() { _detailExpanded = !_detailExpanded; }), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( _detailExpanded ? Icons.expand_less : Icons.expand_more, size: 14, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: 4), Text( _detailExpanded ? l.buttonHideDetails : l.buttonShowDetails, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), if (_detailExpanded) ...[ const SizedBox(height: FaiSpace.xs), ConstrainedBox( constraints: BoxConstraints( maxHeight: widget.maxHeight ?? double.infinity, ), child: Scrollbar( child: SingleChildScrollView( child: SelectableText( friendly.detail, style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant, ), ), ), ), ), ], ], ] else ConstrainedBox( constraints: BoxConstraints( maxHeight: widget.maxHeight ?? double.infinity, ), child: Scrollbar( child: SingleChildScrollView( child: SelectableText( widget.text, style: FaiTheme.mono(size: 11, color: fg), ), ), ), ), ], ), ); } }