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