From 04cc12bc547f744bc36562d6905255a6c6527af7 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 7 May 2026 20:46:16 +0200 Subject: [PATCH] feat(studio): inline README + per-module update badge in Store (v0.19.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/data/hub.dart | 13 ++ lib/main.dart | 2 +- lib/pages/store.dart | 314 +++++++++++++++++++++++++++++++++++++++++-- pubspec.lock | 18 ++- pubspec.yaml | 4 +- 5 files changed, 335 insertions(+), 16 deletions(-) diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 4063d25..a0d9f70 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -326,6 +326,19 @@ class HubService { return (name: r.name, version: r.version); } + /// Fetch a module's README markdown via the hub. Errors come + /// back as `errorKind` strings (not exceptions) so callers can + /// render fallback UI without try/catch ceremony. + Future<({String errorKind, String text, String sourceUrl})> + fetchModuleDocs(String name) async { + final r = await _client.fetchModuleDocs(name); + return ( + errorKind: r.errorKind, + text: r.text, + sourceUrl: r.sourceUrl, + ); + } + /// Active channel + per-channel daemon status snapshot. Future channelStatus() async { final r = await _client.channelStatus(); diff --git a/lib/main.dart b/lib/main.dart index efe3bef..b277829 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,7 +23,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.18.0'; +const String kStudioVersion = '0.19.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/pages/store.dart b/lib/pages/store.dart index b2c884a..228a0db 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -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 { super.dispose(); } + Map _installedVersions = const {}; + Future> _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((_) => []), + ]); + final all = results[0] as List; + final mods = results[1] as List; + _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 { 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 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 installedVersions; final ValueChanged onTap; final ValueChanged 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 _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; + } +} diff --git a/pubspec.lock b/pubspec.lock index 0370b3f..43c18da 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -79,7 +79,7 @@ packages: path: "../fai_dart_sdk" relative: true source: path - version: "0.9.0" + version: "0.10.0" fake_async: dependency: transitive description: @@ -125,6 +125,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" + url: "https://pub.dev" + source: hosted + version: "0.7.7+1" flutter_test: dependency: "direct dev" description: flutter @@ -279,6 +287,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 + url: "https://pub.dev" + source: hosted + version: "7.3.1" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 90dd562..4e20142 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.18.0 +version: 0.19.0 environment: sdk: ^3.11.0-200.1.beta @@ -16,6 +16,8 @@ dependencies: path: ../fai_dart_sdk google_fonts: ^8.1.0 shared_preferences: ^2.5.5 + # Inline README rendering inside the Store detail sheet. + flutter_markdown: ^0.7.7 dev_dependencies: flutter_test: