Shuffle/app/lib/core/widgets/game_cover.dart
cschlaefke 8a68d4f449 feat(catalog): add GameCover widget with uniform placeholder
Refs #2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 15:18:48 +01:00

134 lines
4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:shuffle/core/l10n/l10n.dart';
import 'package:shuffle/core/theme/app_spacing.dart';
/// Size variants of [GameCover]: [small] for lists, [large] for detail pages.
enum GameCoverSize {
small(width: 56),
large(width: 160);
const GameCoverSize({required this.width});
/// Width in dp. The height follows from [GameCover.aspectRatio].
final double width;
}
/// Cover of a game with a uniform neutral placeholder (Spec #2, F9).
///
/// Without [imageUrl], while the image loads and when loading fails, the
/// placeholder is shown (a missing cover is not an error). The placeholder is
/// drawn with Flutter only: theme-colored surface and a neutral icon, no
/// third-party artwork.
///
/// Semantics: if the game [title] is shown right next to the cover
/// ([titleVisibleNearby]), the cover is excluded from the semantics tree.
/// Otherwise it is announced with a label built from [title].
class GameCover extends StatelessWidget {
const GameCover({
required this.title,
super.key,
this.imageUrl,
this.size = GameCoverSize.small,
this.titleVisibleNearby = false,
});
/// Width / height of the cover (portrait box art).
static const double aspectRatio = 3 / 4;
/// Resolved image URL; `null` or empty shows the placeholder.
final String? imageUrl;
/// Game title, used for the semantics label only.
final String title;
final GameCoverSize size;
/// `true` if the title is rendered next to the cover, so the cover adds
/// nothing for screen readers.
final bool titleVisibleNearby;
@override
Widget build(BuildContext context) {
final url = imageUrl;
final hasUrl = url != null && url.isNotEmpty;
final height = size.width / aspectRatio;
Widget placeholder({bool loading = false}) =>
_CoverPlaceholder(size: size, loading: loading);
final Widget visual = hasUrl
? Image.network(
url,
fit: BoxFit.cover,
width: size.width,
height: height,
excludeFromSemantics: true,
// No frame decoded yet (also before the first byte arrives).
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) =>
frame == null && !wasSynchronouslyLoaded
? placeholder(loading: true)
: child,
errorBuilder: (context, error, stackTrace) => placeholder(),
)
: placeholder();
final framed = SizedBox(
key: const Key('game_cover'),
width: size.width,
height: height,
child: ClipRRect(borderRadius: AppRadius.smAll, child: visual),
);
if (titleVisibleNearby) {
return ExcludeSemantics(child: framed);
}
final l10n = context.l10n;
return Semantics(
container: true,
image: true,
label: hasUrl
? l10n.gameCoverLabel(title)
: l10n.gameCoverPlaceholderLabel(title),
excludeSemantics: true,
child: framed,
);
}
}
class _CoverPlaceholder extends StatelessWidget {
const _CoverPlaceholder({required this.size, required this.loading});
final GameCoverSize size;
final bool loading;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ColoredBox(
key: const Key('game_cover_placeholder'),
color: scheme.surfaceContainerHighest,
child: Stack(
alignment: Alignment.center,
children: [
Icon(
Icons.casino_outlined,
size: size.width * 0.4,
color: scheme.onSurfaceVariant,
),
if (loading)
Positioned(
bottom: AppSpacing.xs,
left: AppSpacing.xs,
right: AppSpacing.xs,
child: LinearProgressIndicator(
key: const Key('game_cover_loading'),
minHeight: 2,
color: scheme.onSurfaceVariant,
backgroundColor: Colors.transparent,
),
),
],
),
);
}
}