chain-studio/lib/pages/store.dart
flemming-it 0f93d90963
Some checks failed
Security / Security check (push) Failing after 2s
fix(studio): chain config dir + spawn path + doc URLs
Track the platform rename: the hub spawn path is now ~/.chain/bin/chain
(was ~/.fai/bin/fai.exe on Windows — both dir and binary were stale, so
Studio could not launch the hub after the config-dir rename), the
~/.fai/* help strings become ~/.chain/*, FAI_REGISTRY_TOKEN ->
CHAIN_REGISTRY_TOKEN, and the two in-app doc URLs point at the public
fai/chain repo (fai/platform was renamed to the private fai/chain-private).
The .fai module bundle extension is left unchanged (format phase).
flutter analyze: no issues.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-06-16 09:38:30 +02:00

3697 lines
134 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 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import '../data/error_presentation.dart';
import '../data/hub.dart';
import '../data/system_actions.dart';
import '../data/today_story_loader.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;
/// "Show installable modules only". On by default: roughly
/// 2/3 of the seed-index entries currently carry status
/// `planned` (no bundle to install), and pre-filter polish
/// found that operators couldn't tell why they couldn't
/// install half the cards. Planned modules are one chip-flip
/// away in the filter dialog.
bool _installableOnly = true;
/// Tracks per-recommended-source add buttons to disable them
/// while the request is in flight.
final Set<String> _addingSources = <String>{};
/// Whether the operator dismissed the editorial hero this
/// session. Not persisted — the next Studio launch shows it
/// again so a release-bumped story has a chance to be seen.
bool _todayDismissed = false;
/// The carousel of stories rendered in the hero. When an
/// operator-accepted story exists at `~/.chain/today/active.yaml`,
/// it is the only entry (carousel collapses to a single
/// slide). Otherwise the curated fallback list rotates.
late final List<TodayStoryData> _todayStories = (() {
final loaded = TodayStoryLoader.loadOrFallback(
_kFallbackTodayStories.first,
);
// When the loader fell back to the const, it returned the
// first entry of our list — surface the whole list so the
// operator can browse. Otherwise the loader produced a real
// accepted story and we honour it as the only item.
if (identical(loaded, _kFallbackTodayStories.first)) {
return _kFallbackTodayStories;
}
return <TodayStoryData>[loaded];
})();
/// Carousel index. Wraps around modulo `_todayStories.length`.
int _todayIndex = 0;
late Future<List<StoreItem>> _future;
// ── AI search state ──────────────────────────────────────
// The Ask bar runs traditional substring search on every
// keystroke (cheap; same as before). When the operator hits
// Enter on a question-shaped query AND a System-AI is
// configured, the LLM gets a structured ranking call and the
// grid filters to its picks. Clearing the query resets both
// surfaces back to the unfiltered view.
bool _aiThinking = false;
String? _aiAnswer;
List<_AiMatch>? _aiMatches;
Set<String>? _aiMatchedNames;
String? _aiError;
/// Debounce-Timer für Such-Filterung beim Tippen. Ohne
/// Debounce flackert "Keine Treffer" zwischen Buchstaben
/// (für 2-3 Buchstaben matched z.B. nichts, das nächste
/// dann doch). 250ms ist comfortable für Tippen.
Timer? _typingDebounce;
/// Whether the operator has a System-AI configured. Drives
/// the Ask-bar hint copy and disables the AI path when off.
bool _systemAiEnabled = false;
/// 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();
_refreshSystemAiStatus();
}
Future<void> _refreshSystemAiStatus() async {
try {
final s = await HubService.instance.systemAiStatus();
if (!mounted) return;
setState(() => _systemAiEnabled = s.enabled);
} catch (_) {
// Stay disabled on any failure — Ask bar falls back to
// pure keyword search until next try.
}
}
@override
void dispose() {
_typingDebounce?.cancel();
_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};
var out = all;
if (_installedOnly) {
out = out.where((e) => e.installed).toList();
}
if (_installableOnly) {
// An already-installed item counts as "installable" for
// this filter — the user can still update / uninstall it.
out = out
.where(
(e) =>
e.installed || e.status == 'published' || e.status == 'alpha',
)
.toList();
}
return out;
}
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(
// titleSpacing matches the body's horizontal padding
// (FaiSpace.xl == 24) so the title's left edge sits
// flush with the cards below. Default 16 left a 8-px
// gap that read as a misaligned heading.
titleSpacing: FaiSpace.xl,
title: Text(l.searchGroupStore),
actions: [
FutureBuilder<List<StoreItem>>(
future: _future,
builder: (ctx, snap) {
final raw = snap.data ?? const <StoreItem>[];
final categories = _categoriesIn(raw);
return Padding(
padding: const EdgeInsets.only(right: FaiSpace.sm),
child: _CategoryDropdown(
categories: categories,
selected: _category,
onSelected: (v) {
setState(() => _category = v);
_runSearch();
},
),
);
},
),
Padding(
padding: const EdgeInsets.only(right: FaiSpace.sm),
child: _FilterButton(
activeCount: _activeFilterCount(),
onPressed: _openFilterDialog,
),
),
IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: l.storeReloadTooltip,
onPressed: _runSearch,
),
// Trailing pad pulls the IconButton's outer edge in
// to match the body's right-side padding. The icon
// glyph itself ends up flush with the rightmost grid
// card, not floating in space.
const SizedBox(width: FaiSpace.md),
],
),
body: Column(
children: [
// Scrollable content: editorial chrome + grid. Sits in
// an Expanded so the bottom AskBar pins to the foot of
// the viewport, mirroring the chat-input pattern of
// Claude / ChatGPT / Slack.
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(
FaiSpace.xl,
FaiSpace.lg,
FaiSpace.xl,
0,
),
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 = _applyAllFilters(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;
_installableOnly = false;
_queryCtrl.clear();
});
_runSearch();
},
child: Text(l.storeClearFilters),
),
);
}
// Browsing-only chrome — every editorial
// surface hides the moment a filter is set so
// the operator's query stays the focus.
final isBrowsing =
_queryCtrl.text.trim().isEmpty &&
_category.isEmpty &&
_status.isEmpty &&
_source.isEmpty &&
!_installedOnly;
final hasNoFederation = !raw.any((e) => e.isFederated);
// Today carousel stays visible until the
// operator explicitly dismisses it. The earlier
// installedCount≥3 auto-hide killed the strip
// for anyone past first-launch — meaning the
// rotating editorial stories (themes, audit,
// SDK, sandbox…) were never seen by experienced
// operators, which is exactly the audience the
// wider story set is for. One-click dismiss is
// enough opt-out.
final showToday = !_todayDismissed && isBrowsing;
// Recommended-source quick-adds are inlined in
// the Today footer when both apply, so the
// operator never sees two competing editorial
// cards stacked on top of each other.
// (Hero footer no longer carries the chips —
// brand-name suggestions only show in the
// standalone "Suggested sources" strip below
// and via Settings → MCP Clients.)
// Standalone strip only fires in the corner
// case: Today dismissed AND no federation.
// Featured strip and Today never compete —
// Today is the editorial hero, Featured plays
// second fiddle and only renders once Today is
// out of the way.
final showStandaloneRecommended =
isBrowsing && _todayDismissed && hasNoFederation;
final showFeatured =
isBrowsing &&
_todayDismissed &&
items.any((e) => e.featured);
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_aiThinking ||
_aiAnswer != null ||
_aiError != null) ...[
_AiAnswerCard(
thinking: _aiThinking,
answer: _aiAnswer,
matches: _aiMatches ?? const <_AiMatch>[],
error: _aiError,
onClear: _clearQuery,
),
const SizedBox(height: FaiSpace.lg),
],
if (showToday) ...[
_StoreTodayHero(
story:
_todayStories[_todayIndex %
_todayStories.length],
locale: _locale,
currentIndex: _todayIndex,
totalCount: _todayStories.length,
onPrev: _todayStories.length > 1
? () => setState(() {
_todayIndex =
(_todayIndex -
1 +
_todayStories.length) %
_todayStories.length;
})
: null,
onNext: _todayStories.length > 1
? () => setState(() {
_todayIndex =
(_todayIndex + 1) %
_todayStories.length;
})
: null,
onDismiss: () =>
setState(() => _todayDismissed = true),
onCta: _ctaCallbackFor(
_todayStories[_todayIndex % _todayStories.length],
),
// Per-story brand chips: only the MCP
// story shows DeepWiki / Semgrep
// quick-adds, so the chips don't read
// as ads on stories that aren't about
// sources. Standalone "Suggested
// sources" strip below still catches
// the dismissed-Today / no-federation
// edge case.
recommendedSources:
_todayStories[_todayIndex %
_todayStories.length]
.cta ==
TodayCta.showSuggestedSources
? _kRecommendedSources
: const <_RecommendedSource>[],
recommendedAdding: _addingSources,
onAddRecommended: _addRecommendedSource,
),
const SizedBox(height: FaiSpace.lg),
],
if (showStandaloneRecommended) ...[
_RecommendedSourcesStrip(
adding: _addingSources,
locale: _locale,
onAdd: _addRecommendedSource,
),
const SizedBox(height: FaiSpace.lg),
],
if (showFeatured) ...[
_FeaturedStrip(
items: items.where((e) => e.featured).toList(),
locale: _locale,
onTap: _openDetail,
onInstall: _install,
),
const SizedBox(height: FaiSpace.lg),
],
_StoreGrid(
items: items,
locale: _locale,
installedVersions: _installedVersions,
onTap: _openDetail,
onInstall: _install,
),
const SizedBox(height: FaiSpace.lg),
],
),
);
},
),
),
),
// Pinned chat-style ask bar at the foot of the
// viewport. Always visible so the operator can switch
// from browsing to asking with one click — same shape
// as Claude / ChatGPT / Slack composer rows.
Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
top: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.xl,
vertical: FaiSpace.md,
),
child: _AskBar(
controller: _queryCtrl,
thinking: _aiThinking,
aiAvailable: _systemAiEnabled,
onSubmit: _onAskSubmit,
onClear: _clearQuery,
onTextChange: _onAskTyping,
),
),
],
),
);
}
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();
}
/// Source filter + AI-match overlay. AI mode narrows the grid
/// to whatever the LLM ranked above the line so the answer
/// card and the grid stay coherent.
List<StoreItem> _applyAllFilters(List<StoreItem> items) {
var out = _applySourceFilter(items);
final ai = _aiMatchedNames;
if (ai != null) {
out = out.where((e) => ai.contains(e.name)).toList();
}
return out;
}
int _activeFilterCount() {
var n = 0;
if (_status.isNotEmpty) n++;
if (_source.isNotEmpty) n++;
if (_installedOnly) n++;
// `installableOnly` defaults to ON, so we only highlight it
// as an "active filter" when the operator turned it OFF —
// the surprising state is the one worth flagging.
if (!_installableOnly) n++;
return n;
}
/// On every keystroke. Short keyword-shaped input runs the
/// hub's substring search live so the grid follows along. A
/// question-shaped input holds the grid steady until the
/// operator submits — otherwise typing "ein Modul, das …"
/// wipes the visible result set with every space.
void _onAskTyping(String value) {
if (_aiAnswer != null || _aiMatches != null) {
setState(() {
_aiAnswer = null;
_aiMatches = null;
_aiMatchedNames = null;
_aiError = null;
});
}
// Tippen läuft IMMER nur durch die lokale Substring-Suche.
// Die KI-Suche kostet einen LLM-Roundtrip und gehört hinter
// den expliziten Submit-Knopf — sonst feuern wir pro
// Keystroke einen Prompt. Debounce verhindert "Keine
// Treffer" zwischen Buchstaben.
_typingDebounce?.cancel();
_typingDebounce = Timer(const Duration(milliseconds: 250), () {
if (!mounted) return;
_runSearch();
});
}
Future<void> _onAskSubmit() async {
// Pending typing-debounce abbrechen — wir submitten jetzt
// explizit und wollen nicht hinterher nochmal substring-
// filter laufen lassen.
_typingDebounce?.cancel();
final q = _queryCtrl.text.trim();
if (q.isEmpty) {
_clearQuery();
return;
}
// Wenn System-AI verfügbar ist, geht der explizite Submit
// IMMER über die KI-Suche — egal ob die Eingabe wie eine
// Frage aussieht. Das war der Bug: Nutzer tippt "tabellen"
// + klick Suchen, erwartet KI, bekommt Substring-Filter.
// Substring-Filter passiert weiter live beim Tippen
// (siehe _onAskTyping).
if (_systemAiEnabled) {
await _runAiQuery(q);
} else {
_runSearch();
}
}
/// Resolve a Today story's CTA descriptor into a real callback
/// the hero card can wire to its button. `null` collapses the
/// button out of the layout — used for narrative-only stories
/// (audit / sandbox explainer) where there's nowhere obvious
/// to send the operator next.
VoidCallback? _ctaCallbackFor(TodayStoryData story) {
switch (story.cta) {
case TodayCta.none:
return null;
case TodayCta.openSettings:
return () => FaiSettingsDialog.show(context);
case TodayCta.filterCategory:
if (story.ctaPayload.isEmpty) return null;
return () {
// Two-step refresh: setState makes the dropdown
// header re-render with the new selection, then
// _runSearch re-issues the backend query with
// `category:` in its filter — without the second
// call the grid keeps showing the unfiltered list
// and the carousel button looked broken.
setState(() => _category = story.ctaPayload);
_runSearch();
};
case TodayCta.runQuery:
if (story.ctaPayload.isEmpty) return null;
return () {
_queryCtrl.text = story.ctaPayload;
setState(() {});
_runSearch();
};
case TodayCta.showSuggestedSources:
// Stays on this slide — the chips render in the hero
// footer for stories with this CTA. The button just
// scrolls focus to them; for now the chips are
// immediately visible above the fold, so the click is
// effectively a no-op acknowledgement. Keeping it
// wired avoids a dead button.
return () {};
}
}
void _clearQuery() {
setState(() {
_queryCtrl.clear();
_aiAnswer = null;
_aiMatches = null;
_aiMatchedNames = null;
_aiError = null;
});
_runSearch();
}
Future<void> _runAiQuery(String query) async {
setState(() {
_aiThinking = true;
_aiAnswer = null;
_aiMatches = null;
_aiMatchedNames = null;
_aiError = null;
});
try {
final all = await HubService.instance.searchStore(limit: 200);
final modules = all
.map(
(s) => {
'name': s.name,
'tagline': _locale == 'de' && s.taglineDe.isNotEmpty
? s.taglineDe
: s.taglineEn,
'category': s.category,
},
)
.toList();
final prompt = _buildAiSearchPrompt(query, modules, _locale);
final result = await HubService.instance.askAi(prompt);
if (!mounted) return;
if (!result.isSuccess) {
setState(() {
_aiThinking = false;
_aiError = result.text;
});
return;
}
final parsed = _parseAiSearchResponse(result.text, all);
setState(() {
_aiThinking = false;
_aiAnswer = parsed.answer;
_aiMatches = parsed.matches;
_aiMatchedNames = parsed.matches.isEmpty
? null
: parsed.matches.map((m) => m.name).toSet();
});
} catch (e) {
if (!mounted) return;
setState(() {
_aiThinking = false;
_aiError = e.toString();
});
}
}
Future<void> _openFilterDialog() async {
final outcome = await showDialog<_FilterOutcome>(
context: context,
builder: (_) => _FilterDialog(
status: _status,
source: _source,
installedOnly: _installedOnly,
installableOnly: _installableOnly,
),
);
if (outcome == null) return;
setState(() {
_status = outcome.status;
_source = outcome.source;
_installedOnly = outcome.installedOnly;
_installableOnly = outcome.installableOnly;
});
_runSearch();
}
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;
// toolCount == 0 happens when the server registered fine
// but discovery returned nothing — typically a transport
// mismatch or a server that wants `notifications/initialized`
// before listing. We surface the difference so the
// operator knows whether to celebrate or to debug.
final msg = added.toolCount > 0
? l.storeProviderAddedToast(s.label, added.toolCount)
: l.storeProviderAddedZeroToast(s.label);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
duration: added.toolCount > 0
? const Duration(seconds: 4)
: const Duration(seconds: 8),
),
);
_runSearch();
} catch (e) {
if (!mounted) return;
showFaiErrorSnack(
context,
'store.provider.add',
e,
title: l.storeProviderAddFailed(s.label, ''),
);
} finally {
if (mounted) setState(() => _addingSources.remove(s.name));
}
}
}
/// Maps a store entry to its provenance bucket: 'native' for
/// non-federated entries, 'mcp' / 'n8n' / 'temporal' 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.
///
/// Prefers the wire-level `sourceKind` field (0.12+); falls back
/// to the legacy name-prefix heuristic for pre-0.12 hubs that
/// don't populate it.
String _sourceForItem(StoreItem i) {
if (i.sourceKind.isNotEmpty) {
switch (i.sourceKind) {
case 'bundle':
return 'native';
case 'mcp':
case 'n8n':
case 'temporal':
return i.sourceKind;
default:
return 'federated';
}
}
if (!i.isFederated) return 'native';
if (i.name.startsWith('mcp.')) return 'mcp';
if (i.name.startsWith('n8n.')) return 'n8n';
return 'federated';
}
/// Translates a wire-level category id (`bridge`, `debug`, …)
/// into a label the operator can read. Wire ids stay in the
/// data model — only the display side gets localised. Unknown
/// ids fall through unchanged so newly-added categories don't
/// vanish until we update this map.
String _categoryDisplayName(BuildContext ctx, String wire) {
final l = AppLocalizations.of(ctx)!;
switch (wire) {
case '':
return '';
case 'bridge':
return l.storeCategoryBridge;
case 'debug':
return l.storeCategoryDebug;
case 'text':
case 'text-processing':
return l.storeCategoryText;
case 'llm':
return l.storeCategoryLlm;
case 'system':
return l.storeCategorySystem;
case 'auth':
return l.storeCategoryAuth;
case 'storage':
return l.storeCategoryStorage;
case 'channel':
return l.storeCategoryChannel;
case 'web':
return l.storeCategoryWeb;
case 'orchestrator':
return l.storeCategoryOrchestrator;
default:
return wire;
}
}
/// Same idea for the `published` / `alpha` / `planned` status
/// values. Operators read "stable / experimental / coming
/// soon"; the wire keeps the strict enum it has always had.
/// Map a permission string (`net:api.example.com`,
/// `fs.read:/etc/fai`, `env:OPENAI_API_KEY`, …) to a glyph the
/// operator can read at a glance. Mirrors the table that used
/// to live in `fai_module_sheet.dart` so the Store detail-sheet
/// can render permissions in the same shape after the Modules
/// page was dropped.
IconData _iconForPermission(String permission) {
if (permission.startsWith('net:')) return Icons.public;
if (permission.startsWith('fs.read:')) return Icons.folder_open;
if (permission.startsWith('fs.write:')) return Icons.edit_note;
if (permission.startsWith('env:')) return Icons.terminal;
if (permission.startsWith('hub:')) return Icons.shield_outlined;
return Icons.lock_outline;
}
String _statusDisplayName(BuildContext ctx, String wire) {
final l = AppLocalizations.of(ctx)!;
switch (wire) {
case 'published':
return l.storeStatusPublished;
case 'alpha':
return l.storeStatusAlpha;
case 'planned':
return l.storeStatusPlanned;
default:
return wire;
}
}
/// Multi-line ask-and-search input. Drives the toolbar's
/// primary action: substring keyword search on every keystroke
/// (cheap, identical to the legacy `_SearchField`) plus an
/// LLM-routed "ask the AI" path when the operator submits a
/// question-shaped query and a System-AI is configured.
class _AskBar extends StatelessWidget {
final TextEditingController controller;
final bool thinking;
final bool aiAvailable;
final ValueChanged<String> onTextChange;
final VoidCallback onSubmit;
final VoidCallback onClear;
const _AskBar({
required this.controller,
required this.thinking,
required this.aiAvailable,
required this.onTextChange,
required this.onSubmit,
required this.onClear,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (_, value, _) {
final empty = value.text.trim().isEmpty;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: FaiSpace.sm,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.md),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 6),
child: Icon(
Icons.search,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: FaiSpace.sm),
Expanded(
// Cmd+Enter (or Ctrl+Enter on Linux) submits;
// bare Enter inserts a newline so the operator
// can write multi-line questions naturally.
child: Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(
LogicalKeyboardKey.meta,
LogicalKeyboardKey.enter,
): const _SubmitAskIntent(),
LogicalKeySet(
LogicalKeyboardKey.control,
LogicalKeyboardKey.enter,
): const _SubmitAskIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
_SubmitAskIntent: CallbackAction<_SubmitAskIntent>(
onInvoke: (_) {
onSubmit();
return null;
},
),
},
child: TextField(
controller: controller,
autofocus: false,
minLines: 1,
maxLines: 4,
onChanged: onTextChange,
// No onSubmitted — bare Enter would
// collide with multi-line authoring.
// Submit happens via the button or
// Cmd/Ctrl+Enter.
decoration: InputDecoration(
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(vertical: 6),
hintText: aiAvailable
? l.storeAskHint
: l.storeSearchHint,
hintMaxLines: 2,
hintStyle: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
border: InputBorder.none,
),
style: theme.textTheme.bodyLarge,
),
),
),
),
if (!empty)
IconButton(
icon: const Icon(Icons.close, size: 16),
visualDensity: VisualDensity.compact,
tooltip: l.storeAskClear,
onPressed: onClear,
),
const SizedBox(width: FaiSpace.xs),
FilledButton.icon(
onPressed: thinking ? null : onSubmit,
icon: thinking
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
aiAvailable ? Icons.auto_awesome : Icons.search,
size: 14,
),
label: Text(thinking ? l.storeAskAiThinking : l.storeAskSubmit),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
);
},
);
}
}
class _SubmitAskIntent extends Intent {
const _SubmitAskIntent();
}
/// Compact Filter-popover trigger. Shows a small badge with the
/// number of active filters so the operator never has to open
/// the dialog to know what's set.
class _FilterButton extends StatelessWidget {
final int activeCount;
final VoidCallback onPressed;
const _FilterButton({required this.activeCount, required this.onPressed});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return OutlinedButton.icon(
onPressed: onPressed,
icon: const Icon(Icons.tune, size: 14),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l.storeFilterLabel),
if (activeCount > 0) ...[
const SizedBox(width: FaiSpace.xs),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$activeCount',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
);
}
}
/// Outcome of `_FilterDialog`. `null` from the dialog means
/// "operator cancelled" — caller leaves filter state untouched.
class _FilterOutcome {
final String status;
final String source;
final bool installedOnly;
final bool installableOnly;
const _FilterOutcome({
required this.status,
required this.source,
required this.installedOnly,
required this.installableOnly,
});
}
/// Modal that hosts the previously-inline status / source /
/// installed chips. Lives behind a button so the toolbar stays
/// quiet — operators can browse for a long time without
/// touching filters and shouldn't have a filter row eating
/// their viewport while doing so.
class _FilterDialog extends StatefulWidget {
final String status;
final String source;
final bool installedOnly;
final bool installableOnly;
const _FilterDialog({
required this.status,
required this.source,
required this.installedOnly,
required this.installableOnly,
});
@override
State<_FilterDialog> createState() => _FilterDialogState();
}
class _FilterDialogState extends State<_FilterDialog> {
late String _status = widget.status;
late String _source = widget.source;
late bool _installedOnly = widget.installedOnly;
late bool _installableOnly = widget.installableOnly;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
Widget header(String text) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
text.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
);
Widget chipRow(
List<({String value, String label})> items,
String selected,
void Function(String) onSelect,
) => Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
children: [
for (final i in items)
_ChoiceChip(
label: i.label,
selected: selected == i.value,
onSelected: () => onSelect(i.value),
),
],
);
return AlertDialog(
title: Text(l.storeFilterLabel),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
header(l.storeFilterStatusGroup),
chipRow(
[
(value: '', label: l.storeStatusAll),
(value: 'published', label: l.storeStatusPublished),
(value: 'alpha', label: l.storeStatusAlpha),
(value: 'planned', label: l.storeStatusPlanned),
],
_status,
(v) => setState(() => _status = v),
),
const SizedBox(height: FaiSpace.lg),
header(l.storeFilterSourceGroup),
chipRow(
[
(value: '', label: l.storeSourceAll),
(value: 'native', label: l.storeSourceNative),
(value: 'mcp', label: l.storeSourceMcp),
(value: 'n8n', label: l.storeSourceN8n),
(value: 'temporal', label: l.storeSourceTemporal),
],
_source,
(v) => setState(() => _source = v),
),
const SizedBox(height: FaiSpace.lg),
header(l.storeFilterInstalledGroup),
Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
children: [
FilterChip(
label: Text(l.storeInstallableOnly),
selected: _installableOnly,
onSelected: (v) => setState(() => _installableOnly = v),
showCheckmark: true,
),
FilterChip(
label: Text(l.storeInstalledOnly),
selected: _installedOnly,
onSelected: (v) => setState(() => _installedOnly = v),
showCheckmark: true,
),
],
),
],
),
),
actions: [
TextButton(
onPressed: () {
setState(() {
_status = '';
_source = '';
_installedOnly = false;
// Reset puts `installableOnly` back to its default
// (on) — the operator hits Reset to escape a
// custom-filter state and expects the default view
// back, not an everything-including-planned view.
_installableOnly = true;
});
},
child: Text(l.storeFilterReset),
),
const Spacer(),
TextButton(
onPressed: () => Navigator.pop(context, null),
child: Text(l.buttonCancel),
),
FilledButton(
onPressed: () => Navigator.pop(
context,
_FilterOutcome(
status: _status,
source: _source,
installedOnly: _installedOnly,
installableOnly: _installableOnly,
),
),
child: Text(l.storeFilterApply),
),
],
);
}
}
/// One LLM-suggested module. Held as a small class (not a
/// record) because we map it through StatelessWidget params and
/// records make the type signatures noisy.
class _AiMatch {
final String name;
final String why;
const _AiMatch({required this.name, required this.why});
}
/// Card that surfaces the LLM's free-form answer plus a list of
/// matched module names. Renders only when the operator is in
/// AI mode — clearing the question collapses it.
class _AiAnswerCard extends StatelessWidget {
final bool thinking;
final String? answer;
final List<_AiMatch> matches;
final String? error;
final VoidCallback onClear;
const _AiAnswerCard({
required this.thinking,
required this.answer,
required this.matches,
required this.error,
required this.onClear,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final accent = error != null
? theme.colorScheme.error
: theme.colorScheme.primary;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.md),
border: Border.all(color: accent.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.auto_awesome, size: 14, color: accent),
const SizedBox(width: FaiSpace.xs),
Text(
l.storeAskAnswerLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 14),
visualDensity: VisualDensity.compact,
tooltip: l.storeAskClear,
onPressed: onClear,
),
],
),
const SizedBox(height: FaiSpace.xs),
if (thinking)
Row(
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: FaiSpace.sm),
Text(
l.storeAskAiThinking,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
)
else if (error != null)
Text(
error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
)
else ...[
SelectableText(
answer ?? l.storeAskNoMatches,
style: theme.textTheme.bodyMedium,
),
if (matches.isNotEmpty) ...[
const SizedBox(height: FaiSpace.sm),
Text(
l.storeAskAnswerWhy,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 4),
for (final m in matches)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: const EdgeInsets.only(top: 6, right: 8),
width: 6,
height: 6,
decoration: BoxDecoration(
color: accent,
shape: BoxShape.circle,
),
),
Expanded(
child: Wrap(
spacing: FaiSpace.xs,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
m.name,
style: FaiTheme.mono(
size: 12,
weight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
Text(
'${m.why}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
],
],
],
),
);
}
}
/// Compact category picker that lives in the AppBar's actions
/// row. Replaces the older horizontally-scrolling chip strip
/// because chips ate a full row of viewport for what is in
/// practice a one-click operation; a dropdown is faster to
/// reach and never grows past the toolbar.
class _CategoryDropdown extends StatelessWidget {
final List<String> categories;
final String selected;
final ValueChanged<String> onSelected;
const _CategoryDropdown({
required this.categories,
required this.selected,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final activeLabel = selected.isEmpty
? l.storeCategoryAll
: _categoryDisplayName(context, selected);
return PopupMenuButton<String>(
tooltip: l.storeCategoryLabel,
initialValue: selected,
onSelected: onSelected,
itemBuilder: (_) => [
PopupMenuItem(value: '', child: Text(l.storeCategoryAll)),
const PopupMenuDivider(),
for (final c in categories)
PopupMenuItem(
value: c,
child: Text(_categoryDisplayName(context, c)),
),
],
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: 6,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.folder_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(activeLabel, style: theme.textTheme.bodySmall),
Icon(
Icons.arrow_drop_down,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
);
}
}
/// Builds the JSON-output prompt fed to the System-AI when the
/// operator submits a question-shaped query. Pinned to a strict
/// reply schema so the parser has stable ground to walk on; the
/// prompt also hand-encodes the operator's locale so the LLM
/// answers in the right language.
String _buildAiSearchPrompt(
String query,
List<Map<String, dynamic>> modules,
String locale,
) {
final lang = locale == 'de' ? 'German' : 'English';
return '''You are the Ch∆In Store assistant. The operator just asked, in $lang:
"$query"
These are the modules currently in the store, as JSON:
${jsonEncode(modules)}
Reply with strict JSON only — no markdown fences, no commentary:
{
"answer": "<one short sentence in $lang explaining the recommendation>",
"matches": [
{"name": "<exact module name from the list above>", "why": "<one short phrase in $lang>"}
]
}
Up to 5 matches, ordered by best fit first. Use only module names that appear in the list above. If nothing fits, return matches: [] and explain in answer.''';
}
/// Pulls the JSON object out of the LLM's reply. Models often
/// add chatty preamble or markdown fences; we slice from the
/// first `{` to the last `}` and try to decode that. Unknown
/// module names are dropped silently — the operator should not
/// see hallucinated entries that don't actually exist.
({String answer, List<_AiMatch> matches}) _parseAiSearchResponse(
String text,
List<StoreItem> all,
) {
final start = text.indexOf('{');
final end = text.lastIndexOf('}');
if (start < 0 || end <= start) {
return (answer: text.trim(), matches: const <_AiMatch>[]);
}
try {
final dynamic decoded = jsonDecode(text.substring(start, end + 1));
if (decoded is! Map) {
return (answer: text.trim(), matches: const <_AiMatch>[]);
}
final answer = (decoded['answer'] ?? '').toString();
final matches = <_AiMatch>[];
final knownNames = all.map((e) => e.name).toSet();
final list = (decoded['matches'] as List?) ?? const [];
for (final m in list) {
if (m is Map) {
final name = (m['name'] ?? '').toString();
final why = (m['why'] ?? '').toString();
if (knownNames.contains(name)) {
matches.add(_AiMatch(name: name, why: why));
}
}
}
return (answer: answer, matches: matches);
} catch (_) {
return (answer: text.trim(), matches: const <_AiMatch>[]);
}
}
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);
// shrinkWrap + NeverScrollable lets the grid sit inside
// the page-level SingleChildScrollView so editorial
// chrome and the grid scroll as one continuous surface
// (App-Store / Play-Store behaviour). Without this, the
// inner grid claims its own scroll viewport and the
// outer Column overflows on small windows.
return GridView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
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(
_categoryDisplayName(context, item.category),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(Icons.star, size: 18, color: FaiColors.success),
],
),
const SizedBox(height: FaiSpace.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.isFederated)
// Federated items get tagline copy straight
// from the upstream MCP / n8n server, which
// is English regardless of Studio's locale.
// The [EN] pill is the honest signal until
// the planned `studio.translate` plugin
// rewrites it on the fly.
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: FaiEnBadge.forContext(context),
),
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)
Tooltip(
// Federated upstream services (MCP, n8n,
// Temporal workflows) don't honour semver,
// so their version pill carries a "~"
// prefix + tooltip noting that the value
// is advisory only.
message: item.isFederated
? l.storeAdvisoryVersionTooltip
: '',
child: FaiPill(
label: item.isFederated
? '~v${item.bestVersion}'
: 'v${item.bestVersion}',
tone: FaiPillTone.neutral,
monospace: true,
),
),
const SizedBox(width: FaiSpace.xs),
if (item.status.isNotEmpty)
FaiPill(
label: _statusDisplayName(context, 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),
)
else
// Status is `planned` (or another non-installable
// wire value). Without an explicit pill here the
// featured tile would just go silent, leaving the
// operator wondering why the card has no action.
FaiPill(
label: l.storePillComingSoon,
tone: FaiPillTone.neutral,
icon: Icons.schedule,
),
],
),
],
),
),
),
);
}
}
class _StoreCard extends StatefulWidget {
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,
});
@override
State<_StoreCard> createState() => _StoreCardState();
}
class _StoreCardState extends State<_StoreCard> {
bool _hovered = false;
StoreItem get item => widget.item;
String get locale => widget.locale;
String? get installedVersion => widget.installedVersion;
VoidCallback get onTap => widget.onTap;
VoidCallback get onInstall => widget.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';
// Dim planned/non-installable cards so the eye lands on
// actionable cards first. We don't disable interaction — the
// detail sheet still opens — but the visual hierarchy is
// honest about what the operator can act on.
final notInstallable = !installable && !item.installed;
return Opacity(
opacity: notInstallable ? 0.6 : 1.0,
// Hover-lift: a 2px rise plus shadow bump signals the card
// is clickable without a heavy hover fill. Resting state
// carries the low elevation so cards read as physical.
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: FaiMotion.base,
curve: FaiMotion.easing,
transform: Matrix4.translationValues(0, _hovered ? -2 : 0, 0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.md),
boxShadow: _hovered
? FaiElevation.medium(theme.brightness)
: FaiElevation.low(theme.brightness),
),
child: 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
? ''
: _categoryDisplayName(
context,
item.category,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
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: _statusDisplayName(context, 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) ...[
Tooltip(
message: item.isFederated
? l.storeAdvisoryVersionTooltip
: '',
child: FaiPill(
label: item.isFederated
? '~v${item.bestVersion}'
: '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,
elevation: 8,
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;
/// `null` while the initial probe is in flight; non-null once
/// `readInstalledModuleDocs` has returned. The probe is cheap —
/// a single file stat on disk — so we run it eagerly when the
/// sheet opens for an installed module instead of gating it
/// behind a "Load docs" click.
({String state, String text, String sourcePath})? _docsResult;
bool _docsProbed = false;
/// Live module-info for installed entries: declared
/// permissions and on-disk directory. Pulled lazily so
/// not-installed entries don't run an unnecessary RPC. Stays
/// null when the fetch fails — the corresponding sections
/// just hide rather than show a spinner inside a hover sheet.
ModuleDetail? _moduleDetail;
@override
void initState() {
super.initState();
if (widget.item.installed) {
_loadModuleDetail();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Locale is read off `Localizations`, which is only ready in
// didChangeDependencies, not initState.
if (widget.item.installed && !_docsProbed) {
_docsProbed = true;
_probeInstalledDocs();
}
}
Future<void> _probeInstalledDocs() async {
final locale = Localizations.localeOf(context).languageCode;
try {
final r = await HubService.instance.readInstalledModuleDocs(
widget.item.name,
locale: locale,
);
if (!mounted) return;
setState(() => _docsResult = r);
} catch (_) {
// Treat probe failures as "no docs available" — the section
// simply hides, the rest of the sheet still works.
if (!mounted) return;
setState(
() => _docsResult = (state: 'no_docs', text: '', sourcePath: ''),
);
}
}
Future<void> _loadModuleDetail() async {
try {
final detail = await HubService.instance.moduleInfo(widget.item.name);
if (!mounted) return;
setState(() => _moduleDetail = detail);
} catch (_) {
// Silent — sections stay hidden, the sheet still works.
}
}
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)!;
// Multi-version aware: ask which version to remove when
// more than one is installed side-by-side. Otherwise the
// hub picks "highest version" and the operator can be
// surprised by what disappeared.
final versions = await HubService.instance.installedVersions(
widget.item.name,
);
if (!mounted) return;
String? targetVersion;
if (versions.length > 1) {
targetVersion = await showDialog<String>(
context: context,
builder: (ctx) => _StoreUninstallVersionPickerDialog(
moduleName: widget.item.name,
versions: versions,
),
);
if (targetVersion == null || !mounted) return;
}
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,
version: targetVersion ?? '',
);
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) {
showFaiErrorSnack(
context,
'store.repository.open',
r.stderr.isEmpty ? 'open failed' : r.stderr,
);
}
}
@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)
Tooltip(
message: item.isFederated
? l.storeAdvisoryVersionTooltip
: '',
child: FaiPill(
label: item.isFederated
? '~v${item.bestVersion}'
: 'v${item.bestVersion}',
tone: FaiPillTone.neutral,
monospace: true,
),
),
if (item.status.isNotEmpty)
FaiPill(
label: _statusDisplayName(
context,
item.status,
),
tone: _toneForStatus(item.status),
),
if (item.category.isNotEmpty)
FaiPill(
label: _categoryDisplayName(
context,
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) ...[
if (item.isFederated) ...[
// Description copy was supplied by the
// upstream server, not by Studio's
// localisation pipeline — flag it.
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: FaiEnBadge.forContext(context),
),
],
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),
],
// Live-from-hub sections for installed
// modules. Folded in here in v0.35.0 when the
// standalone Modules page got dropped — its
// unique value (declared permissions, on-disk
// directory) belongs in the same surface
// operators already use for everything else
// about a module.
if (item.installed && _moduleDetail != null) ...[
_SectionHeader(l.storeSectionPermissions),
const SizedBox(height: FaiSpace.sm),
if (_moduleDetail!.permissions.isEmpty)
Text(
l.storeSectionPermissionsNone,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final p in _moduleDetail!.permissions)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Icon(
_iconForPermission(p),
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.sm),
SelectableText(
p,
style: FaiTheme.mono(
size: 12,
color: theme.colorScheme.onSurface,
),
),
],
),
),
],
),
const SizedBox(height: FaiSpace.lg),
_SectionHeader(l.storeSectionDirectory),
const SizedBox(height: FaiSpace.sm),
SelectableText(
_moduleDetail!.directory,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
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 (_docsResult?.state == 'found') ...[
_SectionHeader(l.storeSectionDocumentation),
const SizedBox(height: FaiSpace.sm),
_DocsPanel(text: _docsResult!.text),
const SizedBox(height: FaiSpace.lg),
],
if (item.repository.isNotEmpty) ...[
_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: [
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 ...[
// Inline hint replaces the previous
// tooltip-only affordance: the operator
// sees the reason without having to hover.
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: FaiSpace.sm,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(
color: theme.colorScheme.outlineVariant,
),
),
child: Row(
children: [
Icon(
Icons.schedule,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Text(
l.storeNotInstallableInline(item.status),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
),
],
],
),
],
),
),
),
],
),
);
}
}
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),
FaiErrorBox(
error: snap.error,
isError: true,
maxHeight: 200,
),
],
),
);
}
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;
}
}
/// Renders the module's inline `MODULE.md` (or its localized
/// sibling) in the same theme as the rest of Studio. The markdown
/// body is supplied by the parent — `_DocsPanel` itself stays a
/// pure renderer with no async / no error handling.
///
/// Blockquote + table-cell decoration is overridden because
/// flutter_markdown's default styles hardcode
/// `Colors.blue.shade100`, which is unreadable on a dark theme.
class _DocsPanel extends StatelessWidget {
final String text;
const _DocsPanel({required this.text});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// No maxHeight + NeverScrollableScrollPhysics so the docs
// section participates in the outer sheet's scroll instead
// of opening its own viewport. The previous nested-scroll
// form trapped the reader on a 480 px window with two
// scrollbars fighting for the wheel.
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: Markdown(
data: text,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
selectable: true,
onTapLink: (text, href, title) async {
if (href == null || href.isEmpty) return;
await SystemActions.openInOs(href);
},
styleSheet: FaiTheme.markdownStyle(theme),
),
);
}
}
/// 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;
}
/// 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,
),
],
],
),
),
),
);
}
}
/// Compiled-in fallback stories shown when
/// `~/.chain/today/active.yaml` is absent or fails schema
/// validation. Operator can override at any time without
/// recompiling Studio — see docs/today-pipeline.md. When the
/// operator accepts a story, that single story becomes the
/// active item and the carousel collapses to a single slide.
///
/// **Editorial discipline:** every entry here points at a
/// concrete store / federation item the operator can act on,
/// not abstract platform philosophy. Architecture education
/// (sandbox model, hash-chained audit, air-gap posture) lives
/// on the dedicated Welcome page, not in the store carousel.
const List<TodayStoryData> _kFallbackTodayStories = <TodayStoryData>[
TodayStoryData(
badgeEn: 'MCP',
badgeDe: 'MCP',
titleEn: 'Plug in any MCP server',
titleDe: 'Jeden MCP-Server einbinden',
bodyEn:
'Ch∆In speaks the Model Context Protocol out of the box. Add a public server (DeepWiki, Semgrep) or a private one — its tools show up in this grid next to the built-in modules. Same install button, same audit trail.',
bodyDe:
'Ch∆In spricht das Model Context Protocol von Haus aus. Einen öffentlichen Server (DeepWiki, Semgrep) oder einen eigenen hinzufügen — seine Tools tauchen in diesem Grid neben den eingebauten Modulen auf. Gleicher Install-Button, gleiche Audit-Spur.',
ctaLabelEn: 'Browse suggested sources',
ctaLabelDe: 'Empfohlene Quellen ansehen',
icon: Icons.hub_outlined,
cta: TodayCta.showSuggestedSources,
),
TodayStoryData(
badgeEn: 'FLOWS',
badgeDe: 'FLOWS',
titleEn: 'Flows chain modules deterministically',
titleDe: 'Flows verknüpfen Module deterministisch',
bodyEn:
'A flow is a YAML file with three keys — inputs, steps, outputs. Each step calls one module. Every run lands in the audit log with a verifiable hash chain. Build flows graphically or write the YAML directly — either way, the run is reproducible.',
bodyDe:
'Ein Flow ist eine YAML-Datei mit drei Schlüsseln — inputs, steps, outputs. Jeder Schritt ruft ein Modul auf. Jeder Lauf landet im Audit-Log mit verifizierbarer Hash-Kette. Flows grafisch bauen oder direkt YAML schreiben — der Lauf bleibt reproduzierbar.',
ctaLabelEn: 'Show featured flows',
ctaLabelDe: 'Featured-Flows zeigen',
icon: Icons.account_tree_outlined,
cta: TodayCta.runQuery,
ctaPayload: 'hello',
),
TodayStoryData(
badgeEn: 'THEMES',
badgeDe: 'THEMES',
titleEn: 'Make Studio yours — pick a theme',
titleDe: 'Studio nach deinem Geschmack — Theme wählen',
bodyEn:
'Solarized, Sunflower, Space, Sunset, Glass-Apple, Accessibility — Studio plugins re-skin the whole app at runtime. Or pick a single accent colour and let Material 3 derive the rest. Browse the studio-plugin category for the full set.',
bodyDe:
'Solarized, Sunflower, Space, Sunset, Glass-Apple, Accessibility — Studio-Plugins themen die App zur Laufzeit komplett um. Oder eine einzelne Akzentfarbe wählen und Material 3 leitet den Rest ab. Die Kategorie Studio-Plugin zeigt das volle Set.',
ctaLabelEn: 'Show themes',
ctaLabelDe: 'Themes anzeigen',
icon: Icons.palette_outlined,
cta: TodayCta.filterCategory,
ctaPayload: 'studio-plugin',
),
TodayStoryData(
badgeEn: 'TEXT TOOLS',
badgeDe: 'TEXT-TOOLS',
titleEn: 'Extract, anonymise, translate, summarise',
titleDe: 'Extrahieren, anonymisieren, übersetzen, zusammenfassen',
bodyEn:
'The text-processing bundle is Ch∆In\'s document-analysis backbone. PDF and DOCX in, plain text out. Then anonymise PII, translate, or summarise — every step audit-grade, every Ollama call recorded with model digest.',
bodyDe:
'Das Text-Processing-Bündel ist Ch∆Ins Dokumentenanalyse-Rückgrat. PDF und DOCX rein, Klartext raus. Dann PII anonymisieren, übersetzen oder zusammenfassen — jeder Schritt audit-tauglich, jeder Ollama-Aufruf mit Modell-Digest protokolliert.',
ctaLabelEn: 'Show text modules',
ctaLabelDe: 'Text-Module zeigen',
icon: Icons.article_outlined,
cta: TodayCta.filterCategory,
ctaPayload: 'text-processing',
),
TodayStoryData(
badgeEn: 'SANDBOX',
badgeDe: 'SANDBOX',
titleEn: 'Every module declares its permissions',
titleDe: 'Jedes Modul deklariert seine Berechtigungen',
bodyEn:
'Ch∆In modules are WASM components. They run in a sandbox with no ambient authority — every filesystem path, every network host, every env var has to be listed in module.yaml. The hub enforces them. No declaration, no access.',
bodyDe:
'Ch∆In-Module sind WASM-Komponenten. Sie laufen in einer Sandbox ohne Ambient-Authority — jeder Dateipfad, jeder Netzwerk-Host, jede Env-Var muss in module.yaml stehen. Der Hub setzt es durch. Keine Deklaration, kein Zugriff.',
ctaLabelEn: 'Review permissions',
ctaLabelDe: 'Berechtigungen prüfen',
icon: Icons.shield_outlined,
cta: TodayCta.openSettings,
),
TodayStoryData(
badgeEn: 'AUDIT',
badgeDe: 'AUDIT',
titleEn: 'Tamper-evident hash chain — built in',
titleDe: 'Manipulationssicher per Hash-Kette — eingebaut',
bodyEn:
'Every flow run, every install, every approval lands in ~/.chain/audit/ as a hash-chained event log. Any later edit invalidates the chain. CRA-ready out of the box — no compliance product to buy on top.',
bodyDe:
'Jeder Flow-Lauf, jede Installation, jede Freigabe landet in ~/.chain/audit/ als hash-verkettetes Event-Log. Jede spätere Änderung bricht die Kette. CRA-tauglich von Haus aus — kein Compliance-Produkt zum Aufkaufen nötig.',
ctaLabelEn: '',
ctaLabelDe: '',
icon: Icons.timeline_outlined,
cta: TodayCta.none,
),
TodayStoryData(
badgeEn: 'TEST FIRST',
badgeDe: 'ZUERST TESTEN',
titleEn: 'chain doctor — diagnose before you ship',
titleDe: 'chain doctor — diagnostizieren bevor ausgerollt wird',
bodyEn:
'Before installing a module on a production hub, run `chain doctor` in the CLI. It validates manifest, signatures, declared permissions against the operator policy ceiling, and reports anything the hub would reject — no surprises at install time.',
bodyDe:
'Bevor ein Modul auf einem Produktions-Hub installiert wird, in der CLI `chain doctor` laufen lassen. Validiert Manifest, Signaturen und deklarierte Berechtigungen gegen die Operator-Policy-Decke — keine Überraschungen beim Install.',
ctaLabelEn: '',
ctaLabelDe: '',
icon: Icons.health_and_safety_outlined,
cta: TodayCta.none,
),
TodayStoryData(
badgeEn: 'BUILD ONE',
badgeDe: 'EIGENES MODUL',
titleEn: 'Build a module in a single Rust file',
titleDe: 'Ein Modul in einer einzigen Rust-Datei bauen',
bodyEn:
'fai-module-sdk gives you a #[fai::module] macro and a Cargo target. Declare inputs / outputs in module.yaml, write the function, cargo build, drop the wasm in ~/.chain/modules. The hub picks it up at next list. Polyglot SDKs follow in Phase 1.',
bodyDe:
'fai-module-sdk liefert ein #[fai::module]-Makro und ein Cargo-Target. Inputs / Outputs in module.yaml deklarieren, Funktion schreiben, cargo build, das wasm in ~/.chain/modules legen. Der Hub erkennt es beim nächsten Listing. Polyglott-SDKs folgen in Phase 1.',
ctaLabelEn: '',
ctaLabelDe: '',
icon: Icons.code_outlined,
cta: TodayCta.none,
),
];
/// Editorial hero strip — the first surface a browsing operator
/// sees. Plays the role of Apple's Today tab: one curated
/// story, gradient backdrop, narrative copy over feature
/// bullets. Auto-hides whenever a filter is active so a
/// purposeful search isn't pushed below the fold.
class _StoreTodayHero extends StatelessWidget {
final TodayStoryData story;
final String locale;
/// Carousel position (0-based) and total slide count. Drives
/// the dot indicator and the visibility of the prev/next
/// arrows. When `totalCount == 1` the chrome collapses to a
/// single-card layout — operator-accepted stories never wear
/// carousel dressing.
final int currentIndex;
final int totalCount;
final VoidCallback? onPrev;
final VoidCallback? onNext;
final VoidCallback onDismiss;
/// CTA action — null hides the button. Wired by the parent
/// because navigation targets live in the store-page state,
/// not in const story data.
final VoidCallback? onCta;
/// Inline one-click "+ Add public source" chips rendered in
/// the hero footer. Empty list hides the row — the parent
/// passes [_kRecommendedSources] only when the store has zero
/// federated entries, so we never duplicate the standalone
/// strip.
final List<_RecommendedSource> recommendedSources;
final Set<String> recommendedAdding;
final void Function(_RecommendedSource)? onAddRecommended;
const _StoreTodayHero({
required this.story,
required this.locale,
required this.currentIndex,
required this.totalCount,
required this.onPrev,
required this.onNext,
required this.onDismiss,
required this.onCta,
this.recommendedSources = const <_RecommendedSource>[],
this.recommendedAdding = const <String>{},
this.onAddRecommended,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final isDe = locale == 'de';
final badge = isDe ? story.badgeDe : story.badgeEn;
final title = isDe ? story.titleDe : story.titleEn;
final body = isDe ? story.bodyDe : story.bodyEn;
final ctaLabel = isDe ? story.ctaLabelDe : story.ctaLabelEn;
return Container(
width: double.infinity,
// Min-height keeps the carousel slides at a consistent
// size so the surrounding layout doesn't jump when the
// operator clicks the prev/next chevrons. Sized to fit
// the longest story (≈ 6 body lines + title + CTA + chip
// row) without leaving empty space on shorter slides.
constraints: const BoxConstraints(minHeight: 240),
padding: const EdgeInsets.fromLTRB(
FaiSpace.xl,
FaiSpace.lg,
FaiSpace.md,
FaiSpace.lg,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.md),
gradient: LinearGradient(
colors: [
theme.colorScheme.primary.withValues(alpha: 0.16),
theme.colorScheme.primary.withValues(alpha: 0.04),
theme.colorScheme.surfaceContainer.withValues(alpha: 0.0),
],
stops: const [0.0, 0.55, 1.0],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.25),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(FaiSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.14),
shape: BoxShape.circle,
),
child: Icon(story.icon, size: 24, color: theme.colorScheme.primary),
),
const SizedBox(width: FaiSpace.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: FaiSpace.sm,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
FaiPill(label: l.storeTodayBadge, tone: FaiPillTone.accent),
Text(
badge,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
Text(
'· ${l.storeTodayDeck}',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
fontSize: 10,
),
),
],
),
const SizedBox(height: FaiSpace.sm),
Text(
title,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
height: 1.15,
),
),
const SizedBox(height: FaiSpace.sm),
Text(
body,
maxLines: 6,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.45,
),
),
if (onCta != null || recommendedSources.isNotEmpty) ...[
const SizedBox(height: FaiSpace.md),
Wrap(
spacing: FaiSpace.sm,
runSpacing: FaiSpace.xs,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (onCta != null)
FilledButton.tonalIcon(
onPressed: onCta,
icon: const Icon(Icons.arrow_forward, size: 14),
label: Text(ctaLabel),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
for (final s in recommendedSources)
ActionChip(
avatar: recommendedAdding.contains(s.name)
? const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Icon(s.icon, size: 14),
label: Text('+ ${s.label}'),
onPressed:
recommendedAdding.contains(s.name) ||
onAddRecommended == null
? null
: () => onAddRecommended!(s),
),
],
),
],
],
),
),
// Right-edge column: dismiss + (when more than one
// slide exists) a carousel control row. Stacking them
// vertically keeps the hero's main row tidy whatever
// the slide count.
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
icon: const Icon(Icons.close, size: 16),
visualDensity: VisualDensity.compact,
tooltip: l.storeTodayDismissTooltip,
onPressed: onDismiss,
),
if (totalCount > 1) ...[
const SizedBox(height: FaiSpace.sm),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.chevron_left, size: 18),
visualDensity: VisualDensity.compact,
onPressed: onPrev,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < totalCount; i++)
Container(
width: 6,
height: 6,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == currentIndex
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.chevron_right, size: 18),
visualDensity: VisualDensity.compact,
onPressed: onNext,
),
],
),
],
],
),
],
),
);
}
}
/// 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,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
Text(
desc,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
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,
);
case 'temporal':
return FaiPill(
label: l.storeProvenanceTemporal(item.provider),
tone: FaiPillTone.accent,
monospace: true,
icon: Icons.timer_outlined,
);
default:
// Native is the default: no badge needed. Keeps the
// card visually quiet — only foreign code (mcp / n8n)
// earns the provenance pill, the same way a "verified"
// badge is opt-in in commercial app stores.
return const SizedBox.shrink();
}
}
}
/// Store-side version picker. Same shape as the module-sheet
/// version picker but lives here so the Store page doesn't take
/// a dependency on the module-sheet's private widgets. The
/// duplication is small (one dialog), and keeping them
/// separate lets each surface evolve its copy independently.
class _StoreUninstallVersionPickerDialog extends StatefulWidget {
final String moduleName;
final List<String> versions;
const _StoreUninstallVersionPickerDialog({
required this.moduleName,
required this.versions,
});
@override
State<_StoreUninstallVersionPickerDialog> createState() =>
_StoreUninstallVersionPickerDialogState();
}
class _StoreUninstallVersionPickerDialogState
extends State<_StoreUninstallVersionPickerDialog> {
late String _picked;
@override
void initState() {
super.initState();
_picked = widget.versions.last;
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(l.uninstallVersionPickerTitle),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(l.uninstallVersionPickerBody(widget.moduleName)),
const SizedBox(height: FaiSpace.md),
RadioGroup<String>(
groupValue: _picked,
onChanged: (val) {
if (val != null) setState(() => _picked = val);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final v in widget.versions)
RadioListTile<String>(
value: v,
title: Text(
'v$v',
style: const TextStyle(fontFamily: 'monospace'),
),
dense: true,
contentPadding: EdgeInsets.zero,
),
],
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l.buttonCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, _picked),
child: Text(l.uninstallVersionPickerContinue),
),
],
);
}
}