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:
parent
081ffd7233
commit
34f2b7b313
14 changed files with 849 additions and 296 deletions
85
lib/data/flow_output.dart
Normal file
85
lib/data/flow_output.dart
Normal 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
33
lib/data/format.dart
Normal 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]}';
|
||||
}
|
||||
|
|
@ -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.<locale>.md`) from disk under
|
||||
/// `~/.fai/modules/<module>/`. 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<Map<String, String>> 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<Map<String, FlowOutput>> runSavedFlow({
|
||||
required String name,
|
||||
Map<String, String> textInputs = const {},
|
||||
Map<String, Uint8List> fileInputs = const {},
|
||||
Map<String, String> 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 '<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({
|
||||
int limit = 50,
|
||||
List<String> types = const [],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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<FlowsPage> {
|
|||
flows: results[0] as List<SavedFlow>,
|
||||
installedCapabilities: (results[1] as List<ModuleSummary>)
|
||||
.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<FlowsPage> {
|
|||
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<String, String> textInputs;
|
||||
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({
|
||||
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<FlowInputDef> defs) {
|
||||
final textInputs = <String, String>{};
|
||||
final fileInputs = <String, Uint8List>{};
|
||||
final fileMimeTypes = <String, String>{};
|
||||
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<void> _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<String, String> textInputs;
|
||||
final Map<String, Uint8List> fileInputs;
|
||||
final Map<String, String> 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<Map<String, String>> _future;
|
||||
late final Future<Map<String, FlowOutput>> _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<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(
|
||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||
),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480),
|
||||
child: FutureBuilder<Map<String, String>>(
|
||||
child: FutureBuilder<Map<String, FlowOutput>>(
|
||||
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),
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
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,84 +2472,22 @@ 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,
|
||||
});
|
||||
const _DocsPanel({required this.text});
|
||||
|
||||
@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),
|
||||
|
|
@ -2550,122 +2498,14 @@ class _DocsPanel extends StatelessWidget {
|
|||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Markdown(
|
||||
data: r.text,
|
||||
data: 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
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.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: [
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
288
lib/widgets/fai_flow_output.dart
Normal file
288
lib/widgets/fai_flow_output.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue