diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 5179608..8b2e31f 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -391,6 +391,9 @@ class HubService { status: e.status, installed: e.installed, featured: e.featured, + iconUrl: e.iconUrl, + screenshotUrls: e.screenshotUrls, + docsUrl: e.docsUrl, ), ) .toList(); @@ -1065,6 +1068,16 @@ class StoreItem { /// featured (curated quarterly in the bundled seed.yaml). /// Drives the "Featured" strip at the top of the Store page. final bool featured; + /// Optional icon URL — square PNG / SVG. Empty for entries + /// that don't supply one; UI falls back to a category icon. + final String iconUrl; + /// Ordered list of screenshot URLs; empty when none. Studio + /// renders them as a horizontally-scrollable strip in the + /// detail sheet. + final List screenshotUrls; + /// Explicit docs URL override. When empty, the hub falls back + /// to the well-known raw-README paths off [repository]. + final String docsUrl; const StoreItem({ required this.name, @@ -1082,5 +1095,8 @@ class StoreItem { required this.status, required this.installed, required this.featured, + required this.iconUrl, + required this.screenshotUrls, + required this.docsUrl, }); } diff --git a/lib/main.dart b/lib/main.dart index 392e942..18ca721 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,12 +19,13 @@ import 'pages/modules.dart'; import 'pages/store.dart'; import 'theme/theme.dart'; import 'theme/tokens.dart'; +import 'widgets/fai_search_palette.dart'; import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.20.1'; +const String kStudioVersion = '0.21.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -189,6 +190,33 @@ class _StudioShellState extends State { super.dispose(); } + void _openSearchPalette() { + final hits = [ + for (var i = 0; i < _pages.length; i++) + FaiSearchHit( + label: _pages[i].label, + hint: 'page · ⌘${i + 1}', + icon: _pages[i].icon, + group: 'Pages', + onSelect: () { + Navigator.of(context).pop(); + setState(() => _selectedIndex = i); + }, + ), + FaiSearchHit( + label: 'Settings', + hint: 'hub endpoint, channels, system AI · ⌘;', + icon: Icons.settings_outlined, + group: 'Pages', + onSelect: () { + Navigator.of(context).pop(); + FaiSettingsDialog.show(context); + }, + ), + ]; + FaiSearchPalette.show(context, staticHits: hits); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -212,6 +240,12 @@ class _StudioShellState extends State { const _OpenSettingsIntent(), const SingleActivator(LogicalKeyboardKey.comma, meta: true): const _OpenSettingsIntent(), + // Universal command palette. Mirrors Cmd+K from VSCode / + // Linear / 1Password — operators jump anywhere by typing. + const SingleActivator(LogicalKeyboardKey.keyK, meta: true): + const _OpenSearchIntent(), + const SingleActivator(LogicalKeyboardKey.keyK, control: true): + const _OpenSearchIntent(), }, child: Actions( actions: >{ @@ -227,6 +261,12 @@ class _StudioShellState extends State { return null; }, ), + _OpenSearchIntent: CallbackAction<_OpenSearchIntent>( + onInvoke: (_) { + _openSearchPalette(); + return null; + }, + ), }, child: Focus( autofocus: true, @@ -285,6 +325,10 @@ class _OpenSettingsIntent extends Intent { const _OpenSettingsIntent(); } +class _OpenSearchIntent extends Intent { + const _OpenSearchIntent(); +} + class _Sidebar extends StatelessWidget { final int selectedIndex; final ValueChanged onSelect; diff --git a/lib/pages/modules.dart b/lib/pages/modules.dart index d61871d..3df4929 100644 --- a/lib/pages/modules.dart +++ b/lib/pages/modules.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../data/hub.dart'; +import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; @@ -13,15 +14,25 @@ class ModulesPage extends StatefulWidget { class _ModulesPageState extends State { late Future> _future; + late Future> _historyFuture; @override void initState() { super.initState(); _future = HubService.instance.listModules(); + _historyFuture = _loadHistory(); + } + + Future> _loadHistory() { + return HubService.instance.recentEvents( + limit: 10, + types: const ['module.installed', 'module.uninstalled'], + ); } void _refresh() => setState(() { _future = HubService.instance.listModules(); + _historyFuture = _loadHistory(); }); @override @@ -67,25 +78,27 @@ class _ModulesPageState extends State { 'Run `fai install ` or check ~/.fai/modules/.', ); } - return ListView.separated( + return ListView( padding: const EdgeInsets.all(FaiSpace.xl), - itemCount: modules.length, - separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md), - itemBuilder: (context, i) { - final m = modules[i]; - return GestureDetector( - onTap: () async { - final uninstalled = - await FaiModuleSheet.show(context, m.name); - if (uninstalled) _refresh(); - }, - behavior: HitTestBehavior.opaque, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: _ModuleCard(module: m), + children: [ + _RecentActivityPanel(future: _historyFuture), + const SizedBox(height: FaiSpace.md), + for (var i = 0; i < modules.length; i++) ...[ + if (i > 0) const SizedBox(height: FaiSpace.md), + GestureDetector( + onTap: () async { + final uninstalled = + await FaiModuleSheet.show(context, modules[i].name); + if (uninstalled) _refresh(); + }, + behavior: HitTestBehavior.opaque, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: _ModuleCard(module: modules[i]), + ), ), - ); - }, + ], + ], ); }, ), @@ -158,3 +171,131 @@ class _ModuleCard extends StatelessWidget { } } + +/// Compact "Recent activity" header listing the last few +/// install / uninstall events from the audit log. Lets +/// operators trace "wait, when did that module appear?" +/// without leaving the Modules page. Hidden when the audit +/// log has no relevant events yet. +class _RecentActivityPanel extends StatelessWidget { + final Future> future; + const _RecentActivityPanel({required this.future}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return FutureBuilder>( + future: future, + builder: (context, snap) { + final events = snap.data ?? const []; + if (events.isEmpty) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.md, + vertical: FaiSpace.sm, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.history, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + 'RECENT ACTIVITY', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, + ), + ), + const SizedBox(width: FaiSpace.sm), + Text( + 'last ${events.length} install/uninstall events from the audit log', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: FaiSpace.xs), + for (final e in events) _ActivityRow(event: e), + ], + ), + ); + }, + ); + } +} + +class _ActivityRow extends StatelessWidget { + final AuditEvent event; + const _ActivityRow({required this.event}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final installed = event.type == 'module.installed'; + final accent = installed ? FaiColors.success : FaiColors.warning; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Icon( + installed ? Icons.add_circle_outline : Icons.remove_circle_outline, + size: 14, + color: accent, + ), + const SizedBox(width: 6), + SizedBox( + width: 150, + child: Text( + _formatTimestamp(event.timestamp.toLocal()), + style: FaiTheme.mono( + size: 10, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + '${installed ? "installed" : "uninstalled"} ' + '${event.moduleName ?? "(unknown)"}' + '${event.moduleVersion != null ? " v${event.moduleVersion}" : ""}', + style: theme.textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + +/// Locale-unambiguous timestamp shared by the activity rows. +/// Same-day events get HH:mm:ss; older events get the full +/// YYYY-MM-DD HH:mm:ss so an operator never wonders what day +/// "21:25" was. +String _formatTimestamp(DateTime local) { + final now = DateTime.now(); + final hh = local.hour.toString().padLeft(2, '0'); + final mm = local.minute.toString().padLeft(2, '0'); + final ss = local.second.toString().padLeft(2, '0'); + final time = '$hh:$mm:$ss'; + final sameDay = local.year == now.year && + local.month == now.month && + local.day == now.day; + if (sameDay) return time; + final mo = local.month.toString().padLeft(2, '0'); + final dd = local.day.toString().padLeft(2, '0'); + return '${local.year}-$mo-$dd $time'; +} diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 9df49eb..4e69083 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -1032,15 +1032,10 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - radius: 26, - backgroundColor: theme.colorScheme.primary - .withValues(alpha: 0.12), - child: Icon( - _iconForCategory(item.category), - size: 28, - color: theme.colorScheme.primary, - ), + _ModuleIcon( + iconUrl: item.iconUrl, + category: item.category, + radius: 28, ), const SizedBox(width: FaiSpace.lg), Expanded( @@ -1160,7 +1155,24 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { ), const SizedBox(height: FaiSpace.lg), ], - if (item.repository.isNotEmpty) ...[ + if (item.screenshotUrls.isNotEmpty) ...[ + _SectionHeader('Screenshots'), + const SizedBox(height: FaiSpace.sm), + SizedBox( + height: 220, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: item.screenshotUrls.length, + separatorBuilder: (_, _) => + const SizedBox(width: FaiSpace.sm), + itemBuilder: (context, i) => _Screenshot( + url: item.screenshotUrls[i], + ), + ), + ), + const SizedBox(height: FaiSpace.lg), + ], + if (item.repository.isNotEmpty || item.docsUrl.isNotEmpty) ...[ _SectionHeader('Documentation'), const SizedBox(height: FaiSpace.sm), _DocsPanel( @@ -1644,3 +1656,139 @@ String _friendlyDocsError(String kind, String detail) { return detail.isEmpty ? 'Could not load README.' : detail; } } + +/// Module icon: renders the explicit icon URL when set, falls +/// back to the category-derived `Icons.X` glyph in a colored +/// circle when the URL is empty or fails to load. Wraps the +/// network image in `frameBuilder` so a load failure doesn't +/// leave a broken-image rectangle in the hero header. +class _ModuleIcon extends StatelessWidget { + final String iconUrl; + final String category; + final double radius; + + const _ModuleIcon({ + required this.iconUrl, + required this.category, + required this.radius, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fallback = CircleAvatar( + radius: radius, + backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.12), + child: Icon( + _iconForCategory(category), + size: radius * 1.0, + color: theme.colorScheme.primary, + ), + ); + if (iconUrl.isEmpty) return fallback; + return ClipOval( + child: SizedBox( + width: radius * 2, + height: radius * 2, + child: Image.network( + iconUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => fallback, + loadingBuilder: (_, child, progress) => + progress == null ? child : fallback, + ), + ), + ); + } +} + +/// One screenshot tile in the detail-sheet strip. Click opens +/// the image full-size via the OS handler — keeps Studio +/// chromeless. Renders a placeholder on load failure rather +/// than the default broken-image rectangle. +class _Screenshot extends StatelessWidget { + final String url; + const _Screenshot({required this.url}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + child: InkWell( + onTap: () => SystemActions.openInOs(url), + borderRadius: BorderRadius.circular(FaiRadius.sm), + child: ClipRRect( + borderRadius: BorderRadius.circular(FaiRadius.sm), + child: SizedBox( + width: 320, + child: Image.network( + url, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _ScreenshotPlaceholder( + label: 'image unavailable', + detail: url, + ), + loadingBuilder: (_, child, progress) { + if (progress == null) return child; + return const _ScreenshotPlaceholder( + label: 'loading…', + detail: '', + ); + }, + ), + ), + ), + ), + ); + } +} + +class _ScreenshotPlaceholder extends StatelessWidget { + final String label; + final String detail; + const _ScreenshotPlaceholder({required this.label, required this.detail}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + color: theme.colorScheme.surfaceContainer, + child: Center( + child: Padding( + padding: const EdgeInsets.all(FaiSpace.md), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.image_outlined, + size: 32, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 4), + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (detail.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + detail, + style: FaiTheme.mono( + size: 9, + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/fai_search_palette.dart b/lib/widgets/fai_search_palette.dart new file mode 100644 index 0000000..53bf3c6 --- /dev/null +++ b/lib/widgets/fai_search_palette.dart @@ -0,0 +1,424 @@ +// 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 '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 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: 'installed module · v${m.version}', + icon: Icons.extension, + group: 'Modules', + onSelect: () { + Navigator.pop(context); + FaiModuleSheet.show(context, m.name); + }, + )); + } + for (final s in store) { + hits.add(FaiSearchHit( + label: s.name, + hint: s.taglineEn.isEmpty + ? 'store · ${s.category.isEmpty ? "uncategorized" : s.category}' + : 'store · ${s.taglineEn}', + icon: Icons.storefront_outlined, + group: 'Store', + // 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 == 'Store' && h.group == 'Pages') { + h.onSelect(); + return; + } + } + }, + )); + } + for (final f in flows) { + hits.add(FaiSearchHit( + label: f.name, + hint: 'saved flow', + icon: Icons.account_tree_outlined, + group: 'Flows', + onSelect: () { + Navigator.pop(context); + for (final h in widget.staticHits) { + if (h.label == 'Flows' && h.group == 'Pages') { + 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); + 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: + 'Jump anywhere — modules, store, flows, pages…', + 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 + ? 'Indexing…' + : 'No matches.', + 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: '↑↓ navigate'), + const SizedBox(width: 12), + _Hint(text: 'enter open'), + const SizedBox(width: 12), + _Hint(text: 'esc close'), + const Spacer(), + Text( + '${hits.length} result${hits.length == 1 ? "" : "s"}', + 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, + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index cb3b3a5..d3f9c85 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -79,7 +79,7 @@ packages: path: "../fai_dart_sdk" relative: true source: path - version: "0.10.1" + version: "0.11.0" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0121ed3..7b5aaf2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.20.1 +version: 0.21.0 environment: sdk: ^3.11.0-200.1.beta