Some checks failed
Security / Security check (push) Failing after 1s
Studio's `_sourceForItem` now prefers the wire-level `sourceKind` field (populated by 0.12+ hubs) over the legacy name-prefix heuristic. Adds a "temporal" bucket alongside "mcp" / "n8n" / "native" / "federated" so SPARK-federated workflows render with their own transport pill on the store grid + filter chip. Pre-0.12 hubs return empty `sourceKind` and the legacy sniffing path still works -- back-compat preserved. `StoreItem` data class accepts an optional `sourceKind` parameter (defaults to empty). dart analyze green. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
3366 lines
120 KiB
Dart
3366 lines
120 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/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);
|
||
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.
|
||
final embedRecommended = showToday && hasNoFederation;
|
||
// 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: _todayStories[
|
||
_todayIndex % _todayStories.length]
|
||
.cta ==
|
||
TodayCta.openSettings
|
||
? () => FaiSettingsDialog.show(context)
|
||
: null,
|
||
recommendedSources: embedRecommended
|
||
? _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();
|
||
}
|
||
}
|
||
|
||
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;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(l.storeProviderAddFailed(s.label, e.toString())),
|
||
),
|
||
);
|
||
} finally {
|
||
if (mounted) setState(() => _addingSources.remove(s.name));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Maps a store entry to its provenance bucket: 'native' for
|
||
/// non-federated entries, 'mcp' / 'n8n' / '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),
|
||
],
|
||
_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)
|
||
FaiPill(
|
||
label: '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) ...[
|
||
FaiPill(
|
||
label: 'v${item.bestVersion}',
|
||
tone: FaiPillTone.neutral,
|
||
monospace: true,
|
||
),
|
||
const SizedBox(width: FaiSpace.xs),
|
||
],
|
||
_ProvenancePill(item: item),
|
||
const Spacer(),
|
||
if (_hasUpdate)
|
||
FilledButton.icon(
|
||
onPressed: onInstall,
|
||
icon: const Icon(Icons.system_update_alt, size: 14),
|
||
label: Text(l.storeUpdateButton(item.bestVersion)),
|
||
style: FilledButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
)
|
||
else if (item.installed)
|
||
TextButton.icon(
|
||
onPressed: onTap,
|
||
icon: const Icon(Icons.info_outline, size: 14),
|
||
label: Text(l.buttonDetails),
|
||
)
|
||
else if (installable)
|
||
FilledButton.icon(
|
||
onPressed: onInstall,
|
||
icon: const Icon(Icons.download, size: 14),
|
||
label: Text(l.buttonInstall),
|
||
style: FilledButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
)
|
||
else
|
||
OutlinedButton.icon(
|
||
onPressed: onTap,
|
||
icon: const Icon(Icons.info_outline, size: 14),
|
||
label: Text(l.buttonDetails),
|
||
style: OutlinedButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Per-module detail sheet. Renders the full bilingual
|
||
/// description, the capabilities + services the module
|
||
/// requires, plus repository / docs links. Returns `true` when
|
||
/// state changed (install / uninstall) so the caller refreshes.
|
||
class _StoreDetailSheet extends StatefulWidget {
|
||
final StoreItem item;
|
||
final String locale;
|
||
|
||
const _StoreDetailSheet({required this.item, required this.locale});
|
||
|
||
static Future<bool> show(
|
||
BuildContext context,
|
||
StoreItem item,
|
||
String locale,
|
||
) async {
|
||
final r = await showModalBottomSheet<bool>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
|
||
),
|
||
builder: (_) => _StoreDetailSheet(item: item, locale: locale),
|
||
);
|
||
return r ?? false;
|
||
}
|
||
|
||
@override
|
||
State<_StoreDetailSheet> createState() => _StoreDetailSheetState();
|
||
}
|
||
|
||
class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||
/// Locale comes from MaterialApp via `Localizations.localeOf`;
|
||
/// the per-sheet state from before is no longer needed.
|
||
String get _locale => Localizations.localeOf(context).languageCode;
|
||
bool _busy = false;
|
||
String? _toast;
|
||
/// `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)!;
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: Text(l.storeUninstallTitle),
|
||
content: Text(l.storeUninstallBody(widget.item.name)),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: Text(l.buttonCancel),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||
foregroundColor: Theme.of(ctx).colorScheme.onError,
|
||
),
|
||
child: Text(l.buttonUninstall),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (ok != true || !mounted) return;
|
||
setState(() {
|
||
_busy = true;
|
||
_toast = null;
|
||
});
|
||
try {
|
||
final r = await HubService.instance.uninstallModule(widget.item.name);
|
||
if (!mounted) return;
|
||
final l2 = AppLocalizations.of(context)!;
|
||
setState(() {
|
||
_busy = false;
|
||
_toast = l2.storeUninstalledToast(r.name, r.version);
|
||
});
|
||
Navigator.pop(context, true);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
final l2 = AppLocalizations.of(context)!;
|
||
setState(() {
|
||
_busy = false;
|
||
_toast = l2.storeUninstallFailedToast(e.toString());
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _openRepository() async {
|
||
if (widget.item.repository.isEmpty) return;
|
||
final r = await SystemActions.openInOs(widget.item.repository);
|
||
if (!mounted) return;
|
||
if (!r.ok) {
|
||
final l = AppLocalizations.of(context)!;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(l.storeOpenBrowserFailed(r.stderr))),
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final item = widget.item;
|
||
final tagline = _locale == 'de' && item.taglineDe.isNotEmpty
|
||
? item.taglineDe
|
||
: item.taglineEn;
|
||
final description = _locale == 'de' && item.descriptionDe.isNotEmpty
|
||
? item.descriptionDe
|
||
: item.descriptionEn;
|
||
final installable = item.status == 'published' || item.status == 'alpha';
|
||
final maxHeight = MediaQuery.of(context).size.height * 0.85;
|
||
|
||
return ConstrainedBox(
|
||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
||
child: Container(
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.outlineVariant,
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
),
|
||
Flexible(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
FaiSpace.xxl,
|
||
FaiSpace.md,
|
||
FaiSpace.xxl,
|
||
FaiSpace.xxl,
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_ModuleIcon(
|
||
iconUrl: item.iconUrl,
|
||
category: item.category,
|
||
radius: 28,
|
||
),
|
||
const SizedBox(width: FaiSpace.lg),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
item.name,
|
||
style: theme.textTheme.headlineSmall?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Wrap(
|
||
spacing: FaiSpace.xs,
|
||
runSpacing: 4,
|
||
children: [
|
||
if (item.bestVersion.isNotEmpty)
|
||
FaiPill(
|
||
label: 'v${item.bestVersion}',
|
||
tone: FaiPillTone.neutral,
|
||
monospace: true,
|
||
),
|
||
if (item.status.isNotEmpty)
|
||
FaiPill(
|
||
label: _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);
|
||
return Container(
|
||
width: double.infinity,
|
||
constraints: const BoxConstraints(maxHeight: 480),
|
||
padding: const EdgeInsets.all(FaiSpace.md),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
child: Markdown(
|
||
data: text,
|
||
shrinkWrap: true,
|
||
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: 'PUBLIC SOURCES',
|
||
badgeDe: 'ÖFFENTLICHE QUELLEN',
|
||
titleEn: 'Fill your store in two clicks',
|
||
titleDe: 'Den Store in zwei Klicks auffüllen',
|
||
bodyEn:
|
||
'Right now the store shows only the modules that ship with the hub. Pick a public source from the buttons below and the store fills with whatever tools that server offers — straight over HTTPS, no install steps, nothing to configure.',
|
||
bodyDe:
|
||
'Im Moment zeigt der Store nur die Module, die mit dem Hub mitkommen. Wähle unten eine öffentliche Quelle aus, und der Store füllt sich mit allem, was dieser Server an Tools anbietet — direkt über HTTPS, ohne Installation, ohne weitere Konfiguration.',
|
||
ctaLabelEn: 'Manage sources',
|
||
ctaLabelDe: 'Quellen verwalten',
|
||
icon: Icons.hub_outlined,
|
||
cta: TodayCta.openSettings,
|
||
),
|
||
TodayStoryData(
|
||
badgeEn: 'TEXT MODULES',
|
||
badgeDe: 'TEXT-MODULE',
|
||
titleEn: 'Three text modules already in the store',
|
||
titleDe: 'Drei Text-Module sind schon im Store',
|
||
bodyEn:
|
||
'text.extract pulls plain text from PDFs and DOCX. text.summarize turns long material into a short briefing via your configured System AI. text.translate moves text between languages. All three install in one click from the grid below — search for "text" to find them.',
|
||
bodyDe:
|
||
'text.extract holt Plaintext aus PDFs und DOCX. text.summarize verdichtet lange Texte über die konfigurierte System-AI zu einem Briefing. text.translate übersetzt zwischen Sprachen. Alle drei lassen sich aus dem Grid unten mit einem Klick installieren — suche nach „text".',
|
||
ctaLabelEn: '',
|
||
ctaLabelDe: '',
|
||
icon: Icons.menu_book_outlined,
|
||
cta: TodayCta.none,
|
||
),
|
||
TodayStoryData(
|
||
badgeEn: 'SAMPLE FLOW',
|
||
badgeDe: 'BEISPIEL-FLOW',
|
||
titleEn: 'Try the extract → summarize flow',
|
||
titleDe: 'Probier den Extract → Summarize-Flow',
|
||
bodyEn:
|
||
'The bundled `flows/extract-summarize.yaml` chains text.extract into text.summarize so a PDF in one end produces a short briefing at the other. Open the Flows tab, hit Run, point it at any document — the result lands as plain text plus an audit-log trail of which modules ran with which inputs.',
|
||
bodyDe:
|
||
'Der mitgelieferte Flow `flows/extract-summarize.yaml` reicht text.extract an text.summarize weiter — vorne kommt ein PDF rein, hinten kommt ein kurzes Briefing raus. Flows-Tab öffnen, Run klicken, beliebiges Dokument auswählen; das Ergebnis ist Klartext plus Audit-Spur welcher Modul-Schritt mit welchen Eingaben gelaufen ist.',
|
||
ctaLabelEn: '',
|
||
ctaLabelDe: '',
|
||
icon: Icons.account_tree_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,
|
||
);
|
||
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();
|
||
}
|
||
}
|
||
}
|