chain-studio/lib/pages/store.dart
flemming-it 581ebbeaf7 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>
2026-05-08 12:25:59 +02:00

2150 lines
72 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

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

// Store browser. Lists modules from the hub's bundled
// store-index. Top-level surface mirrors the look operators
// expect from app stores (search bar, category strip, filter
// chips, status chips), with a per-entry detail sheet that
// renders the full bilingual description, requires-list, and
// repository link.
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import '../data/hub.dart';
import '../data/system_actions.dart';
import '../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<StorePage> createState() => _StorePageState();
}
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
/// `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<String, String> _installedVersions = const {};
Future<List<StoreItem>> _load() async {
// Fetch the store entries and the installed modules in
// parallel so the page paints in one round-trip. The
// installed-version map drives the per-card "Update"
// badge — store entries only carry `best_version` and
// `installed: bool`, never the installed version itself.
final results = await Future.wait([
HubService.instance.searchStore(
query: _queryCtrl.text.trim(),
category: _category,
status: _status,
limit: 200,
),
HubService.instance.listModules().catchError((_) => <ModuleSummary>[]),
]);
final all = results[0] as List<StoreItem>;
final mods = results[1] as List<ModuleSummary>;
_installedVersions = {for (final m in mods) m.name: m.version};
return _installedOnly ? all.where((e) => e.installed).toList() : all;
}
void _runSearch() {
// Block body, so the closure's return type is `void`
// rather than `Future`. setState() rejects closures that
// resolve to a Future — the assignment must be synchronous
// even though the future itself is awaited later.
setState(() {
_future = _load();
});
}
Future<void> _openDetail(StoreItem item) async {
final changed = await _StoreDetailSheet.show(context, item, _locale);
if (changed) _runSearch();
}
Future<void> _install(StoreItem item) async {
if (!mounted) return;
final outcome = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (_) => _InstallProgressDialog(item: item),
);
if (outcome == true) _runSearch();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
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<List<StoreItem>>(
future: _future,
builder: (context, snap) {
final raw = snap.data ?? const <StoreItem>[];
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<List<StoreItem>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: l.hubUnreachable,
hint: l.hubUnreachableHint,
action: FilledButton.tonal(
onPressed: _runSearch,
child: Text(l.buttonRetry),
),
);
}
final raw = snap.data ?? const <StoreItem>[];
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<String> _categoriesIn(List<StoreItem> items) {
final set = <String>{};
for (final i in items) {
if (i.category.isNotEmpty) set.add(i.category);
}
final list = set.toList()..sort();
return list;
}
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.
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<TextEditingValue>(
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<String> categories;
final String selected;
final void Function(String) onSelected;
const _CategoryStrip({
required this.categories,
required this.selected,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
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<StoreItem> items;
final String locale;
/// Map of installed module name → installed version. Cards
/// compare against `bestVersion` to render an "Update" badge
/// when the store offers something newer.
final Map<String, String> installedVersions;
final ValueChanged<StoreItem> onTap;
final ValueChanged<StoreItem> onInstall;
const _StoreGrid({
required this.items,
required this.locale,
required this.installedVersions,
required this.onTap,
required this.onInstall,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
const minCardWidth = 360.0;
final cols = (constraints.maxWidth / minCardWidth).floor().clamp(1, 4);
return GridView.builder(
padding: EdgeInsets.zero,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisSpacing: FaiSpace.md,
crossAxisSpacing: FaiSpace.md,
mainAxisExtent: 168,
),
itemCount: items.length,
itemBuilder: (context, i) => _StoreCard(
item: items[i],
locale: locale,
installedVersion: installedVersions[items[i].name],
onTap: () => onTap(items[i]),
onInstall: () => onInstall(items[i]),
),
);
},
);
}
}
/// Horizontally-scrolling band of editorial picks, rendered
/// only when the operator hasn't filtered the result set.
/// Each tile is a hero version of the regular store card —
/// bigger icon, gradient backdrop, prominent install button.
/// Curated quarterly via the bundled `seed.yaml` `featured:`
/// list so the choice can be reviewed in code review.
class _FeaturedStrip extends StatelessWidget {
final List<StoreItem> items;
final String locale;
final ValueChanged<StoreItem> onTap;
final ValueChanged<StoreItem> onInstall;
const _FeaturedStrip({
required this.items,
required this.locale,
required this.onTap,
required this.onInstall,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
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<bool> show(
BuildContext context,
StoreItem item,
String locale,
) async {
final r = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
),
builder: (_) => _StoreDetailSheet(item: item, locale: locale),
);
return r ?? false;
}
@override
State<_StoreDetailSheet> createState() => _StoreDetailSheetState();
}
class _StoreDetailSheetState extends State<_StoreDetailSheet> {
/// 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<void> _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<void> _uninstall() async {
final l = AppLocalizations.of(context)!;
final ok = await showDialog<bool>(
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<void> _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.<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) {
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,
);
}
}
}