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