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

@ -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