feat(studio): Cmd+K palette, recent-activity panel, icon/screenshot rendering (v0.21.0)
Three sweeps in a single bundle. Universal command palette (⌘K / ctrl+K): - Modal at top-of-window with a single search input. Indexes pages, installed modules, store entries, saved flows on open; filters client-side as the operator types. - Keyboard nav (↑/↓ + Enter), grouped sections, hover-to- highlight. Hit selection navigates / opens the right surface in one keystroke. Closes with Esc. - Designed to mirror the VSCode / Linear / 1Password ergonomics — gives non-CLI operators a "jump anywhere" affordance that scales with the number of installed modules. Modules page recent-activity panel: - Top-of-page strip lists the last 10 install / uninstall events from the audit log, color-coded by direction. Hidden when no relevant events exist (fresh installs). Same locale-unambiguous timestamp format used in audit. Store detail sheet now renders icons + screenshots: - `_ModuleIcon` widget loads the explicit `iconUrl` if provided, falls back to the category glyph on missing URL or load failure (no broken-image rectangle). - Screenshots strip below the description: 320×200 tiles, horizontally-scrollable, click opens full-size via the OS handler. Placeholder card on load failure. StoreItem extended with `iconUrl`, `screenshotUrls`, `docsUrl` so the new content paths through unchanged. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
fb7352d182
commit
11b0ac97a1
7 changed files with 803 additions and 30 deletions
|
|
@ -391,6 +391,9 @@ class HubService {
|
||||||
status: e.status,
|
status: e.status,
|
||||||
installed: e.installed,
|
installed: e.installed,
|
||||||
featured: e.featured,
|
featured: e.featured,
|
||||||
|
iconUrl: e.iconUrl,
|
||||||
|
screenshotUrls: e.screenshotUrls,
|
||||||
|
docsUrl: e.docsUrl,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
@ -1065,6 +1068,16 @@ class StoreItem {
|
||||||
/// featured (curated quarterly in the bundled seed.yaml).
|
/// featured (curated quarterly in the bundled seed.yaml).
|
||||||
/// Drives the "Featured" strip at the top of the Store page.
|
/// Drives the "Featured" strip at the top of the Store page.
|
||||||
final bool featured;
|
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<String> 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({
|
const StoreItem({
|
||||||
required this.name,
|
required this.name,
|
||||||
|
|
@ -1082,5 +1095,8 @@ class StoreItem {
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.installed,
|
required this.installed,
|
||||||
required this.featured,
|
required this.featured,
|
||||||
|
required this.iconUrl,
|
||||||
|
required this.screenshotUrls,
|
||||||
|
required this.docsUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,13 @@ import 'pages/modules.dart';
|
||||||
import 'pages/store.dart';
|
import 'pages/store.dart';
|
||||||
import 'theme/theme.dart';
|
import 'theme/theme.dart';
|
||||||
import 'theme/tokens.dart';
|
import 'theme/tokens.dart';
|
||||||
|
import 'widgets/fai_search_palette.dart';
|
||||||
import 'widgets/widgets.dart';
|
import 'widgets/widgets.dart';
|
||||||
|
|
||||||
/// Studio's own build version. Bump on every UI commit so the
|
/// Studio's own build version. Bump on every UI commit so the
|
||||||
/// running app self-identifies — visible in the sidebar header
|
/// running app self-identifies — visible in the sidebar header
|
||||||
/// and quick-glance proof that you're seeing the current build.
|
/// and quick-glance proof that you're seeing the current build.
|
||||||
const String kStudioVersion = '0.20.1';
|
const String kStudioVersion = '0.21.0';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
@ -189,6 +190,33 @@ class _StudioShellState extends State<StudioShell> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openSearchPalette() {
|
||||||
|
final hits = <FaiSearchHit>[
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -212,6 +240,12 @@ class _StudioShellState extends State<StudioShell> {
|
||||||
const _OpenSettingsIntent(),
|
const _OpenSettingsIntent(),
|
||||||
const SingleActivator(LogicalKeyboardKey.comma, meta: true):
|
const SingleActivator(LogicalKeyboardKey.comma, meta: true):
|
||||||
const _OpenSettingsIntent(),
|
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(
|
child: Actions(
|
||||||
actions: <Type, Action<Intent>>{
|
actions: <Type, Action<Intent>>{
|
||||||
|
|
@ -227,6 +261,12 @@ class _StudioShellState extends State<StudioShell> {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
_OpenSearchIntent: CallbackAction<_OpenSearchIntent>(
|
||||||
|
onInvoke: (_) {
|
||||||
|
_openSearchPalette();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
child: Focus(
|
child: Focus(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
|
@ -285,6 +325,10 @@ class _OpenSettingsIntent extends Intent {
|
||||||
const _OpenSettingsIntent();
|
const _OpenSettingsIntent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _OpenSearchIntent extends Intent {
|
||||||
|
const _OpenSearchIntent();
|
||||||
|
}
|
||||||
|
|
||||||
class _Sidebar extends StatelessWidget {
|
class _Sidebar extends StatelessWidget {
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onSelect;
|
final ValueChanged<int> onSelect;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
|
import '../theme/theme.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
import '../widgets/widgets.dart';
|
import '../widgets/widgets.dart';
|
||||||
|
|
||||||
|
|
@ -13,15 +14,25 @@ class ModulesPage extends StatefulWidget {
|
||||||
|
|
||||||
class _ModulesPageState extends State<ModulesPage> {
|
class _ModulesPageState extends State<ModulesPage> {
|
||||||
late Future<List<ModuleSummary>> _future;
|
late Future<List<ModuleSummary>> _future;
|
||||||
|
late Future<List<AuditEvent>> _historyFuture;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_future = HubService.instance.listModules();
|
_future = HubService.instance.listModules();
|
||||||
|
_historyFuture = _loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<AuditEvent>> _loadHistory() {
|
||||||
|
return HubService.instance.recentEvents(
|
||||||
|
limit: 10,
|
||||||
|
types: const ['module.installed', 'module.uninstalled'],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _refresh() => setState(() {
|
void _refresh() => setState(() {
|
||||||
_future = HubService.instance.listModules();
|
_future = HubService.instance.listModules();
|
||||||
|
_historyFuture = _loadHistory();
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -67,25 +78,27 @@ class _ModulesPageState extends State<ModulesPage> {
|
||||||
'Run `fai install <capability-name>` or check ~/.fai/modules/.',
|
'Run `fai install <capability-name>` or check ~/.fai/modules/.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ListView.separated(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(FaiSpace.xl),
|
padding: const EdgeInsets.all(FaiSpace.xl),
|
||||||
itemCount: modules.length,
|
children: [
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
|
_RecentActivityPanel(future: _historyFuture),
|
||||||
itemBuilder: (context, i) {
|
const SizedBox(height: FaiSpace.md),
|
||||||
final m = modules[i];
|
for (var i = 0; i < modules.length; i++) ...[
|
||||||
return GestureDetector(
|
if (i > 0) const SizedBox(height: FaiSpace.md),
|
||||||
|
GestureDetector(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final uninstalled =
|
final uninstalled =
|
||||||
await FaiModuleSheet.show(context, m.name);
|
await FaiModuleSheet.show(context, modules[i].name);
|
||||||
if (uninstalled) _refresh();
|
if (uninstalled) _refresh();
|
||||||
},
|
},
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: MouseRegion(
|
child: MouseRegion(
|
||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
child: _ModuleCard(module: m),
|
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<List<AuditEvent>> future;
|
||||||
|
const _RecentActivityPanel({required this.future});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return FutureBuilder<List<AuditEvent>>(
|
||||||
|
future: future,
|
||||||
|
builder: (context, snap) {
|
||||||
|
final events = snap.data ?? const <AuditEvent>[];
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1032,15 +1032,10 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
_ModuleIcon(
|
||||||
radius: 26,
|
iconUrl: item.iconUrl,
|
||||||
backgroundColor: theme.colorScheme.primary
|
category: item.category,
|
||||||
.withValues(alpha: 0.12),
|
radius: 28,
|
||||||
child: Icon(
|
|
||||||
_iconForCategory(item.category),
|
|
||||||
size: 28,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: FaiSpace.lg),
|
const SizedBox(width: FaiSpace.lg),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -1160,7 +1155,24 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.lg),
|
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'),
|
_SectionHeader('Documentation'),
|
||||||
const SizedBox(height: FaiSpace.sm),
|
const SizedBox(height: FaiSpace.sm),
|
||||||
_DocsPanel(
|
_DocsPanel(
|
||||||
|
|
@ -1644,3 +1656,139 @@ String _friendlyDocsError(String kind, String detail) {
|
||||||
return detail.isEmpty ? 'Could not load README.' : 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
424
lib/widgets/fai_search_palette.dart
Normal file
424
lib/widgets/fai_search_palette.dart
Normal file
|
|
@ -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<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 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: '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<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);
|
||||||
|
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:
|
||||||
|
'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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -79,7 +79,7 @@ packages:
|
||||||
path: "../fai_dart_sdk"
|
path: "../fai_dart_sdk"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.10.1"
|
version: "0.11.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: fai_studio
|
name: fai_studio
|
||||||
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.20.1
|
version: 0.21.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue