chain-studio/lib/pages/store.dart
flemming-it fb7352d182 fix(studio): live-search exception, daemon-card width, featured tile size; add Start-hub button (v0.20.1)
Three reported bugs and one onramp gap:

- Store live-search threw `setState() callback returned a
  Future`. The `=>` body of `_runSearch` evaluated to the
  Future returned by `_load()`. Switched to a block body so
  the closure resolves to void; the future itself is still
  awaited by the FutureBuilder.

- Doctor's Daemon-control card was visibly narrower than its
  siblings — its only stretching child was a Wrap of small
  buttons. Wrapped the content in `SizedBox(width: infinity)`
  so the card fills the section's width like every other
  panel. Card now leads with the live `running on local:
  127.0.0.1:50051` status row + status pill so operators see
  state at a glance, then offers Restart / Start (when
  stopped) / Stop / Status.

- Featured tiles were 320×152 against the regular grid's
  ~360×168, so the editorial picks looked subordinate to
  the dense grid. Bumped to 480×220 with bigger icon, larger
  title, larger tagline so the strip reads as a hero band.

- ConnectionPill in the sidebar now offers an inline "Start
  hub" button when the daemon is unreachable. Spawns
  `fai daemon start` via SystemActions; the shell-level
  health poll picks the daemon up on its next tick. Closes
  the chicken-and-egg for Windows users who can't open a
  shell to bootstrap.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-07 21:27:09 +02:00

