// Store browser. Lists modules from the hub's bundled // store-index, with category + status filters and a one-click // install action. Mirrors `fai store search` on the CLI side. import 'package:flutter/material.dart'; import '../data/hub.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; class StorePage extends StatefulWidget { const StorePage({super.key}); @override State createState() => _StorePageState(); } class _StorePageState extends State { final _queryCtrl = TextEditingController(); String _category = ''; String _status = ''; late Future> _future; @override void initState() { super.initState(); _future = _load(); } @override void dispose() { _queryCtrl.dispose(); super.dispose(); } Future> _load() { return HubService.instance.searchStore( query: _queryCtrl.text.trim(), category: _category, status: _status, ); } void _runSearch() => setState(() => _future = _load()); Future _install(StoreItem item) async { // The store entry doesn't carry the bundle URL; for Phase 0.5 // we prompt the operator for the source (URL or local path). // Future store entries will include `download_url` per // platform. final source = await _SourcePromptDialog.show(context, item); if (source == null || source.isEmpty) return; if (!mounted) return; showDialog( context: context, barrierDismissible: false, builder: (_) => _InstallProgressDialog(item: item, source: source), ).then((_) => _runSearch()); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( title: const Text('Store'), actions: [ IconButton( icon: const Icon(Icons.refresh, size: 18), tooltip: 'Reload', onPressed: _runSearch, ), const SizedBox(width: FaiSpace.sm), ], ), body: Padding( padding: const EdgeInsets.all(FaiSpace.xl), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SearchBar( controller: _queryCtrl, category: _category, status: _status, onCategoryChanged: (v) { setState(() => _category = v); _runSearch(); }, onStatusChanged: (v) { setState(() => _status = v); _runSearch(); }, onSubmit: _runSearch, ), const SizedBox(height: FaiSpace.lg), Expanded( child: FutureBuilder>( 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 []; if (items.isEmpty) { return const FaiEmptyState( icon: Icons.store_outlined, title: 'No matches', hint: 'Adjust the filters or clear the search to see all entries.', ); } return ListView.separated( padding: EdgeInsets.zero, itemCount: items.length, separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md), itemBuilder: (context, i) => _StoreCard(item: items[i], onInstall: () => _install(items[i])), ); }, ), ), ], ), ), ); } } class _SearchBar extends StatelessWidget { final TextEditingController controller; final String category; final String status; final void Function(String) onCategoryChanged; final void Function(String) onStatusChanged; final VoidCallback onSubmit; const _SearchBar({ required this.controller, required this.category, required this.status, required this.onCategoryChanged, required this.onStatusChanged, required this.onSubmit, }); @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: TextField( controller: controller, decoration: const InputDecoration( prefixIcon: Icon(Icons.search, size: 18), hintText: 'search name, tagline, tag…', border: OutlineInputBorder(), isDense: true, ), onSubmitted: (_) => onSubmit(), ), ), const SizedBox(width: FaiSpace.md), _DropdownFilter( label: 'Category', value: category, options: const ['', 'llm', 'text', 'system', 'orchestrator'], onChanged: onCategoryChanged, ), const SizedBox(width: FaiSpace.md), _DropdownFilter( label: 'Status', value: status, options: const ['', 'published', 'alpha', 'planned'], onChanged: onStatusChanged, ), ], ); } } class _DropdownFilter extends StatelessWidget { final String label; final String value; final List options; final void Function(String) onChanged; const _DropdownFilter({ required this.label, required this.value, required this.options, required this.onChanged, }); @override Widget build(BuildContext context) { return SizedBox( width: 160, child: DropdownButtonFormField( initialValue: value, isDense: true, decoration: InputDecoration( labelText: label, border: const OutlineInputBorder(), isDense: true, ), items: options .map( (o) => DropdownMenuItem( value: o, child: Text(o.isEmpty ? 'any' : o), ), ) .toList(), onChanged: (v) => onChanged(v ?? ''), ), ); } } class _StoreCard extends StatelessWidget { final StoreItem item; final VoidCallback onInstall; const _StoreCard({required this.item, required this.onInstall}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return FaiCard( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( item.installed ? Icons.check_circle_outline : Icons.extension_outlined, size: 20, color: item.installed ? FaiColors.success : theme.colorScheme.primary, ), const SizedBox(width: FaiSpace.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( item.name, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(width: FaiSpace.sm), 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), ), ], ), if (item.taglineEn.isNotEmpty) ...[ const SizedBox(height: 2), Text( item.taglineEn, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], const SizedBox(height: FaiSpace.xs), Wrap( spacing: FaiSpace.xs, runSpacing: FaiSpace.xs, children: [ if (item.category.isNotEmpty) FaiPill( label: item.category, tone: FaiPillTone.neutral, ), for (final t in item.tags.take(4)) FaiPill(label: t, tone: FaiPillTone.neutral), ], ), ], ), ), const SizedBox(width: FaiSpace.md), if (item.installed) FilledButton.tonalIcon( onPressed: null, icon: const Icon(Icons.check, size: 16), label: const Text('Installed'), ) else FilledButton.icon( onPressed: onInstall, icon: const Icon(Icons.download, size: 16), label: const Text('Install'), ), ], ), ); } 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; } } } class _SourcePromptDialog extends StatefulWidget { final StoreItem item; const _SourcePromptDialog({required this.item}); static Future show(BuildContext context, StoreItem item) { return showDialog( context: context, builder: (_) => _SourcePromptDialog(item: item), ); } @override State<_SourcePromptDialog> createState() => _SourcePromptDialogState(); } class _SourcePromptDialogState extends State<_SourcePromptDialog> { final _ctrl = TextEditingController(); @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return AlertDialog( title: Text('Install ${widget.item.name}'), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(FaiRadius.md), ), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'The store index does not yet ship binary URLs. Paste the ' '`.fai` bundle source — a https URL or a local filesystem ' 'path — and the hub will fetch + verify + unpack.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: FaiSpace.md), TextField( controller: _ctrl, autofocus: true, style: FaiTheme.mono(size: 12), decoration: const InputDecoration( labelText: 'source', hintText: 'https://… or /path/to/bundle.fai', border: OutlineInputBorder(), isDense: true, ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context, null), child: const Text('Cancel'), ), FilledButton.icon( icon: const Icon(Icons.download, size: 16), label: const Text('Install'), onPressed: () => Navigator.pop(context, _ctrl.text.trim()), ), ], ); } } class _InstallProgressDialog extends StatefulWidget { final StoreItem item; final String source; const _InstallProgressDialog({required this.item, required this.source}); @override State<_InstallProgressDialog> createState() => _InstallProgressDialogState(); } class _InstallProgressDialogState extends State<_InstallProgressDialog> { late final Future<({String name, String version})> _future; @override void initState() { super.initState(); _future = HubService.instance.installModule(source: widget.source); } @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), child: const Text('Close'), ), ], ); } }