Files
pluriwave/lib/widgets/tarjeta_emisora.dart
T
FreeTLab d0660a4966 fix(stations): replace the ringed circle thumbnail with a plain square
The prototype's station thumbnail is a plain 44-48px square, radius
11/12, with no ring or glow (t4 lines 84, 175, 227, 302, 614).
TarjetaEmisora's compact row variant instead painted a 58x58 circle
wrapped in a SweepGradient ring (magenta/cyan/coral) and a 22-blur
glow behind a 50x50 ClipRRect(18).

Replace it with a 48x48 ClipRRect(12) square. Update the loading
shimmer placeholder to match (was also a 58x58 circle block).

S6, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 20:29:31 +02:00

469 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';
/// 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),
);
}
Widget _iconoFallback(double size) {
final art = _fallbackArtFor(widget.emisora.uuid);
return Stack(
fit: StackFit.expand,
children: [
Image.asset(
art,
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) => DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
context.pluriTokens.deepViolet,
context.pluriTokens.electricMagenta.withValues(
alpha: 0.8,
),
],
),
),
),
),
Center(
child: PluriIcon(
glyph: PluriIconGlyph.player,
variant: PluriIconVariant.activeGlow,
size: size,
semanticLabel: AppLocalizations.of(context).stationIconLabel,
),
),
],
);
}
String _fallbackArtFor(String seed) {
const arts = [
'assets/images/station_art_aurora.png',
'assets/images/station_art_cosmic.png',
'assets/images/station_art_pulse.png',
'assets/images/station_art_nova.png',
];
final index = seed.codeUnits.fold<int>(0, (a, b) => a + b) % arts.length;
return arts[index];
}
}
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,
);
}
}