Some checks failed
Security / Security check (push) Failing after 1s
Bundles the error-UX overhaul and the inline log viewer:
- New `FaiLog` (`~/.fai/logs/studio-errors.log`, 256 KiB rotation,
JSON-per-line). Every operator-visible failure is appended so
`fai admin doctor` and the new viewer can show the trail
without the operator having to reproduce the failure.
- New `showFaiErrorSnack` / `showFaiErrorDialog` helpers wrap
`FaiErrorBox` in copyable surfaces; 27 ad-hoc
`SnackBar(content: Text(e.toString()))` sites swept to use
them (settings, doctor, audit, store, module sheet, system-AI
editor, flow output).
- New `FaiLogViewer` modal (`showFaiLogViewer`) renders log
files inline with line numbers, JSON-key + `[level]` token
colouring, Copy-all, Refresh, Open-externally. Doctor's
daemon-paths panel grows a "View" button next to "Open" for
every `.log` row and now also lists the Studio errors log.
- Today carousel: CTAs now actually re-run search after a
`filterCategory` / `runQuery` story is tapped (was only
flipping the chip state). Fallback story list bumped to 8.
- Editor bumped to git ref carrying 0.15.0 (type-token
colouring + analyzer diagnostics).
Studio bumped to 0.62.0.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
3662 lines
132 KiB
Dart
3662 lines
132 KiB
Dart
// Store browser. Lists modules from the hub's bundled
|
||
// store-index. Top-level surface mirrors the look operators
|
||
// expect from app stores (search bar, category strip, filter
|
||
// chips, status chips), with a per-entry detail sheet that
|
||
// renders the full bilingual description, requires-list, and
|
||
// repository link.
|
||
|
||
import 'dart:async';
|
||
import 'dart:convert';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||
|
||
import '../data/error_presentation.dart';
|
||
import '../data/hub.dart';
|
||
import '../data/system_actions.dart';
|
||
import '../data/today_story_loader.dart';
|
||
import '../l10n/app_localizations.dart';
|
||
import '../theme/theme.dart';
|
||
import '../theme/tokens.dart';
|
||
import '../widgets/widgets.dart';
|
||
|
||
class StorePage extends StatefulWidget {
|
||
const StorePage({super.key});
|
||
|
||
@override
|
||
State<StorePage> createState() => _StorePageState();
|
||
}
|
||
|
||
class _StorePageState extends State<StorePage> {
|
||
final _queryCtrl = TextEditingController();
|
||
String _category = '';
|
||
String _status = '';
|
||
|
||
/// Source filter: '' (all), 'native', 'mcp', 'n8n'. Applied
|
||
/// client-side after the hub returns results — the search RPC
|
||
/// has no source field.
|
||
String _source = '';
|
||
bool _installedOnly = false;
|
||
|
||
/// "Show installable modules only". On by default: roughly
|
||
/// 2/3 of the seed-index entries currently carry status
|
||
/// `planned` (no bundle to install), and pre-filter polish
|
||
/// found that operators couldn't tell why they couldn't
|
||
/// install half the cards. Planned modules are one chip-flip
|
||
/// away in the filter dialog.
|
||
bool _installableOnly = true;
|
||
|
||
/// Tracks per-recommended-source add buttons to disable them
|
||
/// while the request is in flight.
|
||
final Set<String> _addingSources = <String>{};
|
||
|
||
/// Whether the operator dismissed the editorial hero this
|
||
/// session. Not persisted — the next Studio launch shows it
|
||
/// again so a release-bumped story has a chance to be seen.
|
||
bool _todayDismissed = false;
|
||
|
||
/// The carousel of stories rendered in the hero. When an
|
||
/// operator-accepted story exists at `~/.fai/today/active.yaml`,
|
||
/// it is the only entry (carousel collapses to a single
|
||
/// slide). Otherwise the curated fallback list rotates.
|
||
late final List<TodayStoryData> _todayStories = (() {
|
||
final loaded = TodayStoryLoader.loadOrFallback(
|
||
_kFallbackTodayStories.first,
|
||
);
|
||
// When the loader fell back to the const, it returned the
|
||
// first entry of our list — surface the whole list so the
|
||
// operator can browse. Otherwise the loader produced a real
|
||
// accepted story and we honour it as the only item.
|
||
if (identical(loaded, _kFallbackTodayStories.first)) {
|
||
return _kFallbackTodayStories;
|
||
}
|
||
return <TodayStoryData>[loaded];
|
||
})();
|
||
|
||
/// Carousel index. Wraps around modulo `_todayStories.length`.
|
||
int _todayIndex = 0;
|
||
late Future<List<StoreItem>> _future;
|
||
|
||
// ── AI search state ──────────────────────────────────────
|
||
// The Ask bar runs traditional substring search on every
|
||
// keystroke (cheap; same as before). When the operator hits
|
||
// Enter on a question-shaped query AND a System-AI is
|
||
// configured, the LLM gets a structured ranking call and the
|
||
// grid filters to its picks. Clearing the query resets both
|
||
// surfaces back to the unfiltered view.
|
||
bool _aiThinking = false;
|
||
String? _aiAnswer;
|
||
List<_AiMatch>? _aiMatches;
|
||
Set<String>? _aiMatchedNames;
|
||
String? _aiError;
|
||
|
||
/// Debounce-Timer für Such-Filterung beim Tippen. Ohne
|
||
/// Debounce flackert "Keine Treffer" zwischen Buchstaben
|
||
/// (für 2-3 Buchstaben matched z.B. nichts, das nächste
|
||
/// dann doch). 250ms ist comfortable für Tippen.
|
||
Timer? _typingDebounce;
|
||
|
||
/// Whether the operator has a System-AI configured. Drives
|
||
/// the Ask-bar hint copy and disables the AI path when off.
|
||
bool _systemAiEnabled = false;
|
||
|
||
/// Active UI locale, read from the app-wide MaterialApp
|
||
/// `locale` setting. Drives which side of the bilingual
|
||
/// store-index entries renders (taglineEn vs taglineDe,
|
||
/// descriptionEn vs descriptionDe). The sidebar's
|
||
/// language toggle flips this for the whole app.
|
||
String get _locale => Localizations.localeOf(context).languageCode;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_future = _load();
|
||
_refreshSystemAiStatus();
|
||
}
|
||
|
||
Future<void> _refreshSystemAiStatus() async {
|
||
try {
|
||
final s = await HubService.instance.systemAiStatus();
|
||
if (!mounted) return;
|
||
setState(() => _systemAiEnabled = s.enabled);
|
||
} catch (_) {
|
||
// Stay disabled on any failure — Ask bar falls back to
|
||
// pure keyword search until next try.
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_typingDebounce?.cancel();
|
||
_queryCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Map<String, String> _installedVersions = const {};
|
||
|
||
Future<List<StoreItem>> _load() async {
|
||
// Fetch the store entries and the installed modules in
|
||
// parallel so the page paints in one round-trip. The
|
||
// installed-version map drives the per-card "Update"
|
||
// badge — store entries only carry `best_version` and
|
||
// `installed: bool`, never the installed version itself.
|
||
final results = await Future.wait([
|
||
HubService.instance.searchStore(
|
||
query: _queryCtrl.text.trim(),
|
||
category: _category,
|
||
status: _status,
|
||
limit: 200,
|
||
),
|
||
HubService.instance.listModules().catchError((_) => <ModuleSummary>[]),
|
||
]);
|
||
final all = results[0] as List<StoreItem>;
|
||
final mods = results[1] as List<ModuleSummary>;
|
||
_installedVersions = {for (final m in mods) m.name: m.version};
|
||
var out = all;
|
||
if (_installedOnly) {
|
||
out = out.where((e) => e.installed).toList();
|
||
}
|
||
if (_installableOnly) {
|
||
// An already-installed item counts as "installable" for
|
||
// this filter — the user can still update / uninstall it.
|
||
out = out
|
||
.where(
|
||
(e) =>
|
||
e.installed || e.status == 'published' || e.status == 'alpha',
|
||
)
|
||
.toList();
|
||
}
|
||
return out;
|
||
}
|
||
|
||
void _runSearch() {
|
||
// Block body, so the closure's return type is `void`
|
||
// rather than `Future`. setState() rejects closures that
|
||
// resolve to a Future — the assignment must be synchronous
|
||
// even though the future itself is awaited later.
|
||
setState(() {
|
||
_future = _load();
|
||
});
|
||
}
|
||
|
||
Future<void> _openDetail(StoreItem item) async {
|
||
final changed = await _StoreDetailSheet.show(context, item, _locale);
|
||
if (changed) _runSearch();
|
||
}
|
||
|
||
Future<void> _install(StoreItem item) async {
|
||
if (!mounted) return;
|
||
final outcome = await showDialog<bool>(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (_) => _InstallProgressDialog(item: item),
|
||
);
|
||
if (outcome == true) _runSearch();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return Scaffold(
|
||
backgroundColor: theme.scaffoldBackgroundColor,
|
||
appBar: AppBar(
|
||
// titleSpacing matches the body's horizontal padding
|
||
// (FaiSpace.xl == 24) so the title's left edge sits
|
||
// flush with the cards below. Default 16 left a 8-px
|
||
// gap that read as a misaligned heading.
|
||
titleSpacing: FaiSpace.xl,
|
||
title: Text(l.searchGroupStore),
|
||
actions: [
|
||
FutureBuilder<List<StoreItem>>(
|
||
future: _future,
|
||
builder: (ctx, snap) {
|
||
final raw = snap.data ?? const <StoreItem>[];
|
||
final categories = _categoriesIn(raw);
|
||
return Padding(
|
||
padding: const EdgeInsets.only(right: FaiSpace.sm),
|
||
child: _CategoryDropdown(
|
||
categories: categories,
|
||
selected: _category,
|
||
onSelected: (v) {
|
||
setState(() => _category = v);
|
||
_runSearch();
|
||
},
|
||
),
|
||
);
|
||
},
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: FaiSpace.sm),
|
||
child: _FilterButton(
|
||
activeCount: _activeFilterCount(),
|
||
onPressed: _openFilterDialog,
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.refresh, size: 18),
|
||
tooltip: l.storeReloadTooltip,
|
||
onPressed: _runSearch,
|
||
),
|
||
// Trailing pad pulls the IconButton's outer edge in
|
||
// to match the body's right-side padding. The icon
|
||
// glyph itself ends up flush with the rightmost grid
|
||
// card, not floating in space.
|
||
const SizedBox(width: FaiSpace.md),
|
||
],
|
||
),
|
||
body: Column(
|
||
children: [
|
||
// Scrollable content: editorial chrome + grid. Sits in
|
||
// an Expanded so the bottom AskBar pins to the foot of
|
||
// the viewport, mirroring the chat-input pattern of
|
||
// Claude / ChatGPT / Slack.
|
||
Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
FaiSpace.xl,
|
||
FaiSpace.lg,
|
||
FaiSpace.xl,
|
||
0,
|
||
),
|
||
child: FutureBuilder<List<StoreItem>>(
|
||
future: _future,
|
||
builder: (context, snap) {
|
||
if (snap.connectionState == ConnectionState.waiting) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (snap.hasError) {
|
||
return FaiEmptyState(
|
||
icon: Icons.cloud_off_outlined,
|
||
iconColor: theme.colorScheme.error,
|
||
title: l.hubUnreachable,
|
||
hint: l.hubUnreachableHint,
|
||
action: FilledButton.tonal(
|
||
onPressed: _runSearch,
|
||
child: Text(l.buttonRetry),
|
||
),
|
||
);
|
||
}
|
||
final raw = snap.data ?? const <StoreItem>[];
|
||
final items = _applyAllFilters(raw);
|
||
if (items.isEmpty) {
|
||
return FaiEmptyState(
|
||
icon: Icons.search_off,
|
||
title: l.storeNoMatches,
|
||
hint: l.storeNoMatchesHint,
|
||
action: FilledButton.tonal(
|
||
onPressed: () {
|
||
setState(() {
|
||
_category = '';
|
||
_status = '';
|
||
_source = '';
|
||
_installedOnly = false;
|
||
_installableOnly = false;
|
||
_queryCtrl.clear();
|
||
});
|
||
_runSearch();
|
||
},
|
||
child: Text(l.storeClearFilters),
|
||
),
|
||
);
|
||
}
|
||
// Browsing-only chrome — every editorial
|
||
// surface hides the moment a filter is set so
|
||
// the operator's query stays the focus.
|
||
final isBrowsing =
|
||
_queryCtrl.text.trim().isEmpty &&
|
||
_category.isEmpty &&
|
||
_status.isEmpty &&
|
||
_source.isEmpty &&
|
||
!_installedOnly;
|
||
final hasNoFederation = !raw.any((e) => e.isFederated);
|
||
// Today carousel stays visible until the
|
||
// operator explicitly dismisses it. The earlier
|
||
// installedCount≥3 auto-hide killed the strip
|
||
// for anyone past first-launch — meaning the
|
||
// rotating editorial stories (themes, audit,
|
||
// SDK, sandbox…) were never seen by experienced
|
||
// operators, which is exactly the audience the
|
||
// wider story set is for. One-click dismiss is
|
||
// enough opt-out.
|
||
final showToday = !_todayDismissed && isBrowsing;
|
||
// Recommended-source quick-adds are inlined in
|
||
// the Today footer when both apply, so the
|
||
// operator never sees two competing editorial
|
||
// cards stacked on top of each other.
|
||
// (Hero footer no longer carries the chips —
|
||
// brand-name suggestions only show in the
|
||
// standalone "Suggested sources" strip below
|
||
// and via Settings → MCP Clients.)
|
||
// Standalone strip only fires in the corner
|
||
// case: Today dismissed AND no federation.
|
||
// Featured strip and Today never compete —
|
||
// Today is the editorial hero, Featured plays
|
||
// second fiddle and only renders once Today is
|
||
// out of the way.
|
||
final showStandaloneRecommended =
|
||
isBrowsing && _todayDismissed && hasNoFederation;
|
||
final showFeatured =
|
||
isBrowsing &&
|
||
_todayDismissed &&
|
||
items.any((e) => e.featured);
|
||
return SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (_aiThinking ||
|
||
_aiAnswer != null ||
|
||
_aiError != null) ...[
|
||
_AiAnswerCard(
|
||
thinking: _aiThinking,
|
||
answer: _aiAnswer,
|
||
matches: _aiMatches ?? const <_AiMatch>[],
|
||
error: _aiError,
|
||
onClear: _clearQuery,
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
if (showToday) ...[
|
||
_StoreTodayHero(
|
||
story:
|
||
_todayStories[_todayIndex %
|
||
_todayStories.length],
|
||
locale: _locale,
|
||
currentIndex: _todayIndex,
|
||
totalCount: _todayStories.length,
|
||
onPrev: _todayStories.length > 1
|
||
? () => setState(() {
|
||
_todayIndex =
|
||
(_todayIndex -
|
||
1 +
|
||
_todayStories.length) %
|
||
_todayStories.length;
|
||
})
|
||
: null,
|
||
onNext: _todayStories.length > 1
|
||
? () => setState(() {
|
||
_todayIndex =
|
||
(_todayIndex + 1) %
|
||
_todayStories.length;
|
||
})
|
||
: null,
|
||
onDismiss: () =>
|
||
setState(() => _todayDismissed = true),
|
||
onCta: _ctaCallbackFor(
|
||
_todayStories[_todayIndex % _todayStories.length],
|
||
),
|
||
// Per-story brand chips: only the MCP
|
||
// story shows DeepWiki / Semgrep
|
||
// quick-adds, so the chips don't read
|
||
// as ads on stories that aren't about
|
||
// sources. Standalone "Suggested
|
||
// sources" strip below still catches
|
||
// the dismissed-Today / no-federation
|
||
// edge case.
|
||
recommendedSources:
|
||
_todayStories[_todayIndex %
|
||
_todayStories.length]
|
||
.cta ==
|
||
TodayCta.showSuggestedSources
|
||
? _kRecommendedSources
|
||
: const <_RecommendedSource>[],
|
||
recommendedAdding: _addingSources,
|
||
onAddRecommended: _addRecommendedSource,
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
if (showStandaloneRecommended) ...[
|
||
_RecommendedSourcesStrip(
|
||
adding: _addingSources,
|
||
locale: _locale,
|
||
onAdd: _addRecommendedSource,
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
if (showFeatured) ...[
|
||
_FeaturedStrip(
|
||
items: items.where((e) => e.featured).toList(),
|
||
locale: _locale,
|
||
onTap: _openDetail,
|
||
onInstall: _install,
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
_StoreGrid(
|
||
items: items,
|
||
locale: _locale,
|
||
installedVersions: _installedVersions,
|
||
onTap: _openDetail,
|
||
onInstall: _install,
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
// Pinned chat-style ask bar at the foot of the
|
||
// viewport. Always visible so the operator can switch
|
||
// from browsing to asking with one click — same shape
|
||
// as Claude / ChatGPT / Slack composer rows.
|
||
Container(
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surface,
|
||
border: Border(
|
||
top: BorderSide(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
),
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: FaiSpace.xl,
|
||
vertical: FaiSpace.md,
|
||
),
|
||
child: _AskBar(
|
||
controller: _queryCtrl,
|
||
thinking: _aiThinking,
|
||
aiAvailable: _systemAiEnabled,
|
||
onSubmit: _onAskSubmit,
|
||
onClear: _clearQuery,
|
||
onTextChange: _onAskTyping,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
List<String> _categoriesIn(List<StoreItem> items) {
|
||
final set = <String>{};
|
||
for (final i in items) {
|
||
if (i.category.isNotEmpty) set.add(i.category);
|
||
}
|
||
final list = set.toList()..sort();
|
||
return list;
|
||
}
|
||
|
||
List<StoreItem> _applySourceFilter(List<StoreItem> items) {
|
||
if (_source.isEmpty) return items;
|
||
return items.where((e) => _sourceForItem(e) == _source).toList();
|
||
}
|
||
|
||
/// Source filter + AI-match overlay. AI mode narrows the grid
|
||
/// to whatever the LLM ranked above the line so the answer
|
||
/// card and the grid stay coherent.
|
||
List<StoreItem> _applyAllFilters(List<StoreItem> items) {
|
||
var out = _applySourceFilter(items);
|
||
final ai = _aiMatchedNames;
|
||
if (ai != null) {
|
||
out = out.where((e) => ai.contains(e.name)).toList();
|
||
}
|
||
return out;
|
||
}
|
||
|
||
int _activeFilterCount() {
|
||
var n = 0;
|
||
if (_status.isNotEmpty) n++;
|
||
if (_source.isNotEmpty) n++;
|
||
if (_installedOnly) n++;
|
||
// `installableOnly` defaults to ON, so we only highlight it
|
||
// as an "active filter" when the operator turned it OFF —
|
||
// the surprising state is the one worth flagging.
|
||
if (!_installableOnly) n++;
|
||
return n;
|
||
}
|
||
|
||
/// On every keystroke. Short keyword-shaped input runs the
|
||
/// hub's substring search live so the grid follows along. A
|
||
/// question-shaped input holds the grid steady until the
|
||
/// operator submits — otherwise typing "ein Modul, das …"
|
||
/// wipes the visible result set with every space.
|
||
void _onAskTyping(String value) {
|
||
if (_aiAnswer != null || _aiMatches != null) {
|
||
setState(() {
|
||
_aiAnswer = null;
|
||
_aiMatches = null;
|
||
_aiMatchedNames = null;
|
||
_aiError = null;
|
||
});
|
||
}
|
||
// Tippen läuft IMMER nur durch die lokale Substring-Suche.
|
||
// Die KI-Suche kostet einen LLM-Roundtrip und gehört hinter
|
||
// den expliziten Submit-Knopf — sonst feuern wir pro
|
||
// Keystroke einen Prompt. Debounce verhindert "Keine
|
||
// Treffer" zwischen Buchstaben.
|
||
_typingDebounce?.cancel();
|
||
_typingDebounce = Timer(const Duration(milliseconds: 250), () {
|
||
if (!mounted) return;
|
||
_runSearch();
|
||
});
|
||
}
|
||
|
||
Future<void> _onAskSubmit() async {
|
||
// Pending typing-debounce abbrechen — wir submitten jetzt
|
||
// explizit und wollen nicht hinterher nochmal substring-
|
||
// filter laufen lassen.
|
||
_typingDebounce?.cancel();
|
||
final q = _queryCtrl.text.trim();
|
||
if (q.isEmpty) {
|
||
_clearQuery();
|
||
return;
|
||
}
|
||
// Wenn System-AI verfügbar ist, geht der explizite Submit
|
||
// IMMER über die KI-Suche — egal ob die Eingabe wie eine
|
||
// Frage aussieht. Das war der Bug: Nutzer tippt "tabellen"
|
||
// + klick Suchen, erwartet KI, bekommt Substring-Filter.
|
||
// Substring-Filter passiert weiter live beim Tippen
|
||
// (siehe _onAskTyping).
|
||
if (_systemAiEnabled) {
|
||
await _runAiQuery(q);
|
||
} else {
|
||
_runSearch();
|
||
}
|
||
}
|
||
|
||
/// Resolve a Today story's CTA descriptor into a real callback
|
||
/// the hero card can wire to its button. `null` collapses the
|
||
/// button out of the layout — used for narrative-only stories
|
||
/// (audit / sandbox explainer) where there's nowhere obvious
|
||
/// to send the operator next.
|
||
VoidCallback? _ctaCallbackFor(TodayStoryData story) {
|
||
switch (story.cta) {
|
||
case TodayCta.none:
|
||
return null;
|
||
case TodayCta.openSettings:
|
||
return () => FaiSettingsDialog.show(context);
|
||
case TodayCta.filterCategory:
|
||
if (story.ctaPayload.isEmpty) return null;
|
||
return () {
|
||
// Two-step refresh: setState makes the dropdown
|
||
// header re-render with the new selection, then
|
||
// _runSearch re-issues the backend query with
|
||
// `category:` in its filter — without the second
|
||
// call the grid keeps showing the unfiltered list
|
||
// and the carousel button looked broken.
|
||
setState(() => _category = story.ctaPayload);
|
||
_runSearch();
|
||
};
|
||
case TodayCta.runQuery:
|
||
if (story.ctaPayload.isEmpty) return null;
|
||
return () {
|
||
_queryCtrl.text = story.ctaPayload;
|
||
setState(() {});
|
||
_runSearch();
|
||
};
|
||
case TodayCta.showSuggestedSources:
|
||
// Stays on this slide — the chips render in the hero
|
||
// footer for stories with this CTA. The button just
|
||
// scrolls focus to them; for now the chips are
|
||
// immediately visible above the fold, so the click is
|
||
// effectively a no-op acknowledgement. Keeping it
|
||
// wired avoids a dead button.
|
||
return () {};
|
||
}
|
||
}
|
||
|
||
void _clearQuery() {
|
||
setState(() {
|
||
_queryCtrl.clear();
|
||
_aiAnswer = null;
|
||
_aiMatches = null;
|
||
_aiMatchedNames = null;
|
||
_aiError = null;
|
||
});
|
||
_runSearch();
|
||
}
|
||
|
||
Future<void> _runAiQuery(String query) async {
|
||
setState(() {
|
||
_aiThinking = true;
|
||
_aiAnswer = null;
|
||
_aiMatches = null;
|
||
_aiMatchedNames = null;
|
||
_aiError = null;
|
||
});
|
||
try {
|
||
final all = await HubService.instance.searchStore(limit: 200);
|
||
final modules = all
|
||
.map(
|
||
(s) => {
|
||
'name': s.name,
|
||
'tagline': _locale == 'de' && s.taglineDe.isNotEmpty
|
||
? s.taglineDe
|
||
: s.taglineEn,
|
||
'category': s.category,
|
||
},
|
||
)
|
||
.toList();
|
||
final prompt = _buildAiSearchPrompt(query, modules, _locale);
|
||
final result = await HubService.instance.askAi(prompt);
|
||
if (!mounted) return;
|
||
if (!result.isSuccess) {
|
||
setState(() {
|
||
_aiThinking = false;
|
||
_aiError = result.text;
|
||
});
|
||
return;
|
||
}
|
||
final parsed = _parseAiSearchResponse(result.text, all);
|
||
setState(() {
|
||
_aiThinking = false;
|
||
_aiAnswer = parsed.answer;
|
||
_aiMatches = parsed.matches;
|
||
_aiMatchedNames = parsed.matches.isEmpty
|
||
? null
|
||
: parsed.matches.map((m) => m.name).toSet();
|
||
});
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_aiThinking = false;
|
||
_aiError = e.toString();
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _openFilterDialog() async {
|
||
final outcome = await showDialog<_FilterOutcome>(
|
||
context: context,
|
||
builder: (_) => _FilterDialog(
|
||
status: _status,
|
||
source: _source,
|
||
installedOnly: _installedOnly,
|
||
installableOnly: _installableOnly,
|
||
),
|
||
);
|
||
if (outcome == null) return;
|
||
setState(() {
|
||
_status = outcome.status;
|
||
_source = outcome.source;
|
||
_installedOnly = outcome.installedOnly;
|
||
_installableOnly = outcome.installableOnly;
|
||
});
|
||
_runSearch();
|
||
}
|
||
|
||
Future<void> _addRecommendedSource(_RecommendedSource s) async {
|
||
if (_addingSources.contains(s.name)) return;
|
||
setState(() => _addingSources.add(s.name));
|
||
final l = AppLocalizations.of(context)!;
|
||
try {
|
||
final list = await HubService.instance.addMcpClient(
|
||
name: s.name,
|
||
endpoint: s.endpoint,
|
||
apiKeyEnv: '',
|
||
description: s.descriptionEn,
|
||
);
|
||
final added = list.firstWhere(
|
||
(c) => c.name == s.name,
|
||
orElse: () => list.last,
|
||
);
|
||
if (!mounted) return;
|
||
// toolCount == 0 happens when the server registered fine
|
||
// but discovery returned nothing — typically a transport
|
||
// mismatch or a server that wants `notifications/initialized`
|
||
// before listing. We surface the difference so the
|
||
// operator knows whether to celebrate or to debug.
|
||
final msg = added.toolCount > 0
|
||
? l.storeProviderAddedToast(s.label, added.toolCount)
|
||
: l.storeProviderAddedZeroToast(s.label);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(msg),
|
||
duration: added.toolCount > 0
|
||
? const Duration(seconds: 4)
|
||
: const Duration(seconds: 8),
|
||
),
|
||
);
|
||
_runSearch();
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
showFaiErrorSnack(
|
||
context,
|
||
'store.provider.add',
|
||
e,
|
||
title: l.storeProviderAddFailed(s.label, ''),
|
||
);
|
||
} finally {
|
||
if (mounted) setState(() => _addingSources.remove(s.name));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Maps a store entry to its provenance bucket: 'native' for
|
||
/// non-federated entries, 'mcp' / 'n8n' / 'temporal' for federated
|
||
/// tools that flow through one of the bridges. Used by the
|
||
/// source filter chip and the per-card provenance pill so
|
||
/// operators can tell at a glance which entries come from where.
|
||
///
|
||
/// Prefers the wire-level `sourceKind` field (0.12+); falls back
|
||
/// to the legacy name-prefix heuristic for pre-0.12 hubs that
|
||
/// don't populate it.
|
||
String _sourceForItem(StoreItem i) {
|
||
if (i.sourceKind.isNotEmpty) {
|
||
switch (i.sourceKind) {
|
||
case 'bundle':
|
||
return 'native';
|
||
case 'mcp':
|
||
case 'n8n':
|
||
case 'temporal':
|
||
return i.sourceKind;
|
||
default:
|
||
return 'federated';
|
||
}
|
||
}
|
||
if (!i.isFederated) return 'native';
|
||
if (i.name.startsWith('mcp.')) return 'mcp';
|
||
if (i.name.startsWith('n8n.')) return 'n8n';
|
||
return 'federated';
|
||
}
|
||
|
||
/// Translates a wire-level category id (`bridge`, `debug`, …)
|
||
/// into a label the operator can read. Wire ids stay in the
|
||
/// data model — only the display side gets localised. Unknown
|
||
/// ids fall through unchanged so newly-added categories don't
|
||
/// vanish until we update this map.
|
||
String _categoryDisplayName(BuildContext ctx, String wire) {
|
||
final l = AppLocalizations.of(ctx)!;
|
||
switch (wire) {
|
||
case '':
|
||
return '';
|
||
case 'bridge':
|
||
return l.storeCategoryBridge;
|
||
case 'debug':
|
||
return l.storeCategoryDebug;
|
||
case 'text':
|
||
case 'text-processing':
|
||
return l.storeCategoryText;
|
||
case 'llm':
|
||
return l.storeCategoryLlm;
|
||
case 'system':
|
||
return l.storeCategorySystem;
|
||
case 'auth':
|
||
return l.storeCategoryAuth;
|
||
case 'storage':
|
||
return l.storeCategoryStorage;
|
||
case 'channel':
|
||
return l.storeCategoryChannel;
|
||
case 'web':
|
||
return l.storeCategoryWeb;
|
||
case 'orchestrator':
|
||
return l.storeCategoryOrchestrator;
|
||
default:
|
||
return wire;
|
||
}
|
||
}
|
||
|
||
/// Same idea for the `published` / `alpha` / `planned` status
|
||
/// values. Operators read "stable / experimental / coming
|
||
/// soon"; the wire keeps the strict enum it has always had.
|
||
/// Map a permission string (`net:api.example.com`,
|
||
/// `fs.read:/etc/fai`, `env:OPENAI_API_KEY`, …) to a glyph the
|
||
/// operator can read at a glance. Mirrors the table that used
|
||
/// to live in `fai_module_sheet.dart` so the Store detail-sheet
|
||
/// can render permissions in the same shape after the Modules
|
||
/// page was dropped.
|
||
IconData _iconForPermission(String permission) {
|
||
if (permission.startsWith('net:')) return Icons.public;
|
||
if (permission.startsWith('fs.read:')) return Icons.folder_open;
|
||
if (permission.startsWith('fs.write:')) return Icons.edit_note;
|
||
if (permission.startsWith('env:')) return Icons.terminal;
|
||
if (permission.startsWith('hub:')) return Icons.shield_outlined;
|
||
return Icons.lock_outline;
|
||
}
|
||
|
||
String _statusDisplayName(BuildContext ctx, String wire) {
|
||
final l = AppLocalizations.of(ctx)!;
|
||
switch (wire) {
|
||
case 'published':
|
||
return l.storeStatusPublished;
|
||
case 'alpha':
|
||
return l.storeStatusAlpha;
|
||
case 'planned':
|
||
return l.storeStatusPlanned;
|
||
default:
|
||
return wire;
|
||
}
|
||
}
|
||
|
||
/// Multi-line ask-and-search input. Drives the toolbar's
|
||
/// primary action: substring keyword search on every keystroke
|
||
/// (cheap, identical to the legacy `_SearchField`) plus an
|
||
/// LLM-routed "ask the AI" path when the operator submits a
|
||
/// question-shaped query and a System-AI is configured.
|
||
class _AskBar extends StatelessWidget {
|
||
final TextEditingController controller;
|
||
final bool thinking;
|
||
final bool aiAvailable;
|
||
final ValueChanged<String> onTextChange;
|
||
final VoidCallback onSubmit;
|
||
final VoidCallback onClear;
|
||
|
||
const _AskBar({
|
||
required this.controller,
|
||
required this.thinking,
|
||
required this.aiAvailable,
|
||
required this.onTextChange,
|
||
required this.onSubmit,
|
||
required this.onClear,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return ValueListenableBuilder<TextEditingValue>(
|
||
valueListenable: controller,
|
||
builder: (_, value, _) {
|
||
final empty = value.text.trim().isEmpty;
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: FaiSpace.md,
|
||
vertical: FaiSpace.sm,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 6),
|
||
child: Icon(
|
||
Icons.search,
|
||
size: 18,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Expanded(
|
||
// Cmd+Enter (or Ctrl+Enter on Linux) submits;
|
||
// bare Enter inserts a newline so the operator
|
||
// can write multi-line questions naturally.
|
||
child: Shortcuts(
|
||
shortcuts: <LogicalKeySet, Intent>{
|
||
LogicalKeySet(
|
||
LogicalKeyboardKey.meta,
|
||
LogicalKeyboardKey.enter,
|
||
): const _SubmitAskIntent(),
|
||
LogicalKeySet(
|
||
LogicalKeyboardKey.control,
|
||
LogicalKeyboardKey.enter,
|
||
): const _SubmitAskIntent(),
|
||
},
|
||
child: Actions(
|
||
actions: <Type, Action<Intent>>{
|
||
_SubmitAskIntent: CallbackAction<_SubmitAskIntent>(
|
||
onInvoke: (_) {
|
||
onSubmit();
|
||
return null;
|
||
},
|
||
),
|
||
},
|
||
child: TextField(
|
||
controller: controller,
|
||
autofocus: false,
|
||
minLines: 1,
|
||
maxLines: 4,
|
||
onChanged: onTextChange,
|
||
// No onSubmitted — bare Enter would
|
||
// collide with multi-line authoring.
|
||
// Submit happens via the button or
|
||
// Cmd/Ctrl+Enter.
|
||
decoration: InputDecoration(
|
||
isCollapsed: true,
|
||
contentPadding: const EdgeInsets.symmetric(vertical: 6),
|
||
hintText: aiAvailable
|
||
? l.storeAskHint
|
||
: l.storeSearchHint,
|
||
hintMaxLines: 2,
|
||
hintStyle: theme.textTheme.bodyMedium?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
border: InputBorder.none,
|
||
),
|
||
style: theme.textTheme.bodyLarge,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (!empty)
|
||
IconButton(
|
||
icon: const Icon(Icons.close, size: 16),
|
||
visualDensity: VisualDensity.compact,
|
||
tooltip: l.storeAskClear,
|
||
onPressed: onClear,
|
||
),
|
||
const SizedBox(width: FaiSpace.xs),
|
||
FilledButton.icon(
|
||
onPressed: thinking ? null : onSubmit,
|
||
icon: thinking
|
||
? const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: Icon(
|
||
aiAvailable ? Icons.auto_awesome : Icons.search,
|
||
size: 14,
|
||
),
|
||
label: Text(thinking ? l.storeAskAiThinking : l.storeAskSubmit),
|
||
style: FilledButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SubmitAskIntent extends Intent {
|
||
const _SubmitAskIntent();
|
||
}
|
||
|
||
/// Compact Filter-popover trigger. Shows a small badge with the
|
||
/// number of active filters so the operator never has to open
|
||
/// the dialog to know what's set.
|
||
class _FilterButton extends StatelessWidget {
|
||
final int activeCount;
|
||
final VoidCallback onPressed;
|
||
|
||
const _FilterButton({required this.activeCount, required this.onPressed});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return OutlinedButton.icon(
|
||
onPressed: onPressed,
|
||
icon: const Icon(Icons.tune, size: 14),
|
||
label: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(l.storeFilterLabel),
|
||
if (activeCount > 0) ...[
|
||
const SizedBox(width: FaiSpace.xs),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.primary.withValues(alpha: 0.18),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Text(
|
||
'$activeCount',
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.primary,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Outcome of `_FilterDialog`. `null` from the dialog means
|
||
/// "operator cancelled" — caller leaves filter state untouched.
|
||
class _FilterOutcome {
|
||
final String status;
|
||
final String source;
|
||
final bool installedOnly;
|
||
final bool installableOnly;
|
||
const _FilterOutcome({
|
||
required this.status,
|
||
required this.source,
|
||
required this.installedOnly,
|
||
required this.installableOnly,
|
||
});
|
||
}
|
||
|
||
/// Modal that hosts the previously-inline status / source /
|
||
/// installed chips. Lives behind a button so the toolbar stays
|
||
/// quiet — operators can browse for a long time without
|
||
/// touching filters and shouldn't have a filter row eating
|
||
/// their viewport while doing so.
|
||
class _FilterDialog extends StatefulWidget {
|
||
final String status;
|
||
final String source;
|
||
final bool installedOnly;
|
||
final bool installableOnly;
|
||
|
||
const _FilterDialog({
|
||
required this.status,
|
||
required this.source,
|
||
required this.installedOnly,
|
||
required this.installableOnly,
|
||
});
|
||
|
||
@override
|
||
State<_FilterDialog> createState() => _FilterDialogState();
|
||
}
|
||
|
||
class _FilterDialogState extends State<_FilterDialog> {
|
||
late String _status = widget.status;
|
||
late String _source = widget.source;
|
||
late bool _installedOnly = widget.installedOnly;
|
||
late bool _installableOnly = widget.installableOnly;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
Widget header(String text) => Padding(
|
||
padding: const EdgeInsets.only(bottom: 6),
|
||
child: Text(
|
||
text.toUpperCase(),
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
);
|
||
Widget chipRow(
|
||
List<({String value, String label})> items,
|
||
String selected,
|
||
void Function(String) onSelect,
|
||
) => Wrap(
|
||
spacing: FaiSpace.xs,
|
||
runSpacing: FaiSpace.xs,
|
||
children: [
|
||
for (final i in items)
|
||
_ChoiceChip(
|
||
label: i.label,
|
||
selected: selected == i.value,
|
||
onSelected: () => onSelect(i.value),
|
||
),
|
||
],
|
||
);
|
||
return AlertDialog(
|
||
title: Text(l.storeFilterLabel),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
),
|
||
content: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 460),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
header(l.storeFilterStatusGroup),
|
||
chipRow(
|
||
[
|
||
(value: '', label: l.storeStatusAll),
|
||
(value: 'published', label: l.storeStatusPublished),
|
||
(value: 'alpha', label: l.storeStatusAlpha),
|
||
(value: 'planned', label: l.storeStatusPlanned),
|
||
],
|
||
_status,
|
||
(v) => setState(() => _status = v),
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
header(l.storeFilterSourceGroup),
|
||
chipRow(
|
||
[
|
||
(value: '', label: l.storeSourceAll),
|
||
(value: 'native', label: l.storeSourceNative),
|
||
(value: 'mcp', label: l.storeSourceMcp),
|
||
(value: 'n8n', label: l.storeSourceN8n),
|
||
(value: 'temporal', label: l.storeSourceTemporal),
|
||
],
|
||
_source,
|
||
(v) => setState(() => _source = v),
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
header(l.storeFilterInstalledGroup),
|
||
Wrap(
|
||
spacing: FaiSpace.xs,
|
||
runSpacing: FaiSpace.xs,
|
||
children: [
|
||
FilterChip(
|
||
label: Text(l.storeInstallableOnly),
|
||
selected: _installableOnly,
|
||
onSelected: (v) => setState(() => _installableOnly = v),
|
||
showCheckmark: true,
|
||
),
|
||
FilterChip(
|
||
label: Text(l.storeInstalledOnly),
|
||
selected: _installedOnly,
|
||
onSelected: (v) => setState(() => _installedOnly = v),
|
||
showCheckmark: true,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () {
|
||
setState(() {
|
||
_status = '';
|
||
_source = '';
|
||
_installedOnly = false;
|
||
// Reset puts `installableOnly` back to its default
|
||
// (on) — the operator hits Reset to escape a
|
||
// custom-filter state and expects the default view
|
||
// back, not an everything-including-planned view.
|
||
_installableOnly = true;
|
||
});
|
||
},
|
||
child: Text(l.storeFilterReset),
|
||
),
|
||
const Spacer(),
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, null),
|
||
child: Text(l.buttonCancel),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(
|
||
context,
|
||
_FilterOutcome(
|
||
status: _status,
|
||
source: _source,
|
||
installedOnly: _installedOnly,
|
||
installableOnly: _installableOnly,
|
||
),
|
||
),
|
||
child: Text(l.storeFilterApply),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// One LLM-suggested module. Held as a small class (not a
|
||
/// record) because we map it through StatelessWidget params and
|
||
/// records make the type signatures noisy.
|
||
class _AiMatch {
|
||
final String name;
|
||
final String why;
|
||
const _AiMatch({required this.name, required this.why});
|
||
}
|
||
|
||
/// Card that surfaces the LLM's free-form answer plus a list of
|
||
/// matched module names. Renders only when the operator is in
|
||
/// AI mode — clearing the question collapses it.
|
||
class _AiAnswerCard extends StatelessWidget {
|
||
final bool thinking;
|
||
final String? answer;
|
||
final List<_AiMatch> matches;
|
||
final String? error;
|
||
final VoidCallback onClear;
|
||
|
||
const _AiAnswerCard({
|
||
required this.thinking,
|
||
required this.answer,
|
||
required this.matches,
|
||
required this.error,
|
||
required this.onClear,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final accent = error != null
|
||
? theme.colorScheme.error
|
||
: theme.colorScheme.primary;
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(FaiSpace.md),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
border: Border.all(color: accent.withValues(alpha: 0.3)),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(Icons.auto_awesome, size: 14, color: accent),
|
||
const SizedBox(width: FaiSpace.xs),
|
||
Text(
|
||
l.storeAskAnswerLabel,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
IconButton(
|
||
icon: const Icon(Icons.close, size: 14),
|
||
visualDensity: VisualDensity.compact,
|
||
tooltip: l.storeAskClear,
|
||
onPressed: onClear,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.xs),
|
||
if (thinking)
|
||
Row(
|
||
children: [
|
||
const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Text(
|
||
l.storeAskAiThinking,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
)
|
||
else if (error != null)
|
||
Text(
|
||
error!,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.error,
|
||
),
|
||
)
|
||
else ...[
|
||
SelectableText(
|
||
answer ?? l.storeAskNoMatches,
|
||
style: theme.textTheme.bodyMedium,
|
||
),
|
||
if (matches.isNotEmpty) ...[
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Text(
|
||
l.storeAskAnswerWhy,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
for (final m in matches)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 4),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
margin: const EdgeInsets.only(top: 6, right: 8),
|
||
width: 6,
|
||
height: 6,
|
||
decoration: BoxDecoration(
|
||
color: accent,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Wrap(
|
||
spacing: FaiSpace.xs,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
children: [
|
||
Text(
|
||
m.name,
|
||
style: FaiTheme.mono(
|
||
size: 12,
|
||
weight: FontWeight.w600,
|
||
color: theme.colorScheme.onSurface,
|
||
),
|
||
),
|
||
Text(
|
||
'— ${m.why}',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Compact category picker that lives in the AppBar's actions
|
||
/// row. Replaces the older horizontally-scrolling chip strip
|
||
/// because chips ate a full row of viewport for what is in
|
||
/// practice a one-click operation; a dropdown is faster to
|
||
/// reach and never grows past the toolbar.
|
||
class _CategoryDropdown extends StatelessWidget {
|
||
final List<String> categories;
|
||
final String selected;
|
||
final ValueChanged<String> onSelected;
|
||
|
||
const _CategoryDropdown({
|
||
required this.categories,
|
||
required this.selected,
|
||
required this.onSelected,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final activeLabel = selected.isEmpty
|
||
? l.storeCategoryAll
|
||
: _categoryDisplayName(context, selected);
|
||
return PopupMenuButton<String>(
|
||
tooltip: l.storeCategoryLabel,
|
||
initialValue: selected,
|
||
onSelected: onSelected,
|
||
itemBuilder: (_) => [
|
||
PopupMenuItem(value: '', child: Text(l.storeCategoryAll)),
|
||
const PopupMenuDivider(),
|
||
for (final c in categories)
|
||
PopupMenuItem(
|
||
value: c,
|
||
child: Text(_categoryDisplayName(context, c)),
|
||
),
|
||
],
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: FaiSpace.sm,
|
||
vertical: 6,
|
||
),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.folder_outlined,
|
||
size: 14,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(activeLabel, style: theme.textTheme.bodySmall),
|
||
Icon(
|
||
Icons.arrow_drop_down,
|
||
size: 16,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Builds the JSON-output prompt fed to the System-AI when the
|
||
/// operator submits a question-shaped query. Pinned to a strict
|
||
/// reply schema so the parser has stable ground to walk on; the
|
||
/// prompt also hand-encodes the operator's locale so the LLM
|
||
/// answers in the right language.
|
||
String _buildAiSearchPrompt(
|
||
String query,
|
||
List<Map<String, dynamic>> modules,
|
||
String locale,
|
||
) {
|
||
final lang = locale == 'de' ? 'German' : 'English';
|
||
return '''You are the 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": "<one short sentence in $lang explaining the recommendation>",
|
||
"matches": [
|
||
{"name": "<exact module name from the list above>", "why": "<one short phrase in $lang>"}
|
||
]
|
||
}
|
||
|
||
Up to 5 matches, ordered by best fit first. Use only module names that appear in the list above. If nothing fits, return matches: [] and explain in answer.''';
|
||
}
|
||
|
||
/// Pulls the JSON object out of the LLM's reply. Models often
|
||
/// add chatty preamble or markdown fences; we slice from the
|
||
/// first `{` to the last `}` and try to decode that. Unknown
|
||
/// module names are dropped silently — the operator should not
|
||
/// see hallucinated entries that don't actually exist.
|
||
({String answer, List<_AiMatch> matches}) _parseAiSearchResponse(
|
||
String text,
|
||
List<StoreItem> all,
|
||
) {
|
||
final start = text.indexOf('{');
|
||
final end = text.lastIndexOf('}');
|
||
if (start < 0 || end <= start) {
|
||
return (answer: text.trim(), matches: const <_AiMatch>[]);
|
||
}
|
||
try {
|
||
final dynamic decoded = jsonDecode(text.substring(start, end + 1));
|
||
if (decoded is! Map) {
|
||
return (answer: text.trim(), matches: const <_AiMatch>[]);
|
||
}
|
||
final answer = (decoded['answer'] ?? '').toString();
|
||
final matches = <_AiMatch>[];
|
||
final knownNames = all.map((e) => e.name).toSet();
|
||
final list = (decoded['matches'] as List?) ?? const [];
|
||
for (final m in list) {
|
||
if (m is Map) {
|
||
final name = (m['name'] ?? '').toString();
|
||
final why = (m['why'] ?? '').toString();
|
||
if (knownNames.contains(name)) {
|
||
matches.add(_AiMatch(name: name, why: why));
|
||
}
|
||
}
|
||
}
|
||
return (answer: answer, matches: matches);
|
||
} catch (_) {
|
||
return (answer: text.trim(), matches: const <_AiMatch>[]);
|
||
}
|
||
}
|
||
|
||
class _ChoiceChip extends StatelessWidget {
|
||
final String label;
|
||
final bool selected;
|
||
final VoidCallback onSelected;
|
||
|
||
const _ChoiceChip({
|
||
required this.label,
|
||
required this.selected,
|
||
required this.onSelected,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(right: FaiSpace.xs),
|
||
child: ChoiceChip(
|
||
label: Text(label),
|
||
selected: selected,
|
||
onSelected: (_) => onSelected(),
|
||
showCheckmark: false,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Responsive grid — wraps to as many columns as the viewport
|
||
/// allows. Cards are click-to-expand; install is also a top-
|
||
/// level action so single-click installs are still possible.
|
||
class _StoreGrid extends StatelessWidget {
|
||
final List<StoreItem> items;
|
||
final String locale;
|
||
|
||
/// Map of installed module name → installed version. Cards
|
||
/// compare against `bestVersion` to render an "Update" badge
|
||
/// when the store offers something newer.
|
||
final Map<String, String> installedVersions;
|
||
final ValueChanged<StoreItem> onTap;
|
||
final ValueChanged<StoreItem> onInstall;
|
||
|
||
const _StoreGrid({
|
||
required this.items,
|
||
required this.locale,
|
||
required this.installedVersions,
|
||
required this.onTap,
|
||
required this.onInstall,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
const minCardWidth = 360.0;
|
||
final cols = (constraints.maxWidth / minCardWidth).floor().clamp(1, 4);
|
||
// shrinkWrap + NeverScrollable lets the grid sit inside
|
||
// the page-level SingleChildScrollView so editorial
|
||
// chrome and the grid scroll as one continuous surface
|
||
// (App-Store / Play-Store behaviour). Without this, the
|
||
// inner grid claims its own scroll viewport and the
|
||
// outer Column overflows on small windows.
|
||
return GridView.builder(
|
||
padding: EdgeInsets.zero,
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: cols,
|
||
mainAxisSpacing: FaiSpace.md,
|
||
crossAxisSpacing: FaiSpace.md,
|
||
mainAxisExtent: 168,
|
||
),
|
||
itemCount: items.length,
|
||
itemBuilder: (context, i) => _StoreCard(
|
||
item: items[i],
|
||
locale: locale,
|
||
installedVersion: installedVersions[items[i].name],
|
||
onTap: () => onTap(items[i]),
|
||
onInstall: () => onInstall(items[i]),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Horizontally-scrolling band of editorial picks, rendered
|
||
/// only when the operator hasn't filtered the result set.
|
||
/// Each tile is a hero version of the regular store card —
|
||
/// bigger icon, gradient backdrop, prominent install button.
|
||
/// Curated quarterly via the bundled `seed.yaml` `featured:`
|
||
/// list so the choice can be reviewed in code review.
|
||
class _FeaturedStrip extends StatelessWidget {
|
||
final List<StoreItem> items;
|
||
final String locale;
|
||
final ValueChanged<StoreItem> onTap;
|
||
final ValueChanged<StoreItem> onInstall;
|
||
|
||
const _FeaturedStrip({
|
||
required this.items,
|
||
required this.locale,
|
||
required this.onTap,
|
||
required this.onInstall,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(Icons.star, size: 14, color: FaiColors.success),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
l.storeFeatured,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Text(
|
||
l.storeFeaturedHint,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
SizedBox(
|
||
// Tiles render larger than regular grid cards
|
||
// (which are 168 × ~360) so the editorial picks
|
||
// visibly stand out. Same hierarchy you see on app
|
||
// stores: hero band before the dense list.
|
||
height: 220,
|
||
child: ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
itemCount: items.length,
|
||
separatorBuilder: (_, _) => const SizedBox(width: FaiSpace.md),
|
||
itemBuilder: (context, i) {
|
||
final item = items[i];
|
||
return SizedBox(
|
||
width: 480,
|
||
child: _FeaturedTile(
|
||
item: item,
|
||
locale: locale,
|
||
onTap: () => onTap(item),
|
||
onInstall: () => onInstall(item),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _FeaturedTile extends StatelessWidget {
|
||
final StoreItem item;
|
||
final String locale;
|
||
final VoidCallback onTap;
|
||
final VoidCallback onInstall;
|
||
|
||
const _FeaturedTile({
|
||
required this.item,
|
||
required this.locale,
|
||
required this.onTap,
|
||
required this.onInstall,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final tagline = locale == 'de' && item.taglineDe.isNotEmpty
|
||
? item.taglineDe
|
||
: item.taglineEn;
|
||
final installable = item.status == 'published' || item.status == 'alpha';
|
||
return Material(
|
||
color: theme.colorScheme.surfaceContainer,
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(FaiSpace.lg),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
gradient: LinearGradient(
|
||
colors: [
|
||
theme.colorScheme.primary.withValues(alpha: 0.14),
|
||
theme.colorScheme.surfaceContainer.withValues(alpha: 0.0),
|
||
],
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
CircleAvatar(
|
||
radius: 24,
|
||
backgroundColor: theme.colorScheme.primary.withValues(
|
||
alpha: 0.18,
|
||
),
|
||
child: Icon(
|
||
_iconForCategory(item.category),
|
||
size: 26,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
),
|
||
const SizedBox(width: FaiSpace.md),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
item.name,
|
||
style: theme.textTheme.titleLarge?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
if (item.category.isNotEmpty)
|
||
Text(
|
||
_categoryDisplayName(context, item.category),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Icon(Icons.star, size: 18, color: FaiColors.success),
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (item.isFederated)
|
||
// Federated items get tagline copy straight
|
||
// from the upstream MCP / n8n server, which
|
||
// is English regardless of Studio's locale.
|
||
// The [EN] pill is the honest signal until
|
||
// the planned `studio.translate` plugin
|
||
// rewrites it on the fly.
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 4),
|
||
child: FaiEnBadge.forContext(context),
|
||
),
|
||
Expanded(
|
||
child: Text(
|
||
tagline.isEmpty ? l.storeNoTagline : tagline,
|
||
style: theme.textTheme.bodyLarge,
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Row(
|
||
children: [
|
||
if (item.bestVersion.isNotEmpty)
|
||
Tooltip(
|
||
// Federated upstream services (MCP, n8n,
|
||
// Temporal workflows) don't honour semver,
|
||
// so their version pill carries a "~"
|
||
// prefix + tooltip noting that the value
|
||
// is advisory only.
|
||
message: item.isFederated
|
||
? l.storeAdvisoryVersionTooltip
|
||
: '',
|
||
child: FaiPill(
|
||
label: item.isFederated
|
||
? '~v${item.bestVersion}'
|
||
: 'v${item.bestVersion}',
|
||
tone: FaiPillTone.neutral,
|
||
monospace: true,
|
||
),
|
||
),
|
||
const SizedBox(width: FaiSpace.xs),
|
||
if (item.status.isNotEmpty)
|
||
FaiPill(
|
||
label: _statusDisplayName(context, item.status),
|
||
tone: _toneForStatus(item.status),
|
||
),
|
||
const Spacer(),
|
||
if (item.installed)
|
||
FaiPill(
|
||
label: l.storePillInstalled,
|
||
tone: FaiPillTone.success,
|
||
icon: Icons.check,
|
||
)
|
||
else if (installable)
|
||
FilledButton.icon(
|
||
onPressed: onInstall,
|
||
icon: const Icon(Icons.download, size: 16),
|
||
label: Text(l.buttonInstall),
|
||
)
|
||
else
|
||
// Status is `planned` (or another non-installable
|
||
// wire value). Without an explicit pill here the
|
||
// featured tile would just go silent, leaving the
|
||
// operator wondering why the card has no action.
|
||
FaiPill(
|
||
label: l.storePillComingSoon,
|
||
tone: FaiPillTone.neutral,
|
||
icon: Icons.schedule,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _StoreCard extends StatelessWidget {
|
||
final StoreItem item;
|
||
final String locale;
|
||
|
||
/// Installed version for this name, when the operator has it.
|
||
/// `null` for not-installed entries.
|
||
final String? installedVersion;
|
||
final VoidCallback onTap;
|
||
final VoidCallback onInstall;
|
||
|
||
const _StoreCard({
|
||
required this.item,
|
||
required this.locale,
|
||
required this.installedVersion,
|
||
required this.onTap,
|
||
required this.onInstall,
|
||
});
|
||
|
||
/// True iff the operator has this module installed AND the
|
||
/// store-index offers a strictly-newer best_version. Uses a
|
||
/// dotted-numeric semver-light compare so 0.10.5 > 0.9.99.
|
||
bool get _hasUpdate {
|
||
final installed = installedVersion;
|
||
final best = item.bestVersion;
|
||
if (installed == null || best.isEmpty) return false;
|
||
if (installed == best) return false;
|
||
return _versionCmp(best, installed) > 0;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final tagline = locale == 'de' && item.taglineDe.isNotEmpty
|
||
? item.taglineDe
|
||
: item.taglineEn;
|
||
final installable = item.status == 'published' || item.status == 'alpha';
|
||
// 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,
|
||
child: Material(
|
||
color: theme.colorScheme.surfaceContainer,
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(FaiSpace.md),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
CircleAvatar(
|
||
radius: 18,
|
||
backgroundColor: theme.colorScheme.primary.withValues(
|
||
alpha: 0.12,
|
||
),
|
||
child: Icon(
|
||
_iconForCategory(item.category),
|
||
size: 18,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
item.name,
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
Text(
|
||
item.category.isEmpty
|
||
? '—'
|
||
: _categoryDisplayName(context, item.category),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (_hasUpdate)
|
||
Tooltip(
|
||
message: l.storeUpdateTooltip(
|
||
installedVersion ?? '?',
|
||
item.bestVersion,
|
||
),
|
||
child: FaiPill(
|
||
label: l.storePillUpdate,
|
||
tone: FaiPillTone.warning,
|
||
icon: Icons.system_update_alt,
|
||
),
|
||
)
|
||
else if (item.installed)
|
||
FaiPill(
|
||
label: l.storePillInstalled,
|
||
tone: FaiPillTone.success,
|
||
icon: Icons.check,
|
||
)
|
||
else if (item.status.isNotEmpty)
|
||
FaiPill(
|
||
label: _statusDisplayName(context, item.status),
|
||
tone: _toneForStatus(item.status),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Expanded(
|
||
child: Text(
|
||
tagline.isEmpty ? l.storeNoTagline : tagline,
|
||
style: theme.textTheme.bodySmall,
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Row(
|
||
children: [
|
||
if (item.bestVersion.isNotEmpty) ...[
|
||
Tooltip(
|
||
message: item.isFederated
|
||
? l.storeAdvisoryVersionTooltip
|
||
: '',
|
||
child: FaiPill(
|
||
label: item.isFederated
|
||
? '~v${item.bestVersion}'
|
||
: 'v${item.bestVersion}',
|
||
tone: FaiPillTone.neutral,
|
||
monospace: true,
|
||
),
|
||
),
|
||
const SizedBox(width: FaiSpace.xs),
|
||
],
|
||
_ProvenancePill(item: item),
|
||
const Spacer(),
|
||
if (_hasUpdate)
|
||
FilledButton.icon(
|
||
onPressed: onInstall,
|
||
icon: const Icon(Icons.system_update_alt, size: 14),
|
||
label: Text(l.storeUpdateButton(item.bestVersion)),
|
||
style: FilledButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
)
|
||
else if (item.installed)
|
||
TextButton.icon(
|
||
onPressed: onTap,
|
||
icon: const Icon(Icons.info_outline, size: 14),
|
||
label: Text(l.buttonDetails),
|
||
)
|
||
else if (installable)
|
||
FilledButton.icon(
|
||
onPressed: onInstall,
|
||
icon: const Icon(Icons.download, size: 14),
|
||
label: Text(l.buttonInstall),
|
||
style: FilledButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
)
|
||
else
|
||
OutlinedButton.icon(
|
||
onPressed: onTap,
|
||
icon: const Icon(Icons.info_outline, size: 14),
|
||
label: Text(l.buttonDetails),
|
||
style: OutlinedButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Per-module detail sheet. Renders the full bilingual
|
||
/// description, the capabilities + services the module
|
||
/// requires, plus repository / docs links. Returns `true` when
|
||
/// state changed (install / uninstall) so the caller refreshes.
|
||
class _StoreDetailSheet extends StatefulWidget {
|
||
final StoreItem item;
|
||
final String locale;
|
||
|
||
const _StoreDetailSheet({required this.item, required this.locale});
|
||
|
||
static Future<bool> show(
|
||
BuildContext context,
|
||
StoreItem item,
|
||
String locale,
|
||
) async {
|
||
final r = await showModalBottomSheet<bool>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
|
||
),
|
||
builder: (_) => _StoreDetailSheet(item: item, locale: locale),
|
||
);
|
||
return r ?? false;
|
||
}
|
||
|
||
@override
|
||
State<_StoreDetailSheet> createState() => _StoreDetailSheetState();
|
||
}
|
||
|
||
class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||
/// Locale comes from MaterialApp via `Localizations.localeOf`;
|
||
/// the per-sheet state from before is no longer needed.
|
||
String get _locale => Localizations.localeOf(context).languageCode;
|
||
bool _busy = false;
|
||
String? _toast;
|
||
|
||
/// `null` while the initial probe is in flight; non-null once
|
||
/// `readInstalledModuleDocs` has returned. The probe is cheap —
|
||
/// a single file stat on disk — so we run it eagerly when the
|
||
/// sheet opens for an installed module instead of gating it
|
||
/// behind a "Load docs" click.
|
||
({String state, String text, String sourcePath})? _docsResult;
|
||
bool _docsProbed = false;
|
||
|
||
/// Live module-info for installed entries: declared
|
||
/// permissions and on-disk directory. Pulled lazily so
|
||
/// not-installed entries don't run an unnecessary RPC. Stays
|
||
/// null when the fetch fails — the corresponding sections
|
||
/// just hide rather than show a spinner inside a hover sheet.
|
||
ModuleDetail? _moduleDetail;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
if (widget.item.installed) {
|
||
_loadModuleDetail();
|
||
}
|
||
}
|
||
|
||
@override
|
||
void didChangeDependencies() {
|
||
super.didChangeDependencies();
|
||
// Locale is read off `Localizations`, which is only ready in
|
||
// didChangeDependencies, not initState.
|
||
if (widget.item.installed && !_docsProbed) {
|
||
_docsProbed = true;
|
||
_probeInstalledDocs();
|
||
}
|
||
}
|
||
|
||
Future<void> _probeInstalledDocs() async {
|
||
final locale = Localizations.localeOf(context).languageCode;
|
||
try {
|
||
final r = await HubService.instance.readInstalledModuleDocs(
|
||
widget.item.name,
|
||
locale: locale,
|
||
);
|
||
if (!mounted) return;
|
||
setState(() => _docsResult = r);
|
||
} catch (_) {
|
||
// Treat probe failures as "no docs available" — the section
|
||
// simply hides, the rest of the sheet still works.
|
||
if (!mounted) return;
|
||
setState(
|
||
() => _docsResult = (state: 'no_docs', text: '', sourcePath: ''),
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<void> _loadModuleDetail() async {
|
||
try {
|
||
final detail = await HubService.instance.moduleInfo(widget.item.name);
|
||
if (!mounted) return;
|
||
setState(() => _moduleDetail = detail);
|
||
} catch (_) {
|
||
// Silent — sections stay hidden, the sheet still works.
|
||
}
|
||
}
|
||
|
||
Future<void> _install() async {
|
||
setState(() {
|
||
_busy = true;
|
||
_toast = null;
|
||
});
|
||
try {
|
||
final r = await HubService.instance.installModule(
|
||
source: widget.item.name,
|
||
);
|
||
if (!mounted) return;
|
||
final l = AppLocalizations.of(context)!;
|
||
setState(() {
|
||
_busy = false;
|
||
_toast = l.storeInstalledToast(r.name, r.version);
|
||
});
|
||
Navigator.pop(context, true);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
final l = AppLocalizations.of(context)!;
|
||
setState(() {
|
||
_busy = false;
|
||
_toast = l.storeInstallFailedToast(e.toString());
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _uninstall() async {
|
||
final l = AppLocalizations.of(context)!;
|
||
// Multi-version aware: ask which version to remove when
|
||
// more than one is installed side-by-side. Otherwise the
|
||
// hub picks "highest version" and the operator can be
|
||
// surprised by what disappeared.
|
||
final versions = await HubService.instance.installedVersions(
|
||
widget.item.name,
|
||
);
|
||
if (!mounted) return;
|
||
String? targetVersion;
|
||
if (versions.length > 1) {
|
||
targetVersion = await showDialog<String>(
|
||
context: context,
|
||
builder: (ctx) => _StoreUninstallVersionPickerDialog(
|
||
moduleName: widget.item.name,
|
||
versions: versions,
|
||
),
|
||
);
|
||
if (targetVersion == null || !mounted) return;
|
||
}
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: Text(l.storeUninstallTitle),
|
||
content: Text(l.storeUninstallBody(widget.item.name)),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: Text(l.buttonCancel),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||
foregroundColor: Theme.of(ctx).colorScheme.onError,
|
||
),
|
||
child: Text(l.buttonUninstall),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (ok != true || !mounted) return;
|
||
setState(() {
|
||
_busy = true;
|
||
_toast = null;
|
||
});
|
||
try {
|
||
final r = await HubService.instance.uninstallModule(
|
||
widget.item.name,
|
||
version: targetVersion ?? '',
|
||
);
|
||
if (!mounted) return;
|
||
final l2 = AppLocalizations.of(context)!;
|
||
setState(() {
|
||
_busy = false;
|
||
_toast = l2.storeUninstalledToast(r.name, r.version);
|
||
});
|
||
Navigator.pop(context, true);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
final l2 = AppLocalizations.of(context)!;
|
||
setState(() {
|
||
_busy = false;
|
||
_toast = l2.storeUninstallFailedToast(e.toString());
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _openRepository() async {
|
||
if (widget.item.repository.isEmpty) return;
|
||
final r = await SystemActions.openInOs(widget.item.repository);
|
||
if (!mounted) return;
|
||
if (!r.ok) {
|
||
showFaiErrorSnack(
|
||
context,
|
||
'store.repository.open',
|
||
r.stderr.isEmpty ? 'open failed' : r.stderr,
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final item = widget.item;
|
||
final tagline = _locale == 'de' && item.taglineDe.isNotEmpty
|
||
? item.taglineDe
|
||
: item.taglineEn;
|
||
final description = _locale == 'de' && item.descriptionDe.isNotEmpty
|
||
? item.descriptionDe
|
||
: item.descriptionEn;
|
||
final installable = item.status == 'published' || item.status == 'alpha';
|
||
final maxHeight = MediaQuery.of(context).size.height * 0.85;
|
||
|
||
return ConstrainedBox(
|
||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
||
child: Container(
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.outlineVariant,
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
),
|
||
Flexible(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
FaiSpace.xxl,
|
||
FaiSpace.md,
|
||
FaiSpace.xxl,
|
||
FaiSpace.xxl,
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_ModuleIcon(
|
||
iconUrl: item.iconUrl,
|
||
category: item.category,
|
||
radius: 28,
|
||
),
|
||
const SizedBox(width: FaiSpace.lg),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
item.name,
|
||
style: theme.textTheme.headlineSmall?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Wrap(
|
||
spacing: FaiSpace.xs,
|
||
runSpacing: 4,
|
||
children: [
|
||
if (item.bestVersion.isNotEmpty)
|
||
Tooltip(
|
||
message: item.isFederated
|
||
? l.storeAdvisoryVersionTooltip
|
||
: '',
|
||
child: FaiPill(
|
||
label: item.isFederated
|
||
? '~v${item.bestVersion}'
|
||
: 'v${item.bestVersion}',
|
||
tone: FaiPillTone.neutral,
|
||
monospace: true,
|
||
),
|
||
),
|
||
if (item.status.isNotEmpty)
|
||
FaiPill(
|
||
label: _statusDisplayName(
|
||
context,
|
||
item.status,
|
||
),
|
||
tone: _toneForStatus(item.status),
|
||
),
|
||
if (item.category.isNotEmpty)
|
||
FaiPill(
|
||
label: _categoryDisplayName(
|
||
context,
|
||
item.category,
|
||
),
|
||
tone: FaiPillTone.neutral,
|
||
),
|
||
if (item.installed)
|
||
FaiPill(
|
||
label: l.storePillInstalled,
|
||
tone: FaiPillTone.success,
|
||
icon: Icons.check,
|
||
),
|
||
if (item.license.isNotEmpty)
|
||
FaiPill(
|
||
label: item.license,
|
||
tone: FaiPillTone.neutral,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// Locale follows the app-wide setting
|
||
// from the sidebar; the detail sheet
|
||
// does not host its own toggle anymore.
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
if (tagline.isNotEmpty) ...[
|
||
Text(
|
||
tagline,
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
if (description.isNotEmpty) ...[
|
||
if (item.isFederated) ...[
|
||
// Description copy was supplied by the
|
||
// upstream server, not by Studio's
|
||
// localisation pipeline — flag it.
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 6),
|
||
child: FaiEnBadge.forContext(context),
|
||
),
|
||
],
|
||
SelectableText(
|
||
description,
|
||
style: theme.textTheme.bodyMedium,
|
||
),
|
||
const SizedBox(height: FaiSpace.xl),
|
||
],
|
||
if (item.tags.isNotEmpty) ...[
|
||
_SectionHeader(l.storeSectionTags),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Wrap(
|
||
spacing: FaiSpace.xs,
|
||
runSpacing: FaiSpace.xs,
|
||
children: [
|
||
for (final t in item.tags)
|
||
FaiPill(label: t, tone: FaiPillTone.neutral),
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
if (item.requiresCapabilities.isNotEmpty) ...[
|
||
_SectionHeader(l.storeSectionRequiredCapabilities),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Wrap(
|
||
spacing: FaiSpace.xs,
|
||
runSpacing: FaiSpace.xs,
|
||
children: [
|
||
for (final c in item.requiresCapabilities)
|
||
FaiPill(
|
||
label: c,
|
||
tone: FaiPillTone.accent,
|
||
monospace: true,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
if (item.requiresServices.isNotEmpty) ...[
|
||
_SectionHeader(l.storeSectionRequiredHostServices),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Wrap(
|
||
spacing: FaiSpace.xs,
|
||
runSpacing: FaiSpace.xs,
|
||
children: [
|
||
for (final s in item.requiresServices)
|
||
FaiPill(
|
||
label: s,
|
||
tone: FaiPillTone.warning,
|
||
monospace: true,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
// Live-from-hub sections for installed
|
||
// modules. Folded in here in v0.35.0 when the
|
||
// standalone Modules page got dropped — its
|
||
// unique value (declared permissions, on-disk
|
||
// directory) belongs in the same surface
|
||
// operators already use for everything else
|
||
// about a module.
|
||
if (item.installed && _moduleDetail != null) ...[
|
||
_SectionHeader(l.storeSectionPermissions),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
if (_moduleDetail!.permissions.isEmpty)
|
||
Text(
|
||
l.storeSectionPermissionsNone,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
)
|
||
else
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
for (final p in _moduleDetail!.permissions)
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
_iconForPermission(p),
|
||
size: 14,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
SelectableText(
|
||
p,
|
||
style: FaiTheme.mono(
|
||
size: 12,
|
||
color: theme.colorScheme.onSurface,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
_SectionHeader(l.storeSectionDirectory),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
SelectableText(
|
||
_moduleDetail!.directory,
|
||
style: FaiTheme.mono(
|
||
size: 11,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
if (item.screenshotUrls.isNotEmpty) ...[
|
||
_SectionHeader(l.storeSectionScreenshots),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
SizedBox(
|
||
height: 220,
|
||
child: ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
itemCount: item.screenshotUrls.length,
|
||
separatorBuilder: (_, _) =>
|
||
const SizedBox(width: FaiSpace.sm),
|
||
itemBuilder: (context, i) =>
|
||
_Screenshot(url: item.screenshotUrls[i]),
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
if (_docsResult?.state == 'found') ...[
|
||
_SectionHeader(l.storeSectionDocumentation),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
_DocsPanel(text: _docsResult!.text),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
if (item.repository.isNotEmpty) ...[
|
||
_SectionHeader(l.storeSectionSource),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
InkWell(
|
||
onTap: _openRepository,
|
||
child: Row(
|
||
children: [
|
||
const Icon(Icons.open_in_new, size: 14),
|
||
const SizedBox(width: FaiSpace.xs),
|
||
Flexible(
|
||
child: Text(
|
||
item.repository,
|
||
style: FaiTheme.mono(
|
||
size: 11,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
if (_toast != null) ...[
|
||
Container(
|
||
padding: const EdgeInsets.all(FaiSpace.sm),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
border: Border.all(
|
||
color: theme.colorScheme.outlineVariant,
|
||
),
|
||
),
|
||
child: SelectableText(
|
||
_toast!,
|
||
style: FaiTheme.mono(size: 11),
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
],
|
||
Row(
|
||
children: [
|
||
const Spacer(),
|
||
if (item.installed) ...[
|
||
OutlinedButton.icon(
|
||
onPressed: _busy ? null : _uninstall,
|
||
icon: _busy
|
||
? const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
),
|
||
)
|
||
: const Icon(Icons.delete_outline, size: 16),
|
||
label: Text(l.buttonUninstall),
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: theme.colorScheme.error,
|
||
side: BorderSide(
|
||
color: theme.colorScheme.error.withValues(
|
||
alpha: 0.5,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
] else if (installable)
|
||
FilledButton.icon(
|
||
onPressed: _busy ? null : _install,
|
||
icon: _busy
|
||
? const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
),
|
||
)
|
||
: const Icon(Icons.download, size: 16),
|
||
label: Text(
|
||
_busy ? l.storeInstallingButton : l.buttonInstall,
|
||
),
|
||
)
|
||
else ...[
|
||
// Inline hint replaces the previous
|
||
// tooltip-only affordance: the operator
|
||
// sees the reason without having to hover.
|
||
Expanded(
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: FaiSpace.md,
|
||
vertical: FaiSpace.sm,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainer,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
border: Border.all(
|
||
color: theme.colorScheme.outlineVariant,
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.schedule,
|
||
size: 16,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Expanded(
|
||
child: Text(
|
||
l.storeNotInstallableInline(item.status),
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SectionHeader extends StatelessWidget {
|
||
final String text;
|
||
const _SectionHeader(this.text);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Text(
|
||
text.toUpperCase(),
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
fontSize: 10,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _InstallProgressDialog extends StatefulWidget {
|
||
final StoreItem item;
|
||
|
||
const _InstallProgressDialog({required this.item});
|
||
|
||
@override
|
||
State<_InstallProgressDialog> createState() => _InstallProgressDialogState();
|
||
}
|
||
|
||
class _InstallProgressDialogState extends State<_InstallProgressDialog> {
|
||
late final Future<({String name, String version})> _future;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
// Capability-name install: hub resolves the wasm_url from the
|
||
// bundled store-index. No source prompt needed for entries
|
||
// whose seed.yaml already has wasm_url.
|
||
_future = HubService.instance.installModule(source: widget.item.name);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return AlertDialog(
|
||
title: Text(l.storeInstallProgressTitle(widget.item.name)),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
),
|
||
content: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 360),
|
||
child: FutureBuilder<({String name, String version})>(
|
||
future: _future,
|
||
builder: (context, snap) {
|
||
if (snap.connectionState == ConnectionState.waiting) {
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const SizedBox(height: FaiSpace.md),
|
||
const CircularProgressIndicator(),
|
||
const SizedBox(height: FaiSpace.md),
|
||
Text(
|
||
l.storeInstallProgressBody,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
if (snap.hasError) {
|
||
return SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.error_outline,
|
||
color: theme.colorScheme.error,
|
||
size: 32,
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
FaiErrorBox(
|
||
error: snap.error,
|
||
isError: true,
|
||
maxHeight: 200,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
final r = snap.data!;
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.check_circle_outline,
|
||
size: 32,
|
||
color: FaiColors.success,
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
Text(
|
||
l.storeInstalledToast(r.name, r.version),
|
||
style: theme.textTheme.titleMedium,
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, true),
|
||
child: Text(l.buttonClose),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
FaiPillTone _toneForStatus(String s) {
|
||
switch (s) {
|
||
case 'published':
|
||
return FaiPillTone.success;
|
||
case 'alpha':
|
||
return FaiPillTone.warning;
|
||
case 'planned':
|
||
return FaiPillTone.neutral;
|
||
default:
|
||
return FaiPillTone.neutral;
|
||
}
|
||
}
|
||
|
||
IconData _iconForCategory(String category) {
|
||
switch (category) {
|
||
case 'llm':
|
||
return Icons.psychology_outlined;
|
||
case 'text':
|
||
case 'text-processing':
|
||
return Icons.description_outlined;
|
||
case 'system':
|
||
return Icons.settings_outlined;
|
||
case 'orchestrator':
|
||
return Icons.account_tree_outlined;
|
||
case 'debug':
|
||
return Icons.bug_report_outlined;
|
||
case 'auth':
|
||
return Icons.lock_outline;
|
||
case 'storage':
|
||
return Icons.storage_outlined;
|
||
case 'channel':
|
||
return Icons.forum_outlined;
|
||
default:
|
||
return Icons.extension_outlined;
|
||
}
|
||
}
|
||
|
||
/// Renders the module's inline `MODULE.md` (or its localized
|
||
/// sibling) in the same theme as the rest of Studio. The markdown
|
||
/// body is supplied by the parent — `_DocsPanel` itself stays a
|
||
/// pure renderer with no async / no error handling.
|
||
///
|
||
/// Blockquote + table-cell decoration is overridden because
|
||
/// flutter_markdown's default styles hardcode
|
||
/// `Colors.blue.shade100`, which is unreadable on a dark theme.
|
||
class _DocsPanel extends StatelessWidget {
|
||
final String text;
|
||
|
||
const _DocsPanel({required this.text});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
// No maxHeight + NeverScrollableScrollPhysics so the docs
|
||
// section participates in the outer sheet's scroll instead
|
||
// of opening its own viewport. The previous nested-scroll
|
||
// form trapped the reader on a 480 px window with two
|
||
// scrollbars fighting for the wheel.
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(FaiSpace.md),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
child: Markdown(
|
||
data: text,
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
selectable: true,
|
||
onTapLink: (text, href, title) async {
|
||
if (href == null || href.isEmpty) return;
|
||
await SystemActions.openInOs(href);
|
||
},
|
||
styleSheet: FaiTheme.markdownStyle(theme),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Lightweight semver compare used by the "update available"
|
||
/// badge. Returns negative if a < b, 0 if equal, positive if
|
||
/// a > b. Splits on '.' and compares integer chunks; falls back
|
||
/// to lexicographic compare on non-numeric segments so
|
||
/// `0.10.0-rc.1` still sorts sensibly. Good enough for the
|
||
/// store's coarse "is there a newer publish" question — the
|
||
/// hub does its own strict resolution at install time.
|
||
int _versionCmp(String a, String b) {
|
||
final aParts = a.split('.');
|
||
final bParts = b.split('.');
|
||
final n = aParts.length > bParts.length ? aParts.length : bParts.length;
|
||
for (var i = 0; i < n; i++) {
|
||
final ap = i < aParts.length ? aParts[i] : '0';
|
||
final bp = i < bParts.length ? bParts[i] : '0';
|
||
final ai = int.tryParse(ap);
|
||
final bi = int.tryParse(bp);
|
||
if (ai != null && bi != null) {
|
||
if (ai != bi) return ai.compareTo(bi);
|
||
} else {
|
||
final cmp = ap.compareTo(bp);
|
||
if (cmp != 0) return cmp;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/// Module icon: renders the explicit icon URL when set, falls
|
||
/// back to the category-derived `Icons.X` glyph in a colored
|
||
/// circle when the URL is empty or fails to load. Wraps the
|
||
/// network image in `frameBuilder` so a load failure doesn't
|
||
/// leave a broken-image rectangle in the hero header.
|
||
class _ModuleIcon extends StatelessWidget {
|
||
final String iconUrl;
|
||
final String category;
|
||
final double radius;
|
||
|
||
const _ModuleIcon({
|
||
required this.iconUrl,
|
||
required this.category,
|
||
required this.radius,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final fallback = CircleAvatar(
|
||
radius: radius,
|
||
backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.12),
|
||
child: Icon(
|
||
_iconForCategory(category),
|
||
size: radius * 1.0,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
);
|
||
if (iconUrl.isEmpty) return fallback;
|
||
return ClipOval(
|
||
child: SizedBox(
|
||
width: radius * 2,
|
||
height: radius * 2,
|
||
child: Image.network(
|
||
iconUrl,
|
||
fit: BoxFit.cover,
|
||
errorBuilder: (_, _, _) => fallback,
|
||
loadingBuilder: (_, child, progress) =>
|
||
progress == null ? child : fallback,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// One screenshot tile in the detail-sheet strip. Click opens
|
||
/// the image full-size via the OS handler — keeps Studio
|
||
/// chromeless. Renders a placeholder on load failure rather
|
||
/// than the default broken-image rectangle.
|
||
class _Screenshot extends StatelessWidget {
|
||
final String url;
|
||
const _Screenshot({required this.url});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Material(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
child: InkWell(
|
||
onTap: () => SystemActions.openInOs(url),
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
child: SizedBox(
|
||
width: 320,
|
||
child: Image.network(
|
||
url,
|
||
fit: BoxFit.cover,
|
||
errorBuilder: (_, _, _) => _ScreenshotPlaceholder(
|
||
label: 'image unavailable',
|
||
detail: url,
|
||
),
|
||
loadingBuilder: (_, child, progress) {
|
||
if (progress == null) return child;
|
||
return const _ScreenshotPlaceholder(
|
||
label: 'loading…',
|
||
detail: '',
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ScreenshotPlaceholder extends StatelessWidget {
|
||
final String label;
|
||
final String detail;
|
||
const _ScreenshotPlaceholder({required this.label, required this.detail});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Container(
|
||
color: theme.colorScheme.surfaceContainer,
|
||
child: Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(FaiSpace.md),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.image_outlined,
|
||
size: 32,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
label,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
if (detail.isNotEmpty) ...[
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
detail,
|
||
style: FaiTheme.mono(
|
||
size: 9,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Compiled-in fallback stories shown when
|
||
/// `~/.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<TodayStoryData> _kFallbackTodayStories = <TodayStoryData>[
|
||
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<String> recommendedAdding;
|
||
final void Function(_RecommendedSource)? onAddRecommended;
|
||
|
||
const _StoreTodayHero({
|
||
required this.story,
|
||
required this.locale,
|
||
required this.currentIndex,
|
||
required this.totalCount,
|
||
required this.onPrev,
|
||
required this.onNext,
|
||
required this.onDismiss,
|
||
required this.onCta,
|
||
this.recommendedSources = const <_RecommendedSource>[],
|
||
this.recommendedAdding = const <String>{},
|
||
this.onAddRecommended,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final isDe = locale == 'de';
|
||
final badge = isDe ? story.badgeDe : story.badgeEn;
|
||
final title = isDe ? story.titleDe : story.titleEn;
|
||
final body = isDe ? story.bodyDe : story.bodyEn;
|
||
final ctaLabel = isDe ? story.ctaLabelDe : story.ctaLabelEn;
|
||
return Container(
|
||
width: double.infinity,
|
||
// Min-height keeps the carousel slides at a consistent
|
||
// size so the surrounding layout doesn't jump when the
|
||
// operator clicks the prev/next chevrons. Sized to fit
|
||
// the longest story (≈ 6 body lines + title + CTA + chip
|
||
// row) without leaving empty space on shorter slides.
|
||
constraints: const BoxConstraints(minHeight: 240),
|
||
padding: const EdgeInsets.fromLTRB(
|
||
FaiSpace.xl,
|
||
FaiSpace.lg,
|
||
FaiSpace.md,
|
||
FaiSpace.lg,
|
||
),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
gradient: LinearGradient(
|
||
colors: [
|
||
theme.colorScheme.primary.withValues(alpha: 0.16),
|
||
theme.colorScheme.primary.withValues(alpha: 0.04),
|
||
theme.colorScheme.surfaceContainer.withValues(alpha: 0.0),
|
||
],
|
||
stops: const [0.0, 0.55, 1.0],
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
),
|
||
border: Border.all(
|
||
color: theme.colorScheme.primary.withValues(alpha: 0.25),
|
||
),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.all(FaiSpace.md),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.primary.withValues(alpha: 0.14),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(story.icon, size: 24, color: theme.colorScheme.primary),
|
||
),
|
||
const SizedBox(width: FaiSpace.lg),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Wrap(
|
||
spacing: FaiSpace.sm,
|
||
runSpacing: 4,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
children: [
|
||
FaiPill(label: l.storeTodayBadge, tone: FaiPillTone.accent),
|
||
Text(
|
||
badge,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
Text(
|
||
'· ${l.storeTodayDeck}',
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
fontStyle: FontStyle.italic,
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Text(
|
||
title,
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.headlineSmall?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
height: 1.15,
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Text(
|
||
body,
|
||
maxLines: 6,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
height: 1.45,
|
||
),
|
||
),
|
||
if (onCta != null || recommendedSources.isNotEmpty) ...[
|
||
const SizedBox(height: FaiSpace.md),
|
||
Wrap(
|
||
spacing: FaiSpace.sm,
|
||
runSpacing: FaiSpace.xs,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
children: [
|
||
if (onCta != null)
|
||
FilledButton.tonalIcon(
|
||
onPressed: onCta,
|
||
icon: const Icon(Icons.arrow_forward, size: 14),
|
||
label: Text(ctaLabel),
|
||
style: FilledButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
),
|
||
for (final s in recommendedSources)
|
||
ActionChip(
|
||
avatar: recommendedAdding.contains(s.name)
|
||
? const SizedBox(
|
||
width: 12,
|
||
height: 12,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
),
|
||
)
|
||
: Icon(s.icon, size: 14),
|
||
label: Text('+ ${s.label}'),
|
||
onPressed:
|
||
recommendedAdding.contains(s.name) ||
|
||
onAddRecommended == null
|
||
? null
|
||
: () => onAddRecommended!(s),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
// Right-edge column: dismiss + (when more than one
|
||
// slide exists) a carousel control row. Stacking them
|
||
// vertically keeps the hero's main row tidy whatever
|
||
// the slide count.
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
IconButton(
|
||
icon: const Icon(Icons.close, size: 16),
|
||
visualDensity: VisualDensity.compact,
|
||
tooltip: l.storeTodayDismissTooltip,
|
||
onPressed: onDismiss,
|
||
),
|
||
if (totalCount > 1) ...[
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
IconButton(
|
||
icon: const Icon(Icons.chevron_left, size: 18),
|
||
visualDensity: VisualDensity.compact,
|
||
onPressed: onPrev,
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
for (var i = 0; i < totalCount; i++)
|
||
Container(
|
||
width: 6,
|
||
height: 6,
|
||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: i == currentIndex
|
||
? theme.colorScheme.primary
|
||
: theme.colorScheme.outlineVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.chevron_right, size: 18),
|
||
visualDensity: VisualDensity.compact,
|
||
onPressed: onNext,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Curated public MCP server that the operator can register
|
||
/// with one click — no Node, no API key, no subprocess. Fields
|
||
/// are kept const so the strip can be a `const` widget tree.
|
||
class _RecommendedSource {
|
||
/// Stable lowercase id used as the MCP-client name. Becomes
|
||
/// the prefix of every synthetic store entry the server emits
|
||
/// (`mcp.<name>.<tool>`).
|
||
final String name;
|
||
|
||
/// Display label shown on the card.
|
||
final String label;
|
||
final IconData icon;
|
||
|
||
/// HTTPS Streamable-HTTP endpoint. No auth required.
|
||
final String endpoint;
|
||
|
||
/// Bilingual description used as the MCP-client `description`
|
||
/// field — keeps the audit log in the operator's locale.
|
||
final String descriptionEn;
|
||
final String descriptionDe;
|
||
|
||
const _RecommendedSource({
|
||
required this.name,
|
||
required this.label,
|
||
required this.icon,
|
||
required this.endpoint,
|
||
required this.descriptionEn,
|
||
required this.descriptionDe,
|
||
});
|
||
}
|
||
|
||
/// Curated public MCP servers offered as one-click adds. Two
|
||
/// guardrails apply when picking entries: outbound HTTPS only
|
||
/// (no subprocess, no npm), and a small-enough tool surface
|
||
/// that the store stays browsable after the add (Useful AI was
|
||
/// considered but its 340+ tools would drown the index).
|
||
const _kRecommendedSources = <_RecommendedSource>[
|
||
_RecommendedSource(
|
||
name: 'deepwiki',
|
||
label: 'DeepWiki',
|
||
icon: Icons.menu_book_outlined,
|
||
endpoint: 'https://mcp.deepwiki.com/mcp',
|
||
descriptionEn: 'GitHub repo documentation search (AI-powered).',
|
||
descriptionDe: 'GitHub-Repo-Doku-Suche (KI-gestützt).',
|
||
),
|
||
_RecommendedSource(
|
||
name: 'semgrep',
|
||
label: 'Semgrep',
|
||
icon: Icons.security,
|
||
endpoint: 'https://mcp.semgrep.ai/mcp',
|
||
descriptionEn: 'Security scanning for code vulnerabilities.',
|
||
descriptionDe: 'Security-Scanning für Code-Schwachstellen.',
|
||
),
|
||
];
|
||
|
||
/// Recommended-sources strip — replaces the older Settings nudge.
|
||
/// Renders one card per [_kRecommendedSources] entry with a
|
||
/// single `[+ Add]` button so the operator can fill the store
|
||
/// without leaving the Store page. Hides as soon as any
|
||
/// federated entry exists.
|
||
class _RecommendedSourcesStrip extends StatelessWidget {
|
||
final Set<String> adding;
|
||
final String locale;
|
||
final void Function(_RecommendedSource) onAdd;
|
||
|
||
const _RecommendedSourcesStrip({
|
||
required this.adding,
|
||
required this.locale,
|
||
required this.onAdd,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(FaiSpace.md),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainer,
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.hub_outlined,
|
||
size: 18,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Text(
|
||
l.storeRecommendedTitle,
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
l.storeRecommendedBody,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
Wrap(
|
||
spacing: FaiSpace.md,
|
||
runSpacing: FaiSpace.sm,
|
||
children: [
|
||
for (final s in _kRecommendedSources)
|
||
_RecommendedSourceCard(
|
||
source: s,
|
||
locale: locale,
|
||
busy: adding.contains(s.name),
|
||
onAdd: () => onAdd(s),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _RecommendedSourceCard extends StatelessWidget {
|
||
final _RecommendedSource source;
|
||
final String locale;
|
||
final bool busy;
|
||
final VoidCallback onAdd;
|
||
|
||
const _RecommendedSourceCard({
|
||
required this.source,
|
||
required this.locale,
|
||
required this.busy,
|
||
required this.onAdd,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final desc = locale == 'de' ? source.descriptionDe : source.descriptionEn;
|
||
return SizedBox(
|
||
width: 320,
|
||
child: Container(
|
||
padding: const EdgeInsets.all(FaiSpace.sm),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(source.icon, size: 18, color: theme.colorScheme.primary),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
source.label,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
Text(
|
||
desc,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Tooltip(
|
||
message: busy
|
||
? l.storeRecommendedAdding
|
||
: l.storeRecommendedAddTooltip,
|
||
child: IconButton.filledTonal(
|
||
onPressed: busy ? null : onAdd,
|
||
icon: busy
|
||
? const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(Icons.add, size: 16),
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Small color-coded chip that names the entry's source: native
|
||
/// (muted), MCP (warning tone), or n8n (accent). The on-card
|
||
/// position lets the operator triage at a glance who emitted the
|
||
/// capability — the same role the "verified" badge plays in
|
||
/// commercial app stores.
|
||
class _ProvenancePill extends StatelessWidget {
|
||
final StoreItem item;
|
||
|
||
const _ProvenancePill({required this.item});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l = AppLocalizations.of(context)!;
|
||
final source = _sourceForItem(item);
|
||
switch (source) {
|
||
case 'mcp':
|
||
return FaiPill(
|
||
label: l.storeProvenanceMcp(item.provider),
|
||
tone: FaiPillTone.warning,
|
||
monospace: true,
|
||
icon: Icons.hub_outlined,
|
||
);
|
||
case 'n8n':
|
||
return FaiPill(
|
||
label: l.storeProvenanceN8n(item.provider),
|
||
tone: FaiPillTone.accent,
|
||
monospace: true,
|
||
icon: Icons.account_tree_outlined,
|
||
);
|
||
case 'temporal':
|
||
return FaiPill(
|
||
label: l.storeProvenanceTemporal(item.provider),
|
||
tone: FaiPillTone.accent,
|
||
monospace: true,
|
||
icon: Icons.timer_outlined,
|
||
);
|
||
default:
|
||
// Native is the default: no badge needed. Keeps the
|
||
// card visually quiet — only foreign code (mcp / n8n)
|
||
// earns the provenance pill, the same way a "verified"
|
||
// badge is opt-in in commercial app stores.
|
||
return const SizedBox.shrink();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Store-side version picker. Same shape as the module-sheet
|
||
/// version picker but lives here so the Store page doesn't take
|
||
/// a dependency on the module-sheet's private widgets. The
|
||
/// duplication is small (one dialog), and keeping them
|
||
/// separate lets each surface evolve its copy independently.
|
||
class _StoreUninstallVersionPickerDialog extends StatefulWidget {
|
||
final String moduleName;
|
||
final List<String> versions;
|
||
|
||
const _StoreUninstallVersionPickerDialog({
|
||
required this.moduleName,
|
||
required this.versions,
|
||
});
|
||
|
||
@override
|
||
State<_StoreUninstallVersionPickerDialog> createState() =>
|
||
_StoreUninstallVersionPickerDialogState();
|
||
}
|
||
|
||
class _StoreUninstallVersionPickerDialogState
|
||
extends State<_StoreUninstallVersionPickerDialog> {
|
||
late String _picked;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_picked = widget.versions.last;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l = AppLocalizations.of(context)!;
|
||
return AlertDialog(
|
||
title: Text(l.uninstallVersionPickerTitle),
|
||
content: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(l.uninstallVersionPickerBody(widget.moduleName)),
|
||
const SizedBox(height: FaiSpace.md),
|
||
RadioGroup<String>(
|
||
groupValue: _picked,
|
||
onChanged: (val) {
|
||
if (val != null) setState(() => _picked = val);
|
||
},
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
for (final v in widget.versions)
|
||
RadioListTile<String>(
|
||
value: v,
|
||
title: Text(
|
||
'v$v',
|
||
style: const TextStyle(fontFamily: 'monospace'),
|
||
),
|
||
dense: true,
|
||
contentPadding: EdgeInsets.zero,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: Text(l.buttonCancel),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(context, _picked),
|
||
child: Text(l.uninstallVersionPickerContinue),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|