diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 5d60dc1..679c012 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -177,9 +177,36 @@ "@storeDocsErrorNetwork": { "placeholders": { "detail": { "type": "String" } } }, "storeDocsErrorParse": "README ist kein gültiges UTF-8.", "storeDocsErrorGeneric": "README konnte nicht geladen werden.", - "storeFederationNudgeTitle": "Mehr Capabilities?", - "storeFederationNudgeBody": "MCP-Server oder n8n-Endpunkt unter Einstellungen → MCP-Clients / N8N-Endpunkte konfigurieren. Entdeckte Tools und Workflows tauchen hier als `mcp..`- und `n8n..`-Capabilities auf — jeder Anthropic-/Community-MCP-Server bringt mehrere auf einmal.", - "storeFederationNudgeButton": "Einstellungen öffnen", + "storeSourceLabel": "Quelle", + "storeSourceAll": "Alle Quellen", + "storeSourceNative": "Nativ", + "storeSourceMcp": "MCP", + "storeSourceN8n": "n8n", + "storeProvenanceNative": "nativ", + "storeProvenanceMcp": "mcp · {provider}", + "@storeProvenanceMcp": { "placeholders": { "provider": { "type": "String" } } }, + "storeProvenanceN8n": "n8n · {provider}", + "@storeProvenanceN8n": { "placeholders": { "provider": { "type": "String" } } }, + "storeRecommendedTitle": "Öffentliche Quelle hinzufügen — ein Klick", + "storeRecommendedBody": "Kuratierte MCP-Server über HTTPS erreichbar. Kein Subprozess, kein API-Key, kein Node. Föderierte Capabilities erscheinen sofort im Store.", + "storeRecommendedAddTooltip": "Hinzufügen und entdecken", + "storeRecommendedAdding": "Wird hinzugefügt…", + "storeRecommendedDeepWikiTagline": "GitHub-Repo-Doku-Suche (KI-gestützt)", + "storeRecommendedSemgrepTagline": "Security-Scanning für Code-Schwachstellen", + "storeProviderAddedToast": "{name} hinzugefügt — {n} {n, plural, =1{föderierte Capability} other{föderierte Capabilities}}.", + "@storeProviderAddedToast": { + "placeholders": { + "name": { "type": "String" }, + "n": { "type": "int" } + } + }, + "storeProviderAddFailed": "{name} konnte nicht hinzugefügt werden: {error}", + "@storeProviderAddFailed": { + "placeholders": { + "name": { "type": "String" }, + "error": { "type": "String" } + } + }, "auditTitle": "Protokoll", "auditNoEvents": "Noch keine Ereignisse", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a1ef61c..424d3a4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -178,9 +178,36 @@ "@storeDocsErrorNetwork": { "placeholders": { "detail": { "type": "String" } } }, "storeDocsErrorParse": "README is not valid UTF-8.", "storeDocsErrorGeneric": "Could not load README.", - "storeFederationNudgeTitle": "Want more capabilities?", - "storeFederationNudgeBody": "Configure an MCP server or n8n endpoint in Settings → MCP Clients / N8N Endpoints. Discovered tools and workflows surface here as `mcp..` and `n8n..` capabilities — every Anthropic / community MCP server adds a handful at once.", - "storeFederationNudgeButton": "Open Settings", + "storeSourceLabel": "Source", + "storeSourceAll": "All sources", + "storeSourceNative": "Native", + "storeSourceMcp": "MCP", + "storeSourceN8n": "n8n", + "storeProvenanceNative": "native", + "storeProvenanceMcp": "mcp · {provider}", + "@storeProvenanceMcp": { "placeholders": { "provider": { "type": "String" } } }, + "storeProvenanceN8n": "n8n · {provider}", + "@storeProvenanceN8n": { "placeholders": { "provider": { "type": "String" } } }, + "storeRecommendedTitle": "Add a public source — one click", + "storeRecommendedBody": "Curated MCP servers reachable over HTTPS. No subprocess, no API key, no Node. Federated capabilities appear in the store immediately.", + "storeRecommendedAddTooltip": "Add and discover", + "storeRecommendedAdding": "Adding…", + "storeRecommendedDeepWikiTagline": "GitHub repo documentation search (AI-powered)", + "storeRecommendedSemgrepTagline": "Security scanning for code vulnerabilities", + "storeProviderAddedToast": "Added {name} — {n} federated {n, plural, =1{capability} other{capabilities}}.", + "@storeProviderAddedToast": { + "placeholders": { + "name": { "type": "String" }, + "n": { "type": "int" } + } + }, + "storeProviderAddFailed": "Could not add {name}: {error}", + "@storeProviderAddFailed": { + "placeholders": { + "name": { "type": "String" }, + "error": { "type": "String" } + } + }, "auditTitle": "Audit", "auditNoEvents": "No events yet", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a3e7e03..42518e1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -890,23 +890,101 @@ abstract class AppLocalizations { /// **'Could not load README.'** String get storeDocsErrorGeneric; - /// No description provided for @storeFederationNudgeTitle. + /// No description provided for @storeSourceLabel. /// /// In en, this message translates to: - /// **'Want more capabilities?'** - String get storeFederationNudgeTitle; + /// **'Source'** + String get storeSourceLabel; - /// No description provided for @storeFederationNudgeBody. + /// No description provided for @storeSourceAll. /// /// In en, this message translates to: - /// **'Configure an MCP server or n8n endpoint in Settings → MCP Clients / N8N Endpoints. Discovered tools and workflows surface here as `mcp..` and `n8n..` capabilities — every Anthropic / community MCP server adds a handful at once.'** - String get storeFederationNudgeBody; + /// **'All sources'** + String get storeSourceAll; - /// No description provided for @storeFederationNudgeButton. + /// No description provided for @storeSourceNative. /// /// In en, this message translates to: - /// **'Open Settings'** - String get storeFederationNudgeButton; + /// **'Native'** + String get storeSourceNative; + + /// No description provided for @storeSourceMcp. + /// + /// In en, this message translates to: + /// **'MCP'** + String get storeSourceMcp; + + /// No description provided for @storeSourceN8n. + /// + /// In en, this message translates to: + /// **'n8n'** + String get storeSourceN8n; + + /// No description provided for @storeProvenanceNative. + /// + /// In en, this message translates to: + /// **'native'** + String get storeProvenanceNative; + + /// No description provided for @storeProvenanceMcp. + /// + /// In en, this message translates to: + /// **'mcp · {provider}'** + String storeProvenanceMcp(String provider); + + /// No description provided for @storeProvenanceN8n. + /// + /// In en, this message translates to: + /// **'n8n · {provider}'** + String storeProvenanceN8n(String provider); + + /// No description provided for @storeRecommendedTitle. + /// + /// In en, this message translates to: + /// **'Add a public source — one click'** + String get storeRecommendedTitle; + + /// No description provided for @storeRecommendedBody. + /// + /// In en, this message translates to: + /// **'Curated MCP servers reachable over HTTPS. No subprocess, no API key, no Node. Federated capabilities appear in the store immediately.'** + String get storeRecommendedBody; + + /// No description provided for @storeRecommendedAddTooltip. + /// + /// In en, this message translates to: + /// **'Add and discover'** + String get storeRecommendedAddTooltip; + + /// No description provided for @storeRecommendedAdding. + /// + /// In en, this message translates to: + /// **'Adding…'** + String get storeRecommendedAdding; + + /// No description provided for @storeRecommendedDeepWikiTagline. + /// + /// In en, this message translates to: + /// **'GitHub repo documentation search (AI-powered)'** + String get storeRecommendedDeepWikiTagline; + + /// No description provided for @storeRecommendedSemgrepTagline. + /// + /// In en, this message translates to: + /// **'Security scanning for code vulnerabilities'** + String get storeRecommendedSemgrepTagline; + + /// No description provided for @storeProviderAddedToast. + /// + /// In en, this message translates to: + /// **'Added {name} — {n} federated {n, plural, =1{capability} other{capabilities}}.'** + String storeProviderAddedToast(String name, int n); + + /// No description provided for @storeProviderAddFailed. + /// + /// In en, this message translates to: + /// **'Could not add {name}: {error}'** + String storeProviderAddFailed(String name, String error); /// No description provided for @auditTitle. /// diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index c96c274..3f4d95a 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -471,14 +471,70 @@ class AppLocalizationsDe extends AppLocalizations { String get storeDocsErrorGeneric => 'README konnte nicht geladen werden.'; @override - String get storeFederationNudgeTitle => 'Mehr Capabilities?'; + String get storeSourceLabel => 'Quelle'; @override - String get storeFederationNudgeBody => - 'MCP-Server oder n8n-Endpunkt unter Einstellungen → MCP-Clients / N8N-Endpunkte konfigurieren. Entdeckte Tools und Workflows tauchen hier als `mcp..`- und `n8n..`-Capabilities auf — jeder Anthropic-/Community-MCP-Server bringt mehrere auf einmal.'; + String get storeSourceAll => 'Alle Quellen'; @override - String get storeFederationNudgeButton => 'Einstellungen öffnen'; + String get storeSourceNative => 'Nativ'; + + @override + String get storeSourceMcp => 'MCP'; + + @override + String get storeSourceN8n => 'n8n'; + + @override + String get storeProvenanceNative => 'nativ'; + + @override + String storeProvenanceMcp(String provider) { + return 'mcp · $provider'; + } + + @override + String storeProvenanceN8n(String provider) { + return 'n8n · $provider'; + } + + @override + String get storeRecommendedTitle => + 'Öffentliche Quelle hinzufügen — ein Klick'; + + @override + String get storeRecommendedBody => + 'Kuratierte MCP-Server über HTTPS erreichbar. Kein Subprozess, kein API-Key, kein Node. Föderierte Capabilities erscheinen sofort im Store.'; + + @override + String get storeRecommendedAddTooltip => 'Hinzufügen und entdecken'; + + @override + String get storeRecommendedAdding => 'Wird hinzugefügt…'; + + @override + String get storeRecommendedDeepWikiTagline => + 'GitHub-Repo-Doku-Suche (KI-gestützt)'; + + @override + String get storeRecommendedSemgrepTagline => + 'Security-Scanning für Code-Schwachstellen'; + + @override + String storeProviderAddedToast(String name, int n) { + String _temp0 = intl.Intl.pluralLogic( + n, + locale: localeName, + other: 'föderierte Capabilities', + one: 'föderierte Capability', + ); + return '$name hinzugefügt — $n $_temp0.'; + } + + @override + String storeProviderAddFailed(String name, String error) { + return '$name konnte nicht hinzugefügt werden: $error'; + } @override String get auditTitle => 'Protokoll'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 04e1463..c3ee300 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -489,14 +489,69 @@ class AppLocalizationsEn extends AppLocalizations { String get storeDocsErrorGeneric => 'Could not load README.'; @override - String get storeFederationNudgeTitle => 'Want more capabilities?'; + String get storeSourceLabel => 'Source'; @override - String get storeFederationNudgeBody => - 'Configure an MCP server or n8n endpoint in Settings → MCP Clients / N8N Endpoints. Discovered tools and workflows surface here as `mcp..` and `n8n..` capabilities — every Anthropic / community MCP server adds a handful at once.'; + String get storeSourceAll => 'All sources'; @override - String get storeFederationNudgeButton => 'Open Settings'; + String get storeSourceNative => 'Native'; + + @override + String get storeSourceMcp => 'MCP'; + + @override + String get storeSourceN8n => 'n8n'; + + @override + String get storeProvenanceNative => 'native'; + + @override + String storeProvenanceMcp(String provider) { + return 'mcp · $provider'; + } + + @override + String storeProvenanceN8n(String provider) { + return 'n8n · $provider'; + } + + @override + String get storeRecommendedTitle => 'Add a public source — one click'; + + @override + String get storeRecommendedBody => + 'Curated MCP servers reachable over HTTPS. No subprocess, no API key, no Node. Federated capabilities appear in the store immediately.'; + + @override + String get storeRecommendedAddTooltip => 'Add and discover'; + + @override + String get storeRecommendedAdding => 'Adding…'; + + @override + String get storeRecommendedDeepWikiTagline => + 'GitHub repo documentation search (AI-powered)'; + + @override + String get storeRecommendedSemgrepTagline => + 'Security scanning for code vulnerabilities'; + + @override + String storeProviderAddedToast(String name, int n) { + String _temp0 = intl.Intl.pluralLogic( + n, + locale: localeName, + other: 'capabilities', + one: 'capability', + ); + return 'Added $name — $n federated $_temp0.'; + } + + @override + String storeProviderAddFailed(String name, String error) { + return 'Could not add $name: $error'; + } @override String get auditTitle => 'Audit'; diff --git a/lib/main.dart b/lib/main.dart index 614c6a9..05f1270 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,7 +26,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.28.0'; +const String kStudioVersion = '0.29.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 4707ef2..fc96d97 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -26,7 +26,14 @@ 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 @@ -139,8 +146,9 @@ class _StorePageState extends State { FutureBuilder>( future: _future, builder: (context, snap) { - final items = snap.data ?? const []; - final categories = _categoriesIn(items); + final raw = snap.data ?? const []; + final items = _applySourceFilter(raw); + final categories = _categoriesIn(raw); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -155,11 +163,13 @@ class _StorePageState extends State { 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(); @@ -190,7 +200,8 @@ class _StorePageState extends State { ), ); } - final items = snap.data ?? const []; + final raw = snap.data ?? const []; + final items = _applySourceFilter(raw); if (items.isEmpty) { return FaiEmptyState( icon: Icons.search_off, @@ -201,6 +212,7 @@ class _StorePageState extends State { setState(() { _category = ''; _status = ''; + _source = ''; _installedOnly = false; _queryCtrl.clear(); }); @@ -219,25 +231,31 @@ class _StorePageState extends State { 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, - // hint that adding an MCP / n8n endpoint - // brings dozens of capabilities at once. - // Hides the moment any federated entry shows - // up — the nudge has done its job. - final showFederationNudge = + // 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 && - !items.any((e) => e.isFederated); + !raw.any((e) => e.isFederated); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (showFederationNudge) ...[ - const _FederationNudge(), + if (showRecommendedStrip) ...[ + _RecommendedSourcesStrip( + adding: _addingSources, + locale: _locale, + onAdd: _addRecommendedSource, + ), const SizedBox(height: FaiSpace.md), ], if (showFeatured) ...[ @@ -277,6 +295,59 @@ class _StorePageState extends State { 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. @@ -361,15 +432,19 @@ class _CategoryStrip extends StatelessWidget { 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, }); @@ -378,7 +453,10 @@ class _FilterRow extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; - return Row( + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: FaiSpace.sm, + runSpacing: FaiSpace.xs, children: [ _ChoiceChip( label: l.storeStatusAll, @@ -400,14 +478,37 @@ class _FilterRow extends StatelessWidget { selected: status == 'planned', onSelected: () => onStatus('planned'), ), - const SizedBox(width: FaiSpace.md), + 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, ), - const Spacer(), Text( l.storeNResults(resultCount), style: theme.textTheme.bodySmall?.copyWith( @@ -816,12 +917,15 @@ class _StoreCard extends StatelessWidget { const SizedBox(height: FaiSpace.sm), Row( children: [ - if (item.bestVersion.isNotEmpty) + 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( @@ -1805,15 +1909,73 @@ class _ScreenshotPlaceholder extends StatelessWidget { } } -/// Sparse-store nudge: the operator's store has only the -/// bundled native + planned modules and no federated entries. -/// The single biggest store-fullness lever is configuring an -/// MCP server or n8n endpoint, so the banner says exactly -/// that. Tap → Settings dialog opens straight to the editor. -/// Dismisses automatically the next render after a federated -/// entry shows up — no per-user "don't show again". -class _FederationNudge extends StatelessWidget { - const _FederationNudge(); +/// 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) { @@ -1827,43 +1989,162 @@ class _FederationNudge extends StatelessWidget { borderRadius: BorderRadius.circular(FaiRadius.md), border: Border.all(color: theme.colorScheme.outlineVariant), ), - child: Row( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.hub_outlined, - size: 22, - color: theme.colorScheme.primary, + 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(width: FaiSpace.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l.storeFederationNudgeTitle, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - l.storeFederationNudgeBody, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], + const SizedBox(height: 4), + Text( + l.storeRecommendedBody, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), ), - const SizedBox(width: FaiSpace.md), - FilledButton.tonalIcon( - onPressed: () => FaiSettingsDialog.show(context), - icon: const Icon(Icons.settings_outlined, size: 16), - label: Text(l.storeFederationNudgeButton), + 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, + ); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 4e8fbe9..5b578bd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.28.0 +version: 0.29.0 environment: sdk: ^3.11.0-200.1.beta