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
+40 -35
View File
@@ -1,12 +1,14 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'estado/estado_radio.dart';
import 'pantallas/pantalla_inicio.dart';
import 'pantallas/pantalla_buscar.dart';
import 'pantallas/pantalla_favoritos.dart';
import 'pantallas/pantalla_ajustes.dart';
import 'tema/pluriwave_theme.dart';
import 'widgets/mini_reproductor.dart';
import 'widgets/pluri_icon.dart';
class PluriWaveApp extends StatelessWidget {
const PluriWaveApp({super.key});
@@ -18,36 +20,13 @@ class PluriWaveApp extends StatelessWidget {
child: MaterialApp(
title: 'PluriWave',
debugShowCheckedModeBanner: false,
theme: _buildTheme(Brightness.dark),
darkTheme: _buildTheme(Brightness.dark),
theme: PluriWaveTheme.dark(),
darkTheme: PluriWaveTheme.dark(),
themeMode: ThemeMode.dark,
home: const _PaginaPrincipal(),
),
);
}
ThemeData _buildTheme(Brightness brightness) {
final colorScheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF6750A4),
brightness: brightness,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
textTheme: GoogleFonts.interTextTheme(
ThemeData(brightness: brightness).textTheme,
),
cardTheme: CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
color: colorScheme.surfaceContainerLow,
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
}
class _PaginaPrincipal extends StatefulWidget {
@@ -59,6 +38,8 @@ class _PaginaPrincipal extends StatefulWidget {
class _PaginaPrincipalState extends State<_PaginaPrincipal> {
int _indice = 0;
StreamSubscription<String>? _errorSubscription;
EstadoRadio? _estadoSuscrito;
static const _paginas = [
PantallaInicio(),
@@ -69,23 +50,35 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
static const _destinos = [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
icon: PluriIcon(glyph: PluriIconGlyph.home),
selectedIcon: PluriIcon(
glyph: PluriIconGlyph.home,
variant: PluriIconVariant.activeGlow,
),
label: 'Inicio',
),
NavigationDestination(
icon: Icon(Icons.search_outlined),
selectedIcon: Icon(Icons.search),
icon: PluriIcon(glyph: PluriIconGlyph.search),
selectedIcon: PluriIcon(
glyph: PluriIconGlyph.search,
variant: PluriIconVariant.activeGlow,
),
label: 'Buscar',
),
NavigationDestination(
icon: Icon(Icons.favorite_outline),
selectedIcon: Icon(Icons.favorite),
icon: PluriIcon(glyph: PluriIconGlyph.favorites),
selectedIcon: PluriIcon(
glyph: PluriIconGlyph.favorites,
variant: PluriIconVariant.activeGlow,
),
label: 'Favoritos',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
icon: PluriIcon(glyph: PluriIconGlyph.settings),
selectedIcon: PluriIcon(
glyph: PluriIconGlyph.settings,
variant: PluriIconVariant.activeGlow,
),
label: 'Ajustes',
),
];
@@ -93,7 +86,13 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
context.read<EstadoRadio>().errorStream.listen((msg) {
final estado = context.read<EstadoRadio>();
if (identical(_estadoSuscrito, estado) && _errorSubscription != null) {
return;
}
_errorSubscription?.cancel();
_estadoSuscrito = estado;
_errorSubscription = estado.errorStream.listen((msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -105,6 +104,12 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
});
}
@override
void dispose() {
_errorSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
+3 -4
View File
@@ -120,7 +120,7 @@ class EstadoRadio extends ChangeNotifier {
_suscripcionEstadoAudio = audio.estadoStream.listen((estado) {
if (estado == EstadoReproduccion.error) {
if (timer.activo) {
timer.cancelar();
unawaited(timer.cancelar());
}
notifyListeners();
}
@@ -199,7 +199,7 @@ class EstadoRadio extends ChangeNotifier {
notifyListeners();
} catch (e) {
if (timer.activo) {
timer.cancelar();
unawaited(timer.cancelar());
}
final mensajeError = e.toString().replaceFirst('Exception: ', '');
_errorController.add(
@@ -222,7 +222,6 @@ class EstadoRadio extends ChangeNotifier {
await deshabilitarPresetEcualizadorPorEmisora(emisora.uuid, notificar: false);
}
await cargarFavoritos();
notifyListeners();
return esFav;
}
@@ -474,7 +473,7 @@ class EstadoRadio extends ChangeNotifier {
}
void cancelarTimer() {
timer.cancelar();
unawaited(timer.cancelar());
notifyListeners();
}
+115 -141
View File
@@ -1,31 +1,36 @@
import 'dart:convert';
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart' show Share, XFile;
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
import '../estado/estado_radio.dart';
import '../modelos/emisora.dart';
import '../widgets/ecualizador_widget.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_wave_scaffold.dart';
class PantallaAjustes extends StatelessWidget {
const PantallaAjustes({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
return PluriWaveScaffold(
appBar: AppBar(title: const Text('Ajustes')),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
children: const [
_SeccionEcualizador(),
Divider(),
SizedBox(height: 12),
_SeccionEmisoras(),
Divider(),
SizedBox(height: 12),
_SeccionBackup(),
Divider(),
SizedBox(height: 12),
_SeccionInfo(),
],
),
@@ -33,8 +38,6 @@ class PantallaAjustes extends StatelessWidget {
}
}
// ── Sección Ecualizador ───────────────────────────────────────────────────────
class _SeccionEcualizador extends StatelessWidget {
const _SeccionEcualizador();
@@ -44,12 +47,10 @@ class _SeccionEcualizador extends StatelessWidget {
builder: (ctx, estado, _) {
final disponible = estado.ecualizadorDisponible;
final emisoraActual = estado.emisoraActual;
final mostrarModoPorEmisora =
emisoraActual != null && estado.emisoraActualEsFavorita;
final mostrarModoPorEmisora = emisoraActual != null && estado.emisoraActualEsFavorita;
final usandoEqPropio = estado.emisoraActualTienePresetPropio;
return Padding(
padding: const EdgeInsets.all(16),
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -60,8 +61,8 @@ class _SeccionEcualizador extends StatelessWidget {
Text('Ecualizador', style: Theme.of(ctx).textTheme.titleMedium),
const Spacer(),
if (!disponible)
Chip(
label: const Text('Se guarda aunque no esté activo'),
const Chip(
label: Text('Se guarda aunque no esté activo'),
visualDensity: VisualDensity.compact,
),
],
@@ -77,11 +78,7 @@ class _SeccionEcualizador extends StatelessWidget {
: 'Usando EQ principal para ${emisoraActual.nombre}',
),
value: usandoEqPropio,
onChanged: (usarPropio) {
estado.cambiarModoEcualizadorEmisoraActual(
usarPropio: usarPropio,
);
},
onChanged: (usarPropio) => estado.cambiarModoEcualizadorEmisoraActual(usarPropio: usarPropio),
),
],
const SizedBox(height: 8),
@@ -92,9 +89,7 @@ class _SeccionEcualizador extends StatelessWidget {
const SizedBox(height: 12),
EcualizadorWidget(
preset: estado.presetEcualizador,
onCambio: (p) {
estado.cambiarPresetEcualizador(p);
},
onCambio: (p) => estado.cambiarPresetEcualizador(p),
),
],
),
@@ -104,8 +99,6 @@ class _SeccionEcualizador extends StatelessWidget {
}
}
// ── Sección Emisoras personalizadas ──────────────────────────────────────────
class _SeccionEmisoras extends StatelessWidget {
const _SeccionEmisoras();
@@ -114,17 +107,15 @@ class _SeccionEmisoras extends StatelessWidget {
final estado = context.watch<EstadoRadio>();
final custom = estado.emisorasCustom;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.add_circle_outline),
const SizedBox(width: 12),
Text('Emisoras personalizadas',
style: Theme.of(context).textTheme.titleMedium),
Text('Emisoras personalizadas', style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
TextButton.icon(
icon: const Icon(Icons.add),
@@ -133,36 +124,36 @@ class _SeccionEmisoras extends StatelessWidget {
),
],
),
),
if (custom.isEmpty)
const Padding(
padding: EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Text('No hay emisoras personalizadas.',
style: TextStyle(color: Colors.grey)),
)
else
for (final emisora in custom)
ListTile(
leading: const Icon(Icons.radio),
title: Text(emisora.nombre),
subtitle: Text(emisora.url, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.play_arrow),
tooltip: 'Reproducir',
onPressed: () => context.read<EstadoRadio>().reproducir(emisora),
),
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Eliminar',
onPressed: () => context.read<EstadoRadio>().eliminarEmitoraCustom(emisora.uuid),
),
],
if (custom.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text('No hay emisoras personalizadas.', style: TextStyle(color: Colors.grey)),
)
else
for (final emisora in custom)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.radio),
title: Text(emisora.nombre),
subtitle: Text(emisora.url, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.play_arrow),
tooltip: 'Reproducir',
onPressed: () => context.read<EstadoRadio>().reproducir(emisora),
),
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Eliminar',
onPressed: () => context.read<EstadoRadio>().eliminarEmitoraCustom(emisora.uuid),
),
],
),
),
),
],
],
),
);
}
@@ -249,10 +240,7 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> {
const SizedBox(height: 12),
TextFormField(
controller: _paisCtrl,
decoration: const InputDecoration(
labelText: 'País (opcional)',
border: OutlineInputBorder(),
),
decoration: const InputDecoration(labelText: 'País (opcional)', border: OutlineInputBorder()),
),
const SizedBox(height: 20),
FilledButton(
@@ -268,8 +256,6 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> {
}
}
// ── Sección Backup ────────────────────────────────────────────────────────────
class _SeccionBackup extends StatelessWidget {
const _SeccionBackup();
@@ -290,19 +276,14 @@ class _SeccionBackup extends StatelessWidget {
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error al exportar: $e')),
);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error al exportar: $e')));
}
}
}
Future<void> _importar(BuildContext context) async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['json'],
);
final result = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['json']);
if (result == null || result.files.single.path == null) return;
final file = File(result.files.single.path!);
@@ -313,106 +294,99 @@ class _SeccionBackup extends StatelessWidget {
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Importar configuración'),
content: const Text(
'Esto añadirá los favoritos, emisoras y presets del fichero. '
'¿Continuar?'),
content: const Text('Esto añadirá los favoritos, emisoras y presets del fichero. ¿Continuar?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancelar')),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Importar')),
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancelar')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Importar')),
],
),
);
if (confirmar != true) return;
if (context.mounted) {
await context.read<EstadoRadio>().importarConfig(json);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Configuración importada correctamente')),
);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Configuración importada correctamente')));
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error al importar: $e')),
);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error al importar: $e')));
}
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.backup_outlined),
const SizedBox(width: 12),
Text('Copia de seguridad',
style: Theme.of(context).textTheme.titleMedium),
Text('Copia de seguridad', style: Theme.of(context).textTheme.titleMedium),
],
),
),
ListTile(
leading: const Icon(Icons.upload_outlined),
title: const Text('Exportar configuración'),
subtitle: const Text('Favoritos, emisoras custom y presets de EQ'),
onTap: () => _exportar(context),
),
ListTile(
leading: const Icon(Icons.download_outlined),
title: const Text('Importar configuración'),
subtitle: const Text('Restaurar desde un fichero de copia de seguridad'),
onTap: () => _importar(context),
),
],
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.upload_outlined),
title: const Text('Exportar configuración'),
subtitle: const Text('Favoritos, emisoras custom y presets de EQ'),
onTap: () => _exportar(context),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.download_outlined),
title: const Text('Importar configuración'),
subtitle: const Text('Restaurar desde un fichero de copia de seguridad'),
onTap: () => _importar(context),
),
],
),
);
}
}
// ── Sección Info ──────────────────────────────────────────────────────────────
class _SeccionInfo extends StatelessWidget {
const _SeccionInfo();
@override
Widget build(BuildContext context) {
return Consumer<EstadoRadio>(
builder: (ctx, estado, _) => Column(
children: [
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('PluriWave'),
subtitle: const Text('v0.3.0 — Radio mundial'),
),
FutureBuilder<int>(
future: estado.favoritos.obtenerTodos().then((l) => l.length),
builder: (ctx, snap) => ListTile(
leading: const Icon(Icons.favorite_outline),
title: const Text('Favoritos guardados'),
trailing: Text(snap.data?.toString() ?? '',
style: Theme.of(ctx).textTheme.bodyLarge),
builder: (ctx, estado, _) => PluriGlassSurface(
child: Column(
children: [
const ListTile(
contentPadding: EdgeInsets.zero,
leading: PluriIcon(glyph: PluriIconGlyph.settings, variant: PluriIconVariant.filled),
title: Text('PluriWave'),
subtitle: Text('v0.3.0 — Radio mundial'),
),
),
ListTile(
leading: const Icon(Icons.verified_outlined),
title: const Text('Filtro de emisoras'),
subtitle: const Text('Solo emisoras verificadas como activas'),
trailing: const Icon(Icons.check_circle, color: Colors.green),
),
ListTile(
leading: const Icon(Icons.music_note_outlined),
title: const Text('Audio en background'),
subtitle: const Text('Continúa al apagar la pantalla'),
trailing: const Icon(Icons.check_circle, color: Colors.green),
),
],
FutureBuilder<int>(
future: estado.favoritos.obtenerTodos().then((l) => l.length),
builder: (ctx, snap) => ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.favorite_outline),
title: const Text('Favoritos guardados'),
trailing: Text(snap.data?.toString() ?? '', style: Theme.of(ctx).textTheme.bodyLarge),
),
),
const ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.verified_outlined),
title: Text('Filtro de emisoras'),
subtitle: Text('Solo emisoras verificadas como activas'),
trailing: Icon(Icons.check_circle, color: Colors.green),
),
const ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.music_note_outlined),
title: Text('Audio en background'),
subtitle: Text('Continúa al apagar la pantalla'),
trailing: Icon(Icons.check_circle, color: Colors.green),
),
],
),
),
);
}
+73 -86
View File
@@ -1,7 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import '../estado/estado_radio.dart';
import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/tarjeta_emisora.dart';
const _paises = [
@@ -15,7 +19,6 @@ const _idiomas = [
'italian', 'japanese', 'arabic', 'russian',
];
/// Pantalla de búsqueda avanzada de emisoras.
class PantallaBuscar extends StatefulWidget {
const PantallaBuscar({super.key});
@@ -27,7 +30,6 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
final _controller = TextEditingController();
String? _paisSeleccionado;
String? _idiomaSeleccionado;
bool _buscando = false;
@override
void dispose() {
@@ -51,90 +53,79 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
return Column(
children: [
// Barra de búsqueda
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: SearchBar(
controller: _controller,
hintText: 'Nombre de la emisora...',
leading: const Icon(Icons.search),
trailing: [
if (_controller.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
setState(() {});
},
),
],
onSubmitted: (_) => _buscar(),
onChanged: (_) => setState(() {}),
padding: const EdgeInsets.fromLTRB(16, 10, 16, 0),
child: PluriGlassSurface(
padding: const EdgeInsets.all(10),
child: SearchBar(
controller: _controller,
hintText: 'Nombre de la emisora...',
leading: const PluriIcon(glyph: PluriIconGlyph.search, variant: PluriIconVariant.filled),
trailing: [
if (_controller.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
setState(() {});
_buscar();
},
),
],
onSubmitted: (_) => _buscar(),
onChanged: (_) => setState(() {}),
),
),
),
// Filtros país
_seccionFiltro(
theme,
'País',
_paises.map((p) => (p.$1, p.$2)).toList(),
_paisSeleccionado,
(v) => setState(() {
_paisSeleccionado = v;
_buscar();
}),
),
// Filtros idioma
_seccionFiltro(
theme,
'Idioma',
_idiomas.map((i) => (i, i)).toList(),
_idiomaSeleccionado,
(v) => setState(() {
_idiomaSeleccionado = v;
_buscar();
}),
),
// Resultados
Expanded(
child: _resultados(estado, theme),
),
_seccionFiltro('País', _paises.map((p) => (p.$1, p.$2)).toList(), _paisSeleccionado, (v) {
setState(() => _paisSeleccionado = v);
_buscar();
}),
_seccionFiltro('Idioma', _idiomas.map((i) => (i, i)).toList(), _idiomaSeleccionado, (v) {
setState(() => _idiomaSeleccionado = v);
_buscar();
}),
Expanded(child: _resultados(estado, theme)),
],
);
}
Widget _seccionFiltro(
ThemeData theme,
String titulo,
List<(String, String)> opciones,
String? seleccionado,
void Function(String?) onChanged,
) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(titulo, style: theme.textTheme.labelLarge),
const SizedBox(height: 4),
SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: opciones.length,
separatorBuilder: (_, __) => const SizedBox(width: 6),
itemBuilder: (_, i) {
final (label, value) = opciones[i];
final sel = seleccionado == value;
return FilterChip(
label: Text(label),
selected: sel,
visualDensity: VisualDensity.compact,
onSelected: (_) => onChanged(sel ? null : value),
);
},
child: PluriGlassSurface(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(titulo, style: theme.textTheme.labelLarge),
const SizedBox(height: 4),
SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: opciones.length,
separatorBuilder: (_, __) => const SizedBox(width: 6),
itemBuilder: (_, i) {
final (label, value) = opciones[i];
final sel = seleccionado == value;
return FilterChip(
label: Text(label),
selected: sel,
visualDensity: VisualDensity.compact,
onSelected: (_) => onChanged(sel ? null : value),
);
},
),
),
),
],
],
),
),
);
}
@@ -147,21 +138,17 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
final resultados = estado.resultadosBusqueda;
if (resultados.isEmpty) {
final sinFiltros = _controller.text.isEmpty &&
_paisSeleccionado == null &&
_idiomaSeleccionado == null;
final sinFiltros = _controller.text.isEmpty && _paisSeleccionado == null && _idiomaSeleccionado == null;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.search, size: 64, color: theme.colorScheme.outlineVariant),
const SizedBox(height: 16),
Text(
sinFiltros ? 'Busca una emisora' : 'Sin resultados',
style: theme.textTheme.titleMedium,
),
],
child: PluriGlassSurface(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const PluriIcon(glyph: PluriIconGlyph.search, variant: PluriIconVariant.activeGlow, size: 44),
const SizedBox(height: 14),
Text(sinFiltros ? 'Buscá una emisora' : 'Sin resultados', style: theme.textTheme.titleMedium),
],
),
),
);
}
@@ -169,7 +156,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: resultados.length,
separatorBuilder: (_, __) => const SizedBox(height: 4),
separatorBuilder: (_, __) => const SizedBox(height: 6),
itemBuilder: (context, i) => TarjetaEmisora(
emisora: resultados[i],
esCompacta: true,
+52 -39
View File
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_radio.dart';
import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/tarjeta_emisora.dart';
/// Pantalla de emisoras favoritas con reordenado y swipe-to-delete.
class PantallaFavoritos extends StatelessWidget {
const PantallaFavoritos({super.key});
@@ -15,26 +18,26 @@ class PantallaFavoritos extends StatelessWidget {
if (favoritos.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.favorite_border, size: 72, color: theme.colorScheme.outlineVariant),
const SizedBox(height: 16),
Text('Sin favoritos aún', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Toca ♥ en cualquier emisora para guardarla',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
child: PluriGlassSurface(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const PluriIcon(glyph: PluriIconGlyph.favorites, variant: PluriIconVariant.activeGlow, size: 52),
const SizedBox(height: 16),
Text('Sin favoritos aún', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Tocá ♥ en cualquier emisora para guardarla',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
),
],
],
),
),
);
}
return ReorderableListView.builder(
padding: const EdgeInsets.all(8),
padding: const EdgeInsets.all(12),
onReorder: (oldIndex, newIndex) async {
if (newIndex > oldIndex) newIndex--;
final emisora = favoritos[oldIndex];
@@ -44,29 +47,39 @@ class PantallaFavoritos extends StatelessWidget {
itemCount: favoritos.length,
itemBuilder: (context, i) {
final emisora = favoritos[i];
return Dismissible(
key: Key(emisora.uuid),
direction: DismissDirection.endToStart,
background: Container(
color: theme.colorScheme.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: Icon(Icons.delete, color: theme.colorScheme.onError),
),
onDismissed: (_) async {
await estado.favoritos.eliminar(emisora.uuid);
await estado.cargarFavoritos();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${emisora.nombre} eliminada de favoritos')),
);
}
},
child: TarjetaEmisora(
key: Key(emisora.uuid),
emisora: emisora,
esCompacta: true,
onTap: () => estado.reproducir(emisora),
return Padding(
key: ValueKey('favorito-pad-${emisora.uuid}'),
padding: const EdgeInsets.symmetric(vertical: 4),
child: PluriGlassSurface(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
const Icon(Icons.drag_indicator_rounded),
const SizedBox(width: 6),
Expanded(
child: TarjetaEmisora(
key: Key(emisora.uuid),
emisora: emisora,
esCompacta: true,
onTap: () => estado.reproducir(emisora),
),
),
IconButton(
tooltip: 'Eliminar de favoritos',
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () async {
await estado.favoritos.eliminar(emisora.uuid);
await estado.cargarFavoritos();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${emisora.nombre} eliminada de favoritos')),
);
}
},
),
],
),
),
),
);
},
+123 -117
View File
@@ -1,8 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart' as shimmer;
import '../estado/estado_radio.dart';
import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/tarjeta_emisora.dart';
/// Pantalla principal: emisoras populares y por género.
@@ -24,93 +28,117 @@ class _PantallaInicioState extends State<PantallaInicio> {
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
final theme = Theme.of(context);
final t = context.pluriTokens;
return RefreshIndicator(
onRefresh: estado.cargarPopulares,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _seccionTendencias(estado, theme),
SliverToBoxAdapter(child: _heroHeader(context)),
SliverToBoxAdapter(child: _seccionTendencias(estado, theme)),
SliverToBoxAdapter(child: _chipGeneros(context, theme)),
if (estado.error != null) SliverToBoxAdapter(child: _errorBanner(estado, theme)),
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: t.spacingMd),
sliver: _gridEmisoras(estado),
),
SliverToBoxAdapter(
child: _chipGeneros(theme),
),
if (estado.error != null)
SliverToBoxAdapter(
child: _errorBanner(estado, theme),
),
_gridEmisoras(estado, theme),
],
),
);
}
Widget _heroHeader(BuildContext context) {
final t = context.pluriTokens;
final theme = Theme.of(context);
return Padding(
padding: EdgeInsets.fromLTRB(t.spacingMd, t.spacingSm, t.spacingMd, t.spacingSm),
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(t.radiusLg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const PluriIcon(glyph: PluriIconGlyph.home, variant: PluriIconVariant.activeGlow, size: 30),
const SizedBox(height: 10),
Text('PluriWave', style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700)),
Text('Ondas vivas globales', style: theme.textTheme.titleMedium?.copyWith(color: t.warmCoral)),
],
),
),
);
}
Widget _seccionTendencias(EstadoRadio estado, ThemeData theme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('🔥 Tendencias', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 56,
child: estado.cargandoPopulares
? ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: 5,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, __) => _ChipShimmer(theme: theme),
)
: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: estado.tendencias.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final e = estado.tendencias[i];
return ActionChip(
avatar: const Icon(Icons.radio, size: 18),
label: Text(e.nombre, maxLines: 1),
onPressed: () => context.read<EstadoRadio>().reproducir(e),
).animate().fadeIn(delay: (i * 50).ms);
},
),
),
],
child: PluriGlassSurface(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Tendencias premium', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 56,
child: estado.cargandoPopulares
? ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: 5,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, __) => _ChipShimmer(theme: theme),
)
: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: estado.tendencias.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final e = estado.tendencias[i];
return ActionChip(
avatar: const Icon(Icons.graphic_eq_rounded, size: 18),
label: Text(e.nombre, maxLines: 1),
onPressed: () => context.read<EstadoRadio>().reproducir(e),
).animate().fadeIn(delay: (i * 50).ms);
},
),
),
],
),
),
);
}
Widget _chipGeneros(ThemeData theme) {
Widget _chipGeneros(BuildContext context, ThemeData theme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Géneros', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: _generos.map((g) {
final seleccionado = _generoSeleccionado == g;
return FilterChip(
label: Text(g),
selected: seleccionado,
onSelected: (_) {
setState(() {
_generoSeleccionado = seleccionado ? null : g;
});
if (!seleccionado) {
context.read<EstadoRadio>().buscar(tag: g);
} else {
context.read<EstadoRadio>().cargarPopulares();
}
},
);
}).toList(),
),
],
child: PluriGlassSurface(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Géneros', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: _generos.map((g) {
final seleccionado = _generoSeleccionado == g;
return FilterChip(
label: Text(g),
selected: seleccionado,
onSelected: (_) {
setState(() {
_generoSeleccionado = seleccionado ? null : g;
});
if (!seleccionado) {
context.read<EstadoRadio>().buscar(tag: g);
} else {
context.read<EstadoRadio>().cargarPopulares();
}
},
);
}).toList(),
),
],
),
),
);
}
@@ -118,44 +146,27 @@ class _PantallaInicioState extends State<PantallaInicio> {
Widget _errorBanner(EstadoRadio estado, ThemeData theme) {
return Padding(
padding: const EdgeInsets.all(16),
child: Card(
color: theme.colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.wifi_off, color: theme.colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
estado.error!,
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
),
TextButton(
onPressed: estado.cargarPopulares,
child: const Text('Reintentar'),
),
],
),
child: PluriGlassSurface(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.wifi_off, color: theme.colorScheme.error),
const SizedBox(width: 8),
Expanded(child: Text(estado.error!)),
TextButton(onPressed: estado.cargarPopulares, child: const Text('Reintentar')),
],
),
),
);
}
Widget _gridEmisoras(EstadoRadio estado, ThemeData theme) {
final emisoras = _generoSeleccionado != null
? estado.resultadosBusqueda
: estado.emisorasInicio;
final cargando = estado.cargandoPopulares ||
(_generoSeleccionado != null && estado.cargandoBusqueda);
Widget _gridEmisoras(EstadoRadio estado) {
final emisoras = _generoSeleccionado != null ? estado.resultadosBusqueda : estado.emisorasInicio;
final cargando = estado.cargandoPopulares || (_generoSeleccionado != null && estado.cargandoBusqueda);
if (cargando) {
return SliverGrid(
delegate: SliverChildBuilderDelegate(
(_, __) => const TarjetaEmisoraShimmer(),
childCount: 12,
),
delegate: SliverChildBuilderDelegate((_, __) => const TarjetaEmisoraShimmer(), childCount: 12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.85,
@@ -166,27 +177,22 @@ class _PantallaInicioState extends State<PantallaInicio> {
}
if (emisoras.isEmpty) {
return const SliverFillRemaining(
child: Center(child: Text('No hay emisoras disponibles')),
);
return const SliverFillRemaining(child: Center(child: Text('No hay emisoras disponibles')));
}
return SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, i) => TarjetaEmisora(
emisora: emisoras[i],
onTap: () => context.read<EstadoRadio>().reproducir(emisoras[i]),
).animate().fadeIn(delay: (i * 30).ms).slideY(begin: 0.1),
childCount: emisoras.length,
),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.85,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
return SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, i) => TarjetaEmisora(
emisora: emisoras[i],
onTap: () => context.read<EstadoRadio>().reproducir(emisoras[i]),
).animate().fadeIn(delay: (i * 30).ms).slideY(begin: 0.1),
childCount: emisoras.length,
),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.85,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
);
}
+227 -272
View File
@@ -1,37 +1,31 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart';
import '../widgets/visualizador_audio.dart';
import '../estado/estado_radio.dart';
import '../modelos/emisora.dart';
import '../servicios/servicio_audio.dart';
import '../servicios/servicio_timer.dart';
import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_wave_scaffold.dart';
import '../widgets/visualizador_audio.dart';
/// Pantalla completa del reproductor de radio.
///
/// Muestra: carátula/logo grande, nombre emisora, información (país, idioma,
/// codec/bitrate), controles play/pause, botón favorito, acceso al timer.
///
/// Se abre como ruta desde cualquier pantalla al pulsar sobre una emisora
/// o desde el MiniReproductor.
class PantallaReproductor extends StatefulWidget {
final Emisora emisora;
const PantallaReproductor({super.key, required this.emisora});
/// Navega a la pantalla del reproductor.
static Future<void> abrir(BuildContext context, Emisora emisora) {
return Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (_, animation, __) => PantallaReproductor(emisora: emisora),
transitionsBuilder: (_, animation, __, child) => SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)),
position: Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero)
.animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)),
child: child,
),
transitionDuration: const Duration(milliseconds: 350),
@@ -46,28 +40,16 @@ class PantallaReproductor extends StatefulWidget {
class _PantallaReproductorState extends State<PantallaReproductor>
with SingleTickerProviderStateMixin {
late AnimationController _pulseController;
bool _esFavorito = false;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
);
_checkFavorito();
_pulseController = AnimationController(vsync: this, duration: const Duration(seconds: 2));
_iniciarReproduccion();
}
Future<void> _checkFavorito() async {
final estado = context.read<EstadoRadio>();
final fav = await estado.esFavorito(widget.emisora.uuid);
if (mounted) setState(() => _esFavorito = fav);
}
Future<void> _iniciarReproduccion() async {
final estado = context.read<EstadoRadio>();
// Solo reproductor si no es ya la emisora activa
if (estado.emisoraActual?.uuid != widget.emisora.uuid) {
await estado.reproducir(widget.emisora);
}
@@ -82,10 +64,12 @@ class _PantallaReproductorState extends State<PantallaReproductor>
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.pluriTokens;
final estado = context.watch<EstadoRadio>();
final emisoraActiva = estado.emisoraActual ?? widget.emisora;
final esFavorito = estado.listaFavoritos.any((e) => e.uuid == emisoraActiva.uuid);
return Scaffold(
backgroundColor: theme.colorScheme.surface,
return PluriWaveScaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
@@ -95,78 +79,60 @@ class _PantallaReproductorState extends State<PantallaReproductor>
onPressed: () => Navigator.pop(context),
),
actions: [
// Botón favorito
IconButton(
icon: Icon(
_esFavorito ? Icons.favorite_rounded : Icons.favorite_outline_rounded,
color: _esFavorito ? theme.colorScheme.error : null,
esFavorito ? Icons.favorite_rounded : Icons.favorite_outline_rounded,
color: esFavorito ? theme.colorScheme.error : null,
),
tooltip: _esFavorito ? 'Quitar de favoritos' : 'Añadir a favoritos',
onPressed: () async {
final esFav = await estado.toggleFavorito(widget.emisora);
if (mounted) setState(() => _esFavorito = esFav);
},
tooltip: esFavorito ? 'Quitar de favoritos' : 'Anadir a favoritos',
onPressed: () async => estado.toggleFavorito(emisoraActiva),
),
],
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
const Spacer(flex: 1),
// Carátula / logo grande
_Artwork(
emisora: widget.emisora,
estadoStream: estado.estadoStream,
).animate().scale(begin: const Offset(0.8, 0.8), duration: 400.ms,
curve: Curves.easeOutBack),
const SizedBox(height: 32),
// Nombre de la emisora
const SizedBox(height: 8),
_WaveHero(emisora: emisoraActiva, estadoStream: estado.estadoStream)
.animate()
.scale(begin: const Offset(0.86, 0.86), duration: 420.ms, curve: Curves.easeOutBack),
const SizedBox(height: 18),
Text(
widget.emisora.nombre,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
emisoraActiva.nombre,
style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
).animate().fadeIn(delay: 150.ms),
const SizedBox(height: 8),
// Info: país, idioma
_InfoChips(emisora: widget.emisora)
.animate()
.fadeIn(delay: 200.ms)
.slideY(begin: 0.2),
const SizedBox(height: 4),
// Codec / bitrate
if (widget.emisora.codec != null || widget.emisora.bitrate != null)
const SizedBox(height: 10),
_InfoChips(emisora: emisoraActiva).animate().fadeIn(delay: 200.ms).slideY(begin: 0.2),
const SizedBox(height: 6),
if (emisoraActiva.codec != null || emisoraActiva.bitrate != null)
Text(
_codecInfo(widget.emisora),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
_codecInfo(emisoraActiva),
style: theme.textTheme.bodySmall?.copyWith(color: Colors.white.withValues(alpha: 0.72)),
).animate().fadeIn(delay: 250.ms),
const SizedBox(height: 16),
// Visualizador de audio
VisualizadorAudio(
estadoStream: estado.estadoStream,
barras: 24,
color: theme.colorScheme.primary,
altura: 48,
const SizedBox(height: 14),
PluriGlassSurface(
borderRadius: BorderRadius.circular(tokens.radiusLg),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: VisualizadorAudio(
estadoStream: estado.estadoStream,
barras: 26,
color: tokens.electricMagenta,
altura: 46,
),
).animate().fadeIn(delay: 280.ms),
const Spacer(flex: 2),
// Controles
_Controles(
estado: estado,
emisora: widget.emisora,
).animate().fadeIn(delay: 300.ms).slideY(begin: 0.3),
const SizedBox(height: 24),
// Timer
_TimerWidget(estado: estado)
const Spacer(),
_Controles(estado: estado, emisora: emisoraActiva)
.animate()
.fadeIn(delay: 400.ms),
const Spacer(flex: 1),
.fadeIn(delay: 300.ms)
.slideY(begin: 0.3),
const SizedBox(height: 24),
_TimerWidget(estado: estado).animate().fadeIn(delay: 400.ms),
const SizedBox(height: 16),
],
),
),
@@ -182,18 +148,17 @@ class _PantallaReproductorState extends State<PantallaReproductor>
}
}
// ─── Artwork ────────────────────────────────────────────────────────────────
class _Artwork extends StatelessWidget {
class _WaveHero extends StatelessWidget {
final Emisora emisora;
final Stream<EstadoReproduccion> estadoStream;
const _Artwork({required this.emisora, required this.estadoStream});
const _WaveHero({required this.emisora, required this.estadoStream});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final size = MediaQuery.of(context).size.width * 0.65;
final t = context.pluriTokens;
final size = MediaQuery.of(context).size.width * 0.62;
return StreamBuilder<EstadoReproduccion>(
stream: estadoStream,
@@ -202,76 +167,74 @@ class _Artwork extends StatelessWidget {
final cargando = snapshot.data == EstadoReproduccion.cargando;
final hayError = snapshot.data == EstadoReproduccion.error;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: hayError
? [
BoxShadow(
color: theme.colorScheme.error.withValues(alpha: 0.25),
blurRadius: 12,
spreadRadius: 2,
),
]
: reproduciendo
? [
BoxShadow(
color: theme.colorScheme.primary.withValues(alpha: 0.4),
blurRadius: 30,
spreadRadius: 5,
),
]
: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 12,
spreadRadius: 2,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Stack(
fit: StackFit.expand,
children: [
// Logo / imagen
// errorWidget captura HandshakeException (cert autofirmado)
// y cualquier fallo de red en artwork. El error queda
// contenido aquí — no se propaga ni rompe el reproductor.
if (emisora.favicon != null && emisora.favicon!.isNotEmpty)
CachedNetworkImage(
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, url, error) => _iconoFallback(theme),
)
else
_iconoFallback(theme),
// Overlay de carga
if (cargando)
Container(
color: Colors.black45,
child: const Center(
child: CircularProgressIndicator(color: Colors.white),
return SizedBox(
width: size + 40,
height: size + 40,
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: size + 34,
height: size + 34,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
t.electricMagenta.withValues(alpha: reproduciendo ? 0.35 : 0.18),
t.deepViolet.withValues(alpha: 0.0),
],
),
),
),
Container(
width: size + 12,
height: size + 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withValues(alpha: 0.16)),
),
),
PluriGlassSurface(
borderRadius: BorderRadius.circular(size),
padding: EdgeInsets.zero,
child: SizedBox(
width: size,
height: size,
child: ClipOval(
child: Stack(
fit: StackFit.expand,
children: [
if (emisora.favicon != null && emisora.favicon!.isNotEmpty)
CachedNetworkImage(
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
)
else
_iconoFallback(theme),
if (cargando)
Container(
color: Colors.black45,
child: const Center(child: CircularProgressIndicator(color: Colors.white)),
),
if (hayError)
Container(
color: Colors.black54,
child: Center(
child: Icon(
Icons.wifi_off_rounded,
size: 56,
color: Colors.white.withValues(alpha: 0.85),
),
),
),
],
),
),
// Overlay de error de reproducción
if (hayError)
Container(
color: Colors.black54,
child: Center(
child: Icon(
Icons.wifi_off_rounded,
size: 56,
color: Colors.white.withValues(alpha: 0.85),
),
),
),
],
),
),
),
],
),
);
},
@@ -279,23 +242,17 @@ class _Artwork extends StatelessWidget {
}
Widget _shimmer(ThemeData theme) => Shimmer.fromColors(
baseColor: theme.colorScheme.surfaceContainerHighest,
highlightColor: theme.colorScheme.surface,
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
baseColor: theme.colorScheme.surfaceContainerHighest,
highlightColor: theme.colorScheme.surface,
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
Widget _iconoFallback(ThemeData theme) => Container(
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 80,
color: theme.colorScheme.onPrimaryContainer,
),
);
color: theme.colorScheme.primaryContainer,
child: Icon(Icons.radio_rounded, size: 80, color: theme.colorScheme.onPrimaryContainer),
);
}
// ─── Info chips ─────────────────────────────────────────────────────────────
class _InfoChips extends StatelessWidget {
final Emisora emisora;
const _InfoChips({required this.emisora});
@@ -309,24 +266,24 @@ class _InfoChips extends StatelessWidget {
if (items.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: 6,
spacing: 8,
runSpacing: 6,
alignment: WrapAlignment.center,
children: items
.map((label) => Chip(
label: Text(label),
visualDensity: VisualDensity.compact,
backgroundColor: theme.colorScheme.secondaryContainer,
labelStyle: TextStyle(
color: theme.colorScheme.onSecondaryContainer,
fontSize: 12),
padding: EdgeInsets.zero,
))
.map(
(label) => Chip(
label: Text(label),
visualDensity: VisualDensity.compact,
backgroundColor: theme.colorScheme.secondaryContainer.withValues(alpha: 0.8),
labelStyle: TextStyle(color: theme.colorScheme.onSecondaryContainer, fontSize: 12),
padding: EdgeInsets.zero,
),
)
.toList(),
);
}
}
// ─── Controles ──────────────────────────────────────────────────────────────
class _Controles extends StatelessWidget {
final EstadoRadio estado;
final Emisora emisora;
@@ -345,22 +302,15 @@ class _Controles extends StatelessWidget {
final cargando = s == EstadoReproduccion.cargando;
final hayError = s == EstadoReproduccion.error;
// En estado de error: mostrar mensaje y botón de reintento
if (hayError) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline_rounded,
size: 40,
color: theme.colorScheme.error,
),
Icon(Icons.error_outline_rounded, size: 40, color: theme.colorScheme.error),
const SizedBox(height: 8),
Text(
'No se puede reproducir esta radio',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.error),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
@@ -373,87 +323,92 @@ class _Controles extends StatelessWidget {
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Botón detener
IconButton(
icon: const Icon(Icons.stop_rounded),
iconSize: 36,
color: theme.colorScheme.onSurfaceVariant,
tooltip: 'Detener',
onPressed: cargando ? null : () => estado.audio.detener(),
),
const SizedBox(width: 16),
// Botón play/pause principal
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.primary,
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withValues(alpha: 0.35),
blurRadius: reproduciendo ? 16 : 6,
spreadRadius: reproduciendo ? 4 : 0,
),
],
return PluriGlassSurface(
borderRadius: BorderRadius.circular(28),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Semantics(
button: true,
label: 'Detener reproduccion',
child: IconButton(
icon: const Icon(Icons.stop_rounded),
iconSize: 34,
constraints: const BoxConstraints(minWidth: 56, minHeight: 56),
color: Colors.white.withValues(alpha: 0.78),
tooltip: 'Detener',
onPressed: cargando ? null : () => estado.audio.detener(),
),
),
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: cargando
? null
: () {
if (reproduciendo || s == EstadoReproduccion.pausado) {
estado.togglePlay();
} else {
estado.reproducir(emisora);
}
},
child: Center(
child: cargando
? const SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: Icon(
reproduciendo
? Icons.pause_rounded
: Icons.play_arrow_rounded,
size: 40,
color: theme.colorScheme.onPrimary,
),
const SizedBox(width: 16),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.primary,
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withValues(alpha: 0.35),
blurRadius: reproduciendo ? 16 : 6,
spreadRadius: reproduciendo ? 4 : 0,
),
],
),
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
radius: 40,
onTap: cargando
? null
: () {
if (reproduciendo || s == EstadoReproduccion.pausado) {
estado.togglePlay();
} else {
estado.reproducir(emisora);
}
},
child: Semantics(
button: true,
label: reproduciendo ? 'Pausar reproduccion' : 'Iniciar reproduccion',
child: Center(
child: cargando
? const SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
)
: Icon(
reproduciendo ? Icons.pause_rounded : Icons.play_arrow_rounded,
size: 40,
color: theme.colorScheme.onPrimary,
),
),
),
),
),
),
),
const SizedBox(width: 16),
// Indicador en vivo
Icon(
Icons.fiber_manual_record_rounded,
size: 36,
color: reproduciendo
? theme.colorScheme.error
: theme.colorScheme.surfaceContainerHighest,
),
],
const SizedBox(width: 16),
Semantics(
label: reproduciendo ? 'En vivo' : 'No esta reproduciendo',
child: Icon(
Icons.fiber_manual_record_rounded,
size: 32,
color: reproduciendo ? theme.colorScheme.error : theme.colorScheme.surfaceContainerHighest,
),
),
],
),
);
},
);
}
}
// ─── Timer widget ────────────────────────────────────────────────────────────
class _TimerWidget extends StatelessWidget {
final EstadoRadio estado;
const _TimerWidget({required this.estado});
@@ -465,7 +420,7 @@ class _TimerWidget extends StatelessWidget {
if (!estado.timer.activo) {
return TextButton.icon(
icon: const Icon(Icons.bedtime_outlined, size: 18),
label: const Text('Timer de sueño'),
label: const Text('Timer de sueno'),
onPressed: () => _mostrarTimerDialog(context),
);
}
@@ -476,9 +431,7 @@ class _TimerWidget extends StatelessWidget {
final t = snap.data ?? Duration.zero;
final m = t.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = t.inSeconds.remainder(60).toString().padLeft(2, '0');
final label = t.inHours > 0
? '${t.inHours}h ${m}m'
: '${m}m ${s}s';
final label = t.inHours > 0 ? '${t.inHours}h ${m}m' : '${m}m ${s}s';
return Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -511,18 +464,20 @@ class _TimerWidget extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Timer de sueño', style: Theme.of(ctx).textTheme.titleLarge),
Text('Timer de sueno', style: Theme.of(ctx).textTheme.titleLarge),
const SizedBox(height: 16),
Wrap(
spacing: 8,
children: opcionesTimer
.map((min) => ActionChip(
label: Text('$min min'),
onPressed: () {
estado.iniciarTimer(min);
Navigator.pop(ctx);
},
))
.map(
(min) => ActionChip(
label: Text('$min min'),
onPressed: () {
estado.iniciarTimer(min);
Navigator.pop(ctx);
},
),
)
.toList(),
),
],
+23 -6
View File
@@ -115,11 +115,28 @@ class ServicioFavoritos {
/// Actualiza el orden de un favorito.
Future<void> reordenar(String uuid, int nuevoOrden) async {
final db = await _database;
await db.update(
'favoritos',
{'orden': nuevoOrden},
where: 'uuid = ?',
whereArgs: [uuid],
);
await db.transaction((txn) async {
final rows = await txn.query(
'favoritos',
columns: ['uuid'],
orderBy: 'orden ASC, id ASC',
);
final uuids = rows.map((r) => r['uuid'] as String).toList();
final oldIndex = uuids.indexOf(uuid);
if (oldIndex == -1 || uuids.isEmpty) return;
final targetIndex = nuevoOrden.clamp(0, uuids.length - 1);
final moved = uuids.removeAt(oldIndex);
uuids.insert(targetIndex, moved);
for (var i = 0; i < uuids.length; i++) {
await txn.update(
'favoritos',
{'orden': i},
where: 'uuid = ?',
whereArgs: [uuids[i]],
);
}
});
}
}
+4 -4
View File
@@ -34,7 +34,7 @@ class ServicioTimer {
/// Inicia el timer para [minutos] minutos.
void iniciar(int minutos) {
cancelar();
unawaited(cancelar());
final duracion = Duration(minutes: minutos);
_finAt = DateTime.now().add(duracion);
_tiempoRestante = duracion;
@@ -92,7 +92,7 @@ class ServicioTimer {
}
/// Cancela el timer activo sin detener el audio.
void cancelar({bool detenerAudio = false}) {
Future<void> cancelar({bool detenerAudio = false}) async {
_timer?.cancel();
_timer = null;
_fadeTicker?.cancel();
@@ -102,12 +102,12 @@ class ServicioTimer {
_controller.add(_tiempoRestante);
if (detenerAudio) {
_audio.detener();
await _audio.detener();
}
}
void dispose() {
cancelar();
unawaited(cancelar());
_controller.close();
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
@immutable
class PluriWaveMotion extends ThemeExtension<PluriWaveMotion> {
const PluriWaveMotion({
required this.quick,
required this.normal,
required this.emphasisCurve,
required this.standardCurve,
});
final Duration quick;
final Duration normal;
final Curve emphasisCurve;
final Curve standardCurve;
static const dark = PluriWaveMotion(
quick: Duration(milliseconds: 140),
normal: Duration(milliseconds: 240),
emphasisCurve: Curves.easeOutCubic,
standardCurve: Curves.easeInOut,
);
@override
PluriWaveMotion copyWith({
Duration? quick,
Duration? normal,
Curve? emphasisCurve,
Curve? standardCurve,
}) {
return PluriWaveMotion(
quick: quick ?? this.quick,
normal: normal ?? this.normal,
emphasisCurve: emphasisCurve ?? this.emphasisCurve,
standardCurve: standardCurve ?? this.standardCurve,
);
}
@override
ThemeExtension<PluriWaveMotion> lerp(covariant ThemeExtension<PluriWaveMotion>? other, double t) {
if (other is! PluriWaveMotion) return this;
return t < 0.5 ? this : other;
}
}
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'pluriwave_motion.dart';
import 'pluriwave_tokens.dart';
abstract final class PluriWaveTheme {
static ThemeData dark() {
const tokens = PluriWaveTokens.dark;
final colorScheme = const ColorScheme.dark().copyWith(
primary: tokens.electricMagenta,
secondary: tokens.warmCoral,
surface: const Color(0xFF130B22),
surfaceContainerLow: const Color(0xFF1A112C),
onSurface: const Color(0xFFF4EEFF),
onPrimary: Colors.white,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: tokens.deepViolet,
textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme),
extensions: const <ThemeExtension<dynamic>>[
tokens,
PluriWaveMotion.dark,
],
cardTheme: CardThemeData(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.radiusMd),
),
),
);
}
}
extension PluriWaveThemeContextX on BuildContext {
PluriWaveTokens get pluriTokens => Theme.of(this).extension<PluriWaveTokens>() ?? PluriWaveTokens.dark;
PluriWaveMotion get pluriMotion => Theme.of(this).extension<PluriWaveMotion>() ?? PluriWaveMotion.dark;
}
+107
View File
@@ -0,0 +1,107 @@
import 'dart:ui' show lerpDouble;
import 'package:flutter/material.dart';
@immutable
class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
const PluriWaveTokens({
required this.deepViolet,
required this.electricMagenta,
required this.warmCoral,
required this.glassSurface,
required this.glassBorder,
required this.glowColor,
required this.radiusSm,
required this.radiusMd,
required this.radiusLg,
required this.spacingXs,
required this.spacingSm,
required this.spacingMd,
required this.spacingLg,
});
final Color deepViolet;
final Color electricMagenta;
final Color warmCoral;
final Color glassSurface;
final Color glassBorder;
final Color glowColor;
final double radiusSm;
final double radiusMd;
final double radiusLg;
final double spacingXs;
final double spacingSm;
final double spacingMd;
final double spacingLg;
static const dark = PluriWaveTokens(
deepViolet: Color(0xFF24123D),
electricMagenta: Color(0xFFE228D1),
warmCoral: Color(0xFFFF6F61),
glassSurface: Color(0x2AFFFFFF),
glassBorder: Color(0x40FFFFFF),
glowColor: Color(0x66E228D1),
radiusSm: 10,
radiusMd: 16,
radiusLg: 24,
spacingXs: 4,
spacingSm: 8,
spacingMd: 16,
spacingLg: 24,
);
@override
PluriWaveTokens copyWith({
Color? deepViolet,
Color? electricMagenta,
Color? warmCoral,
Color? glassSurface,
Color? glassBorder,
Color? glowColor,
double? radiusSm,
double? radiusMd,
double? radiusLg,
double? spacingXs,
double? spacingSm,
double? spacingMd,
double? spacingLg,
}) {
return PluriWaveTokens(
deepViolet: deepViolet ?? this.deepViolet,
electricMagenta: electricMagenta ?? this.electricMagenta,
warmCoral: warmCoral ?? this.warmCoral,
glassSurface: glassSurface ?? this.glassSurface,
glassBorder: glassBorder ?? this.glassBorder,
glowColor: glowColor ?? this.glowColor,
radiusSm: radiusSm ?? this.radiusSm,
radiusMd: radiusMd ?? this.radiusMd,
radiusLg: radiusLg ?? this.radiusLg,
spacingXs: spacingXs ?? this.spacingXs,
spacingSm: spacingSm ?? this.spacingSm,
spacingMd: spacingMd ?? this.spacingMd,
spacingLg: spacingLg ?? this.spacingLg,
);
}
@override
PluriWaveTokens lerp(covariant ThemeExtension<PluriWaveTokens>? other, double t) {
if (other is! PluriWaveTokens) return this;
return PluriWaveTokens(
deepViolet: Color.lerp(deepViolet, other.deepViolet, t) ?? deepViolet,
electricMagenta: Color.lerp(electricMagenta, other.electricMagenta, t) ?? electricMagenta,
warmCoral: Color.lerp(warmCoral, other.warmCoral, t) ?? warmCoral,
glassSurface: Color.lerp(glassSurface, other.glassSurface, t) ?? glassSurface,
glassBorder: Color.lerp(glassBorder, other.glassBorder, t) ?? glassBorder,
glowColor: Color.lerp(glowColor, other.glowColor, t) ?? glowColor,
radiusSm: lerpDouble(radiusSm, other.radiusSm, t) ?? radiusSm,
radiusMd: lerpDouble(radiusMd, other.radiusMd, t) ?? radiusMd,
radiusLg: lerpDouble(radiusLg, other.radiusLg, t) ?? radiusLg,
spacingXs: lerpDouble(spacingXs, other.spacingXs, t) ?? spacingXs,
spacingSm: lerpDouble(spacingSm, other.spacingSm, t) ?? spacingSm,
spacingMd: lerpDouble(spacingMd, other.spacingMd, t) ?? spacingMd,
spacingLg: lerpDouble(spacingLg, other.spacingLg, t) ?? spacingLg,
);
}
}
+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();
}