Two threads.
Translation expansion: every visible string on the Doctor
page (section headers, status text, button labels, daemon-
control card, daemon-files panel, update banner) and the
Modules page (recent-activity strip, capabilities label,
uninstall dialog + toast) flips between DE and EN with the
sidebar toggle. Adds ~50 ARB keys split between
`app_en.arb` / `app_de.arb`. Pluralised + parametrised
strings (`{n} events verified`, `Running on {name}: {endpoint}`)
use the standard ICU placeholder syntax so future locales
slot in without code changes.
Sparse-store federation nudge: the Store page renders an
inline banner above the grid when the operator has zero
federated entries — single biggest store-fullness lever is
configuring an MCP server / n8n endpoint, so the banner
says exactly that and the button opens Settings straight
to the editor. Banner dismisses automatically the next
render after a federated entry appears.
Coverage stand for translation: Doctor + Modules + sidebar
+ page titles + nav + Cmd+K labels are bilingual. Settings
dialog, Approvals cards, Audit drilldown, MCP/n8n editors,
Store search hint + filter chips remain English-literal.
The infrastructure (ARB plumbing, generator hookup,
locale-notifier) means each follow-up surface is a
one-line ARB edit + one call-site swap.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
1855 lines
62 KiB
Dart
1855 lines
62 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 '../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);
|
||
return Scaffold(
|
||
backgroundColor: theme.scaffoldBackgroundColor,
|
||
appBar: AppBar(
|
||
title: const Text('Store'),
|
||
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: 'Reload',
|
||
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: 'Hub unreachable',
|
||
hint: 'Start the hub with `fai daemon start`.',
|
||
action: FilledButton.tonal(
|
||
onPressed: _runSearch,
|
||
child: const Text('Retry'),
|
||
),
|
||
);
|
||
}
|
||
final items = snap.data ?? const <StoreItem>[];
|
||
if (items.isEmpty) {
|
||
return FaiEmptyState(
|
||
icon: Icons.search_off,
|
||
title: 'No matches',
|
||
hint:
|
||
'Adjust the filters or clear the search to see all entries.',
|
||
action: FilledButton.tonal(
|
||
onPressed: () {
|
||
setState(() {
|
||
_category = '';
|
||
_status = '';
|
||
_installedOnly = false;
|
||
_queryCtrl.clear();
|
||
});
|
||
_runSearch();
|
||
},
|
||
child: const Text('Clear filters'),
|
||
),
|
||
);
|
||
}
|
||
// 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) {
|
||
return ValueListenableBuilder<TextEditingValue>(
|
||
valueListenable: controller,
|
||
builder: (_, value, _) {
|
||
return TextField(
|
||
controller: controller,
|
||
decoration: InputDecoration(
|
||
prefixIcon: const Icon(Icons.search, size: 20),
|
||
hintText: 'Search modules — name, tagline, tag, capability…',
|
||
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) {
|
||
if (categories.isEmpty) return const SizedBox.shrink();
|
||
return SizedBox(
|
||
height: 36,
|
||
child: ListView(
|
||
scrollDirection: Axis.horizontal,
|
||
children: [
|
||
_ChoiceChip(
|
||
label: 'All',
|
||
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);
|
||
return Row(
|
||
children: [
|
||
_ChoiceChip(
|
||
label: 'All',
|
||
selected: status.isEmpty,
|
||
onSelected: () => onStatus(''),
|
||
),
|
||
_ChoiceChip(
|
||
label: 'Published',
|
||
selected: status == 'published',
|
||
onSelected: () => onStatus('published'),
|
||
),
|
||
_ChoiceChip(
|
||
label: 'Alpha',
|
||
selected: status == 'alpha',
|
||
onSelected: () => onStatus('alpha'),
|
||
),
|
||
_ChoiceChip(
|
||
label: 'Planned',
|
||
selected: status == 'planned',
|
||
onSelected: () => onStatus('planned'),
|
||
),
|
||
const SizedBox(width: FaiSpace.md),
|
||
FilterChip(
|
||
label: const Text('Installed'),
|
||
selected: installedOnly,
|
||
onSelected: onInstalledOnly,
|
||
showCheckmark: true,
|
||
),
|
||
const Spacer(),
|
||
Text(
|
||
'$resultCount result${resultCount == 1 ? "" : "s"}',
|
||
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);
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(Icons.star, size: 14, color: FaiColors.success),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
'FEATURED',
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Text(
|
||
'curated picks · click for details',
|
||
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 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 ? '(no tagline)' : 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)
|
||
const FaiPill(
|
||
label: 'installed',
|
||
tone: FaiPillTone.success,
|
||
icon: Icons.check,
|
||
)
|
||
else if (installable)
|
||
FilledButton.icon(
|
||
onPressed: onInstall,
|
||
icon: const Icon(Icons.download, size: 16),
|
||
label: const Text('Install'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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 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:
|
||
'Installed ${installedVersion ?? "?"} — store has v${item.bestVersion}',
|
||
child: const FaiPill(
|
||
label: 'update',
|
||
tone: FaiPillTone.warning,
|
||
icon: Icons.system_update_alt,
|
||
),
|
||
)
|
||
else if (item.installed)
|
||
const FaiPill(
|
||
label: 'installed',
|
||
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 ? '(no tagline)' : 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('Update to ${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: const Text('Details'),
|
||
)
|
||
else if (installable)
|
||
FilledButton.icon(
|
||
onPressed: onInstall,
|
||
icon: const Icon(Icons.download, size: 14),
|
||
label: const Text('Install'),
|
||
style: FilledButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
)
|
||
else
|
||
OutlinedButton.icon(
|
||
onPressed: onTap,
|
||
icon: const Icon(Icons.info_outline, size: 14),
|
||
label: const Text('Details'),
|
||
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;
|
||
setState(() {
|
||
_busy = false;
|
||
_toast = '${r.name} v${r.version} installed.';
|
||
});
|
||
Navigator.pop(context, true);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_busy = false;
|
||
_toast = 'Install failed: $e';
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _uninstall() async {
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: const Text('Uninstall module?'),
|
||
content: Text(
|
||
'Removes ${widget.item.name} from this hub. Flows that '
|
||
'reference it will fail at the next run.',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: const Text('Cancel'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||
foregroundColor: Theme.of(ctx).colorScheme.onError,
|
||
),
|
||
child: const Text('Uninstall'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (ok != true || !mounted) return;
|
||
setState(() {
|
||
_busy = true;
|
||
_toast = null;
|
||
});
|
||
try {
|
||
final r = await HubService.instance.uninstallModule(widget.item.name);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_busy = false;
|
||
_toast = '${r.name} v${r.version} uninstalled.';
|
||
});
|
||
Navigator.pop(context, true);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_busy = false;
|
||
_toast = 'Uninstall failed: $e';
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _openRepository() async {
|
||
if (widget.item.repository.isEmpty) return;
|
||
final r = await SystemActions.openInOs(widget.item.repository);
|
||
if (!mounted) return;
|
||
if (!r.ok) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('Could not open browser: ${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 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)
|
||
const FaiPill(
|
||
label: 'installed',
|
||
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('Tags'),
|
||
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('Required capabilities'),
|
||
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('Required host services'),
|
||
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('Screenshots'),
|
||
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('Documentation'),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
_DocsPanel(
|
||
future: _docsFuture,
|
||
onLoad: _loadDocs,
|
||
onOpenRepo: _openRepository,
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
_SectionHeader('Source'),
|
||
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: const Text('Read docs'),
|
||
),
|
||
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: const Text('Uninstall'),
|
||
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 ? 'Installing…' : 'Install'),
|
||
)
|
||
else
|
||
Tooltip(
|
||
message:
|
||
'Status "${item.status}" — install path not yet wired.',
|
||
child: const FilledButton(
|
||
onPressed: null,
|
||
child: Text('Not installable'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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);
|
||
return AlertDialog(
|
||
title: Text('Installing ${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(
|
||
'Fetching, verifying, unpacking…',
|
||
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(
|
||
'${r.name} v${r.version} installed.',
|
||
style: theme.textTheme.titleMedium,
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, true),
|
||
child: const Text('Close'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
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);
|
||
if (future == null) {
|
||
return Row(
|
||
children: [
|
||
OutlinedButton.icon(
|
||
onPressed: onLoad,
|
||
icon: const Icon(Icons.menu_book_outlined, size: 16),
|
||
label: const Text('Load documentation'),
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Text(
|
||
'README rendered inline; uses the hub\'s registry token when needed.',
|
||
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 const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: FaiSpace.md),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
),
|
||
SizedBox(width: FaiSpace.sm),
|
||
Text('Fetching README…'),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
if (snap.hasError) {
|
||
return _DocsErrorPanel(
|
||
message: snap.error.toString(),
|
||
onOpenRepo: onOpenRepo,
|
||
);
|
||
}
|
||
final r = snap.data!;
|
||
if (r.errorKind.isNotEmpty) {
|
||
return _DocsErrorPanel(
|
||
message: _friendlyDocsError(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: const Text('Open repository in browser'),
|
||
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(String kind, String detail) {
|
||
switch (kind) {
|
||
case 'not_found':
|
||
return 'This module is not in the hub\'s store index.';
|
||
case 'no_docs':
|
||
return 'No repository URL is recorded for this module.';
|
||
case 'auth':
|
||
return 'The registry requires authentication to fetch this README. '
|
||
'Set FAI_REGISTRY_TOKEN in the daemon\'s environment '
|
||
'and restart the hub.';
|
||
case 'network':
|
||
return 'Network error while fetching the README. $detail';
|
||
case 'parse':
|
||
return 'README is not valid UTF-8.';
|
||
default:
|
||
return detail.isEmpty ? 'Could not load README.' : 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),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|