Files
pluriwave/lib/widgets/tarjeta_emisora.dart
T
FreeTLab d1a911e587 fix(widgets): share station-art fallback across every surface
TarjetaEmisora had the only good fallback for a station with no artwork --
a deterministic pick from 4 bundled illustrations with a gradient/glyph
last resort. FilaEmisoraPlana's flat rows, the Escuchar hero, the "Tus
emisoras" grid cell, the mini player and the full player each had their
own, separate, flat primaryContainer square instead.

Extract the good fallback into PluriStationArtFallback and use it from
every one of those call sites. The selection formula (asset order,
codeUnits-sum modulo) is preserved exactly, since navegacion_auto.dart
mirrors the same formula independently for Android Auto's own drawable
rotation.
2026-07-30 18:54:31 +02:00

431 lines
14 KiB
Dart

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart';
import '../estado/estado_radio.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../tema/pluriwave_theme.dart';
import 'pluri_glass_surface.dart';
import 'pluri_icon.dart';
import 'pluri_station_art_fallback.dart';
/// Tarjeta compacta para mostrar una emisora en listas y grids.
/// Incluye botón de favorito visible en ambos modos.
class TarjetaEmisora extends StatefulWidget {
final Emisora emisora;
final VoidCallback? onTap;
final bool esCompacta;
const TarjetaEmisora({
super.key,
required this.emisora,
this.onTap,
this.esCompacta = false,
});
@override
State<TarjetaEmisora> createState() => _TarjetaEmisoraState();
}
class _TarjetaEmisoraState extends State<TarjetaEmisora> {
bool _toggling = false;
Future<void> _toggle() async {
if (_toggling) return;
setState(() => _toggling = true);
final estado = context.read<EstadoRadio>();
final esFav = await estado.toggleFavorito(widget.emisora);
if (mounted) setState(() => _toggling = false);
if (mounted) {
final l10n = AppLocalizations.of(context);
final stationName = localizedStationName(l10n, widget.emisora.nombre);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
esFav
? l10n.favoritesAddedMessage(stationName)
: l10n.favoritesRemovedMessage(stationName),
),
duration: const Duration(seconds: 2),
),
);
}
}
@override
Widget build(BuildContext context) {
final t = context.pluriTokens;
final l10n = AppLocalizations.of(context);
final stationName = localizedStationName(l10n, widget.emisora.nombre);
return Semantics(
button: widget.onTap != null,
label: l10n.stationSemanticLabel(stationName),
child: PluriGlassSurface(
padding: EdgeInsets.zero,
// S4 (Tier 1 visual fidelity): compact (esCompacta) is a flat-list
// ROW — prototype dominant row radius 14 (t4 lines 175, 227, 302).
// The full grid card keeps the dominant CARD radius, 18.
borderRadius: BorderRadius.circular(
widget.esCompacta ? t.radiusSm : t.radiusMd,
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap,
child: widget.esCompacta ? _buildCompacta() : _buildCompleta(),
),
),
),
);
}
Widget _buildCompleta() {
final t = context.pluriTokens;
final stationName = localizedStationName(
AppLocalizations.of(context),
widget.emisora.nombre,
);
return Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: Stack(
fit: StackFit.expand,
children: [
_logo(60),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.34),
],
),
),
),
Positioned(
left: t.spacingSm,
bottom: t.spacingSm,
child: _LiveBadge(mini: true),
),
],
),
),
Padding(
padding: EdgeInsets.fromLTRB(
t.spacingMd,
t.spacingSm,
t.spacingMd,
t.spacingMd,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
stationName,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (widget.emisora.pais != null)
Padding(
padding: EdgeInsets.only(top: t.spacingXs),
child: Text(
widget.emisora.pais!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.72),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
Positioned(
top: t.spacingSm,
right: t.spacingSm,
child: _botonFavorito(mini: true),
),
],
);
}
Widget _buildCompacta() {
final t = context.pluriTokens;
final stationName = localizedStationName(
AppLocalizations.of(context),
widget.emisora.nombre,
);
final subtitulo = [
widget.emisora.pais,
widget.emisora.idioma,
].where((s) => s != null && s.isNotEmpty).join(' · ');
return Padding(
padding: EdgeInsets.symmetric(
horizontal: t.spacingSm,
vertical: t.spacingXs,
),
child: Row(
children: [
// S6 (Tier 1 visual fidelity): the prototype's station thumbnail
// is a plain square, radius 11/12, with NO ring or glow (t4 lines
// 84, 175, 227, 302, 614) — was a 58x58 circle wrapped in a
// SweepGradient ring and a 22-blur glow.
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(width: 48, height: 48, child: _logo(22)),
),
SizedBox(width: t.spacingSm),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
stationName,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (subtitulo.isNotEmpty)
Text(
subtitulo,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.72),
),
),
],
),
),
const SizedBox(width: 8),
_LiveBadge(mini: false),
_botonFavorito(mini: false),
],
),
);
}
Widget _botonFavorito({required bool mini}) {
final t = context.pluriTokens;
final l10n = AppLocalizations.of(context);
final esFavorito = context.select<EstadoRadio, bool>(
(estado) =>
estado.listaFavoritos.any((e) => e.uuid == widget.emisora.uuid),
);
final icono =
mini
? Icon(
esFavorito
? Icons.favorite_rounded
: Icons.favorite_outline_rounded,
color:
esFavorito
? t.warmCoral
: Colors.white.withValues(alpha: 0.82),
size: 18,
)
: PluriIcon(
glyph: PluriIconGlyph.favorites,
variant:
esFavorito
? PluriIconVariant.activeGlow
: PluriIconVariant.outline,
size: 20,
semanticLabel:
esFavorito
? l10n.favoritesRemoveTooltip
: l10n.favoritesAddTooltip,
);
// S5-R2: container forces an OWN semantics node — without it the card's
// InkWell absorbs the favorite into one merged node and screen readers
// cannot reach the action independently.
return Semantics(
container: true,
button: true,
toggled: esFavorito,
label:
esFavorito ? l10n.favoritesRemoveTooltip : l10n.favoritesAddTooltip,
child: Material(
color: mini ? t.glassSurface : Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: _toggling ? null : _toggle,
// S5-R2: 48dp minimum touch target in both variants.
child: SizedBox(width: 48, height: 48, child: Center(child: icono)),
),
),
);
}
Widget _logo(double iconSize) {
if (widget.emisora.favicon != null && widget.emisora.favicon!.isNotEmpty) {
return CachedNetworkImage(
imageUrl: widget.emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(),
errorWidget: (_, __, ___) => _iconoFallback(iconSize),
);
}
return _iconoFallback(iconSize);
}
Widget _shimmer() {
final theme = Theme.of(context);
return Shimmer.fromColors(
baseColor: theme.colorScheme.surfaceContainerHighest,
highlightColor: theme.colorScheme.surface,
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
}
// Issue 6 (feedback-pruebas): extracted into the shared
// `PluriStationArtFallback` so every surface that can render a station
// without artwork shows the SAME fallback — see that widget's own doc
// comment for why the selection formula must never drift.
Widget _iconoFallback(double size) {
return PluriStationArtFallback(seed: widget.emisora.uuid, iconSize: size);
}
}
class _LiveBadge extends StatelessWidget {
const _LiveBadge({required this.mini});
final bool mini;
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.secondary;
final l10n = AppLocalizations.of(context);
return Container(
padding: EdgeInsets.symmetric(
horizontal: mini ? 8 : 6,
vertical: mini ? 5 : 4,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: Colors.black.withValues(alpha: 0.35),
border: Border.all(color: color.withValues(alpha: 0.48)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.fiber_manual_record_rounded,
size: mini ? 10 : 8,
color: color,
),
if (mini) ...[
const SizedBox(width: 5),
Text(
l10n.liveNow,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w900),
),
],
],
),
);
}
}
/// Placeholder shimmer para listas en carga.
///
/// S5-R6: corners match the real card radii; [esCompacta] mirrors the
/// compact row layout used in search results.
class TarjetaEmisoraShimmer extends StatelessWidget {
const TarjetaEmisoraShimmer({super.key, this.esCompacta = false});
final bool esCompacta;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final t = context.pluriTokens;
final color = theme.colorScheme.surfaceContainerHighest;
Widget bloque({
double? width,
double? height,
double? radius,
BoxShape shape = BoxShape.rectangle,
}) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: color,
shape: shape,
borderRadius:
shape == BoxShape.circle || radius == null
? null
: BorderRadius.circular(radius),
),
);
}
final contenido =
esCompacta
? Row(
children: [
// S6: matches the real square thumbnail (48x48, radius 12)
// — was a 58x58 circle block.
bloque(width: 48, height: 48, radius: 12),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
bloque(height: 14, radius: 6),
const SizedBox(height: 6),
bloque(height: 12, width: 90, radius: 6),
],
),
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(aspectRatio: 1, child: bloque(radius: t.radiusLg)),
const SizedBox(height: 8),
bloque(height: 14, radius: 6),
const SizedBox(height: 4),
bloque(height: 12, width: 60, radius: 6),
],
);
return Shimmer.fromColors(
baseColor: color,
highlightColor: theme.colorScheme.surface,
child: contenido,
);
}
}