Files
pluriwave/lib/widgets/tarjeta_emisora.dart
T
FreeTLab 202bef3539 feat(ui): design token discipline, accessibility and i18n pass
- Replace all hardcoded Color literals outside lib/tema with theme tokens (new static brand palette in PluriWaveTokens); media notification uses the brand color instead of the Material default purple
- Favorite button on station cards grows to a 48dp target and becomes an independent semantics node for screen readers (Semantics container fix)
- All flutter_animate call sites route through the PluriAnimate reduced-motion gate (zero direct .animate() left)
- Locale-aware short dates via intl DateFormat (new lib/l10n/formato_fechas.dart) replacing the hardcoded DD/MM/YYYY; proper plural messages for the favorites counter; example stream URL as a localized key - all 13 locales
- Rounded shimmer placeholders matching card radii; shimmer loading state in search instead of a bare spinner; rounded icon variants unified in settings; bottom-sheet conventions on the custom station form
- Fix latent debug crash: vacation editor read AppLocalizations in initState
- 11 new tests (121 total green), flutter analyze clean
2026-06-11 23:42:16 +02:00

487 lines
15 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 '../tema/pluriwave_tokens.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,
borderRadius: BorderRadius.circular(
widget.esCompacta ? t.radiusMd : t.radiusLg,
),
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: [
Stack(
alignment: Alignment.center,
children: [
Container(
width: 58,
height: 58,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: SweepGradient(
colors: [
t.electricMagenta,
PluriWaveTokens.brightCyan,
t.warmCoral,
t.electricMagenta,
],
),
boxShadow: [
BoxShadow(
color: t.glowColor.withValues(alpha: 0.24),
blurRadius: 22,
),
],
),
),
ClipRRect(
borderRadius: BorderRadius.circular(18),
child: SizedBox(width: 50, height: 50, child: _logo(24)),
),
],
),
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: [
bloque(width: 58, height: 58, shape: BoxShape.circle),
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,
);
}
}