chain-studio/lib/widgets/fai_error_box.dart
flemming-it c1c60d5434
Some checks failed
Security / Security check (push) Failing after 1s
feat(studio): rich theme picker (presets + custom colour) + editor 0.11.0
Settings dialog's Theme Plugin section is now a grid of
swatched tiles:

- Built-in (none) — falls back to FaiTheme.light/.dark
- One tile per installed studio.theme.* plugin, each
  showing the plugin's primary/secondary/tertiary as
  live colour dots. Tile loads its preview lazily so a
  dozen installed themes don't block the picker.
- Custom — opens a colour-picker dialog with 12 curated
  Material presets + a hex input + live preview. Selecting
  applies ColorScheme.fromSeed for both brightnesses.

main.dart's _pluginThemes parses a 'custom:#RRGGBB' sigil
in the same notifier slot as plugin capability ids, so the
existing persistence + restoration paths cover the custom
case with no new state.

Bumps editor to 0.11.0 (type-checked port connections +
dynamic card width fix + card-height border allowance) and
Studio to 0.58.0.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-06-02 01:29:34 +02:00

199 lines
6.7 KiB
Dart

// 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<FaiErrorBox> createState() => _FaiErrorBoxState();
}
class _FaiErrorBoxState extends State<FaiErrorBox> {
bool _justCopied = false;
bool _detailExpanded = false;
Future<void> _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),
),
),
),
),
],
),
);
}
}