148 lines
4.8 KiB
Dart
148 lines
4.8 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|