feat(catalog): add GameCover widget with uniform placeholder
Refs #2 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
79e06fbfe9
commit
8a68d4f449
7 changed files with 329 additions and 0 deletions
134
app/lib/core/widgets/game_cover.dart
Normal file
134
app/lib/core/widgets/game_cover.dart
Normal file
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -77,5 +77,7 @@
|
||||||
"authResetTitle": "Neues Passwort festlegen",
|
"authResetTitle": "Neues Passwort festlegen",
|
||||||
"authResetSubmit": "Passwort speichern",
|
"authResetSubmit": "Passwort speichern",
|
||||||
"authResetCancel": "Abbrechen",
|
"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."
|
"authPasswordChanged": "Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,5 +123,15 @@
|
||||||
"authResetTitle": "Set a new password",
|
"authResetTitle": "Set a new password",
|
||||||
"authResetSubmit": "Save password",
|
"authResetSubmit": "Save password",
|
||||||
"authResetCancel": "Cancel",
|
"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."
|
"authPasswordChanged": "Password changed. Other devices will be signed out; this can take up to an hour."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -560,6 +560,18 @@ abstract class AppLocalizations {
|
||||||
/// **'Cancel'**
|
/// **'Cancel'**
|
||||||
String get authResetCancel;
|
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.
|
/// No description provided for @authPasswordChanged.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,16 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get authResetCancel => 'Abbrechen';
|
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
|
@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.';
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,16 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get authResetCancel => 'Cancel';
|
String get authResetCancel => 'Cancel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String gameCoverPlaceholderLabel(String title) {
|
||||||
|
return 'No cover for $title';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String gameCoverLabel(String title) {
|
||||||
|
return 'Cover of $title';
|
||||||
|
}
|
||||||
|
|
||||||
@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.';
|
||||||
|
|
|
||||||
151
app/test/core/widgets/game_cover_test.dart
Normal file
151
app/test/core/widgets/game_cover_test.dart
Normal file
|
|
@ -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<void>.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue