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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue