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:
parent
93926741b1
commit
04cc12bc54
5 changed files with 335 additions and 16 deletions
|
|
@ -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<ChannelStatusSnapshot> channelStatus() async {
|
||||
final r = await _client.channelStatus();
|
||||
|
|
|
|||
|
|
@ -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<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
pubspec.lock
18
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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue