// FaiSearchPalette — global Cmd+K command bar. // // Indexes: nav destinations, installed modules, store entries, // saved flows. Filters client-side as the operator types. // Selected result navigates / opens / acts in one keystroke. // // Triggered from the shell-level Shortcuts/Actions binding so // the palette is reachable from every page. import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../data/hub.dart'; import '../l10n/app_localizations.dart'; import 'fai_module_sheet.dart'; /// One row in the palette. Carries everything needed to render /// + dispatch — keep it serializable-shaped so we can persist /// recent picks later. class FaiSearchHit { final String label; final String hint; final IconData icon; /// Free-form group used for the section header in the /// results list ("Pages", "Modules", "Store", "Flows"). final String group; /// Lowercase haystack — name + hint + group. Filtered against /// the lowercased query. final String haystack; /// Action to run when this hit is selected. final VoidCallback onSelect; FaiSearchHit({ required this.label, required this.hint, required this.icon, required this.group, required this.onSelect, }) : haystack = '$label $hint $group'.toLowerCase(); } class FaiSearchPalette extends StatefulWidget { /// Pages the operator can navigate to. Each entry maps to a /// callback that selects the right tab back in the shell. final List staticHits; const FaiSearchPalette({super.key, required this.staticHits}); /// Convenience launcher — used from the Cmd+K shortcut. static Future show( BuildContext context, { required List staticHits, }) { return showDialog( context: context, barrierColor: Colors.black54, builder: (_) => FaiSearchPalette(staticHits: staticHits), ); } @override State createState() => _FaiSearchPaletteState(); } class _FaiSearchPaletteState extends State { final _query = TextEditingController(); final _focus = FocusNode(); Future>? _dynamicFuture; int _highlight = 0; @override void initState() { super.initState(); _dynamicFuture = _loadDynamic(); // Autofocus is handled by the TextField, but the focus // node also catches arrow keys for keyboard navigation. WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _focus.requestFocus(); }); } @override void dispose() { _query.dispose(); _focus.dispose(); super.dispose(); } /// Pull modules / store / flows in parallel so the palette /// is searchable as soon as the operator finishes typing the /// first character. Future> _loadDynamic() async { final svc = HubService.instance; final l = AppLocalizations.of(context)!; final results = await Future.wait([ svc.listModules().catchError((_) => []), svc.searchStore(limit: 200).catchError((_) => []), svc.listFlows().catchError((_) => []), ]); final modules = results[0] as List; final store = results[1] as List; final flows = results[2] as List; final hits = []; for (final m in modules) { hits.add( FaiSearchHit( label: m.name, hint: l.searchInstalledModuleHint(m.version), icon: Icons.extension, group: l.searchGroupModules, onSelect: () { Navigator.pop(context); FaiModuleSheet.show(context, m.name); }, ), ); } final storeGroup = l.searchGroupStore; final pagesGroup = l.searchGroupPages; for (final s in store) { hits.add( FaiSearchHit( label: s.name, hint: s.taglineEn.isEmpty ? l.searchStoreHintWithCategory( s.category.isEmpty ? l.searchStoreUncategorized : s.category, ) : l.searchStoreHintWithTagline(s.taglineEn), icon: Icons.storefront_outlined, group: storeGroup, // Selecting a store hit just closes the palette and // focuses the Store tab; deep-linking to the detail // sheet would require thread-through plumbing we don't // have yet. onSelect: () { Navigator.pop(context); for (final h in widget.staticHits) { if (h.label == storeGroup && h.group == pagesGroup) { h.onSelect(); return; } } }, ), ); } final flowsGroup = l.searchGroupFlows; for (final f in flows) { hits.add( FaiSearchHit( label: f.name, hint: l.searchSavedFlowHint, icon: Icons.account_tree_outlined, group: flowsGroup, onSelect: () { Navigator.pop(context); for (final h in widget.staticHits) { if (h.label == flowsGroup && h.group == pagesGroup) { h.onSelect(); return; } } }, ), ); } return hits; } /// Filtered + grouped result list. Empty query returns the /// pages first then everything else, capped to 50 rows so the /// dialog stays scrollable. List _resultsFor(List dynamic_, String raw) { final query = raw.trim().toLowerCase(); final all = [...widget.staticHits, ...dynamic_]; if (query.isEmpty) return all.take(50).toList(); final scored = <(int, FaiSearchHit)>[]; for (final h in all) { final i = h.haystack.indexOf(query); if (i < 0) continue; // Prefer label-prefix matches, then any substring. final isLabelPrefix = h.label.toLowerCase().startsWith(query); final score = (isLabelPrefix ? 0 : 1000) + i; scored.add((score, h)); } scored.sort((a, b) => a.$1.compareTo(b.$1)); return [for (final s in scored) s.$2].take(50).toList(); } KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event, int hitCount) { if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return KeyEventResult.ignored; } if (event.logicalKey == LogicalKeyboardKey.arrowDown) { setState(() { _highlight = (_highlight + 1).clamp(0, hitCount - 1); }); return KeyEventResult.handled; } if (event.logicalKey == LogicalKeyboardKey.arrowUp) { setState(() { _highlight = (_highlight - 1).clamp(0, hitCount - 1); }); return KeyEventResult.handled; } if (event.logicalKey == LogicalKeyboardKey.escape) { Navigator.pop(context); return KeyEventResult.handled; } return KeyEventResult.ignored; } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Dialog( alignment: Alignment.topCenter, insetPadding: const EdgeInsets.only(top: 80, left: 24, right: 24), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 640, maxHeight: 540), child: FutureBuilder>( future: _dynamicFuture, builder: (context, snap) { final dyn = snap.data ?? const []; return ValueListenableBuilder( valueListenable: _query, builder: (_, value, _) { final hits = _resultsFor(dyn, value.text); if (_highlight >= hits.length) { _highlight = hits.isEmpty ? 0 : hits.length - 1; } return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Focus( focusNode: _focus, onKeyEvent: (n, e) => _onKeyEvent(n, e, hits.length), child: TextField( controller: _query, autofocus: true, decoration: InputDecoration( prefixIcon: const Icon(Icons.search, size: 20), hintText: l.searchHint, border: InputBorder.none, suffixIcon: snap.connectionState == ConnectionState.waiting ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator( strokeWidth: 2, ), ) : null, ), style: theme.textTheme.titleMedium, onSubmitted: (_) { if (hits.isNotEmpty) { hits[_highlight].onSelect(); } }, ), ), ), const Divider(height: 1), Flexible( child: hits.isEmpty ? Padding( padding: const EdgeInsets.all(24), child: Text( snap.connectionState == ConnectionState.waiting ? l.searchIndexing : l.searchNoMatches, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ) : ListView.builder( padding: const EdgeInsets.symmetric(vertical: 4), itemCount: hits.length, itemBuilder: (context, i) { final h = hits[i]; final highlighted = i == _highlight; final showHeader = i == 0 || hits[i - 1].group != h.group; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (showHeader) Padding( padding: const EdgeInsets.fromLTRB( 16, 8, 16, 4, ), child: Text( h.group.toUpperCase(), style: theme.textTheme.labelSmall ?.copyWith( color: theme .colorScheme .onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), ), InkWell( onTap: h.onSelect, onHover: (hovered) { if (hovered) { setState(() => _highlight = i); } }, child: Container( color: highlighted ? theme .colorScheme .surfaceContainerHigh : null, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), child: Row( children: [ Icon( h.icon, size: 18, color: theme.colorScheme.primary, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( h.label, style: theme .textTheme .bodyMedium ?.copyWith( fontWeight: FontWeight.w500, ), ), if (h.hint.isNotEmpty) Text( h.hint, style: theme .textTheme .bodySmall ?.copyWith( color: theme .colorScheme .onSurfaceVariant, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), if (highlighted) Icon( Icons.keyboard_return, size: 14, color: theme .colorScheme .onSurfaceVariant, ), ], ), ), ), ], ); }, ), ), const Divider(height: 1), Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 6, ), child: Row( children: [ _Hint(text: l.searchHintNavigate), const SizedBox(width: 12), _Hint(text: l.searchHintOpen), const SizedBox(width: 12), _Hint(text: l.searchHintClose), const Spacer(), Text( l.storeNResults(hits.length), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ], ); }, ); }, ), ), ); } } class _Hint extends StatelessWidget { final String text; const _Hint({required this.text}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Text( text, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontSize: 10, ), ); } }