feat(studio): store-side source filter, provenance pills, one-click recommended sources (v0.29.0)

The Store is now the centre of capability discovery — provider
configuration moves out of Settings into the Store itself.
Three changes work together:

1. Source filter chips next to the existing status chips:
   All sources / Native / MCP / n8n. Applied client-side after
   the hub returns results so toggling is instant. The result
   count and the "Clear filters" reset both account for the
   source filter too.

2. Per-card provenance pill (`_ProvenancePill`): shows whether
   each entry is native, mcp · <provider>, or n8n · <provider>
   so the operator can triage the source at a glance — same
   role the "verified" badge plays in commercial app stores.

3. `_RecommendedSourcesStrip` replaces the older
   `_FederationNudge`. Renders curated public MCP servers
   (DeepWiki, Semgrep) as one-click cards with an inline
   `[+ Add]` button — no Settings detour, no form. Both servers
   are HTTPS Streamable-HTTP, no API key, no subprocess.
   Useful AI was considered but its 340+ tools would drown the
   index — kept out of the curated list. The strip auto-hides
   the moment any federated entry exists.

Removed the now-orphaned storeFederationNudge* ARB keys.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-08 12:25:59 +02:00
parent b5a4c74c4b
commit 581ebbeaf7
8 changed files with 603 additions and 79 deletions

View file

@ -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.<server>.<tool>`- und `n8n.<endpoint>.<workflow>`-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",

View file

@ -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.<server>.<tool>` and `n8n.<endpoint>.<workflow>` 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",

View file

@ -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.<server>.<tool>` and `n8n.<endpoint>.<workflow>` 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.
///

View file

@ -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.<server>.<tool>`- und `n8n.<endpoint>.<workflow>`-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';

View file

@ -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.<server>.<tool>` and `n8n.<endpoint>.<workflow>` 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';

View file

@ -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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

View file

@ -26,7 +26,14 @@ class _StorePageState extends State<StorePage> {
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<String> _addingSources = <String>{};
late Future<List<StoreItem>> _future;
/// Active UI locale, read from the app-wide MaterialApp
@ -139,8 +146,9 @@ class _StorePageState extends State<StorePage> {
FutureBuilder<List<StoreItem>>(
future: _future,
builder: (context, snap) {
final items = snap.data ?? const <StoreItem>[];
final categories = _categoriesIn(items);
final raw = snap.data ?? const <StoreItem>[];
final items = _applySourceFilter(raw);
final categories = _categoriesIn(raw);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -155,11 +163,13 @@ class _StorePageState extends State<StorePage> {
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<StorePage> {
),
);
}
final items = snap.data ?? const <StoreItem>[];
final raw = snap.data ?? const <StoreItem>[];
final items = _applySourceFilter(raw);
if (items.isEmpty) {
return FaiEmptyState(
icon: Icons.search_off,
@ -201,6 +212,7 @@ class _StorePageState extends State<StorePage> {
setState(() {
_category = '';
_status = '';
_source = '';
_installedOnly = false;
_queryCtrl.clear();
});
@ -219,25 +231,31 @@ class _StorePageState extends State<StorePage> {
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<StorePage> {
final list = set.toList()..sort();
return list;
}
List<StoreItem> _applySourceFilter(List<StoreItem> items) {
if (_source.isEmpty) return items;
return items.where((e) => _sourceForItem(e) == _source).toList();
}
Future<void> _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.<name>.<tool>`).
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<String> 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,
);
}
}
}

View file

@ -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