refactor: rename internal Fai* design system + fai_ helpers to chain
Some checks failed
Security / Security check (push) Failing after 2s
Some checks failed
Security / Security check (push) Failing after 2s
The Studio design system, widgets and helpers carried a Fai* / fai_ prefix (FaiSpace, FaiColors, FaiTheme, FaiLog, 17 fai_*.dart files, the faiBinary* l10n keys). Studio is the Ch∆In product, so rename them to Chain* / chain_ — carefully preserving English fail/failure/failed. Also fix stale references: the 'fai' binary in l10n strings -> 'chain', FAI_* env vars (FAI_BIN/DATA_DIR/MODULES_DIR/TODAY/BOOTSTRAP_TOKEN) -> CHAIN_*, fai_platform -> fai_chain, fai_hub -> chain_hub. Vendor security-hook tooling (FAI_BANNED_TERMS_FILE) + the .fai bundle ext left. flutter analyze + test: clean (20 passed). Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
68d23ab7dd
commit
891acd2ba2
52 changed files with 1225 additions and 1225 deletions
443
lib/widgets/chain_search_palette.dart
Normal file
443
lib/widgets/chain_search_palette.dart
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
// ChainSearchPalette — 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 'chain_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 ChainSearchHit {
|
||||
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;
|
||||
|
||||
ChainSearchHit({
|
||||
required this.label,
|
||||
required this.hint,
|
||||
required this.icon,
|
||||
required this.group,
|
||||
required this.onSelect,
|
||||
}) : haystack = '$label $hint $group'.toLowerCase();
|
||||
}
|
||||
|
||||
class ChainSearchPalette 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<ChainSearchHit> staticHits;
|
||||
|
||||
const ChainSearchPalette({super.key, required this.staticHits});
|
||||
|
||||
/// Convenience launcher — used from the Cmd+K shortcut.
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required List<ChainSearchHit> staticHits,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierColor: Colors.black54,
|
||||
builder: (_) => ChainSearchPalette(staticHits: staticHits),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ChainSearchPalette> createState() => _FaiSearchPaletteState();
|
||||
}
|
||||
|
||||
class _FaiSearchPaletteState extends State<ChainSearchPalette> {
|
||||
final _query = TextEditingController();
|
||||
final _focus = FocusNode();
|
||||
Future<List<ChainSearchHit>>? _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<ChainSearchHit>> _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 = <ChainSearchHit>[];
|
||||
for (final m in modules) {
|
||||
hits.add(
|
||||
ChainSearchHit(
|
||||
label: m.name,
|
||||
hint: l.searchInstalledModuleHint(m.version),
|
||||
icon: Icons.extension,
|
||||
group: l.searchGroupModules,
|
||||
onSelect: () {
|
||||
Navigator.pop(context);
|
||||
ChainModuleSheet.show(context, m.name);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
final storeGroup = l.searchGroupStore;
|
||||
final pagesGroup = l.searchGroupPages;
|
||||
for (final s in store) {
|
||||
hits.add(
|
||||
ChainSearchHit(
|
||||
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(
|
||||
ChainSearchHit(
|
||||
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<ChainSearchHit> _resultsFor(List<ChainSearchHit> dynamic_, String raw) {
|
||||
final query = raw.trim().toLowerCase();
|
||||
final all = [...widget.staticHits, ...dynamic_];
|
||||
if (query.isEmpty) return all.take(50).toList();
|
||||
final scored = <(int, ChainSearchHit)>[];
|
||||
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<ChainSearchHit>>(
|
||||
future: _dynamicFuture,
|
||||
builder: (context, snap) {
|
||||
final dyn = snap.data ?? const <ChainSearchHit>[];
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue