// 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 createState() => _StorePageState(); } class _StorePageState extends State { final _queryCtrl = TextEditingController(); String _category = ''; String _status = ''; /// Source filter: '' (all), 'native', 'mcp', 'n8n'. Applied /// client-side after the hub returns results — the search RPC /// has no source field. String _source = ''; bool _installedOnly = false; /// "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 _addingSources = {}; /// 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 `~/.fai/today/active.yaml`, /// it is the only entry (carousel collapses to a single /// slide). Otherwise the curated fallback list rotates. late final List _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 [loaded]; })(); /// Carousel index. Wraps around modulo `_todayStories.length`. int _todayIndex = 0; late Future> _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? _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 _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 _installedVersions = const {}; Future> _load() async { // Fetch the store entries and the installed modules in // parallel so the page paints in one round-trip. The // installed-version map drives the per-card "Update" // badge — store entries only carry `best_version` and // `installed: bool`, never the installed version itself. final results = await Future.wait([ HubService.instance.searchStore( query: _queryCtrl.text.trim(), category: _category, status: _status, limit: 200, ), HubService.instance.listModules().catchError((_) => []), ]); final all = results[0] as List; final mods = results[1] as List; _installedVersions = {for (final m in mods) m.name: m.version}; 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 _openDetail(StoreItem item) async { final changed = await _StoreDetailSheet.show(context, item, _locale); if (changed) _runSearch(); } Future _install(StoreItem item) async { if (!mounted) return; final outcome = await showDialog( context: context, barrierDismissible: false, builder: (_) => _InstallProgressDialog(item: item), ); if (outcome == true) _runSearch(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( // 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>( future: _future, builder: (ctx, snap) { final raw = snap.data ?? const []; 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>( future: _future, builder: (context, snap) { if (snap.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snap.hasError) { return FaiEmptyState( icon: Icons.cloud_off_outlined, iconColor: theme.colorScheme.error, title: l.hubUnreachable, hint: l.hubUnreachableHint, action: FilledButton.tonal( onPressed: _runSearch, child: Text(l.buttonRetry), ), ); } final raw = snap.data ?? const []; final items = _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 _categoriesIn(List items) { final set = {}; for (final i in items) { if (i.category.isNotEmpty) set.add(i.category); } final list = set.toList()..sort(); return list; } List _applySourceFilter(List items) { if (_source.isEmpty) return items; return items.where((e) => _sourceForItem(e) == _source).toList(); } /// 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 _applyAllFilters(List 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 _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 _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 _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 _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 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( 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( LogicalKeyboardKey.meta, LogicalKeyboardKey.enter, ): const _SubmitAskIntent(), LogicalKeySet( LogicalKeyboardKey.control, LogicalKeyboardKey.enter, ): const _SubmitAskIntent(), }, child: Actions( actions: >{ _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 categories; final String selected; final ValueChanged 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( 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> modules, String locale, ) { final lang = locale == 'de' ? 'German' : 'English'; return '''You are the F∆I 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": "", "matches": [ {"name": "", "why": ""} ] } 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 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 items; final String locale; /// Map of installed module name → installed version. Cards /// compare against `bestVersion` to render an "Update" badge /// when the store offers something newer. final Map installedVersions; final ValueChanged onTap; final ValueChanged onInstall; const _StoreGrid({ required this.items, required this.locale, required this.installedVersions, required this.onTap, required this.onInstall, }); @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { const minCardWidth = 360.0; final cols = (constraints.maxWidth / minCardWidth).floor().clamp(1, 4); // 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 items; final String locale; final ValueChanged onTap; final ValueChanged onInstall; const _FeaturedStrip({ required this.items, required this.locale, required this.onTap, required this.onInstall, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.star, size: 14, color: FaiColors.success), const SizedBox(width: 6), Text( l.storeFeatured, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(width: FaiSpace.sm), Text( l.storeFeaturedHint, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), const SizedBox(height: FaiSpace.sm), SizedBox( // Tiles render larger than regular grid cards // (which are 168 × ~360) so the editorial picks // visibly stand out. Same hierarchy you see on app // stores: hero band before the dense list. height: 220, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: items.length, separatorBuilder: (_, _) => const SizedBox(width: FaiSpace.md), itemBuilder: (context, i) { final item = items[i]; return SizedBox( width: 480, child: _FeaturedTile( item: item, locale: locale, onTap: () => onTap(item), onInstall: () => onInstall(item), ), ); }, ), ), ], ); } } class _FeaturedTile extends StatelessWidget { final StoreItem item; final String locale; final VoidCallback onTap; final VoidCallback onInstall; const _FeaturedTile({ required this.item, required this.locale, required this.onTap, required this.onInstall, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final tagline = locale == 'de' && item.taglineDe.isNotEmpty ? item.taglineDe : item.taglineEn; final installable = item.status == 'published' || item.status == 'alpha'; return Material( color: theme.colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(FaiRadius.md), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(FaiRadius.md), child: Container( padding: const EdgeInsets.all(FaiSpace.lg), decoration: BoxDecoration( borderRadius: BorderRadius.circular(FaiRadius.md), gradient: LinearGradient( colors: [ theme.colorScheme.primary.withValues(alpha: 0.14), theme.colorScheme.surfaceContainer.withValues(alpha: 0.0), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ CircleAvatar( radius: 24, backgroundColor: theme.colorScheme.primary.withValues( alpha: 0.18, ), child: Icon( _iconForCategory(item.category), size: 26, color: theme.colorScheme.primary, ), ), const SizedBox(width: FaiSpace.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.name, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (item.category.isNotEmpty) Text( _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 show( BuildContext context, StoreItem item, String locale, ) async { final r = await showModalBottomSheet( 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 _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 _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 _install() async { setState(() { _busy = true; _toast = null; }); try { final r = await HubService.instance.installModule( source: widget.item.name, ); if (!mounted) return; final l = AppLocalizations.of(context)!; setState(() { _busy = false; _toast = l.storeInstalledToast(r.name, r.version); }); Navigator.pop(context, true); } catch (e) { if (!mounted) return; final l = AppLocalizations.of(context)!; setState(() { _busy = false; _toast = l.storeInstallFailedToast(e.toString()); }); } } Future _uninstall() async { final l = AppLocalizations.of(context)!; // 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( context: context, builder: (ctx) => _StoreUninstallVersionPickerDialog( moduleName: widget.item.name, versions: versions, ), ); if (targetVersion == null || !mounted) return; } final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(l.storeUninstallTitle), content: Text(l.storeUninstallBody(widget.item.name)), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(l.buttonCancel), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom( backgroundColor: Theme.of(ctx).colorScheme.error, foregroundColor: Theme.of(ctx).colorScheme.onError, ), child: Text(l.buttonUninstall), ), ], ), ); if (ok != true || !mounted) return; setState(() { _busy = true; _toast = null; }); try { final r = await HubService.instance.uninstallModule( widget.item.name, 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 _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 /// `~/.fai/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 _kFallbackTodayStories = [ TodayStoryData( badgeEn: 'MCP', badgeDe: 'MCP', titleEn: 'Plug in any MCP server', titleDe: 'Jeden MCP-Server einbinden', bodyEn: 'F∆I 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: 'F∆I 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 F∆I\'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 F∆Is 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: 'F∆I 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: 'F∆I-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 ~/.fai/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 ~/.fai/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: 'fai doctor — diagnose before you ship', titleDe: 'fai doctor — diagnostizieren bevor ausgerollt wird', bodyEn: 'Before installing a module on a production hub, run `fai 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 `fai 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 ~/.fai/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 ~/.fai/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 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 {}, 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..`). final String name; /// Display label shown on the card. final String label; final IconData icon; /// HTTPS Streamable-HTTP endpoint. No auth required. final String endpoint; /// Bilingual description used as the MCP-client `description` /// field — keeps the audit log in the operator's locale. final String descriptionEn; final String descriptionDe; const _RecommendedSource({ required this.name, required this.label, required this.icon, required this.endpoint, required this.descriptionEn, required this.descriptionDe, }); } /// Curated public MCP servers offered as one-click adds. Two /// guardrails apply when picking entries: outbound HTTPS only /// (no subprocess, no npm), and a small-enough tool surface /// that the store stays browsable after the add (Useful AI was /// considered but its 340+ tools would drown the index). const _kRecommendedSources = <_RecommendedSource>[ _RecommendedSource( name: 'deepwiki', label: 'DeepWiki', icon: Icons.menu_book_outlined, endpoint: 'https://mcp.deepwiki.com/mcp', descriptionEn: 'GitHub repo documentation search (AI-powered).', descriptionDe: 'GitHub-Repo-Doku-Suche (KI-gestützt).', ), _RecommendedSource( name: 'semgrep', label: 'Semgrep', icon: Icons.security, endpoint: 'https://mcp.semgrep.ai/mcp', descriptionEn: 'Security scanning for code vulnerabilities.', descriptionDe: 'Security-Scanning für Code-Schwachstellen.', ), ]; /// Recommended-sources strip — replaces the older Settings nudge. /// Renders one card per [_kRecommendedSources] entry with a /// single `[+ Add]` button so the operator can fill the store /// without leaving the Store page. Hides as soon as any /// federated entry exists. class _RecommendedSourcesStrip extends StatelessWidget { final Set adding; final String locale; final void Function(_RecommendedSource) onAdd; const _RecommendedSourcesStrip({ required this.adding, required this.locale, required this.onAdd, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Container( width: double.infinity, padding: const EdgeInsets.all(FaiSpace.md), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(FaiRadius.md), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.hub_outlined, size: 18, color: theme.colorScheme.primary, ), const SizedBox(width: FaiSpace.sm), Text( l.storeRecommendedTitle, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), ], ), const SizedBox(height: 4), Text( l.storeRecommendedBody, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.md), Wrap( spacing: FaiSpace.md, runSpacing: FaiSpace.sm, children: [ for (final s in _kRecommendedSources) _RecommendedSourceCard( source: s, locale: locale, busy: adding.contains(s.name), onAdd: () => onAdd(s), ), ], ), ], ), ); } } class _RecommendedSourceCard extends StatelessWidget { final _RecommendedSource source; final String locale; final bool busy; final VoidCallback onAdd; const _RecommendedSourceCard({ required this.source, required this.locale, required this.busy, required this.onAdd, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; final desc = locale == 'de' ? source.descriptionDe : source.descriptionEn; return SizedBox( width: 320, child: Container( padding: const EdgeInsets.all(FaiSpace.sm), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: Row( children: [ Icon(source.icon, size: 18, color: theme.colorScheme.primary), const SizedBox(width: FaiSpace.sm), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( source.label, 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 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( groupValue: _picked, onChanged: (val) { if (val != null) setState(() => _picked = val); }, child: Column( mainAxisSize: MainAxisSize.min, children: [ for (final v in widget.versions) RadioListTile( 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), ), ], ); } }