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
|
|
@ -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<ModulesPage> {
|
||||
late Future<List<ModuleSummary>> _future;
|
||||
late Future<List<AuditEvent>> _historyFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_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(() {
|
||||
_future = HubService.instance.listModules();
|
||||
_historyFuture = _loadHistory();
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -67,25 +78,27 @@ class _ModulesPageState extends State<ModulesPage> {
|
|||
'Run `fai install <capability-name>` 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<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(
|
||||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue