From 8a68d4f449d34272b5fb84dbb29dd8b6556bde5a Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 15:18:48 +0100 Subject: [PATCH] feat(catalog): add GameCover widget with uniform placeholder Refs #2 Co-Authored-By: Claude Sonnet 5.5 --- app/lib/core/widgets/game_cover.dart | 134 ++++++++++++++++++ app/lib/l10n/app_de.arb | 2 + app/lib/l10n/app_en.arb | 10 ++ app/lib/l10n/app_localizations.dart | 12 ++ app/lib/l10n/app_localizations_de.dart | 10 ++ app/lib/l10n/app_localizations_en.dart | 10 ++ app/test/core/widgets/game_cover_test.dart | 151 +++++++++++++++++++++ 7 files changed, 329 insertions(+) create mode 100644 app/lib/core/widgets/game_cover.dart create mode 100644 app/test/core/widgets/game_cover_test.dart diff --git a/app/lib/core/widgets/game_cover.dart b/app/lib/core/widgets/game_cover.dart new file mode 100644 index 0000000..8a64ed1 --- /dev/null +++ b/app/lib/core/widgets/game_cover.dart @@ -0,0 +1,134 @@ +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, + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 137aa26..859a5c2 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -77,5 +77,7 @@ "authResetTitle": "Neues Passwort festlegen", "authResetSubmit": "Passwort speichern", "authResetCancel": "Abbrechen", + "gameCoverPlaceholderLabel": "Kein Cover für {title}", + "gameCoverLabel": "Cover von {title}", "authPasswordChanged": "Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern." } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 9c2b1d9..4243898 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -123,5 +123,15 @@ "authResetTitle": "Set a new password", "authResetSubmit": "Save password", "authResetCancel": "Cancel", + "gameCoverPlaceholderLabel": "No cover for {title}", + "@gameCoverPlaceholderLabel": { + "description": "Semantics label of the uniform placeholder cover", + "placeholders": {"title": {"type": "String"}} + }, + "gameCoverLabel": "Cover of {title}", + "@gameCoverLabel": { + "description": "Semantics label of a game cover image", + "placeholders": {"title": {"type": "String"}} + }, "authPasswordChanged": "Password changed. Other devices will be signed out; this can take up to an hour." } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 9442e16..4bab985 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -560,6 +560,18 @@ abstract class AppLocalizations { /// **'Cancel'** String get authResetCancel; + /// Semantics label of the uniform placeholder cover + /// + /// In en, this message translates to: + /// **'No cover for {title}'** + String gameCoverPlaceholderLabel(String title); + + /// Semantics label of a game cover image + /// + /// In en, this message translates to: + /// **'Cover of {title}'** + String gameCoverLabel(String title); + /// No description provided for @authPasswordChanged. /// /// In en, this message translates to: diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 091565c..da93811 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -279,6 +279,16 @@ class AppLocalizationsDe extends AppLocalizations { @override String get authResetCancel => 'Abbrechen'; + @override + String gameCoverPlaceholderLabel(String title) { + return 'Kein Cover für $title'; + } + + @override + String gameCoverLabel(String title) { + return 'Cover von $title'; + } + @override String get authPasswordChanged => 'Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern.'; diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 813c024..2db1aa2 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -276,6 +276,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get authResetCancel => 'Cancel'; + @override + String gameCoverPlaceholderLabel(String title) { + return 'No cover for $title'; + } + + @override + String gameCoverLabel(String title) { + return 'Cover of $title'; + } + @override String get authPasswordChanged => 'Password changed. Other devices will be signed out; this can take up to an hour.'; diff --git a/app/test/core/widgets/game_cover_test.dart b/app/test/core/widgets/game_cover_test.dart new file mode 100644 index 0000000..dfb6ac7 --- /dev/null +++ b/app/test/core/widgets/game_cover_test.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/widgets/game_cover.dart'; + +import '../../helpers/pump_app.dart'; +import 'widget_harness.dart'; + +double _contrast(Color a, Color b) { + final la = a.computeLuminance(); + final lb = b.computeLuminance(); + final hi = la > lb ? la : lb; + final lo = la > lb ? lb : la; + return (hi + 0.05) / (lo + 0.05); +} + +void main() { + group('GameCover', () { + testWidgets('without imageUrl shows the uniform placeholder', ( + tester, + ) async { + await pumpWidgetUnderTest(tester, const GameCover(title: 'Catan')); + expect(find.byKey(const Key('game_cover_placeholder')), findsOneWidget); + expect(find.byType(Image), findsNothing); + expect(find.byKey(const Key('game_cover_loading')), findsNothing); + }); + + testWidgets('empty imageUrl shows the placeholder', (tester) async { + await pumpWidgetUnderTest( + tester, + const GameCover(title: 'Catan', imageUrl: ''), + ); + expect(find.byKey(const Key('game_cover_placeholder')), findsOneWidget); + expect(find.byType(Image), findsNothing); + }); + + testWidgets('shows a subtle loading indicator while the image loads', ( + tester, + ) async { + await pumpWidgetUnderTest( + tester, + const GameCover(title: 'Catan', imageUrl: 'https://example.test/c.png'), + settle: false, + ); + expect(find.byKey(const Key('game_cover_placeholder')), findsOneWidget); + expect(find.byKey(const Key('game_cover_loading')), findsOneWidget); + }); + + testWidgets('a load error falls back to the placeholder without error', ( + tester, + ) async { + // flutter_test answers all HTTP requests with status 400. + await pumpWidgetUnderTest( + tester, + const GameCover(title: 'Catan', imageUrl: 'https://example.test/c.png'), + settle: false, + ); + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pump(); + await tester.pump(); + expect(find.byKey(const Key('game_cover_placeholder')), findsOneWidget); + expect(find.byKey(const Key('game_cover_loading')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('size variants differ and keep the aspect ratio', ( + tester, + ) async { + await pumpWidgetUnderTest(tester, const GameCover(title: 'A')); + final small = tester.getSize(find.byKey(const Key('game_cover'))); + await pumpWidgetUnderTest( + tester, + const GameCover(title: 'A', size: GameCoverSize.large), + ); + final large = tester.getSize(find.byKey(const Key('game_cover'))); + expect(large.width, greaterThan(small.width)); + expect(small.width / small.height, closeTo(GameCover.aspectRatio, 0.01)); + expect(large.width / large.height, closeTo(GameCover.aspectRatio, 0.01)); + }); + + for (final (locale, expected) in [ + (const Locale('de'), 'Kein Cover für Catan'), + (const Locale('en'), 'No cover for Catan'), + ]) { + testWidgets('placeholder label in ${locale.languageCode}', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpApp( + tester, + locale: locale, + home: const Scaffold(body: GameCover(title: 'Catan')), + ); + expect(find.bySemanticsLabel(expected), findsOneWidget); + handle.dispose(); + }); + } + + testWidgets('is excluded from semantics when the title is nearby', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + const GameCover(title: 'Catan', titleVisibleNearby: true), + ); + expect(find.bySemanticsLabel(RegExp('Catan')), findsNothing); + handle.dispose(); + }); + + for (final mode in themeModes) { + testWidgets('meets a11y guidelines and icon contrast in $mode', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + const GameCover(title: 'Catan', size: GameCoverSize.large), + themeMode: mode, + ); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + final scheme = Theme.of( + tester.element(find.byKey(const Key('game_cover'))), + ).colorScheme; + // Non-text contrast of the symbol against the surface (QUE-06): 3:1. + expect( + _contrast(scheme.onSurfaceVariant, scheme.surfaceContainerHighest), + greaterThanOrEqualTo(3), + ); + handle.dispose(); + }); + } + + testWidgets('does not overflow at 200 % text scale in a row', ( + tester, + ) async { + await pumpWidgetUnderTest( + tester, + const Row( + children: [ + GameCover(title: 'Catan'), + SizedBox(width: 8), + Expanded(child: Text('Ein sehr langer Spieltitel, der umbricht')), + ], + ), + textScale: 2, + size: const Size(320, 640), + ); + expect(tester.takeException(), isNull); + }); + }); +}