chain-studio/lib/pages/store.dart
flemming-it e2b2639a86 feat(studio): Store + Cmd+K palette i18n (v0.27.0)
- Localize Store page: search hint, category and status filter
  chips (All / Published / Alpha / Planned / Installed),
  pluralized result count, hub-unreachable / no-matches empty
  states, featured strip header, store-card pills (installed /
  update / version), Install / Update / Details / Read docs /
  Uninstall / Not installable buttons, install-progress dialog,
  uninstall confirm dialog, install / uninstall toasts,
  open-browser failure toast.
- Localize detail-sheet sections: Tags, Required capabilities,
  Required host services, Screenshots, Documentation, Source.
- Localize docs panel: Load documentation button, fetching
  spinner copy, Open repository in browser button, friendly
  error mapping (not_found / no_docs / auth / network / parse).
- Localize Cmd+K palette: search hint, group labels (Pages /
  Modules / Store / Flows), keyboard hint footer, indexing /
  no-matches states, dynamic-hit copy ("installed module ·
  v{version}", "saved flow", "uncategorized"). Static page
  hits now flow through the localized "Pages" group; Settings
  entry uses the localized label and hint.

Status pill values ("published", "alpha", "planned") stay as
data-derived English strings since they map directly to the
store-index `status:` enum.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-08 02:49:18 +02:00

1869 lines
63 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Store browser. Lists modules from the hub's bundled
// store-index. Top-level surface mirrors the look operators
// expect from app stores (search bar, category strip, filter
// chips, status chips), with a per-entry detail sheet that
// renders the full bilingual description, requires-list, and
// repository link.
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import '../data/hub.dart';
import '../data/system_actions.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 = '';
bool _installedOnly = false;
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 items = snap.data ?? const <StoreItem>[];
final categories = _categoriesIn(items);
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,
installedOnly: _installedOnly,
onStatus: (v) {
setState(() => _status = v);
_runSearch();
},
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 items = snap.data ?? const <StoreItem>[];
if (items.isEmpty) {
return FaiEmptyState(
icon: Icons.search_off,
title: l.storeNoMatches,
hint: l.storeNoMatchesHint,
action: FilledButton.tonal(
onPressed: () {
setState(() {
_category = '';
_status = '';
_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 &&
!_installedOnly &&
items.any((e) => e.featured);
// Sparse-store nudge: when no filter is set
// and the store carries no federated entries,
// hint that adding an MCP / n8n endpoint
// brings dozens of capabilities at once.
// Hides the moment any federated entry shows
// up — the nudge has done its job.
final showFederationNudge =
_queryCtrl.text.trim().isEmpty &&
_category.isEmpty &&
_status.isEmpty &&
!_installedOnly &&
!items.any((e) => e.isFederated);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showFederationNudge) ...[
const _FederationNudge(),
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;
}
}
/// 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 bool installedOnly;
final void Function(String) onStatus;
final void Function(bool) onInstalledOnly;
final int resultCount;
const _FilterRow({
required this.status,
required this.installedOnly,
required this.onStatus,
required this.onInstalledOnly,
required this.resultCount,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Row(
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'),
),
const SizedBox(width: FaiSpace.md),
FilterChip(
label: Text(l.storeInstalledOnly),
selected: installedOnly,
onSelected: onInstalledOnly,
showCheckmark: true,
),
const Spacer(),
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 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,
),
],
],
),
),
),
);
}
}
/// Sparse-store nudge: the operator's store has only the
/// bundled native + planned modules and no federated entries.
/// The single biggest store-fullness lever is configuring an
/// MCP server or n8n endpoint, so the banner says exactly
/// that. Tap → Settings dialog opens straight to the editor.
/// Dismisses automatically the next render after a federated
/// entry shows up — no per-user "don't show again".
class _FederationNudge extends StatelessWidget {
const _FederationNudge();
@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: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.hub_outlined,
size: 22,
color: theme.colorScheme.primary,
),
const SizedBox(width: FaiSpace.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.storeFederationNudgeTitle,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
l.storeFederationNudgeBody,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: FaiSpace.md),
FilledButton.tonalIcon(
onPressed: () => FaiSettingsDialog.show(context),
icon: const Icon(Icons.settings_outlined, size: 16),
label: Text(l.storeFederationNudgeButton),
),
],
),
);
}
}