// 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 createState() => _StorePageState(); } class _StorePageState extends State { final _queryCtrl = TextEditingController(); String _category = ''; String _status = ''; bool _installedOnly = false; String _locale = 'en'; late Future> _future; @override void initState() { super.initState(); _future = _load(); } @override void dispose() { _queryCtrl.dispose(); super.dispose(); } Map _installedVersions = const {}; Future> _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((_) => []), ]); 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; } 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 _openDetail(StoreItem item) async { final changed = await _StoreDetailSheet.show(context, item, _locale); if (changed) _runSearch(); } Future _install(StoreItem item) async { if (!mounted) return; final outcome = await showDialog( 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>( future: _future, builder: (context, snap) { final items = snap.data ?? const []; 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>( 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 []; 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 _categoriesIn(List items) { final set = {}; 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( 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 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 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 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, }); @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 items; final String locale; final ValueChanged onTap; final ValueChanged 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 show( BuildContext context, StoreItem item, String locale, ) async { final r = await showModalBottomSheet( 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 _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 _uninstall() async { final ok = await showDialog( 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 _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: [ _ModuleIcon( iconUrl: item.iconUrl, category: item.category, radius: 28, ), 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.screenshotUrls.isNotEmpty) ...[ _SectionHeader('Screenshots'), const SizedBox(height: FaiSpace.sm), SizedBox( height: 220, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: item.screenshotUrls.length, separatorBuilder: (_, _) => const SizedBox(width: FaiSpace.sm), itemBuilder: (context, i) => _Screenshot( url: item.screenshotUrls[i], ), ), ), const SizedBox(height: FaiSpace.lg), ], if (item.repository.isNotEmpty || item.docsUrl.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; } } /// 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 /// network image in `frameBuilder` so a load failure doesn't /// leave a broken-image rectangle in the hero header. class _ModuleIcon extends StatelessWidget { final String iconUrl; final String category; final double radius; const _ModuleIcon({ required this.iconUrl, required this.category, required this.radius, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final fallback = CircleAvatar( radius: radius, backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.12), child: Icon( _iconForCategory(category), size: radius * 1.0, color: theme.colorScheme.primary, ), ); if (iconUrl.isEmpty) return fallback; return ClipOval( child: SizedBox( width: radius * 2, height: radius * 2, child: Image.network( iconUrl, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback, loadingBuilder: (_, child, progress) => progress == null ? child : fallback, ), ), ); } } /// One screenshot tile in the detail-sheet strip. Click opens /// the image full-size via the OS handler — keeps Studio /// chromeless. Renders a placeholder on load failure rather /// than the default broken-image rectangle. class _Screenshot extends StatelessWidget { final String url; const _Screenshot({required this.url}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Material( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.sm), child: InkWell( onTap: () => SystemActions.openInOs(url), borderRadius: BorderRadius.circular(FaiRadius.sm), child: ClipRRect( borderRadius: BorderRadius.circular(FaiRadius.sm), child: SizedBox( width: 320, child: Image.network( url, fit: BoxFit.cover, errorBuilder: (_, _, _) => _ScreenshotPlaceholder( label: 'image unavailable', detail: url, ), loadingBuilder: (_, child, progress) { if (progress == null) return child; return const _ScreenshotPlaceholder( label: 'loading…', detail: '', ); }, ), ), ), ), ); } } class _ScreenshotPlaceholder extends StatelessWidget { final String label; final String detail; const _ScreenshotPlaceholder({required this.label, required this.detail}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( color: theme.colorScheme.surfaceContainer, child: Center( child: Padding( padding: const EdgeInsets.all(FaiSpace.md), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.image_outlined, size: 32, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(height: 4), Text( label, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), if (detail.isNotEmpty) ...[ const SizedBox(height: 2), Text( detail, style: FaiTheme.mono( size: 9, color: theme.colorScheme.onSurfaceVariant, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ], ), ), ), ); } }