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

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: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),
],
],

View file

@ -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,210 +2472,40 @@ IconData _iconForCategory(String category) {
}
}
/// Lazy-loaded inline README renderer. Shows a "Load
/// documentation" button until the operator clicks it (so we
/// don't blow the network unprompted), then a markdown view of
/// the result. Auth / not-found errors come back with friendly
/// fix-hint copy and a fallback "View on repository" button.
/// Renders the module's inline `MODULE.md` (or its localized
/// sibling) in the same theme as the rest of Studio. The markdown
/// body is supplied by the parent `_DocsPanel` itself stays a
/// pure renderer with no async / no error handling.
///
/// Blockquote + table-cell decoration is overridden because
/// flutter_markdown's default styles hardcode
/// `Colors.blue.shade100`, which is unreadable on a dark theme.
class _DocsPanel extends StatelessWidget {
final Future<({String errorKind, String text, String sourceUrl})>? future;
final VoidCallback onLoad;
final VoidCallback onOpenRepo;
final String text;
const _DocsPanel({
required this.future,
required this.onLoad,
required this.onOpenRepo,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
if (future == null) {
// Row im Modul-Detail-Sheet kann eng werden (~340 dp).
// Hint-Text wird in Expanded gewrappt damit er bricht
// statt zu overflowen (CrossAxisAlignment.center hält die
// Optik mit dem Button auf einer Höhe).
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
OutlinedButton.icon(
onPressed: onLoad,
icon: const Icon(Icons.menu_book_outlined, size: 16),
label: Text(l.storeLoadDocs),
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Text(
l.storeDocsHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
);
}
return FutureBuilder<({String errorKind, String text, String sourceUrl})>(
future: future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: FaiSpace.md),
child: Row(
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: FaiSpace.sm),
Text(l.storeDocsFetching),
],
),
);
}
if (snap.hasError) {
return _DocsErrorPanel(
message: snap.error.toString(),
onOpenRepo: onOpenRepo,
);
}
final r = snap.data!;
if (r.errorKind.isNotEmpty) {
return _DocsErrorPanel(
message: _friendlyDocsError(context, r.errorKind, r.text),
onOpenRepo: onOpenRepo,
sourceUrl: r.sourceUrl,
);
}
return Container(
width: double.infinity,
constraints: const BoxConstraints(maxHeight: 480),
padding: const EdgeInsets.all(FaiSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Markdown(
data: r.text,
shrinkWrap: true,
selectable: true,
onTapLink: (text, href, title) async {
if (href == null || href.isEmpty) return;
await SystemActions.openInOs(href);
},
styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith(
p: theme.textTheme.bodyMedium,
code: FaiTheme.mono(
size: 12,
color: theme.colorScheme.onSurface,
),
codeblockDecoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
// flutter_markdown default-blockquote nutzt
// hardcodiertes `Colors.blue.shade100` als
// Background, was im Dark-Theme mit weißem Text
// unlesbar wird. Wir setzen Theme-konsistente
// Werte: surfaceContainer-Background + linke
// Akzent-Border, Text in onSurface.
blockquote: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
blockquoteDecoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border(
left: BorderSide(
color: theme.colorScheme.primary,
width: 3,
),
),
),
blockquotePadding: const EdgeInsets.fromLTRB(
FaiSpace.md,
FaiSpace.sm,
FaiSpace.sm,
FaiSpace.sm,
),
// Tabellen-Cells haben dasselbe Default-Problem
// (Material-2-Light-Annahme). Theme-Werte erzwingen.
tableCellsDecoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
),
),
),
);
},
);
}
}
class _DocsErrorPanel extends StatelessWidget {
final String message;
final VoidCallback onOpenRepo;
final String? sourceUrl;
const _DocsErrorPanel({
required this.message,
required this.onOpenRepo,
this.sourceUrl,
});
const _DocsPanel({required this.text});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
constraints: const BoxConstraints(maxHeight: 480),
padding: const EdgeInsets.all(FaiSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Text(
message,
style: theme.textTheme.bodySmall,
),
),
],
),
if (sourceUrl != null && sourceUrl!.isNotEmpty) ...[
const SizedBox(height: 4),
SelectableText(
sourceUrl!,
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: FaiSpace.sm),
OutlinedButton.icon(
onPressed: onOpenRepo,
icon: const Icon(Icons.open_in_new, size: 14),
label: Text(AppLocalizations.of(context)!.storeOpenRepoButton),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
child: Markdown(
data: text,
shrinkWrap: true,
selectable: true,
onTapLink: (text, href, title) async {
if (href == null || href.isEmpty) return;
await SystemActions.openInOs(href);
},
styleSheet: FaiTheme.markdownStyle(theme),
),
);
}
@ -2697,24 +2537,6 @@ int _versionCmp(String a, String b) {
return 0;
}
String _friendlyDocsError(BuildContext context, String kind, String detail) {
final l = AppLocalizations.of(context)!;
switch (kind) {
case 'not_found':
return l.storeDocsErrorNotFound;
case 'no_docs':
return l.storeDocsErrorNoDocs;
case 'auth':
return l.storeDocsErrorAuth;
case 'network':
return l.storeDocsErrorNetwork(detail);
case 'parse':
return l.storeDocsErrorParse;
default:
return detail.isEmpty ? l.storeDocsErrorGeneric : detail;
}
}
/// Module icon: renders the explicit icon URL when set, falls
/// back to the category-derived `Icons.X` glyph in a colored
/// circle when the URL is empty or fails to load. Wraps the

View file

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