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 <sf@flemming.it>
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-25 11:17:58 +02:00
parent 081ffd7233
commit 34f2b7b313
14 changed files with 849 additions and 296 deletions

85
lib/data/flow_output.dart Normal file
View file

@ -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 `<bytes: >` 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();
}

33
lib/data/format.dart Normal file
View file

@ -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]}';
}

View file

@ -11,6 +11,9 @@ import 'package:fai_dart_sdk/fai_dart_sdk.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'flow_output.dart';
export 'flow_output.dart';
class HubService { class HubService {
HubService._(); HubService._();
static final HubService instance = HubService._(); static final HubService instance = HubService._();
@ -346,17 +349,26 @@ class HubService {
return (name: r.name, version: r.version); return (name: r.name, version: r.version);
} }
/// Fetch a module's README markdown via the hub. Errors come /// Read the installed module's `MODULE.md` (or
/// back as `errorKind` strings (not exceptions) so callers can /// `MODULE.<locale>.md`) from disk under
/// render fallback UI without try/catch ceremony. /// `~/.fai/modules/<module>/`. Air-gap friendly, no network.
Future<({String errorKind, String text, String sourceUrl})> ///
fetchModuleDocs(String name, {String locale = ''}) async { /// `state` is one of:
final r = await _client.fetchModuleDocs(name, locale: locale); /// - `'found'` `text` holds the markdown
return ( /// - `'no_docs'` module installed but bundle had no docs
errorKind: r.errorKind, /// - `'not_installed'` module isn't installed at all
text: r.text, Future<({String state, String text, String sourcePath})>
sourceUrl: r.sourceUrl, 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. /// Snapshot of every configured MCP server.
@ -560,35 +572,31 @@ class HubService {
/// land as text Payloads, byte values as bytes Payloads /// land as text Payloads, byte values as bytes Payloads
/// flows that mix both (e.g. extract taking a `document: /// flows that mix both (e.g. extract taking a `document:
/// bytes` plus a text-shaped option) flow through one RPC. /// bytes` plus a text-shaped option) flow through one RPC.
/// Returns the named outputs as a map of UI-friendly strings. /// [fileMimeTypes] is keyed the same as [fileInputs]; missing
Future<Map<String, String>> runSavedFlow({ /// 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<Map<String, FlowOutput>> runSavedFlow({
required String name, required String name,
Map<String, String> textInputs = const {}, Map<String, String> textInputs = const {},
Map<String, Uint8List> fileInputs = const {}, Map<String, Uint8List> fileInputs = const {},
Map<String, String> fileMimeTypes = const {},
}) async { }) async {
final r = await _client.runSavedFlow( final r = await _client.runSavedFlow(
name: name, name: name,
textInputs: textInputs, textInputs: textInputs,
fileInputs: fileInputs, fileInputs: fileInputs,
fileMimeTypes: fileMimeTypes,
); );
return { return {
for (final entry in r.outputs.entries) 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 '<bytes: ${p.bytes.data.length} bytes, ${p.bytes.mimeType}>';
}
if (p.hasFile()) {
return '<file: ${p.file.uri}>';
}
return '<unknown payload variant>';
}
Future<List<AuditEvent>> recentEvents({ Future<List<AuditEvent>> recentEvents({
int limit = 50, int limit = 50,
List<String> types = const [], List<String> types = const [],

View file

@ -506,8 +506,23 @@
"flowsRunButton": "Starten", "flowsRunButton": "Starten",
"flowsRunningTitle": "{name} läuft", "flowsRunningTitle": "{name} läuft",
"@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } }, "@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…", "flowsRunning": "Flow läuft…",
"flowsNoOutputs": "Flow abgeschlossen ohne deklarierte Outputs.", "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", "approvalsTitle": "Freigaben",
"approvalsTabPending": "Offen", "approvalsTabPending": "Offen",

View file

@ -507,8 +507,23 @@
"flowsRunButton": "Run", "flowsRunButton": "Run",
"flowsRunningTitle": "Running {name}", "flowsRunningTitle": "Running {name}",
"@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } }, "@flowsRunningTitle": { "placeholders": { "name": { "type": "String" } } },
"flowsResultTitle": "{name} — result",
"@flowsResultTitle": { "placeholders": { "name": { "type": "String" } } },
"flowsErrorTitle": "{name} — failed",
"@flowsErrorTitle": { "placeholders": { "name": { "type": "String" } } },
"flowsRunning": "Flow running…", "flowsRunning": "Flow running…",
"flowsNoOutputs": "Flow completed with no declared outputs.", "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", "approvalsTitle": "Approvals",
"approvalsTabPending": "Pending", "approvalsTabPending": "Pending",

View file

@ -2282,6 +2282,18 @@ abstract class AppLocalizations {
/// **'Running {name}'** /// **'Running {name}'**
String flowsRunningTitle(String 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. /// No description provided for @flowsRunning.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -2294,6 +2306,54 @@ abstract class AppLocalizations {
/// **'Flow completed with no declared outputs.'** /// **'Flow completed with no declared outputs.'**
String get flowsNoOutputs; 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. /// No description provided for @approvalsTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -1308,12 +1308,52 @@ class AppLocalizationsDe extends AppLocalizations {
return '$name läuft'; return '$name läuft';
} }
@override
String flowsResultTitle(String name) {
return '$name — Ergebnis';
}
@override
String flowsErrorTitle(String name) {
return '$name — fehlgeschlagen';
}
@override @override
String get flowsRunning => 'Flow läuft…'; String get flowsRunning => 'Flow läuft…';
@override @override
String get flowsNoOutputs => 'Flow abgeschlossen ohne deklarierte Outputs.'; 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 @override
String get approvalsTitle => 'Freigaben'; String get approvalsTitle => 'Freigaben';

View file

@ -1321,12 +1321,52 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Running $name'; return 'Running $name';
} }
@override
String flowsResultTitle(String name) {
return '$name — result';
}
@override
String flowsErrorTitle(String name) {
return '$name — failed';
}
@override @override
String get flowsRunning => 'Flow running…'; String get flowsRunning => 'Flow running…';
@override @override
String get flowsNoOutputs => 'Flow completed with no declared outputs.'; 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 @override
String get approvalsTitle => 'Approvals'; String get approvalsTitle => 'Approvals';

View file

@ -5,6 +5,7 @@ import 'package:fai_dart_sdk/fai_dart_sdk.dart' show FlowInputDef;
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../data/format.dart';
import '../data/hub.dart'; import '../data/hub.dart';
import '../data/system_actions.dart'; import '../data/system_actions.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@ -40,6 +41,18 @@ class _FlowsPageState extends State<FlowsPage> {
flows: results[0] as List<SavedFlow>, flows: results[0] as List<SavedFlow>,
installedCapabilities: (results[1] as List<ModuleSummary>) installedCapabilities: (results[1] as List<ModuleSummary>)
.expand((m) => m.capabilities) .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(), .toSet(),
); );
} }
@ -74,6 +87,7 @@ class _FlowsPageState extends State<FlowsPage> {
flow: flow, flow: flow,
textInputs: inputs.textInputs, textInputs: inputs.textInputs,
fileInputs: inputs.fileInputs, fileInputs: inputs.fileInputs,
fileMimeTypes: inputs.fileMimeTypes,
), ),
); );
} }
@ -254,7 +268,7 @@ class _FlowCard extends StatelessWidget {
), ),
), ),
FaiPill( FaiPill(
label: '${flow.sizeBytes} B', label: humanBytes(flow.sizeBytes),
tone: FaiPillTone.neutral, tone: FaiPillTone.neutral,
monospace: true, monospace: true,
), ),
@ -369,16 +383,75 @@ class _ClickablePill extends StatelessWidget {
class _FlowRunInputs { class _FlowRunInputs {
final Map<String, String> textInputs; final Map<String, String> textInputs;
final Map<String, Uint8List> fileInputs; final Map<String, Uint8List> 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<String, String> fileMimeTypes;
const _FlowRunInputs({ const _FlowRunInputs({
required this.textInputs, required this.textInputs,
required this.fileInputs, required this.fileInputs,
required this.fileMimeTypes,
}); });
} }
class _PickedFile { class _PickedFile {
final String name; final String name;
final Uint8List bytes; 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 /// Run-flow input dialog. Fetches the flow's declared input
@ -457,16 +530,26 @@ class _FlowInputDialogState extends State<_FlowInputDialog> {
_FlowRunInputs _collect(List<FlowInputDef> defs) { _FlowRunInputs _collect(List<FlowInputDef> defs) {
final textInputs = <String, String>{}; final textInputs = <String, String>{};
final fileInputs = <String, Uint8List>{}; final fileInputs = <String, Uint8List>{};
final fileMimeTypes = <String, String>{};
for (final d in defs) { for (final d in defs) {
if (_isBytesType(d.type)) { if (_isBytesType(d.type)) {
final picked = _pickedFiles[d.name]; 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 { } else {
final c = _textControllers[d.name]; final c = _textControllers[d.name];
if (c != null) textInputs[d.name] = c.text; if (c != null) textInputs[d.name] = c.text;
} }
} }
return _FlowRunInputs(textInputs: textInputs, fileInputs: fileInputs); return _FlowRunInputs(
textInputs: textInputs,
fileInputs: fileInputs,
fileMimeTypes: fileMimeTypes,
);
} }
Future<void> _pickFile(String inputName) async { Future<void> _pickFile(String inputName) async {
@ -490,7 +573,11 @@ class _FlowInputDialogState extends State<_FlowInputDialog> {
} }
if (bytes == null || !mounted) return; if (bytes == null || !mounted) return;
setState(() { 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 pickedFile == null
? l.flowsTypeBytesNoFile ? l.flowsTypeBytesNoFile
: '${pickedFile!.name} · ' : '${pickedFile!.name} · '
'${l.flowsTypeBytesSize(pickedFile!.bytes.length)}', '${humanBytes(pickedFile!.bytes.length)}',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
@ -702,11 +789,13 @@ class _FlowRunDialog extends StatefulWidget {
final SavedFlow flow; final SavedFlow flow;
final Map<String, String> textInputs; final Map<String, String> textInputs;
final Map<String, Uint8List> fileInputs; final Map<String, Uint8List> fileInputs;
final Map<String, String> fileMimeTypes;
const _FlowRunDialog({ const _FlowRunDialog({
required this.flow, required this.flow,
required this.textInputs, required this.textInputs,
required this.fileInputs, required this.fileInputs,
required this.fileMimeTypes,
}); });
@override @override
@ -714,7 +803,7 @@ class _FlowRunDialog extends StatefulWidget {
} }
class _FlowRunDialogState extends State<_FlowRunDialog> { class _FlowRunDialogState extends State<_FlowRunDialog> {
late final Future<Map<String, String>> _future; late final Future<Map<String, FlowOutput>> _future;
@override @override
void initState() { void initState() {
@ -723,6 +812,7 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
name: widget.flow.name, name: widget.flow.name,
textInputs: widget.textInputs, textInputs: widget.textInputs,
fileInputs: widget.fileInputs, fileInputs: widget.fileInputs,
fileMimeTypes: widget.fileMimeTypes,
); );
} }
@ -731,13 +821,29 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
return AlertDialog( 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<Map<String, FlowOutput>>(
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( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md), borderRadius: BorderRadius.circular(FaiRadius.md),
), ),
content: ConstrainedBox( content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480), constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480),
child: FutureBuilder<Map<String, String>>( child: FutureBuilder<Map<String, FlowOutput>>(
future: _future, future: _future,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
@ -812,21 +918,7 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
), ),
), ),
), ),
Container( FaiFlowOutput(output: entry.value),
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),
),
),
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
], ],
], ],

View file

@ -1823,8 +1823,13 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
String get _locale => Localizations.localeOf(context).languageCode; String get _locale => Localizations.localeOf(context).languageCode;
bool _busy = false; bool _busy = false;
String? _toast; String? _toast;
Future<({String errorKind, String text, String sourceUrl})>? _docsFuture; /// `null` while the initial probe is in flight; non-null once
bool _docsLoaded = false; /// `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 /// Live module-info for installed entries: declared
/// permissions and on-disk directory. Pulled lazily so /// permissions and on-disk directory. Pulled lazily so
/// not-installed entries don't run an unnecessary RPC. Stays /// 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<void> _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<void> _loadModuleDetail() async { Future<void> _loadModuleDetail() async {
try { try {
final detail = 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -2191,15 +2209,13 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
), ),
const SizedBox(height: FaiSpace.lg), const SizedBox(height: FaiSpace.lg),
], ],
if (item.repository.isNotEmpty || item.docsUrl.isNotEmpty) ...[ if (_docsResult?.state == 'found') ...[
_SectionHeader(l.storeSectionDocumentation), _SectionHeader(l.storeSectionDocumentation),
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
_DocsPanel( _DocsPanel(text: _docsResult!.text),
future: _docsFuture,
onLoad: _loadDocs,
onOpenRepo: _openRepository,
),
const SizedBox(height: FaiSpace.lg), const SizedBox(height: FaiSpace.lg),
],
if (item.repository.isNotEmpty) ...[
_SectionHeader(l.storeSectionSource), _SectionHeader(l.storeSectionSource),
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
InkWell( InkWell(
@ -2242,12 +2258,6 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
], ],
Row( Row(
children: [ children: [
if (item.repository.isNotEmpty)
OutlinedButton.icon(
onPressed: _openRepository,
icon: const Icon(Icons.menu_book_outlined, size: 16),
label: Text(l.buttonReadDocs),
),
const Spacer(), const Spacer(),
if (item.installed) ...[ if (item.installed) ...[
OutlinedButton.icon( OutlinedButton.icon(
@ -2462,210 +2472,40 @@ IconData _iconForCategory(String category) {
} }
} }
/// Lazy-loaded inline README renderer. Shows a "Load /// Renders the module's inline `MODULE.md` (or its localized
/// documentation" button until the operator clicks it (so we /// sibling) in the same theme as the rest of Studio. The markdown
/// don't blow the network unprompted), then a markdown view of /// body is supplied by the parent `_DocsPanel` itself stays a
/// the result. Auth / not-found errors come back with friendly /// pure renderer with no async / no error handling.
/// fix-hint copy and a fallback "View on repository" button. ///
/// 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 { class _DocsPanel extends StatelessWidget {
final Future<({String errorKind, String text, String sourceUrl})>? future; final String text;
final VoidCallback onLoad;
final VoidCallback onOpenRepo;
const _DocsPanel({ const _DocsPanel({required this.text});
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,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return Container(
width: double.infinity, width: double.infinity,
constraints: const BoxConstraints(maxHeight: 480),
padding: const EdgeInsets.all(FaiSpace.md), padding: const EdgeInsets.all(FaiSpace.md),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh, color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm), borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant), border: Border.all(color: theme.colorScheme.outlineVariant),
), ),
child: Column( child: Markdown(
crossAxisAlignment: CrossAxisAlignment.start, data: text,
children: [ shrinkWrap: true,
Row( selectable: true,
children: [ onTapLink: (text, href, title) async {
Icon( if (href == null || href.isEmpty) return;
Icons.info_outline, await SystemActions.openInOs(href);
size: 16, },
color: theme.colorScheme.onSurfaceVariant, styleSheet: FaiTheme.markdownStyle(theme),
),
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,
),
),
],
), ),
); );
} }
@ -2697,24 +2537,6 @@ int _versionCmp(String a, String b) {
return 0; 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 /// Module icon: renders the explicit icon URL when set, falls
/// back to the category-derived `Icons.X` glyph in a colored /// back to the category-derived `Icons.X` glyph in a colored
/// circle when the URL is empty or fails to load. Wraps the /// circle when the URL is empty or fails to load. Wraps the

View file

@ -1058,17 +1058,7 @@ class _DocReaderSheetState extends State<_DocReaderSheet> {
await SystemActions.openInOs(href); await SystemActions.openInOs(href);
} }
}, },
styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith( styleSheet: FaiTheme.markdownStyle(theme),
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),
),
),
); );
}, },
), ),

View file

@ -1,6 +1,7 @@
// Theme assembly. ColorScheme + typography + component themes. // Theme assembly. ColorScheme + typography + component themes.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'tokens.dart'; import 'tokens.dart';
@ -77,6 +78,69 @@ class FaiTheme {
height: 1.4, 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() { static ThemeData dark() {
final scheme = ColorScheme( final scheme = ColorScheme(
brightness: Brightness.dark, brightness: Brightness.dark,

View file

@ -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<void> _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<void> _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),
),
],
),
);
}
}

View file

@ -9,6 +9,7 @@ export 'fai_data_row.dart';
export 'fai_delta_mark.dart'; export 'fai_delta_mark.dart';
export 'fai_empty_state.dart'; export 'fai_empty_state.dart';
export 'fai_error_box.dart'; export 'fai_error_box.dart';
export 'fai_flow_output.dart';
export 'fai_module_sheet.dart'; export 'fai_module_sheet.dart';
export 'fai_pill.dart'; export 'fai_pill.dart';
export 'fai_settings_dialog.dart'; export 'fai_settings_dialog.dart';