Cross-store research (Apple, Play, Steam, Docker, VS Code, Chrome Web Store, Flathub) consistently rewards editorial curation over algorithmic recommendations — but manual copywriting per release does not survive a solo-dev cadence. This commit lands a daily-build pipeline so the Today-Hero card stays fresh without operator hand-edits per release. Pipeline shape (full design in docs/today-pipeline.md): 1. tools/today/collect.sh aggregates "what happened in the last 24 hours" across the F∆I monorepos: git log per repo, store-index seed.yaml diffs, architecture/system-gaps doc changes, Studio release tags, and (opt-in) audit-log highlights. Outputs plain text. 2. tools/today/propose.sh feeds the signal summary plus prompt.template.md to the operator's already-configured System-AI (Ollama default; OpenAI-compatible endpoints work via env-var override). Drafts N candidate stories as YAML files under ~/.fai/today/proposals/<date>/. 3. tools/today/accept.sh validates a chosen candidate against the today/v1 schema and the no-marketing-speak banned-word list, then atomic-renames it into ~/.fai/today/active.yaml. 4. Studio reads active.yaml at store-page init via the new TodayStoryLoader (lib/data/today_story_loader.dart). On any failure (file missing, schema mismatch, banned-words hit, parse error) it falls back to the compiled-in _kFallbackTodayStory so KRITIS deployments and fresh installs always render something sensible. Trust + audit: - All proposed and accepted stories live as plain YAML on disk. - The pipeline calls only the operator's already-configured System-AI; it never reaches a CMS, never phones home, works air-gapped if the System-AI does. - The bash accept gate AND the Dart loader both enforce the banned-word list — a hand-edited active.yaml that bypassed the shell still won't reach the UI. - Removing the cron entry disables the pipeline; Studio falls back to the const story and continues to work. Cron / launchd / systemd recipes documented in tools/today/README.md. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2349 lines
80 KiB
Dart
2349 lines
80 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 'package:flutter/material.dart';
|
||
import 'package:flutter_markdown/flutter_markdown.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;
|
||
/// 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;
|
||
/// Loaded once at init from `~/.fai/today/active.yaml`; falls
|
||
/// back to the compiled-in story when the pipeline hasn't
|
||
/// produced an accepted candidate yet.
|
||
late final TodayStoryData _todayStory =
|
||
TodayStoryLoader.loadOrFallback(_kFallbackTodayStory);
|
||
late Future<List<StoreItem>> _future;
|
||
|
||
/// Active UI locale, read from the app-wide MaterialApp
|
||
/// `locale` setting. Drives which side of the bilingual
|
||
/// store-index entries renders (taglineEn vs taglineDe,
|
||
/// descriptionEn vs descriptionDe). The sidebar's
|
||
/// language toggle flips this for the whole app.
|
||
String get _locale => Localizations.localeOf(context).languageCode;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_future = _load();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_queryCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Map<String, String> _installedVersions = const {};
|
||
|
||
Future<List<StoreItem>> _load() async {
|
||
// Fetch the store entries and the installed modules in
|
||
// parallel so the page paints in one round-trip. The
|
||
// installed-version map drives the per-card "Update"
|
||
// badge — store entries only carry `best_version` and
|
||
// `installed: bool`, never the installed version itself.
|
||
final results = await Future.wait([
|
||
HubService.instance.searchStore(
|
||
query: _queryCtrl.text.trim(),
|
||
category: _category,
|
||
status: _status,
|
||
limit: 200,
|
||
),
|
||
HubService.instance.listModules().catchError((_) => <ModuleSummary>[]),
|
||
]);
|
||
final all = results[0] as List<StoreItem>;
|
||
final mods = results[1] as List<ModuleSummary>;
|
||
_installedVersions = {for (final m in mods) m.name: m.version};
|
||
return _installedOnly ? all.where((e) => e.installed).toList() : all;
|
||
}
|
||
|
||
void _runSearch() {
|
||
// Block body, so the closure's return type is `void`
|
||
// rather than `Future`. setState() rejects closures that
|
||
// resolve to a Future — the assignment must be synchronous
|
||
// even though the future itself is awaited later.
|
||
setState(() {
|
||
_future = _load();
|
||
});
|
||
}
|
||
|
||
Future<void> _openDetail(StoreItem item) async {
|
||
final changed = await _StoreDetailSheet.show(context, item, _locale);
|
||
if (changed) _runSearch();
|
||
}
|
||
|
||
Future<void> _install(StoreItem item) async {
|
||
if (!mounted) return;
|
||
final outcome = await showDialog<bool>(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (_) => _InstallProgressDialog(item: item),
|
||
);
|
||
if (outcome == true) _runSearch();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return Scaffold(
|
||
backgroundColor: theme.scaffoldBackgroundColor,
|
||
appBar: AppBar(
|
||
title: Text(l.searchGroupStore),
|
||
actions: [
|
||
// The DE/EN toggle now lives in the sidebar footer
|
||
// and flips the entire app's locale. The Store
|
||
// detail-sheet content follows automatically because
|
||
// _locale reads from `Localizations.localeOf`.
|
||
IconButton(
|
||
icon: const Icon(Icons.refresh, size: 18),
|
||
tooltip: l.storeReloadTooltip,
|
||
onPressed: _runSearch,
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
],
|
||
),
|
||
body: Padding(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
FaiSpace.xl,
|
||
FaiSpace.lg,
|
||
FaiSpace.xl,
|
||
0,
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_SearchField(
|
||
controller: _queryCtrl,
|
||
onSubmit: _runSearch,
|
||
onClear: () {
|
||
_queryCtrl.clear();
|
||
_runSearch();
|
||
},
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
FutureBuilder<List<StoreItem>>(
|
||
future: _future,
|
||
builder: (context, snap) {
|
||
final raw = snap.data ?? const <StoreItem>[];
|
||
final items = _applySourceFilter(raw);
|
||
final categories = _categoriesIn(raw);
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_CategoryStrip(
|
||
categories: categories,
|
||
selected: _category,
|
||
onSelected: (v) {
|
||
setState(() => _category = v);
|
||
_runSearch();
|
||
},
|
||
),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
_FilterRow(
|
||
status: _status,
|
||
source: _source,
|
||
installedOnly: _installedOnly,
|
||
onStatus: (v) {
|
||
setState(() => _status = v);
|
||
_runSearch();
|
||
},
|
||
onSource: (v) => setState(() => _source = v),
|
||
onInstalledOnly: (v) {
|
||
setState(() => _installedOnly = v);
|
||
_runSearch();
|
||
},
|
||
resultCount: items.length,
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
Expanded(
|
||
child: FutureBuilder<List<StoreItem>>(
|
||
future: _future,
|
||
builder: (context, snap) {
|
||
if (snap.connectionState == ConnectionState.waiting) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (snap.hasError) {
|
||
return FaiEmptyState(
|
||
icon: Icons.cloud_off_outlined,
|
||
iconColor: theme.colorScheme.error,
|
||
title: l.hubUnreachable,
|
||
hint: l.hubUnreachableHint,
|
||
action: FilledButton.tonal(
|
||
onPressed: _runSearch,
|
||
child: Text(l.buttonRetry),
|
||
),
|
||
);
|
||
}
|
||
final raw = snap.data ?? const <StoreItem>[];
|
||
final items = _applySourceFilter(raw);
|
||
if (items.isEmpty) {
|
||
return FaiEmptyState(
|
||
icon: Icons.search_off,
|
||
title: l.storeNoMatches,
|
||
hint: l.storeNoMatchesHint,
|
||
action: FilledButton.tonal(
|
||
onPressed: () {
|
||
setState(() {
|
||
_category = '';
|
||
_status = '';
|
||
_source = '';
|
||
_installedOnly = false;
|
||
_queryCtrl.clear();
|
||
});
|
||
_runSearch();
|
||
},
|
||
child: Text(l.storeClearFilters),
|
||
),
|
||
);
|
||
}
|
||
// Featured strip is only useful in the
|
||
// browse-the-whole-thing case. Once the
|
||
// operator has typed a query or picked a
|
||
// filter, the result list itself is the
|
||
// answer they want — featured chrome would
|
||
// just push it down.
|
||
final showFeatured = _queryCtrl.text.trim().isEmpty &&
|
||
_category.isEmpty &&
|
||
_status.isEmpty &&
|
||
_source.isEmpty &&
|
||
!_installedOnly &&
|
||
items.any((e) => e.featured);
|
||
// Sparse-store nudge: when no filter is set
|
||
// and the store carries no federated entries
|
||
// yet, surface curated public MCP servers as
|
||
// one-click add cards. Hides the moment any
|
||
// federated entry shows up — the strip has
|
||
// done its job.
|
||
final showRecommendedStrip =
|
||
_queryCtrl.text.trim().isEmpty &&
|
||
_category.isEmpty &&
|
||
_status.isEmpty &&
|
||
_source.isEmpty &&
|
||
!_installedOnly &&
|
||
!raw.any((e) => e.isFederated);
|
||
// Editorial hero is browsing-only chrome —
|
||
// hides as soon as any filter is set so the
|
||
// operator's actual query stays in focus.
|
||
final showToday = !_todayDismissed &&
|
||
_queryCtrl.text.trim().isEmpty &&
|
||
_category.isEmpty &&
|
||
_status.isEmpty &&
|
||
_source.isEmpty &&
|
||
!_installedOnly;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (showToday) ...[
|
||
_StoreTodayHero(
|
||
story: _todayStory,
|
||
locale: _locale,
|
||
onDismiss: () =>
|
||
setState(() => _todayDismissed = true),
|
||
onCta: _todayStory.cta == TodayCta.openSettings
|
||
? () => FaiSettingsDialog.show(context)
|
||
: null,
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
],
|
||
if (showRecommendedStrip) ...[
|
||
_RecommendedSourcesStrip(
|
||
adding: _addingSources,
|
||
locale: _locale,
|
||
onAdd: _addRecommendedSource,
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
],
|
||
if (showFeatured) ...[
|
||
_FeaturedStrip(
|
||
items: items.where((e) => e.featured).toList(),
|
||
locale: _locale,
|
||
onTap: _openDetail,
|
||
onInstall: _install,
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
Expanded(
|
||
child: _StoreGrid(
|
||
items: items,
|
||
locale: _locale,
|
||
installedVersions: _installedVersions,
|
||
onTap: _openDetail,
|
||
onInstall: _install,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
List<String> _categoriesIn(List<StoreItem> items) {
|
||
final set = <String>{};
|
||
for (final i in items) {
|
||
if (i.category.isNotEmpty) set.add(i.category);
|
||
}
|
||
final list = set.toList()..sort();
|
||
return list;
|
||
}
|
||
|
||
List<StoreItem> _applySourceFilter(List<StoreItem> items) {
|
||
if (_source.isEmpty) return items;
|
||
return items.where((e) => _sourceForItem(e) == _source).toList();
|
||
}
|
||
|
||
Future<void> _addRecommendedSource(_RecommendedSource s) async {
|
||
if (_addingSources.contains(s.name)) return;
|
||
setState(() => _addingSources.add(s.name));
|
||
final l = AppLocalizations.of(context)!;
|
||
try {
|
||
final list = await HubService.instance.addMcpClient(
|
||
name: s.name,
|
||
endpoint: s.endpoint,
|
||
apiKeyEnv: '',
|
||
description: s.descriptionEn,
|
||
);
|
||
final added = list.firstWhere(
|
||
(c) => c.name == s.name,
|
||
orElse: () => list.last,
|
||
);
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
l.storeProviderAddedToast(s.label, added.toolCount),
|
||
),
|
||
),
|
||
);
|
||
_runSearch();
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(l.storeProviderAddFailed(s.label, e.toString())),
|
||
),
|
||
);
|
||
} finally {
|
||
if (mounted) setState(() => _addingSources.remove(s.name));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Maps a store entry to its provenance bucket: 'native' for
|
||
/// non-federated entries, 'mcp' / 'n8n' for federated tools that
|
||
/// flow through one of the bridges. Used by the source filter
|
||
/// chip and the per-card provenance pill so operators can tell
|
||
/// at a glance which entries come from where.
|
||
String _sourceForItem(StoreItem i) {
|
||
if (!i.isFederated) return 'native';
|
||
if (i.name.startsWith('mcp.')) return 'mcp';
|
||
if (i.name.startsWith('n8n.')) return 'n8n';
|
||
return 'federated';
|
||
}
|
||
|
||
/// Big search input that owns the page's primary action.
|
||
class _SearchField extends StatelessWidget {
|
||
final TextEditingController controller;
|
||
final VoidCallback onSubmit;
|
||
final VoidCallback onClear;
|
||
|
||
const _SearchField({
|
||
required this.controller,
|
||
required this.onSubmit,
|
||
required this.onClear,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l = AppLocalizations.of(context)!;
|
||
return ValueListenableBuilder<TextEditingValue>(
|
||
valueListenable: controller,
|
||
builder: (_, value, _) {
|
||
return TextField(
|
||
controller: controller,
|
||
decoration: InputDecoration(
|
||
prefixIcon: const Icon(Icons.search, size: 20),
|
||
hintText: l.storeSearchHint,
|
||
border: const OutlineInputBorder(),
|
||
isDense: true,
|
||
suffixIcon: value.text.isEmpty
|
||
? null
|
||
: IconButton(
|
||
icon: const Icon(Icons.close, size: 18),
|
||
onPressed: onClear,
|
||
),
|
||
),
|
||
onChanged: (_) => onSubmit(),
|
||
onSubmitted: (_) => onSubmit(),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Horizontally-scrollable category chip strip. Always-on
|
||
/// "All" chip first, then categories the current result set
|
||
/// contains. Disappears when the index is empty.
|
||
class _CategoryStrip extends StatelessWidget {
|
||
final List<String> categories;
|
||
final String selected;
|
||
final void Function(String) onSelected;
|
||
|
||
const _CategoryStrip({
|
||
required this.categories,
|
||
required this.selected,
|
||
required this.onSelected,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l = AppLocalizations.of(context)!;
|
||
if (categories.isEmpty) return const SizedBox.shrink();
|
||
return SizedBox(
|
||
height: 36,
|
||
child: ListView(
|
||
scrollDirection: Axis.horizontal,
|
||
children: [
|
||
_ChoiceChip(
|
||
label: l.storeStatusAll,
|
||
selected: selected.isEmpty,
|
||
onSelected: () => onSelected(''),
|
||
),
|
||
for (final c in categories)
|
||
_ChoiceChip(
|
||
label: c,
|
||
selected: selected == c,
|
||
onSelected: () => onSelected(c),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _FilterRow extends StatelessWidget {
|
||
final String status;
|
||
final String source;
|
||
final bool installedOnly;
|
||
final void Function(String) onStatus;
|
||
final void Function(String) onSource;
|
||
final void Function(bool) onInstalledOnly;
|
||
final int resultCount;
|
||
|
||
const _FilterRow({
|
||
required this.status,
|
||
required this.source,
|
||
required this.installedOnly,
|
||
required this.onStatus,
|
||
required this.onSource,
|
||
required this.onInstalledOnly,
|
||
required this.resultCount,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return Wrap(
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
spacing: FaiSpace.sm,
|
||
runSpacing: FaiSpace.xs,
|
||
children: [
|
||
_ChoiceChip(
|
||
label: l.storeStatusAll,
|
||
selected: status.isEmpty,
|
||
onSelected: () => onStatus(''),
|
||
),
|
||
_ChoiceChip(
|
||
label: l.storeStatusPublished,
|
||
selected: status == 'published',
|
||
onSelected: () => onStatus('published'),
|
||
),
|
||
_ChoiceChip(
|
||
label: l.storeStatusAlpha,
|
||
selected: status == 'alpha',
|
||
onSelected: () => onStatus('alpha'),
|
||
),
|
||
_ChoiceChip(
|
||
label: l.storeStatusPlanned,
|
||
selected: status == 'planned',
|
||
onSelected: () => onStatus('planned'),
|
||
),
|
||
Container(
|
||
width: 1,
|
||
height: 18,
|
||
color: theme.colorScheme.outlineVariant,
|
||
),
|
||
_ChoiceChip(
|
||
label: l.storeSourceAll,
|
||
selected: source.isEmpty,
|
||
onSelected: () => onSource(''),
|
||
),
|
||
_ChoiceChip(
|
||
label: l.storeSourceNative,
|
||
selected: source == 'native',
|
||
onSelected: () => onSource('native'),
|
||
),
|
||
_ChoiceChip(
|
||
label: l.storeSourceMcp,
|
||
selected: source == 'mcp',
|
||
onSelected: () => onSource('mcp'),
|
||
),
|
||
_ChoiceChip(
|
||
label: l.storeSourceN8n,
|
||
selected: source == 'n8n',
|
||
onSelected: () => onSource('n8n'),
|
||
),
|
||
FilterChip(
|
||
label: Text(l.storeInstalledOnly),
|
||
selected: installedOnly,
|
||
onSelected: onInstalledOnly,
|
||
showCheckmark: true,
|
||
),
|
||
Text(
|
||
l.storeNResults(resultCount),
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ChoiceChip extends StatelessWidget {
|
||
final String label;
|
||
final bool selected;
|
||
final VoidCallback onSelected;
|
||
|
||
const _ChoiceChip({
|
||
required this.label,
|
||
required this.selected,
|
||
required this.onSelected,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(right: FaiSpace.xs),
|
||
child: ChoiceChip(
|
||
label: Text(label),
|
||
selected: selected,
|
||
onSelected: (_) => onSelected(),
|
||
showCheckmark: false,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Responsive grid — wraps to as many columns as the viewport
|
||
/// allows. Cards are click-to-expand; install is also a top-
|
||
/// level action so single-click installs are still possible.
|
||
class _StoreGrid extends StatelessWidget {
|
||
final List<StoreItem> items;
|
||
final String locale;
|
||
/// Map of installed module name → installed version. Cards
|
||
/// compare against `bestVersion` to render an "Update" badge
|
||
/// when the store offers something newer.
|
||
final Map<String, String> installedVersions;
|
||
final ValueChanged<StoreItem> onTap;
|
||
final ValueChanged<StoreItem> onInstall;
|
||
|
||
const _StoreGrid({
|
||
required this.items,
|
||
required this.locale,
|
||
required this.installedVersions,
|
||
required this.onTap,
|
||
required this.onInstall,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
const minCardWidth = 360.0;
|
||
final cols = (constraints.maxWidth / minCardWidth).floor().clamp(1, 4);
|
||
return GridView.builder(
|
||
padding: EdgeInsets.zero,
|
||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: cols,
|
||
mainAxisSpacing: FaiSpace.md,
|
||
crossAxisSpacing: FaiSpace.md,
|
||
mainAxisExtent: 168,
|
||
),
|
||
itemCount: items.length,
|
||
itemBuilder: (context, i) => _StoreCard(
|
||
item: items[i],
|
||
locale: locale,
|
||
installedVersion: installedVersions[items[i].name],
|
||
onTap: () => onTap(items[i]),
|
||
onInstall: () => onInstall(items[i]),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Horizontally-scrolling band of editorial picks, rendered
|
||
/// only when the operator hasn't filtered the result set.
|
||
/// Each tile is a hero version of the regular store card —
|
||
/// bigger icon, gradient backdrop, prominent install button.
|
||
/// Curated quarterly via the bundled `seed.yaml` `featured:`
|
||
/// list so the choice can be reviewed in code review.
|
||
class _FeaturedStrip extends StatelessWidget {
|
||
final List<StoreItem> items;
|
||
final String locale;
|
||
final ValueChanged<StoreItem> onTap;
|
||
final ValueChanged<StoreItem> onInstall;
|
||
|
||
const _FeaturedStrip({
|
||
required this.items,
|
||
required this.locale,
|
||
required this.onTap,
|
||
required this.onInstall,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(Icons.star, size: 14, color: FaiColors.success),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
l.storeFeatured,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Text(
|
||
l.storeFeaturedHint,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
SizedBox(
|
||
// Tiles render larger than regular grid cards
|
||
// (which are 168 × ~360) so the editorial picks
|
||
// visibly stand out. Same hierarchy you see on app
|
||
// stores: hero band before the dense list.
|
||
height: 220,
|
||
child: ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
itemCount: items.length,
|
||
separatorBuilder: (_, _) => const SizedBox(width: FaiSpace.md),
|
||
itemBuilder: (context, i) {
|
||
final item = items[i];
|
||
return SizedBox(
|
||
width: 480,
|
||
child: _FeaturedTile(
|
||
item: item,
|
||
locale: locale,
|
||
onTap: () => onTap(item),
|
||
onInstall: () => onInstall(item),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _FeaturedTile extends StatelessWidget {
|
||
final StoreItem item;
|
||
final String locale;
|
||
final VoidCallback onTap;
|
||
final VoidCallback onInstall;
|
||
|
||
const _FeaturedTile({
|
||
required this.item,
|
||
required this.locale,
|
||
required this.onTap,
|
||
required this.onInstall,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final tagline = locale == 'de' && item.taglineDe.isNotEmpty
|
||
? item.taglineDe
|
||
: item.taglineEn;
|
||
final installable = item.status == 'published' || item.status == 'alpha';
|
||
return Material(
|
||
color: theme.colorScheme.surfaceContainer,
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(FaiSpace.lg),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
gradient: LinearGradient(
|
||
colors: [
|
||
theme.colorScheme.primary.withValues(alpha: 0.14),
|
||
theme.colorScheme.surfaceContainer.withValues(alpha: 0.0),
|
||
],
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
CircleAvatar(
|
||
radius: 24,
|
||
backgroundColor:
|
||
theme.colorScheme.primary.withValues(alpha: 0.18),
|
||
child: Icon(
|
||
_iconForCategory(item.category),
|
||
size: 26,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
),
|
||
const SizedBox(width: FaiSpace.md),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
item.name,
|
||
style: theme.textTheme.titleLarge?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
if (item.category.isNotEmpty)
|
||
Text(
|
||
item.category,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Icon(Icons.star, size: 18, color: FaiColors.success),
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
Expanded(
|
||
child: Text(
|
||
tagline.isEmpty ? l.storeNoTagline : tagline,
|
||
style: theme.textTheme.bodyLarge,
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Row(
|
||
children: [
|
||
if (item.bestVersion.isNotEmpty)
|
||
FaiPill(
|
||
label: 'v${item.bestVersion}',
|
||
tone: FaiPillTone.neutral,
|
||
monospace: true,
|
||
),
|
||
const SizedBox(width: FaiSpace.xs),
|
||
if (item.status.isNotEmpty)
|
||
FaiPill(
|
||
label: item.status,
|
||
tone: _toneForStatus(item.status),
|
||
),
|
||
const Spacer(),
|
||
if (item.installed)
|
||
FaiPill(
|
||
label: l.storePillInstalled,
|
||
tone: FaiPillTone.success,
|
||
icon: Icons.check,
|
||
)
|
||
else if (installable)
|
||
FilledButton.icon(
|
||
onPressed: onInstall,
|
||
icon: const Icon(Icons.download, size: 16),
|
||
label: Text(l.buttonInstall),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _StoreCard extends StatelessWidget {
|
||
final StoreItem item;
|
||
final String locale;
|
||
/// Installed version for this name, when the operator has it.
|
||
/// `null` for not-installed entries.
|
||
final String? installedVersion;
|
||
final VoidCallback onTap;
|
||
final VoidCallback onInstall;
|
||
|
||
const _StoreCard({
|
||
required this.item,
|
||
required this.locale,
|
||
required this.installedVersion,
|
||
required this.onTap,
|
||
required this.onInstall,
|
||
});
|
||
|
||
/// True iff the operator has this module installed AND the
|
||
/// store-index offers a strictly-newer best_version. Uses a
|
||
/// dotted-numeric semver-light compare so 0.10.5 > 0.9.99.
|
||
bool get _hasUpdate {
|
||
final installed = installedVersion;
|
||
final best = item.bestVersion;
|
||
if (installed == null || best.isEmpty) return false;
|
||
if (installed == best) return false;
|
||
return _versionCmp(best, installed) > 0;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final tagline = locale == 'de' && item.taglineDe.isNotEmpty
|
||
? item.taglineDe
|
||
: item.taglineEn;
|
||
final installable = item.status == 'published' || item.status == 'alpha';
|
||
return Material(
|
||
color: theme.colorScheme.surfaceContainer,
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(FaiSpace.md),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
CircleAvatar(
|
||
radius: 18,
|
||
backgroundColor:
|
||
theme.colorScheme.primary.withValues(alpha: 0.12),
|
||
child: Icon(
|
||
_iconForCategory(item.category),
|
||
size: 18,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
item.name,
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
Text(
|
||
item.category.isEmpty ? '—' : item.category,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (_hasUpdate)
|
||
Tooltip(
|
||
message: l.storeUpdateTooltip(
|
||
installedVersion ?? '?',
|
||
item.bestVersion,
|
||
),
|
||
child: FaiPill(
|
||
label: l.storePillUpdate,
|
||
tone: FaiPillTone.warning,
|
||
icon: Icons.system_update_alt,
|
||
),
|
||
)
|
||
else if (item.installed)
|
||
FaiPill(
|
||
label: l.storePillInstalled,
|
||
tone: FaiPillTone.success,
|
||
icon: Icons.check,
|
||
)
|
||
else if (item.status.isNotEmpty)
|
||
FaiPill(
|
||
label: item.status,
|
||
tone: _toneForStatus(item.status),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Expanded(
|
||
child: Text(
|
||
tagline.isEmpty ? l.storeNoTagline : tagline,
|
||
style: theme.textTheme.bodySmall,
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Row(
|
||
children: [
|
||
if (item.bestVersion.isNotEmpty) ...[
|
||
FaiPill(
|
||
label: 'v${item.bestVersion}',
|
||
tone: FaiPillTone.neutral,
|
||
monospace: true,
|
||
),
|
||
const SizedBox(width: FaiSpace.xs),
|
||
],
|
||
_ProvenancePill(item: item),
|
||
const Spacer(),
|
||
if (_hasUpdate)
|
||
FilledButton.icon(
|
||
onPressed: onInstall,
|
||
icon: const Icon(Icons.system_update_alt, size: 14),
|
||
label: Text(l.storeUpdateButton(item.bestVersion)),
|
||
style: FilledButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
)
|
||
else if (item.installed)
|
||
TextButton.icon(
|
||
onPressed: onTap,
|
||
icon: const Icon(Icons.info_outline, size: 14),
|
||
label: Text(l.buttonDetails),
|
||
)
|
||
else if (installable)
|
||
FilledButton.icon(
|
||
onPressed: onInstall,
|
||
icon: const Icon(Icons.download, size: 14),
|
||
label: Text(l.buttonInstall),
|
||
style: FilledButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
)
|
||
else
|
||
OutlinedButton.icon(
|
||
onPressed: onTap,
|
||
icon: const Icon(Icons.info_outline, size: 14),
|
||
label: Text(l.buttonDetails),
|
||
style: OutlinedButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Per-module detail sheet. Renders the full bilingual
|
||
/// description, the capabilities + services the module
|
||
/// requires, plus repository / docs links. Returns `true` when
|
||
/// state changed (install / uninstall) so the caller refreshes.
|
||
class _StoreDetailSheet extends StatefulWidget {
|
||
final StoreItem item;
|
||
final String locale;
|
||
|
||
const _StoreDetailSheet({required this.item, required this.locale});
|
||
|
||
static Future<bool> show(
|
||
BuildContext context,
|
||
StoreItem item,
|
||
String locale,
|
||
) async {
|
||
final r = await showModalBottomSheet<bool>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
|
||
),
|
||
builder: (_) => _StoreDetailSheet(item: item, locale: locale),
|
||
);
|
||
return r ?? false;
|
||
}
|
||
|
||
@override
|
||
State<_StoreDetailSheet> createState() => _StoreDetailSheetState();
|
||
}
|
||
|
||
class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||
/// Locale comes from MaterialApp via `Localizations.localeOf`;
|
||
/// the per-sheet state from before is no longer needed.
|
||
String get _locale => Localizations.localeOf(context).languageCode;
|
||
bool _busy = false;
|
||
String? _toast;
|
||
Future<({String errorKind, String text, String sourceUrl})>? _docsFuture;
|
||
bool _docsLoaded = false;
|
||
|
||
Future<void> _install() async {
|
||
setState(() {
|
||
_busy = true;
|
||
_toast = null;
|
||
});
|
||
try {
|
||
final r = await HubService.instance.installModule(source: widget.item.name);
|
||
if (!mounted) return;
|
||
final l = AppLocalizations.of(context)!;
|
||
setState(() {
|
||
_busy = false;
|
||
_toast = l.storeInstalledToast(r.name, r.version);
|
||
});
|
||
Navigator.pop(context, true);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
final l = AppLocalizations.of(context)!;
|
||
setState(() {
|
||
_busy = false;
|
||
_toast = l.storeInstallFailedToast(e.toString());
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _uninstall() async {
|
||
final l = AppLocalizations.of(context)!;
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: Text(l.storeUninstallTitle),
|
||
content: Text(l.storeUninstallBody(widget.item.name)),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: Text(l.buttonCancel),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||
foregroundColor: Theme.of(ctx).colorScheme.onError,
|
||
),
|
||
child: Text(l.buttonUninstall),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (ok != true || !mounted) return;
|
||
setState(() {
|
||
_busy = true;
|
||
_toast = null;
|
||
});
|
||
try {
|
||
final r = await HubService.instance.uninstallModule(widget.item.name);
|
||
if (!mounted) return;
|
||
final l2 = AppLocalizations.of(context)!;
|
||
setState(() {
|
||
_busy = false;
|
||
_toast = l2.storeUninstalledToast(r.name, r.version);
|
||
});
|
||
Navigator.pop(context, true);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
final l2 = AppLocalizations.of(context)!;
|
||
setState(() {
|
||
_busy = false;
|
||
_toast = l2.storeUninstallFailedToast(e.toString());
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _openRepository() async {
|
||
if (widget.item.repository.isEmpty) return;
|
||
final r = await SystemActions.openInOs(widget.item.repository);
|
||
if (!mounted) return;
|
||
if (!r.ok) {
|
||
final l = AppLocalizations.of(context)!;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(l.storeOpenBrowserFailed(r.stderr))),
|
||
);
|
||
}
|
||
}
|
||
|
||
void _loadDocs() {
|
||
if (_docsLoaded) return;
|
||
setState(() {
|
||
_docsLoaded = true;
|
||
_docsFuture =
|
||
HubService.instance.fetchModuleDocs(widget.item.name);
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final item = widget.item;
|
||
final tagline = _locale == 'de' && item.taglineDe.isNotEmpty
|
||
? item.taglineDe
|
||
: item.taglineEn;
|
||
final description = _locale == 'de' && item.descriptionDe.isNotEmpty
|
||
? item.descriptionDe
|
||
: item.descriptionEn;
|
||
final installable = item.status == 'published' || item.status == 'alpha';
|
||
final maxHeight = MediaQuery.of(context).size.height * 0.85;
|
||
|
||
return ConstrainedBox(
|
||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
||
child: Container(
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.outlineVariant,
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
),
|
||
Flexible(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
FaiSpace.xxl,
|
||
FaiSpace.md,
|
||
FaiSpace.xxl,
|
||
FaiSpace.xxl,
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_ModuleIcon(
|
||
iconUrl: item.iconUrl,
|
||
category: item.category,
|
||
radius: 28,
|
||
),
|
||
const SizedBox(width: FaiSpace.lg),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
item.name,
|
||
style: theme.textTheme.headlineSmall?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Wrap(
|
||
spacing: FaiSpace.xs,
|
||
runSpacing: 4,
|
||
children: [
|
||
if (item.bestVersion.isNotEmpty)
|
||
FaiPill(
|
||
label: 'v${item.bestVersion}',
|
||
tone: FaiPillTone.neutral,
|
||
monospace: true,
|
||
),
|
||
if (item.status.isNotEmpty)
|
||
FaiPill(
|
||
label: item.status,
|
||
tone: _toneForStatus(item.status),
|
||
),
|
||
if (item.category.isNotEmpty)
|
||
FaiPill(
|
||
label: item.category,
|
||
tone: FaiPillTone.neutral,
|
||
),
|
||
if (item.installed)
|
||
FaiPill(
|
||
label: l.storePillInstalled,
|
||
tone: FaiPillTone.success,
|
||
icon: Icons.check,
|
||
),
|
||
if (item.license.isNotEmpty)
|
||
FaiPill(
|
||
label: item.license,
|
||
tone: FaiPillTone.neutral,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// Locale follows the app-wide setting
|
||
// from the sidebar; the detail sheet
|
||
// does not host its own toggle anymore.
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
if (tagline.isNotEmpty) ...[
|
||
Text(
|
||
tagline,
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
if (description.isNotEmpty) ...[
|
||
SelectableText(
|
||
description,
|
||
style: theme.textTheme.bodyMedium,
|
||
),
|
||
const SizedBox(height: FaiSpace.xl),
|
||
],
|
||
if (item.tags.isNotEmpty) ...[
|
||
_SectionHeader(l.storeSectionTags),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Wrap(
|
||
spacing: FaiSpace.xs,
|
||
runSpacing: FaiSpace.xs,
|
||
children: [
|
||
for (final t in item.tags)
|
||
FaiPill(label: t, tone: FaiPillTone.neutral),
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
if (item.requiresCapabilities.isNotEmpty) ...[
|
||
_SectionHeader(l.storeSectionRequiredCapabilities),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Wrap(
|
||
spacing: FaiSpace.xs,
|
||
runSpacing: FaiSpace.xs,
|
||
children: [
|
||
for (final c in item.requiresCapabilities)
|
||
FaiPill(
|
||
label: c,
|
||
tone: FaiPillTone.accent,
|
||
monospace: true,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
if (item.requiresServices.isNotEmpty) ...[
|
||
_SectionHeader(l.storeSectionRequiredHostServices),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Wrap(
|
||
spacing: FaiSpace.xs,
|
||
runSpacing: FaiSpace.xs,
|
||
children: [
|
||
for (final s in item.requiresServices)
|
||
FaiPill(
|
||
label: s,
|
||
tone: FaiPillTone.warning,
|
||
monospace: true,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
if (item.screenshotUrls.isNotEmpty) ...[
|
||
_SectionHeader(l.storeSectionScreenshots),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
SizedBox(
|
||
height: 220,
|
||
child: ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
itemCount: item.screenshotUrls.length,
|
||
separatorBuilder: (_, _) =>
|
||
const SizedBox(width: FaiSpace.sm),
|
||
itemBuilder: (context, i) => _Screenshot(
|
||
url: item.screenshotUrls[i],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
if (item.repository.isNotEmpty || item.docsUrl.isNotEmpty) ...[
|
||
_SectionHeader(l.storeSectionDocumentation),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
_DocsPanel(
|
||
future: _docsFuture,
|
||
onLoad: _loadDocs,
|
||
onOpenRepo: _openRepository,
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
_SectionHeader(l.storeSectionSource),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
InkWell(
|
||
onTap: _openRepository,
|
||
child: Row(
|
||
children: [
|
||
const Icon(Icons.open_in_new, size: 14),
|
||
const SizedBox(width: FaiSpace.xs),
|
||
Flexible(
|
||
child: Text(
|
||
item.repository,
|
||
style: FaiTheme.mono(
|
||
size: 11,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
],
|
||
if (_toast != null) ...[
|
||
Container(
|
||
padding: const EdgeInsets.all(FaiSpace.sm),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
border: Border.all(
|
||
color: theme.colorScheme.outlineVariant,
|
||
),
|
||
),
|
||
child: SelectableText(
|
||
_toast!,
|
||
style: FaiTheme.mono(size: 11),
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
],
|
||
Row(
|
||
children: [
|
||
if (item.repository.isNotEmpty)
|
||
OutlinedButton.icon(
|
||
onPressed: _openRepository,
|
||
icon: const Icon(Icons.menu_book_outlined, size: 16),
|
||
label: Text(l.buttonReadDocs),
|
||
),
|
||
const Spacer(),
|
||
if (item.installed) ...[
|
||
OutlinedButton.icon(
|
||
onPressed: _busy ? null : _uninstall,
|
||
icon: _busy
|
||
? const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child:
|
||
CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(Icons.delete_outline, size: 16),
|
||
label: Text(l.buttonUninstall),
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: theme.colorScheme.error,
|
||
side: BorderSide(
|
||
color: theme.colorScheme.error
|
||
.withValues(alpha: 0.5),
|
||
),
|
||
),
|
||
),
|
||
] else if (installable)
|
||
FilledButton.icon(
|
||
onPressed: _busy ? null : _install,
|
||
icon: _busy
|
||
? const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child:
|
||
CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(Icons.download, size: 16),
|
||
label: Text(
|
||
_busy ? l.storeInstallingButton : l.buttonInstall,
|
||
),
|
||
)
|
||
else
|
||
Tooltip(
|
||
message: l.storeNotInstallableTooltip(item.status),
|
||
child: FilledButton(
|
||
onPressed: null,
|
||
child: Text(l.storeNotInstallable),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SectionHeader extends StatelessWidget {
|
||
final String text;
|
||
const _SectionHeader(this.text);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Text(
|
||
text.toUpperCase(),
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
fontSize: 10,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _InstallProgressDialog extends StatefulWidget {
|
||
final StoreItem item;
|
||
|
||
const _InstallProgressDialog({required this.item});
|
||
|
||
@override
|
||
State<_InstallProgressDialog> createState() => _InstallProgressDialogState();
|
||
}
|
||
|
||
class _InstallProgressDialogState extends State<_InstallProgressDialog> {
|
||
late final Future<({String name, String version})> _future;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
// Capability-name install: hub resolves the wasm_url from the
|
||
// bundled store-index. No source prompt needed for entries
|
||
// whose seed.yaml already has wasm_url.
|
||
_future = HubService.instance.installModule(source: widget.item.name);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return AlertDialog(
|
||
title: Text(l.storeInstallProgressTitle(widget.item.name)),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
),
|
||
content: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 360),
|
||
child: FutureBuilder<({String name, String version})>(
|
||
future: _future,
|
||
builder: (context, snap) {
|
||
if (snap.connectionState == ConnectionState.waiting) {
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const SizedBox(height: FaiSpace.md),
|
||
const CircularProgressIndicator(),
|
||
const SizedBox(height: FaiSpace.md),
|
||
Text(
|
||
l.storeInstallProgressBody,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
if (snap.hasError) {
|
||
return SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.error_outline,
|
||
color: theme.colorScheme.error,
|
||
size: 32,
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
SelectableText(
|
||
snap.error.toString(),
|
||
style: FaiTheme.mono(
|
||
size: 11,
|
||
color: theme.colorScheme.error,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
final r = snap.data!;
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.check_circle_outline,
|
||
size: 32,
|
||
color: FaiColors.success,
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
Text(
|
||
l.storeInstalledToast(r.name, r.version),
|
||
style: theme.textTheme.titleMedium,
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, true),
|
||
child: Text(l.buttonClose),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
FaiPillTone _toneForStatus(String s) {
|
||
switch (s) {
|
||
case 'published':
|
||
return FaiPillTone.success;
|
||
case 'alpha':
|
||
return FaiPillTone.warning;
|
||
case 'planned':
|
||
return FaiPillTone.neutral;
|
||
default:
|
||
return FaiPillTone.neutral;
|
||
}
|
||
}
|
||
|
||
IconData _iconForCategory(String category) {
|
||
switch (category) {
|
||
case 'llm':
|
||
return Icons.psychology_outlined;
|
||
case 'text':
|
||
case 'text-processing':
|
||
return Icons.description_outlined;
|
||
case 'system':
|
||
return Icons.settings_outlined;
|
||
case 'orchestrator':
|
||
return Icons.account_tree_outlined;
|
||
case 'debug':
|
||
return Icons.bug_report_outlined;
|
||
case 'auth':
|
||
return Icons.lock_outline;
|
||
case 'storage':
|
||
return Icons.storage_outlined;
|
||
case 'channel':
|
||
return Icons.forum_outlined;
|
||
default:
|
||
return Icons.extension_outlined;
|
||
}
|
||
}
|
||
|
||
/// Lazy-loaded inline README renderer. Shows a "Load
|
||
/// documentation" button until the operator clicks it (so we
|
||
/// don't blow the network unprompted), then a markdown view of
|
||
/// the result. Auth / not-found errors come back with friendly
|
||
/// fix-hint copy and a fallback "View on repository" button.
|
||
class _DocsPanel extends StatelessWidget {
|
||
final Future<({String errorKind, String text, String sourceUrl})>? future;
|
||
final VoidCallback onLoad;
|
||
final VoidCallback onOpenRepo;
|
||
|
||
const _DocsPanel({
|
||
required this.future,
|
||
required this.onLoad,
|
||
required this.onOpenRepo,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
if (future == null) {
|
||
return Row(
|
||
children: [
|
||
OutlinedButton.icon(
|
||
onPressed: onLoad,
|
||
icon: const Icon(Icons.menu_book_outlined, size: 16),
|
||
label: Text(l.storeLoadDocs),
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Text(
|
||
l.storeDocsHint,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
return FutureBuilder<({String errorKind, String text, String sourceUrl})>(
|
||
future: future,
|
||
builder: (context, snap) {
|
||
if (snap.connectionState == ConnectionState.waiting) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.md),
|
||
child: Row(
|
||
children: [
|
||
const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Text(l.storeDocsFetching),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
if (snap.hasError) {
|
||
return _DocsErrorPanel(
|
||
message: snap.error.toString(),
|
||
onOpenRepo: onOpenRepo,
|
||
);
|
||
}
|
||
final r = snap.data!;
|
||
if (r.errorKind.isNotEmpty) {
|
||
return _DocsErrorPanel(
|
||
message: _friendlyDocsError(context, r.errorKind, r.text),
|
||
onOpenRepo: onOpenRepo,
|
||
sourceUrl: r.sourceUrl,
|
||
);
|
||
}
|
||
return Container(
|
||
width: double.infinity,
|
||
constraints: const BoxConstraints(maxHeight: 480),
|
||
padding: const EdgeInsets.all(FaiSpace.md),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
child: Markdown(
|
||
data: r.text,
|
||
shrinkWrap: true,
|
||
selectable: true,
|
||
onTapLink: (text, href, title) async {
|
||
if (href == null || href.isEmpty) return;
|
||
await SystemActions.openInOs(href);
|
||
},
|
||
styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith(
|
||
p: theme.textTheme.bodyMedium,
|
||
code: FaiTheme.mono(
|
||
size: 12,
|
||
color: theme.colorScheme.onSurface,
|
||
),
|
||
codeblockDecoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainer,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DocsErrorPanel extends StatelessWidget {
|
||
final String message;
|
||
final VoidCallback onOpenRepo;
|
||
final String? sourceUrl;
|
||
|
||
const _DocsErrorPanel({
|
||
required this.message,
|
||
required this.onOpenRepo,
|
||
this.sourceUrl,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(FaiSpace.md),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.info_outline,
|
||
size: 16,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Expanded(
|
||
child: Text(
|
||
message,
|
||
style: theme.textTheme.bodySmall,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (sourceUrl != null && sourceUrl!.isNotEmpty) ...[
|
||
const SizedBox(height: 4),
|
||
SelectableText(
|
||
sourceUrl!,
|
||
style: FaiTheme.mono(
|
||
size: 10,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: FaiSpace.sm),
|
||
OutlinedButton.icon(
|
||
onPressed: onOpenRepo,
|
||
icon: const Icon(Icons.open_in_new, size: 14),
|
||
label: Text(AppLocalizations.of(context)!.storeOpenRepoButton),
|
||
style: OutlinedButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Lightweight semver compare used by the "update available"
|
||
/// badge. Returns negative if a < b, 0 if equal, positive if
|
||
/// a > b. Splits on '.' and compares integer chunks; falls back
|
||
/// to lexicographic compare on non-numeric segments so
|
||
/// `0.10.0-rc.1` still sorts sensibly. Good enough for the
|
||
/// store's coarse "is there a newer publish" question — the
|
||
/// hub does its own strict resolution at install time.
|
||
int _versionCmp(String a, String b) {
|
||
final aParts = a.split('.');
|
||
final bParts = b.split('.');
|
||
final n = aParts.length > bParts.length ? aParts.length : bParts.length;
|
||
for (var i = 0; i < n; i++) {
|
||
final ap = i < aParts.length ? aParts[i] : '0';
|
||
final bp = i < bParts.length ? bParts[i] : '0';
|
||
final ai = int.tryParse(ap);
|
||
final bi = int.tryParse(bp);
|
||
if (ai != null && bi != null) {
|
||
if (ai != bi) return ai.compareTo(bi);
|
||
} else {
|
||
final cmp = ap.compareTo(bp);
|
||
if (cmp != 0) return cmp;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
String _friendlyDocsError(BuildContext context, String kind, String detail) {
|
||
final l = AppLocalizations.of(context)!;
|
||
switch (kind) {
|
||
case 'not_found':
|
||
return l.storeDocsErrorNotFound;
|
||
case 'no_docs':
|
||
return l.storeDocsErrorNoDocs;
|
||
case 'auth':
|
||
return l.storeDocsErrorAuth;
|
||
case 'network':
|
||
return l.storeDocsErrorNetwork(detail);
|
||
case 'parse':
|
||
return l.storeDocsErrorParse;
|
||
default:
|
||
return detail.isEmpty ? l.storeDocsErrorGeneric : detail;
|
||
}
|
||
}
|
||
|
||
/// Module icon: renders the explicit icon URL when set, falls
|
||
/// back to the category-derived `Icons.X` glyph in a colored
|
||
/// circle when the URL is empty or fails to load. Wraps the
|
||
/// network image in `frameBuilder` so a load failure doesn't
|
||
/// leave a broken-image rectangle in the hero header.
|
||
class _ModuleIcon extends StatelessWidget {
|
||
final String iconUrl;
|
||
final String category;
|
||
final double radius;
|
||
|
||
const _ModuleIcon({
|
||
required this.iconUrl,
|
||
required this.category,
|
||
required this.radius,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final fallback = CircleAvatar(
|
||
radius: radius,
|
||
backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.12),
|
||
child: Icon(
|
||
_iconForCategory(category),
|
||
size: radius * 1.0,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
);
|
||
if (iconUrl.isEmpty) return fallback;
|
||
return ClipOval(
|
||
child: SizedBox(
|
||
width: radius * 2,
|
||
height: radius * 2,
|
||
child: Image.network(
|
||
iconUrl,
|
||
fit: BoxFit.cover,
|
||
errorBuilder: (_, _, _) => fallback,
|
||
loadingBuilder: (_, child, progress) =>
|
||
progress == null ? child : fallback,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// One screenshot tile in the detail-sheet strip. Click opens
|
||
/// the image full-size via the OS handler — keeps Studio
|
||
/// chromeless. Renders a placeholder on load failure rather
|
||
/// than the default broken-image rectangle.
|
||
class _Screenshot extends StatelessWidget {
|
||
final String url;
|
||
const _Screenshot({required this.url});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Material(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
child: InkWell(
|
||
onTap: () => SystemActions.openInOs(url),
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
child: SizedBox(
|
||
width: 320,
|
||
child: Image.network(
|
||
url,
|
||
fit: BoxFit.cover,
|
||
errorBuilder: (_, _, _) => _ScreenshotPlaceholder(
|
||
label: 'image unavailable',
|
||
detail: url,
|
||
),
|
||
loadingBuilder: (_, child, progress) {
|
||
if (progress == null) return child;
|
||
return const _ScreenshotPlaceholder(
|
||
label: 'loading…',
|
||
detail: '',
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ScreenshotPlaceholder extends StatelessWidget {
|
||
final String label;
|
||
final String detail;
|
||
const _ScreenshotPlaceholder({required this.label, required this.detail});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Container(
|
||
color: theme.colorScheme.surfaceContainer,
|
||
child: Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(FaiSpace.md),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.image_outlined,
|
||
size: 32,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
label,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
if (detail.isNotEmpty) ...[
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
detail,
|
||
style: FaiTheme.mono(
|
||
size: 9,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Compiled-in fallback story shown when
|
||
/// `~/.fai/today/active.yaml` is absent or fails schema
|
||
/// validation. The pipeline operator can override at any time
|
||
/// without recompiling Studio — see docs/today-pipeline.md.
|
||
const TodayStoryData _kFallbackTodayStory = TodayStoryData(
|
||
badgeEn: 'CAPABILITY FEDERATION',
|
||
badgeDe: 'CAPABILITY-FEDERATION',
|
||
titleEn: 'Two MCP servers away from a richer hub',
|
||
titleDe: 'Zwei MCP-Server bis zu einem reicheren Hub',
|
||
bodyEn:
|
||
'The store ships with bundled native modules and curated planned bridges. Click a recommended public source below — DeepWiki for repository docs, Semgrep for security scans — and synthetic `mcp.<server>.<tool>` capabilities appear here within seconds. No Node, no API key, no subprocess.',
|
||
bodyDe:
|
||
'Der Store bringt native Module und kuratierte Bridge-Vorschläge mit. Klicke unten auf eine öffentliche Quelle — DeepWiki für Repo-Doku, Semgrep für Security-Scans — und synthetische `mcp.<server>.<tool>`-Capabilities erscheinen binnen Sekunden hier. Kein Node, kein API-Key, kein Subprozess.',
|
||
ctaLabelEn: 'Open MCP settings',
|
||
ctaLabelDe: 'MCP-Einstellungen öffnen',
|
||
icon: Icons.hub_outlined,
|
||
cta: TodayCta.openSettings,
|
||
);
|
||
|
||
/// 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;
|
||
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;
|
||
|
||
const _StoreTodayHero({
|
||
required this.story,
|
||
required this.locale,
|
||
required this.onDismiss,
|
||
required this.onCta,
|
||
});
|
||
|
||
@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,
|
||
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: [
|
||
Row(
|
||
children: [
|
||
FaiPill(
|
||
label: l.storeTodayBadge,
|
||
tone: FaiPillTone.accent,
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Text(
|
||
badge,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Text(
|
||
'·',
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Text(
|
||
l.storeTodayDeck,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
fontStyle: FontStyle.italic,
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Text(
|
||
title,
|
||
style: theme.textTheme.headlineSmall?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
height: 1.15,
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Text(
|
||
body,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
height: 1.45,
|
||
),
|
||
),
|
||
if (onCta != null) ...[
|
||
const SizedBox(height: FaiSpace.md),
|
||
FilledButton.tonalIcon(
|
||
onPressed: onCta,
|
||
icon: const Icon(Icons.arrow_forward, size: 14),
|
||
label: Text(ctaLabel),
|
||
style: FilledButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.close, size: 16),
|
||
visualDensity: VisualDensity.compact,
|
||
tooltip: l.storeTodayDismissTooltip,
|
||
onPressed: onDismiss,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Curated public MCP server that the operator can register
|
||
/// with one click — no Node, no API key, no subprocess. Fields
|
||
/// are kept const so the strip can be a `const` widget tree.
|
||
class _RecommendedSource {
|
||
/// Stable lowercase id used as the MCP-client name. Becomes
|
||
/// the prefix of every synthetic store entry the server emits
|
||
/// (`mcp.<name>.<tool>`).
|
||
final String name;
|
||
/// Display label shown on the card.
|
||
final String label;
|
||
final IconData icon;
|
||
/// HTTPS Streamable-HTTP endpoint. No auth required.
|
||
final String endpoint;
|
||
/// Bilingual description used as the MCP-client `description`
|
||
/// field — keeps the audit log in the operator's locale.
|
||
final String descriptionEn;
|
||
final String descriptionDe;
|
||
|
||
const _RecommendedSource({
|
||
required this.name,
|
||
required this.label,
|
||
required this.icon,
|
||
required this.endpoint,
|
||
required this.descriptionEn,
|
||
required this.descriptionDe,
|
||
});
|
||
}
|
||
|
||
/// Curated public MCP servers offered as one-click adds. Two
|
||
/// guardrails apply when picking entries: outbound HTTPS only
|
||
/// (no subprocess, no npm), and a small-enough tool surface
|
||
/// that the store stays browsable after the add (Useful AI was
|
||
/// considered but its 340+ tools would drown the index).
|
||
const _kRecommendedSources = <_RecommendedSource>[
|
||
_RecommendedSource(
|
||
name: 'deepwiki',
|
||
label: 'DeepWiki',
|
||
icon: Icons.menu_book_outlined,
|
||
endpoint: 'https://mcp.deepwiki.com/mcp',
|
||
descriptionEn: 'GitHub repo documentation search (AI-powered).',
|
||
descriptionDe: 'GitHub-Repo-Doku-Suche (KI-gestützt).',
|
||
),
|
||
_RecommendedSource(
|
||
name: 'semgrep',
|
||
label: 'Semgrep',
|
||
icon: Icons.security,
|
||
endpoint: 'https://mcp.semgrep.ai/mcp',
|
||
descriptionEn: 'Security scanning for code vulnerabilities.',
|
||
descriptionDe: 'Security-Scanning für Code-Schwachstellen.',
|
||
),
|
||
];
|
||
|
||
/// Recommended-sources strip — replaces the older Settings nudge.
|
||
/// Renders one card per [_kRecommendedSources] entry with a
|
||
/// single `[+ Add]` button so the operator can fill the store
|
||
/// without leaving the Store page. Hides as soon as any
|
||
/// federated entry exists.
|
||
class _RecommendedSourcesStrip extends StatelessWidget {
|
||
final Set<String> adding;
|
||
final String locale;
|
||
final void Function(_RecommendedSource) onAdd;
|
||
|
||
const _RecommendedSourcesStrip({
|
||
required this.adding,
|
||
required this.locale,
|
||
required this.onAdd,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(FaiSpace.md),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainer,
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.hub_outlined,
|
||
size: 18,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Text(
|
||
l.storeRecommendedTitle,
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
l.storeRecommendedBody,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
Wrap(
|
||
spacing: FaiSpace.md,
|
||
runSpacing: FaiSpace.sm,
|
||
children: [
|
||
for (final s in _kRecommendedSources)
|
||
_RecommendedSourceCard(
|
||
source: s,
|
||
locale: locale,
|
||
busy: adding.contains(s.name),
|
||
onAdd: () => onAdd(s),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _RecommendedSourceCard extends StatelessWidget {
|
||
final _RecommendedSource source;
|
||
final String locale;
|
||
final bool busy;
|
||
final VoidCallback onAdd;
|
||
|
||
const _RecommendedSourceCard({
|
||
required this.source,
|
||
required this.locale,
|
||
required this.busy,
|
||
required this.onAdd,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final desc = locale == 'de' ? source.descriptionDe : source.descriptionEn;
|
||
return SizedBox(
|
||
width: 320,
|
||
child: Container(
|
||
padding: const EdgeInsets.all(FaiSpace.sm),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(source.icon, size: 18, color: theme.colorScheme.primary),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
source.label,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
Text(
|
||
desc,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Tooltip(
|
||
message: busy
|
||
? l.storeRecommendedAdding
|
||
: l.storeRecommendedAddTooltip,
|
||
child: IconButton.filledTonal(
|
||
onPressed: busy ? null : onAdd,
|
||
icon: busy
|
||
? const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(Icons.add, size: 16),
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Small color-coded chip that names the entry's source: native
|
||
/// (muted), MCP (warning tone), or n8n (accent). The on-card
|
||
/// position lets the operator triage at a glance who emitted the
|
||
/// capability — the same role the "verified" badge plays in
|
||
/// commercial app stores.
|
||
class _ProvenancePill extends StatelessWidget {
|
||
final StoreItem item;
|
||
|
||
const _ProvenancePill({required this.item});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l = AppLocalizations.of(context)!;
|
||
final source = _sourceForItem(item);
|
||
switch (source) {
|
||
case 'mcp':
|
||
return FaiPill(
|
||
label: l.storeProvenanceMcp(item.provider),
|
||
tone: FaiPillTone.warning,
|
||
monospace: true,
|
||
icon: Icons.hub_outlined,
|
||
);
|
||
case 'n8n':
|
||
return FaiPill(
|
||
label: l.storeProvenanceN8n(item.provider),
|
||
tone: FaiPillTone.accent,
|
||
monospace: true,
|
||
icon: Icons.account_tree_outlined,
|
||
);
|
||
default:
|
||
return FaiPill(
|
||
label: l.storeProvenanceNative,
|
||
tone: FaiPillTone.neutral,
|
||
);
|
||
}
|
||
}
|
||
}
|