// ChainFlowOutput — type-aware renderer for one entry in // `runSavedFlow`'s output map. Reads `FlowOutput` and dispatches // to the right widget: // // - text: SelectableText for plain text; Markdown widget // when the (hinted) MIME is text/markdown. // - json: indented JSON in a code-block container. // - bytes: inline image thumbnail when image/*, otherwise a // "{mime}, {size}" summary. Always offers Save-as. // - file: "Open" button → SystemActions.openInOs(uri). // // Stateless on purpose: the dialog wraps it; we never own a // future or a controller here. import 'dart:io'; import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import '../data/error_presentation.dart'; import '../data/flow_output.dart'; import '../data/format.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; class ChainFlowOutput extends StatelessWidget { final FlowOutput output; const ChainFlowOutput({super.key, required this.output}); @override Widget build(BuildContext context) { final theme = Theme.of(context); switch (output) { case FlowOutputText(:final text): return _TextView(text: text); case FlowOutputJson(:final pretty): return _CodeBlock(text: pretty, theme: theme); case FlowOutputBytes(:final bytes, :final mimeType): return _BytesView(bytes: bytes, mimeType: mimeType); case FlowOutputFile(:final uri, :final mimeType): return _FileView(uri: uri, mimeType: mimeType); case FlowOutputUnknown(): return _CodeBlock( text: AppLocalizations.of(context)!.flowsOutputUnknown, theme: theme, ); } } } /// Plain-text renderer. Auto-detects markdown via a cheap /// heuristic (leading `#` heading, fenced code block, or /// numbered/bulleted list) so flows that emit text/markdown /// without setting an explicit MIME still render formatted. class _TextView extends StatelessWidget { final String text; const _TextView({required this.text}); static final RegExp _markdownHeuristic = RegExp( r'(^|\n)(#{1,6}\s|```|\*\s|-\s|\d+\.\s)', ); bool get _looksLikeMarkdown => text.length < 64 * 1024 && _markdownHeuristic.hasMatch(text); @override Widget build(BuildContext context) { final theme = Theme.of(context); if (_looksLikeMarkdown) { return Container( width: double.infinity, padding: const EdgeInsets.all(ChainSpace.md), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(ChainRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: MarkdownBody( data: text, selectable: true, onTapLink: (_, href, _) async { if (href != null && href.isNotEmpty) { await SystemActions.openInOs(href); } }, styleSheet: ChainTheme.markdownStyle(theme), ), ); } return Container( width: double.infinity, padding: const EdgeInsets.all(ChainSpace.md), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(ChainRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: SelectableText( text, style: theme.textTheme.bodyMedium?.copyWith(height: 1.5), ), ); } } class _CodeBlock extends StatelessWidget { final String text; final ThemeData theme; const _CodeBlock({required this.text, required this.theme}); @override Widget build(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.all(ChainSpace.md), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(ChainRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: SelectableText( text, style: ChainTheme.mono(size: 12, color: theme.colorScheme.onSurface), ), ); } } class _BytesView extends StatelessWidget { final Uint8List bytes; final String mimeType; const _BytesView({required this.bytes, required this.mimeType}); bool get _isImage => mimeType.startsWith('image/'); /// Reasonable default filename for the Save dialog. Strips the /// MIME up to the slash and uses the suffix when known /// (image/png → output.png), otherwise a generic `.bin`. String _defaultFilename() { final slash = mimeType.indexOf('/'); if (slash < 0 || slash == mimeType.length - 1) return 'output.bin'; final sub = mimeType.substring(slash + 1); // Strip common parameter suffixes (`png; charset=...`). final semi = sub.indexOf(';'); final ext = semi < 0 ? sub : sub.substring(0, semi); // `vnd.openxmlformats-…wordprocessingml.document` → docx. if (ext.contains('wordprocessingml')) return 'output.docx'; if (ext.contains('spreadsheetml')) return 'output.xlsx'; if (ext.contains('presentationml')) return 'output.pptx'; return 'output.$ext'; } Future _save(BuildContext context) async { final l = AppLocalizations.of(context)!; final path = await FilePicker.saveFile( dialogTitle: l.flowsOutputSaveAsTitle, fileName: _defaultFilename(), ); if (path == null) return; try { await File(path).writeAsBytes(bytes, flush: true); if (!context.mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l.flowsOutputSavedAt(path)))); } catch (e) { if (!context.mounted) return; showFaiErrorSnack(context, 'flows.output.save', e); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Container( width: double.infinity, padding: const EdgeInsets.all(ChainSpace.md), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(ChainRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_isImage) ...[ ConstrainedBox( constraints: const BoxConstraints(maxHeight: 240), child: ClipRRect( borderRadius: BorderRadius.circular(ChainRadius.sm), child: Image.memory(bytes, fit: BoxFit.contain), ), ), const SizedBox(height: ChainSpace.sm), ], Text( '${mimeType.isEmpty ? l.flowsOutputBytesUnknownMime : mimeType}' ' · ${humanBytes(bytes.length)}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: ChainSpace.sm), OutlinedButton.icon( icon: const Icon(Icons.save_alt, size: 16), label: Text(l.flowsOutputSaveAs), onPressed: () => _save(context), ), ], ), ); } } class _FileView extends StatelessWidget { final String uri; final String mimeType; const _FileView({required this.uri, required this.mimeType}); Future _open(BuildContext context) async { // Most file-URIs come from modules that wrote a temp/output // path on the host. SystemActions normalises `file://` for // open-in-OS on all three platforms. final target = uri.startsWith('file://') ? uri.substring(7) : uri; final r = await SystemActions.openInOs(target); if (!context.mounted || r.ok) return; showFaiErrorSnack( context, 'flows.output.open', r.stderr.isEmpty ? 'open failed' : r.stderr, ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Container( width: double.infinity, padding: const EdgeInsets.all(ChainSpace.md), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(ChainRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SelectableText( uri, style: ChainTheme.mono(size: 11, color: theme.colorScheme.primary), ), if (mimeType.isNotEmpty) ...[ const SizedBox(height: 4), Text( mimeType, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], const SizedBox(height: ChainSpace.sm), OutlinedButton.icon( icon: const Icon(Icons.open_in_new, size: 16), label: Text(l.flowsOutputOpen), onPressed: () => _open(context), ), ], ), ); } }