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
+72 -50
View File
@@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import '../modelos/preset_ecualizador.dart';
import 'package:flutter/material.dart';
import '../modelos/preset_ecualizador.dart';
import '../tema/pluriwave_theme.dart';
import 'pluri_glass_surface.dart';
/// Widget de ecualizador con 5 sliders verticales.
/// Basado en JaviHogar EcualizadorWidget, adaptado a Material You.
class EcualizadorWidget extends StatefulWidget {
final PresetEcualizador preset;
final void Function(PresetEcualizador) onCambio;
@@ -39,60 +40,76 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Ecualizador', style: theme.textTheme.titleMedium),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (int i = 0; i < 5; i++)
Expanded(
child: Column(
children: [
SizedBox(
height: 160,
child: RotatedBox(
quarterTurns: 3,
child: Slider(
value: _bandas[i],
min: -12.0,
max: 12.0,
divisions: 24,
onChanged: (v) => _actualizarBanda(i, v),
final tokens = context.pluriTokens;
return PluriGlassSurface(
borderRadius: BorderRadius.circular(tokens.radiusLg),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text('Ecualizador', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
const Spacer(),
Chip(
label: Text(widget.preset.nombre, style: theme.textTheme.labelMedium),
backgroundColor: theme.colorScheme.secondaryContainer.withValues(alpha: 0.75),
),
],
),
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (int i = 0; i < 5; i++)
Expanded(
child: Card(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35),
margin: const EdgeInsets.symmetric(horizontal: 4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: Column(
children: [
SizedBox(
height: 152,
child: Semantics(
slider: true,
label: 'Banda ${_etiquetas[i]}',
value: '${_bandas[i].toStringAsFixed(1)} decibelios',
child: RotatedBox(
quarterTurns: 3,
child: Slider(
value: _bandas[i],
min: -12.0,
max: 12.0,
divisions: 24,
onChanged: (v) => _actualizarBanda(i, v),
),
),
),
),
),
Text(
'${_bandas[i].toStringAsFixed(1)}dB',
style: theme.textTheme.labelSmall,
textAlign: TextAlign.center,
),
Text(
_etiquetas[i],
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
Text('${_bandas[i].toStringAsFixed(1)}dB', style: theme.textTheme.labelSmall),
Text(
_etiquetas[i],
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
textAlign: TextAlign.center,
),
],
],
),
),
),
],
),
],
),
),
],
),
],
),
);
}
}
/// Chips de presets predefinidos.
class PresetsEcualizadorWidget extends StatelessWidget {
final PresetEcualizador presetActual;
final void Function(PresetEcualizador) onSeleccionar;
@@ -105,13 +122,18 @@ class PresetsEcualizadorWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Wrap(
spacing: 8,
runSpacing: 4,
runSpacing: 6,
children: PresetEcualizador.presets.map((p) {
final selected = p.nombre == presetActual.nombre;
return ChoiceChip(
label: Text(p.nombre),
selected: p.nombre == presetActual.nombre,
selected: selected,
showCheckmark: false,
selectedColor: theme.colorScheme.primaryContainer,
backgroundColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.32),
onSelected: (_) => onSeleccionar(p),
);
}).toList(),
+128 -95
View File
@@ -1,11 +1,15 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_radio.dart';
import '../pantallas/pantalla_reproductor.dart';
import '../servicios/servicio_audio.dart';
import '../tema/pluriwave_theme.dart';
import 'pluri_glass_surface.dart';
import 'pluri_icon.dart';
import 'visualizador_audio.dart';
/// Barra inferior persistente con controles básicos de reproducción.
/// Barra inferior persistente con controles básicos de reproducción.
/// Toca la barra para abrir PantallaReproductor completa.
class MiniReproductor extends StatelessWidget {
const MiniReproductor({super.key});
@@ -17,107 +21,136 @@ class MiniReproductor extends StatelessWidget {
if (emisora == null) return const SizedBox.shrink();
final theme = Theme.of(context);
final t = context.pluriTokens;
return GestureDetector(
onTap: () => PantallaReproductor.abrir(context, emisora),
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
border: Border(
top: BorderSide(
color: theme.colorScheme.outlineVariant, width: 0.5)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
// Indicador de reproducción (mini visualizador)
SizedBox(
width: 40,
height: 40,
child: Center(
child: IndicadorReproduccion(
estadoStream: estado.estadoStream,
color: theme.colorScheme.primary,
size: 20,
return SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(t.spacingMd, t.spacingSm, t.spacingMd, t.spacingSm),
child: PluriGlassSurface(
padding: EdgeInsets.symmetric(horizontal: t.spacingSm, vertical: t.spacingXs),
borderRadius: BorderRadius.circular(999),
child: Row(
children: [
Expanded(
child: Semantics(
button: true,
label: 'Abrir reproductor de ${emisora.nombre}',
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: () => PantallaReproductor.abrir(context, emisora),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: t.spacingXs,
vertical: t.spacingXs,
),
child: Row(
children: [
SizedBox(
width: 40,
height: 40,
child: Center(
child: IndicadorReproduccion(
estadoStream: estado.estadoStream,
color: t.electricMagenta,
size: 20,
),
),
),
SizedBox(width: t.spacingSm),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
emisora.nombre,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
StreamBuilder<EstadoReproduccion>(
stream: estado.estadoStream,
builder: (context, snapshot) {
final s = snapshot.data ?? EstadoReproduccion.detenido;
final activo = s == EstadoReproduccion.reproduciendo;
return Text(
_labelEstado(s),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: activo
? t.warmCoral
: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.7),
fontWeight: activo ? FontWeight.w600 : FontWeight.w400,
),
);
},
),
],
),
),
PluriIcon(
glyph: PluriIconGlyph.player,
variant: PluriIconVariant.activeGlow,
size: 18,
semanticLabel: 'Reproductor',
),
],
),
),
),
),
),
const SizedBox(width: 12),
// Nombre y estado
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
emisora.nombre,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
StreamBuilder<EstadoReproduccion>(
stream: estado.estadoStream,
builder: (context, snapshot) {
final s = snapshot.data ?? EstadoReproduccion.detenido;
if (s == EstadoReproduccion.cargando) {
return const SizedBox(
width: 48,
height: 48,
child: Padding(
padding: EdgeInsets.all(12),
child: CircularProgressIndicator(strokeWidth: 2),
),
StreamBuilder<EstadoReproduccion>(
stream: estado.estadoStream,
builder: (context, snapshot) {
final s = snapshot.data ??
EstadoReproduccion.detenido;
return Text(
_labelEstado(s),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
);
},
),
],
),
),
// Botón play/pause
StreamBuilder<EstadoReproduccion>(
stream: estado.estadoStream,
builder: (context, snapshot) {
final s =
snapshot.data ?? EstadoReproduccion.detenido;
if (s == EstadoReproduccion.cargando) {
return const SizedBox(
width: 40,
height: 40,
child: Padding(
padding: EdgeInsets.all(10),
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
// En estado error: mostrar icono de reintento
if (s == EstadoReproduccion.error) {
final emisora = estado.emisoraActual;
return IconButton(
icon: const Icon(Icons.refresh_rounded),
tooltip: 'Reintentar',
onPressed: emisora != null
? () => estado.reproducir(emisora)
: null,
);
}
);
}
if (s == EstadoReproduccion.error) {
final emisoraActual = estado.emisoraActual;
return IconButton(
tooltip: 'Reintentar',
icon: const Icon(Icons.refresh_rounded),
onPressed: emisoraActual != null ? () => estado.reproducir(emisoraActual) : null,
constraints: const BoxConstraints.tightFor(width: 48, height: 48),
);
}
return Semantics(
button: true,
label: s == EstadoReproduccion.reproduciendo ? 'Pausar' : 'Reproducir',
child: IconButton(
tooltip: s == EstadoReproduccion.reproduciendo ? 'Pausar' : 'Reproducir',
icon: Icon(
s == EstadoReproduccion.reproduciendo
? Icons.pause_rounded
: Icons.play_arrow_rounded,
? Icons.pause_circle_filled_rounded
: Icons.play_circle_fill_rounded,
color: t.electricMagenta,
),
onPressed: () {
// Evitar que el tap en el botón abra el reproductor
estado.togglePlay();
},
);
},
),
],
),
onPressed: estado.togglePlay,
constraints: const BoxConstraints.tightFor(width: 48, height: 48),
),
);
},
),
],
),
),
),
@@ -127,9 +160,9 @@ class MiniReproductor extends StatelessWidget {
String _labelEstado(EstadoReproduccion estado) {
return switch (estado) {
EstadoReproduccion.cargando => 'Conectando...',
EstadoReproduccion.reproduciendo => 'En directo',
EstadoReproduccion.reproduciendo => 'En directo',
EstadoReproduccion.pausado => 'Pausado',
EstadoReproduccion.error => 'Error de conexión',
EstadoReproduccion.error => 'Error de conexión',
EstadoReproduccion.detenido => 'Detenido',
};
}
+42
View File
@@ -0,0 +1,42 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../tema/pluriwave_theme.dart';
class PluriGlassSurface extends StatelessWidget {
const PluriGlassSurface({
super.key,
required this.child,
this.padding = const EdgeInsets.all(16),
this.borderRadius,
this.blurSigma = 14,
});
final Widget child;
final EdgeInsetsGeometry padding;
final BorderRadius? borderRadius;
final double blurSigma;
@override
Widget build(BuildContext context) {
final t = context.pluriTokens;
final radius = borderRadius ?? BorderRadius.circular(t.radiusMd);
return RepaintBoundary(
child: ClipRRect(
borderRadius: radius,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
child: DecoratedBox(
decoration: BoxDecoration(
color: t.glassSurface,
borderRadius: radius,
border: Border.all(color: t.glassBorder),
),
child: Padding(padding: padding, child: child),
),
),
),
);
}
}
+88
View File
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import '../tema/pluriwave_tokens.dart';
import '../tema/pluriwave_theme.dart';
enum PluriIconGlyph { home, search, favorites, player, settings }
enum PluriIconVariant { outline, filled, activeGlow }
class PluriIcon extends StatelessWidget {
const PluriIcon({
super.key,
required this.glyph,
this.variant = PluriIconVariant.outline,
this.size = 24,
this.semanticLabel,
});
final PluriIconGlyph glyph;
final PluriIconVariant variant;
final double size;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final tokens = context.pluriTokens;
final icon = Icon(
_resolveData(),
size: size,
color: _resolveColor(context, tokens),
);
final child = variant == PluriIconVariant.activeGlow
? Container(
width: size + 12,
height: size + 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: tokens.glowColor,
blurRadius: 14,
spreadRadius: 1,
),
],
),
alignment: Alignment.center,
child: icon,
)
: icon;
return Semantics(
label: semanticLabel ?? _fallbackLabel(glyph),
image: true,
child: ExcludeSemantics(child: child),
);
}
Color _resolveColor(BuildContext context, PluriWaveTokens tokens) {
if (variant == PluriIconVariant.activeGlow) return tokens.electricMagenta;
if (variant == PluriIconVariant.filled) return Theme.of(context).colorScheme.onSurface;
return Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.78);
}
IconData _resolveData() {
return switch ((glyph, variant)) {
(PluriIconGlyph.home, PluriIconVariant.outline) => Icons.home_outlined,
(PluriIconGlyph.home, _) => Icons.home_rounded,
(PluriIconGlyph.search, PluriIconVariant.outline) => Icons.search_outlined,
(PluriIconGlyph.search, _) => Icons.search_rounded,
(PluriIconGlyph.favorites, PluriIconVariant.outline) => Icons.favorite_border_rounded,
(PluriIconGlyph.favorites, _) => Icons.favorite_rounded,
(PluriIconGlyph.player, PluriIconVariant.outline) => Icons.play_circle_outline_rounded,
(PluriIconGlyph.player, _) => Icons.play_circle_rounded,
(PluriIconGlyph.settings, PluriIconVariant.outline) => Icons.settings_outlined,
(PluriIconGlyph.settings, _) => Icons.settings_rounded,
};
}
String _fallbackLabel(PluriIconGlyph glyph) {
return switch (glyph) {
PluriIconGlyph.home => 'Inicio',
PluriIconGlyph.search => 'Buscar',
PluriIconGlyph.favorites => 'Favoritos',
PluriIconGlyph.player => 'Reproductor',
PluriIconGlyph.settings => 'Ajustes',
};
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import '../tema/pluriwave_theme.dart';
class PluriWaveScaffold extends StatelessWidget {
const PluriWaveScaffold({
super.key,
required this.body,
this.appBar,
this.bottomNavigationBar,
this.floatingActionButton,
});
final PreferredSizeWidget? appBar;
final Widget body;
final Widget? bottomNavigationBar;
final Widget? floatingActionButton;
@override
Widget build(BuildContext context) {
final t = context.pluriTokens;
return Scaffold(
backgroundColor: t.deepViolet,
appBar: appBar,
bottomNavigationBar: bottomNavigationBar,
floatingActionButton: floatingActionButton,
body: DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: const Alignment(-0.75, -0.9),
radius: 1.25,
colors: [
t.electricMagenta.withValues(alpha: 0.22),
t.deepViolet,
const Color(0xFF10091B),
],
stops: const [0.0, 0.42, 1.0],
),
),
child: body,
),
);
}
}
+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',
),
),
);
}
}
+28 -19
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import '../servicios/servicio_audio.dart';
@@ -49,6 +50,7 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
late List<_BarraState> _barras;
final _random = Random();
bool _activo = false;
StreamSubscription<EstadoReproduccion>? _estadoSubscription;
@override
void initState() {
@@ -68,13 +70,14 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
duration: const Duration(seconds: 1),
)..addListener(_actualizar);
widget.estadoStream.listen(_onEstado);
_estadoSubscription = widget.estadoStream.listen(_onEstado);
}
void _onEstado(EstadoReproduccion estado) {
final nuevoActivo = estado == EstadoReproduccion.reproduciendo ||
estado == EstadoReproduccion.cargando;
if (nuevoActivo == _activo) return;
if (!mounted) return;
setState(() => _activo = nuevoActivo);
if (nuevoActivo) {
_controller.repeat();
@@ -91,6 +94,7 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
@override
void dispose() {
_estadoSubscription?.cancel();
_controller.dispose();
super.dispose();
}
@@ -110,10 +114,11 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
final espaciado = totalAncho / widget.barras;
final anchoBar = (espaciado * 0.55).clamp(2.0, 8.0);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(widget.barras, (i) {
return RepaintBoundary(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(widget.barras, (i) {
final b = _barras[i];
final double altura;
@@ -131,21 +136,22 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
altura = b.alturaActual.clamp(2.0, widget.altura * 0.05);
}
return Padding(
padding: EdgeInsets.symmetric(horizontal: (espaciado - anchoBar) / 2),
child: AnimatedContainer(
duration: const Duration(milliseconds: 80),
width: anchoBar,
height: altura.clamp(2.0, widget.altura),
decoration: BoxDecoration(
color: color.withValues(
alpha: _activo ? 0.7 + (altura / widget.altura) * 0.3 : 0.3,
return Padding(
padding: EdgeInsets.symmetric(horizontal: (espaciado - anchoBar) / 2),
child: AnimatedContainer(
duration: const Duration(milliseconds: 80),
width: anchoBar,
height: altura.clamp(2.0, widget.altura),
decoration: BoxDecoration(
color: color.withValues(
alpha: _activo ? 0.7 + (altura / widget.altura) * 0.3 : 0.3,
),
borderRadius: BorderRadius.circular(anchoBar / 2),
),
borderRadius: BorderRadius.circular(anchoBar / 2),
),
),
);
}),
);
}),
),
);
},
),
@@ -190,15 +196,17 @@ class _IndicadorReproduccionState extends State<IndicadorReproduccion>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
bool _reproduciendo = false;
StreamSubscription<EstadoReproduccion>? _estadoSubscription;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 600))
..addListener(() => setState(() {}));
widget.estadoStream.listen((s) {
_estadoSubscription = widget.estadoStream.listen((s) {
final rep = s == EstadoReproduccion.reproduciendo;
if (rep == _reproduciendo) return;
if (!mounted) return;
setState(() => _reproduciendo = rep);
rep ? _ctrl.repeat(reverse: true) : _ctrl.stop();
});
@@ -206,6 +214,7 @@ class _IndicadorReproduccionState extends State<IndicadorReproduccion>
@override
void dispose() {
_estadoSubscription?.cancel();
_ctrl.dispose();
super.dispose();
}