feat(studio): inline README + per-module update badge in Store (v0.19.0)

Two Phase-2 Store improvements that operators feel
immediately:

- Per-module update badge: store cards compare the operator's
  installed version against the index's `best_version`, raise
  an "update" pill + "Update to vX.Y.Z" button when newer is
  available. Result: a single glance at the Store reveals
  what's behind. The page now fetches `listModules` in
  parallel with the search so the comparison is on the
  current snapshot.

- Inline README rendering: detail sheet has a new
  Documentation section. "Load documentation" button (lazy —
  no network until clicked) calls FetchModuleDocs and renders
  the markdown via flutter_markdown, with the operator's
  theme colors and link-tap routed through SystemActions.
  Auth / not-found / network failures fall back to a
  friendly fix-hint panel + "Open repository in browser"
  button so the operator never hits a dead end.

Adds flutter_markdown ^0.7.7 to pubspec; the package is
maintenance-only upstream but is the only mature option
for Material-themed markdown right now. Easy to swap to
markdown_widget if/when we need GitHub-flavored extras.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-07 20:46:16 +02:00
parent 93926741b1
commit 04cc12bc54
5 changed files with 335 additions and 16 deletions

View file

@ -6,6 +6,7 @@
// repository link.
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import '../data/hub.dart';
import '../data/system_actions.dart';
@ -40,15 +41,26 @@ class _StorePageState extends State<StorePage> {
super.dispose();
}
Map<String, String> _installedVersions = const {};
Future<List<StoreItem>> _load() async {
final all = await HubService.instance.searchStore(
query: _queryCtrl.text.trim(),
category: _category,
status: _status,
// Pull a generous page so the local "installed only"
// filter does not bite into early results.
limit: 200,
);
// Fetch the store entries and the installed modules in
// parallel so the page paints in one round-trip. The
// installed-version map drives the per-card "Update"
// badge store entries only carry `best_version` and
// `installed: bool`, never the installed version itself.
final results = await Future.wait([
HubService.instance.searchStore(
query: _queryCtrl.text.trim(),
category: _category,
status: _status,
limit: 200,
),
HubService.instance.listModules().catchError((_) => <ModuleSummary>[]),
]);
final all = results[0] as List<StoreItem>;
final mods = results[1] as List<ModuleSummary>;
_installedVersions = {for (final m in mods) m.name: m.version};
return _installedOnly ? all.where((e) => e.installed).toList() : all;
}
@ -189,6 +201,7 @@ class _StorePageState extends State<StorePage> {
return _StoreGrid(
items: items,
locale: _locale,
installedVersions: _installedVersions,
onTap: _openDetail,
onInstall: _install,
);
@ -402,12 +415,17 @@ class _LocaleToggle extends StatelessWidget {
class _StoreGrid extends StatelessWidget {
final List<StoreItem> items;
final String locale;
/// Map of installed module name installed version. Cards
/// compare against `bestVersion` to render an "Update" badge
/// when the store offers something newer.
final Map<String, String> installedVersions;
final ValueChanged<StoreItem> onTap;
final ValueChanged<StoreItem> onInstall;
const _StoreGrid({
required this.items,
required this.locale,
required this.installedVersions,
required this.onTap,
required this.onInstall,
});
@ -430,6 +448,7 @@ class _StoreGrid extends StatelessWidget {
itemBuilder: (context, i) => _StoreCard(
item: items[i],
locale: locale,
installedVersion: installedVersions[items[i].name],
onTap: () => onTap(items[i]),
onInstall: () => onInstall(items[i]),
),
@ -442,16 +461,31 @@ class _StoreGrid extends StatelessWidget {
class _StoreCard extends StatelessWidget {
final StoreItem item;
final String locale;
/// Installed version for this name, when the operator has it.
/// `null` for not-installed entries.
final String? installedVersion;
final VoidCallback onTap;
final VoidCallback onInstall;
const _StoreCard({
required this.item,
required this.locale,
required this.installedVersion,
required this.onTap,
required this.onInstall,
});
/// True iff the operator has this module installed AND the
/// store-index offers a strictly-newer best_version. Uses a
/// dotted-numeric semver-light compare so 0.10.5 > 0.9.99.
bool get _hasUpdate {
final installed = installedVersion;
final best = item.bestVersion;
if (installed == null || best.isEmpty) return false;
if (installed == best) return false;
return _versionCmp(best, installed) > 0;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -504,7 +538,17 @@ class _StoreCard extends StatelessWidget {
],
),
),
if (item.installed)
if (_hasUpdate)
Tooltip(
message:
'Installed ${installedVersion ?? "?"} — store has v${item.bestVersion}',
child: const FaiPill(
label: 'update',
tone: FaiPillTone.warning,
icon: Icons.system_update_alt,
),
)
else if (item.installed)
const FaiPill(
label: 'installed',
tone: FaiPillTone.success,
@ -536,7 +580,16 @@ class _StoreCard extends StatelessWidget {
monospace: true,
),
const Spacer(),
if (item.installed)
if (_hasUpdate)
FilledButton.icon(
onPressed: onInstall,
icon: const Icon(Icons.system_update_alt, size: 14),
label: Text('Update to ${item.bestVersion}'),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
)
else if (item.installed)
TextButton.icon(
onPressed: onTap,
icon: const Icon(Icons.info_outline, size: 14),
@ -605,6 +658,8 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
late String _locale = widget.locale;
bool _busy = false;
String? _toast;
Future<({String errorKind, String text, String sourceUrl})>? _docsFuture;
bool _docsLoaded = false;
Future<void> _install() async {
setState(() {
@ -686,6 +741,15 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
}
}
void _loadDocs() {
if (_docsLoaded) return;
setState(() {
_docsLoaded = true;
_docsFuture =
HubService.instance.fetchModuleDocs(widget.item.name);
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -859,6 +923,14 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
const SizedBox(height: FaiSpace.lg),
],
if (item.repository.isNotEmpty) ...[
_SectionHeader('Documentation'),
const SizedBox(height: FaiSpace.sm),
_DocsPanel(
future: _docsFuture,
onLoad: _loadDocs,
onOpenRepo: _openRepository,
),
const SizedBox(height: FaiSpace.lg),
_SectionHeader('Source'),
const SizedBox(height: FaiSpace.sm),
InkWell(
@ -908,7 +980,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
label: const Text('Read docs'),
),
const Spacer(),
if (item.installed)
if (item.installed) ...[
OutlinedButton.icon(
onPressed: _busy ? null : _uninstall,
icon: _busy
@ -927,8 +999,8 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
.withValues(alpha: 0.5),
),
),
)
else if (installable)
),
] else if (installable)
FilledButton.icon(
onPressed: _busy ? null : _install,
icon: _busy
@ -1118,3 +1190,219 @@ IconData _iconForCategory(String category) {
return Icons.extension_outlined;
}
}
/// 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.
class _DocsPanel extends StatelessWidget {
final Future<({String errorKind, String text, String sourceUrl})>? future;
final VoidCallback onLoad;
final VoidCallback onOpenRepo;
const _DocsPanel({
required this.future,
required this.onLoad,
required this.onOpenRepo,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (future == null) {
return Row(
children: [
OutlinedButton.icon(
onPressed: onLoad,
icon: const Icon(Icons.menu_book_outlined, size: 16),
label: const Text('Load documentation'),
),
const SizedBox(width: FaiSpace.sm),
Text(
'README rendered inline; uses the hub\'s registry token when needed.',
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 const Padding(
padding: EdgeInsets.symmetric(vertical: FaiSpace.md),
child: Row(
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: FaiSpace.sm),
Text('Fetching README…'),
],
),
);
}
if (snap.hasError) {
return _DocsErrorPanel(
message: snap.error.toString(),
onOpenRepo: onOpenRepo,
);
}
final r = snap.data!;
if (r.errorKind.isNotEmpty) {
return _DocsErrorPanel(
message: _friendlyDocsError(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),
),
),
),
);
},
);
}
}
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: const Text('Open repository in browser'),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
);
}
}
/// Lightweight semver compare used by the "update available"
/// badge. Returns negative if a < b, 0 if equal, positive if
/// a > b. Splits on '.' and compares integer chunks; falls back
/// to lexicographic compare on non-numeric segments so
/// `0.10.0-rc.1` still sorts sensibly. Good enough for the
/// store's coarse "is there a newer publish" question — the
/// hub does its own strict resolution at install time.
int _versionCmp(String a, String b) {
final aParts = a.split('.');
final bParts = b.split('.');
final n = aParts.length > bParts.length ? aParts.length : bParts.length;
for (var i = 0; i < n; i++) {
final ap = i < aParts.length ? aParts[i] : '0';
final bp = i < bParts.length ? bParts[i] : '0';
final ai = int.tryParse(ap);
final bi = int.tryParse(bp);
if (ai != null && bi != null) {
if (ai != bi) return ai.compareTo(bi);
} else {
final cmp = ap.compareTo(bp);
if (cmp != 0) return cmp;
}
}
return 0;
}
String _friendlyDocsError(String kind, String detail) {
switch (kind) {
case 'not_found':
return 'This module is not in the hub\'s store index.';
case 'no_docs':
return 'No repository URL is recorded for this module.';
case 'auth':
return 'The registry requires authentication to fetch this README. '
'Set FAI_REGISTRY_TOKEN in the daemon\'s environment '
'and restart the hub.';
case 'network':
return 'Network error while fetching the README. $detail';
case 'parse':
return 'README is not valid UTF-8.';
default:
return detail.isEmpty ? 'Could not load README.' : detail;
}
}