1646 lines
55 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Store browser. Lists modules from the hub's bundled
// store-index. Top-level surface mirrors the look operators
// expect from app stores (search bar, category strip, filter
// chips, status chips), with a per-entry detail sheet that
// renders the full bilingual description, requires-list, and
// repository link.
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import '../data/hub.dart';
import '../data/system_actions.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
class StorePage extends StatefulWidget {
const StorePage({super.key});
@override
State<StorePage> createState() => _StorePageState();
}
class _StorePageState extends State<StorePage> {
final _queryCtrl = TextEditingController();
String _category = '';
String _status = '';
bool _installedOnly = false;
String _locale = 'en';
late Future<List<StoreItem>> _future;
@override
void initState() {
super.initState();
_future = _load();
}
@override
void dispose() {
_queryCtrl.dispose();
super.dispose();
}
Map<String, String> _installedVersions = const {};
Future<List<StoreItem>> _load() async {
// 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;
}
void _runSearch() {
// Block body, so the closure's return type is `void`
// rather than `Future`. setState() rejects closures that
// resolve to a Future — the assignment must be synchronous
// even though the future itself is awaited later.
setState(() {
_future = _load();
});
}
Future<void> _openDetail(StoreItem item) async {
final changed = await _StoreDetailSheet.show(context, item, _locale);
if (changed) _runSearch();
}
Future<void> _install(StoreItem item) async {
if (!mounted) return;
final outcome = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (_) => _InstallProgressDialog(item: item),
);
if (outcome == true) _runSearch();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(
title: const Text('Store'),
actions: [
// Tiny DE/EN toggle — the store-index ships bilingual
// copy and operators in regulated environments often
// share screens with non-English colleagues.
_LocaleToggle(
value: _locale,
onChanged: (v) => setState(() => _locale = v),
),
IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: 'Reload',
onPressed: _runSearch,
),
const SizedBox(width: FaiSpace.sm),
],
),
body: Padding(
padding: const EdgeInsets.fromLTRB(
FaiSpace.xl,
FaiSpace.lg,
FaiSpace.xl,
0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SearchField(
controller: _queryCtrl,
onSubmit: _runSearch,
onClear: () {
_queryCtrl.clear();
_runSearch();
},
),
const SizedBox(height: FaiSpace.md),
FutureBuilder<List<StoreItem>>(
future: _future,
builder: (context, snap) {
final items = snap.data ?? const <StoreItem>[];
final categories = _categoriesIn(items);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CategoryStrip(
categories: categories,
selected: _category,
onSelected: (v) {
setState(() => _category = v);
_runSearch();
},
),
const SizedBox(height: FaiSpace.sm),
_FilterRow(
status: _status,
installedOnly: _installedOnly,
onStatus: (v) {
setState(() => _status = v);
_runSearch();
},
onInstalledOnly: (v) {
setState(() => _installedOnly = v);
_runSearch();
},
resultCount: items.length,
),
],
);
},
),
const SizedBox(height: FaiSpace.md),
Expanded(
child: FutureBuilder<List<StoreItem>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: 'Hub unreachable',
hint: 'Start the hub with `fai daemon start`.',
action: FilledButton.tonal(
onPressed: _runSearch,
child: const Text('Retry'),
),
);
}
final items = snap.data ?? const <StoreItem>[];
if (items.isEmpty) {
return FaiEmptyState(
icon: Icons.search_off,
title: 'No matches',
hint:
'Adjust the filters or clear the search to see all entries.',
action: FilledButton.tonal(
onPressed: () {
setState(() {
_category = '';
_status = '';
_installedOnly = false;
_queryCtrl.clear();
});
_runSearch();
},
child: const Text('Clear filters'),
),
);
}
// Featured strip is only useful in the
// browse-the-whole-thing case. Once the
// operator has typed a query or picked a
// filter, the result list itself is the
// answer they want — featured chrome would
// just push it down.
final showFeatured = _queryCtrl.text.trim().isEmpty &&
_category.isEmpty &&
_status.isEmpty &&
!_installedOnly &&
items.any((e) => e.featured);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showFeatured) ...[
_FeaturedStrip(
items: items.where((e) => e.featured).toList(),
locale: _locale,
onTap: _openDetail,
onInstall: _install,
),
const SizedBox(height: FaiSpace.lg),
],
Expanded(
child: _StoreGrid(
items: items,
locale: _locale,
installedVersions: _installedVersions,
onTap: _openDetail,
onInstall: _install,
),
),
],
);
},
),
),
],
),
),
);
}
List<String> _categoriesIn(List<StoreItem> items) {
final set = <String>{};
for (final i in items) {
if (i.category.isNotEmpty) set.add(i.category);
}
final list = set.toList()..sort();
return list;
}
}
/// Big search input that owns the page's primary action.
class _SearchField extends StatelessWidget {
final TextEditingController controller;
final VoidCallback onSubmit;
final VoidCallback onClear;
const _SearchField({
required this.controller,
required this.onSubmit,
required this.onClear,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (_, value, _) {
return TextField(
controller: controller,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search, size: 20),
hintText: 'Search modules — name, tagline, tag, capability…',
border: const OutlineInputBorder(),
isDense: true,
suffixIcon: value.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: onClear,
),
),
onChanged: (_) => onSubmit(),
onSubmitted: (_) => onSubmit(),
);
},
);
}
}
/// Horizontally-scrollable category chip strip. Always-on
/// "All" chip first, then categories the current result set
/// contains. Disappears when the index is empty.
class _CategoryStrip extends StatelessWidget {
final List<String> categories;
final String selected;
final void Function(String) onSelected;
const _CategoryStrip({
required this.categories,
required this.selected,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
if (categories.isEmpty) return const SizedBox.shrink();
return SizedBox(
height: 36,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_ChoiceChip(
label: 'All',
selected: selected.isEmpty,
onSelected: () => onSelected(''),
),
for (final c in categories)
_ChoiceChip(
label: c,
selected: selected == c,
onSelected: () => onSelected(c),
),
],
),
);
}
}
class _FilterRow extends StatelessWidget {
final String status;
final bool installedOnly;
final void Function(String) onStatus;
final void Function(bool) onInstalledOnly;
final int resultCount;
const _FilterRow({
required this.status,
required this.installedOnly,
required this.onStatus,
required this.onInstalledOnly,
required this.resultCount,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
_ChoiceChip(
label: 'All',
selected: status.isEmpty,
onSelected: () => onStatus(''),
),
_ChoiceChip(
label: 'Published',
selected: status == 'published',
onSelected: () => onStatus('published'),
),
_ChoiceChip(
label: 'Alpha',
selected: status == 'alpha',
onSelected: () => onStatus('alpha'),
),
_ChoiceChip(
label: 'Planned',
selected: status == 'planned',
onSelected: () => onStatus('planned'),
),
const SizedBox(width: FaiSpace.md),
FilterChip(
label: const Text('Installed'),
selected: installedOnly,
onSelected: onInstalledOnly,
showCheckmark: true,
),
const Spacer(),
Text(
'$resultCount result${resultCount == 1 ? "" : "s"}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
}
class _ChoiceChip extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onSelected;
const _ChoiceChip({
required this.label,
required this.selected,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: FaiSpace.xs),
child: ChoiceChip(
label: Text(label),
selected: selected,
onSelected: (_) => onSelected(),
showCheckmark: false,
),
);
}
}
class _LocaleToggle extends StatelessWidget {
final String value;
final ValueChanged<String> onChanged;
const _LocaleToggle({required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: FaiSpace.sm),
child: Tooltip(
message: value == 'en'
? 'Showing English copy. Click to switch to German.'
: 'Deutsche Beschreibung — Click for English.',
child: TextButton.icon(
onPressed: () => onChanged(value == 'en' ? 'de' : 'en'),
icon: const Icon(Icons.translate, size: 16),
label: Text(value.toUpperCase()),
),
),
);
}
}
/// Responsive grid — wraps to as many columns as the viewport
/// allows. Cards are click-to-expand; install is also a top-
/// level action so single-click installs are still possible.
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,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
const minCardWidth = 360.0;
final cols = (constraints.maxWidth / minCardWidth).floor().clamp(1, 4);
return GridView.builder(
padding: EdgeInsets.zero,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisSpacing: FaiSpace.md,
crossAxisSpacing: FaiSpace.md,
mainAxisExtent: 168,
),
itemCount: items.length,
itemBuilder: (context, i) => _StoreCard(
item: items[i],
locale: locale,
installedVersion: installedVersions[items[i].name],
onTap: () => onTap(items[i]),
onInstall: () => onInstall(items[i]),
),
);
},
);
}
}
/// Horizontally-scrolling band of editorial picks, rendered
/// only when the operator hasn't filtered the result set.
/// Each tile is a hero version of the regular store card —
/// bigger icon, gradient backdrop, prominent install button.
/// Curated quarterly via the bundled `seed.yaml` `featured:`
/// list so the choice can be reviewed in code review.
class _FeaturedStrip extends StatelessWidget {
final List<StoreItem> items;
final String locale;
final ValueChanged<StoreItem> onTap;
final ValueChanged<StoreItem> onInstall;
const _FeaturedStrip({
required this.items,
required this.locale,
required this.onTap,
required this.onInstall,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.star, size: 14, color: FaiColors.success),
const SizedBox(width: 6),
Text(
'FEATURED',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(width: FaiSpace.sm),
Text(
'curated picks · click for details',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: FaiSpace.sm),
SizedBox(
// Tiles render larger than regular grid cards
// (which are 168 × ~360) so the editorial picks
// visibly stand out. Same hierarchy you see on app
// stores: hero band before the dense list.
height: 220,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(width: FaiSpace.md),
itemBuilder: (context, i) {
final item = items[i];
return SizedBox(
width: 480,
child: _FeaturedTile(
item: item,
locale: locale,
onTap: () => onTap(item),
onInstall: () => onInstall(item),
),
);
},
),
),
],
);
}
}
class _FeaturedTile extends StatelessWidget {
final StoreItem item;
final String locale;
final VoidCallback onTap;
final VoidCallback onInstall;
const _FeaturedTile({
required this.item,
required this.locale,
required this.onTap,
required this.onInstall,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tagline = locale == 'de' && item.taglineDe.isNotEmpty
? item.taglineDe
: item.taglineEn;
final installable = item.status == 'published' || item.status == 'alpha';
return Material(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: Container(
padding: const EdgeInsets.all(FaiSpace.lg),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.md),
gradient: LinearGradient(
colors: [
theme.colorScheme.primary.withValues(alpha: 0.14),
theme.colorScheme.surfaceContainer.withValues(alpha: 0.0),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor:
theme.colorScheme.primary.withValues(alpha: 0.18),
child: Icon(
_iconForCategory(item.category),
size: 26,
color: theme.colorScheme.primary,
),
),
const SizedBox(width: FaiSpace.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (item.category.isNotEmpty)
Text(
item.category,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(Icons.star, size: 18, color: FaiColors.success),
],
),
const SizedBox(height: FaiSpace.md),
Expanded(
child: Text(
tagline.isEmpty ? '(no tagline)' : tagline,
style: theme.textTheme.bodyLarge,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
if (item.bestVersion.isNotEmpty)
FaiPill(
label: 'v${item.bestVersion}',
tone: FaiPillTone.neutral,
monospace: true,
),
const SizedBox(width: FaiSpace.xs),
if (item.status.isNotEmpty)
FaiPill(
label: item.status,
tone: _toneForStatus(item.status),
),
const Spacer(),
if (item.installed)
const FaiPill(
label: 'installed',
tone: FaiPillTone.success,
icon: Icons.check,
)
else if (installable)
FilledButton.icon(
onPressed: onInstall,
icon: const Icon(Icons.download, size: 16),
label: const Text('Install'),
),
],
),
],
),
),
),
);
}
}
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);
final tagline = locale == 'de' && item.taglineDe.isNotEmpty
? item.taglineDe
: item.taglineEn;
final installable = item.status == 'published' || item.status == 'alpha';
return Material(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: Padding(
padding: const EdgeInsets.all(FaiSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor:
theme.colorScheme.primary.withValues(alpha: 0.12),
child: Icon(
_iconForCategory(item.category),
size: 18,
color: theme.colorScheme.primary,
),
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
item.category.isEmpty ? '' : item.category,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
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,
icon: Icons.check,
)
else if (item.status.isNotEmpty)
FaiPill(
label: item.status,
tone: _toneForStatus(item.status),
),
],
),
const SizedBox(height: FaiSpace.sm),
Expanded(
child: Text(
tagline.isEmpty ? '(no tagline)' : tagline,
style: theme.textTheme.bodySmall,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
if (item.bestVersion.isNotEmpty)
FaiPill(
label: 'v${item.bestVersion}',
tone: FaiPillTone.neutral,
monospace: true,
),
const Spacer(),
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),
label: const Text('Details'),
)
else if (installable)
FilledButton.icon(
onPressed: onInstall,
icon: const Icon(Icons.download, size: 14),
label: const Text('Install'),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
)
else
OutlinedButton.icon(
onPressed: onTap,
icon: const Icon(Icons.info_outline, size: 14),
label: const Text('Details'),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
],
),
),
),
);
}
}
/// Per-module detail sheet. Renders the full bilingual
/// description, the capabilities + services the module
/// requires, plus repository / docs links. Returns `true` when
/// state changed (install / uninstall) so the caller refreshes.
class _StoreDetailSheet extends StatefulWidget {
final StoreItem item;
final String locale;
const _StoreDetailSheet({required this.item, required this.locale});
static Future<bool> show(
BuildContext context,
StoreItem item,
String locale,
) async {
final r = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
),
builder: (_) => _StoreDetailSheet(item: item, locale: locale),
);
return r ?? false;
}
@override
State<_StoreDetailSheet> createState() => _StoreDetailSheetState();
}
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(() {
_busy = true;
_toast = null;
});
try {
final r = await HubService.instance.installModule(source: widget.item.name);
if (!mounted) return;
setState(() {
_busy = false;
_toast = '${r.name} v${r.version} installed.';
});
Navigator.pop(context, true);
} catch (e) {
if (!mounted) return;
setState(() {
_busy = false;
_toast = 'Install failed: $e';
});
}
}
Future<void> _uninstall() async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Uninstall module?'),
content: Text(
'Removes ${widget.item.name} from this hub. Flows that '
'reference it will fail at the next run.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
foregroundColor: Theme.of(ctx).colorScheme.onError,
),
child: const Text('Uninstall'),
),
],
),
);
if (ok != true || !mounted) return;
setState(() {
_busy = true;
_toast = null;
});
try {
final r = await HubService.instance.uninstallModule(widget.item.name);
if (!mounted) return;
setState(() {
_busy = false;
_toast = '${r.name} v${r.version} uninstalled.';
});
Navigator.pop(context, true);
} catch (e) {
if (!mounted) return;
setState(() {
_busy = false;
_toast = 'Uninstall failed: $e';
});
}
}
Future<void> _openRepository() async {
if (widget.item.repository.isEmpty) return;
final r = await SystemActions.openInOs(widget.item.repository);
if (!mounted) return;
if (!r.ok) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open browser: ${r.stderr}')),
);
}
}
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);
final item = widget.item;
final tagline = _locale == 'de' && item.taglineDe.isNotEmpty
? item.taglineDe
: item.taglineEn;
final description = _locale == 'de' && item.descriptionDe.isNotEmpty
? item.descriptionDe
: item.descriptionEn;
final installable = item.status == 'published' || item.status == 'alpha';
final maxHeight = MediaQuery.of(context).size.height * 0.85;
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
FaiSpace.xxl,
FaiSpace.md,
FaiSpace.xxl,
FaiSpace.xxl,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 26,
backgroundColor: theme.colorScheme.primary
.withValues(alpha: 0.12),
child: Icon(
_iconForCategory(item.category),
size: 28,
color: theme.colorScheme.primary,
),
),
const SizedBox(width: FaiSpace.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Wrap(
spacing: FaiSpace.xs,
runSpacing: 4,
children: [
if (item.bestVersion.isNotEmpty)
FaiPill(
label: 'v${item.bestVersion}',
tone: FaiPillTone.neutral,
monospace: true,
),
if (item.status.isNotEmpty)
FaiPill(
label: item.status,
tone: _toneForStatus(item.status),
),
if (item.category.isNotEmpty)
FaiPill(
label: item.category,
tone: FaiPillTone.neutral,
),
if (item.installed)
const FaiPill(
label: 'installed',
tone: FaiPillTone.success,
icon: Icons.check,
),
if (item.license.isNotEmpty)
FaiPill(
label: item.license,
tone: FaiPillTone.neutral,
),
],
),
],
),
),
_LocaleToggle(
value: _locale,
onChanged: (v) => setState(() => _locale = v),
),
],
),
const SizedBox(height: FaiSpace.lg),
if (tagline.isNotEmpty) ...[
Text(
tagline,
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.lg),
],
if (description.isNotEmpty) ...[
SelectableText(
description,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: FaiSpace.xl),
],
if (item.tags.isNotEmpty) ...[
_SectionHeader('Tags'),
const SizedBox(height: FaiSpace.sm),
Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
children: [
for (final t in item.tags)
FaiPill(label: t, tone: FaiPillTone.neutral),
],
),
const SizedBox(height: FaiSpace.lg),
],
if (item.requiresCapabilities.isNotEmpty) ...[
_SectionHeader('Required capabilities'),
const SizedBox(height: FaiSpace.sm),
Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
children: [
for (final c in item.requiresCapabilities)
FaiPill(
label: c,
tone: FaiPillTone.accent,
monospace: true,
),
],
),
const SizedBox(height: FaiSpace.lg),
],
if (item.requiresServices.isNotEmpty) ...[
_SectionHeader('Required host services'),
const SizedBox(height: FaiSpace.sm),
Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
children: [
for (final s in item.requiresServices)
FaiPill(
label: s,
tone: FaiPillTone.warning,
monospace: true,
),
],
),
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(
onTap: _openRepository,
child: Row(
children: [
const Icon(Icons.open_in_new, size: 14),
const SizedBox(width: FaiSpace.xs),
Flexible(
child: Text(
item.repository,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(height: FaiSpace.lg),
],
if (_toast != null) ...[
Container(
padding: const EdgeInsets.all(FaiSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(
color: theme.colorScheme.outlineVariant,
),
),
child: SelectableText(
_toast!,
style: FaiTheme.mono(size: 11),
),
),
const SizedBox(height: FaiSpace.md),
],
Row(
children: [
if (item.repository.isNotEmpty)
OutlinedButton.icon(
onPressed: _openRepository,
icon: const Icon(Icons.menu_book_outlined, size: 16),
label: const Text('Read docs'),
),
const Spacer(),
if (item.installed) ...[
OutlinedButton.icon(
onPressed: _busy ? null : _uninstall,
icon: _busy
? const SizedBox(
width: 14,
height: 14,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.delete_outline, size: 16),
label: const Text('Uninstall'),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(
color: theme.colorScheme.error
.withValues(alpha: 0.5),
),
),
),
] else if (installable)
FilledButton.icon(
onPressed: _busy ? null : _install,
icon: _busy
? const SizedBox(
width: 14,
height: 14,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download, size: 16),
label: Text(_busy ? 'Installing…' : 'Install'),
)
else
Tooltip(
message:
'Status "${item.status}" — install path not yet wired.',
child: const FilledButton(
onPressed: null,
child: Text('Not installable'),
),
),
],
),
],
),
),
),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String text;
const _SectionHeader(this.text);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Text(
text.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
);
}
}
class _InstallProgressDialog extends StatefulWidget {
final StoreItem item;
const _InstallProgressDialog({required this.item});
@override
State<_InstallProgressDialog> createState() => _InstallProgressDialogState();
}
class _InstallProgressDialogState extends State<_InstallProgressDialog> {
late final Future<({String name, String version})> _future;
@override
void initState() {
super.initState();
// Capability-name install: hub resolves the wasm_url from the
// bundled store-index. No source prompt needed for entries
// whose seed.yaml already has wasm_url.
_future = HubService.instance.installModule(source: widget.item.name);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: Text('Installing ${widget.item.name}'),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 360),
child: FutureBuilder<({String name, String version})>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: FaiSpace.md),
const CircularProgressIndicator(),
const SizedBox(height: FaiSpace.md),
Text(
'Fetching, verifying, unpacking…',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
if (snap.hasError) {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
color: theme.colorScheme.error,
size: 32,
),
const SizedBox(height: FaiSpace.md),
SelectableText(
snap.error.toString(),
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.error,
),
),
],
),
);
}
final r = snap.data!;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle_outline,
size: 32,
color: FaiColors.success,
),
const SizedBox(height: FaiSpace.md),
Text(
'${r.name} v${r.version} installed.',
style: theme.textTheme.titleMedium,
),
],
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Close'),
),
],
);
}
}
FaiPillTone _toneForStatus(String s) {
switch (s) {
case 'published':
return FaiPillTone.success;
case 'alpha':
return FaiPillTone.warning;
case 'planned':
return FaiPillTone.neutral;
default:
return FaiPillTone.neutral;
}
}
IconData _iconForCategory(String category) {
switch (category) {
case 'llm':
return Icons.psychology_outlined;
case 'text':
case 'text-processing':
return Icons.description_outlined;
case 'system':
return Icons.settings_outlined;
case 'orchestrator':
return Icons.account_tree_outlined;
case 'debug':
return Icons.bug_report_outlined;
case 'auth':
return Icons.lock_outline;
case 'storage':
return Icons.storage_outlined;
case 'channel':
return Icons.forum_outlined;
default:
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;
}
}