202bef3539
- 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
487 lines
15 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|