- 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>
430 lines
17 KiB
Dart
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,
|
|
),
|
|
);
|
|
}
|
|
}
|