Merge pull request 'feat(catalog): GameCover-Widget mit einheitlichem Platzhalter (KAT-05, 2a Flutter)' (#52) from feature/2a-game-cover into main
All checks were successful
CI / flutter (push) Successful in 1m18s
CI / web-build (push) Successful in 1m0s
CI / supabase (push) Successful in 8s
CI / supabase-tests (push) Successful in 31s
CI / e2e (push) Successful in 2m53s

Reviewed-on: #52
This commit is contained in:
cschlaefke 2026-11-03 19:32:30 +00:00
commit 8c003ddece
8 changed files with 498 additions and 2 deletions

View file

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

View file

@ -77,5 +77,7 @@
"authResetTitle": "Neues Passwort festlegen", "authResetTitle": "Neues Passwort festlegen",
"authResetSubmit": "Passwort speichern", "authResetSubmit": "Passwort speichern",
"authResetCancel": "Abbrechen", "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}"
} }

View file

@ -123,5 +123,15 @@
"authResetTitle": "Set a new password", "authResetTitle": "Set a new password",
"authResetSubmit": "Save password", "authResetSubmit": "Save password",
"authResetCancel": "Cancel", "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"}}
}
} }

View file

@ -565,6 +565,18 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Password changed. Other devices will be signed out; this can take up to an hour.'** /// **'Password changed. Other devices will be signed out; this can take up to an hour.'**
String get authPasswordChanged; 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 class _AppLocalizationsDelegate

View file

@ -282,4 +282,14 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get authPasswordChanged => String get authPasswordChanged =>
'Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern.'; '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';
}
} }

View file

@ -279,4 +279,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get authPasswordChanged => String get authPasswordChanged =>
'Password changed. Other devices will be signed out; this can take up to an hour.'; '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';
}
} }

View file

@ -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<List<int>> listen(
void Function(List<int> event)? onData, {
Function? onError,
void Function()? onDone,
bool? cancelOnError,
}) => Stream<List<int>>.value(_png).listen(
onData,
onError: onError,
onDone: onDone,
cancelOnError: cancelOnError,
);
}
class _FakeRequest extends Fake implements HttpClientRequest {
@override
HttpHeaders get headers => _FakeHeaders();
@override
Future<HttpClientResponse> close() async => _FakeResponse();
}
class _FakeClient extends Fake implements HttpClient {
@override
bool autoUncompress = true;
@override
Future<HttpClientRequest> 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<void> _pumpImage(WidgetTester tester, Widget child) async {
await pumpWidgetUnderTest(tester, child, settle: false);
await tester.runAsync(
() => Future<void>.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<void> 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<void>.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);
});
});
}

View file

@ -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). - 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. - 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}“). - 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`. - 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 ## 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. 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) ### 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: 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: