chain-studio/lib/pages/store.dart
flemming-it 93926741b1 feat(studio): channel switcher, autostart toggle, Store redesign (v0.18.0)
UI parity for operators who never touch a CLI, plus a Store
that reads more like an app store.

Settings dialog:
- Channel rows gain a popup menu: Connect / Make active /
  Enable autostart / Disable autostart. "Connect" still
  re-points Studio's wire; "Make active" actually writes
  ~/.fai/current-channel via `fai channel switch`.
- Inline output panel surfaces the spawned binary's stdout
  on success or stderr on failure, so operators see what
  happened without opening a terminal.

Store page rewrite:
- Big top search bar with a clear button. Live filter on
  every keystroke.
- Horizontal category strip auto-populated from the index;
  segmented status row (All / Published / Alpha / Planned),
  Installed-only chip, result count.
- Grid of cards that reflows to fit the viewport — replaces
  the previous single-column list. Each card shows
  category-aware icon, version, status, tagline preview, and
  a one-click Install (or Details for installed / planned).
- Per-module detail sheet renders the full bilingual
  description with a DE/EN toggle, separate Required-
  capabilities + Required-host-services sections, repo link,
  Read-docs button. Install + Uninstall live at the bottom.
- StoreItem and HubService.searchStore now carry the German
  tagline + description so the locale toggle has something
  to switch to.

SystemActions extended with `faiChannelSwitch`,
`faiDaemonEnable`, `faiDaemonDisable` so Settings can spawn
the right CLI without each call site reimplementing the
`fai` resolution rules.

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

1120 lines
37 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 '../data/hub.dart';
import '../data/system_actions.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;
String _locale = 'en';
late Future<List<StoreItem>> _future;
@override
void initState() {
super.initState();
_future = _load();
}
@override
void dispose() {
_queryCtrl.dispose();
super.dispose();
}
Future<List<StoreItem>> _load() async {
final all = await HubService.instance.searchStore(
query: _queryCtrl.text.trim(),
category: _category,
status: _status,
// Pull a generous page so the local "installed only"
// filter does not bite into early results.
limit: 200,
);
return _installedOnly ? all.where((e) => e.installed).toList() : all;
}
void _runSearch() => 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: [
// Tiny DE/EN toggle — the store-index ships bilingual
// copy and operators in regulated environments often
// share screens with non-English colleagues.
_LocaleToggle(
value: _locale,
onChanged: (v) => setState(() => _locale = v),
),
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'),
),
);
}
return _StoreGrid(
items: items,
locale: _locale,
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,
),
);
}
}
class _LocaleToggle extends StatelessWidget {
final String value;
final ValueChanged<String> onChanged;
const _LocaleToggle({required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: FaiSpace.sm),
child: Tooltip(
message: value == 'en'
? 'Showing English copy. Click to switch to German.'
: 'Deutsche Beschreibung — Click for English.',
child: TextButton.icon(
onPressed: () => onChanged(value == 'en' ? 'de' : 'en'),
icon: const Icon(Icons.translate, size: 16),
label: Text(value.toUpperCase()),
),
),
);
}
}
/// 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;
final ValueChanged<StoreItem> onTap;
final ValueChanged<StoreItem> onInstall;
const _StoreGrid({
required this.items,
required this.locale,
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,
onTap: () => onTap(items[i]),
onInstall: () => onInstall(items[i]),
),
);
},
);
}
}
class _StoreCard extends StatelessWidget {
final StoreItem item;
final String locale;
final VoidCallback onTap;
final VoidCallback onInstall;
const _StoreCard({
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: 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 (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 (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> {
late String _locale = widget.locale;
bool _busy = false;
String? _toast;
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}')),
);
}
}
@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: [
CircleAvatar(
radius: 26,
backgroundColor: theme.colorScheme.primary
.withValues(alpha: 0.12),
child: Icon(
_iconForCategory(item.category),
size: 28,
color: theme.colorScheme.primary,
),
),
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,
),
],
),
],
),
),
_LocaleToggle(
value: _locale,
onChanged: (v) => setState(() => _locale = v),
),
],
),
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.repository.isNotEmpty) ...[
_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;
}
}