Files
pluriwave/lib/widgets/tarjeta_emisora.dart
T
FreeTLab 3be59d740c
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m17s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
feat(ui): add generated premium assets
2026-05-20 22:15:24 +02:00

402 lines
12 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 '../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) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
esFav
? '${widget.emisora.nombre} añadida a favoritos'
: '${widget.emisora.nombre} eliminada de favoritos',
),
duration: const Duration(seconds: 2),
),
);
}
}
@override
Widget build(BuildContext context) {
final t = context.pluriTokens;
return Semantics(
button: widget.onTap != null,
label: 'Emisora ${widget.emisora.nombre}',
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;
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(
widget.emisora.nombre,
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 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, const Color(0xFF20E6FF), 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(
widget.emisora.nombre,
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 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 ? 'Quitar de favoritos' : 'Añadir a favoritos',
);
return Semantics(
button: true,
toggled: esFavorito,
label: esFavorito ? 'Quitar de favoritos' : 'Añadir a favoritos',
child: Material(
color: mini ? t.glassSurface : Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: _toggling ? null : _toggle,
child: SizedBox(
width: mini ? 36 : 44,
height: mini ? 36 : 44,
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: 'Icono de emisora',
),
),
],
);
}
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;
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('Live', style: Theme.of(context).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w900)),
],
],
),
);
}
}
/// Placeholder shimmer para listas en carga.
class TarjetaEmisoraShimmer extends StatelessWidget {
const TarjetaEmisoraShimmer({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Shimmer.fromColors(
baseColor: theme.colorScheme.surfaceContainerHighest,
highlightColor: theme.colorScheme.surface,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: Container(color: theme.colorScheme.surfaceContainerHighest),
),
const SizedBox(height: 8),
Container(
height: 14,
color: theme.colorScheme.surfaceContainerHighest,
),
const SizedBox(height: 4),
Container(
height: 12,
width: 60,
color: theme.colorScheme.surfaceContainerHighest,
),
],
),
);
}
}