diff --git a/app/lib/core/widgets/game_cover.dart b/app/lib/core/widgets/game_cover.dart new file mode 100644 index 0000000..3c21b35 --- /dev/null +++ b/app/lib/core/widgets/game_cover.dart @@ -0,0 +1,148 @@ +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. + /// + /// Must come from the file access layer (own Supabase Storage), never from + /// `source_url` or other third-party URLs (privacy: the IP address would + /// reach third parties). + /// + /// Notes for the callers (#3, #4), not implemented here: + /// - The loading indicator animates endlessly, so `pumpAndSettle` in tests + /// times out while an image loads (use `pump`). It does not honor + /// `MediaQuery.disableAnimations` yet. + /// - A private bucket would need signed URLs or auth headers. + /// - Self-hosted Storage on the web needs CORS for the app origin. + 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; + final l10n = context.l10n; + + // The label follows what is visible: placeholder vs. loaded image. + Widget labeled(String label, Widget child) => titleVisibleNearby + ? child + : Semantics( + container: true, + image: true, + label: label, + excludeSemantics: true, + child: child, + ); + + Widget placeholder({bool loading = false}) => labeled( + l10n.gameCoverPlaceholderLabel(title), + _CoverPlaceholder(size: size, loading: loading), + ); + + final Widget visual = hasUrl + ? Image.network( + url, + fit: BoxFit.cover, + width: size.width, + height: height, + cacheWidth: (size.width * MediaQuery.devicePixelRatioOf(context)) + .round(), + excludeFromSemantics: true, + // No frame decoded yet (also before the first byte arrives). + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) => + frame == null && !wasSynchronouslyLoaded + ? placeholder(loading: true) + : labeled(l10n.gameCoverLabel(title), 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), + ); + return titleVisibleNearby ? ExcludeSemantics(child: framed) : 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..907b00b 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", - "authPasswordChanged": "Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern." + "authPasswordChanged": "Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern.", + "gameCoverPlaceholderLabel": "Kein Cover für {title}", + "gameCoverLabel": "Cover von {title}" } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 9c2b1d9..86e8967 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", - "authPasswordChanged": "Password changed. Other devices will be signed out; this can take up to an hour." + "authPasswordChanged": "Password changed. Other devices will be signed out; this can take up to an hour.", + "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"}} + } } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 9442e16..2f5596f 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -565,6 +565,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Password changed. Other devices will be signed out; this can take up to an hour.'** String get authPasswordChanged; + + /// 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); } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 091565c..899aaf2 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -282,4 +282,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String get authPasswordChanged => 'Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern.'; + + @override + String gameCoverPlaceholderLabel(String title) { + return 'Kein Cover für $title'; + } + + @override + String gameCoverLabel(String title) { + return 'Cover von $title'; + } } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 813c024..eb22e28 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -279,4 +279,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get authPasswordChanged => 'Password changed. Other devices will be signed out; this can take up to an hour.'; + + @override + String gameCoverPlaceholderLabel(String title) { + return 'No cover for $title'; + } + + @override + String gameCoverLabel(String title) { + return 'Cover of $title'; + } } 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..40b5772 --- /dev/null +++ b/app/test/core/widgets/game_cover_test.dart @@ -0,0 +1,299 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +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); +} + +// 1x1 transparent PNG. +final Uint8List _png = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', +); + +class _FakeHeaders extends Fake implements HttpHeaders { + @override + void add(String name, Object value, {bool preserveHeaderCase = false}) {} +} + +class _FakeResponse extends Fake implements HttpClientResponse { + @override + int get statusCode => 200; + @override + int get contentLength => _png.length; + @override + HttpClientResponseCompressionState get compressionState => + HttpClientResponseCompressionState.notCompressed; + @override + StreamSubscription> listen( + void Function(List event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => Stream>.value(_png).listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); +} + +class _FakeRequest extends Fake implements HttpClientRequest { + @override + HttpHeaders get headers => _FakeHeaders(); + @override + Future close() async => _FakeResponse(); +} + +class _FakeClient extends Fake implements HttpClient { + @override + bool autoUncompress = true; + @override + Future getUrl(Uri url) async => _FakeRequest(); +} + +const _url = 'https://example.test/c.png'; +// Separate URL: a failed load can stay in the image cache's live images. +const _errorUrl = 'https://example.test/broken.png'; + +Future _pumpImage(WidgetTester tester, Widget child) async { + await pumpWidgetUnderTest(tester, child, settle: false); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + await tester.pump(); + await tester.pump(); +} + +/// NetworkImage caches one shared HttpClient, so `HttpOverrides` would only +/// work for the first test; the debug provider is the supported hook. It is +/// reset in `finally` because the framework verifies it before tearDowns run. +void _testWithFakeNetwork( + String description, + Future Function(WidgetTester tester) body, +) { + testWidgets(description, (tester) async { + debugNetworkImageHttpClientProvider = _FakeClient.new; + try { + await body(tester); + } finally { + debugNetworkImageHttpClientProvider = null; + } + }); +} + +void main() { + setUp(() => PaintingBinding.instance.imageCache.clear()); + + group('KAT-05 Kriterium 10: GameCover placeholder', () { + 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('loading shows placeholder, indicator and placeholder label', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + const GameCover(title: 'Catan', imageUrl: _url), + settle: false, + ); + expect(find.byKey(const Key('game_cover_placeholder')), findsOneWidget); + expect(find.byKey(const Key('game_cover_loading')), findsOneWidget); + expect(find.bySemanticsLabel('Kein Cover für Catan'), findsOneWidget); + expect(find.bySemanticsLabel('Cover von Catan'), findsNothing); + handle.dispose(); + }); + + testWidgets('a load error falls back to the placeholder and its label', ( + tester, + ) async { + // flutter_test answers all HTTP requests with status 400. + final handle = tester.ensureSemantics(); + await _pumpImage( + tester, + const GameCover(title: 'Catan', imageUrl: _errorUrl), + ); + expect(find.byKey(const Key('game_cover_placeholder')), findsOneWidget); + expect(find.byKey(const Key('game_cover_loading')), findsNothing); + expect(find.bySemanticsLabel('Kein Cover für Catan'), findsOneWidget); + expect(find.bySemanticsLabel('Cover von Catan'), findsNothing); + expect(tester.takeException(), isNull); + handle.dispose(); + }); + + group('loaded image', () { + for (final (locale, expected) in [ + (const Locale('de'), 'Cover von Catan'), + (const Locale('en'), 'Cover of Catan'), + ]) { + _testWithFakeNetwork( + 'shows the image with its label in ${locale.languageCode}', + (tester) async { + final handle = tester.ensureSemantics(); + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await pumpApp( + tester, + locale: locale, + settle: false, + home: const Scaffold( + body: GameCover(title: 'Catan', imageUrl: _url), + ), + ); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + await tester.pump(); + await tester.pump(); + expect(find.byType(Image), findsOneWidget); + expect( + find.byKey(const Key('game_cover_placeholder')), + findsNothing, + ); + expect(find.bySemanticsLabel(expected), findsOneWidget); + handle.dispose(); + }, + ); + } + + _testWithFakeNetwork('a cached image is shown synchronously', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + const cover = GameCover(title: 'Catan', imageUrl: _url); + await _pumpImage(tester, cover); + expect(find.byKey(const Key('game_cover_placeholder')), findsNothing); + + // New element with the same URL: served from the image cache. + await pumpWidgetUnderTest( + tester, + const GameCover(key: Key('second'), title: 'Catan', imageUrl: _url), + settle: false, + ); + expect(find.byKey(const Key('game_cover_placeholder')), findsNothing); + expect(find.byKey(const Key('game_cover_loading')), findsNothing); + expect(find.bySemanticsLabel('Cover von Catan'), findsOneWidget); + handle.dispose(); + }); + }); + + 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)); + }); + }); + + group('QUE-05 Kriterium 2: GameCover labels', () { + 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(); + }); + } + }); + + group('QUE-06 Kriterium 1, 2: GameCover accessibility', () { + 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: 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); + }); + }); +} diff --git a/docs/specs/2-katalog-daten.md b/docs/specs/2-katalog-daten.md index fea6253..b2e0134 100644 --- a/docs/specs/2-katalog-daten.md +++ b/docs/specs/2-katalog-daten.md @@ -474,6 +474,7 @@ KAT-05 hat in R1 keine eigenen Screens (keine Admin-UI, Klärung 44). Die App-An - Zustände: kein Bild → Platzhalter; Bild lädt → Platzhalter mit dezenter Ladeanzeige; Fehler beim Laden → Platzhalter (kein Fehlerzustand, ein fehlendes Cover ist kein Fehler). - Platzhalter (Spec-F9): **ein einheitlicher, neutraler Platzhalter für alle Spiele**, mit Flutter gezeichnet (Fläche in Theme-Farbe, neutrales Symbol), keine Bilddatei von Dritten, kein neues Paket. - Semantics: Bild ausgeblendet aus dem Semantics-Baum, wenn der Titel daneben steht (`excludeSemantics`-Variante); sonst Beschriftung aus ARB `gameCoverPlaceholderLabel` („Kein Cover für {title}“ / „No cover for {title}“). +- Ergänzung bei der Umsetzung: zusätzlicher ARB-Schlüssel `gameCoverLabel` („Cover von {title}“ / „Cover of {title}“) für ein tatsächlich geladenes Bild; die Beschriftung folgt dem, was sichtbar ist (Platzhalter beim Laden und nach einem Ladefehler, sonst `gameCoverLabel`). Der Parameter für die `excludeSemantics`-Variante heißt `titleVisibleNearby`. `imageUrl` muss aus der Dateizugriffsschicht (eigener Storage) stammen, nie aus `source_url` oder Fremd-URLs (Datenschutz); Hinweise für #3/#4 (animierte Ladeanzeige und `pumpAndSettle`, signierte URLs bei privatem Bucket, CORS beim Self-Hosting) stehen im Doc-Kommentar des Widgets. - Keine Geschäftslogik, keine Domänenmodelle in 2a. Das Domänenmodell `CatalogGame` (freezed) und das Repository legt KAT-01 (#3) an, auf Basis der Spalten von `catalog_games`. ## 6. Plattform-Besonderheiten @@ -686,6 +687,10 @@ Chris hat die Spec freigegeben und alle Fragen wie empfohlen entschieden. Eingea Chris hat W1, W4, W6, W7, H1/H2 und H7 (§ 9.3) einzeln und ohne Änderung bestätigt (Kommentar im Issue #2). § 9.3 trägt jetzt je Zeile den Status „bestätigt (Chris, 2026-11-03)“; die Hinweise „bitte bestätigen“ in Status, E6 und 3.3 sind entfernt; 9.4 entsprechend angepasst. +### 2026-11-03 – Umsetzung PR 2a, Flutter-Anteil + +- `GameCover` umgesetzt; Review-Nacharbeit: Semantics-Label folgt dem sichtbaren Zustand, zusätzlicher ARB-Schlüssel `gameCoverLabel`, Parameter `titleVisibleNearby`, `cacheWidth` nach Gerätepixeln, Hinweise für #3/#4 (5). + ### 2026-11-03 – Umsetzung PR 2a (backend-dev) Umgesetzt: B1, B2, B3a, B3, B4 und die beiden CI-Schritte aus D1 (Deno-Tests und „erzeugte Dateien aktuell“, „`startbestand-keys.csv` wächst nur“). Abweichungen und Ergänzungen, jeweils zur Prüfung im PR: