From 34f2b7b3138f0dff9551bf8b91bdc40cfa655cef Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 25 May 2026 11:17:58 +0200 Subject: [PATCH] feat(studio): typed flow outputs + on-disk docs + UX polish Documentation system (the original "whole docs system is broken" complaint): - Studio reads inline docs from disk via the new getInstalledModuleDocs RPC. No network, no auth, no provider lock-in. - store.dart probes for MODULE.md eagerly when a module-detail sheet opens (a single file stat). The Documentation section only renders when the bundle actually shipped docs. - Shared FaiTheme.markdownStyle helper used by Welcome's DocReaderSheet and Store's DocsPanel so every inline doc reads in the same typography. The shared style forces code.backgroundColor = transparent to suppress the per-span bands flutter_markdown's default code style draws on dark themes. Flow run dialog: - Run-button gating now strips the @version suffix from installed capabilities before the contains() check. Without this fix the button stayed disabled for every flow with dependencies, even after a successful install. - Studio derives a MIME type from the picked file's extension (small per-suffix map; .pdf, .docx, .txt, .json, ...) and forwards it to the hub. Fixes "unsupported MIME type: application/octet-stream" from text.extract. - Dialog title tracks the future's state: running -> "extract laeuft", success -> "extract -- Ergebnis", failure -> "extract -- fehlgeschlagen". - Output rendering moved from String stringification to a sealed FlowOutput type (Text / Json / Bytes / File / Unknown). A new FaiFlowOutput widget dispatches per variant: markdown for text/markdown (heuristic), pretty JSON for proto Struct, inline image preview + Save-As for bytes, Open for file URIs. Byte sizes: - New humanBytes() helper renders 23.3 kB / 1.04 MB style values with three significant digits, matching Finder / GNOME Files. Wired into flow-card pills, picked-file readouts, and the bytes-payload preview line. Signed-off-by: flemming-it Signed-off-by: flemming-it --- lib/data/flow_output.dart | 85 +++++++++ lib/data/format.dart | 33 ++++ lib/data/hub.dart | 60 +++--- lib/l10n/app_de.arb | 15 ++ lib/l10n/app_en.arb | 15 ++ lib/l10n/app_localizations.dart | 60 ++++++ lib/l10n/app_localizations_de.dart | 40 ++++ lib/l10n/app_localizations_en.dart | 40 ++++ lib/pages/flows.dart | 140 +++++++++++--- lib/pages/store.dart | 292 ++++++----------------------- lib/pages/welcome.dart | 12 +- lib/theme/theme.dart | 64 +++++++ lib/widgets/fai_flow_output.dart | 288 ++++++++++++++++++++++++++++ lib/widgets/widgets.dart | 1 + 14 files changed, 849 insertions(+), 296 deletions(-) create mode 100644 lib/data/flow_output.dart create mode 100644 lib/data/format.dart create mode 100644 lib/widgets/fai_flow_output.dart diff --git a/lib/data/flow_output.dart b/lib/data/flow_output.dart new file mode 100644 index 0000000..e8d52ad --- /dev/null +++ b/lib/data/flow_output.dart @@ -0,0 +1,85 @@ +// FlowOutput — typed representation of one entry in +// `SubmitResponse.outputs`. Lets Studio render text vs. JSON vs. +// bytes vs. file with the right widget instead of stringifying +// everything into `` placeholders. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:fai_dart_sdk/fai_dart_sdk.dart' show Payload; + +/// One named output of a flow run. Sealed via factory constructors +/// — callers pattern-match with `switch` on the runtime type to +/// pick a renderer. Variants: +/// +/// - [FlowOutputText] : plain text. `mimeType` may be set +/// (e.g. `text/markdown`) so the +/// renderer can pick a Markdown widget. +/// - [FlowOutputJson] : a proto Struct, already pretty- +/// printed as JSON so the UI can render +/// it directly. +/// - [FlowOutputBytes] : raw bytes + MIME. The renderer +/// decides whether to inline a preview +/// (e.g. images) and exposes a +/// Save-As affordance otherwise. +/// - [FlowOutputFile] : a URI the host already wrote to. The +/// renderer offers an Open action. +/// - [FlowOutputUnknown] : oneof was empty / unset. Never +/// expected in practice but kept so +/// the UI never panics on a future +/// schema bump. +sealed class FlowOutput { + const FlowOutput(); + + factory FlowOutput.fromPayload(Payload p) { + if (p.hasText()) { + return FlowOutputText(text: p.text); + } + if (p.hasJson()) { + // proto Struct → toProto3Json gives a Dart-side + // Map/List/primitive tree. We re-encode with an indent so + // the UI can show the operator-friendly form directly. + final tree = p.json.toProto3Json(); + const encoder = JsonEncoder.withIndent(' '); + return FlowOutputJson(pretty: encoder.convert(tree)); + } + if (p.hasBytes()) { + return FlowOutputBytes( + bytes: Uint8List.fromList(p.bytes.data), + mimeType: p.bytes.mimeType, + ); + } + if (p.hasFile()) { + return FlowOutputFile(uri: p.file.uri, mimeType: p.file.mimeType); + } + return const FlowOutputUnknown(); + } +} + +class FlowOutputText extends FlowOutput { + final String text; + const FlowOutputText({required this.text}); +} + +class FlowOutputJson extends FlowOutput { + /// Already indented JSON, ready for SelectableText / Markdown + /// code-block rendering. + final String pretty; + const FlowOutputJson({required this.pretty}); +} + +class FlowOutputBytes extends FlowOutput { + final Uint8List bytes; + final String mimeType; + const FlowOutputBytes({required this.bytes, required this.mimeType}); +} + +class FlowOutputFile extends FlowOutput { + final String uri; + final String mimeType; + const FlowOutputFile({required this.uri, required this.mimeType}); +} + +class FlowOutputUnknown extends FlowOutput { + const FlowOutputUnknown(); +} diff --git a/lib/data/format.dart b/lib/data/format.dart new file mode 100644 index 0000000..ac85b47 --- /dev/null +++ b/lib/data/format.dart @@ -0,0 +1,33 @@ +// Display-only formatters shared across pages. Pure functions, +// no I/O, no locale fall-through — locale-specific labels (the +// unit word itself, e.g. "Bytes" vs. "bytes") stay in `.arb`; +// these helpers only handle the numeric formatting. + +/// Render a byte count with an SI suffix that stays at three +/// significant digits ("23.3 kB", "1.04 MB", "847 B"). Uses 1000 +/// as the base, not 1024 — matches macOS Finder / GNOME Files +/// and is what most operators expect when they read "kB". +/// +/// Returns plain `n B` for values under 1000 so small flows +/// and tiny inputs stay precise instead of becoming "0.5 kB". +String humanBytes(int bytes) { + if (bytes < 1000) return '$bytes B'; + const units = ['kB', 'MB', 'GB', 'TB', 'PB']; + var v = bytes.toDouble() / 1000.0; + var unitIndex = 0; + while (v >= 1000 && unitIndex < units.length - 1) { + v /= 1000.0; + unitIndex++; + } + // Pick decimals so the rendered number stays at 3 significant + // figures: 9.99 / 99.9 / 999 — same approach Finder uses. + final String formatted; + if (v >= 100) { + formatted = v.toStringAsFixed(0); + } else if (v >= 10) { + formatted = v.toStringAsFixed(1); + } else { + formatted = v.toStringAsFixed(2); + } + return '$formatted ${units[unitIndex]}'; +} diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 61d4f92..0745dd2 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -11,6 +11,9 @@ import 'package:fai_dart_sdk/fai_dart_sdk.dart'; import 'package:flutter/widgets.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'flow_output.dart'; +export 'flow_output.dart'; + class HubService { HubService._(); static final HubService instance = HubService._(); @@ -346,17 +349,26 @@ class HubService { return (name: r.name, version: r.version); } - /// Fetch a module's README markdown via the hub. Errors come - /// back as `errorKind` strings (not exceptions) so callers can - /// render fallback UI without try/catch ceremony. - Future<({String errorKind, String text, String sourceUrl})> - fetchModuleDocs(String name, {String locale = ''}) async { - final r = await _client.fetchModuleDocs(name, locale: locale); - return ( - errorKind: r.errorKind, - text: r.text, - sourceUrl: r.sourceUrl, - ); + /// Read the installed module's `MODULE.md` (or + /// `MODULE..md`) from disk under + /// `~/.fai/modules//`. Air-gap friendly, no network. + /// + /// `state` is one of: + /// - `'found'` — `text` holds the markdown + /// - `'no_docs'` — module installed but bundle had no docs + /// - `'not_installed'` — module isn't installed at all + Future<({String state, String text, String sourcePath})> + readInstalledModuleDocs(String name, {String locale = ''}) async { + final r = await _client.getInstalledModuleDocs(name, locale: locale); + final String state; + if (r.notInstalled) { + state = 'not_installed'; + } else if (r.text.isEmpty) { + state = 'no_docs'; + } else { + state = 'found'; + } + return (state: state, text: r.text, sourcePath: r.sourcePath); } /// Snapshot of every configured MCP server. @@ -560,35 +572,31 @@ class HubService { /// land as text Payloads, byte values as bytes Payloads — /// flows that mix both (e.g. extract taking a `document: /// bytes` plus a text-shaped option) flow through one RPC. - /// Returns the named outputs as a map of UI-friendly strings. - Future> runSavedFlow({ + /// [fileMimeTypes] is keyed the same as [fileInputs]; missing + /// entries fall back to `application/octet-stream`. Modules + /// often gate on MIME (the `text.extract` module refuses + /// octet-stream), so callers should provide a real type. + /// Returns the named outputs as a map of typed [FlowOutput] + /// values so each page can render text / JSON / bytes / file + /// with the right widget. + Future> runSavedFlow({ required String name, Map textInputs = const {}, Map fileInputs = const {}, + Map fileMimeTypes = const {}, }) async { final r = await _client.runSavedFlow( name: name, textInputs: textInputs, fileInputs: fileInputs, + fileMimeTypes: fileMimeTypes, ); return { for (final entry in r.outputs.entries) - entry.key: _payloadToText(entry.value), + entry.key: FlowOutput.fromPayload(entry.value), }; } - String _payloadToText(Payload p) { - if (p.hasText()) return p.text; - if (p.hasJson()) return p.json.toString(); - if (p.hasBytes()) { - return ''; - } - if (p.hasFile()) { - return ''; - } - return ''; - } - Future> recentEvents({ int limit = 50, List types = const [], diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index bbe24f4..5409e7e 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -506,8 +506,23 @@ "flowsRunButton": "Starten", "flowsRunningTitle": "{name} läuft", "@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } }, + "flowsResultTitle": "{name} — Ergebnis", + "@flowsResultTitle": { "placeholders": { "name": { "type": "String" } } }, + "flowsErrorTitle": "{name} — fehlgeschlagen", + "@flowsErrorTitle": { "placeholders": { "name": { "type": "String" } } }, "flowsRunning": "Flow läuft…", "flowsNoOutputs": "Flow abgeschlossen ohne deklarierte Outputs.", + "flowsOutputUnknown": "Unbekannter Output-Payload-Typ.", + "flowsOutputBytesUnknownMime": "Binärdaten", + "flowsOutputSaveAs": "Speichern unter…", + "flowsOutputSaveAsTitle": "Flow-Output speichern", + "flowsOutputSavedAt": "Gespeichert: {path}", + "@flowsOutputSavedAt": { "placeholders": { "path": { "type": "String" } } }, + "flowsOutputSaveFailed": "Speichern fehlgeschlagen: {error}", + "@flowsOutputSaveFailed": { "placeholders": { "error": { "type": "String" } } }, + "flowsOutputOpen": "Öffnen", + "flowsOutputOpenFailed": "Öffnen fehlgeschlagen: {error}", + "@flowsOutputOpenFailed": { "placeholders": { "error": { "type": "String" } } }, "approvalsTitle": "Freigaben", "approvalsTabPending": "Offen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3cc7081..da2c961 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -507,8 +507,23 @@ "flowsRunButton": "Run", "flowsRunningTitle": "Running {name}", "@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } }, + "flowsResultTitle": "{name} — result", + "@flowsResultTitle": { "placeholders": { "name": { "type": "String" } } }, + "flowsErrorTitle": "{name} — failed", + "@flowsErrorTitle": { "placeholders": { "name": { "type": "String" } } }, "flowsRunning": "Flow running…", "flowsNoOutputs": "Flow completed with no declared outputs.", + "flowsOutputUnknown": "Output payload variant not recognised.", + "flowsOutputBytesUnknownMime": "binary", + "flowsOutputSaveAs": "Save as…", + "flowsOutputSaveAsTitle": "Save flow output", + "flowsOutputSavedAt": "Saved to {path}", + "@flowsOutputSavedAt": { "placeholders": { "path": { "type": "String" } } }, + "flowsOutputSaveFailed": "Save failed: {error}", + "@flowsOutputSaveFailed": { "placeholders": { "error": { "type": "String" } } }, + "flowsOutputOpen": "Open", + "flowsOutputOpenFailed": "Open failed: {error}", + "@flowsOutputOpenFailed": { "placeholders": { "error": { "type": "String" } } }, "approvalsTitle": "Approvals", "approvalsTabPending": "Pending", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3c9c664..b733d6b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2282,6 +2282,18 @@ abstract class AppLocalizations { /// **'Running {name}'** String flowsRunningTitle(String name); + /// No description provided for @flowsResultTitle. + /// + /// In en, this message translates to: + /// **'{name} — result'** + String flowsResultTitle(String name); + + /// No description provided for @flowsErrorTitle. + /// + /// In en, this message translates to: + /// **'{name} — failed'** + String flowsErrorTitle(String name); + /// No description provided for @flowsRunning. /// /// In en, this message translates to: @@ -2294,6 +2306,54 @@ abstract class AppLocalizations { /// **'Flow completed with no declared outputs.'** String get flowsNoOutputs; + /// No description provided for @flowsOutputUnknown. + /// + /// In en, this message translates to: + /// **'Output payload variant not recognised.'** + String get flowsOutputUnknown; + + /// No description provided for @flowsOutputBytesUnknownMime. + /// + /// In en, this message translates to: + /// **'binary'** + String get flowsOutputBytesUnknownMime; + + /// No description provided for @flowsOutputSaveAs. + /// + /// In en, this message translates to: + /// **'Save as…'** + String get flowsOutputSaveAs; + + /// No description provided for @flowsOutputSaveAsTitle. + /// + /// In en, this message translates to: + /// **'Save flow output'** + String get flowsOutputSaveAsTitle; + + /// No description provided for @flowsOutputSavedAt. + /// + /// In en, this message translates to: + /// **'Saved to {path}'** + String flowsOutputSavedAt(String path); + + /// No description provided for @flowsOutputSaveFailed. + /// + /// In en, this message translates to: + /// **'Save failed: {error}'** + String flowsOutputSaveFailed(String error); + + /// No description provided for @flowsOutputOpen. + /// + /// In en, this message translates to: + /// **'Open'** + String get flowsOutputOpen; + + /// No description provided for @flowsOutputOpenFailed. + /// + /// In en, this message translates to: + /// **'Open failed: {error}'** + String flowsOutputOpenFailed(String error); + /// No description provided for @approvalsTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index b60a03f..1493743 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1308,12 +1308,52 @@ class AppLocalizationsDe extends AppLocalizations { return '$name läuft'; } + @override + String flowsResultTitle(String name) { + return '$name — Ergebnis'; + } + + @override + String flowsErrorTitle(String name) { + return '$name — fehlgeschlagen'; + } + @override String get flowsRunning => 'Flow läuft…'; @override String get flowsNoOutputs => 'Flow abgeschlossen ohne deklarierte Outputs.'; + @override + String get flowsOutputUnknown => 'Unbekannter Output-Payload-Typ.'; + + @override + String get flowsOutputBytesUnknownMime => 'Binärdaten'; + + @override + String get flowsOutputSaveAs => 'Speichern unter…'; + + @override + String get flowsOutputSaveAsTitle => 'Flow-Output speichern'; + + @override + String flowsOutputSavedAt(String path) { + return 'Gespeichert: $path'; + } + + @override + String flowsOutputSaveFailed(String error) { + return 'Speichern fehlgeschlagen: $error'; + } + + @override + String get flowsOutputOpen => 'Öffnen'; + + @override + String flowsOutputOpenFailed(String error) { + return 'Öffnen fehlgeschlagen: $error'; + } + @override String get approvalsTitle => 'Freigaben'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 612165f..b9523e3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1321,12 +1321,52 @@ class AppLocalizationsEn extends AppLocalizations { return 'Running $name'; } + @override + String flowsResultTitle(String name) { + return '$name — result'; + } + + @override + String flowsErrorTitle(String name) { + return '$name — failed'; + } + @override String get flowsRunning => 'Flow running…'; @override String get flowsNoOutputs => 'Flow completed with no declared outputs.'; + @override + String get flowsOutputUnknown => 'Output payload variant not recognised.'; + + @override + String get flowsOutputBytesUnknownMime => 'binary'; + + @override + String get flowsOutputSaveAs => 'Save as…'; + + @override + String get flowsOutputSaveAsTitle => 'Save flow output'; + + @override + String flowsOutputSavedAt(String path) { + return 'Saved to $path'; + } + + @override + String flowsOutputSaveFailed(String error) { + return 'Save failed: $error'; + } + + @override + String get flowsOutputOpen => 'Open'; + + @override + String flowsOutputOpenFailed(String error) { + return 'Open failed: $error'; + } + @override String get approvalsTitle => 'Approvals'; diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index 34829fc..8d4246a 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -5,6 +5,7 @@ import 'package:fai_dart_sdk/fai_dart_sdk.dart' show FlowInputDef; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import '../data/format.dart'; import '../data/hub.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; @@ -40,6 +41,18 @@ class _FlowsPageState extends State { flows: results[0] as List, installedCapabilities: (results[1] as List) .expand((m) => m.capabilities) + // `listModules()` emits capability strings with a + // `@version` suffix (e.g. `text.extract@0.1.0`). The + // flow's `requiredCapabilities` carry their own + // user-typed version constraint, so we compare on the + // bare capability name and let the hub do the strict + // version match at run time. Without this strip, the + // contains-check would never hit and the Run button + // would stay disabled even after a successful install. + .map((c) { + final at = c.indexOf('@'); + return at >= 0 ? c.substring(0, at) : c; + }) .toSet(), ); } @@ -74,6 +87,7 @@ class _FlowsPageState extends State { flow: flow, textInputs: inputs.textInputs, fileInputs: inputs.fileInputs, + fileMimeTypes: inputs.fileMimeTypes, ), ); } @@ -254,7 +268,7 @@ class _FlowCard extends StatelessWidget { ), ), FaiPill( - label: '${flow.sizeBytes} B', + label: humanBytes(flow.sizeBytes), tone: FaiPillTone.neutral, monospace: true, ), @@ -369,16 +383,75 @@ class _ClickablePill extends StatelessWidget { class _FlowRunInputs { final Map textInputs; final Map fileInputs; + /// Parallel to [fileInputs]; same keys. Modules like + /// `text.extract` reject `application/octet-stream`, so we + /// derive a real MIME type from the picked file's extension + /// and forward it to the hub. + final Map fileMimeTypes; const _FlowRunInputs({ required this.textInputs, required this.fileInputs, + required this.fileMimeTypes, }); } class _PickedFile { final String name; final Uint8List bytes; - const _PickedFile({required this.name, required this.bytes}); + /// MIME derived from the file extension at pick time. Empty + /// when the extension is unknown — the SDK then falls back to + /// `application/octet-stream`. + final String mimeType; + const _PickedFile({ + required this.name, + required this.bytes, + required this.mimeType, + }); +} + +/// Minimal extension → MIME map covering the file types that +/// shipped modules actually accept (PDF, DOCX for text.extract; +/// plain text, json, csv for upcoming modules). Keeping it tiny +/// avoids pulling the `mime` package as a new dependency. Returns +/// empty string for unknown extensions so the SDK default +/// (`application/octet-stream`) still applies. +String _mimeForFilename(String filename) { + final dot = filename.lastIndexOf('.'); + if (dot < 0 || dot == filename.length - 1) return ''; + final ext = filename.substring(dot + 1).toLowerCase(); + switch (ext) { + case 'pdf': + return 'application/pdf'; + case 'docx': + return 'application/vnd.openxmlformats-officedocument' + '.wordprocessingml.document'; + case 'doc': + return 'application/msword'; + case 'txt': + return 'text/plain'; + case 'md': + case 'markdown': + return 'text/markdown'; + case 'json': + return 'application/json'; + case 'csv': + return 'text/csv'; + case 'xml': + return 'application/xml'; + case 'yaml': + case 'yml': + return 'application/yaml'; + case 'html': + case 'htm': + return 'text/html'; + case 'png': + return 'image/png'; + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + default: + return ''; + } } /// Run-flow input dialog. Fetches the flow's declared input @@ -457,16 +530,26 @@ class _FlowInputDialogState extends State<_FlowInputDialog> { _FlowRunInputs _collect(List defs) { final textInputs = {}; final fileInputs = {}; + final fileMimeTypes = {}; for (final d in defs) { if (_isBytesType(d.type)) { final picked = _pickedFiles[d.name]; - if (picked != null) fileInputs[d.name] = picked.bytes; + if (picked != null) { + fileInputs[d.name] = picked.bytes; + if (picked.mimeType.isNotEmpty) { + fileMimeTypes[d.name] = picked.mimeType; + } + } } else { final c = _textControllers[d.name]; if (c != null) textInputs[d.name] = c.text; } } - return _FlowRunInputs(textInputs: textInputs, fileInputs: fileInputs); + return _FlowRunInputs( + textInputs: textInputs, + fileInputs: fileInputs, + fileMimeTypes: fileMimeTypes, + ); } Future _pickFile(String inputName) async { @@ -490,7 +573,11 @@ class _FlowInputDialogState extends State<_FlowInputDialog> { } if (bytes == null || !mounted) return; setState(() { - _pickedFiles[inputName] = _PickedFile(name: f.name, bytes: bytes!); + _pickedFiles[inputName] = _PickedFile( + name: f.name, + bytes: bytes!, + mimeType: _mimeForFilename(f.name), + ); }); } @@ -676,7 +763,7 @@ class _InputField extends StatelessWidget { pickedFile == null ? l.flowsTypeBytesNoFile : '${pickedFile!.name} · ' - '${l.flowsTypeBytesSize(pickedFile!.bytes.length)}', + '${humanBytes(pickedFile!.bytes.length)}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -702,11 +789,13 @@ class _FlowRunDialog extends StatefulWidget { final SavedFlow flow; final Map textInputs; final Map fileInputs; + final Map fileMimeTypes; const _FlowRunDialog({ required this.flow, required this.textInputs, required this.fileInputs, + required this.fileMimeTypes, }); @override @@ -714,7 +803,7 @@ class _FlowRunDialog extends StatefulWidget { } class _FlowRunDialogState extends State<_FlowRunDialog> { - late final Future> _future; + late final Future> _future; @override void initState() { @@ -723,6 +812,7 @@ class _FlowRunDialogState extends State<_FlowRunDialog> { name: widget.flow.name, textInputs: widget.textInputs, fileInputs: widget.fileInputs, + fileMimeTypes: widget.fileMimeTypes, ); } @@ -731,13 +821,29 @@ class _FlowRunDialogState extends State<_FlowRunDialog> { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return AlertDialog( - title: Text(l.flowsRunningTitle(widget.flow.name)), + // Title tracks the future's state so the dialog header + // stops claiming "extract läuft" once the run is over. + // Recomputed on every snapshot change via FutureBuilder. + title: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + final String title; + if (snapshot.connectionState == ConnectionState.waiting) { + title = l.flowsRunningTitle(widget.flow.name); + } else if (snapshot.hasError) { + title = l.flowsErrorTitle(widget.flow.name); + } else { + title = l.flowsResultTitle(widget.flow.name); + } + return Text(title); + }, + ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(FaiRadius.md), ), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480), - child: FutureBuilder>( + child: FutureBuilder>( future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { @@ -812,21 +918,7 @@ class _FlowRunDialogState extends State<_FlowRunDialog> { ), ), ), - Container( - width: double.infinity, - padding: const EdgeInsets.all(FaiSpace.md), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(FaiRadius.sm), - border: Border.all( - color: theme.colorScheme.outlineVariant, - ), - ), - child: SelectableText( - entry.value, - style: FaiTheme.mono(size: 11), - ), - ), + FaiFlowOutput(output: entry.value), const SizedBox(height: FaiSpace.md), ], ], diff --git a/lib/pages/store.dart b/lib/pages/store.dart index b28a236..59b4868 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -1823,8 +1823,13 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { String get _locale => Localizations.localeOf(context).languageCode; bool _busy = false; String? _toast; - Future<({String errorKind, String text, String sourceUrl})>? _docsFuture; - bool _docsLoaded = false; + /// `null` while the initial probe is in flight; non-null once + /// `readInstalledModuleDocs` has returned. The probe is cheap — + /// a single file stat on disk — so we run it eagerly when the + /// sheet opens for an installed module instead of gating it + /// behind a "Load docs" click. + ({String state, String text, String sourcePath})? _docsResult; + bool _docsProbed = false; /// Live module-info for installed entries: declared /// permissions and on-disk directory. Pulled lazily so /// not-installed entries don't run an unnecessary RPC. Stays @@ -1840,6 +1845,32 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { } } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Locale is read off `Localizations`, which is only ready in + // didChangeDependencies, not initState. + if (widget.item.installed && !_docsProbed) { + _docsProbed = true; + _probeInstalledDocs(); + } + } + + Future _probeInstalledDocs() async { + final locale = Localizations.localeOf(context).languageCode; + try { + final r = await HubService.instance + .readInstalledModuleDocs(widget.item.name, locale: locale); + if (!mounted) return; + setState(() => _docsResult = r); + } catch (_) { + // Treat probe failures as "no docs available" — the section + // simply hides, the rest of the sheet still works. + if (!mounted) return; + setState(() => _docsResult = (state: 'no_docs', text: '', sourcePath: '')); + } + } + Future _loadModuleDetail() async { try { final detail = @@ -1934,19 +1965,6 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { } } - void _loadDocs() { - if (_docsLoaded) return; - // Sprache vom aktuellen Studio-Locale ans Hub weitergeben, - // damit `README.de.md` zuerst probiert wird wenn Studio auf - // Deutsch steht. - final locale = Localizations.localeOf(context).languageCode; - setState(() { - _docsLoaded = true; - _docsFuture = - HubService.instance.fetchModuleDocs(widget.item.name, locale: locale); - }); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -2191,15 +2209,13 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { ), const SizedBox(height: FaiSpace.lg), ], - if (item.repository.isNotEmpty || item.docsUrl.isNotEmpty) ...[ + if (_docsResult?.state == 'found') ...[ _SectionHeader(l.storeSectionDocumentation), const SizedBox(height: FaiSpace.sm), - _DocsPanel( - future: _docsFuture, - onLoad: _loadDocs, - onOpenRepo: _openRepository, - ), + _DocsPanel(text: _docsResult!.text), const SizedBox(height: FaiSpace.lg), + ], + if (item.repository.isNotEmpty) ...[ _SectionHeader(l.storeSectionSource), const SizedBox(height: FaiSpace.sm), InkWell( @@ -2242,12 +2258,6 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { ], Row( children: [ - if (item.repository.isNotEmpty) - OutlinedButton.icon( - onPressed: _openRepository, - icon: const Icon(Icons.menu_book_outlined, size: 16), - label: Text(l.buttonReadDocs), - ), const Spacer(), if (item.installed) ...[ OutlinedButton.icon( @@ -2462,210 +2472,40 @@ IconData _iconForCategory(String category) { } } -/// Lazy-loaded inline README renderer. Shows a "Load -/// documentation" button until the operator clicks it (so we -/// don't blow the network unprompted), then a markdown view of -/// the result. Auth / not-found errors come back with friendly -/// fix-hint copy and a fallback "View on repository" button. +/// Renders the module's inline `MODULE.md` (or its localized +/// sibling) in the same theme as the rest of Studio. The markdown +/// body is supplied by the parent — `_DocsPanel` itself stays a +/// pure renderer with no async / no error handling. +/// +/// Blockquote + table-cell decoration is overridden because +/// flutter_markdown's default styles hardcode +/// `Colors.blue.shade100`, which is unreadable on a dark theme. class _DocsPanel extends StatelessWidget { - final Future<({String errorKind, String text, String sourceUrl})>? future; - final VoidCallback onLoad; - final VoidCallback onOpenRepo; + final String text; - const _DocsPanel({ - required this.future, - required this.onLoad, - required this.onOpenRepo, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l = AppLocalizations.of(context)!; - if (future == null) { - // Row im Modul-Detail-Sheet kann eng werden (~340 dp). - // Hint-Text wird in Expanded gewrappt damit er bricht - // statt zu overflowen (CrossAxisAlignment.center hält die - // Optik mit dem Button auf einer Höhe). - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - OutlinedButton.icon( - onPressed: onLoad, - icon: const Icon(Icons.menu_book_outlined, size: 16), - label: Text(l.storeLoadDocs), - ), - const SizedBox(width: FaiSpace.sm), - Expanded( - child: Text( - l.storeDocsHint, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ); - } - return FutureBuilder<({String errorKind, String text, String sourceUrl})>( - future: future, - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: FaiSpace.md), - child: Row( - children: [ - const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: FaiSpace.sm), - Text(l.storeDocsFetching), - ], - ), - ); - } - if (snap.hasError) { - return _DocsErrorPanel( - message: snap.error.toString(), - onOpenRepo: onOpenRepo, - ); - } - final r = snap.data!; - if (r.errorKind.isNotEmpty) { - return _DocsErrorPanel( - message: _friendlyDocsError(context, r.errorKind, r.text), - onOpenRepo: onOpenRepo, - sourceUrl: r.sourceUrl, - ); - } - return Container( - width: double.infinity, - constraints: const BoxConstraints(maxHeight: 480), - padding: const EdgeInsets.all(FaiSpace.md), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(FaiRadius.sm), - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - child: Markdown( - data: r.text, - shrinkWrap: true, - selectable: true, - onTapLink: (text, href, title) async { - if (href == null || href.isEmpty) return; - await SystemActions.openInOs(href); - }, - styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith( - p: theme.textTheme.bodyMedium, - code: FaiTheme.mono( - size: 12, - color: theme.colorScheme.onSurface, - ), - codeblockDecoration: BoxDecoration( - color: theme.colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(FaiRadius.sm), - ), - // flutter_markdown default-blockquote nutzt - // hardcodiertes `Colors.blue.shade100` als - // Background, was im Dark-Theme mit weißem Text - // unlesbar wird. Wir setzen Theme-konsistente - // Werte: surfaceContainer-Background + linke - // Akzent-Border, Text in onSurface. - blockquote: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface, - ), - blockquoteDecoration: BoxDecoration( - color: theme.colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(FaiRadius.sm), - border: Border( - left: BorderSide( - color: theme.colorScheme.primary, - width: 3, - ), - ), - ), - blockquotePadding: const EdgeInsets.fromLTRB( - FaiSpace.md, - FaiSpace.sm, - FaiSpace.sm, - FaiSpace.sm, - ), - // Tabellen-Cells haben dasselbe Default-Problem - // (Material-2-Light-Annahme). Theme-Werte erzwingen. - tableCellsDecoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHigh, - ), - ), - ), - ); - }, - ); - } -} - -class _DocsErrorPanel extends StatelessWidget { - final String message; - final VoidCallback onOpenRepo; - final String? sourceUrl; - - const _DocsErrorPanel({ - required this.message, - required this.onOpenRepo, - this.sourceUrl, - }); + const _DocsPanel({required this.text}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( width: double.infinity, + constraints: const BoxConstraints(maxHeight: 480), padding: const EdgeInsets.all(FaiSpace.md), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.info_outline, - size: 16, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: FaiSpace.sm), - Expanded( - child: Text( - message, - style: theme.textTheme.bodySmall, - ), - ), - ], - ), - if (sourceUrl != null && sourceUrl!.isNotEmpty) ...[ - const SizedBox(height: 4), - SelectableText( - sourceUrl!, - style: FaiTheme.mono( - size: 10, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - const SizedBox(height: FaiSpace.sm), - OutlinedButton.icon( - onPressed: onOpenRepo, - icon: const Icon(Icons.open_in_new, size: 14), - label: Text(AppLocalizations.of(context)!.storeOpenRepoButton), - style: OutlinedButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ], + child: Markdown( + data: text, + shrinkWrap: true, + selectable: true, + onTapLink: (text, href, title) async { + if (href == null || href.isEmpty) return; + await SystemActions.openInOs(href); + }, + styleSheet: FaiTheme.markdownStyle(theme), ), ); } @@ -2697,24 +2537,6 @@ int _versionCmp(String a, String b) { return 0; } -String _friendlyDocsError(BuildContext context, String kind, String detail) { - final l = AppLocalizations.of(context)!; - switch (kind) { - case 'not_found': - return l.storeDocsErrorNotFound; - case 'no_docs': - return l.storeDocsErrorNoDocs; - case 'auth': - return l.storeDocsErrorAuth; - case 'network': - return l.storeDocsErrorNetwork(detail); - case 'parse': - return l.storeDocsErrorParse; - default: - return detail.isEmpty ? l.storeDocsErrorGeneric : detail; - } -} - /// Module icon: renders the explicit icon URL when set, falls /// back to the category-derived `Icons.X` glyph in a colored /// circle when the URL is empty or fails to load. Wraps the diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index db2d6a3..ce8a7a1 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -1058,17 +1058,7 @@ class _DocReaderSheetState extends State<_DocReaderSheet> { await SystemActions.openInOs(href); } }, - styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith( - p: theme.textTheme.bodyMedium?.copyWith(height: 1.5), - code: FaiTheme.mono( - size: 12, - color: theme.colorScheme.onSurface, - ), - codeblockDecoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(FaiRadius.sm), - ), - ), + styleSheet: FaiTheme.markdownStyle(theme), ); }, ), diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index ed68f2b..3546db4 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -1,6 +1,7 @@ // Theme assembly. ColorScheme + typography + component themes. import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:google_fonts/google_fonts.dart'; import 'tokens.dart'; @@ -77,6 +78,69 @@ class FaiTheme { height: 1.4, ); + /// Shared markdown style used by every inline doc renderer + /// (Welcome DocReaderSheet, Store module-detail DocsPanel, + /// future help surfaces). Centralising it means every doc + /// reads in the same typography, with the same dark-theme + /// safe colors. + /// + /// Why every property is set explicitly even when it just + /// re-states the default: + /// + /// - `flutter_markdown`'s `MarkdownStyleSheet.fromTheme(theme)` + /// sets `code` with `backgroundColor: theme.cardTheme.color`. + /// That per-span backgroundColor is rendered behind every + /// character inside a fenced code block, which on a dark + /// theme shows up as horizontal bands behind ASCII diagrams. + /// We force `backgroundColor: Colors.transparent` to suppress + /// it — the codeblock's own decoration provides the panel + /// background. + /// - `blockquote` defaults to a hardcoded `Colors.blue.shade100` + /// background that is unreadable in dark mode. We replace it + /// with theme-aware tokens (surfaceContainer + left accent + /// border). + /// - `tableCellsDecoration` has the same dark-mode bug, fixed + /// the same way. + static MarkdownStyleSheet markdownStyle(ThemeData theme) { + return MarkdownStyleSheet.fromTheme(theme).copyWith( + p: theme.textTheme.bodyMedium?.copyWith(height: 1.5), + code: mono( + size: 12, + color: theme.colorScheme.onSurface, + ).copyWith( + backgroundColor: Colors.transparent, + ), + codeblockDecoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + codeblockPadding: const EdgeInsets.all(FaiSpace.md), + blockquote: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + blockquoteDecoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border( + left: BorderSide( + color: theme.colorScheme.primary, + width: 3, + ), + ), + ), + blockquotePadding: const EdgeInsets.fromLTRB( + FaiSpace.md, + FaiSpace.sm, + FaiSpace.sm, + FaiSpace.sm, + ), + tableCellsDecoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + ), + ); + } + static ThemeData dark() { final scheme = ColorScheme( brightness: Brightness.dark, diff --git a/lib/widgets/fai_flow_output.dart b/lib/widgets/fai_flow_output.dart new file mode 100644 index 0000000..1f15666 --- /dev/null +++ b/lib/widgets/fai_flow_output.dart @@ -0,0 +1,288 @@ +// FaiFlowOutput — 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/flutter_markdown.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 FaiFlowOutput extends StatelessWidget { + final FlowOutput output; + + const FaiFlowOutput({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(FaiSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.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: FaiTheme.markdownStyle(theme), + ), + ); + } + return Container( + width: double.infinity, + padding: const EdgeInsets.all(FaiSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.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(FaiSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: SelectableText( + text, + style: FaiTheme.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.platform.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; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.flowsOutputSaveFailed('$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(FaiSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.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(FaiRadius.sm), + child: Image.memory(bytes, fit: BoxFit.contain), + ), + ), + const SizedBox(height: FaiSpace.sm), + ], + Text( + '${mimeType.isEmpty ? l.flowsOutputBytesUnknownMime : mimeType}' + ' · ${humanBytes(bytes.length)}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.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; + final l = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.flowsOutputOpenFailed(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(FaiSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + uri, + style: FaiTheme.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: FaiSpace.sm), + OutlinedButton.icon( + icon: const Icon(Icons.open_in_new, size: 16), + label: Text(l.flowsOutputOpen), + onPressed: () => _open(context), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index daa7145..22dc088 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -9,6 +9,7 @@ export 'fai_data_row.dart'; export 'fai_delta_mark.dart'; export 'fai_empty_state.dart'; export 'fai_error_box.dart'; +export 'fai_flow_output.dart'; export 'fai_module_sheet.dart'; export 'fai_pill.dart'; export 'fai_settings_dialog.dart';