feat(ui): add premium PluriWave redesign
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user