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:
flemming-it 2026-05-07 21:51:47 +02:00
parent fb7352d182
commit 11b0ac97a1
7 changed files with 803 additions and 30 deletions

View file

@ -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,
),
],
],
),
),
),
);
}
}