feat(ui): add premium PluriWave redesign
Build & Deploy Pluriwave / Análisis de código (push) Failing after 21s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped

This commit is contained in:
2026-05-20 18:42:22 +02:00
parent f95a8290ae
commit c707fc9911
30 changed files with 2218 additions and 954 deletions
+142 -78
View File
@@ -2,11 +2,15 @@ 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.
/// Incluye botón de favorito visible en ambos modos.
class TarjetaEmisora extends StatefulWidget {
final Emisora emisora;
final VoidCallback? onTap;
@@ -24,31 +28,18 @@ class TarjetaEmisora extends StatefulWidget {
}
class _TarjetaEmisoraState extends State<TarjetaEmisora> {
bool _esFavorito = false;
bool _toggling = false;
@override
void initState() {
super.initState();
_checkFavorito();
}
Future<void> _checkFavorito() async {
final fav = await context.read<EstadoRadio>().esFavorito(widget.emisora.uuid);
if (mounted) setState(() => _esFavorito = fav);
}
Future<void> _toggle() async {
if (_toggling) return;
_toggling = true;
setState(() => _toggling = true);
final estado = context.read<EstadoRadio>();
final esFav = await estado.toggleFavorito(widget.emisora);
if (mounted) setState(() => _esFavorito = esFav);
_toggling = false;
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} añadida a favoritos'
: '${widget.emisora.nombre} eliminada de favoritos'),
duration: const Duration(seconds: 2),
));
@@ -57,19 +48,26 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: widget.onTap,
child: widget.esCompacta
? _buildCompacta(theme)
: _buildCompleta(theme),
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(ThemeData theme) {
Widget _buildCompleta() {
final t = context.pluriTokens;
return Stack(
children: [
Column(
@@ -77,96 +75,148 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
children: [
AspectRatio(
aspectRatio: 1,
child: _logo(theme, 60),
child: _logo(60),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
padding: EdgeInsets.fromLTRB(t.spacingMd, t.spacingSm, t.spacingMd, t.spacingMd),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.emisora.nombre,
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (widget.emisora.pais != null)
Text(
widget.emisora.pais!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
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,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
// Botón favorito superpuesto (esquina superior derecha)
Positioned(
top: 4,
right: 4,
child: _botonFavorito(theme, mini: true),
top: t.spacingSm,
right: t.spacingSm,
child: _botonFavorito(mini: true),
),
],
);
}
Widget _buildCompacta(ThemeData theme) {
return ListTile(
leading: SizedBox(width: 48, height: 48, child: _logo(theme, 24)),
title: Text(
widget.emisora.nombre,
maxLines: 1,
overflow: TextOverflow.ellipsis,
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: [
ClipRRect(
borderRadius: BorderRadius.circular(t.radiusSm),
child: SizedBox(width: 48, height: 48, 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)),
),
],
),
),
_botonFavorito(mini: false),
],
),
subtitle: Text(
[widget.emisora.pais, widget.emisora.idioma]
.where((s) => s != null && s.isNotEmpty)
.join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: _botonFavorito(theme, mini: false),
);
}
Widget _botonFavorito(ThemeData theme, {required bool mini}) {
return Material(
color: mini
? theme.colorScheme.surface.withValues(alpha: 0.8)
: Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: _toggle,
child: Padding(
padding: EdgeInsets.all(mini ? 6 : 4),
child: Icon(
_esFavorito ? Icons.favorite_rounded : Icons.favorite_outline_rounded,
color: _esFavorito ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant,
size: mini ? 18 : 22,
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(ThemeData theme, double iconSize) {
Widget _logo(double iconSize) {
if (widget.emisora.favicon != null && widget.emisora.favicon!.isNotEmpty) {
return CachedNetworkImage(
imageUrl: widget.emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme, iconSize),
placeholder: (_, __) => _shimmer(),
errorWidget: (_, __, ___) => _iconoFallback(iconSize),
);
}
return _iconoFallback(theme, iconSize);
return _iconoFallback(iconSize);
}
Widget _shimmer(ThemeData theme) {
Widget _shimmer() {
final theme = Theme.of(context);
return Shimmer.fromColors(
baseColor: theme.colorScheme.surfaceContainerHighest,
highlightColor: theme.colorScheme.surface,
@@ -174,10 +224,24 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
);
}
Widget _iconoFallback(ThemeData theme, double size) {
return Container(
color: theme.colorScheme.primaryContainer,
child: Icon(Icons.radio, size: size, color: theme.colorScheme.onPrimaryContainer),
Widget _iconoFallback(double size) {
final t = context.pluriTokens;
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [t.deepViolet, t.electricMagenta.withValues(alpha: 0.8)],
),
),
child: Center(
child: PluriIcon(
glyph: PluriIconGlyph.player,
variant: PluriIconVariant.filled,
size: size,
semanticLabel: 'Icono de emisora',
),
),
);
}
}