// 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 '../l10n/app_localizations.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 = ''; /// Source filter: '' (all), 'native', 'mcp', 'n8n'. Applied /// client-side after the hub returns results — the search RPC /// has no source field. String _source = ''; bool _installedOnly = false; /// Tracks per-recommended-source add buttons to disable them /// while the request is in flight. final Set _addingSources = {}; late Future> _future; /// Active UI locale, read from the app-wide MaterialApp /// `locale` setting. Drives which side of the bilingual /// store-index entries renders (taglineEn vs taglineDe, /// descriptionEn vs descriptionDe). The sidebar's /// language toggle flips this for the whole app. String get _locale => Localizations.localeOf(context).languageCode; @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); final l = AppLocalizations.of(context)!; return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( title: Text(l.searchGroupStore), actions: [ // The DE/EN toggle now lives in the sidebar footer // and flips the entire app's locale. The Store // detail-sheet content follows automatically because // _locale reads from `Localizations.localeOf`. IconButton( icon: const Icon(Icons.refresh, size: 18), tooltip: l.storeReloadTooltip, 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 raw = snap.data ?? const []; final items = _applySourceFilter(raw); final categories = _categoriesIn(raw); 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, source: _source, installedOnly: _installedOnly, onStatus: (v) { setState(() => _status = v); _runSearch(); }, onSource: (v) => setState(() => _source = v), 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: l.hubUnreachable, hint: l.hubUnreachableHint, action: FilledButton.tonal( onPressed: _runSearch, child: Text(l.buttonRetry), ), ); } final raw = snap.data ?? const []; final items = _applySourceFilter(raw); if (items.isEmpty) { return FaiEmptyState( icon: Icons.search_off, title: l.storeNoMatches, hint: l.storeNoMatchesHint, action: FilledButton.tonal( onPressed: () { setState(() { _category = ''; _status = ''; _source = ''; _installedOnly = false; _queryCtrl.clear(); }); _runSearch(); }, child: Text(l.storeClearFilters), ), ); } // 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 && _source.isEmpty && !_installedOnly && items.any((e) => e.featured); // Sparse-store nudge: when no filter is set // and the store carries no federated entries // yet, surface curated public MCP servers as // one-click add cards. Hides the moment any // federated entry shows up — the strip has // done its job. final showRecommendedStrip = _queryCtrl.text.trim().isEmpty && _category.isEmpty && _status.isEmpty && _source.isEmpty && !_installedOnly && !raw.any((e) => e.isFederated); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (showRecommendedStrip) ...[ _RecommendedSourcesStrip( adding: _addingSources, locale: _locale, onAdd: _addRecommendedSource, ), const SizedBox(height: FaiSpace.md), ], 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; } List _applySourceFilter(List items) { if (_source.isEmpty) return items; return items.where((e) => _sourceForItem(e) == _source).toList(); } Future _addRecommendedSource(_RecommendedSource s) async { if (_addingSources.contains(s.name)) return; setState(() => _addingSources.add(s.name)); final l = AppLocalizations.of(context)!; try { final list = await HubService.instance.addMcpClient( name: s.name, endpoint: s.endpoint, apiKeyEnv: '', description: s.descriptionEn, ); final added = list.firstWhere( (c) => c.name == s.name, orElse: () => list.last, ); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( l.storeProviderAddedToast(s.label, added.toolCount), ), ), ); _runSearch(); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(l.storeProviderAddFailed(s.label, e.toString())), ), ); } finally { if (mounted) setState(() => _addingSources.remove(s.name)); } } } /// Maps a store entry to its provenance bucket: 'native' for /// non-federated entries, 'mcp' / 'n8n' for federated tools that /// flow through one of the bridges. Used by the source filter /// chip and the per-card provenance pill so operators can tell /// at a glance which entries come from where. String _sourceForItem(StoreItem i) { if (!i.isFederated) return 'native'; if (i.name.startsWith('mcp.')) return 'mcp'; if (i.name.startsWith('n8n.')) return 'n8n'; return 'federated'; } /// 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) { final l = AppLocalizations.of(context)!; return ValueListenableBuilder( valueListenable: controller, builder: (_, value, _) { return TextField( controller: controller, decoration: InputDecoration( prefixIcon: const Icon(Icons.search, size: 20), hintText: l.storeSearchHint, 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) { final l = AppLocalizations.of(context)!; if (categories.isEmpty) return const SizedBox.shrink(); return SizedBox( height: 36, child: ListView( scrollDirection: Axis.horizontal, children: [ _ChoiceChip( label: l.storeStatusAll, 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 String source; final bool installedOnly; final void Function(String) onStatus; final void Function(String) onSource; final void Function(bool) onInstalledOnly; final int resultCount; const _FilterRow({ required this.status, required this.source, required this.installedOnly, required this.onStatus, required this.onSource, required this.onInstalledOnly, required this.resultCount, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: FaiSpace.sm, runSpacing: FaiSpace.xs, children: [ _ChoiceChip( label: l.storeStatusAll, selected: status.isEmpty, onSelected: () => onStatus(''), ), _ChoiceChip( label: l.storeStatusPublished, selected: status == 'published', onSelected: () => onStatus('published'), ), _ChoiceChip( label: l.storeStatusAlpha, selected: status == 'alpha', onSelected: () => onStatus('alpha'), ), _ChoiceChip( label: l.storeStatusPlanned, selected: status == 'planned', onSelected: () => onStatus('planned'), ), Container( width: 1, height: 18, color: theme.colorScheme.outlineVariant, ), _ChoiceChip( label: l.storeSourceAll, selected: source.isEmpty, onSelected: () => onSource(''), ), _ChoiceChip( label: l.storeSourceNative, selected: source == 'native', onSelected: () => onSource('native'), ), _ChoiceChip( label: l.storeSourceMcp, selected: source == 'mcp', onSelected: () => onSource('mcp'), ), _ChoiceChip( label: l.storeSourceN8n, selected: source == 'n8n', onSelected: () => onSource('n8n'), ), FilterChip( label: Text(l.storeInstalledOnly), selected: installedOnly, onSelected: onInstalledOnly, showCheckmark: true, ), Text( l.storeNResults(resultCount), 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, ), ); } } /// 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); final l = AppLocalizations.of(context)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.star, size: 14, color: FaiColors.success), const SizedBox(width: 6), Text( l.storeFeatured, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(width: FaiSpace.sm), Text( l.storeFeaturedHint, 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 l = AppLocalizations.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 ? l.storeNoTagline : 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) FaiPill( label: l.storePillInstalled, tone: FaiPillTone.success, icon: Icons.check, ) else if (installable) FilledButton.icon( onPressed: onInstall, icon: const Icon(Icons.download, size: 16), label: Text(l.buttonInstall), ), ], ), ], ), ), ), ); } } 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 l = AppLocalizations.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: l.storeUpdateTooltip( installedVersion ?? '?', item.bestVersion, ), child: FaiPill( label: l.storePillUpdate, tone: FaiPillTone.warning, icon: Icons.system_update_alt, ), ) else if (item.installed) FaiPill( label: l.storePillInstalled, 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 ? l.storeNoTagline : 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 SizedBox(width: FaiSpace.xs), ], _ProvenancePill(item: item), const Spacer(), if (_hasUpdate) FilledButton.icon( onPressed: onInstall, icon: const Icon(Icons.system_update_alt, size: 14), label: Text(l.storeUpdateButton(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: Text(l.buttonDetails), ) else if (installable) FilledButton.icon( onPressed: onInstall, icon: const Icon(Icons.download, size: 14), label: Text(l.buttonInstall), style: FilledButton.styleFrom( visualDensity: VisualDensity.compact, ), ) else OutlinedButton.icon( onPressed: onTap, icon: const Icon(Icons.info_outline, size: 14), label: Text(l.buttonDetails), 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> { /// Locale comes from MaterialApp via `Localizations.localeOf`; /// the per-sheet state from before is no longer needed. String get _locale => Localizations.localeOf(context).languageCode; 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; final l = AppLocalizations.of(context)!; setState(() { _busy = false; _toast = l.storeInstalledToast(r.name, r.version); }); Navigator.pop(context, true); } catch (e) { if (!mounted) return; final l = AppLocalizations.of(context)!; setState(() { _busy = false; _toast = l.storeInstallFailedToast(e.toString()); }); } } Future _uninstall() async { final l = AppLocalizations.of(context)!; final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(l.storeUninstallTitle), content: Text(l.storeUninstallBody(widget.item.name)), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(l.buttonCancel), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom( backgroundColor: Theme.of(ctx).colorScheme.error, foregroundColor: Theme.of(ctx).colorScheme.onError, ), child: Text(l.buttonUninstall), ), ], ), ); if (ok != true || !mounted) return; setState(() { _busy = true; _toast = null; }); try { final r = await HubService.instance.uninstallModule(widget.item.name); if (!mounted) return; final l2 = AppLocalizations.of(context)!; setState(() { _busy = false; _toast = l2.storeUninstalledToast(r.name, r.version); }); Navigator.pop(context, true); } catch (e) { if (!mounted) return; final l2 = AppLocalizations.of(context)!; setState(() { _busy = false; _toast = l2.storeUninstallFailedToast(e.toString()); }); } } Future _openRepository() async { if (widget.item.repository.isEmpty) return; final r = await SystemActions.openInOs(widget.item.repository); if (!mounted) return; if (!r.ok) { final l = AppLocalizations.of(context)!; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l.storeOpenBrowserFailed(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 l = AppLocalizations.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) FaiPill( label: l.storePillInstalled, tone: FaiPillTone.success, icon: Icons.check, ), if (item.license.isNotEmpty) FaiPill( label: item.license, tone: FaiPillTone.neutral, ), ], ), ], ), ), // Locale follows the app-wide setting // from the sidebar; the detail sheet // does not host its own toggle anymore. ], ), 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(l.storeSectionTags), 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(l.storeSectionRequiredCapabilities), 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(l.storeSectionRequiredHostServices), 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(l.storeSectionScreenshots), 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(l.storeSectionDocumentation), const SizedBox(height: FaiSpace.sm), _DocsPanel( future: _docsFuture, onLoad: _loadDocs, onOpenRepo: _openRepository, ), const SizedBox(height: FaiSpace.lg), _SectionHeader(l.storeSectionSource), 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: Text(l.buttonReadDocs), ), 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: Text(l.buttonUninstall), 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 ? l.storeInstallingButton : l.buttonInstall, ), ) else Tooltip( message: l.storeNotInstallableTooltip(item.status), child: FilledButton( onPressed: null, child: Text(l.storeNotInstallable), ), ), ], ), ], ), ), ), ], ), ); } } 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); final l = AppLocalizations.of(context)!; return AlertDialog( title: Text(l.storeInstallProgressTitle(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( l.storeInstallProgressBody, 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( l.storeInstalledToast(r.name, r.version), style: theme.textTheme.titleMedium, ), ], ); }, ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context, true), child: Text(l.buttonClose), ), ], ); } } 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); final l = AppLocalizations.of(context)!; if (future == null) { return Row( children: [ OutlinedButton.icon( onPressed: onLoad, icon: const Icon(Icons.menu_book_outlined, size: 16), label: Text(l.storeLoadDocs), ), const SizedBox(width: FaiSpace.sm), Text( l.storeDocsHint, 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 Padding( padding: const EdgeInsets.symmetric(vertical: FaiSpace.md), child: Row( children: [ const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: FaiSpace.sm), Text(l.storeDocsFetching), ], ), ); } if (snap.hasError) { return _DocsErrorPanel( message: snap.error.toString(), onOpenRepo: onOpenRepo, ); } final r = snap.data!; if (r.errorKind.isNotEmpty) { return _DocsErrorPanel( message: _friendlyDocsError(context, 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: Text(AppLocalizations.of(context)!.storeOpenRepoButton), 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(BuildContext context, String kind, String detail) { final l = AppLocalizations.of(context)!; switch (kind) { case 'not_found': return l.storeDocsErrorNotFound; case 'no_docs': return l.storeDocsErrorNoDocs; case 'auth': return l.storeDocsErrorAuth; case 'network': return l.storeDocsErrorNetwork(detail); case 'parse': return l.storeDocsErrorParse; default: return detail.isEmpty ? l.storeDocsErrorGeneric : 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, ), ], ], ), ), ), ); } } /// Curated public MCP server that the operator can register /// with one click — no Node, no API key, no subprocess. Fields /// are kept const so the strip can be a `const` widget tree. class _RecommendedSource { /// Stable lowercase id used as the MCP-client name. Becomes /// the prefix of every synthetic store entry the server emits /// (`mcp..`). final String name; /// Display label shown on the card. final String label; final IconData icon; /// HTTPS Streamable-HTTP endpoint. No auth required. final String endpoint; /// Bilingual description used as the MCP-client `description` /// field — keeps the audit log in the operator's locale. final String descriptionEn; final String descriptionDe; const _RecommendedSource({ required this.name, required this.label, required this.icon, required this.endpoint, required this.descriptionEn, required this.descriptionDe, }); } /// Curated public MCP servers offered as one-click adds. Two /// guardrails apply when picking entries: outbound HTTPS only /// (no subprocess, no npm), and a small-enough tool surface /// that the store stays browsable after the add (Useful AI was /// considered but its 340+ tools would drown the index). const _kRecommendedSources = <_RecommendedSource>[ _RecommendedSource( name: 'deepwiki', label: 'DeepWiki', icon: Icons.menu_book_outlined, endpoint: 'https://mcp.deepwiki.com/mcp', descriptionEn: 'GitHub repo documentation search (AI-powered).', descriptionDe: 'GitHub-Repo-Doku-Suche (KI-gestützt).', ), _RecommendedSource( name: 'semgrep', label: 'Semgrep', icon: Icons.security, endpoint: 'https://mcp.semgrep.ai/mcp', descriptionEn: 'Security scanning for code vulnerabilities.', descriptionDe: 'Security-Scanning für Code-Schwachstellen.', ), ]; /// Recommended-sources strip — replaces the older Settings nudge. /// Renders one card per [_kRecommendedSources] entry with a /// single `[+ Add]` button so the operator can fill the store /// without leaving the Store page. Hides as soon as any /// federated entry exists. class _RecommendedSourcesStrip extends StatelessWidget { final Set adding; final String locale; final void Function(_RecommendedSource) onAdd; const _RecommendedSourcesStrip({ required this.adding, required this.locale, required this.onAdd, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Container( width: double.infinity, padding: const EdgeInsets.all(FaiSpace.md), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(FaiRadius.md), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.hub_outlined, size: 18, color: theme.colorScheme.primary, ), const SizedBox(width: FaiSpace.sm), Text( l.storeRecommendedTitle, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), ], ), const SizedBox(height: 4), Text( l.storeRecommendedBody, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.md), Wrap( spacing: FaiSpace.md, runSpacing: FaiSpace.sm, children: [ for (final s in _kRecommendedSources) _RecommendedSourceCard( source: s, locale: locale, busy: adding.contains(s.name), onAdd: () => onAdd(s), ), ], ), ], ), ); } } class _RecommendedSourceCard extends StatelessWidget { final _RecommendedSource source; final String locale; final bool busy; final VoidCallback onAdd; const _RecommendedSourceCard({ required this.source, required this.locale, required this.busy, required this.onAdd, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final desc = locale == 'de' ? source.descriptionDe : source.descriptionEn; return SizedBox( width: 320, child: 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: Row( children: [ Icon(source.icon, size: 18, color: theme.colorScheme.primary), const SizedBox(width: FaiSpace.sm), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( source.label, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), Text( desc, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ), ), const SizedBox(width: FaiSpace.sm), Tooltip( message: busy ? l.storeRecommendedAdding : l.storeRecommendedAddTooltip, child: IconButton.filledTonal( onPressed: busy ? null : onAdd, icon: busy ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.add, size: 16), visualDensity: VisualDensity.compact, ), ), ], ), ), ); } } /// Small color-coded chip that names the entry's source: native /// (muted), MCP (warning tone), or n8n (accent). The on-card /// position lets the operator triage at a glance who emitted the /// capability — the same role the "verified" badge plays in /// commercial app stores. class _ProvenancePill extends StatelessWidget { final StoreItem item; const _ProvenancePill({required this.item}); @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; final source = _sourceForItem(item); switch (source) { case 'mcp': return FaiPill( label: l.storeProvenanceMcp(item.provider), tone: FaiPillTone.warning, monospace: true, icon: Icons.hub_outlined, ); case 'n8n': return FaiPill( label: l.storeProvenanceN8n(item.provider), tone: FaiPillTone.accent, monospace: true, icon: Icons.account_tree_outlined, ); default: return FaiPill( label: l.storeProvenanceNative, tone: FaiPillTone.neutral, ); } } }