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

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

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

430 lines
17 KiB
Dart

// 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<FaiSearchHit> staticHits;
const FaiSearchPalette({super.key, required this.staticHits});
/// Convenience launcher — used from the Cmd+K shortcut.
static Future<void> show(
BuildContext context, {
required List<FaiSearchHit> staticHits,
}) {
return showDialog<void>(
context: context,
barrierColor: Colors.black54,
builder: (_) => FaiSearchPalette(staticHits: staticHits),
);
}
@override
State<FaiSearchPalette> createState() => _FaiSearchPaletteState();
}
class _FaiSearchPaletteState extends State<FaiSearchPalette> {
final _query = TextEditingController();
final _focus = FocusNode();
Future<List<FaiSearchHit>>? _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<List<FaiSearchHit>> _loadDynamic() async {
final svc = HubService.instance;
final l = AppLocalizations.of(context)!;
final results = await Future.wait([
svc.listModules().catchError((_) => <ModuleSummary>[]),
svc.searchStore(limit: 200).catchError((_) => <StoreItem>[]),
svc.listFlows().catchError((_) => <SavedFlow>[]),
]);
final modules = results[0] as List<ModuleSummary>;
final store = results[1] as List<StoreItem>;
final flows = results[2] as List<SavedFlow>;
final hits = <FaiSearchHit>[];
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<FaiSearchHit> _resultsFor(List<FaiSearchHit> 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<List<FaiSearchHit>>(
future: _dynamicFuture,
builder: (context, snap) {
final dyn = snap.data ?? const <FaiSearchHit>[];
return ValueListenableBuilder<TextEditingValue>(
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,
),
);
}
}